Files
brain-of-reese/.agents/phases/complete/18_follow_bottom_scroll/00_phase.md
T
ducoterra dbf2af26c6 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.
2026-09-05 10:57:07 -04:00

8.6 KiB

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

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"