chore(agent): track .agent/ planning tree in git
Build and Push Containers / build-and-push-app (push) Successful in 12s
Build and Push Containers / build-and-push-db (push) Successful in 10s

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:
2026-09-01 10:18:22 -04:00
parent 5fa620fde5
commit 4971e2859d
818 changed files with 23964 additions and 4 deletions
+107
View File
@@ -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).