# Story: Follow-the-Bottom Scroll **Phase:** `18_follow_bottom_scroll` · **E2E:** `tests/e2e/test_follow_bottom_scroll.py` ## Narrative As **a user**, Brain's answers (and its thinking) stream for 10–30 seconds. I want to scroll up and read earlier messages — or the top of a long thinking block — without the page dragging me back to the bottom token by token; but when I submit, I should still see my message and the reply appear. - **Given** a turn is streaming (thinking, or answering) and I scroll up to read - **When** the stream keeps going - **Then** the viewport holds still for the rest of the turn — nothing auto-scrolls — while submitting (I'm pinned at the composer) still reveals my message, and the reply follows into view while I stay pinned. ## Acceptance criteria 1. Submitting a question always reveals the user's own message (explicit action, unconditional). 2. While the user is pinned to the bottom (within 200px of it — the composer zone), the typing indicator, thinking chunks, and answer deltas follow into view (smooth, or instant under `prefers-reduced-motion`). 3. Once the user scrolls up (more than 200px from the bottom), nothing auto-scrolls for the rest of the turn — thinking or answer; the viewport position is unchanged at turn end. 4. The thinking block's *internal* text still bottom-pins itself while open (that is the block's own overflow, not the page). 5. Restoring a stored conversation still lands on the latest message (one-shot, non-smooth). 6. No new UI surface (no "new content" pill — owner chose the minimal contract, 2026-08-23); no other page is affected. ## UI Visualization & Structure - **Scroller:** the document itself — there is no inner overflow container (`body` is `min-height: 100dvh`; the page scrolls on the window). All measurements go through `window.scrollY`, `document.documentElement.scrollHeight`, and `window.innerHeight`. - **The gate:** `scrollReveal(wrap, behavior = SCROLL, force = false)` is the **single** `scrollIntoView` call site in `app.js` — it fires only when `force` is set or `isNearBottom()` is true. `NEAR_BOTTOM_PX = 200` is exported and unit-pinned (same pattern as `TURN_TIMEOUT_MS`); the 200px band ≈ the composer zone (the textarea auto-grows to 192px plus the button row), so "the composer is in view" counts as pinned — exactly where the user sits when they submit. Scrolling up into the conversation (≫200px from the bottom) leaves the band. - **Call sites:** the user's own message on submit (the plain gated default — no force; the user submits from the composer, i.e. pinned, so the message reveals), the typing indicator, the streaming `delta` branch, and the phase-17 `thinking` branch — all go through the gate. The only `force`d callers are the two phase-14 restore landings (`"auto", true` — one-shot, load-time, non-smooth). - **Reused unchanged:** the `SCROLL` constant (smooth, or `auto` under `prefers-reduced-motion` — "calm, don't remove") still controls the *feel* of a follow scroll; reduced-motion handling is untouched. The thinking block's internal `.thinking-text` bottom-pinning (scrollTop of the block's own overflow) stays as-is — that is the block's element, not the page. - **No new UI surface:** no "↓ new content" pill (owner chose the minimal contract, 2026-08-23 — a follow-up phase if ever wanted), no CSS or HTML template changes, no new DOM nodes, no other page affected (Sources, document viewer, and login have no vertical auto-scroll). ## Playwright Mapping Rule **Test Scenario → `tests/e2e/test_follow_bottom_scroll.py`** (mock LLM — `write a long answer` long-answer trigger, plus the phase-17 `think out loud` trigger for scenario 4; mock-only by design, since `E2E_REAL_LLM=1` would make the scroll-away windows unpredictable): 1. `test_submit_reveals_new_message` — fresh chat page (pinned at the bottom by default); submit `LONG_QUESTION`; once settled, the last `.msg.brain` is inside the viewport (bounding box) and `near_bottom(scroll_state(page))`. 2. `test_stream_follows_while_pinned_at_bottom` — fresh page; submit `LONG_QUESTION`; ~2s into the stream (poll until the last brain bubble's text length > 200), assert `near_bottom(scroll_state(page))` — the follow behavior is alive, not accidentally removed; at settle, still near bottom. 3. `test_no_yank_while_scrolled_up_during_answer_stream` — submit `LONG_QUESTION` (fresh page, pinned — normal flow; once settled, the document overflows the 800px viewport — assert `sh > ch`); submit a second `LONG_QUESTION`; wait until the new brain bubble's text length > 200 (streaming has started); `window.scrollTo(0, 0)` (the user goes up to read); wait until the bubble's text length > 600 (the stream kept running while the viewport was at the top); assert `scroll_state(page)["y"] <= 5` (viewport held); wait for settle; assert `y <= 5` again (no scroll happened for the rest of the turn — the answer finished off-screen below, by design). 4. `test_no_yank_while_scrolled_up_during_thinking` — one settled turn first (overflow exists); submit `THINK_LONG_QUESTION` (normal flow, pinned); wait for `details.thinking` in the last `.msg.brain` to attach (thinking is streaming — phase-17 behavior) and is open; `window.scrollTo(0, 0)`; wait until the answer `.bubble` text is non-empty (the whole thinking stream plus the answer's start happened at the top); assert `y <= 5`; wait for settle; assert `y <= 5` and that the thinking text contains `Step 2: Check my notes` and the bubble is filled (all present but off-screen — the point of the story). 5. `test_restore_lands_on_latest_message` — two settled turns (user + brain × 2, overflow); `page.reload()`; after restore, the last `.msg.brain` is inside the viewport and `near_bottom(scroll_state(page))` (phase-14 one-shot landing preserved — pinned so a future "remove all scrolling" change fails loudly instead of silently).