chore(agent): phase roadmap from TODO.md — 8 phases (40–47), 24 tasks

Converts the 9 TODO items into an executable phase roadmap (Protocol B,
appended after phase 39):

- 40 tuning toggle anonymous flash (TODO L3)
- 41 sync fail-fast + modal when a model is down (TODO L4)
- 42 no reply autoscroll (TODO L5)
- 43 thinking scroll back — user scroll + gated autoscroll (TODO L7)
- 44 markdown tables (TODO L6)
- 45 agent unlimited tool calls behind BOR_AGENT_MAX_ROUNDS (TODO L8)
- 46 mobile hamburger nav (TODO L9)
- 47 quadlet + jinja import formats, A9 revision (TODO L10–L11)

Each phase carries a user story, a dedicated Playwright E2E suite plan,
and owner-locked decisions (R1 A9 format extension, R2 phase-37 budget
revision, A1–A5 scope decisions) confirmed 2026-08-27.

Also records the completed phases 30–39 todo/ -> complete/ moves that
were pending in the working tree. TODO.md is cleared (items now live in
.agent/phases/todo/).
This commit is contained in:
2026-08-27 18:25:53 -04:00
parent 492d8275e7
commit 02c76ad328
66 changed files with 1906 additions and 0 deletions
@@ -0,0 +1,38 @@
# Phase 43 — Thinking scroll back (user scroll + generate-time autoscroll)
**Source:** `TODO.md` L7 — "Add scrolling back to the thinking block, but have it autoscroll while thinking content is generating."
**Story:** `.agent/user_stories/thinking-scroll-back.md`
**Context:** Phase 17 streams reasoning into the collapsible `.thinking` block with a per-chunk bottom-pin (`textEl.scrollTop = textEl.scrollHeight` in the `thinking` SSE handler). Phase 21 (owner choice 2026-08-24) made `.thinking-text` a no-scroll live tail: `overflow-y: hidden` (the JS pin is the sole scroller). The owner now reverses phase 21: the window is user-scrollable again, and the pin becomes **gated** — follow the tail only while the user is pinned near the window's bottom. This is the window-level successor of the phase-18 pattern (the page-level one is removed in phase 42, which runs first and touches the same `thinking` handler line — order matters).
## Objective
The Thinking block follows its live tail while reasoning is generating **and** the user is at the bottom; a scrolled-up user is never yanked down, and returning to the bottom resumes following.
## Dependencies
- `42_no_reply_autoscroll` (todo) — must run **first**: it strips the page-level scroll from the same `thinking` handler; this phase then reworks the window pin in the cleaned-up handler.
- `17_thinking_display` (complete) — the block, the pin, the auto-collapse on first delta.
- `21_thinking_no_scroll` (complete) — the `overflow-y: hidden` + 320px window being reversed (the 320px clip is kept).
## Tasks
1. `01_window_user_scrollable.md` — CSS: `overflow-y: auto` back, comment replaced (owner direction 2026-08-27).
2. `02_gated_bottom_pin.md` — `app.js`: `THINKING_NEAR_BOTTOM_PX = 32` + gated pin; unit pin rewritten (phase-21 file replaced).
3. `03_thinking_scroll_e2e_and_commit.md` — replace the phase-21 E2E with the new-contract suite + regressions + commit.
## Testing & Quality
- Unit: `tests/unit/test_thinking_no_scroll.py` **deleted**, replaced by `tests/unit/test_thinking_scroll.py` — pins: `overflow-y: auto` + `max-height: 320px` in the `.thinking-text` rule; the 2026-08-27 owner-direction comment; `export const THINKING_NEAR_BOTTOM_PX = 32`; the pin is gated on `isThinkingNearBottom(textEl)` (no unconditional pin).
- Coverage: frontend-only — `app/` TOTAL unchanged, >90%.
- E2E (mandatory, A16): `tests/e2e/test_thinking_scroll.py`, run in isolation. `tests/e2e/test_thinking_no_scroll.py` is **deleted** (behavior intentionally reversed).
## Completion Criteria
- [ ] Wheel/drag/keyboard move `.thinking-text` (frozen-tail state); computed `overflow-y: auto`, `max-height: 320px`.
- [ ] While pinned at the window bottom: each chunk re-pins to the tail (±1px). Scrolled up: no re-pin across chunks. Return to bottom: following resumes.
- [ ] Auto-collapse on first delta, reduced-motion stillness, answer-bubble scroll (phase 11), restored-collapsed block (phase 17) all unchanged.
- [ ] `uv run pytest` green; coverage TOTAL unchanged.
- [ ] `uv run pytest tests/e2e/test_thinking_scroll.py -v --no-cov` green in isolation (DB up).
- [ ] Regression E2E suites green in isolation: `test_thinking_display.py`, `test_chat_persistence.py`, `test_no_reply_autoscroll.py`, `test_smoke.py`.
- [ ] `uv run ruff check . && uv run pyright` clean.
- [ ] One `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.
## Locked decisions
- **Owner direction (2026-08-27, roadmap A2)** reverses the phase-21 owner choice (2026-08-24): the window is user-scrollable again; autoscroll only while pinned near the bottom (32px band). The 320px clip is kept (owner-confirmed).
- **A15 unchanged** — SSE contract untouched; pure client-side.
- **A16/A17 honoured** — one story E2E suite (replacing the removed one), one atomic commit.
@@ -0,0 +1,28 @@
# Task 01 — Window user-scrollable again (CSS + unit pin swap)
**Phase:** `43_thinking_scroll_back` · **Source:** `TODO.md:7` — "Add scrolling back to the thinking block, but have it autoscroll while thinking content is generating."
**Story:** `.agent/user_stories/thinking-scroll-back.md`
## Objective
Restore user scrolling on the Thinking window — `overflow-y: auto`, 320px clip kept, comment updated — and swap the phase-21 unit pins for the new contract so the suite stays green.
## Work
1. `frontend/assets/styles.css` — in the phase-17/21 thinking section, the `details.thinking .thinking-text` rule:
- `overflow-y: hidden;` → `overflow-y: auto;`
- replace the phase-21 comment (*"no user scroll back (owner choice 2026-08-24): the window is a live tail only — the phase-17 JS bottom-pin … is the sole scroller"*) with: *"user-scrollable window (owner direction 2026-08-27, `TODO.md` L7): autoscroll follows the live tail only while the user is pinned near the window's bottom — the phase-17 pin, gated in app.js (task 02: `THINKING_NEAR_BOTTOM_PX`); scrolling up pauses the follow, returning to the bottom resumes it."*
- `max-height: 320px` and **every other declaration in the rule stay byte-identical**; the tightened `p`/`ul` margins rule and the reduced-motion chevron block are untouched.
2. **Delete** `tests/unit/test_thinking_no_scroll.py` (its pins assert the reversed behavior) and create `tests/unit/test_thinking_scroll.py` (house style — source pins, same slicing helpers as the deleted file) with, for now, the CSS contract only:
- the `.thinking-text` rule body contains `overflow-y: auto`, `max-height: 320px`, and the 2026-08-27 owner-direction comment (assert `"owner direction 2026-08-27"` and `"TODO.md L7"`);
- `overflow-y: hidden` / `overflow-y: scroll` are absent from that rule body;
- the phase-17 bottom-pin marker (`textEl.scrollTop = textEl.scrollHeight`) is still present in `app.js` (it becomes gated in task 02 — the pin's existence is asserted now so task 02's diff stays minimal and reviewable).
- The JS-gate pins (`THINKING_NEAR_BOTTOM_PX`, `isThinkingNearBottom`, gated call) are added in task 02 — do not assert them yet.
3. Check `tests/e2e/test_thinking_no_scroll.py` still passes at this checkpoint: it asserts computed `overflow-y: hidden` — **it will fail** (the behavior is intentionally changed). Per the gate, the phase's E2E replacement is task 03; to keep the per-task gate green, **delete** that E2E file in this task as well (its behavior is reversed; task 03 lands the replacement suite). Note the deletion in the final commit message of task 03.
## Testing & Quality
- Unit: `tests/unit/test_thinking_scroll.py` green; full `uv run pytest` green (the deleted E2E file does not run under the unit/integration gate, but the full pytest run must not collect it either — it is gone from the tree).
- Coverage: **>90%** on `app/` (unchanged).
## Completion Criteria
- [ ] `overflow-y: auto` + 320px clip + new comment in the CSS rule; all other declarations byte-identical.
- [ ] Old unit + old E2E phase-21 files deleted; new unit file pins the CSS contract and the surviving pin marker.
- [ ] `uv run pytest` green at this checkpoint.
@@ -0,0 +1,59 @@
# Task 02 — Gated bottom pin (follow while pinned)
**Phase:** `43_thinking_scroll_back` · **Source:** `TODO.md:7` — "Add scrolling back to the thinking block, but have it autoscroll while thinking content is generating."
**Story:** `.agent/user_stories/thinking-scroll-back.md`
## Objective
The phase-17 per-chunk pin becomes a **gate**: the window follows the live tail only while the user is near its bottom; a scrolled-up user is never re-pinned; returning to the bottom re-arms the pin automatically.
## Work
1. `frontend/assets/app.js` —
- add (near the existing `SCROLL` constant, with the phase-18 comment block already removed by phase 42):
```js
/* Thinking-window follow-the-tail contract (owner direction
* 2026-08-27, `TODO.md` L7): the scratchpad autoscrolls to its live
* tail only while the user is pinned near the window's bottom —
* the 32px band is the "window bottom in view" threshold. Scrolling
* up pauses the follow; returning to the bottom resumes it (the
* check runs on every chunk). Exported so the band is unit-pinned
* (same pattern as TURN_TIMEOUT_MS). */
export const THINKING_NEAR_BOTTOM_PX = 32;
function isThinkingNearBottom(textEl) {
return (
textEl.scrollHeight - textEl.scrollTop - textEl.clientHeight <=
THINKING_NEAR_BOTTOM_PX
);
}
```
- in the `thinking` SSE handler, replace the phase-17 block:
```js
if (block.open) {
textEl.scrollTop = textEl.scrollHeight; // pin the stream to the bottom
}
```
(phase 42 already removed the `scrollReveal(wrap)` line there) with the gated pin:
```js
if (block.open && isThinkingNearBottom(textEl)) {
// Follow the live tail only while the user is pinned to the window
// bottom (owner direction 2026-08-27); a scrolled-up reader is
// never re-pinned — returning to the bottom re-arms the pin.
textEl.scrollTop = textEl.scrollHeight;
}
```
- everything else in the handler (acc, sawThinking, clearTurnTimeout, ensureThinkingBlock, `textEl.innerHTML = renderMarkdown(thinkingAcc)`) stays byte-identical.
2. `tests/unit/test_thinking_scroll.py` — extend (from task 01) with the JS pins:
- `app.js` exports `const THINKING_NEAR_BOTTOM_PX = 32`;
- `isThinkingNearBottom` computes `scrollHeight - scrollTop - clientHeight <= THINKING_NEAR_BOTTOM_PX`;
- the thinking handler's pin is gated — the pin line is preceded by `isThinkingNearBottom(textEl)` in the same `if` (assert the combined condition; assert there is **no** unconditional `if (block.open) { textEl.scrollTop = ... }` left);
- `block.open` is still part of the gate (closed blocks never pin);
- the restore path renders collapsed blocks (phase 17) — keep the surviving assertion from task 01.
3. `uv run pytest` green at this checkpoint (E2E not run by the unit gate; the replacement suite lands in task 03).
## Testing & Quality
- Unit: the extended pin file; full suite green.
- Coverage: **>90%** on `app/` (unchanged).
## Completion Criteria
- [ ] The pin fires only when the block is open **and** the window is within 32px of its bottom; scrolled-up users are never re-pinned; the gate re-arms on return (by construction — the check runs per chunk).
- [ ] `uv run pytest` green at this checkpoint.
@@ -0,0 +1,28 @@
# Task 03 — Thinking-scroll E2E (replaces phase 21) + regressions + commit
**Phase:** `43_thinking_scroll_back` · **Source:** `TODO.md:7` — "Add scrolling back to the thinking block, but have it autoscroll while thinking content is generating."
**Story:** `.agent/user_stories/thinking-scroll-back.md`
## Objective
Prove the full contract in the browser — user scroll restored, follow-while-pinned, pause-on-scroll-up, resume-on-return, CSS contract, and the phase-11/17 regressions — then commit the phase.
## Work
1. `tests/e2e/test_thinking_scroll.py` (new) — mock-only, DB up. Reuse the phase-21 determinism machinery (`mock_llm.compose_thinking` is already ~2 700 chars ≈ 4.5 s of paced frames, overflowing the 320px window ~2×; the phase-20 hesitation trigger gives a deterministic 4 s frozen-tail state with the block open). Per the story's Playwright Mapping Rule:
- `test_thinking_window_user_scrollable` — frozen tail: focus `.thinking-text`, wheel up / `Home` / mouse-drag up → `scrollTop` moves and earlier content is visible;
- `test_thinking_window_follows_while_pinned` — live stream: at the window bottom, after the 2nd-to-last and the last chunk the window is pinned to the tail (±1px); the last chunk's text renders inside the visible rectangle;
- `test_thinking_window_stops_on_scroll_up` — mid-stream: scroll up ~half the window; over the next ≥5 chunks `scrollTop` stable (±1px);
- `test_thinking_window_resumes_on_return` — from the paused state, set `scrollTop` to the bottom; on the next chunk the window is re-pinned to the tail (±1px);
- `test_thinking_window_css_contract` — computed `overflow-y: auto`, `max-height: 320px`, `scrollHeight > clientHeight` (real clip);
- `test_answer_bubble_still_scrollable` (phase 11) — long answer: page scrolls, bubble overflow untouched;
- `test_restored_collapsed_thinking_unaffected` (phase 17) — settled thinking turn reloads collapsed with full text.
2. Regression pass (isolation runs): `test_thinking_display.py`, `test_chat_persistence.py`, `test_no_reply_autoscroll.py` (phase 42 — the cleaned `thinking` handler must not have lost the phase-42 contract), `test_smoke.py`.
3. `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL unchanged; `uv run ruff check . && uv run pyright` clean.
4. Commit (Conventional Commits, `--no-gpg-sign`), e.g. `feat(chat): thinking window scrolls again, follows the tail only while pinned`, staging this phase's files **including the two deleted phase-21 test files** (`tests/unit/test_thinking_no_scroll.py`, `tests/e2e/test_thinking_no_scroll.py`) and the new unit + E2E files; move `.agent/phases/todo/43_thinking_scroll_back/` → `.agent/phases/complete/`.
## Testing & Quality
- E2E: `uv run pytest tests/e2e/test_thinking_scroll.py -v --no-cov` green in isolation.
- Coverage: **>90%** on `app/` (unchanged — frontend-only phase).
## Completion Criteria
- [ ] The new suite passes in isolation (all seven tests); the four regression suites pass in isolation.
- [ ] One atomic `--no-gpg-sign` commit covering both deleted and both new test files; phase dir moved to `.agent/phases/complete/`.