phase: 87_big_read_progress
Build and Push Containers / build-and-push-app (push) Successful in 2m48s
Build and Push Containers / build-and-push-db (push) Successful in 19s

All criteria verified — no defects found, nothing to fix. Final report:

**Phase 87 — big read progress: final verification pass (all tasks already complete in `complete/`)**
- Verified implementation vs. overview: `TOOL_LINE_ELAPSED_AFTER_MS = 5_000`, single live `armToolLineClock` site, settle on thinking/retry/delta, `stopToolLineClock` in `setUiState`, visible `.typing-elapsed` (aria kept byte-identical), CSS AA pairing — all match design; `app/` byte-identical (0 changes)
- **Tests/lint (exact outcomes):**
  - `uv run pytest --cov=app --cov-report=term` → 1732 passed, coverage **99%** (>90% ✓)
  - `uv run pytest tests/e2e/test_big_read_progress.py -v --no-cov` → **4 passed** (ticking suffix, visible hint, settle, no-timer restore)
  - `test_thinking_display.py` → 5 passed · `test_agent_document_tools.py` → 4 passed · `test_smoke.py` → 3 passed (all isolated)
  - 3 pinned frontend suites + new unit pins → 62 passed · `uv run ruff check . && uv run pyright` → clean, 0 errors
