**Phase 182 — final verification pass (all 8 tasks already complete; this was verification + docs + one defect fix).**
- Verified: `app/rag/verbatim.py` (locator grammar, `splitlines()` slice, line-boundary cap, `resolve_answer_blocks`), `SHOW_TEXT_TOOL` + `shown_texts`, `SHOW_TEXT_CLAUSE`, `done.answer_text` (9 fields, key dropped when empty) + `ChatMessageRecord.answer_text` (≤50, 600 000-char cap counted in the 8 M budget), `verbatim.js` + 4 `{verbatim:true}` renderers, `Containerfile` minify step, `.env.example` 3 vars.
- `uv run python -c "…answer_text, show_text_max_chars, show_text_max_blocks"` → `True 12000 5`; each negative refuses startup naming its field; `0` accepted as inert ✅
- `bash .agents/validate.sh` → **validation OK**: 4320 unit+integration tests green, `TOTAL 6849 17 → 99%` (gate app/ >90% OK), `ruff check .` "All checks passed!", `pyright` 0 errors (re-run after my edits, 08:28).
- E2E, each in isolation `--no-cov`: `test_verbatim_show_text.py` 13 passed (panel bytes == `documents.content`, server caption, clipboard == server string, `/document.html?source=…&path=…`, honest unresolved state, reload+share, `show_image` regression, flag-off app) ✅; green: `test_answer_images` `test_harness_aligned_tools` `test_agent_document_tools` `test_markdown_tables` `test_markdown_inline_links_images` `test_svg_document_preview` `test_smoke` `test_document_viewer` `test_markdown_blockquotes` `test_chat_persistence` `test_thinking_timeline` `test_chat_rag` `test_settings_mobile_value_width`.
- **Defect found & fixed (pre-existing, not phase 182):** `tests/e2e/test_admin_settings_panel.py::test_admin_sees_groups_and_source_badges` failed — its whole-page `assert "reese" not in page.content()` tripped on the shell's onboarding chips, which follow the newest saved chats in the one shared E2E DB (a leftover `reeselink.json` question from a tool suite). Scoped the strict sweep to `.settings-shell` and kept a whole-page `reese:reese` credential needle → 12 passed with the leftovers still present.
- Docs: README fixed 2 inconsistencies — Quick Start omitted the **required** `BOR_ADMIN_PASSWORD`/`BOR_SESSION_SECRET` (the app fail-loud refuses boot) and the broken anchor `#chat--` → `#chat-`; full re-read of README (1816 lines) + AGENTS confirmed the phase-182 feature/settings/log/UI-contract text matches the code, and every `.env.example` var + every `Settings` field is documented.
- Notable: kept `resolve_answer_blocks` synchronous on the SSE terminal path (bounded indexed SELECTs, matching the existing `query_log` precedent and the phase's documented fence-lookup bound); no live-infra changes made.
- Next pending phase: none — `todo/` holds only 182 (its 8 task files are in `complete/`; the harness commits + moves `00_phase.md`).
257 lines
12 KiB
Python
257 lines
12 KiB
Python
"""Unit: the no-reply-autoscroll contract in the static frontend
|
|
(phase 42, owner direction 2026-08-27, TODO.md L5).
|
|
|
|
The owner removed the phase-18 follow-the-bottom auto-follow: the page
|
|
NEVER auto-scrolls while a turn streams (thinking / tool / delta frames
|
|
all leave the viewport alone), so a user reading earlier content is no
|
|
longer yanked down mid-answer. Scrolls happen only on explicit user
|
|
intent: the submit (the user's own message is revealed) and the phase-14
|
|
restore landing (one-shot, load-time). Both user-intent scrolls land at
|
|
the DOCUMENT BOTTOM (scrollReveal's `window.scrollTo`): the old
|
|
`scrollIntoView({ block: "end" })` aligned the message's bottom to the
|
|
viewport bottom — which sits above the in-flow composer — so every
|
|
submit hopped the page UP by the composer+footer height and pushed the
|
|
composer below the fold.
|
|
|
|
The JS behavior itself is E2E-covered
|
|
(tests/e2e/test_no_reply_autoscroll.py); here we pin the source markers
|
|
of the contract — the phase-18 gate is gone (no NEAR_BOTTOM_PX /
|
|
isNearBottom), scrollReveal scrolls unconditionally and is the single
|
|
page scroll in app.js (a document-bottom `window.scrollTo` — no
|
|
`scrollIntoView` call remains), addMessage takes an explicit `scroll`
|
|
intent, and the streaming handlers contain no page-scroll call at all —
|
|
so a silent regression back to per-frame autoscroll (or to the
|
|
upward-hopping block:"end" reveal) 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_phase18_gate_is_gone() -> None:
|
|
"""The follow-the-bottom machinery (phase 18) is removed by owner
|
|
direction 2026-08-27: no page-level band constant, no gate
|
|
function, and no document-scroller measurement left anywhere in
|
|
app.js. (The phase-43 window-level THINKING_NEAR_BOTTOM_PX /
|
|
isThinkingNearBottom pair is a different band — the thinking
|
|
window's, not the page's.)"""
|
|
js = _js()
|
|
assert not re.search(r"(?<![A-Z_])NEAR_BOTTOM_PX\b", js), (
|
|
"the 200px page-level band constant must be gone"
|
|
)
|
|
assert "isNearBottom" not in js, "the pinned-to-bottom gate must be gone"
|
|
assert "window.scrollY" not in js, (
|
|
"nothing in app.js measures the page scroll offset anymore"
|
|
)
|
|
|
|
|
|
def test_scroll_helper_is_unconditional() -> None:
|
|
"""scrollReveal is still the ONE page scroll in app.js, and it
|
|
scrolls unconditionally — no gate in its body, and the phase-18
|
|
`force` parameter is gone. A page scroll can only ever happen where
|
|
scrollReveal is CALLED (submit + restore). It lands at the document
|
|
BOTTOM via `window.scrollTo`: the old `scrollIntoView({ block:
|
|
"end" })` aligned the message's bottom to the viewport bottom, which
|
|
sits above the in-flow composer, so every submit hopped the page UP
|
|
by the composer+footer height (the "Enter scrolls the page up"
|
|
bug) — no `scrollIntoView` call may remain."""
|
|
js = _js()
|
|
body = _fn_body(js, "scrollReveal")
|
|
assert "window.scrollTo(" in body
|
|
assert "document.documentElement.scrollHeight" in body
|
|
assert "if (" not in body, "the helper must have no gate — it scrolls when called"
|
|
assert "force" not in body, "the phase-18 force parameter must be gone"
|
|
assert 'block: "end"' not in body, (
|
|
"the upward-hopping block:'end' alignment must be gone"
|
|
)
|
|
# Still smooth / reduced-motion-aware through the default behavior.
|
|
assert "behavior = SCROLL" in body
|
|
# The regression pins: no scrollIntoView call anywhere in the file,
|
|
# and the one window.scrollTo call lives inside scrollReveal.
|
|
assert js.count(".scrollIntoView(") == 0, (
|
|
"app.js must not call scrollIntoView — the document-bottom "
|
|
"scrollTo replaces the block:'end' reveal"
|
|
)
|
|
assert js.count("window.scrollTo(") == 1
|
|
assert js.find("window.scrollTo(") > js.find("function scrollReveal")
|
|
|
|
|
|
def test_add_message_takes_explicit_scroll_intent() -> None:
|
|
"""addMessage(who, html, scroll = false, image = null): the phase-18
|
|
scrollBehavior/force parameters are gone; the bubble scrolls only
|
|
when the caller explicitly asks (submit reveal, restore landing).
|
|
Phase 123 (task 02) appended the optional `image` argument (the
|
|
question's attached image — { src, alt } on a user bubble, the ONE
|
|
renderer for live + restore + shared); the scroll contract is
|
|
untouched."""
|
|
js = _js()
|
|
body = _fn_body(js, "addMessage")
|
|
assert "function addMessage(who, html, scroll = false, image = null)" in body
|
|
assert "if (scroll) scrollReveal(wrap)" in body
|
|
assert "force" not in body
|
|
assert "scrollBehavior" not in body
|
|
|
|
|
|
def test_submit_reveals_user_message() -> None:
|
|
"""User intent kept by the owner: submitting scrolls the viewport down
|
|
so the user's own message is visible — the submit addMessage passes
|
|
the scroll intent; the streaming brain-bubble creations in the same
|
|
turn handler never do.
|
|
|
|
Phase 49: the turn handler is runTurn (extracted from handleSend) —
|
|
the user append + persistence save point 1 sit in its `!reask`
|
|
block (the redo-in-place retry path skips both: the question is
|
|
already in the DOM + conversation)."""
|
|
js = _js()
|
|
turn = js.find("async function runTurn")
|
|
assert turn != -1, "runTurn must exist (phase 49 extraction)"
|
|
body = js[turn : js.find("\n}\n", turn)]
|
|
assert "if (!reask) {" in body, "the user append is gated on !reask"
|
|
# Phase 123 (task 02): the user append gained the optional image
|
|
# argument (the attached image's { src, alt }) — the call is
|
|
# multi-line now; the contract is the same: user + the raw text +
|
|
# the explicit scroll intent true.
|
|
assert re.search(
|
|
r'addMessage\(\s*"user",\s*renderMarkdown\(text\),\s*true', body
|
|
), "the submit must reveal the user message (scroll intent true)"
|
|
for call in re.findall(r'addMessage\("brain"([^)]*)\)', body):
|
|
assert "true" not in call, (
|
|
f"streaming brain bubbles must not scroll the page: {call!r}"
|
|
)
|
|
|
|
|
|
def test_streaming_handlers_never_scroll_the_page() -> None:
|
|
"""The heart of the phase-42 contract: the thinking / tool / delta
|
|
branches contain NO page-scroll call (no scrollReveal, no raw
|
|
scrollIntoView). The thinking branch keeps the block-INTERNAL pin
|
|
(textEl.scrollTop — phase 17, reworked in phase 43): that scrolls
|
|
the block's own clip, not the page."""
|
|
js = _js()
|
|
think = js.find('ev.type === "thinking"')
|
|
tool = js.find('ev.type === "tool"')
|
|
delta = js.find('ev.type === "delta"')
|
|
done = js.find('ev.type === "done"')
|
|
assert -1 < think < tool < delta < done, "the turn handler must branch in order"
|
|
for name, branch in (
|
|
("thinking", js[think:tool]),
|
|
("tool", js[tool:delta]),
|
|
("delta", js[delta:done]),
|
|
):
|
|
assert "scrollReveal" not in branch, f"the {name} branch must not scroll the page"
|
|
assert ".scrollIntoView(" not in branch, (
|
|
f"the {name} branch must not scroll the page"
|
|
)
|
|
# The thinking window pin survives (phase 17 — untouched by this phase).
|
|
thinking_branch = js[think:delta]
|
|
assert "textEl.scrollTop = textEl.scrollHeight" in thinking_branch
|
|
assert js.count("textEl.scrollTop = textEl.scrollHeight") == 1
|
|
|
|
|
|
def test_restore_landing_is_one_shot() -> None:
|
|
"""The phase-14 restore landing keeps its one-shot scroll
|
|
(owner-kept): both restore call sites pass the explicit scroll
|
|
intent and they are the only two restore scrolls; with the submit's
|
|
single reveal, exactly three `true` intents exist in the whole file.
|
|
The old forced "auto" landing is gone, and the one-shot, load-time
|
|
contract is documented at the call site."""
|
|
js = _js()
|
|
body = _fn_body(js, "renderStoredMessage")
|
|
assert 'addMessage("user", renderMarkdown(m.text), true)' in body
|
|
# Phase 182 (task 07): the brain renderer gained the verbatim option —
|
|
# the SCROLL intent (the trailing `true`) is untouched, and the option
|
|
# object (`{ verbatim: true }`) never ends in `", true)"`, so the count
|
|
# below still counts the two restore landings alone.
|
|
assert 'addMessage("brain", renderMarkdown(m.text, { verbatim: true }), true)' in body
|
|
assert js.count('"auto", true') == 0, "the old forced 'auto' landing must be gone"
|
|
# Submit reveal + the two restore landings — nothing else scrolls.
|
|
# Phase 123 (task 02): the submit call is multi-line (the optional
|
|
# image argument follows the scroll intent), so it no longer ends
|
|
# in the single-line ", true)" literal — the two restore calls do;
|
|
# the submit reveal is counted by its own (multi-line) shape.
|
|
assert js.count(", true)") == 2, (
|
|
"only the two restore calls may scroll (single-line shape)"
|
|
)
|
|
assert len(re.findall(r'addMessage\(\s*"user",\s*renderMarkdown\(text\),\s*true', js)) == 1, (
|
|
"the submit reveal may scroll (multi-line since phase 123's image argument)"
|
|
)
|
|
# The marker comment documents the one-shot, load-time contract.
|
|
assert "restore landing" in body
|
|
assert "one-shot" in body
|
|
|
|
|
|
def test_typing_bubble_does_not_scroll() -> None:
|
|
"""A typing indicator appearing must not yank the page — the phase-18
|
|
scrollReveal call in addTyping is removed with the gate."""
|
|
js = _js()
|
|
body = _fn_body(js, "addTyping")
|
|
assert "scrollReveal" not in body
|
|
assert ".scrollIntoView(" not in body
|
|
|
|
|
|
def test_scroll_constant_reduced_motion_intact() -> None:
|
|
"""The SCROLL constant is untouched (calm, don't remove): smooth by
|
|
default, "auto" under prefers-reduced-motion — the two kept scroll
|
|
call sites ride it as the default behavior."""
|
|
js = _js()
|
|
assert 'const SCROLL = reducedMotion ? "auto" : "smooth";' in js
|
|
assert 'matchMedia("(prefers-reduced-motion: reduce)")' in js
|
|
assert "Calm, don't remove" in js
|
|
|
|
|
|
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.
|
|
preventScroll keeps the keyboard flow without the scroll.
|
|
|
|
Phase 143 (TODO L5): the focus-back is now GATED on pointer type —
|
|
`if (!COARSE_POINTER) input.focus({ preventScroll: true })`. On a
|
|
coarse pointer (phone/tablet) focusing the composer pops the
|
|
virtual keyboard over the completed answer, so the composer stays
|
|
unfocused there (the ≥44px tap target starts the next question).
|
|
This pin's job: the focus-back exists, is preventScroll (the
|
|
phase-06/42 contract, byte-preserved on desktop), and is
|
|
coarse-gated — a bare ungated `input.focus(...)` line in the
|
|
finally block is a failure, as a missing or mistyped
|
|
`COARSE_POINTER` constant is."""
|
|
js = _js()
|
|
# Phase 48: the settle line gained the user-stop terminal (stop →
|
|
# idle, no banner) — the focus-back contract is unchanged.
|
|
finally_idx = js.find("// done | error | stop → 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)]
|
|
# Phase 143: the gated shape — the focus call exists, keeps
|
|
# preventScroll, and is wrapped in the coarse-pointer gate.
|
|
assert "if (!COARSE_POINTER) input.focus({ preventScroll: true })" in block
|
|
# A bare, ungated focus line (the pre-phase-143 shape) is gone:
|
|
# every focus call in the block rides the gate.
|
|
focus_lines = [ln for ln in block.splitlines() if "input.focus(" in ln]
|
|
assert focus_lines, "the turn-end focus-back must still exist"
|
|
assert all("if (!COARSE_POINTER)" in ln for ln in focus_lines), (
|
|
"the turn-end focus-back must be coarse-pointer gated (phase 143)"
|
|
)
|
|
assert "input.focus()" not in block
|
|
# The L391-twin constant: the same typeof-guarded matchMedia shape
|
|
# as reducedMotion, bound to COARSE_POINTER.
|
|
assert re.search(
|
|
r'const COARSE_POINTER\s*=\s*typeof matchMedia === "function"'
|
|
r' && matchMedia\("\(pointer: coarse\)"\)\.matches;',
|
|
js,
|
|
)
|