fix(chat): stop autoscrolling while a reply streams (owner direction)

TODO.md L5: "Get rid of the chat reply autoscroll, it's breaking things
like making it impossible for the user to scroll while a reply
generates." Owner direction 2026-08-27 (roadmap A1) revises the
phase-18 follow-the-bottom choice: the page NEVER auto-scrolls while a
turn streams. Kept (owner decision): the submit reveal (the user's own
message) and the one-shot phase-14 restore landing.

- frontend/assets/app.js: delete NEAR_BOTTOM_PX + isNearBottom;
  scrollReveal becomes the one unconditional scrollIntoView (still
  smooth, still "auto" under prefers-reduced-motion via SCROLL);
  addMessage(who, html, scroll = false) carries an explicit scroll
  intent — only the submit (", true") and the two restore landings
  scroll. The thinking/tool/delta handlers and the typing indicator
  drop their page-scroll calls; the thinking block's INTERNAL
  bottom-pin (textEl.scrollTop, phase 17 — reworked separately in
  phase 43) and the turn-end focus({ preventScroll: true }) survive.
- tests/unit/test_frontend_scroll.py: rewritten pin for the new
  contract — phase-18 gate absent, helper unconditional, explicit
  intent at submit/restore, no page-scroll call in the streaming
  handlers, typing bubble scroll-free, SCROLL reduced-motion intact.
- tests/unit/test_chat_persistence.py: restore-landing pin updated to
  the new signature (the old forced "auto" is gone; the landing
  rides the default SCROLL — noted at the call site).
- tests/e2e/test_no_reply_autoscroll.py (new, replaces the deleted
  test_follow_bottom_scroll.py): no autoscroll across >=10 samples
  (1px tolerance) during a long answer and during the thinking stream;
  submit-from-the-top still reveals the user message; the restore
  landing lands one-shot on the latest message and stays; long answer
  + sources and the collapsed thinking block persist and restore.

