diff --git a/.agent/PLAN.md b/.agent/PLAN.md index 070c854..6629e8c 100644 --- a/.agent/PLAN.md +++ b/.agent/PLAN.md @@ -6,7 +6,8 @@ > **Revisions (2026-08-21, owner permission):** A7/A8/A9 revised (multi-format > ingestion, hybrid FTS+vector retrieval, re-tuned honesty gate); dark tech > theme (Phase 08); clickable document viewer (Phase 10); thinking display -> (Phase 17, owner permission 2026-08-23). See roadmap §12. +> (Phase 17, owner permission 2026-08-23); follow-the-bottom scroll +> (Phase 18, owner choice 2026-08-23). See roadmap §12. --- @@ -308,9 +309,13 @@ Rules: | **Error** | Red banner (`role="alert"`) with retry hint; button re-enabled. | | **KB offline** | Amber banner at top of chat ("start Postgres…"); chat disabled with explanation. | | **Guard** | 120s client-side timeout → error state (a button can never sit "stuck" forever). | +| **Scroll (follow-the-bottom, phase 18)** | The page auto-scrolls only while the user is pinned to the bottom (≤200px band, `NEAR_BOTTOM_PX` — the composer zone; submitting reveals the user's message through the same gate, which holds in real use); scrolling up holds the viewport for the rest of the turn (thinking and answer alike); restore lands one-shot on the latest message. | > The **Thinking (model reasoning)** row is a phase-17 addition (owner > permission 2026-08-23) — see the §4 SSE revision. +> +> The **Scroll** row is a phase-18 addition (owner choice 2026-08-23 — +> option 1: follow-the-bottom, no "↓ new content" pill). ### 7.5 Component inventory (ids used by tests) `#messages` (stream), `#empty-state`, `#suggestions`, `.suggestion-chip`, @@ -410,9 +415,14 @@ ranks live hybrid results for a question (retrieval tuning). | 09 | `09_story_retrieval_quality.md` | `retrieval-quality.md` | `tests/e2e/test_retrieval_quality.py` | | 10 | `10_story_document_viewer.md` | `document-viewer.md` | `tests/e2e/test_document_viewer.py` | | 17 | `17_thinking_display.md` | `thinking-display.md` | `tests/e2e/test_thinking_display.py` | +| 18 | `18_follow_bottom_scroll.md` | `follow-bottom-scroll.md` | `tests/e2e/test_follow_bottom_scroll.py` | > Row 17 (thinking display) added 2026-08-23 with owner permission — the > A15 SSE extension recorded in §4. +> +> Row 18 (follow-the-bottom scroll) added 2026-08-23 with owner choice — +> option 1: follow-the-bottom, no "↓ new content" pill (UI-behavior-only +> change; no anchor revised). Completion = unit+integration green, coverage >90%, story E2E green in isolation, UI verification passed, **one `--no-gpg-sign` commit**. diff --git a/frontend/assets/app.js b/frontend/assets/app.js index e7d45ab..84a4075 100644 --- a/frontend/assets/app.js +++ b/frontend/assets/app.js @@ -50,6 +50,16 @@ * announced through a polite live region (#steering-announcer), and the * panel + count badge update on every change. * + * Scroll (phase 18, owner choice 2026-08-23): the page auto-scrolls only + * while the user is pinned to the bottom. NEAR_BOTTOM_PX (200px) covers + * the composer zone — the textarea auto-grows to 192px plus the button + * row — so "the composer is in view" counts as pinned: submitting from + * the composer reveals your own message, and the answer follows token by + * token while you stay pinned. Once you scroll up to read earlier + * content, nothing drags the viewport back down for the rest of the turn + * (thinking or answer). scrollReveal(wrap) is the single scroll gate; + * `force` is reserved for the one-shot phase-14 restore landing. + * * All DOM ids match frontend/index.html. */ @@ -99,6 +109,28 @@ const reducedMotion = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches; const SCROLL = reducedMotion ? "auto" : "smooth"; +/* Follow-the-bottom scroll contract (phase 18, owner choice + * 2026-08-23): the page auto-scrolls only while the user is pinned + * at the bottom — the 200px band covers the composer zone (the + * textarea auto-grows to 192px + the button row), i.e. "the + * composer is in view". Exported so the band is unit-pinned (same + * pattern as TURN_TIMEOUT_MS). */ +export const NEAR_BOTTOM_PX = 200; + +function isNearBottom() { + const bottom = + document.documentElement.scrollHeight - window.scrollY - window.innerHeight; + return bottom <= NEAR_BOTTOM_PX; +} + +/* The ONE scroll call site in this file. `force` is used only by + * the phase-14 restore landing (one-shot, load-time). */ +function scrollReveal(wrap, behavior = SCROLL, force = false) { + if (force || isNearBottom()) { + wrap.scrollIntoView({ behavior, block: "end" }); + } +} + /* ---------- document viewer link (phase 10; phase 13 adds `back`) ---------- * Every cited document opens in the viewer, in a NEW tab. All query * values are percent-encoded: real paths contain slashes and sometimes @@ -327,8 +359,11 @@ const BRAIN_AVATAR = const USER_AVATAR = ''; -/* ---------- messages ---------- */ -function addMessage(who, html, scrollBehavior = SCROLL) { +/* ---------- messages ---------- + * Scroll is conditional (phase 18): addMessage reveals through + * scrollReveal — only when the user is pinned to the bottom, or when + * forced (the one-shot phase-14 restore landing). */ +function addMessage(who, html, scrollBehavior = SCROLL, force = false) { if (emptyState) emptyState.hidden = true; const wrap = document.createElement("div"); wrap.className = `msg ${who}`; @@ -338,7 +373,7 @@ function addMessage(who, html, scrollBehavior = SCROLL) {
${html}
`; messagesEl.appendChild(wrap); - wrap.scrollIntoView({ behavior: scrollBehavior, block: "end" }); + scrollReveal(wrap, scrollBehavior, force); return wrap; } @@ -356,7 +391,7 @@ function addTyping() { `; messagesEl.appendChild(wrap); - wrap.scrollIntoView({ behavior: SCROLL, block: "end" }); + scrollReveal(wrap); } function removeTyping() { @@ -670,11 +705,14 @@ export function clearStoredConversation() { } function renderStoredMessage(m) { + // Phase 18: the restore landing is the only `force`d scroll — one-shot, + // non-smooth, so a restored conversation lands on its latest message + // (phase-14 behavior preserved) without smooth-scrolling through it. if (m.who === "user") { - addMessage("user", renderMarkdown(m.text), "auto"); + addMessage("user", renderMarkdown(m.text), "auto", true); return; } - const wrap = addMessage("brain", renderMarkdown(m.text), "auto"); + const wrap = addMessage("brain", renderMarkdown(m.text), "auto", true); if (m.thinking) { // Phase 17: restore the thinking block COLLAPSED above the bubble. const block = ensureThinkingBlock(wrap); @@ -855,7 +893,7 @@ async function handleSend(e) { textEl.innerHTML = renderMarkdown(thinkingAcc); // escape-first, XSS-safe if (block.open) { textEl.scrollTop = textEl.scrollHeight; // pin the stream to the bottom - wrap.scrollIntoView({ behavior: SCROLL, block: "end" }); + scrollReveal(wrap); // page follows only while pinned (phase 18) } } else if (ev.type === "delta") { acc += ev.text || ""; @@ -863,7 +901,7 @@ async function handleSend(e) { if (!wrap) wrap = addMessage("brain", ""); // first token: live bubble in closeThinkingBlock(wrap); // auto-collapse; idempotent, never reopens wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc); - wrap.scrollIntoView({ behavior: SCROLL, block: "end" }); + scrollReveal(wrap); // page follows only while pinned (phase 18) } else if (ev.type === "done") { sawDone = true; closeThinkingBlock(wrap); // the turn is over: settle the block closed @@ -928,7 +966,9 @@ async function handleSend(e) { stopThinkingClock(); try { res?.body?.cancel(); } catch { /* stream already closed */ } if (uiState !== UI_STATE.idle) setUiState(UI_STATE.idle); - input.focus(); + // Phase 18: focus back for the next question, but never move the + // viewport — a user reading earlier content stays where they are. + input.focus({ preventScroll: true }); } } diff --git a/tests/e2e/test_follow_bottom_scroll.py b/tests/e2e/test_follow_bottom_scroll.py new file mode 100644 index 0000000..a9b691c --- /dev/null +++ b/tests/e2e/test_follow_bottom_scroll.py @@ -0,0 +1,355 @@ +"""Phase 18 E2E (Playwright, mock-only): the chat follows the bottom. + +Story: ``.agent/user_stories/follow-bottom-scroll.md`` +Run in isolation (DB must be up: ``podman compose up -d db``): + + uv run pytest tests/e2e/test_follow_bottom_scroll.py -v --no-cov + +The follow-the-bottom contract (owner choice 2026-08-23, option 1 — no +"↓ new content" pill): the page auto-scrolls *only while the user is +already pinned at the bottom*; submitting a question reveals the user's +own message; once the user scrolls up, nothing auto-scrolls for the rest +of the turn (thinking or answer); a restored conversation still lands on +the latest message. + +MOCK-ONLY suite: the scenarios key off the deterministic mock's +``write a long answer`` trigger (~900 words ≈ 8s of streaming — a wide, +reliable window to scroll away in) and, for scenario 4, the phase-17 +``think out loud`` trigger (both fire independently). ``E2E_REAL_LLM=1`` +would make the scroll-away windows unpredictable, so it is not supported +here. + +Measurement convention: the scroller is the DOCUMENT — there is no inner +scroll container (``body`` is ``min-height: 100dvh``; the page scrolls on +the window). Scroll position is read via ``page.evaluate`` as +``{ y: window.scrollY, sh: document.documentElement.scrollHeight, +ch: window.innerHeight }``; "near bottom" = ``sh - y - ch <= 200`` +(mirrors the frontend's ``NEAR_BOTTOM_PX``); scrolling to the top is +``page.evaluate("() => window.scrollTo(0, 0)")``. + +Real-user flow: the user submits from the composer — i.e. pinned at the +bottom (a normal ``fill`` + ``Enter``/click) — and only *after* the +stream starts do they scroll up to read earlier messages. The no-yank +scenarios follow exactly that sequence, so no off-screen input +manipulation is needed (and Playwright's own click/fill auto-scroll +never fires, because the composer is already in view). + +Determinism note: the mock paces every SSE frame at 0.02s, so the long +answer streams for several seconds — "mid-stream" assertions land +comfortably inside the window on headless Chromium. Every "held still" +assertion compares against the exact ``scrollTo(0, 0)`` position +(tolerance 5px for rounding). + +Test → story mapping (Playwright Mapping Rule): +1. ``test_submit_reveals_new_message`` +2. ``test_stream_follows_while_pinned_at_bottom`` +3. ``test_no_yank_while_scrolled_up_during_answer_stream`` +4. ``test_no_yank_while_scrolled_up_during_thinking`` +5. ``test_restore_lands_on_latest_message`` +""" +from __future__ import annotations + +import asyncio +import time +from collections.abc import Iterator +from pathlib import Path +from threading import Thread +from typing import Any + +import pytest +from playwright.sync_api import Page, expect +from sqlalchemy import text + +from app.config import Settings +from app.db import SessionLocal +from app.rag.importer import ImportSummary, import_sources +from app.rag.llm import LLMClient + +REPO = Path(__file__).resolve().parents[2] +FIXTURES = REPO / "tests" / "fixtures" / "docs" + +#: Mirror of app.js's exported ``NEAR_BOTTOM_PX`` (the 200px composer-zone +#: band that counts as "pinned to the bottom"). +NEAR_BOTTOM_PX = 200 + +#: Mock long-answer trigger (~900 words ≈ 8s of streaming at the mock's +#: 0.02s/frame pace) — the wide, deterministic window to scroll away in. +LONG_QUESTION = "write a long answer about my kubernetes cluster" +#: Phase-17 thinking prefix + the long-answer trigger: both mock triggers +#: fire independently (a ~1.3s reasoning stream, then the long answer). +THINK_LONG_QUESTION = "think out loud — write a long answer about my kubernetes cluster" +#: The mock long answer's unique final line (mock_llm.LONG_ANSWER_END) — +#: proves the whole stream landed even while the viewport was at the top. +LONG_ANSWER_END = "LONG-ANSWER-END" +#: Line fragment the mock's deterministic scratchpad carries +#: (mock_llm.compose_thinking) — same key phase 17's suite uses. +THINKING_FRAGMENT = "Step 2: Check my notes" +#: Tolerance for "the viewport held still at the top" (rounding). +HOLD_TOLERANCE_PX = 5 + + +async def _import_fixtures(mock_port: int) -> ImportSummary: + kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"} + settings = Settings(**kwargs) # pyright: ignore[reportCallIssue] + return await import_sources([FIXTURES], LLMClient(settings)) + + +def _run_in_thread(coro: Any) -> Any: + """Run a coroutine on a worker thread. + + Playwright's sync API keeps an asyncio loop running on the test thread, + so ``asyncio.run`` cannot be called directly from a test body. + """ + box: dict[str, Any] = {} + + def runner() -> None: + try: + box["value"] = asyncio.run(coro) + except BaseException as e: # noqa: BLE001 — re-raised on the test thread + box["error"] = e + + t = Thread(target=runner) + t.start() + t.join() + if "error" in box: + raise box["error"] + return box["value"] + + +def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None: + """Truncate the KB (and query log), then optionally re-import fixtures.""" + with SessionLocal() as db: + db.execute(text("TRUNCATE chunks, documents, query_log")) + db.commit() + if not seed: + return None + return _run_in_thread(_import_fixtures(mock_port)) + + +@pytest.fixture() +def seeded_kb(mock_llm: int, db_ready: None) -> Iterator[None]: + """A fresh KB seeded from ``tests/fixtures/docs`` (8 docs, A9 formats), + truncated again on teardown. ``db_ready`` (conftest) skips with clear + instructions when Postgres is down.""" + summary = _reset_db(mock_llm, seed=True) + assert summary is not None and summary.added == 8 + yield + _reset_db(mock_llm, seed=False) + + +# --------------------------------------------------------------------------- +# Measurement + flow helpers +# --------------------------------------------------------------------------- + + +def scroll_state(page: Page) -> dict[str, float]: + """The document scroller's state (there is no inner scroll container).""" + return page.evaluate( + "() => ({ y: window.scrollY, " + "sh: document.documentElement.scrollHeight, " + "ch: window.innerHeight })" + ) + + +def near_bottom(state: dict[str, float]) -> bool: + """Mirror of app.js's ``isNearBottom`` — the NEAR_BOTTOM_PX band.""" + return state["sh"] - state["y"] - state["ch"] <= NEAR_BOTTOM_PX + + +def held_at_top(page: Page) -> bool: + """The viewport has not moved from ``window.scrollTo(0, 0)`` (±5px).""" + return scroll_state(page)["y"] <= HOLD_TOLERANCE_PX + + +def wait_settled(page: Page) -> None: + """The turn is over: the never-stale contract re-enabled the button.""" + expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000) + expect(page.locator("#send-label")).to_have_text("Send") + + +def submit(page: Page, question: str) -> None: + """Submit from the composer — the real-user flow (pinned at the + bottom, so Playwright's click/fill auto-scroll never kicks in).""" + page.fill("#message-input", question) + page.click("#send-btn") + expect(page.locator(".msg.user .bubble").last).to_contain_text(question) + + +def brain_bubble_longer_than(n: int) -> str: + """JS predicate: the LAST brain bubble's rendered text is > n chars + (i.e. that far into the stream).""" + return ( + "() => { const els = document.querySelectorAll('.msg.brain .bubble');" + f" const el = els[els.length - 1]; return !!el && el.innerText.length > {n}; }}" + ) + + +def brain_message_in_view(page: Page) -> bool: + """The last brain message intersects the viewport vertically. Partial + visibility counts: a long answer is taller than the window, and the + contract is that it is revealed (its lower edge in view), not that it + fits.""" + box = page.locator(".msg.brain").last.bounding_box() + if box is None: + return False + ch = scroll_state(page)["ch"] + return box["y"] < ch and box["y"] + box["height"] > 0 + + +# --------------------------------------------------------------------------- +# 1. Submit: the user's message and the answer reveal into view +# --------------------------------------------------------------------------- + + +def test_submit_reveals_new_message(page: Page, app_url: str, seeded_kb: None) -> None: + page.set_default_timeout(30_000) + page.goto(app_url) + # A fresh chat page starts pinned at the bottom (short conversation — + # the composer, i.e. the user, sits in the band). + assert near_bottom(scroll_state(page)) + + submit(page, LONG_QUESTION) + wait_settled(page) + + # The last brain message is inside the viewport ... + assert brain_message_in_view(page), ( + "the answer must be revealed — the last brain message is not in view" + ) + # ... and the page is still pinned at the bottom. + assert near_bottom(scroll_state(page)) + + +# --------------------------------------------------------------------------- +# 2. Follow: while pinned, the page keeps up with the stream +# --------------------------------------------------------------------------- + + +def test_stream_follows_while_pinned_at_bottom(page: Page, app_url: str, seeded_kb: None) -> None: + page.set_default_timeout(30_000) + page.goto(app_url) + submit(page, LONG_QUESTION) + + # ~2s into the stream: the answer bubble already carries >200 chars + # (the mock paces frames at 0.02s). + page.wait_for_function(brain_bubble_longer_than(200), timeout=30_000) + # Let the smooth follow scroll settle before measuring. + time.sleep(0.3) + # The follow behavior is alive — not accidentally removed. + assert near_bottom(scroll_state(page)), ( + "the page must follow the stream while the user is pinned at the bottom" + ) + + wait_settled(page) + assert near_bottom(scroll_state(page)) + + +# --------------------------------------------------------------------------- +# 3. No yank: scrolled up mid-ANSWER — the viewport holds for the rest +# of the turn (the answer finishes off-screen below, by design) +# --------------------------------------------------------------------------- + + +def test_no_yank_while_scrolled_up_during_answer_stream( + page: Page, app_url: str, seeded_kb: None +) -> None: + page.set_default_timeout(30_000) + page.goto(app_url) + + # Turn 1 (settled) makes the document overflow the 800px viewport. + submit(page, LONG_QUESTION) + wait_settled(page) + state = scroll_state(page) + assert state["sh"] > state["ch"], "a long answer must make the document scrollable" + assert near_bottom(state), "follow was active: the settled turn ends pinned" + + # Turn 2: submit from the composer (pinned — normal flow), then let + # the new answer stream a bit. + submit(page, LONG_QUESTION) + page.wait_for_function(brain_bubble_longer_than(200), timeout=30_000) + + # The user goes up to read while the stream is running. + page.evaluate("() => window.scrollTo(0, 0)") + # The stream kept running at the top ... + page.wait_for_function(brain_bubble_longer_than(600), timeout=30_000) + assert held_at_top(page), "the viewport must hold still while scrolled up" + + # ... and nothing scrolls for the rest of the turn — the answer + # finishes off-screen below, by design. + wait_settled(page) + assert held_at_top(page) + + +# --------------------------------------------------------------------------- +# 4. No yank: scrolled up during THINKING — the whole reasoning stream +# plus the answer's start happen at the top +# --------------------------------------------------------------------------- + + +def test_no_yank_while_scrolled_up_during_thinking( + page: Page, app_url: str, seeded_kb: None +) -> None: + page.set_default_timeout(30_000) + page.goto(app_url) + + # One settled turn first, so the document overflows (scrollable). + submit(page, LONG_QUESTION) + wait_settled(page) + + # The thinking turn: submit pinned (normal flow) ... + submit(page, THINK_LONG_QUESTION) + details = page.locator(".msg.brain").last.locator("details.thinking") + details.wait_for(state="attached", timeout=10_000) + # ... and, while the reasoning stream is still open (phase-17 + # behavior: created open, ~1.3s before the first answer token) ... + expect(details).to_have_attribute("open", "") + # ... the user goes up to read. + page.evaluate("() => window.scrollTo(0, 0)") + + # The whole thinking stream plus the answer's start happen at the top. + bubble = page.locator(".msg.brain").last.locator(".bubble") + expect(bubble).not_to_have_text("", timeout=30_000) + assert held_at_top(page), "the viewport must hold still during thinking" + + # Settled: still at the top, and everything landed (off-screen, + # which is the point of the story). + wait_settled(page) + assert held_at_top(page) + expect(details.locator(".thinking-text")).to_contain_text(THINKING_FRAGMENT) + expect(bubble).to_contain_text(LONG_ANSWER_END) + + +# --------------------------------------------------------------------------- +# 5. Restore: the one-shot landing still puts the latest message in view +# (phase 14 behavior preserved — pinned so a future "remove all +# scrolling" change fails loudly instead of silently) +# --------------------------------------------------------------------------- + + +def test_restore_lands_on_latest_message(page: Page, app_url: str, seeded_kb: None) -> None: + page.set_default_timeout(30_000) + page.goto(app_url) + + # Two settled turns (user + brain × 2) — the document overflows. + submit(page, LONG_QUESTION) + wait_settled(page) + submit(page, LONG_QUESTION) + wait_settled(page) + + page.reload() + # Restore re-renders from localStorage; wait until the last restored + # brain answer is fully back. + expect(page.locator(".msg.brain .bubble").last).to_contain_text( + LONG_ANSWER_END, timeout=30_000 + ) + wait_settled(page) + expect(page.locator(".msg.user .bubble")).to_have_count(2) + state = scroll_state(page) + assert state["sh"] > state["ch"] + + # The forced one-shot landing (the only `force`d scrolls) puts the + # last brain message back in view ... + assert brain_message_in_view(page), ( + "a restored conversation must land on its latest message" + ) + # ... and the page sits at the bottom. + assert near_bottom(state) diff --git a/tests/unit/test_chat_persistence.py b/tests/unit/test_chat_persistence.py index a672738..dd05651 100644 --- a/tests/unit/test_chat_persistence.py +++ b/tests/unit/test_chat_persistence.py @@ -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 diff --git a/tests/unit/test_frontend_scroll.py b/tests/unit/test_frontend_scroll.py new file mode 100644 index 0000000..094b4b6 --- /dev/null +++ b/tests/unit/test_frontend_scroll.py @@ -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 ` (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