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.
8.6 KiB
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 thethinkingbranch 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 theSCROLLsmooth/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 —
bodyismin-height: 100dvhand the page scrolls on the window). All measurements go throughwindow.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 asTURN_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" });—forceis used only by the phase-14 restore landing (one-shot, load-time). The existingSCROLLconstant (smooth, orautounderprefers-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 withscrollReveal(wrap, scrollBehavior, force)(replaces the unconditionalwrap.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
deltabranch: replacewrap.scrollIntoView({ behavior: SCROLL, block: "end" })withscrollReveal(wrap)— follows only while pinned. - Phase-17
thinkingbranch: same replacement (scrollReveal(wrap)); the block's internal.thinking-textbottom-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 remainingforceusers; a load-time landing, not continuous auto-scroll — intentional, pinned by E2E test 5). - Fallbacks (empty-answer bubble,
done-without-wrap "…" placeholder):scrollRevealwith no force — appears if pinned, never yanks. startNewChat: no change (the document shrinks; the browser clamps the stale scrollTop).
- After this phase,
scrollIntoViewappears exactly once inapp.js(insidescrollReveal) — 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
01_follow_bottom_scroll.md—app.js:NEAR_BOTTOM_PX,isNearBottom,scrollReveal, all call sites rewired; new source-level unit pins.02_e2e_story_suite.md—tests/e2e/test_follow_bottom_scroll.py(5 scenarios, isolated run) + regression suites (incl. phase 17'stest_thinking_display.pyand phase 14'stest_chat_persistence.py).03_docs_plan_commit.md— user story file, PLAN revisions (owner-permission noted), final validation, the single atomic commit, phase move tocomplete/.
Locked decisions
- No anchor changes. A11 (vanilla JS, no CDN) — pure
app.jslogic. 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;scrollIntoViewoccurs exactly once inapp.jsand only insidescrollReveal; 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=appmust 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, prereqpodman compose up -d db). - Lint/types:
uv run ruff check . && uv run pyrightclean.
Completion Criteria
uv run pytestgreen;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-covgreen 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 pyrightclean.- 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-motionstill respected (theSCROLLconstant is untouched). - PLAN carries the revisions with the 2026-08-23 owner-choice
wording;
.agent/user_stories/follow-bottom-scroll.mdexists. - One
--no-gpg-signcommit (below);.agent/phases/todo/18_follow_bottom_scroll/moved to.agent/phases/complete/.
Commit
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"