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.
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
# Phase 18 — Scroll Control: Follow the Bottom (No Yank While Reading)
|
||||
|
||||
**Story:** `.agents/user_stories/follow-bottom-scroll.md` (created by task 03)
|
||||
**Context:** `frontend/assets/app.js` — every `scrollIntoView` call site
|
||||
(`addMessage`, `addTyping`, the streaming `delta` branch, and the
|
||||
phase-17 `thinking` branch), the `SCROLL`/`reducedMotion` constants, and
|
||||
the phase-14 restore path; PLAN §7.4 ("never stale" feedback contract).
|
||||
|
||||
## Objective
|
||||
The chat must stop yanking the viewport. Today `app.js` calls
|
||||
`scrollIntoView` on **every message added, on the typing indicator, and
|
||||
on every streaming delta** — so a user who scrolls up to read earlier
|
||||
messages (or the top of a long thinking block) is dragged back to the
|
||||
bottom token by token. This phase implements the owner-chosen
|
||||
**follow-the-bottom** contract (owner choice 2026-08-23, option 1 of the
|
||||
two presented — no "↓ new content" pill): the page auto-scrolls *only
|
||||
while the user is already pinned at the bottom*; submitting a question
|
||||
still reveals the user's own message; once the user scrolls up, nothing
|
||||
auto-scrolls for the rest of the turn (thinking or answer); a restored
|
||||
conversation still lands on the latest message.
|
||||
|
||||
## Dependencies
|
||||
- `17_thinking_display` (**todo — must complete first**): its task 02
|
||||
adds the `thinking` branch that also scrolls per chunk; this phase
|
||||
gates that call site too. The pipeline runs phases in numeric order, so
|
||||
17 lands before 18 by construction.
|
||||
- `14_chat_persistence` (complete) — the restore path keeps its
|
||||
one-shot "land on the latest message" behavior (now via forced
|
||||
reveals).
|
||||
- `06_story_loading_feedback` (complete) — the state machine and the
|
||||
`SCROLL` smooth/auto constant are reused, not changed.
|
||||
- `08_story_dark_tech_theme` (complete) — no new UI surface, so no new
|
||||
tokens.
|
||||
|
||||
## Design
|
||||
- **Scroller:** the document itself (there is no inner scroll container —
|
||||
`body` is `min-height: 100dvh` and the page scrolls on the window).
|
||||
All measurements go through `window.scrollY` /
|
||||
`document.documentElement.scrollHeight` / `window.innerHeight`.
|
||||
- **New constants/helpers in `frontend/assets/app.js`:**
|
||||
- `export const NEAR_BOTTOM_PX = 200;` — the "pinned to the bottom"
|
||||
band (exported + unit-pinned, same pattern as `TURN_TIMEOUT_MS`).
|
||||
200px ≈ the composer zone (the textarea auto-grows to 192px plus
|
||||
the button row), so "the composer is fully 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.
|
||||
- `function isNearBottom()` —
|
||||
`document.documentElement.scrollHeight - window.scrollY -
|
||||
window.innerHeight <= NEAR_BOTTOM_PX`.
|
||||
- `function scrollReveal(wrap, behavior = SCROLL, force = false)` —
|
||||
the **single** scroll call site:
|
||||
`if (force || isNearBottom()) wrap.scrollIntoView({ behavior,
|
||||
block: "end" });` — `force` is used only by the phase-14 restore
|
||||
landing (one-shot, load-time).
|
||||
The existing `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.
|
||||
- **Call-site wiring (all in `app.js`):**
|
||||
- `addMessage(who, html, scrollBehavior = SCROLL, force = false)` —
|
||||
body ends with `scrollReveal(wrap, scrollBehavior, force)` (replaces
|
||||
the unconditional `wrap.scrollIntoView(…)`).
|
||||
- **Submit** (`handleSend`): the user's own message is revealed
|
||||
through the **same gate** (no force): `addMessage("user",
|
||||
renderMarkdown(text))`. In real use the user submits from the
|
||||
composer — i.e. they are pinned (within the 200px band) — so the
|
||||
message appears in view, per the owner's option-1 contract ("on
|
||||
send you still see your message and the answer appear"); a submit
|
||||
that happens with the viewport away from the bottom (only reachable
|
||||
artificially) does **not** yank it.
|
||||
- **Typing indicator** (`addTyping`): `scrollReveal(wrap)` — right
|
||||
after submit the user is pinned (visible); if they scroll up during
|
||||
pre-token "Thinking…", the indicator no longer drags them down.
|
||||
- **Streaming `delta` branch:** replace
|
||||
`wrap.scrollIntoView({ behavior: SCROLL, block: "end" })` with
|
||||
`scrollReveal(wrap)` — follows only while pinned.
|
||||
- **Phase-17 `thinking` branch:** same replacement
|
||||
(`scrollReveal(wrap)`); the block's internal
|
||||
`.thinking-text` bottom-pinning (scrollTop of the block's own
|
||||
overflow) stays as-is — that is the user's element, not the page.
|
||||
- **Restore (phase 14):** both restore call sites become
|
||||
`addMessage(…, "auto", true)` — one-shot, non-smooth, lands on the
|
||||
last restored message exactly as today (the only remaining `force`
|
||||
users; a load-time landing, not continuous auto-scroll —
|
||||
intentional, pinned by E2E test 5).
|
||||
- **Fallbacks** (empty-answer bubble, `done`-without-wrap "…"
|
||||
placeholder): `scrollReveal` with no force — appears if pinned,
|
||||
never yanks.
|
||||
- `startNewChat`: no change (the document shrinks; the browser clamps
|
||||
the stale scrollTop).
|
||||
- **After this phase, `scrollIntoView` appears exactly once in
|
||||
`app.js`** (inside `scrollReveal`) — the regression pin.
|
||||
- **Non-goals:** no "↓ new content" pill (owner opted against extras,
|
||||
2026-08-23 — a follow-up phase if ever wanted); no scroll-position
|
||||
persistence across reloads; no changes to other pages (Sources,
|
||||
document viewer, login have no vertical auto-scroll); no virtualized
|
||||
message list; no new DOM nodes, no CSS changes, no backend changes.
|
||||
|
||||
## Tasks
|
||||
1. `01_follow_bottom_scroll.md` — `app.js`: `NEAR_BOTTOM_PX`,
|
||||
`isNearBottom`, `scrollReveal`, all call sites rewired; new
|
||||
source-level unit pins.
|
||||
2. `02_e2e_story_suite.md` — `tests/e2e/test_follow_bottom_scroll.py`
|
||||
(5 scenarios, isolated run) + regression suites (incl. phase 17's
|
||||
`test_thinking_display.py` and phase 14's `test_chat_persistence.py`).
|
||||
3. `03_docs_plan_commit.md` — user story file, PLAN revisions
|
||||
(owner-permission noted), final validation, the single atomic commit,
|
||||
phase move to `complete/`.
|
||||
|
||||
## Locked decisions
|
||||
- **No anchor changes.** A11 (vanilla JS, no CDN) — pure `app.js`
|
||||
logic. A15/A16 — one new story E2E suite + unit pins, no API change.
|
||||
A10/A12/A13 — untouched. This is a UI behavior change only.
|
||||
|
||||
## Testing & Quality
|
||||
- **Unit:** `tests/unit/test_frontend_scroll.py` (new) — source-level
|
||||
pins: `export const NEAR_BOTTOM_PX = 200`; `function isNearBottom`;
|
||||
`function scrollReveal`; `scrollIntoView` occurs **exactly once** in
|
||||
`app.js` and only inside `scrollReveal`; the submit call is the plain
|
||||
default (gated, no force); restore call sites pass
|
||||
`("auto", true)`.
|
||||
- **Integration:** none (no `app/` code changes) —
|
||||
`uv run pytest --cov=app` must stay ≥ today's number.
|
||||
- **Coverage:** frontend-only phase; the `app/` >90% gate is unaffected
|
||||
but re-run to prove it.
|
||||
- **E2E:** `tests/e2e/test_follow_bottom_scroll.py` — five scenarios
|
||||
(see task 02), green **in isolation**
|
||||
(`uv run pytest tests/e2e/test_follow_bottom_scroll.py -v --no-cov`,
|
||||
prereq `podman compose up -d db`).
|
||||
- **Lint/types:** `uv run ruff check . && uv run pyright` clean.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest` green; `uv run pytest --cov=app
|
||||
--cov-report=term-missing` ≥ today's coverage (frontend-only
|
||||
phase — expect no change).
|
||||
- [ ] `uv run pytest tests/e2e/test_follow_bottom_scroll.py -v --no-cov`
|
||||
green in isolation (5/5).
|
||||
- [ ] Regression suites green **in isolation** (one command each):
|
||||
`test_thinking_display.py` (phase 17 — the thinking scroll it
|
||||
added is now gated), `test_chat_persistence.py` (restore landing),
|
||||
`test_loading_feedback.py`, `test_chat_rag.py`,
|
||||
`test_suggestion_chips.py`.
|
||||
- [ ] `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] Manual check (dev server): submit a question → own message +
|
||||
answer follow into view; scroll up mid-stream (or mid-thinking
|
||||
with a real aipi turn) → viewport holds still until the turn
|
||||
ends; "New chat" and reload behave as before.
|
||||
- [ ] UI Structure Check (AGENTS.md rule 5): no new UI surface —
|
||||
verify no CSS/HTML template change was needed;
|
||||
`prefers-reduced-motion` still respected (the `SCROLL` constant
|
||||
is untouched).
|
||||
- [ ] PLAN carries the revisions with the 2026-08-23 owner-choice
|
||||
wording; `.agents/user_stories/follow-bottom-scroll.md` exists.
|
||||
- [ ] One `--no-gpg-sign` commit (below);
|
||||
`.agents/phases/todo/18_follow_bottom_scroll/` moved to
|
||||
`.agents/phases/complete/`.
|
||||
|
||||
## Commit
|
||||
```bash
|
||||
git add -A .agents/ frontend/ tests/ && git commit --no-gpg-sign -m "feat(ui): chat auto-scrolls only while pinned to the bottom — submitting reveals your message, scrolling up holds the viewport"
|
||||
```
|
||||
@@ -0,0 +1,106 @@
|
||||
# Task 01 — app.js: single scroll gate (follow-the-bottom)
|
||||
|
||||
**Phase:** `18_follow_bottom_scroll` · **Story:** `.agents/user_stories/follow-bottom-scroll.md`
|
||||
|
||||
## Objective
|
||||
All chat-page scrolling goes through one gate — `scrollReveal` — which
|
||||
only fires when the user is pinned to the bottom (the 200px composer-zone
|
||||
band, `NEAR_BOTTOM_PX`) or when a call site forces it (restore only).
|
||||
The per-delta / per-thinking-chunk / typing-indicator scrolls that yank
|
||||
the viewport disappear.
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/app.js`
|
||||
- Next to the `SCROLL` constant, add:
|
||||
```js
|
||||
/* Follow-the-bottom scroll contract (phase 18, owner choice
|
||||
* 2026-08-23): the page auto-scrolls only while the user is pinned
|
||||
* at the bottom — the 200px band covers the composer zone (the
|
||||
* textarea auto-grows to 192px + the button row), i.e. "the
|
||||
* composer is in view". Exported so the band is unit-pinned (same
|
||||
* pattern as TURN_TIMEOUT_MS). */
|
||||
export const NEAR_BOTTOM_PX = 200;
|
||||
|
||||
function isNearBottom() {
|
||||
const doc = document.documentElement;
|
||||
return doc.scrollHeight - window.scrollY - window.innerHeight <= NEAR_BOTTOM_PX;
|
||||
}
|
||||
|
||||
/* The ONE scroll call site in this file. `force` is used only by
|
||||
* the phase-14 restore landing (one-shot, load-time). */
|
||||
function scrollReveal(wrap, behavior = SCROLL, force = false) {
|
||||
if (force || isNearBottom()) {
|
||||
wrap.scrollIntoView({ behavior, block: "end" });
|
||||
}
|
||||
}
|
||||
```
|
||||
- `addMessage(who, html, scrollBehavior = SCROLL, force = false)` —
|
||||
replace the trailing `wrap.scrollIntoView({ behavior: scrollBehavior,
|
||||
block: "end" });` with `scrollReveal(wrap, scrollBehavior, force);`
|
||||
(update the function's comment: scrolling is now conditional).
|
||||
- `addTyping()` — replace its
|
||||
`wrap.scrollIntoView({ behavior: SCROLL, block: "end" });` with
|
||||
`scrollReveal(wrap);`.
|
||||
- `handleSend` — the user-message call stays
|
||||
`addMessage("user", renderMarkdown(text));` (default: gated, no
|
||||
force). The user submits from the composer, so they are pinned and
|
||||
the message reveals; a submit with the viewport away from the
|
||||
bottom does not yank it.
|
||||
- Streaming `delta` branch — replace
|
||||
`wrap.scrollIntoView({ behavior: SCROLL, block: "end" });` with
|
||||
`scrollReveal(wrap);`.
|
||||
- Phase-17 `thinking` branch — replace its
|
||||
`wrap.scrollIntoView({ behavior: SCROLL, block: "end" });` with
|
||||
`scrollReveal(wrap);` (keep the `.thinking-text` internal
|
||||
`scrollTop = scrollHeight` bottom-pinning — that scrolls the
|
||||
block's own overflow, not the page). Phase 17 is complete by the
|
||||
time this task runs (pipeline numeric order), so the branch exists.
|
||||
- `renderStoredMessage` (both user and brain branches) — the restore
|
||||
calls become `addMessage("user", renderMarkdown(m.text), "auto",
|
||||
true)` / `addMessage("brain", renderMarkdown(m.text), "auto",
|
||||
true)` (one-shot, non-smooth landing on the last restored message —
|
||||
phase-14 behavior preserved).
|
||||
- `startNewChat`, fallback/placeholder `addMessage` calls: no change
|
||||
(they now go through the gated default — no force).
|
||||
- Header doc comment: add a short "Scroll (phase 18)" paragraph
|
||||
describing the follow-the-bottom contract and naming
|
||||
`NEAR_BOTTOM_PX` / `scrollReveal` as the single gate.
|
||||
2. `tests/unit/test_frontend_scroll.py` (new file — same source-level
|
||||
style as `test_frontend_feedback.py`, reading
|
||||
`frontend/assets/app.js`):
|
||||
- `test_near_bottom_constant_exported_at_200px` — regex
|
||||
`export\s+const\s+NEAR_BOTTOM_PX\s*=\s*200\s*;`.
|
||||
- `test_is_near_bottom_uses_document_scroller` — `isNearBottom`
|
||||
defined; references `documentElement.scrollHeight`,
|
||||
`window.scrollY`, `window.innerHeight`, `NEAR_BOTTOM_PX`.
|
||||
- `test_single_scroll_gate` — `function scrollReveal` exists; its
|
||||
body guards with `force || isNearBottom()`;
|
||||
`app.js.count("scrollIntoView") == 1` (exactly one occurrence,
|
||||
inside `scrollReveal`); `block: "end"` still used.
|
||||
- `test_submit_reveal_is_gated` — the send handler's user-message
|
||||
call is the plain default `addMessage("user", renderMarkdown(text))`
|
||||
(no force argument — the gate decides, and it does in real use
|
||||
because the composer being visible means pinned).
|
||||
- `test_restore_force_landing` — both restore call sites pass
|
||||
`("auto", true)`.
|
||||
- `test_streaming_scrolls_only_through_gate` — the delta branch and
|
||||
the thinking branch contain `scrollReveal(wrap)` and **no**
|
||||
raw `scrollIntoView` (covered by the count==1 test, but pin the
|
||||
two call-site markers explicitly for locality).
|
||||
|
||||
## Testing & Quality
|
||||
- No `app/` code changes — `uv run pytest --cov=app
|
||||
--cov-report=term-missing` must report the same coverage as before the
|
||||
task (gate stays green).
|
||||
- `uv run pytest tests/unit/test_frontend_scroll.py -v --no-cov` green.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `rg -c "scrollIntoView" frontend/assets/app.js` reports exactly 1.
|
||||
- [ ] `uv run pytest tests/unit/test_frontend_scroll.py -v --no-cov`
|
||||
green; `uv run pytest` fully green.
|
||||
- [ ] `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] Manual smoke (dev server, real or mock LLM): submit → own message
|
||||
+ typing + answer follow into view while at the bottom; scroll to
|
||||
the top mid-stream → the viewport does not move for the rest of
|
||||
the turn; reload → lands on the latest message.
|
||||
- [ ] No changes to `index.html`, `styles.css`, or any other page.
|
||||
@@ -0,0 +1,106 @@
|
||||
# Task 02 — E2E: the follow-the-bottom story suite
|
||||
|
||||
**Phase:** `18_follow_bottom_scroll` · **Story:** `.agents/user_stories/follow-bottom-scroll.md`
|
||||
|
||||
## Objective
|
||||
Dedicated Playwright gate for the scroll contract (A16 — one story, one
|
||||
file, run in isolation): submit reveals the user's message, streaming
|
||||
follows while pinned at the bottom, and — the heart of the story — the
|
||||
viewport **holds still** while the user is scrolled up, whether the
|
||||
stream is thinking (phase 17) or answering; restore still lands on the
|
||||
latest message.
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/test_follow_bottom_scroll.py` (new)
|
||||
- Header comment: story, prereq (`podman compose up -d db`), and the
|
||||
measurement convention — the scroller is the **document**
|
||||
(no inner scroll container): read
|
||||
`{ y: window.scrollY, sh: document.documentElement.scrollHeight,
|
||||
ch: window.innerHeight }` via `page.evaluate`; "near bottom" =
|
||||
`sh - y - ch <= 200` (mirrors `NEAR_BOTTOM_PX`); scroll to the top
|
||||
with `page.evaluate("() => window.scrollTo(0, 0)")`.
|
||||
- Real-user flow note: the user submits from the composer (pinned at
|
||||
the bottom — normal `fill` + `Enter`), and only **after** the
|
||||
stream starts do they scroll up to read. The no-yank scenarios
|
||||
follow exactly that sequence, so no off-screen input manipulation
|
||||
is needed.
|
||||
- Fixtures mirroring `tests/e2e/test_chat_persistence.py`:
|
||||
`seeded_kb` (truncate, import `tests/fixtures/docs` through the
|
||||
real `LLMClient` + `import_sources`, assert `summary.added == 8`,
|
||||
truncate in teardown); reuse the conftest `db_ready` skip.
|
||||
- Helpers:
|
||||
- `LONG_QUESTION = "write a long answer about my kubernetes cluster"`
|
||||
(mock long-answer trigger ≈ 900 words ≈ 8s of streaming — a wide,
|
||||
deterministic window to scroll away in).
|
||||
- `THINK_LONG_QUESTION = "think out loud — write a long answer about my kubernetes cluster"`
|
||||
(phase-17 thinking prefix + long answer; both mock triggers fire
|
||||
independently).
|
||||
- `scroll_state(page)` → the evaluate above; `near_bottom(state)`
|
||||
helper with the 200px band.
|
||||
- `wait_settled(page)` — `expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)`.
|
||||
- **The five scenarios** (also the story's Playwright Mapping Rule):
|
||||
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).
|
||||
- Determinism note (comment in the file): the mock paces SSE frames
|
||||
at 0.02s; the long answer (~900 words) streams for several seconds,
|
||||
so "mid-stream" assertions land comfortably inside the window on
|
||||
headless Chromium; every "held still" assertion compares against
|
||||
the exact `scrollTo(0, 0)` position (tolerance 5px for rounding).
|
||||
2. Regression pass — each **in isolation** (A16):
|
||||
`test_thinking_display.py` (phase 17 — its thinking scroll is now
|
||||
gated; its assertions attach/visible from a pinned-at-bottom fresh
|
||||
page, so they must still pass), `test_chat_persistence.py`
|
||||
(restore behavior), `test_loading_feedback.py` (pre-token/streaming
|
||||
feedback), `test_chat_rag.py` (core turn), `test_suggestion_chips.py`
|
||||
(chip row is horizontal — unaffected, cheap to include).
|
||||
|
||||
## Testing & Quality
|
||||
- This suite is the story's gate:
|
||||
`uv run pytest tests/e2e/test_follow_bottom_scroll.py -v --no-cov`
|
||||
green in isolation.
|
||||
- No `app/` or mock changes in this task. If a scenario exposes a real
|
||||
bug, fix it in `frontend/assets/app.js` (the owning file) and re-run
|
||||
task 01's unit pins + this suite.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_follow_bottom_scroll.py -v --no-cov`
|
||||
green in isolation (5/5).
|
||||
- [ ] All five regression suites above green, one command each.
|
||||
- [ ] `uv run pytest` (unit + integration) still green;
|
||||
`uv run ruff check . && uv run pyright` clean.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Task 03 — Story file, PLAN revisions, the phase commit
|
||||
|
||||
**Phase:** `18_follow_bottom_scroll` · **Story:** `.agents/user_stories/follow-bottom-scroll.md`
|
||||
|
||||
## Objective
|
||||
Record the change: the user story file (AGENTS.md rule 4), the PLAN
|
||||
revisions with the owner-choice noted, and the single atomic
|
||||
`--no-gpg-sign` commit with the phase moved to `complete/`.
|
||||
|
||||
## Work
|
||||
1. `.agents/user_stories/follow-bottom-scroll.md` (new — match the
|
||||
format of the sibling stories, e.g. `loading-feedback.md`):
|
||||
- Header: `**Phase:** 18_follow_bottom_scroll · **E2E:**
|
||||
tests/e2e/test_follow_bottom_scroll.py`.
|
||||
- Narrative: as a user, Brain's answers (and thinking) stream for
|
||||
10–30s. I want to scroll up and read 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.
|
||||
- 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: the scroller is the document (no
|
||||
inner overflow container); `NEAR_BOTTOM_PX = 200` (exported,
|
||||
unit-pinned); `scrollReveal` is the single `scrollIntoView` call
|
||||
site; the `SCROLL` smooth/auto constant (reduced-motion) is reused
|
||||
unchanged.
|
||||
- Playwright Mapping Rule: the five scenarios of
|
||||
`tests/e2e/test_follow_bottom_scroll.py` verbatim from task 02.
|
||||
2. `.agents/PLAN.md` revisions — **record the owner choice
|
||||
(2026-08-23, "option 1: follow-the-bottom, no pill")** in each note,
|
||||
style per the phase-16 A10-revision precedent:
|
||||
- Header revisions line: append
|
||||
`; follow-the-bottom scroll (Phase 18)`.
|
||||
- **§7.4 table:** new row — **Scroll (follow-the-bottom, phase
|
||||
18)**: the page auto-scrolls only while the user is pinned to the
|
||||
bottom (≤200px band, `NEAR_BOTTOM_PX` — the composer zone; submit
|
||||
reveals the user's message through the same gate, which holds in
|
||||
real use); scrolling up holds the viewport for the rest of the
|
||||
turn (thinking and answer alike); restore lands one-shot on the
|
||||
latest message.
|
||||
- **§12 roadmap:** new row 18 — `18_follow_bottom_scroll` /
|
||||
`follow-bottom-scroll.md` / `test_follow_bottom_scroll.py`.
|
||||
- Do not alter any locked anchor or renumber anything. (No §9/§4/§7.5
|
||||
changes — no log, API, or new component surface.)
|
||||
3. `README.md` — no change (no operator-facing behavior; the README
|
||||
does not document chat scroll behavior today).
|
||||
4. Final validation pass (all gates, AGENTS.md rules 5 + 9):
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` (≥ today's
|
||||
coverage),
|
||||
- `uv run pytest tests/e2e/test_follow_bottom_scroll.py -v --no-cov`
|
||||
in isolation, plus the five regression suites from task 02 (one
|
||||
command each, in isolation),
|
||||
- `uv run ruff check . && uv run pyright`,
|
||||
- `rg -c "scrollIntoView" frontend/assets/app.js` → 1.
|
||||
5. Commit + phase move (last step, only when all gates are green):
|
||||
```bash
|
||||
git add -A .agents/ frontend/ tests/
|
||||
git commit --no-gpg-sign -m "feat(ui): chat auto-scrolls only while pinned to the bottom — submitting reveals your message, scrolling up holds the viewport"
|
||||
mv .agents/phases/todo/18_follow_bottom_scroll .agents/phases/complete/
|
||||
```
|
||||
|
||||
## Testing & Quality
|
||||
- No new logic — this is the record-keeping + validation pass; the
|
||||
gates above are the phase's final proof. If validation fails, fix in
|
||||
the owning task's files, re-run that task's tests, then commit.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `.agents/user_stories/follow-bottom-scroll.md` exists with all
|
||||
five sections (header, narrative, acceptance, UI visualization,
|
||||
Playwright Mapping Rule).
|
||||
- [ ] `.agents/PLAN.md` carries the header-revision/§7.4/§12 notes with
|
||||
the 2026-08-23 owner-choice wording; no anchor text altered.
|
||||
- [ ] All gates green (coverage, story E2E + 5 regressions in
|
||||
isolation, ruff + pyright, single `scrollIntoView`).
|
||||
- [ ] Exactly one new commit, conventional, `--no-gpg-sign`;
|
||||
`.agents/phases/todo/18_follow_bottom_scroll/` is now under
|
||||
`.agents/phases/complete/`.
|
||||
Reference in New Issue
Block a user