feat(ui): chat auto-scrolls only while pinned to the bottom — submitting reveals your message, scrolling up holds the viewport

This commit is contained in:
2026-08-24 10:53:04 -04:00
parent b16deb2b1d
commit bc0158f858
5 changed files with 558 additions and 12 deletions
+4 -2
View File
@@ -84,8 +84,10 @@ def test_raw_text_only_stored_and_re_rendered_on_restore() -> None:
on restore) — no HTML is ever stored. Restore re-applies the full
brain-message chrome: is-deflected styling, maybe-try chips, sources."""
js = _js()
assert 'addMessage("user", renderMarkdown(m.text), "auto")' in js
assert 'addMessage("brain", renderMarkdown(m.text), "auto")' in js
# Phase 18: restore landings are forced ("auto" + force) one-shot
# scrollReveal calls — the only forced scrolls in the app.
assert 'addMessage("user", renderMarkdown(m.text), "auto", true)' in js
assert 'addMessage("brain", renderMarkdown(m.text), "auto", true)' in js
assert "wrap.classList.add(\"is-deflected\")" in js
assert "appendMaybeTry(wrap, m.suggestions)" in js
assert "appendSources(wrap, m.sources)" in js
+139
View File
@@ -0,0 +1,139 @@
"""Unit: the follow-the-bottom scroll contract in the static frontend
(phase 18, owner choice 2026-08-23).
The JS behavior itself is E2E-covered (tests/e2e/test_follow_bottom_scroll.py);
here we pin the exported band constant and the single-gate markers that the
story depends on — scrollIntoView appears exactly once in app.js, inside
scrollReveal — so a silent regression back to unconditional per-delta /
per-chunk scrolls is caught without a browser.
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
APP_JS = FRONTEND / "assets" / "app.js"
def _js() -> str:
return APP_JS.read_text(encoding="utf-8")
def _fn_body(js: str, name: str) -> str:
"""Source of the function starting at `function <name>` (to its closing
brace at column 0) — same slicing style as test_frontend_feedback.py."""
fn = js.find(f"function {name}")
assert fn != -1, f"{name} must exist in app.js"
return js[fn : js.find("\n}\n", fn)]
def test_near_bottom_constant_exported_at_200px() -> None:
"""The "pinned to the bottom" band (the composer zone) must be an
*exported* constant — unit-pinned, same pattern as TURN_TIMEOUT_MS."""
js = _js()
assert re.search(r"export\s+const\s+NEAR_BOTTOM_PX\s*=\s*200\s*;", js), (
"app.js must export `const NEAR_BOTTOM_PX = 200`"
)
def test_is_near_bottom_uses_document_scroller() -> None:
"""isNearBottom measures the DOCUMENT scroller (there is no inner
scroll container — the page scrolls on the window): distance from the
bottom of the document <= NEAR_BOTTOM_PX."""
js = _js()
body = _fn_body(js, "isNearBottom")
for ref in (
"documentElement.scrollHeight",
"window.scrollY",
"window.innerHeight",
"NEAR_BOTTOM_PX",
):
assert ref in body, f"isNearBottom must reference {ref!r}"
assert "<=" in body, "the pinned band is an upper bound, not exact equality"
def test_single_scroll_gate() -> None:
"""scrollReveal is the ONE scroll call site in app.js: it fires only
when forced or when the user is pinned to the bottom, keeps
`block: "end"`, and both addMessage (behavior + force passthrough) and
addTyping (defaults) delegate to it."""
js = _js()
body = _fn_body(js, "scrollReveal")
assert "force || isNearBottom()" in body, "gate: force OR pinned to the bottom"
assert "scrollIntoView" in body
assert 'block: "end"' in body
# The regression pin: exactly one scrollIntoView in the whole file, and
# it lives inside scrollReveal.
assert js.count("scrollIntoView") == 1, (
"app.js must call scrollIntoView exactly once (inside scrollReveal)"
)
assert js.find("scrollIntoView") > js.find("function scrollReveal")
# addMessage passes its behavior/force through; addTyping uses defaults.
add_body = _fn_body(js, "addMessage")
assert "scrollReveal(wrap, scrollBehavior, force)" in add_body
assert "force = false" in add_body
typing_body = _fn_body(js, "addTyping")
assert "scrollReveal(wrap)" in typing_body
def test_submit_reveal_is_gated() -> None:
"""Submit keeps the plain default call — no force: the gate decides,
and it does in real use because submitting from the composer means the
user is pinned (inside the 200px band); a submit with the viewport away
from the bottom does not yank it."""
js = _js()
send = js.find("async function handleSend")
assert send != -1, "handleSend must exist"
call = 'addMessage("user", renderMarkdown(text));'
idx = js.find(call, send)
assert idx != -1, "handleSend must reveal the user message via the plain default"
assert 'addMessage("user", renderMarkdown(text),' not in js, (
"the submit call must not pass a third/fourth argument (no force)"
)
def test_restore_force_landing() -> None:
"""Both restore call sites are the only `force`d scrolls: one-shot,
non-smooth ("auto") landing on the last restored message (phase-14
behavior preserved)."""
js = _js()
body = _fn_body(js, "renderStoredMessage")
assert 'addMessage("user", renderMarkdown(m.text), "auto", true)' in body
assert 'addMessage("brain", renderMarkdown(m.text), "auto", true)' in body
# Forced restores are restore-only: exactly two ("auto", true) sites.
assert js.count('"auto", true') == 2, "only the two restore calls may force"
def test_streaming_scrolls_only_through_gate() -> None:
"""The per-chunk scrolls that used to yank the viewport (the phase-17
thinking branch and the streaming delta branch) now go through
scrollReveal with no raw scrollIntoView at either call site; the
block's internal bottom-pinning (its own overflow, not the page) stays."""
js = _js()
thinking_idx = js.find('ev.type === "thinking"')
delta_idx = js.find('ev.type === "delta"')
done_idx = js.find('ev.type === "done"')
assert -1 < thinking_idx < delta_idx < done_idx
thinking_branch = js[thinking_idx:delta_idx]
delta_branch = js[delta_idx:done_idx]
assert "scrollReveal(wrap)" in thinking_branch
assert "scrollReveal(wrap)" in delta_branch
assert "scrollIntoView" not in thinking_branch
assert "scrollIntoView" not in delta_branch
assert "textEl.scrollTop = textEl.scrollHeight" in thinking_branch
def test_turn_end_focus_does_not_scroll() -> None:
"""The turn-end focus-back (phase 06's "always focus back") must not
move the viewport: focusing the composer while the user is scrolled up
would yank them to the bottom at the moment the turn ends — the exact
defect phase 18 removes. preventScroll keeps the keyboard flow.
startNewChat keeps plain focus (the list is cleared, nothing to yank
past)."""
js = _js()
finally_idx = js.find("// done | error → idle: always settle, always focus back")
assert finally_idx != -1, "the turn's finally block must exist"
block = js[finally_idx : js.find("\n}", finally_idx)]
assert 'input.focus({ preventScroll: true })' in block
assert "input.focus()" not in block