chore(agent): phase roadmap from TODO.md, 3 phases (85-87)

TODO.md L3-L5 converted to executable phases (protocol B - append):
- 85_mobile_menu_gate_overlap (L3): the phase-79 token gate (z 500, fixed
  full-viewport) sits above the sticky header (z 20), so an
  unauthenticated visitor's tap on the mobile hamburger hits the gate
  overlay and the menu is unreachable until login. Reproduced:
  elementFromPoint at the toggle resolves to #auth-gate on every shell
  view; real clicks are intercepted. Fix: gate at z 15 (below the
  header + its mobile dropdown), #main stays inert-locked; the
  phase-46 E2E's programmatic-click workaround becomes a real click +
  a new TODO-regression pin.
- 86_history_page_width (L4): at 375px the History page panned ~250px
  into a blank region (document scrollWidth 626) although the table
  scrolled fine inside its card. Root cause: the .visually-hidden
  Actions header span is position:absolute with no positioned ancestor,
  so its 1px box (at the 640px table's right edge) leaks into the
  document's scrollable overflow. Fix: position:relative on the shared
  .table-wrap card (the identical Tokens-view defect, measured 618, is
  fixed by the same rule; RAG is clean and pinned). New E2E story.
- 87_big_read_progress (L5): after a tool read the UI sat on a static
  'Reading <path>' line while the model prefilled the big context -
  the turn looked frozen. Frontend-only: a ticking '(Ns)' suffix on
  the latest tool line after 5s of frame silence (settle on the next
  frame, live-only - restored lines stay timer-free) + the existing
  10s aria-only typing clock promoted to a visible 'Ns' hint. New
  source-level unit pins + slow-proxy E2E story (deterministic >=6s
  gaps via the mock tool flow).

TODO.md cleared (items now live in .agents/phases/todo/). The pre-existing
uncommitted 81-84 phases + remediation_plan.md are a separate workstream
and are NOT part of this commit.
This commit is contained in:
2026-09-07 21:12:03 -04:00
parent 7baca3d289
commit e2bed52751
13 changed files with 540 additions and 0 deletions
@@ -0,0 +1,73 @@
# Phase 87 — Show progress while a big read is being processed
**Source:** `TODO.md` L5 — "Need indication that prompt processing is happening during a big read, it can look frozen."
**Story:** n/a (owner bug report — `TODO.md` L5, 2026-09-07)
**Context:** `app/api/chat.py` (the SSE turn: a `tool` frame — `ChatToolEvent{name, argument}` — is emitted the moment the model's tool-call request arrives, i.e. BEFORE `app/rag/agent.py::_execute_tool` runs and the next model round starts; the next frame after that arrives only when the model has executed the read, prefilled the big context, and started generating — the frameless gap the owner sees), `frontend/assets/app.js` (the chat state machine — `UI_STATE.thinking` keeps the button as "Stop"; `appendToolLine` renders the STATIC "📄 Reading `<path>`" / "🔎 Searching for `<pattern>`" / "🔎 Listing documents…" line — no animation, no timing; the `tool` frame handler (the `ev.type === "tool"` branch of the turn's SSE loop) calls `clearTurnTimeout()` — "the stream is alive — a frame arrived"; `startThinkingClock` ticks 1s from turn start and after 10s updates ONLY the typing bubble's **aria-label** (`${brand()} is still thinking (${secs}s)`) — invisible to sighted users; `addTyping`/`removeTyping` own `#typing-indicator` — three animated dot spans in a `role="status"` bubble; `setUiState` is the single entry point — "timers belong to the state machine: every transition stops/clears them"), `tests/unit/test_frontend_tool_states.py` (pins the EXACT tool-line template literals — `line.textContent = "📄 Reading "` etc. — and the emoji-guard strip set; those literals must stay byte-identical), `tests/unit/test_frontend_feedback.py` + `tests/unit/test_frontend_brand.py` (pin the "still thinking" aria-label contract and its exact template literal — must stay green), `tests/e2e/mock_llm.py` (the deterministic "use your tools" flow: request 1 streams `ls` tool_calls, request 2 streams a `read` tool_call, request 3 streams the answer — no thinking frames) + `tests/e2e/slow_llm.py` (the delay-injecting reverse proxy, `SLOW_LLM_DELAY_S` per-request sleep — the deterministic-gap machinery).
## Bug basis (code-traced + reproduced, 2026-09-07)
- **The gap:** for a big read, the `tool` frame lands when the model REQUESTS the read. The server then executes the tool (a DB fetch — fast) and starts the NEXT model round, whose prefill of the big read context takes tens of seconds on the self-hosted models. No SSE frame arrives during that window; the UI's only motion is the typing dots' CSS animation, and the only elapsed feedback is screen-reader-only (the 10s aria-label). To a sighted user the turn "looks frozen" — exactly TODO.md L5.
- **The static line:** `appendToolLine` renders "📄 Reading `<path>`" once and never touches it again — no in-progress mark, no timing, no settle. The line sits exactly where the user's attention is (above the answer), which is why the frozen feeling is strongest there.
- **Existing guard (noted, OUT OF SCOPE):** the 120s pre-token guard (`TURN_TIMEOUT_MS`) is cleared by the `tool` frame (the stream is alive) — so the backstop that errors a stuck turn does not re-arm after a tool frame. Changing guard semantics is a separate owner decision; this phase only ADDS the visible indication the owner asked for and leaves the guard byte-identical.
- **The mock flow makes the gap deterministic for E2E:** "use your tools" → `ls` tool frame → (server executes, next round) → `read` tool frame → (server executes, next round) → answer deltas. With the slow-LLM proxy sleeping `SLOW_LLM_DELAY_S` seconds per request, each inter-frame gap is ≥ that sleep — a 6s sleep puts every gap past both new thresholds (5s tool-line, 10s typing hint is crossed during the second gap at t≈10s).
## Objective
During any frameless gap of an in-flight turn — most visibly after a big read — the UI shows, to sighted AND screen-reader users, that processing is ongoing: a ticking elapsed-seconds suffix on the latest tool line (after 5s of silence, settling when the next frame arrives) and a visible elapsed hint on the typing indicator (the existing 10s clock promoted from aria-only to visible text). Both settle the instant content resumes; persisted/restored turns never show timers.
## Owner decisions (chat, 2026-09-07 — confirmed with the roadmap, recorded per AGENTS.md rule 3)
- **A4 — frontend-only scope.** No new server frame (no `tool_done`): the client derives the indication from the existing SSE stream — a `tool` frame arms the line's clock (reset per line); ANY later frame (thinking / tool / retry / delta) settles it; turn-end transitions stop it via the state machine. The server is untouched — the 120s-guard observation above stays out of scope.
- **A5 — thresholds.** Tool-line elapsed suffix appears after **5s** of frame silence (`TOOL_LINE_ELAPSED_AFTER_MS = 5_000`, a named module constant); the typing-indicator hint reuses the EXISTING 10s pre-token clock (its `secs < 10` gate) — now also writing visible text. Both thresholds are pinned constants, not magic numbers.
- **A6 — live-only indication.** The suffix/hint exist only while the turn streams. The restore path (phase 14 re-renders persisted lines through the SAME `appendToolLine`) never arms the clock — the arming call lives ONLY in the live `tool` frame branch. A restored line reads exactly as it did pre-phase (the permanent record, no stale timer).
- **Settle = remove the suffix.** When a thinking/retry/delta frame arrives, the `.tool-elapsed` suffix is REMOVED from the line (the visible indication moves to the thinking block / answer bubble — a frozen timestamp on a finished line is noise; the line itself stays the permanent record). A new `tool` frame re-arms on the new line with a fresh baseline.
## Design (shared by all tasks — the executor reads this, not the chat)
- **`frontend/assets/app.js`** (all changes in the house comment style, citing this phase + TODO.md L5):
- Constants (next to `TURN_TIMEOUT_MS`): `const TOOL_LINE_ELAPSED_AFTER_MS = 5_000;` (A5) with a comment: the visible "processing" threshold for a tool line — below it the gap reads as normal latency, above it the user needs proof of life.
- **Task 01 — the typing hint:** in `startThinkingClock`'s 1s interval, alongside the existing aria-label update (KEPT byte-identical — the unit pins), after `secs >= 10`: ensure a `<span class="typing-elapsed">` (createElement, `document.createElement`, never innerHTML) as the LAST child of the `#typing-indicator .bubble` (after the three dot spans) and set its `textContent = secs + "s"`. The bubble's `role="status"` announces the change; the aria-label pin stays (both channels). `removeTyping()` already removes the whole indicator on every non-thinking transition (the state machine owns it — no extra cleanup).
- **Task 02 — the tool-line clock:** turn-scoped module state `let toolLineTimer = 0; let toolLineStart = 0; let toolLineWrap = null;` (documented next to `thinkingClock`):
- `armToolLineClock(wrap)` — `toolLineWrap = wrap; toolLineStart = Date.now();` start the 1s interval if not running: `secs = Math.round((Date.now() - toolLineStart) / 1000); if (secs * 1000 < TOOL_LINE_ELAPSED_AFTER_MS) return;` then find the LATEST line (`toolLineWrap`'s `.tool-calls` container's last `.tool-call` child) and ensure/append its `<span class="tool-elapsed">` (createElement; `textContent = `(${secs}s)`` — the parenthesized suffix reads as the line's status; the line's own content — the pinned template literal + the `<code>` argument — is untouched: the suffix is a SIBLING appended after them).
- `settleToolLine()` — clear the interval and REMOVE every `.tool-elapsed` from `toolLineWrap` (the A6/A-settle contract); null the wrap.
- `stopToolLineClock()` — `settleToolLine()` + `toolLineWrap = null` (the state-machine entry: no residue across turns).
- Call sites: `armToolLineClock(wrap)` in the `ev.type === "tool"` branch, right after the existing `appendToolLine(wrap, name, argument)` (the ONLY call site — A6); `settleToolLine()` at the TOP of the `ev.type === "thinking"`, `"retry"`, and `"delta"` branches (a frame arrived — the line is no longer "processing"); `stopToolLineClock()` inside `setUiState`, next to the existing `stopThinkingClock()` / `clearTurnTimeout()` (every transition stops/clears — the house invariant).
- The restore path (`appendToolLine` called from the phase-14 restore) is untouched — it never arms the clock (A6).
- **The exact tool-line template literals (`line.textContent = "📄 Reading "` etc.) are NOT modified** — `tests/unit/test_frontend_tool_states.py` pins them byte-for-byte and the emoji guard strips precisely those; the suffix is a separate element added by the clock only.
- **`frontend/assets/styles.css`** — two new rules (house AA palette, the `.history-status` token pairing — `var(--ink-soft)` on the bubble/card surfaces is the documented ≥4.5:1 shape; small mono matches the status-line language):
```css
/* Phase 87 (TODO.md L5): the visible "processing" indications … */
.tool-elapsed { font-family: var(--mono); font-size: 0.75rem; color: var(--ink-soft); margin-left: 0.5rem; white-space: nowrap; }
.typing-elapsed { font-family: var(--mono); font-size: 0.75rem; color: var(--ink-soft); margin-left: 0.5rem; }
```
(plain text, no animation — `prefers-reduced-motion` needs no override; place them near the `.tool-call` / `.typing` rules.)
- **`tests/unit/test_big_read_progress.py` (new — source-level house pattern):**
- `app.js`: `TOOL_LINE_ELAPSED_AFTER_MS` defined with the 5s value (regex `TOOL_LINE_ELAPSED_AFTER_MS\s*=\s*5_?000`); `armToolLineClock(` appears EXACTLY twice (the definition + the single live call site — the live-only/A6 contract); `settleToolLine(` appears at least 4× (definition + the three frame branches); `stopToolLineClock()` is called in `setUiState` (the call site sits inside the function body after `stopThinkingClock()`); the suffix text is built with `textContent` (pin the template `(${secs}s)` or equivalent — NO `innerHTML` assignment to the suffix element); the existing aria-label literal stays (the brand test enforces this too — but pin it here as well so THIS file is self-documenting).
- `styles.css`: the `.tool-elapsed` and `.typing-elapsed` rules exist and use `var(--mono)` + `var(--ink-soft)` (the AA pairing pin).
- **E2E story (new suite — `tests/e2e/test_big_read_progress.py`):** the mock "use your tools" flow behind the slow-LLM proxy at `SLOW_LLM_DELAY_S = 6.0` (deterministic ≥6s gaps — past the 5s threshold; the 10s typing hint is crossed during the second gap). Four tests: (1) the tool line grows a visible ticking `.tool-elapsed` suffix during the gap (two samples ≥1.5s apart, strictly increasing `\(\d+s\)` value); (2) the typing indicator shows the visible `.typing-elapsed` hint at t≈11–12s (≥10, increasing); (3) on the answer's arrival BOTH settle — `#typing-indicator` removed, every `.tool-elapsed` gone, no re-appearance 1.5s later, the answer bubble complete; (4) a RELOAD after the turn (same context — the phase-14/50 persisted conversation restores) renders the tool lines WITHOUT any `.tool-elapsed` (A6 end-to-end).
- **Not touched:** `app/` (server byte-identical — A4), the 120s guard, the tool-line template literals, `mock_llm.py` / `slow_llm.py` (used as-is with the suite's own proxy fixture), persistence format.
## Dependencies
- `86_history_page_width` (todo) — pipeline predecessor (execution order) only; NO code dependency (different files: `app.js` / `styles.css` / a new E2E suite — phase 86 touches only the `.table-wrap` rule and table-view E2E).
## Tasks
1. `01_typing_elapsed_hint.md` — the visible typing-indicator elapsed hint (app.js + CSS + unit pins).
2. `02_tool_line_elapsed.md` — the per-line clock, suffix, and settle (app.js + CSS + unit pins).
3. `03_e2e_story_suite.md` — `tests/e2e/test_big_read_progress.py` (slow-proxy deterministic gaps).
4. `04_verify_and_commit.md` — full gate (suite + coverage + the new E2E story in isolation + the adjacent chat/tool suites in isolation, smoke, ruff + pyright) + atomic commit.
## Testing & Quality
- Unit — `tests/unit/test_big_read_progress.py` (new): the pins listed in the Design section (named 5s constant, single live arm site, settle-on-three-frame-types, state-machine stop, textContent-only suffix, the two CSS rules' AA pairing).
- Existing unit suites MUST stay green UNCHANGED: `tests/unit/test_frontend_tool_states.py` (the exact tool-line literals + emoji guard), `tests/unit/test_frontend_feedback.py` (the "still thinking" aria contract), `tests/unit/test_frontend_brand.py` (the exact aria-label template literal).
- E2E — `tests/e2e/test_big_read_progress.py` (new; isolation gate per AGENTS.md rule 9): the four tests above, mock LLM + slow proxy (`SLOW_LLM_DELAY_S = 6.0`) via the conftest app-server pattern.
- Regression E2E (run in isolation by task 04): `tests/e2e/test_thinking_display.py` (the thinking block + the state machine's neighbors) and `tests/e2e/test_agent_document_tools.py` (the tool-line rendering behavior) green unchanged; `tests/e2e/test_smoke.py` green.
- Coverage: **>90%** on `app/` — no `app/` code changes (the floor is held by the untouched suite).
## Completion Criteria
- [ ] Live turn, big-read gap ≥5s: the latest tool line shows a ticking "(Ns)" suffix (E2E pin 1); the typing indicator shows the visible "Ns" hint after 10s of pre-token silence (E2E pin 2) — the owner's "it can look frozen" is fixed for sighted users; screen-reader users keep the aria channels (existing pins green).
- [ ] On the next frame / turn end: both indications settle (suffix removed, typing removed on state change) — no stale timers after the answer (E2E pin 3); the 120s guard and every existing state-machine behavior are byte-identical.
- [ ] Reload after a tool turn: restored tool lines carry NO timer (E2E pin 4 — A6).
- [ ] `uv run pytest` green (including the three untouched frontend unit suites); `uv run pytest --cov=app --cov-report=term-missing` >90%; the new E2E story + `test_thinking_display.py` + `test_agent_document_tools.py` + `test_smoke.py` green in isolation; `uv run ruff check . && uv run pyright` clean.
- [ ] `git diff --stat` limited to `frontend/assets/app.js`, `frontend/assets/styles.css`, the new unit test, the new E2E file, phase files — nothing in `app/`.
- [ ] One atomic `--no-gpg-sign` commit (e.g. `feat(ui): show elapsed progress during tool reads and pre-token gaps`); phase dir moved to `.agents/phases/complete/`.
## Locked decisions
- **Frontend-only (A4)** — the server, the SSE event set, and the 120s guard are byte-identical; the indication is derived client-side from the existing stream.
- **The tool-line template literals stay (the emoji-guard + unit pins)** — the suffix is a separate element the clock appends; `appendToolLine` renders exactly as before (which is also what makes A6 fall out for free on restore).
- **Named thresholds, state-machine-owned timers (A5 + house invariant)** — `TOOL_LINE_ELAPSED_AFTER_MS = 5_000`; every `setUiState` transition stops/clears the new clock exactly like the existing ones ("a stuck button is impossible" applies to a stuck timer too).