Files
brain-of-reese/tests/unit/test_pinned_composer.py
T
ducoterra bef24e05e2
Build and Push Containers / build-and-push-app (push) Successful in 1m54s
Build and Push Containers / build-and-push-db (push) Failing after 13s
phase: 123_chat_image_questions
All gates green. Verification complete.

**Phase 123 — final verification pass (all 4 tasks already in `complete/`)**

- Verified the full implementation is in the working tree: `app/api/chat_images.py` (upload/serve pair), `ChatRequest.image`/`ChatMessage.image` (path-validated, omitted-when-None), toggle-off + stale-file hinted error frames, `build_user_content` multimodal build at both sites (chat.py deflected branch + `run_agent`), config-gated composer attach/preview/upload-then-send, restore + shared rendering, CSP `img-src 'self' data:` carve-out, mock-LLM capture buffer.
- `uv run pytest` → **2796 passed**, exit 0 (unit + integration).
- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (29/4615 missed; phase-123 modules 99–100%).
- `uv run pytest tests/e2e/test_chat_image_questions.py -v --no-cov` → **5 passed** in isolation.
- `uv run ruff check . && uv run pyright` → clean (0 errors).

**Completion criteria:** (1) attach→send→multimodal text+image to the model, bubble/reload/shared all render it, saved chat stores the PATH with `"base64" not in json.dumps(stored)` — **verified** (E2E tests 1–4 + integration round-trip); (2) `BOR_IMAGES=false` — control hidden, exact hinted error frame, zero model calls / no query_log row — **verified** (E2E test 5 + integration); (3) text-only byte-identical (`content` stays a plain `str`) — **verified** (unit + integration); (4) all gates green — **verified**; (5) commit + phase move — left to the harness per pipeline rules (no `git add`/`commit` run).

No defects found; no live-infrastructure changes (repo + local dev DB only). **Next pending phase: none** — 123 is the last phase in `todo/`.
2026-09-25 05:19:18 -04:00

429 lines
19 KiB
Python

