Files
brain-of-reese/.agents/phases/complete/109_turn_progress_loader/00_phase.md
T
ducoterra 3a81793565
Build and Push Containers / build-and-push-app (push) Successful in 4m39s
Build and Push Containers / build-and-push-db (push) Successful in 35s
phase: 109_turn_progress_loader
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).
2026-09-14 01:27:16 -04:00

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

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"