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/`.
429 lines
19 KiB
Python
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
|