"""Unit: the pinned composer in the static frontend (phase 52, owner
direction 2026-08-30, TODO.md L3 — "The message input text box needs to be
pinned to the bottom of the screen so it doesn't 'run away' from the user
as they try to click 'stop'"; owner revision the same day — the box had to
be at the bottom of the screen on an EMPTY chat too, which sticky alone
cannot do).
The chat page scrolls at the DOCUMENT level and `.chat-shell` (the
centered 72rem column — the .container width, phase 100) is the
composer's sticky containing block. The pin is therefore TWO rules, and both are pinned here:
* `.messages { flex: 1 1 auto }` absorbs the free space of a short page,
so the composer's RESTING (in-flow) position is already the bottom of
the full-height column — `position: sticky` can only pull a box UP
toward the viewport's bottom edge, it can never push one DOWN to meet
it, so without the grow the empty/short chat left the input mid-screen
with a dead band under it (the reported bug);
* `.composer { position: sticky; bottom: env(safe-area-inset-bottom, 0) }`
takes over the moment the conversation overflows the viewport and keeps
the box — and the Stop control inside it — glued to the viewport's
bottom edge at every scroll position, settling back into flow (above
the footer) once the document bottom is reached;
Phase 65 (task 02, owner-locked A1, 2026-09-01, `TODO.md` L3): the pin
moved up one level — the `.chat-bottom` wrapper (the row + this form,
the LAST child of `.chat-shell`) carries `position: sticky; bottom:
env(safe-area-inset-bottom, 0)` and is the unit that stays glued to the
viewport bottom; the composer's own sticky pair above and the
`.messages` grow stay exactly as phase 52 pinned them (the composer's
sticky is redundant inside the wrapper but kept — and pinned here —
for the computed-style contract).
The pin is CSS-only:
* `.composer` carries the sticky pair (``position: sticky`` + the
notch-aware ``bottom`` inset, with the explicit ``0`` fallback) and
keeps its solid ``--surface`` background, border, radius and shadow — a
reply scrolling behind the pinned box must never show through it;
* NO ``z-index`` is added to the composer (it already paints above
``.messages`` by DOM order, never overlaps the sticky header (z 20)
and stays under the z-1000 document modal), and the pin is not undone
in the ≤640px media query;
* the sticky context survives: `.chat-shell` / `.app-main` / `.messages`
gain no ``overflow`` and `.messages` gains no inner scroller — the page
keeps scrolling at the document level (the phase-42 model);
* ``index.html`` (phase 65 task 02, owner-locked A1): the row + composer
are wrapped in the `.chat-bottom` unit — the wrapper (NO id) is now
the LAST child of `.chat-shell` (the sticky shift range is still that
column's box) and the `#message-input` / `#send-btn` / `#send-status`
markup is untouched;
* ``app.js`` gains NO page-scroll call site — the phase-42 invariant
(``scrollReveal`` is still the single ``window.scrollTo``; no
``scrollIntoView``, no ``window.scrollY``) is re-pinned here so the
pin can never silently arrive in JS instead of CSS.
The browser geometry itself is E2E-gated by the story suite
(tests/e2e/test_pinned_composer.py, task 02); these are source pins in
the house style (see tests/unit/test_frontend_scroll.py).
"""
from __future__ import annotations
import re
from html.parser import HTMLParser
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
APP_JS = FRONTEND / "assets" / "app.js"
INDEX_HTML = FRONTEND / "index.html"
VOID_ELEMENTS = frozenset(
{
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"param",
"source",
"track",
"wbr",
}
)
def _css() -> str:
assert STYLES_CSS.is_file(), f"missing {STYLES_CSS}"
return STYLES_CSS.read_text(encoding="utf-8")
def _js() -> str:
assert APP_JS.is_file(), f"missing {APP_JS}"
return APP_JS.read_text(encoding="utf-8")
def _html() -> str:
assert INDEX_HTML.is_file(), f"missing {INDEX_HTML}"
return INDEX_HTML.read_text(encoding="utf-8")
def _rule(css: str, selector: str) -> str:
"""The body of the rule whose selector line is exactly `selector`
(multi-line block) — same slicing style as test_history_page.py."""
block = re.search(rf"^{re.escape(selector)} \{{\n([\s\S]*?)\n\}}", css, re.MULTILINE)
assert block, f"styles.css must carry a `{selector} {{ … }}` rule"
return block.group(1)
def _walk(node: dict) -> list[dict]:
"""Every element in a parsed subtree (depth-first, document order)."""
out = [node]
for child in node["children"]:
out.extend(_walk(child))
return out
def _mobile_block(css: str) -> str:
"""The ≤640px media query body (the phase-07 responsive block)."""
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}\n", css)
assert mobile, "the mobile media query must exist"
return mobile.group(1)
class _Tree(HTMLParser):
"""Minimal element tree of index.html — enough to ask "who is the
last element child of `.chat-shell`?" without a DOM library."""
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.root: dict = {"tag": "#root", "attrs": {}, "children": []}
self._stack: list[dict] = [self.root]
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
node = {"tag": tag, "attrs": dict(attrs), "children": []}
self._stack[-1]["children"].append(node)
if tag not in VOID_ELEMENTS:
self._stack.append(node)
def handle_endtag(self, tag: str) -> None:
for i in range(len(self._stack) - 1, 0, -1):
if self._stack[i]["tag"] == tag:
del self._stack[i:]
return
def find(self, classes: str) -> dict:
"""The first element whose `class` attribute contains `classes`
(all of them, space-separated)."""
wanted = classes.split()
stack = [self.root]
while stack:
node = stack.pop(0)
have = (node["attrs"].get("class") or "").split()
if node["tag"] != "#root" and all(c in have for c in wanted):
return node
stack.extend(node["children"])
raise AssertionError(f"index.html must contain .{classes}")
def _tree() -> _Tree:
tree = _Tree()
tree.feed(_html())
return tree
# ---------- the sticky pin itself ----------
def test_composer_is_sticky_bottom() -> None:
"""`.composer` carries the sticky pair — `position: sticky` AND a
`bottom` offset — inside its own rule. `bottom` is the notch-aware
safe-area inset with an explicit `0` fallback (it resolves to 0 on a
desktop viewport, so the box sits flush with the viewport bottom; on a
notched phone the composer clears the home indicator instead of hiding
under it — and a browser without `env()` support still gets 0)."""
body = _rule(_css(), ".composer")
assert "position: sticky;" in body, (
"the composer must be sticky — phase 52 pins it to the viewport "
"bottom so Stop is reachable without scrolling (TODO.md L3)"
)
assert "bottom: env(safe-area-inset-bottom, 0);" in body, (
"the sticky offset must be the safe-area inset with a 0 fallback "
"(the phase-07 mobile contract: the composer stays reachable "
"around the notch, and never falls back to `auto` = no pin)"
)
# `top` would pin it to the wrong edge (and fight the sticky header).
assert "top:" not in body, "the composer pins the BOTTOM edge only"
def test_message_list_absorbs_the_short_page_space() -> None:
"""The other half of the pin — and the half phase 52 originally missed.
`position: sticky` never pushes a box DOWN to the viewport's bottom
edge, so on an empty/short chat (no free space absorbed) the composer
sat right under the empty state, mid-screen. `.messages` must GROW to
take up that space, which puts the composer's resting position at the
bottom of the full-height column the page already builds
(`body{min-height:100dvh}` → `.app-main{flex:1}` → `.chat-shell{flex:1}`).
"""
messages = _rule(_css(), ".messages")
assert re.search(r"flex:\s*1(\s+1\s+auto)?;", messages), (
"the message list must grow to fill the short page — otherwise the "
"pinned composer only works once the conversation overflows and the "
"empty chat leaves the input mid-screen (owner revision 2026-08-30)"
)
# flex-basis must stay `auto`: a `0` basis would size the list BELOW its
# content when the conversation overflows and let bubbles overlap the box.
assert not re.search(r"flex:\s*1\s+1\s+0", messages), (
"flex-basis must stay auto — the list keeps its content height when "
"the page overflows (no free space to absorb there anyway)"
)
# The column the composer sits in must keep stretching to the viewport.
for selector in (".app-main", ".chat-shell"):
frame = _rule(_css(), selector)
assert re.search(r"flex:\s*1[;\s]", frame), (
f"{selector} must keep growing to the viewport height — the "
"composer can only rest at the bottom of a full-height column"
)
def test_composer_stays_opaque_behind_scrolled_messages() -> None:
"""The pinned box overlaps the message list while the page is
scrolled: it keeps its SOLID `--surface` background plus border,
radius and shadow (no glass/transparency), so a reply streaming
behind it never shows through the input."""
body = _rule(_css(), ".composer")
assert "background: var(--surface);" in body, (
"the composer needs an opaque background — messages must not "
"show through the pinned box"
)
assert "transparent" not in body and "rgb(" not in body, (
"no translucent background on the pinned composer"
)
assert "border: 1px solid var(--line);" in body
assert "border-radius: var(--radius);" in body
assert "box-shadow: var(--shadow);" in body, (
"the elevation shadow separates the pinned box from the content "
"scrolling behind it"
)
def test_no_z_index_added_to_the_composer() -> None:
"""Locked assumption: NO z-index change. DOM order already paints
the composer above `.messages`, it never reaches the sticky header
(z 20) at the top and it must stay under the z-1000 document modal."""
body = _rule(_css(), ".composer")
assert "z-index" not in body, (
"the composer stays unlayered — a z-index here could lift it over "
"the sticky header (20) or the document modal (1000)"
)
css = _css()
assert re.search(r"\.app-header \{[\s\S]*?z-index: 20;", css), (
"the sticky header keeps its layer"
)
assert re.search(r"^\.doc-modal \{[\s\S]*?z-index: 1000;", css, re.MULTILINE), (
"the document modal stays the topmost layer"
)
def test_mobile_pin_not_undone() -> None:
"""≤640px: the pin must survive the responsive block — the mobile
`.composer` rule only tightens the padding (it must not reset
`position` or drop the box out of the sticky model), and the phone
keeps its own `main { padding-bottom: env(safe-area-inset-bottom) }`
flow padding under the settled box."""
mobile = _mobile_block(_css())
assert ".composer" in mobile, "the mobile composer rule must remain"
mobile_composer = re.search(r"\.composer \{([^}]*)\}", mobile)
assert mobile_composer, "the ≤640px block must keep the .composer rule"
body = mobile_composer.group(1)
assert "padding: 0.5rem;" in body
assert "position" not in body, (
"the mobile rule must not reset the sticky position"
)
assert "main { padding-bottom: env(safe-area-inset-bottom, 0); }" in mobile
# ---------- the sticky context (no inner scroller, no clipping ancestor) ----------
def test_document_level_scroll_is_untouched() -> None:
"""Sticky resolves against the NEAREST SCROLLING ANCESTOR. The pin
relies on that being the document, so no ancestor between the
composer and the viewport may become a scroll container: `.chat-shell`
and `.app-main` stay overflow-visible and `.messages` gains no inner
scroller / height cap (that would move the scroll — and the phase-42
contract — into the message list)."""
css = _css()
for selector in (".chat-shell", ".app-main", ".messages"):
body = _rule(css, selector)
assert "overflow" not in body, (
f"{selector} must not clip/scroll — the composer pins to the "
"document viewport, not to an inner scrollport"
)
messages = _rule(css, ".messages")
assert not re.search(r"(?<!min-)\bheight:", messages), (
"the message list must not become its own scroller (only the "
"phase-01 `min-height` floor is allowed) — `flex-grow` stretches it, "
"a `height` cap would clip the conversation instead"
)
# ---------- index.html: no DOM change needed ----------
def test_chat_bottom_unit_is_last_child_of_the_chat_shell() -> None:
"""Phase 65 (task 02, owner-locked A1): the LAST element child of
`.chat-shell` is the `.chat-bottom` wrapper — NO id (nothing in JS
binds it; the bindings live on the inner elements, the move is pure
HTML/CSS) — holding the `.chat-actions` row, the phase-104
`#char-count` counter, the phase-123 `#attach-preview` strip (hidden
by default — zero height at rest, the sticky geometry untouched),
and the `#composer` form, in that order: the row + counter +
preview + composer are ONE sticky unit, and the wrapper owns the
shell's bottom slot, so the sticky shift range is still that
column's box (a sibling after it would carve the range away and
re-break the pin). The composer form keeps `novalidate` and its
contract ids."""
shell = _tree().find("chat-shell")
last = shell["children"][-1]
assert last["tag"] == "div" and (
last["attrs"].get("class") or ""
).split() == ["chat-bottom"], (
"the .chat-bottom wrapper must be the last child of .chat-shell"
)
assert last["attrs"].get("id") is None, (
"the wrapper carries no id — the JS bindings live on the inner "
"elements"
)
kids = last["children"]
assert len(kids) == 4, (
"the unit holds exactly four element children: .chat-actions, "
"then #char-count (phase 104), then #attach-preview (phase 123), "
"then #composer"
)
row, counter, preview, form = kids
assert row["tag"] == "div" and (
row["attrs"].get("class") or ""
).split() == ["chat-actions"], (
"the first child is the .chat-actions row (the New chat → Share "
"DOM order is pinned by test_save_chat_ui.py)"
)
# Phase 104 (owner 2026-09-12): the question-length counter — a
# hidden-by-default <p> between the row and the composer (zero
# height while hidden; a new child INSIDE the unit breaks no pin —
# the sticky geometry below is untouched).
assert counter["tag"] == "p" and counter["attrs"].get("id") == "char-count", (
"the second child is the phase-104 #char-count counter"
)
assert (counter["attrs"].get("class") or "") == "char-count"
assert "hidden" in counter["attrs"], (
"the counter ships hidden — it appears only from 80% of the "
"4,000-char cap (app.js updateCharCount)"
)
# Phase 123 (task 02, TODO L6): the attach preview strip — a
# hidden-by-default div between the counter and the composer (the
# selected image above the input row; zero height while hidden, the
# pinned-cluster geometry untouched).
assert preview["tag"] == "div" and (
preview["attrs"].get("id") == "attach-preview"
), "the third child is the phase-123 #attach-preview strip"
assert (preview["attrs"].get("class") or "") == "attach-preview"
assert "hidden" in preview["attrs"], (
"the strip ships hidden — it appears only while a file is "
"attached (app.js attachedImage)"
)
assert form["tag"] == "form" and form["attrs"].get("id") == "composer"
assert "novalidate" in form["attrs"], (
"phase 48: the composer form stays `novalidate` (a `required` "
"input would swallow the Stop click)"
)
ids = {
node["attrs"].get("id")
for node in _walk(form)
if node["attrs"].get("id")
}
assert {"composer", "message-input", "send-btn", "send-status"} <= ids, (
"the composer markup contract ids must all be present (task 02 E2E "
"clicks Stop from this pinned box)"
)
# ---------- app.js: the pin adds no page scroll (phase-42 invariant) ----------
def test_app_js_gains_no_page_scroll_call_site() -> None:
"""The phase-42 never-auto-scroll contract, re-pinned for phase 52:
the composer pin is CSS-only, so `app.js`'s scroll surface is
byte-for-byte the phase-42 one — `scrollReveal`'s single
`window.scrollTo` is still the ONLY page scroll, no `scrollIntoView`
came back, nothing measures `window.scrollY`, and the only other
scroll is the thinking window's own bottom pin (`textEl.scrollTop`,
a block-internal clip, not the page). A JS-scrolled "pin" (an
IntersectionObserver / sticky polyfill / scroll listener) would trip
one of these assertions."""
js = _js()
assert js.count("window.scrollTo(") == 1, "exactly one page scroll may exist"
assert js.find("window.scrollTo(") > js.find("function scrollReveal"), (
"the one page scroll must live inside scrollReveal"
)
assert js.count(".scrollIntoView(") == 0
assert "window.scrollY" not in js
assert "addEventListener(\"scroll\"" not in js, (
"the pin must not install a scroll listener"
)
assert "IntersectionObserver" not in js, (
"no JS sticky polyfill — the pin is `position: sticky`"
)
assert js.count("textEl.scrollTop = textEl.scrollHeight") == 1, (
"the thinking window's internal pin stays, and stays the only one"
)
def test_turn_end_focus_still_uses_preventscroll() -> None:
"""The turn-end focus-back lands on the pinned composer every turn —
with the box now sticky it must still never move the viewport
(`focus({ preventScroll: true })`, phase 42), or every finished turn
would yank the reader to the bottom."""
js = _js()
idx = js.find("// done | error | stop → idle: always settle, always focus back")
assert idx != -1, "the turn's finally block must exist"
block = js[idx : js.find("\n}", idx)]
assert "input.focus({ preventScroll: true })" in block