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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user