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)
15 KiB
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
toolframe 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:
appendToolLinerenders "📄 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 thetoolframe (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" →
lstool frame → (server executes, next round) →readtool frame → (server executes, next round) → answer deltas. With the slow-LLM proxy sleepingSLOW_LLM_DELAY_Sseconds 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 — atoolframe 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 (itssecs < 10gate) — 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 livetoolframe 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-elapsedsuffix 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 newtoolframe 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), aftersecs >= 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 itstextContent = secs + "s". The bubble'srole="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 tothinkingClock):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-callscontainer's last.tool-callchild) 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-elapsedfromtoolLineWrap(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 theev.type === "tool"branch, right after the existingappendToolLine(wrap, name, argument)(the ONLY call site — A6);settleToolLine()at the TOP of theev.type === "thinking","retry", and"delta"branches (a frame arrived — the line is no longer "processing");stopToolLineClock()insidesetUiState, next to the existingstopThinkingClock()/clearTurnTimeout()(every transition stops/clears — the house invariant). - The restore path (
appendToolLinecalled 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.pypins them byte-for-byte and the emoji guard strips precisely those; the suffix is a separate element added by the clock only.
- Constants (next to
frontend/assets/styles.css— two new rules (house AA palette, the.history-statustoken pairing —var(--ink-soft)on the bubble/card surfaces is the documented ≥4.5:1 shape; small mono matches the status-line language):(plain text, no animation —/* 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; }prefers-reduced-motionneeds no override; place them near the.tool-call/.typingrules.)tests/unit/test_big_read_progress.py(new — source-level house pattern):app.js:TOOL_LINE_ELAPSED_AFTER_MSdefined with the 5s value (regexTOOL_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 insetUiState(the call site sits inside the function body afterstopThinkingClock()); the suffix text is built withtextContent(pin the template(${secs}s)or equivalent — NOinnerHTMLassignment 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-elapsedand.typing-elapsedrules exist and usevar(--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 atSLOW_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-elapsedsuffix during the gap (two samples ≥1.5s apart, strictly increasing\(\d+s\)value); (2) the typing indicator shows the visible.typing-elapsedhint at t≈11–12s (≥10, increasing); (3) on the answer's arrival BOTH settle —#typing-indicatorremoved, every.tool-elapsedgone, 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-wraprule and table-view E2E).
Tasks
01_typing_elapsed_hint.md— the visible typing-indicator elapsed hint (app.js + CSS + unit pins).02_tool_line_elapsed.md— the per-line clock, suffix, and settle (app.js + CSS + unit pins).03_e2e_story_suite.md—tests/e2e/test_big_read_progress.py(slow-proxy deterministic gaps).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) andtests/e2e/test_agent_document_tools.py(the tool-line rendering behavior) green unchanged;tests/e2e/test_smoke.pygreen. - Coverage: >90% on
app/— noapp/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 pytestgreen (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.pygreen in isolation;uv run ruff check . && uv run pyrightclean.git diff --statlimited tofrontend/assets/app.js,frontend/assets/styles.css, the new unit test, the new E2E file, phase files — nothing inapp/.- One atomic
--no-gpg-signcommit (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;
appendToolLinerenders 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; everysetUiStatetransition stops/clears the new clock exactly like the existing ones ("a stuck button is impossible" applies to a stuck timer too).