Files
brain-of-reese/.agents/phases/complete/87_big_read_progress/00_phase.md
T
ducoterra 7cfe58fb21
Build and Push Containers / build-and-push-app (push) Successful in 2m48s
Build and Push Containers / build-and-push-db (push) Successful in 19s
phase: 87_big_read_progress
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)
2026-09-08 05:51:23 -04:00

15 KiB
Raw Blame History

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):
    /* 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).