E2E (isolation): test_no_reply_autoscroll.py 5/5; regressions
test_chat_rag 3/3, test_thinking_display 5/5,
test_chat_persistence 4/4, test_long_answers 2/2, test_smoke 3/3;
unit+integration 723 passed, app/ coverage 99%; ruff + pyright clean.
This commit is contained in:
2026-08-28 00:29:25 -04:00
parent 6cf1df9bf2
commit 7c6763319b
5 changed files with 673 additions and 489 deletions
+46 -46
View File
@@ -70,15 +70,17 @@
* (announceSteering()) through the module. Note text is always rendered * (announceSteering()) through the module. Note text is always rendered
* with textContent (XSS-safe) in both places. * with textContent (XSS-safe) in both places.
* *
* Scroll (phase 18, owner choice 2026-08-23): the page auto-scrolls only * No reply autoscroll (owner direction 2026-08-27, TODO.md L5 —
* while the user is pinned to the bottom. NEAR_BOTTOM_PX (200px) covers * revising the phase 18 follow-the-bottom choice): the page NEVER
* the composer zone — the textarea auto-grows to 192px plus the button * auto-scrolls while a turn streams — no thinking, tool, or delta frame
* row — so "the composer is in view" counts as pinned: submitting from * moves the viewport, so scrolling up to read earlier content holds for
* the composer reveals your own message, and the answer follows token by * the rest of the turn. The only scroll call sites are user intent: the
* token while you stay pinned. Once you scroll up to read earlier * submit (your own message is revealed) and the phase-14 restore landing
* content, nothing drags the viewport back down for the rest of the turn * (one-shot, load-time). scrollReveal(wrap) is the one scrollIntoView in
* (thinking or answer). scrollReveal(wrap) is the single scroll gate; * this file; addMessage(who, html, scroll) carries the intent. The
* `force` is reserved for the one-shot phase-14 restore landing. * 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 * Document modal (phase 26): a source chip opens the cited document in
* the almost-fullscreen modal overlay (assets/document-modal.js) on the * 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; typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
const SCROLL = reducedMotion ? "auto" : "smooth"; const SCROLL = reducedMotion ? "auto" : "smooth";
/* Follow-the-bottom scroll contract (phase 18, owner choice /* No reply autoscroll (owner direction 2026-08-27, TODO.md L5 —
* 2026-08-23): the page auto-scrolls only while the user is pinned * revising the phase 18 follow-the-bottom choice): the page never
* at the bottom — the 200px band covers the composer zone (the * auto-scrolls while a turn streams. The only scroll call sites are
* textarea auto-grows to 192px + the button row), i.e. "the * the user submit (reveal my message) and the phase-14 restore
* composer is in view". Exported so the band is unit-pinned (same * landing (one-shot, load-time). */
* pattern as TURN_TIMEOUT_MS). */
export const NEAR_BOTTOM_PX = 200;
function isNearBottom() { /* The ONE scrollIntoView in this file — unconditional (unit-pinned):
const bottom = * scrollReveal scrolls whenever it is called, so a page scroll can only
document.documentElement.scrollHeight - window.scrollY - window.innerHeight; * ever happen from those two user-intent call sites. */
return bottom <= NEAR_BOTTOM_PX; function scrollReveal(wrap, behavior = SCROLL) {
} wrap.scrollIntoView({ behavior, block: "end" });
/* 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`) ---------- /* ---------- document viewer link (phase 10; phase 13 adds `back`) ----------
@@ -339,10 +332,12 @@ const USER_AVATAR =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="8" r="3.6"/><path d="M4.8 20.2c.9-3.9 3.8-6 7.2-6s6.3 2.1 7.2 6"/></svg>'; '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="8" r="3.6"/><path d="M4.8 20.2c.9-3.9 3.8-6 7.2-6s6.3 2.1 7.2 6"/></svg>';
/* ---------- messages ---------- /* ---------- messages ----------
* Scroll is conditional (phase 18): addMessage reveals through * Scroll is explicit intent (phase 42, no reply autoscroll): addMessage
* scrollReveal — only when the user is pinned to the bottom, or when * scrolls only when the caller passes `scroll = true` — the user submit
* forced (the one-shot phase-14 restore landing). */ * (reveal my message) and the phase-14 restore landing. The streaming
function addMessage(who, html, scrollBehavior = SCROLL, force = false) { * 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; if (emptyState) emptyState.hidden = true;
const wrap = document.createElement("div"); const wrap = document.createElement("div");
wrap.className = `msg ${who}`; wrap.className = `msg ${who}`;
@@ -352,7 +347,7 @@ function addMessage(who, html, scrollBehavior = SCROLL, force = false) {
<div class="bubble">${html}</div> <div class="bubble">${html}</div>
</div>`; </div>`;
messagesEl.appendChild(wrap); messagesEl.appendChild(wrap);
scrollReveal(wrap, scrollBehavior, force); if (scroll) scrollReveal(wrap);
return wrap; return wrap;
} }
@@ -370,7 +365,7 @@ function addTyping() {
</div> </div>
</div>`; </div>`;
messagesEl.appendChild(wrap); messagesEl.appendChild(wrap);
scrollReveal(wrap); // No page scroll (phase 42): a typing bubble must not yank the viewport.
} }
function removeTyping() { function removeTyping() {
@@ -765,14 +760,17 @@ export function clearStoredConversation() {
} }
function renderStoredMessage(m) { function renderStoredMessage(m) {
// Phase 18: the restore landing is the only `force`d scroll — one-shot, // Phase-14 restore landing (kept by the phase-42 direction): the
// non-smooth, so a restored conversation lands on its latest message // one-shot load-time scroll — scroll=true so a restored conversation
// (phase-14 behavior preserved) without smooth-scrolling through it. // 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") { if (m.who === "user") {
addMessage("user", renderMarkdown(m.text), "auto", true); addMessage("user", renderMarkdown(m.text), true);
return; return;
} }
const wrap = addMessage("brain", renderMarkdown(m.text), "auto", true); const wrap = addMessage("brain", renderMarkdown(m.text), true);
if (m.thinking) { if (m.thinking) {
// Phase 17: restore the thinking block COLLAPSED above the bubble. // Phase 17: restore the thinking block COLLAPSED above the bubble.
const block = ensureThinkingBlock(wrap); const block = ensureThinkingBlock(wrap);
@@ -893,7 +891,7 @@ async function handleSend(e) {
const text = input.value.trim(); const text = input.value.trim();
if (!text || sendBtn.disabled) return; 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 // Persistence save point 1: the question is stored the moment it is
// sent, so a failed/interrupted turn never loses it. // sent, so a failed/interrupted turn never loses it.
conversation.push({ who: "user", text }); conversation.push({ who: "user", text });
@@ -958,8 +956,9 @@ async function handleSend(e) {
const textEl = block.querySelector(".thinking-text"); const textEl = block.querySelector(".thinking-text");
textEl.innerHTML = renderMarkdown(thinkingAcc); // escape-first, XSS-safe textEl.innerHTML = renderMarkdown(thinkingAcc); // escape-first, XSS-safe
if (block.open) { if (block.open) {
textEl.scrollTop = textEl.scrollHeight; // pin the stream to the bottom // Pin the block's OWN stream (phase 17 — reworked in phase 43);
scrollReveal(wrap); // page follows only while pinned (phase 18) // the page never follows (phase 42, no reply autoscroll).
textEl.scrollTop = textEl.scrollHeight;
} }
} else if (ev.type === "tool") { } else if (ev.type === "tool") {
// Phase 37 (PLAN §4 extension): an agent tool call. The UI // Phase 37 (PLAN §4 extension): an agent tool call. The UI
@@ -988,14 +987,14 @@ async function handleSend(e) {
?.setAttribute("aria-label", toolStatus); ?.setAttribute("aria-label", toolStatus);
} }
appendToolLine(wrap, name, argument); 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") { } else if (ev.type === "delta") {
acc += ev.text || ""; acc += ev.text || "";
if (uiState === UI_STATE.thinking) setUiState(UI_STATE.streaming); if (uiState === UI_STATE.thinking) setUiState(UI_STATE.streaming);
if (!wrap) wrap = addMessage("brain", ""); // first token: live bubble in if (!wrap) wrap = addMessage("brain", ""); // first token: live bubble in
closeThinkingBlock(wrap); // auto-collapse; idempotent, never reopens closeThinkingBlock(wrap); // auto-collapse; idempotent, never reopens
wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc); 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") { } else if (ev.type === "done") {
sawDone = true; sawDone = true;
closeThinkingBlock(wrap); // the turn is over: settle the block closed closeThinkingBlock(wrap); // the turn is over: settle the block closed
@@ -1062,8 +1061,9 @@ async function handleSend(e) {
stopThinkingClock(); stopThinkingClock();
cancelStream(res); // the reader lock is released — no unhandled rejection cancelStream(res); // the reader lock is released — no unhandled rejection
if (uiState !== UI_STATE.idle) setUiState(UI_STATE.idle); if (uiState !== UI_STATE.idle) setUiState(UI_STATE.idle);
// Phase 18: focus back for the next question, but never move the // Focus back for the next question, but never move the viewport —
// viewport — a user reading earlier content stays where they are. // the page never auto-scrolls (phase 42), so a user reading earlier
// content stays where they are.
input.focus({ preventScroll: true }); input.focus({ preventScroll: true });
} }
} }
-356
View File
@@ -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)
+496
View File
@@ -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)
+7 -4
View File
@@ -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 on restore) — no HTML is ever stored. Restore re-applies the full
brain-message chrome: is-deflected styling, maybe-try chips, sources.""" brain-message chrome: is-deflected styling, maybe-try chips, sources."""
js = _js() js = _js()
# Phase 18: restore landings are forced ("auto" + force) one-shot # Phase 42 (owner direction 2026-08-27): the reply autoscroll is gone;
# scrollReveal calls — the only forced scrolls in the app. # restore landings keep their one-shot load-time scroll via the
assert 'addMessage("user", renderMarkdown(m.text), "auto", true)' in js # explicit intent (scroll=true) — the new addMessage signature has no
assert 'addMessage("brain", renderMarkdown(m.text), "auto", true)' in js # 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 "wrap.classList.add(\"is-deflected\")" in js
assert "appendMaybeTry(wrap, m.suggestions)" in js assert "appendMaybeTry(wrap, m.suggestions)" in js
assert "appendSources(wrap, m.sources)" in js assert "appendSources(wrap, m.sources)" in js
+124 -83
View File
@@ -1,11 +1,21 @@
"""Unit: the follow-the-bottom scroll contract in the static frontend """Unit: the no-reply-autoscroll contract in the static frontend
(phase 18, owner choice 2026-08-23). (phase 42, owner direction 2026-08-27, TODO.md L5).
The JS behavior itself is E2E-covered (tests/e2e/test_follow_bottom_scroll.py); The owner removed the phase-18 follow-the-bottom auto-follow: the page
here we pin the exported band constant and the single-gate markers that the NEVER auto-scrolls while a turn streams (thinking / tool / delta frames
story depends on — scrollIntoView appears exactly once in app.js, inside all leave the viewport alone), so a user reading earlier content is no
scrollReveal — so a silent regression back to unconditional per-delta / longer yanked down mid-answer. Scrolls happen only on explicit user
per-chunk scrolls is caught without a browser. 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 from __future__ import annotations
@@ -28,109 +38,140 @@ def _fn_body(js: str, name: str) -> str:
return js[fn : js.find("\n}\n", fn)] return js[fn : js.find("\n}\n", fn)]
def test_near_bottom_constant_exported_at_200px() -> None: def test_phase18_gate_is_gone() -> None:
"""The "pinned to the bottom" band (the composer zone) must be an """The follow-the-bottom machinery (phase 18) is removed by owner
*exported* constant — unit-pinned, same pattern as TURN_TIMEOUT_MS.""" direction 2026-08-27: no band constant, no gate function, and no
document-scroller measurement left anywhere in app.js."""
js = _js() js = _js()
assert re.search(r"export\s+const\s+NEAR_BOTTOM_PX\s*=\s*200\s*;", js), ( assert "NEAR_BOTTOM_PX" not in js, "the 200px band constant must be gone"
"app.js must export `const NEAR_BOTTOM_PX = 200`" 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: def test_scroll_helper_is_unconditional() -> None:
"""isNearBottom measures the DOCUMENT scroller (there is no inner """scrollReveal is still the ONE scrollIntoView in app.js, but it now
scroll container — the page scrolls on the window): distance from the scrolls unconditionally — no force-or-near-bottom condition in its
bottom of the document <= NEAR_BOTTOM_PX.""" body, and the phase-18 `force` parameter is gone. A page scroll can
js = _js() only ever happen where scrollReveal is CALLED (submit + restore)."""
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() js = _js()
body = _fn_body(js, "scrollReveal") body = _fn_body(js, "scrollReveal")
assert "force || isNearBottom()" in body, "gate: force OR pinned to the bottom"
assert "scrollIntoView" in body assert "scrollIntoView" in body
assert 'block: "end"' in body assert 'block: "end"' in body
# The regression pin: exactly one scrollIntoView in the whole file, and assert "if (" not in body, "the helper must have no gate — it scrolls when called"
# it lives inside scrollReveal. assert "force" not in body, "the phase-18 force parameter must be gone"
assert js.count("scrollIntoView") == 1, ( # 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)" "app.js must call scrollIntoView exactly once (inside scrollReveal)"
) )
assert js.find("scrollIntoView") > js.find("function 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: def test_add_message_takes_explicit_scroll_intent() -> None:
"""Submit keeps the plain default call — no force: the gate decides, """addMessage(who, html, scroll = false): the phase-18
and it does in real use because submitting from the composer means the scrollBehavior/force parameters are gone; the bubble scrolls only
user is pinned (inside the 200px band); a submit with the viewport away when the caller explicitly asks (submit reveal, restore landing)."""
from the bottom does not yank it.""" 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() js = _js()
send = js.find("async function handleSend") send = js.find("async function handleSend")
assert send != -1, "handleSend must exist" assert send != -1, "handleSend must exist"
call = 'addMessage("user", renderMarkdown(text));' body = js[send : js.find("\n}\n", send)]
idx = js.find(call, send) assert 'addMessage("user", renderMarkdown(text), true)' in body, (
assert idx != -1, "handleSend must reveal the user message via the plain default" "the submit must reveal the user message (scroll intent true)"
assert 'addMessage("user", renderMarkdown(text),' not in js, (
"the submit call must not pass a third/fourth argument (no force)"
) )
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: def test_streaming_handlers_never_scroll_the_page() -> None:
"""Both restore call sites are the only `force`d scrolls: one-shot, """The heart of the phase-42 contract: the thinking / tool / delta
non-smooth ("auto") landing on the last restored message (phase-14 branches contain NO page-scroll call (no scrollReveal, no raw
behavior preserved).""" 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() js = _js()
body = _fn_body(js, "renderStoredMessage") body = _fn_body(js, "renderStoredMessage")
assert 'addMessage("user", renderMarkdown(m.text), "auto", true)' in body assert 'addMessage("user", renderMarkdown(m.text), true)' in body
assert 'addMessage("brain", renderMarkdown(m.text), "auto", true)' in body assert 'addMessage("brain", renderMarkdown(m.text), true)' in body
# Forced restores are restore-only: exactly two ("auto", true) sites. assert js.count('"auto", true') == 0, "the old forced 'auto' landing must be gone"
assert js.count('"auto", true') == 2, "only the two restore calls may force" # 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: def test_typing_bubble_does_not_scroll() -> None:
"""The per-chunk scrolls that used to yank the viewport (the phase-17 """A typing indicator appearing must not yank the page — the phase-18
thinking branch and the streaming delta branch) now go through scrollReveal call in addTyping is removed with the gate."""
scrollReveal with no raw scrollIntoView at either call site; the
block's internal bottom-pinning (its own overflow, not the page) stays."""
js = _js() js = _js()
thinking_idx = js.find('ev.type === "thinking"') body = _fn_body(js, "addTyping")
delta_idx = js.find('ev.type === "delta"') assert "scrollReveal" not in body
done_idx = js.find('ev.type === "done"') assert ".scrollIntoView(" not in body
assert -1 < thinking_idx < delta_idx < done_idx
thinking_branch = js[thinking_idx:delta_idx]
delta_branch = js[delta_idx:done_idx] def test_scroll_constant_reduced_motion_intact() -> None:
assert "scrollReveal(wrap)" in thinking_branch """The SCROLL constant is untouched (calm, don't remove): smooth by
assert "scrollReveal(wrap)" in delta_branch default, "auto" under prefers-reduced-motion — the two kept scroll
assert "scrollIntoView" not in thinking_branch call sites ride it as the default behavior."""
assert "scrollIntoView" not in delta_branch js = _js()
assert "textEl.scrollTop = textEl.scrollHeight" in thinking_branch 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: def test_turn_end_focus_does_not_scroll() -> None:
"""The turn-end focus-back (phase 06's "always focus back") must not """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 move the viewport: focusing the composer while the user is scrolled
would yank them to the bottom at the moment the turn ends — the exact up would yank them to the bottom at the moment the turn ends.
defect phase 18 removes. preventScroll keeps the keyboard flow. preventScroll keeps the keyboard flow without the scroll."""
startNewChat keeps plain focus (the list is cleared, nothing to yank
past)."""
js = _js() js = _js()
finally_idx = js.find("// done | error → idle: always settle, always focus back") finally_idx = js.find("// done | error → idle: always settle, always focus back")
assert finally_idx != -1, "the turn's finally block must exist" assert finally_idx != -1, "the turn's finally block must exist"