All gates green. Final report: **Phase 109 — final verification pass (all 3 tasks were already complete; no re-implementation needed)** - Verified D15 (thinking-handler re-open `block.open = true` after `ensureThinkingBlock`; delta keeps closing; "never reopens" narrative removed; restore path still collapses) and D16 (single static `#turn-loader` in composer row, `aria-hidden`, sole `turnLoader.hidden = !inFlight` write in `setUiState`; CSS reuses `typing` keyframes + reduced-motion override after the main rule + provenance; global `[hidden]` guard intact; mock `TURN_PROGRESS_TRIGGER` repro marker; `app/` untouched — pure UI phase). - Unit pins: `tests/unit/test_frontend_turn_loader.py` (9 tests) — green via full run. - `uv run pytest tests/e2e/test_turn_progress_loader.py -v --no-cov` → **3 passed** (isolation, DB up): loader visible send→tool-gap→final-thinking, hidden after done; re-opened scratchpad carries `marker-thought-42`/`marker-final-thought-42`; `#send-status` carries state text, back to empty idle; reduced-motion context stills dots (computed `animation-name: none`) with loader still visible. - Regressions in isolation: `test_thinking_display` 5 passed, `test_stop_generation` 3 passed, `test_big_read_progress` 4 passed, `test_loading_feedback` 5 passed. - `uv run pytest` → **2314 passed**; `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (>90%); `uv run ruff check .` clean; `uv run pyright` → 0 errors, 0 warnings. - All terminal paths (done→idle L2554, stream error→error L2543, timeout→error L2247) funnel through `setUiState` — never-stale by construction. - No defects found; no code changes made in this pass. Commit + phase-dir move left to the harness per pipeline rules (working tree carries all changes). - Next pending phase: `108_history_wire_check` (still in `todo/`; out of scope here).
11 KiB
Phase 109 — Never-frozen turn: re-expanding thinking block + the persistent in-turn loader
Source: TODO.md L3 (owner 2026-09-16): "Thinking can happen after the model starts responding. This sometimes results in a the chat appearing 'frozen' because the model responds, calls a tool, then continues thinking without re-expanding the thinking block. There should be a visual that the chat is still progressing regardless of what state it's in (some kind of loader will do)."
Story: n/a (owner request — extends the phase 17/48/87 thinking/tool feedback under the PLAN §7.4 never-stale contract; the phase's E2E proves the reported repro no longer freezes).
Context (traced 2026-09-16): the turn's visible feedback is state-driven in frontend/assets/app.js: the UI_STATE machine (L340-345: idle/thinking/streaming/error) is owned by setUiState (L1243) — the typing bubble (a #typing-indicator message with the animated .bubble.typing dots, addTyping L859, the 10s elapsed-seconds clock L1137) shows ONLY in thinking; inFlight = thinking|streaming drives the Stop button (L1251-1255); #send-status is the sole a11y live region (L347-353; visual elements are aria-hidden — the L1791 house pattern). The reported freeze, exactly as the owner described it: the delta handler (L2357-2370) runs setUiState(streaming) on the FIRST delta — which removeTyping()s the dots — then closeThinkingBlock(wrap) (L905-909: "auto-collapse; idempotent, never reopens"). A LATER thinking frame (the next agent round — the model answered, called a tool, then thinks again) hits the thinking handler (L2268-2301), which only appends to the collapsed block's .thinking-text — nothing is visible: the answer text is static, the dots are gone, the scratchpad is closed → the chat reads as frozen. Every OTHER state already has a cue: pre-delta thinking = live open block + dots; tool = the .tool-call line with the phase-87 (Ns) elapsed counter (plus relabeled dots pre-delta); streaming = growing text; retry = the status line. The post-delta thinking gap is the ONE uncovered state — and the owner wants a constant cue anyway ("regardless of what state it's in"). The CSS lives in frontend/assets/styles.css (the typing-dots rules there; prefers-reduced-motion is house law, §7.2). House patterns: unit pins read the assets as text (tests/unit/test_frontend_tool_states.py / test_frontend_feedback.py already pin the thinking-block + typing behavior); the mock LLM has THINKING_TRIGGER = "think out loud" (mock_llm.py L500 — streams ~700 chars of reasoning_content ahead of content) and the multi-round tool_calls markers (L77-191); the house rule is that a marker/regex change lands WITH its consuming task (PLAN §4).
Objective
The chat never reads as frozen: (1) a thinking frame RE-OPENS the thinking block after the answer has started (the block is open-while-thinking / closed-while-answering — the reported symptom, fixed at the handler), and (2) a compact persistent loader is visible for the ENTIRE active turn (send → terminal frame) in the composer status area — the constant progress cue the owner asked for, driven by the single setUiState owner so it can never go stale (§7.4).
Dependencies
17_thinking_display(complete) — the thinking block, the follow-the-tail pin contract (THINKING_NEAR_BOTTOM_PX), the restore-path collapsed rendering; the block's unit pins live in thetest_frontend_*suites this phase extends.48_stop_generation(complete) — theinFlight/Stop-button state ownership the loader's single-owner toggle joins.87_big_read_progress(complete) — the.tool-callline + thearmToolLineClock/settleToolLine(Ns)counter (the "at least one cue" inventory's tool entry).06_loading_feedback(complete) — the UI state machine + the never-stale feedback contract this phase extends (every state keeps a defined UI).
Design (shared by all tasks — the executor reads this, not the chat)
- D15 — The thinking block becomes a TOGGLE (task 01). The
thinkingSSE handler gainsblock.open = trueafterensureThinkingBlock(wrap)(idempotent — a no-op while already open, so the pre-delta live flow is byte-identical in behavior); thedeltahandler KEEPS itscloseThinkingBlock(wrap). The contract flips from "never reopens" to open-while-thinking, closed-while-answering — the block reflects the model's current activity in every agent round.closeThinkingBlock's docstring/comment updates (the "never reopens" claim is gone — the delta handler closes, the thinking handler opens). The follow-the-tail pin logic (L2293:block.open && isThinkingNearBottom(textEl)measured BEFORE the re-render) is UNCHANGED — it already keys offblock.open, so a re-opened block resumes pinned tail-following exactly like the live pre-delta block. The phase-14 RESTORE path (renderStoredMessageL1517-1518) still renders stored blocks collapsed — untouched. - D16 — The turn loader (task 02): a static shell element, single-owner visibility.
frontend/index.html— ONE static element in the composer's status row (next to the#send-statuslive region):<div id="turn-loader" class="turn-loader" aria-hidden="true" hidden></div>— static markup, hidden by default (no JS-built HTML — the createElement/textContent house rule; no document-derived data anywhere near it).frontend/assets/app.js—setUiState(L1243) is the SOLE owner, exactly like the existingis-stoptoggle:turnLoader.hidden = !inFlight(shown iffuiState ∈ {thinking, streaming}). Every terminal path funnels throughsetUiState(done →idle, error →error, stop/timeout →error/idleper the existing handlers), so the loader CANNOT be left visible in a terminal state — the §7.4 never-stale guarantee comes from the single-owner pattern, not from per-handler cleanup.frontend/assets/styles.css—.turn-loadernext to the typing-dots rules: compact, reuses the EXISTING typing-dot animation (same keyframes/dot styling — no new animation family), provenance comment citing phase 109 +TODO.mdL3, and aprefers-reduced-motionvariant mirroring the typing dots' treatment (static dots, no pulse). Contrast N/A (the dots are decorative —aria-hidden+ the#send-statusannouncer carry meaning; §7.2 "text + color, never color alone" — the state TEXT stays in#send-status).- The invariant (unit + E2E): while a turn is active, at least one visible progress cue is ALWAYS present — the loader (constant, D16), the open thinking block (thinking frames, D15), the tool-line
(Ns)counter (tool frames, phase 87), or the growing answer text (streaming). D16 makes it true by construction; task 03 proves the reported repro (delta → tool → thinking-after-delta) no longer freezes.
- NOT touched: the
UI_STATEset, theSEND_STATUScopy, the typing bubble's own lifecycle (it still shows only pre-delta, per the phase-17 contract — the loader is a SEPARATE constant cue, not a re-homing of the dots),#send-status(unchanged — still the sole a11y announcer), the mock's EXISTING markers, the server (this is a pure UI phase —app/is untouched, soapp/coverage is a regression check only),PLAN.md, completed phases.
Tasks
01_thinking_block_reexpand.md— thethinkinghandler re-opens the collapsed block; the delta handler keeps closing; the "never reopens" narrative updated; unit pins (read-the-assets pattern).02_turn_active_loader.md— the static#turn-loaderelement, thesetUiStatesingle-owner toggle, the CSS (reused dot animation + reduced-motion + provenance), unit pins.03_e2e_and_gate.md— the new mock marker forcing the reporteddelta → tool → thinking-after-deltasequence (lands WITH this task), the dedicated E2Etests/e2e/test_turn_progress_loader.py(isolation), the a11y pass, the full gate, the atomic commit.
Testing & Quality
- Unit —
tests/unit/test_frontend_turn_loader.py(NEW, the house read-the-assets-as-text pattern): task 01 pins — thethinkinghandler contains theblock.open = truere-open (afterensureThinkingBlock), thedeltahandler still callscloseThinkingBlock,closeThinkingBlock's docstring no longer claims "never reopens", the restore path (renderStoredMessage) still setsblock.open = false; task 02 pins —index.htmlcarries exactly one#turn-loaderwitharia-hidden="true"+hidden,setUiStateis the SOLE writer ofturnLoader.hidden(cross-file single-owner check:turnLoader.hiddenappears nowhere else inapp.js), the.turn-loaderCSS rule exists next to the typing rules with the reduced-motion variant + the phase-109 provenance comment. - E2E (mandatory, A16) —
tests/e2e/test_turn_progress_loader.py(task 03):uv run pytest tests/e2e/test_turn_progress_loader.py -v --no-covwith the DB up. - Coverage: >90% on
app/(uv run pytest --cov=app --cov-report=term-missing— unchanged by this UI phase; the gate is the regression check).
Completion Criteria
- A
thinkingframe after the answer has started RE-OPENS the thinking block (the block is open-while-thinking / closed-while-answering; pre-delta flow + restore path unchanged — unit-pinned). #turn-loaderis visible for the entire active turn and hidden in every terminal state — owned solely bysetUiState(unit-pinned single-owner + the E2E's start/mid/end samples).- E2E green in isolation: the reported repro (delta → tool → thinking-after-delta) shows the re-opened scratchpad with the new thinking text, the loader visible throughout, hidden after
done;#send-statuscarries the state text (the loader isaria-hidden). - The phase-17/48/87/6 regressions green in isolation:
uv run pytest tests/e2e/test_thinking_display.py -v --no-cov,tests/e2e/test_stop_generation.py -v --no-cov,tests/e2e/test_big_read_progress.py -v --no-cov,tests/e2e/test_loading_feedback.py -v --no-cov;uv run pytestgreen;uv run pytest --cov=app --cov-report=term-missingTOTAL >90%;uv run ruff check . && uv run pyrightclean. - One
--no-gpg-signcommit; phase dir moved to.agents/phases/complete/by the pipeline gate.
Locked decisions
- D15 — The thinking block is a toggle, not a one-way door (owner-instructed: "continues thinking without re-expanding the thinking block" is THE reported defect).
thinkingframes open the block (idempotent),deltaframes close it; the follow-the-tail pin contract and the restore path are unchanged. No new block, no new state — the existing scratchpad reflects the model's current activity in every round. - D16 — The constant cue is a separate static loader, owned by
setUiState(owner-instructed: "a visual that the chat is still progressing regardless of what state it's in — some kind of loader will do"). A static#turn-loaderin the composer status row (reused typing-dot animation,aria-hidden,#send-statusstays the sole announcer), shown iffinFlight— the single-owner pattern makes a stale loader impossible. The typing bubble's own pre-delta lifecycle is NOT re-homed (the phase-17 contract stands); the loader ADDS the constant the owner asked for.
Commit
git add frontend/index.html frontend/assets/app.js frontend/assets/styles.css tests/ .agents/phases/ && git commit --no-gpg-sign -m "feat(chat): never-frozen turn — re-expanding thinking block + the persistent in-turn loader"