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.
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).