chore(agent): track .agent/ planning tree in git
Build and Push Containers / build-and-push-app (push) Successful in 12s
Build and Push Containers / build-and-push-db (push) Successful in 10s

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:
2026-09-01 10:18:22 -04:00
parent 5fa620fde5
commit 4971e2859d
818 changed files with 23964 additions and 4 deletions
@@ -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"
```