diff --git a/frontend/assets/app.js b/frontend/assets/app.js
index b09c1ec..7bb2dee 100644
--- a/frontend/assets/app.js
+++ b/frontend/assets/app.js
@@ -70,15 +70,17 @@
* (announceSteering()) through the module. Note text is always rendered
* with textContent (XSS-safe) in both places.
*
- * 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.
+ * No reply autoscroll (owner direction 2026-08-27, TODO.md L5 —
+ * revising the phase 18 follow-the-bottom choice): the page NEVER
+ * auto-scrolls while a turn streams — no thinking, tool, or delta frame
+ * moves the viewport, so scrolling up to read earlier content holds for
+ * the rest of the turn. The only scroll call sites are user intent: the
+ * submit (your own message is revealed) and the phase-14 restore landing
+ * (one-shot, load-time). scrollReveal(wrap) is the one scrollIntoView in
+ * this file; addMessage(who, html, scroll) carries the intent. The
+ * thinking block's internal bottom-pin (textEl.scrollTop, phase 17 —
+ * reworked separately in phase 43) pins the block's own clip, not the
+ * page, and is untouched here.
*
* Document modal (phase 26): a source chip opens the cited document in
* the almost-fullscreen modal overlay (assets/document-modal.js) on the
@@ -160,26 +162,17 @@ 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;
+/* No reply autoscroll (owner direction 2026-08-27, TODO.md L5 —
+ * revising the phase 18 follow-the-bottom choice): the page never
+ * auto-scrolls while a turn streams. The only scroll call sites are
+ * the user submit (reveal my message) and the phase-14 restore
+ * landing (one-shot, load-time). */
-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" });
- }
+/* The ONE scrollIntoView in this file — unconditional (unit-pinned):
+ * scrollReveal scrolls whenever it is called, so a page scroll can only
+ * ever happen from those two user-intent call sites. */
+function scrollReveal(wrap, behavior = SCROLL) {
+ wrap.scrollIntoView({ behavior, block: "end" });
}
/* ---------- document viewer link (phase 10; phase 13 adds `back`) ----------
@@ -339,10 +332,12 @@ const USER_AVATAR =
'';
/* ---------- 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) {
+ * Scroll is explicit intent (phase 42, no reply autoscroll): addMessage
+ * scrolls only when the caller passes `scroll = true` — the user submit
+ * (reveal my message) and the phase-14 restore landing. The streaming
+ * path (thinking / tool / delta) creates bubbles with the default
+ * (scroll = false): the page never follows a turn. */
+function addMessage(who, html, scroll = false) {
if (emptyState) emptyState.hidden = true;
const wrap = document.createElement("div");
wrap.className = `msg ${who}`;
@@ -352,7 +347,7 @@ function addMessage(who, html, scrollBehavior = SCROLL, force = false) {
${html}
`;
messagesEl.appendChild(wrap);
- scrollReveal(wrap, scrollBehavior, force);
+ if (scroll) scrollReveal(wrap);
return wrap;
}
@@ -370,7 +365,7 @@ function addTyping() {
`;
messagesEl.appendChild(wrap);
- scrollReveal(wrap);
+ // No page scroll (phase 42): a typing bubble must not yank the viewport.
}
function removeTyping() {
@@ -765,14 +760,17 @@ 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.
+ // Phase-14 restore landing (kept by the phase-42 direction): the
+ // one-shot load-time scroll — scroll=true so a restored conversation
+ // lands on its latest message. The new addMessage(who, html, scroll)
+ // signature has no per-call behavior override, so the landing rides
+ // the default SCROLL (smooth; "auto" under prefers-reduced-motion)
+ // instead of the old forced "auto" — noted per the phase-42 task.
if (m.who === "user") {
- addMessage("user", renderMarkdown(m.text), "auto", true);
+ addMessage("user", renderMarkdown(m.text), true);
return;
}
- const wrap = addMessage("brain", renderMarkdown(m.text), "auto", true);
+ const wrap = addMessage("brain", renderMarkdown(m.text), true);
if (m.thinking) {
// Phase 17: restore the thinking block COLLAPSED above the bubble.
const block = ensureThinkingBlock(wrap);
@@ -893,7 +891,7 @@ async function handleSend(e) {
const text = input.value.trim();
if (!text || sendBtn.disabled) return;
- addMessage("user", renderMarkdown(text));
+ addMessage("user", renderMarkdown(text), true); // reveal my message (owner-kept)
// Persistence save point 1: the question is stored the moment it is
// sent, so a failed/interrupted turn never loses it.
conversation.push({ who: "user", text });
@@ -958,8 +956,9 @@ async function handleSend(e) {
const textEl = block.querySelector(".thinking-text");
textEl.innerHTML = renderMarkdown(thinkingAcc); // escape-first, XSS-safe
if (block.open) {
- textEl.scrollTop = textEl.scrollHeight; // pin the stream to the bottom
- scrollReveal(wrap); // page follows only while pinned (phase 18)
+ // Pin the block's OWN stream (phase 17 — reworked in phase 43);
+ // the page never follows (phase 42, no reply autoscroll).
+ textEl.scrollTop = textEl.scrollHeight;
}
} else if (ev.type === "tool") {
// Phase 37 (PLAN §4 extension): an agent tool call. The UI
@@ -988,14 +987,14 @@ async function handleSend(e) {
?.setAttribute("aria-label", toolStatus);
}
appendToolLine(wrap, name, argument);
- scrollReveal(wrap); // page follows only while pinned (phase 18)
+ // No page scroll (phase 42): tool lines never yank the viewport.
} else if (ev.type === "delta") {
acc += ev.text || "";
if (uiState === UI_STATE.thinking) setUiState(UI_STATE.streaming);
if (!wrap) wrap = addMessage("brain", ""); // first token: live bubble in
closeThinkingBlock(wrap); // auto-collapse; idempotent, never reopens
wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc);
- scrollReveal(wrap); // page follows only while pinned (phase 18)
+ // No page scroll (phase 42): the answer never follows the viewport.
} else if (ev.type === "done") {
sawDone = true;
closeThinkingBlock(wrap); // the turn is over: settle the block closed
@@ -1062,8 +1061,9 @@ async function handleSend(e) {
stopThinkingClock();
cancelStream(res); // the reader lock is released — no unhandled rejection
if (uiState !== UI_STATE.idle) setUiState(UI_STATE.idle);
- // Phase 18: focus back for the next question, but never move the
- // viewport — a user reading earlier content stays where they are.
+ // Focus back for the next question, but never move the viewport —
+ // the page never auto-scrolls (phase 42), so 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
deleted file mode 100644
index ca42daf..0000000
--- a/tests/e2e/test_follow_bottom_scroll.py
+++ /dev/null
@@ -1,356 +0,0 @@
-"""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 ~4.5s reasoning stream — lengthened in phase 21 —
-#: 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/e2e/test_no_reply_autoscroll.py b/tests/e2e/test_no_reply_autoscroll.py
new file mode 100644
index 0000000..fc76729
--- /dev/null
+++ b/tests/e2e/test_no_reply_autoscroll.py
@@ -0,0 +1,496 @@
+"""Phase 42 E2E (Playwright, mock-only): the chat NEVER autoscrolls.
+
+Story: ``.agent/user_stories/no-reply-autoscroll.md`` — owner direction
+2026-08-27 (TODO.md L5) revising the phase-18 follow-the-bottom choice:
+"Get rid of the chat reply autoscroll, it's breaking things like making
+it impossible for the user to scroll while a reply generates."
+
+Run in isolation (DB must be up: ``podman compose up -d db``):
+
+ uv run pytest tests/e2e/test_no_reply_autoscroll.py -v --no-cov
+
+This is the INVERSE of the phase-18 contract: while a turn streams
+(thinking, tool, or answer frames), nothing moves the viewport — a user
+reading earlier content stays exactly where they put it for the rest of
+the turn. The only scrolls left in the app are user intent: the submit
+(the user's own message is revealed) and the phase-14 restore landing
+(one-shot, load-time). The phase-18 suite
+(``tests/e2e/test_follow_bottom_scroll.py``) is deleted with this one —
+its behavior was intentionally removed.
+
+MOCK-ONLY suite: the scenarios key off the deterministic mock's
+``write a long answer`` trigger (~900 words ≈ 8–11s of streaming — a
+wide, reliable window to scroll away in) and the phase-17
+``think out loud`` trigger (~4.5s reasoning stream). ``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. ``window.scrollY`` is read via ``page.evaluate``;
+"stable" means every sample is within 1px of every other sample (the
+story's tolerance). The stylesheet sets no ``scroll-behavior``, so
+``window.scrollTo(0, y)`` is instant — the recorded position is the exact
+position the stream must not move.
+
+Test → story mapping (Playwright Mapping Rule):
+1. ``test_no_autoscroll_during_long_answer``
+2. ``test_no_autoscroll_during_thinking``
+3. ``test_submit_reveals_user_message``
+4. ``test_restore_landing_one_shot``
+5. ``test_answer_content_intact``
+"""
+from __future__ import annotations
+
+import asyncio
+import json
+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"
+
+#: Mock long-answer trigger (~900 words ≈ 8–11s 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 trigger: a ~4.5s reasoning stream, then a short
+#: grounded mock answer (both fire independently of the long trigger).
+THINKING_QUESTION = "think out loud about my kubernetes cluster"
+#: Short grounded question (phase-14 marker answer).
+SHORT_QUESTION = "How is my Kubernetes cluster set up?"
+#: The mock long answer's unique final line (mock_llm.LONG_ANSWER_END) —
+#: proves the whole stream landed even while the viewport was up.
+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"
+MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
+STORAGE_KEY = "bor.chat.v1"
+
+#: The story's stability tolerance: the viewport must not move more than
+#: 1px while a turn streams with the user scrolled away.
+STABLE_PX = 1
+
+
+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 brain_bubble_longer_than(n: int, min_bubbles: int = 1) -> str:
+ """JS predicate: there are at least ``min_bubbles`` brain bubbles and
+ the LAST one's rendered text is > n chars (i.e. that far into the
+ stream). ``min_bubbles=2`` guards a second turn: before its first
+ delta, ``.last`` would still be the PREVIOUS turn's bubble."""
+ return (
+ "() => { const els = document.querySelectorAll('.msg.brain .bubble');"
+ f" return els.length >= {min_bubbles} && els[els.length - 1].innerText.length > {n}; }}"
+ )
+
+
+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 wait_scroll_still(page: Page, timeout: float = 10.0) -> float:
+ """window.scrollY once the viewport has stopped moving (two consecutive
+ reads within STABLE_PX). The submit's smooth reveal and the restore
+ landing's smooth scroll are the only animations left — both settle
+ this way before any stream measurement begins."""
+ deadline = time.monotonic() + timeout
+ prev: float | None = None
+ while True:
+ y = scroll_state(page)["y"]
+ if prev is not None and abs(y - prev) <= STABLE_PX:
+ return y
+ prev = y
+ if time.monotonic() >= deadline:
+ raise AssertionError("the viewport did not settle within timeout")
+ time.sleep(0.25)
+
+
+def submit(page: Page, question: str) -> None:
+ """Submit through the composer (the real-user flow). Playwright's
+ fill/click scroll the composer into view first — a user-initiated
+ move, never an app scroll."""
+ page.fill("#message-input", question)
+ page.click("#send-btn")
+ expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
+
+
+def submit_from_top(page: Page, question: str) -> None:
+ """Submit with the viewport where the user left it (the very top).
+
+ ``page.fill``/``page.click`` would scroll the composer into view
+ first — which IS the viewport move under test — so the send goes
+ through the page's own DOM: set the value, fire ``input`` (autoGrow),
+ click the submit button. A JS click never scrolls the page."""
+ page.evaluate(
+ """(q) => {
+ const input = document.querySelector('#message-input');
+ input.value = q;
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ document.querySelector('#send-btn').click();
+ }""",
+ question,
+ )
+ expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
+
+
+def user_message_in_view(page: Page) -> bool:
+ """The LAST user message's box is fully inside the viewport
+ (the submit reveal aligns it to the bottom edge)."""
+ box = page.locator(".msg.user").last.bounding_box()
+ if box is None:
+ return False
+ ch = scroll_state(page)["ch"]
+ return box["y"] >= -1 and box["y"] + box["height"] <= ch + 1
+
+
+# ---------------------------------------------------------------------------
+# 1. No autoscroll: scrolled up mid-ANSWER — the viewport holds for the
+# rest of the turn (the answer finishes off-screen below, by design)
+# ---------------------------------------------------------------------------
+
+
+def test_no_autoscroll_during_long_answer(
+ 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"
+
+ # Turn 2: the same long answer. The submit reveals the user's message
+ # (the one kept app scroll) — let that smooth reveal settle first.
+ submit(page, LONG_QUESTION)
+ page.wait_for_function(brain_bubble_longer_than(200, min_bubbles=2), timeout=30_000)
+ y0 = wait_scroll_still(page)
+
+ # The user scrolls UP ~2× the answer's current height to read earlier
+ # context while the stream is still running.
+ box = page.locator(".msg.brain").last.bounding_box()
+ assert box is not None
+ target = max(0.0, y0 - 2 * box["height"])
+ assert target <= y0 - STABLE_PX, "the scroll-up must actually move the viewport"
+ page.evaluate("y => window.scrollTo(0, y)", target)
+ assert abs(scroll_state(page)["y"] - target) <= STABLE_PX
+
+ # Sample the viewport across the rest of the stream ...
+ samples: list[float] = []
+ mid_stream = 0
+ deadline = time.monotonic() + 40
+ while time.monotonic() < deadline:
+ time.sleep(0.25)
+ samples.append(scroll_state(page)["y"])
+ if not page.locator("#send-btn").is_enabled():
+ mid_stream += 1
+ if len(samples) >= 10 and page.locator("#send-btn").is_enabled():
+ # ... and a few more AFTER `done` (the turn is over; nothing
+ # queued behind the stream may move the page either).
+ for _ in range(3):
+ time.sleep(0.3)
+ samples.append(scroll_state(page)["y"])
+ break
+ else:
+ raise AssertionError("the long turn did not settle within 40s")
+ assert mid_stream >= 8, "the samples must land while the stream is running"
+
+ spread = max(samples) - min(samples)
+ assert spread <= STABLE_PX, (
+ f"the viewport moved {spread:.1f}px while the user was scrolled up "
+ "(no-reply-autoscroll contract)"
+ )
+
+ # The whole answer still landed (off-screen below — by design).
+ expect(page.locator(".msg.brain .bubble").last).to_contain_text(LONG_ANSWER_END)
+
+
+# ---------------------------------------------------------------------------
+# 2. No autoscroll: scrolled up during THINKING — the whole reasoning
+# stream plus the answer's start happen with the viewport held
+# ---------------------------------------------------------------------------
+
+
+def test_no_autoscroll_during_thinking(
+ page: Page, app_url: str, seeded_kb: None
+) -> None:
+ page.set_default_timeout(30_000)
+ page.goto(app_url)
+
+ # One settled long turn so the document overflows (scrollable).
+ submit(page, LONG_QUESTION)
+ wait_settled(page)
+
+ # The thinking turn: the submit reveals the user's message (the kept
+ # app scroll) ...
+ submit(page, THINKING_QUESTION)
+ details = page.locator(".msg.brain").last.locator("details.thinking")
+ details.wait_for(state="attached", timeout=10_000)
+ expect(details).to_have_attribute("open", "") # created open (phase 17)
+ # ... and, once the reasoning stream is clearly running ...
+ page.wait_for_function(
+ "() => { const el = document.querySelector('details.thinking .thinking-text');"
+ " return !!el && el.innerText.length > 300; }",
+ timeout=30_000,
+ )
+ # ... let the submit's smooth reveal settle, then the user goes up.
+ wait_scroll_still(page)
+ page.evaluate("() => window.scrollTo(0, 0)")
+
+ # Sample across the remaining thinking stream: no per-chunk follow.
+ samples: list[float] = []
+ open_samples = 0
+ deadline = time.monotonic() + 25
+ while time.monotonic() < deadline:
+ time.sleep(0.3)
+ state = page.evaluate(
+ """() => {
+ const block = document.querySelector('details.thinking');
+ const wrap = block ? block.closest('.msg.brain') : null;
+ const el = wrap ? wrap.querySelector('.bubble') : null;
+ return { y: window.scrollY,
+ open: !!(block && block.open),
+ bubble: el ? el.innerText.length : 0 };
+ }"""
+ )
+ samples.append(state["y"])
+ if state["open"]:
+ open_samples += 1
+ if state["bubble"] > 0 and len(samples) >= 8:
+ break
+ else:
+ raise AssertionError("the first answer token never arrived")
+ assert open_samples >= 6, "the samples must land while the thinking stream is open"
+
+ spread = max(samples) - min(samples)
+ assert spread <= STABLE_PX, (
+ f"the viewport moved {spread:.1f}px during the thinking stream "
+ "(no per-chunk page follow)"
+ )
+
+ # Settled: still at the top, everything landed (off-screen, which is
+ # the point of the story).
+ wait_settled(page)
+ assert abs(scroll_state(page)["y"]) <= STABLE_PX
+ expect(details.locator(".thinking-text")).to_contain_text(THINKING_FRAGMENT)
+ expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER)
+
+
+# ---------------------------------------------------------------------------
+# 3. Submit: scrolled to the very top, sending a question still reveals
+# the user's own message (the kept, user-initiated scroll)
+# ---------------------------------------------------------------------------
+
+
+def test_submit_reveals_user_message(
+ page: Page, app_url: str, seeded_kb: None
+) -> None:
+ page.set_default_timeout(30_000)
+ page.goto(app_url)
+
+ # A populated conversation that overflows the 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"
+
+ # The user is reading at the very top ...
+ page.evaluate("() => window.scrollTo(0, 0)")
+ assert scroll_state(page)["y"] <= STABLE_PX
+
+ # ... and sends a question without first scrolling down.
+ submit_from_top(page, SHORT_QUESTION)
+
+ # The submit's reveal is the kept app scroll: the user's own message
+ # ends up in view (its box fully inside the viewport).
+ deadline = time.monotonic() + 10
+ while time.monotonic() < deadline and not user_message_in_view(page):
+ time.sleep(0.2)
+ assert user_message_in_view(page), (
+ "the submit must reveal the user's message — its box is not in the viewport"
+ )
+
+ # The turn completes; the answer lands off-screen below, but the user
+ # message stays revealed (nothing re-positions it).
+ wait_settled(page)
+ assert user_message_in_view(page)
+ expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER)
+
+
+# ---------------------------------------------------------------------------
+# 4. Restore landing (phase 14, owner-kept): a reload lands one-shot on
+# the latest message and stays there while idle
+# ---------------------------------------------------------------------------
+
+
+def test_restore_landing_one_shot(
+ page: Page, app_url: str, seeded_kb: None
+) -> None:
+ page.set_default_timeout(30_000)
+ page.goto(app_url)
+
+ # Settle a conversation (phase-14 persistence): long + short turns.
+ submit(page, LONG_QUESTION)
+ wait_settled(page)
+ submit(page, SHORT_QUESTION)
+ wait_settled(page)
+ time.sleep(0.5) # let the persistence writes land before the reload
+
+ page.reload()
+ # Restore re-renders from localStorage; wait until the last restored
+ # brain answer (the short one — the long answer carries no mock
+ # marker) is fully back.
+ expect(page.locator(".msg.brain .bubble").last).to_contain_text(
+ MOCK_ANSWER_MARKER, timeout=30_000
+ )
+ # The one-shot landing rides a smooth scroll — let it settle ...
+ wait_scroll_still(page)
+ state = scroll_state(page)
+ assert state["sh"] > state["ch"]
+
+ # ... and it lands on the latest message: its bubble is in view, in
+ # the lower half of the viewport (the chips + composer sit just
+ # below the fold — the landing predates them by design).
+ box = page.locator(".msg.brain").last.locator(".bubble").bounding_box()
+ assert box is not None
+ assert box["y"] < state["ch"] and box["y"] + box["height"] >= state["ch"] * 0.6, (
+ "the restore landing must put the latest message in view"
+ )
+
+ # ... and STAYS: idle samples (no stream active) never move.
+ samples = [state["y"]]
+ for _ in range(4):
+ time.sleep(0.4)
+ samples.append(scroll_state(page)["y"])
+ spread = max(samples) - min(samples)
+ assert spread <= STABLE_PX, (
+ f"the restored page moved {spread:.1f}px while idle"
+ )
+
+
+# ---------------------------------------------------------------------------
+# 5. Content intact (regression): the long answer completes with sources;
+# a thinking turn persists + restores its collapsed block (phase 17)
+# ---------------------------------------------------------------------------
+
+
+def test_answer_content_intact(page: Page, app_url: str, seeded_kb: None) -> None:
+ page.set_default_timeout(30_000)
+ page.goto(app_url)
+
+ # The long answer streams to completion with its sources ...
+ submit(page, LONG_QUESTION)
+ wait_settled(page)
+ expect(page.locator(".msg.brain .bubble").last).to_contain_text(LONG_ANSWER_END)
+ expect(
+ page.locator(".msg.brain .source-chip", has_text="kubernetes.md")
+ ).to_have_count(1)
+
+ # ... and a thinking turn completes with its block auto-collapsed
+ # (phase 17: open while streaming, closed from the first delta on).
+ submit(page, THINKING_QUESTION)
+ wait_settled(page)
+ details = page.locator(".msg.brain").last.locator("details.thinking")
+ expect(details).not_to_have_attribute("open")
+ expect(details.locator(".thinking-text")).to_contain_text(THINKING_FRAGMENT)
+ expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER)
+
+ # Persistence: four messages, the thinking text + sources stored raw.
+ raw = page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')")
+ stored = json.loads(raw)
+ assert [m["who"] for m in stored["messages"]] == ["user", "brain", "user", "brain"]
+ assert LONG_ANSWER_END in stored["messages"][1]["text"]
+ assert THINKING_FRAGMENT in stored["messages"][3]["thinking"]
+ assert any(
+ s["path"] == "homelab/kubernetes.md" for s in stored["messages"][3]["sources"]
+ )
+
+ # Restore: the long answer (with its chip) and the COLLAPSED thinking
+ # block come back intact.
+ page.reload()
+ expect(page.locator(".msg.user .bubble")).to_have_count(2)
+ expect(page.locator(".msg.brain .bubble")).to_have_count(2)
+ expect(page.locator(".msg.brain .bubble").first).to_contain_text(LONG_ANSWER_END)
+ expect(
+ page.locator(".msg.brain .source-chip", has_text="kubernetes.md")
+ ).to_have_count(2)
+ restored = page.locator(".msg.brain").last.locator("details.thinking")
+ expect(restored).not_to_have_attribute("open")
+ expect(restored.locator(".thinking-text")).to_contain_text(THINKING_FRAGMENT)
+ wait_settled(page)
diff --git a/tests/unit/test_chat_persistence.py b/tests/unit/test_chat_persistence.py
index f5ee89d..2a0d6d5 100644
--- a/tests/unit/test_chat_persistence.py
+++ b/tests/unit/test_chat_persistence.py
@@ -85,10 +85,13 @@ 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()
- # 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
+ # Phase 42 (owner direction 2026-08-27): the reply autoscroll is gone;
+ # restore landings keep their one-shot load-time scroll via the
+ # explicit intent (scroll=true) — the new addMessage signature has no
+ # per-call behavior override (default SCROLL instead of forced
+ # "auto" — documented at the call site).
+ assert 'addMessage("user", renderMarkdown(m.text), true)' in js
+ assert 'addMessage("brain", renderMarkdown(m.text), 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
index 094b4b6..c678c7e 100644
--- a/tests/unit/test_frontend_scroll.py
+++ b/tests/unit/test_frontend_scroll.py
@@ -1,11 +1,21 @@
-"""Unit: the follow-the-bottom scroll contract in the static frontend
-(phase 18, owner choice 2026-08-23).
+"""Unit: the no-reply-autoscroll contract in the static frontend
+(phase 42, owner direction 2026-08-27, TODO.md L5).
-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.
+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).
+
+The JS behavior itself is E2E-covered
+(tests/e2e/test_no_reply_autoscroll.py); here we pin the source markers
+of the new contract — the phase-18 gate is gone (no NEAR_BOTTOM_PX /
+isNearBottom), scrollReveal scrolls unconditionally and is the single
+scrollIntoView in app.js, 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 is caught without a
+browser.
"""
from __future__ import annotations
@@ -28,109 +38,140 @@ def _fn_body(js: str, name: str) -> str:
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."""
+def test_phase18_gate_is_gone() -> None:
+ """The follow-the-bottom machinery (phase 18) is removed by owner
+ direction 2026-08-27: no band constant, no gate function, and no
+ document-scroller measurement left anywhere in app.js."""
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`"
+ assert "NEAR_BOTTOM_PX" not in js, "the 200px 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_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."""
+def test_scroll_helper_is_unconditional() -> None:
+ """scrollReveal is still the ONE scrollIntoView in app.js, but it now
+ scrolls unconditionally — no force-or-near-bottom condition in its
+ body, and the phase-18 `force` parameter is gone. A page scroll can
+ only ever happen where scrollReveal is CALLED (submit + restore)."""
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, (
+ 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"
+ # Still smooth / reduced-motion-aware through the default behavior.
+ assert "behavior = SCROLL" in body
+ # The regression pin: exactly one actual scrollIntoView CALL in the
+ # whole file, and it lives inside scrollReveal (the word may appear
+ # in comments; the call must not).
+ 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
+ assert js.find(".scrollIntoView(") > js.find("function scrollReveal")
-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."""
+def test_add_message_takes_explicit_scroll_intent() -> None:
+ """addMessage(who, html, scroll = false): the phase-18
+ scrollBehavior/force parameters are gone; the bubble scrolls only
+ when the caller explicitly asks (submit reveal, restore landing)."""
+ js = _js()
+ body = _fn_body(js, "addMessage")
+ assert "function addMessage(who, html, scroll = false)" 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
+ function never do."""
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)"
+ body = js[send : js.find("\n}\n", send)]
+ assert 'addMessage("user", renderMarkdown(text), true)' in 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_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)."""
+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), "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"
+ assert 'addMessage("user", renderMarkdown(m.text), true)' in body
+ assert 'addMessage("brain", renderMarkdown(m.text), 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.
+ assert js.count(", true)") == 3, "only submit + the two restore calls may scroll"
+ # The marker comment documents the one-shot, load-time contract.
+ assert "restore landing" in body
+ assert "one-shot" in body
-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."""
+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()
- 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
+ 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 — the exact
- defect phase 18 removes. preventScroll keeps the keyboard flow.
- startNewChat keeps plain focus (the list is cleared, nothing to yank
- past)."""
+ 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."""
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"