Remove the blanket .agent/ gitignore so the phase roadmap, user stories, reports, and PLAN.md are versioned with the code. Only runtime artifacts (.agent/phase-sessions/, .agent/pipeline.log) remain ignored. Update AGENTS.md git protocol rule to match.
6.0 KiB
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
- Submitting a question always reveals the user's own message (explicit action, unconditional).
- 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). - 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.
- The thinking block's internal text still bottom-pins itself while open (that is the block's own overflow, not the page).
- Restoring a stored conversation still lands on the latest message (one-shot, non-smooth).
- 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 (
bodyismin-height: 100dvh; the page scrolls on the window). All measurements go throughwindow.scrollY,document.documentElement.scrollHeight, andwindow.innerHeight. - The gate:
scrollReveal(wrap, behavior = SCROLL, force = false)is the singlescrollIntoViewcall site inapp.js— it fires only whenforceis set orisNearBottom()is true.NEAR_BOTTOM_PX = 200is exported and unit-pinned (same pattern asTURN_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
deltabranch, and the phase-17thinkingbranch — all go through the gate. The onlyforced callers are the two phase-14 restore landings ("auto", true— one-shot, load-time, non-smooth). - Reused unchanged: the
SCROLLconstant (smooth, orautounderprefers-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-textbottom-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):
test_submit_reveals_new_message— fresh chat page (pinned at the bottom by default); submitLONG_QUESTION; once settled, the last.msg.brainis inside the viewport (bounding box) andnear_bottom(scroll_state(page)).test_stream_follows_while_pinned_at_bottom— fresh page; submitLONG_QUESTION; ~2s into the stream (poll until the last brain bubble's text length > 200), assertnear_bottom(scroll_state(page))— the follow behavior is alive, not accidentally removed; at settle, still near bottom.test_no_yank_while_scrolled_up_during_answer_stream— submitLONG_QUESTION(fresh page, pinned — normal flow; once settled, the document overflows the 800px viewport — assertsh > ch); submit a secondLONG_QUESTION; wait until the new brain bubble's text length200 (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); assertscroll_state(page)["y"] <= 5(viewport held); wait for settle; asserty <= 5again (no scroll happened for the rest of the turn — the answer finished off-screen below, by design).test_no_yank_while_scrolled_up_during_thinking— one settled turn first (overflow exists); submitTHINK_LONG_QUESTION(normal flow, pinned); wait fordetails.thinkingin the last.msg.brainto attach (thinking is streaming — phase-17 behavior) and is open;window.scrollTo(0, 0); wait until the answer.bubbletext is non-empty (the whole thinking stream plus the answer's start happened at the top); asserty <= 5; wait for settle; asserty <= 5and that the thinking text containsStep 2: Check my notesand the bubble is filled (all present but off-screen — the point of the story).test_restore_lands_on_latest_message— two settled turns (user + brain × 2, overflow);page.reload(); after restore, the last.msg.brainis inside the viewport andnear_bottom(scroll_state(page))(phase-14 one-shot landing preserved — pinned so a future "remove all scrolling" change fails loudly instead of silently).