Files
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

108 lines
6.0 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).