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
+38 -7
View File
@@ -305,6 +305,7 @@ const input = document.querySelector("#message-input");
const sendBtn = document.querySelector("#send-btn");
const sendLabel = document.querySelector("#send-label");
const sendStatus = document.querySelector("#send-status");
const turnLoader = document.querySelector("#turn-loader"); // phase 109 (D16): the persistent in-turn loader — ships hidden; setUiState is its sole visibility owner
const banner = document.querySelector("#kb-banner");
const bannerText = document.querySelector("#kb-banner-text");
const versionEl = document.querySelector("#app-version");
@@ -877,14 +878,24 @@ function removeTyping() {
document.querySelector("#typing-indicator")?.remove();
}
/* ---------- thinking block (phase 17) ----------
/* ---------- thinking block (phase 17; phase 109 makes it a toggle) ----------
* The model's reasoning streams into a collapsible <details> block ABOVE
* the answer bubble: created OPEN on the first `thinking` event,
* auto-collapsed when the first answer token lands, and user-toggleable
* afterwards (native <details>/<summary> — a real focusable control).
* the answer bubble: created OPEN on the first `thinking` event, then
* user-toggleable (native <details>/<summary> — a real focusable
* control).
*
* Phase 109 (TODO.md L3, D15) — the block is a TOGGLE, not a one-way
* door: open-while-thinking, closed-while-answering. The `thinking` SSE
* handler re-opens it (`block.open = true`, idempotent) — a `thinking`
* frame after the answer has started (the next agent round: the model
* answered, called a tool, then thinks again) re-opens the collapsed
* scratchpad — and the `delta` handler closes it (closeThinkingBlock,
* idempotent). The block reflects the model's current activity in every
* agent round — the "frozen chat" repro from TODO.md L3 is gone. The
* phase-14 restore path (renderStoredMessage) still renders stored
* blocks COLLAPSED — untouched.
* ensureThinkingBlock is idempotent (returns the existing block if any);
* closeThinkingBlock never reopens a block once the answer has started,
* so a late/interleaved `thinking` event only appends to the closed text. */
* closeThinkingBlock is a no-op without a block. */
function ensureThinkingBlock(wrap) {
let block = wrap.querySelector(".thinking");
if (!block) {
@@ -1249,6 +1260,14 @@ export function setUiState(state, errorDetail = "") {
const inFlight = state === UI_STATE.thinking || state === UI_STATE.streaming;
sendBtn.disabled = false; // enabled in every state — Stop is a control
sendBtn.classList.toggle("is-stop", inFlight);
// Phase 109 (TODO.md L3, D16): the persistent in-turn loader — this
// line is its SOLE visibility owner (unit-pinned single-owner check):
// shown iff a turn is in flight (thinking|streaming). Every terminal
// path funnels through setUiState (done → idle, error → error,
// stop/timeout → their error/idle landings), so it hides in every
// terminal state BY CONSTRUCTION — the §7.4 never-stale guarantee is
// structural, not per-handler cleanup (that is the point).
turnLoader.hidden = !inFlight;
sendBtn.querySelector(".spinner").hidden = true; // the Stop label carries it
sendLabel.textContent = inFlight ? "Stop" : "Send";
sendStatus.textContent = SEND_STATUS[state]?.() ?? "";
@@ -2275,12 +2294,24 @@ async function runTurn(text, { reask = false } = {}) {
// "thinking" (the button stays the enabled "Stop" control —
// phase 48 — #send-status unchanged) — the live block simply
// replaces the typing dots as the visible feedback.
// Phase 109 (TODO.md L3, D15): the block is a TOGGLE —
// open-while-thinking, closed-while-answering. The re-open
// assignment below (right after ensureThinkingBlock) re-opens a
// block a previous `delta` closed — the next agent round's
// thinking (the model answered, called a tool, then thinks
// again) no longer lands in an invisible collapsed scratchpad;
// while the block is already open (the pre-delta live flow) it
// is a no-op — that flow is unchanged.
thinkingAcc += ev.text || "";
sawThinking = true;
clearTurnTimeout(); // the stream is alive — as the first delta says
if (!wrap) wrap = addMessage("brain", "");
removeTyping(); // the live block replaces the dots as feedback
const block = ensureThinkingBlock(wrap);
// Phase 109 (D15): open-while-thinking — idempotent: a no-op
// while already open (the pre-delta live flow), a re-open after
// a `delta` closed the block (the next agent round).
block.open = true;
const textEl = block.querySelector(".thinking-text");
// Pin state is measured BEFORE the re-render: a chunk taller
// than the 32px band — any paragraph break ("\n\n") or a few
@@ -2373,7 +2404,7 @@ async function runTurn(text, { reask = false } = {}) {
acc += ev.text || "";
if (uiState === UI_STATE.thinking) setUiState(UI_STATE.streaming);
if (!wrap) wrap = addMessage("brain", ""); // first token: live bubble in
closeThinkingBlock(wrap); // auto-collapse; idempotent, never reopens
closeThinkingBlock(wrap); // auto-collapse; idempotent — the next `thinking` frame re-opens it (phase 109, D15)
wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc);
// No page scroll (phase 42): the answer never follows the viewport.
} else if (ev.type === "done") {