# 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" ```