# 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 the `test_frontend_*` suites this phase extends. - `48_stop_generation` (complete) — the `inFlight`/Stop-button state ownership the loader's single-owner toggle joins. - `87_big_read_progress` (complete) — the `.tool-call` line + the `armToolLineClock`/`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 `thinking` SSE handler gains `block.open = true` after `ensureThinkingBlock(wrap)` (idempotent — a no-op while already open, so the pre-delta live flow is byte-identical in behavior); the `delta` handler KEEPS its `closeThinkingBlock(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 off `block.open`, so a re-opened block resumes pinned tail-following exactly like the live pre-delta block. The phase-14 RESTORE path (`renderStoredMessage` L1517-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-status` live region): `` — 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 existing `is-stop` toggle: `turnLoader.hidden = !inFlight` (shown iff `uiState ∈ {thinking, streaming}`). Every terminal path funnels through `setUiState` (done → `idle`, error → `error`, stop/timeout → `error`/`idle` per 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-loader` next 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.md` L3, and a `prefers-reduced-motion` variant mirroring the typing dots' treatment (static dots, no pulse). Contrast N/A (the dots are decorative — `aria-hidden` + the `#send-status` announcer 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_STATE` set, the `SEND_STATUS` copy, 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, so `app/` coverage is a regression check only), `PLAN.md`, completed phases. ## Tasks 1. `01_thinking_block_reexpand.md` — the `thinking` handler re-opens the collapsed block; the delta handler keeps closing; the "never reopens" narrative updated; unit pins (read-the-assets pattern). 2. `02_turn_active_loader.md` — the static `#turn-loader` element, the `setUiState` single-owner toggle, the CSS (reused dot animation + reduced-motion + provenance), unit pins. 3. `03_e2e_and_gate.md` — the new mock marker forcing the reported `delta → tool → thinking-after-delta` sequence (lands WITH this task), the dedicated E2E `tests/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 — the `thinking` handler contains the `block.open = true` re-open (after `ensureThinkingBlock`), the `delta` handler still calls `closeThinkingBlock`, `closeThinkingBlock`'s docstring no longer claims "never reopens", the restore path (`renderStoredMessage`) still sets `block.open = false`; task 02 pins — `index.html` carries exactly one `#turn-loader` with `aria-hidden="true"` + `hidden`, `setUiState` is the SOLE writer of `turnLoader.hidden` (cross-file single-owner check: `turnLoader.hidden` appears nowhere else in `app.js`), the `.turn-loader` CSS 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-cov` with 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 `thinking` frame 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-loader` is visible for the entire active turn and hidden in every terminal state — owned solely by `setUiState` (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-status` carries the state text (the loader is `aria-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 pytest` green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL >90%; `uv run ruff check . && uv run pyright` clean. - [ ] One `--no-gpg-sign` commit; 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).** `thinking` frames open the block (idempotent), `delta` frames 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-loader` in the composer status row (reused typing-dot animation, `aria-hidden`, `#send-status` stays the sole announcer), shown iff `inFlight` — 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 ```bash 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" ```