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,160 @@
|
||||
# Phase 18 — Scroll Control: Follow the Bottom (No Yank While Reading)
|
||||
|
||||
**Story:** `.agent/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; `.agent/user_stories/follow-bottom-scroll.md` exists.
|
||||
- [ ] One `--no-gpg-sign` commit (below);
|
||||
`.agent/phases/todo/18_follow_bottom_scroll/` moved to
|
||||
`.agent/phases/complete/`.
|
||||
|
||||
## Commit
|
||||
```bash
|
||||
git add -A .agent/ 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"
|
||||
```
|
||||
Reference in New Issue
Block a user