phase: 109_turn_progress_loader
Build and Push Containers / build-and-push-app (push) Successful in 4m39s
Build and Push Containers / build-and-push-db (push) Successful in 35s

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).
This commit is contained in:
2026-09-14 01:27:16 -04:00
parent fbbd98d734
commit 3a81793565
22 changed files with 1459 additions and 7 deletions
@@ -0,0 +1,52 @@
# 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
```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"
```
@@ -0,0 +1,28 @@
# Task 01 — The thinking block becomes a toggle: re-open on `thinking` frames, close on `delta`
**Phase:** `109_turn_progress_loader` · **Source:** `TODO.md` L3 — "the model responds, calls a tool, then continues thinking without re-expanding the thinking block."
## Objective
Fix the reported freeze at its source: a `thinking` frame that arrives after the answer has started re-opens the collapsed thinking block (D15) — the scratchpad is visible exactly while the model is thinking, in every agent round — while the `delta` handler keeps closing it and nothing else about the block's lifecycle changes.
## Work
1. `frontend/assets/app.js` — the `thinking` SSE handler (L2268-2301): after `const block = ensureThinkingBlock(wrap);`, add `block.open = true;` (idempotent — while the block is already open (the pre-delta live flow) this is a no-op, so that flow's behavior is unchanged; after a `delta` closed it, this re-opens it for the new round's thinking). Update the handler's comment to state the toggle contract (D15): open-while-thinking, closed-while-answering — phase 109, `TODO.md` L3.
- The follow-the-tail logic below it (`const pinned = block.open && isThinkingNearBottom(textEl);`, L2293) is UNCHANGED — it already reads `block.open` before the re-render, so a re-opened block resumes pinned tail-following exactly like the live pre-delta block.
2. `frontend/assets/app.js` — `closeThinkingBlock` (L905-909): keep the function exactly as-is (the `delta` handler still calls it, L2369); update its docstring/comment — the "never reopens" claim is replaced by the toggle contract (the `thinking` handler re-opens; the `delta` handler closes).
- `renderStoredMessage` (L1517-1518 — the phase-14 restore path) is UNTOUCHED: stored blocks still render collapsed.
3. `tests/unit/test_frontend_turn_loader.py` (NEW — the house read-the-assets-as-text pattern; copy the file header/docstring conventions from `tests/unit/test_frontend_tool_states.py`):
- `test_thinking_handler_reopens_the_collapsed_block` — the `thinking` handler source contains `block.open = true` positioned AFTER the `ensureThinkingBlock(wrap)` line (order asserted — the block must exist before it opens).
- `test_delta_handler_still_closes_the_block` — the `delta` handler still calls `closeThinkingBlock(wrap)` (the close side of the toggle survives).
- `test_close_thinking_block_docstring_says_toggle_not_one_way` — `closeThinkingBlock`'s comment no longer contains "never reopens"; the toggle contract (open-while-thinking / closed-while-answering) is documented (assert the new wording, e.g. it names the `thinking` handler's re-open).
- `test_restore_path_still_collapses_stored_blocks` — `renderStoredMessage` still sets `block.open = false` (phase-14 contract regression).
- Cross-file: the `THINKING_NEAR_BOTTOM_PX` pin + `isThinkingNearBottom` logic are untouched (assert the pre-render `block.open &&` guard line still exists in the handler).
4. Run `uv run pytest tests/unit/test_frontend_turn_loader.py -v` + the existing frontend suites that pin this area (`tests/unit/test_frontend_tool_states.py tests/unit/test_frontend_feedback.py tests/unit/test_frontend_scroll.py tests/unit/test_big_read_progress.py`) — green.
## Testing & Quality
- Unit: the toggle contract pinned at the source level (the house pattern for `app.js` behavior — no browser).
- Coverage: **>90%** on `app/` (unchanged — pure frontend task; the gate is the regression check).
## Completion Criteria
- [ ] The `thinking` handler re-opens the block (unit-pinned, correct order); the `delta` handler still closes it; the "never reopens" narrative is gone (unit-pinned)
- [ ] The restore path + the follow-the-tail pin logic are untouched (unit-pinned regressions)
- [ ] The existing frontend unit suites green; `uv run ruff check . && uv run pyright` clean
@@ -0,0 +1,37 @@
# Task 02 — The persistent in-turn loader: static `#turn-loader`, single-owner visibility in `setUiState`
**Phase:** `109_turn_progress_loader` · **Source:** `TODO.md` L3 — "There should be a visual that the chat is still progressing regardless of what state it's in (some kind of loader will do)."
## Objective
Add the constant progress cue (D16): a compact animated loader in the composer status row that is visible for the ENTIRE active turn (send → terminal frame), owned solely by `setUiState` so it can never be left stale — and hidden in every terminal state by construction.
## Work
1. `frontend/index.html` — ONE static element in the composer's status row (the L290-300 region holding `#char-count` / `#send-btn` / `#send-status` — inspect the actual markup and place it adjacent to `#send-status` so it reads as the status line's companion):
```html
<div id="turn-loader" class="turn-loader" aria-hidden="true" hidden></div>
```
Static markup, `hidden` by default (idle on load). No JS-built HTML anywhere (the createElement/textContent house rule — this element is never constructed in JS).
2. `frontend/assets/app.js` — the single-owner toggle (D16):
- At the top of the module with the other element lookups (near `const sendStatus = document.querySelector("#send-status");` L307): `const turnLoader = document.querySelector("#turn-loader");`
- Inside `setUiState` (L1243), next to the existing `sendBtn.classList.toggle("is-stop", inFlight);` (L1251-1255): `turnLoader.hidden = !inFlight;` — shown iff `uiState ∈ {thinking, streaming}`. This is the SOLE writer of `turnLoader.hidden` in the file: every terminal path (done → `idle`, error → `error`, stop/timeout → the existing error/idle landings) funnels through `setUiState`, so the loader is hidden in every terminal state BY CONSTRUCTION — the §7.4 never-stale guarantee, no per-handler cleanup (that is the point; comment it that way).
- Do NOT touch the typing bubble's lifecycle (`addTyping`/`removeTyping` stay exactly as-is — the phase-17 pre-delta contract stands; the loader is a separate constant cue), the `SEND_STATUS` copy, or `#send-status` (still the sole a11y announcer — the loader is `aria-hidden` decoration, the L1791 house pattern).
3. `frontend/assets/styles.css` — the `.turn-loader` rule NEXT TO the typing-dots rules (find the `.bubble.typing` / dots animation block):
- Compact horizontal three-dot indicator, REUSING the existing typing-dot keyframes/dot styling (same animation name — no new animation family; sized down for the status row).
- Provenance comment: phase 109, `TODO.md` L3 — the constant in-turn progress cue; decorative (`aria-hidden`), `#send-status` carries the meaning.
- A `prefers-reduced-motion` variant mirroring the typing dots' treatment (static dots, no pulse — §7.2 house law).
4. `tests/unit/test_frontend_turn_loader.py` (EXTEND the file task 01 created):
- `test_index_html_carries_exactly_one_turn_loader` — `frontend/index.html` contains exactly ONE `id="turn-loader"`, with `aria-hidden="true"` and the `hidden` attribute (hidden by default).
- `test_set_ui_state_is_the_sole_owner_of_the_loader` — in `app.js`: `turnLoader.hidden` appears EXACTLY ONCE, inside `setUiState` (the cross-file single-owner check — grep the file text; any second write site fails the test, keeping the never-stale guarantee structural).
- `test_loader_css_reuses_the_typing_animation_and_reduced_motion` — the `.turn-loader` rule exists in `styles.css` after (or adjacent to) the typing-dots rules, references the SAME animation name as the typing dots, and a `prefers-reduced-motion` block covers it (static, no pulse); the provenance comment names phase 109.
- `test_loader_is_aria_hidden_and_status_untouched` — the loader element is `aria-hidden`; `#send-status`'s attributes are unchanged in `index.html` (still the live region — the house a11y split).
5. Run `uv run pytest tests/unit/test_frontend_turn_loader.py tests/unit/test_frontend_tool_states.py tests/unit/test_theme_frontend.py -v` (the theme suite parses `styles.css` — a new CSS rule must not break the built-in-theme pins) — green.
## Testing & Quality
- Unit: the single-owner invariant + the markup/CSS contract pinned at the source level (the house pattern).
- Coverage: **>90%** on `app/` (unchanged — pure frontend task; the gate is the regression check).
## Completion Criteria
- [ ] `#turn-loader` exists exactly once in `index.html` (static, `aria-hidden`, hidden by default); `setUiState` is its sole visibility owner (unit-pinned)
- [ ] The CSS reuses the typing-dot animation, has the reduced-motion variant + the phase-109 provenance comment (unit-pinned); the theme CSS-parsing suites stay green
- [ ] The typing bubble's lifecycle, `SEND_STATUS`, and `#send-status` are byte-unchanged in behavior (the existing frontend suites green)
- [ ] `uv run ruff check . && uv run pyright` clean; full `uv run pytest` green
@@ -0,0 +1,40 @@
# Task 03 — E2E: the reported repro (delta → tool → thinking-after-delta) + full gate + commit
**Phase:** `109_turn_progress_loader` · **Source:** `TODO.md` L3 — "the model responds, calls a tool, then continues thinking without re-expanding the thinking block" + "a visual that the chat is still progressing regardless of what state it's in"; AGENTS.md rules 4/8/9.
## Objective
Prove the fix end to end with a dedicated Playwright suite: a deterministic mock sequence that replays the owner's exact repro (answer starts, tool call, thinking AFTER the answer) must show the re-opened scratchpad, the loader visible throughout, and clean terminal states — then run the full gate and land the atomic commit.
## Work
1. `tests/e2e/mock_llm.py` — ONE new marker (the house rule: marker/regex changes land WITH their consuming task — this task; document it in the module docstring next to the existing markers):
- A new `*_TRIGGER` constant + branch (checked like the other user-message markers, BEFORE the DEFLECT_MODE branch) whose question forces the reported sequence with BAKED-IN DELAYS (mid-turn windows of ≥1 s each, so Playwright assertions are deterministic — the `slow_llm.py` precedent for deliberate pacing):
- model call 1: ~2 s pre-delay (model latency — the loader's start-state window), then a short `content` delta (2-3 chunks; NO reasoning), then an `ls` `tool_calls` delta (synthetic id, no arguments — the L77-81 pattern), `finish_reason: "tool_calls"`.
- model call 2 (after the server's `tool_result`): `reasoning_content` chunks (~10 × ~0.3 s), then a `content` delta (2-3 chunks ending in a DISTINCTIVE final sentence the tests can match), then a FINAL `reasoning_content` chunk (3 × ~0.3 s), then finish.
- The server is position-independent over the wire (each `reasoning_content` chunk → a `thinking` SSE frame, each `content` chunk → a `delta` frame — `app/rag/llm.py` L572+), so the resulting SSE is exactly `delta → tool → tool_result → thinking → delta → thinking → done` — the owner's repro, deterministic.
2. `tests/e2e/test_turn_progress_loader.py` (NEW — copy the app-server + fixture idiom from `tests/e2e/test_llm_history.py`: module-scoped mock-LLM app, fixture-docs import, `login`, per-test fresh conversation; module docstring: story n/a — owner request 2026-09-16, the isolation command, the marker contract, and what each test pins). Isolation: `uv run pytest tests/e2e/test_turn_progress_loader.py -v --no-cov` (DB up). Each test sends the marker question in its OWN fresh conversation (one full turn per test):
- `test_loader_visible_from_send_through_the_tool_gap` — right after the send (inside call 1's 2 s pre-delay window): `#turn-loader` is VISIBLE (the thinking state, no frame yet); then wait for the `.tool-call` line to appear (the turn is provably in flight) → the loader is STILL visible, the tool line carries the phase-87 elapsed counter, and `#send-status` carries a state text (not empty).
- `test_thinking_block_reopens_after_delta_with_visible_loader` — wait until the thinking block is open with non-empty `.thinking-text` AND the answer bubble already carries call 1's content (i.e. the post-delta re-open — THE reported symptom's state): assert the block is `open`, the new thinking text is VISIBLE in it, and the loader is STILL visible (the frozen window is gone). Then wait for the terminal state (call 2's distinctive final sentence in the bubble, or the send button back to "Send"): the loader is HIDDEN, the block is still open (the LAST frame was thinking), `.thinking-text` is non-empty, the bubble contains BOTH call 1's and call 2's content, and the send button reads "Send" (not "Stop").
- `test_loader_a11y_and_reduced_motion` — after a full turn: the loader element is `aria-hidden="true"` in the DOM (the `#send-status` live region remains the sole announcer — assert its post-done text follows the `SEND_STATUS` idle shape, i.e. not stuck on a mid-turn label); then, in a context with `reducedMotion: "reduce"` (Playwright context option), send a second turn and assert the loader is still visible mid-turn (the reduced-motion variant renders the static dots — the CSS rule, not the visibility, is what changes).
3. Run the suite in isolation — all three tests green.
4. **The full gate** (DB up; every command passes before the commit):
- `uv run pytest` — green.
- `uv run pytest --cov=app --cov-report=term-missing` — TOTAL >90%.
- Regressions in isolation (the feedback-state history this phase extends): `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 ruff check . && uv run pyright` — clean.
- Scope check: `git diff --stat` shows only `frontend/index.html`, `frontend/assets/app.js`, `frontend/assets/styles.css`, `tests/**`, `.agents/phases/**` (this is a UI phase — NO `app/` changes; if the diff shows any, stop and fix the scope).
5. **The commit** (exactly one, `--no-gpg-sign`):
```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"
```
6. Move the phase directory: `mv .agents/phases/todo/109_turn_progress_loader .agents/phases/complete/` (the pipeline gate does this on success — do it only after the commit, matching how prior phases recorded the move).
## Testing & Quality
- E2E: the owner's repro replayed deterministically (the marker's baked delays make every window assertion race-free); the a11y split (visual loader + `#send-status` announcer) + the reduced-motion variant pinned.
- Coverage: **>90%** on `app/` (unchanged by this UI phase — the gate is the regression check).
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_turn_progress_loader.py -v --no-cov` green in isolation (DB up) — all three tests
- [ ] The reported repro is pinned: post-delta thinking re-opens the block with visible text while the loader stays visible; terminal states are clean (loader hidden, button "Send", status not stuck)
- [ ] The regression suites (`test_thinking_display`, `test_stop_generation`, `test_big_read_progress`, `test_loading_feedback`) green in isolation; `uv run pytest` green; coverage TOTAL >90%; ruff + pyright clean
- [ ] Exactly one new commit with the phase message, `--no-gpg-sign`; the diff scoped to `frontend/` + `tests/` + `.agents/phases/`; `git status` clean afterwards
- [ ] Phase dir at `.agents/phases/complete/109_turn_progress_loader/`