chore(agent): track .agent/ planning tree in git
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.
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
# 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).
|
||||
Reference in New Issue
Block a user