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.
108 lines
6.0 KiB
Markdown
108 lines
6.0 KiB
Markdown
# 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).
|