Files
brain-of-reese/.agents/user_stories/follow-bottom-scroll.md
T
ducoterra dbf2af26c6 refactor(agents): migrate .agent/ planning tree to .agents/
Standardize on the .agents/ directory (shared with project skills):
phases/, user_stories/, reports/, screenshots/, validate.sh, and
phase-sessions/ + pipeline.log all move to .agents/ (git mv preserves
history; runtime artifacts move alongside).

Updates every reference in AGENTS.md, README.md, .gitignore, app
docstrings, and test story headers. Historical KB content in data/
and the runtime pipeline.log transcript are left untouched.
2026-09-05 10:57:07 -04:00

6.0 KiB
Raw Blame History

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 forced 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).