- **Completion criteria:** E2E pins 1–4 ✓ · guard/state-machine byte-identical ✓ (diff is additive only) · diff scope limited to `app.js`, `styles.css`, 2 new test files, phase files; nothing in `app/` ✓
- **Notable:** no deviations; commit + `00_phase.md` move left to the harness per executor rules (task files already in `complete/`)
- **Next pending phase:** none — `todo/` contains only this phase (87 is the last)
This commit is contained in:
2026-09-08 05:51:23 -04:00
parent 0f6b9ff7e6
commit 7cfe58fb21
24 changed files with 1650 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).
@@ -0,0 +1,43 @@
# Task 01 — Promote the 10s typing clock to a visible hint
**Phase:** `87_big_read_progress` · **Source:** `TODO.md:5` — "Need indication that prompt processing is happening during a big read, it can look frozen."
**Story:** n/a (owner bug report — `TODO.md` L5)
## Objective
While a turn is pre-token (state `thinking`), after 10s of silence the typing indicator shows a VISIBLE ticking "Ns" hint next to the dots — the existing aria-only clock promoted to sighted users too — while the pinned aria-label contract stays byte-identical.
## Work
1. `frontend/assets/app.js` — in `startThinkingClock`'s 1s interval (the block that today, after `if (secs < 10) return;`, updates `#typing-indicator .bubble`'s aria-label with `` `${brand()} is still thinking (${secs}s)` ``):
- KEEP the existing `bubble.setAttribute("aria-label", …)` line BYTE-IDENTICAL (pinned by `tests/unit/test_frontend_feedback.py` + `tests/unit/test_frontend_brand.py`);
- add, after it: ensure a hint element — `let el = bubble.querySelector(".typing-elapsed"); if (!el) { el = document.createElement("span"); el.className = "typing-elapsed"; bubble.appendChild(el); }` (append AFTER the three dot spans — the bubble's innerHTML is the template in `addTyping`; the hint is the last child) — and `el.textContent = secs + "s";` (textContent only — the bubble is `role="status"`, so the change is announced; no innerHTML anywhere with turn data).
- house comment above the new lines: phase 87 (TODO.md L5) — the 10s hint was aria-label-only (invisible to sighted users); now it ALSO renders a visible mono suffix; the aria channel is kept (both users, same clock).
- No change to `addTyping` / `removeTyping` (the hint lives and dies with the indicator — the state machine already removes the whole indicator on every non-thinking transition).
2. `frontend/assets/styles.css` — add the `.typing-elapsed` rule (the house AA pairing — `var(--ink-soft)` on the typing bubble surface, the documented ≥4.5:1 shape; small mono, the status-line language; plain text, no animation — no reduced-motion override needed):
```css
/* Phase 87 (TODO.md L5): the visible pre-token elapsed hint — the
10s thinking clock promoted from aria-only to sighted users.
ink-soft on the bubble surface (the AA pairing), small mono like
every status line. Plain text: no animation, no motion opt-out. */
.typing-elapsed {
font-family: var(--mono);
font-size: 0.75rem;
color: var(--ink-soft);
margin-left: 0.5rem;
}
```
Place it next to the existing `.typing` (typing-dot bubble) rules.
3. `tests/unit/test_big_read_progress.py` (new — start the module here; task 02 extends it) — house source-level pattern (read `frontend/assets/app.js` + `frontend/assets/styles.css` as text, no browser), the module docstring cites TODO.md L5 + the design:
- `app.js`: the `.typing-elapsed` class string appears; the visible hint is created with `document.createElement` (pin the `el.className = "typing-elapsed"` or the `createElement("span")` + class-assignment pair) and written with `textContent` (pin `textContent = secs + "s"` or the equivalent assignment — assert NO `innerHTML` on the typing bubble after the change: the `bubble.innerHTML` occurrence count is UNCHANGED from the `addTyping` template only — the simplest robust pin: `"typing-elapsed" in js` AND `js.count('bubble.innerHTML') == 1` (the addTyping template));
- the existing aria-label literal is still present (`` `${brand()} is still thinking (${secs}s)` `` — mirrors the brand pin so this module is self-documenting);
- `styles.css`: the `.typing-elapsed` rule exists and uses `var(--mono)` and `var(--ink-soft)` (the AA pairing).
4. `- ASSUMPTION: the hint text is plain "Ns" (e.g. "12s") with no brand prefix — the bubble's aria-label already carries the full sentence; the visible suffix is deliberately terse (A5). The executor does not add wording.`
## Testing & Quality
- Unit: `uv run pytest tests/unit/test_big_read_progress.py -v` green (this task's pins); `uv run pytest tests/unit/test_frontend_feedback.py tests/unit/test_frontend_brand.py tests/unit/test_frontend_tool_states.py -v` green UNCHANGED (the untouched pins).
- Coverage: **>90%** on `app/` unaffected (no `app/` change).
## Completion Criteria
- [ ] `uv run pytest tests/unit/test_big_read_progress.py -v` green; the three frontend unit suites green unchanged.
- [ ] The aria-label line in `app.js` is byte-identical (`git diff frontend/assets/app.js` shows only additions around it).
- [ ] `uv run ruff check . && uv run pyright` clean (no `app/` impact, but the gate runs).
- [ ] No behavior change in completed work (the E2E proof is task 03/04).
@@ -0,0 +1,55 @@
# Task 02 — The per-tool-line elapsed clock, suffix, and settle
**Phase:** `87_big_read_progress` · **Source:** `TODO.md:5` — "Need indication that prompt processing is happening during a big read, it can look frozen."
**Story:** n/a (owner bug report — `TODO.md` L5)
## Objective
The latest tool line ("📄 Reading `<path>`") shows a ticking "(Ns)" suffix after 5s of frame silence — proof of life exactly where the user's attention is — and the suffix (and the clock) settle the instant the next frame arrives or the turn ends; restored turns never show a timer.
## Work
1. `frontend/assets/app.js` — the turn-scoped clock (module scope, next to `thinkingClock` / `turnTimeout`, house comment style citing phase 87 + TODO.md L5):
- state: `let toolLineTimer = 0; let toolLineStart = 0; let toolLineWrap = null;` — one clock per turn, re-armed per `tool` frame (the baseline resets on every new line, so each line counts its OWN silence);
- `const TOOL_LINE_ELAPSED_AFTER_MS = 5_000;` — place it with the other constants (next to `TURN_TIMEOUT_MS`), comment: A5 — below it the gap reads as normal latency; at/above it the latest line proves it is still processing; pinned constant, not a magic number;
- `function armToolLineClock(wrap) { toolLineWrap = wrap; toolLineStart = Date.now(); if (!toolLineTimer) { toolLineTimer = setInterval(() => { const secs = Math.round((Date.now() - toolLineStart) / 1000); if (secs * 1000 < TOOL_LINE_ELAPSED_AFTER_MS) return; const line = toolLineWrap?.querySelector?.(".tool-calls .tool-call:last-child"); if (!line) return; let el = line.querySelector(".tool-elapsed"); if (!el) { el = document.createElement("span"); el.className = "tool-elapsed"; line.appendChild(el); } el.textContent = \`(${secs}s)\`; }, 1000); } }`
- the suffix is appended to the line AFTER its existing children (the pinned template text + the `<code>` argument) — a SIBLING, never a rewrite: the exact `line.textContent = "📄 Reading "` literals in `appendToolLine` stay byte-identical (the `test_frontend_tool_states.py` + emoji-guard pins);
- `function settleToolLine() { if (toolLineTimer) { clearInterval(toolLineTimer); toolLineTimer = 0; } toolLineWrap?.querySelectorAll?.(".tool-elapsed").forEach((el) => el.remove()); }`
- `function stopToolLineClock() { settleToolLine(); toolLineWrap = null; }` — the state-machine entry (no residue across turns).
- call sites (the ONLY ones — the live-only/A6 contract):
- the `ev.type === "tool"` branch (the turn's SSE loop, the branch that calls `appendToolLine(wrap, name, argument)`): add `armToolLineClock(wrap);` immediately after the `appendToolLine(…)` call;
- the TOP of the `ev.type === "thinking"`, `ev.type === "retry"`, and `ev.type === "delta"` branches: `settleToolLine();` (a frame arrived — the line is no longer "processing"; the visible indication moves to the thinking block / answer bubble);
- inside `export function setUiState(state, …)`: add `stopToolLineClock();` on the line next to the existing `stopThinkingClock();` / `clearTurnTimeout();` (every transition stops/clears — the house invariant "a stuck button is impossible" applied to a stuck timer).
- the restore path (the phase-14 code that re-renders persisted lines through `appendToolLine`) is UNTOUCHED — it never calls `armToolLineClock` (A6).
2. `frontend/assets/styles.css` — add the `.tool-elapsed` rule next to the existing `.tool-call` rules (same AA pairing as task 01; `white-space: nowrap` so "(12s)" never wraps):
```css
/* Phase 87 (TODO.md L5): the latest tool line's visible "processing"
suffix — ticking "(Ns)" while ≥5s of the turn's stream stay silent
after the call; removed on the next frame (settle). ink-soft on the
bubble surface (the AA pairing), small mono like every status line. */
.tool-elapsed {
font-family: var(--mono);
font-size: 0.75rem;
color: var(--ink-soft);
margin-left: 0.5rem;
white-space: nowrap;
}
```
3. `tests/unit/test_big_read_progress.py` (extend the module from task 01) — the source-level pins:
- `TOOL_LINE_ELAPSED_AFTER_MS` defined with the 5s value (regex `TOOL_LINE_ELAPSED_AFTER_MS\s*=\s*5_?000`);
- `armToolLineClock(` appears EXACTLY twice in `app.js` (definition + the single live call site — the A6 live-only contract: the restore path has no arm call);
- `settleToolLine(` appears at least 4× (definition + the thinking/retry/delta branches) and `stopToolLineClock()` is called inside `setUiState` (pin: the call occurs after the `stopThinkingClock()` call and before the function's `sendBtn.disabled` line — or more simply: it sits in the same statement block, i.e. between the `stopThinkingClock();` and `clearTurnTimeout();` lines — pick the pin that is stable and document it);
- the suffix text is built with `textContent` (pin the template containing `(${secs}s)`) and the element is created with `createElement` (no innerHTML for the suffix);
- the exact tool-line template literals are still present (the three `line.textContent = "…"` strings — mirrors `test_frontend_tool_states.py` so this module is self-documenting);
- `styles.css`: the `.tool-elapsed` rule exists with `var(--mono)` + `var(--ink-soft)`.
4. `- ASSUMPTION: ":last-child" targets the LATEST line only (older lines of the same turn keep their permanent record without timers); if the `.tool-calls` container is absent when the tick fires (the wrap was reset by a New-Chat click mid-gap), the tick is a no-op — the null-safe `?.` chain is the guard, pinned implicitly by the settle/stop contract.`
5. `- ASSUMPTION: the suffix format is parenthesized "(Ns)" — reads as the line's status suffix (e.g. "📄 Reading src/app.py (12s)"); the typing hint stays bare "Ns" (task 01). No owner-visible wording debate — pinned here so the executor does not guess.`
## Testing & Quality
- Unit: `uv run pytest tests/unit/test_big_read_progress.py -v` green (all pins, task 01 + this task); `uv run pytest tests/unit/test_frontend_tool_states.py tests/unit/test_frontend_feedback.py tests/unit/test_frontend_brand.py -v` green UNCHANGED.
- Quick live sanity (session log): dev server, ask a question that triggers a tool call (the "use your tools" phrase against the dev LLM if configured, else the phase-37 flow) — after ~5s of silence the latest tool line gains "(5s)" and it ticks; the next frame removes it. (The deterministic E2E is task 03 — this is a smoke of the wiring only.)
- Coverage: **>90%** on `app/` unaffected.
## Completion Criteria
- [ ] `git diff frontend/assets/app.js` shows: the constant, the three clock functions, the ONE arm call (in the `tool` branch), the three settle calls (thinking/retry/delta), the ONE stop call (in `setUiState`) — and NOTHING else; the `appendToolLine` body and its template literals byte-identical.
- [ ] `uv run pytest tests/unit/test_big_read_progress.py -v` green; the three frontend unit suites green unchanged.
- [ ] `uv run ruff check . && uv run pyright` clean.
- [ ] No behavior change in completed work (E2E proof in task 03/04).
@@ -0,0 +1,44 @@
# Task 03 — The E2E story: progress indication during a deterministic big-read gap
**Phase:** `87_big_read_progress` · **Source:** `TODO.md:5` — "Need indication that prompt processing is happening during a big read, it can look frozen."
**Story:** n/a (owner bug report — `TODO.md` L5)
## Objective
A dedicated Playwright suite (one file per story, run in isolation — AGENTS.md rule 4/9) drives the mock LLM's deterministic tool flow behind the slow-LLM proxy so every inter-frame gap is ≥6s — past both thresholds — and pins: the tool line's ticking suffix, the visible typing hint, the settle on the answer, and the no-timer restore contract.
## Work
1. `tests/e2e/test_big_read_progress.py` (new) — house E2E conventions (module docstring: the Source line, the run-in-isolation command `uv run pytest tests/e2e/test_big_read_progress.py -v --no-cov` — DB up + mock LLM, the mechanism paragraph (mock "use your tools" flow → ls tool frame → read tool frame → answer deltas; the slow proxy's per-request sleep makes every gap deterministic), the Test → story mapping list):
- **Suite fixtures:** a module-level `app_server`-style fixture pair following the phase-64 pattern (the conftest already spawns the mock LLM; this suite additionally spawns the proxy): `uv run python -m uvicorn tests.e2e.slow_llm:app --port <proxy port>` with `E2E_MOCK_PORT` = the conftest mock port and **`SLOW_LLM_DELAY_S = 6.0`** (the ≥6s gaps — past the 5s tool-line threshold; the 10s typing hint is crossed during the SECOND gap at t≈10s, before the answer's first delta). Reuse the conftest's subprocess-management helpers verbatim (the phase-64 suite is the reference implementation — mirror its fixture shape; do not re-implement process handling differently).
- **`test_tool_line_shows_ticking_elapsed`** — admin (or anonymous — the mock flow is public; use the anonymous context, no login needed, like the phase-37 suite: confirm which the phase-37 E2E uses and mirror it) at the conftest 1280×800 viewport: `page.goto(app_url)`, type the EXACT mock trigger phrase "use your tools" into `#message-input`, submit:
- wait for the FIRST `.tool-call` line (the "Listing documents" line — the mock flow's first call) to appear in `#messages`;
- from the line's appearance, wait until a `.tool-elapsed` child is visible (poll; it must appear by ~7s — 5s threshold + tick granularity — assert with a generous timeout of 12s so the suite is not flaky);
- sample its text twice, ≥1.5s apart (regex `\((\d+)s\)`): the second value is STRICTLY greater (the clock ticks while the gap holds);
- note the turn is still in flight (the button is the enabled "Stop" — `#send-label` text "Stop" — the phase-48 contract, a cheap invariant check).
- **`test_typing_indicator_shows_visible_elapsed`** — a fresh page + a fresh turn with the same trigger:
- at t≈11–12s after submit (during the second gap — pre-token: no thinking/delta frame has arrived in the mock flow), `#typing-indicator .typing-elapsed` is visible and its value (regex `(\d+)s`) is ≥10;
- a second sample ≥1.5s later is strictly greater;
- the aria-label channel still works (the bubble's `aria-label` contains "still thinking" at the same moment — the kept contract, E2E-observable).
- **`test_indicators_settle_when_the_answer_arrives`** — a fresh turn with the same trigger:
- first assert the ticking state (a `.tool-elapsed` visible — reuse the test-1 wait helper);
- wait for the answer: the mock flow's final answer renders in a `.bubble` under the brain msg (the phase-37 suite's answer-wait pattern — mirror it: wait for non-empty answer text / the `done` state — the button back to "Send");
- then: `#typing-indicator` is absent (the state machine removed it); NO `.tool-elapsed` element exists anywhere in `#messages` (the settle removed them);
- wait 1.5s more: still none (no re-appearance — the turn is over, the clock is stopped by the transition);
- the tool lines themselves remain (the permanent record — both the "Listing documents" and "Reading …" lines still present, byte-identical text).
- **`test_restored_turn_has_no_timer`** — a fresh turn with the same trigger, let it COMPLETE (answer rendered — the auto-save persisted it, the phase-50/55 headless auto-save contract): `page.reload()` (same context — `bor.chat.v1` survives):
- the restored conversation renders the tool lines (`.tool-call` elements present — the phase-14 restore path);
- NO `.tool-elapsed` element exists (A6 — the restore never arms the clock);
- the page is otherwise settled (no `#typing-indicator`, button "Send").
- Shared helpers in the module: `_submit_tools_turn(page, app_url)` (type the trigger + submit + wait for the first `.tool-call`), `_elapsed_value(el)` (the regex parse), `_sample_twice(page, selector)` (two values ≥1.5s apart) — small, local, documented.
2. `- ASSUMPTION: the anonymous context is used (no login) IF the phase-37 agent-tools E2E (tests/e2e/test_agent_document_tools.py) runs anonymous — mirror its auth choice exactly so the suite matches the house pattern for this flow; if that suite logs in, mirror the login instead. The mock flow + proxy are identical either way.`
3. `- ASSUMPTION: `SLOW_LLM_DELAY_S = 6.0` is the suite's value (the phase-64 default 0.15 is far below the 5s threshold — the suite must pass its own, like phase 64 did); the whole turn then runs ~18s + overhead — acceptable for an isolated story suite (the phase-64 suite has the same shape). Timeouts are set with ≥2× headroom on every wait so CI variance cannot flake the suite.`
4. `- ASSUMPTION: no changes to mock_llm.py / slow_llm.py / conftest.py — the suite reuses them as-is (the proxy is spawned per-suite the phase-64 way); if the conftest's mock port is occupied by the phase-64 pattern's choice, the proxy port follows the same allocation rule that suite uses.`
## Testing & Quality
- E2E (this task's gate): `uv run pytest tests/e2e/test_big_read_progress.py -v --no-cov` green **in isolation** (DB up; mock LLM + the suite's slow proxy).
- Flakiness discipline: run the suite THREE times in isolation (`for i in 1 2 3; do uv run pytest tests/e2e/test_big_read_progress.py -v --no-cov || break; done`) — all three green before the task is done (the timing assertions are the flake risk; the ≥2× timeout headroom + the strictly-increasing (not exact-value) assertions are the countermeasures).
- Coverage: >90% on `app/` unaffected (test-only task).
## Completion Criteria
- [ ] The four tests exist; the module docstring maps each to the contract (the suite's README).
- [ ] `uv run pytest tests/e2e/test_big_read_progress.py -v --no-cov` green in isolation — three consecutive runs.
- [ ] No change to `app/`, `frontend/`, `conftest.py`, `mock_llm.py`, `slow_llm.py`, or other suites.
@@ -0,0 +1,32 @@
# Task 04 — Full gate + atomic commit
**Phase:** `87_big_read_progress` · **Source:** `TODO.md:5` — the big-read indication bug report (TODO.md L5)
**Story:** n/a (owner bug report — `TODO.md` L5)
## Objective
Run the complete phase gate, land the phase as one atomic commit, and move the phase directory to `complete/`.
## Work
1. **Full regression gate** (AGENTS.md rule 9):
- `uv run pytest` — unit + integration green (including the three untouched frontend unit suites: `test_frontend_tool_states.py`, `test_frontend_feedback.py`, `test_frontend_brand.py` — the byte-identical-literal proof).
- `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage **>90%** (no `app/` change this phase — confirm the floor is held).
- `uv run pytest tests/e2e/test_big_read_progress.py -v --no-cov` — green **in isolation** (this phase's E2E story — ticking suffix, visible hint, settle, no-timer restore).
- `uv run pytest tests/e2e/test_thinking_display.py -v --no-cov` — green in isolation (the thinking block + the state machine's neighborhood — the task-01 clock change's regression proof).
- `uv run pytest tests/e2e/test_agent_document_tools.py -v --no-cov` — green in isolation (the tool-line rendering behavior — the task-02 change's regression proof).
- `uv run pytest tests/e2e/test_smoke.py -v --no-cov` — green in isolation.
- `uv run ruff check . && uv run pyright` — clean.
2. **Manual live check** (keep the output in the session log): dev server with the REAL LLM (or the mock via the dev env if that is the dev setup), ask a question that makes the model read a large document: while the read's follow-up round is in flight, the latest "📄 Reading …" line ticks "(5s), (6s), …" and the typing indicator shows the visible hint after 10s of silence; when the answer starts, both settle and the turn completes normally; Stop mid-gap → no residue (reload: no timer, clean state).
3. **Commit** (AGENTS.md rule 8 — one atomic, Conventional-Commits commit, always `--no-gpg-sign`), staging `frontend/assets/app.js`, `frontend/assets/styles.css`, `tests/unit/test_big_read_progress.py`, `tests/e2e/test_big_read_progress.py`, and the phase files:
`feat(ui): show elapsed progress during tool reads and pre-token gaps`
— body: TODO.md L5 — after a tool `read` the UI sat on a static "Reading <path>" line while the model prefilled the big context (tens of seconds, no frames): the turn looked frozen. Two visible, state-machine-owned indications: (1) the latest tool line grows a ticking "(Ns)" suffix after 5s of frame silence (`TOOL_LINE_ELAPSED_AFTER_MS`, armed ONLY from the live `tool` frame — restored lines stay timer-free) and it is removed on the next thinking/retry/delta frame; (2) the existing 10s pre-token thinking clock — previously aria-label-only — now also renders a visible "Ns" hint on the typing indicator (the aria channel kept). Frontend-only: the SSE event set, the 120s guard, the server, the pinned tool-line template literals, and the persistence format are byte-identical. New source-level unit pins + a dedicated slow-proxy E2E story (deterministic ≥6s gaps).
4. Move the phase directory: `mv .agents/phases/todo/87_big_read_progress .agents/phases/complete/` and include the move in the same commit.
## Testing & Quality
- This task IS the phase-level gate — the commands above are the completion evidence.
- Coverage: >90% held.
## Completion Criteria
- [ ] All seven gate commands green (unit + integration, coverage >90%, the new E2E story + `test_thinking_display.py` + `test_agent_document_tools.py` + smoke in isolation, ruff + pyright).
- [ ] The live check shows the ticking tool-line suffix + the visible typing hint during a real big read, settling on the answer (output kept in the session log).
- [ ] `git show --stat HEAD` lists the staged files above + the phase files (todo → complete move) — nothing else (in particular `app/`, `pyproject.toml` / `uv.lock`, `conftest.py`, `mock_llm.py`, `slow_llm.py` untouched).
- [ ] `.agents/phases/complete/87_big_read_progress/` exists; `todo/` no longer contains it.