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") {
+51
View File
@@ -1197,6 +1197,57 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
details.thinking summary::before { transition: none; }
}
/* Phase 109 (TODO.md L3, D16): the persistent in-turn loader — the
constant progress cue in the composer's status row, visible for the
ENTIRE active turn (send → terminal frame). Its hidden attribute is
owned solely by app.js's setUiState (shown iff inFlight — hidden by
construction in every terminal state, PLAN §7.4). DECORATIVE
(aria-hidden in index.html): #send-status carries the state's
meaning, so contrast is N/A here (§7.2 "text + color, never color
alone" — the state TEXT stays in #send-status). Compact horizontal
three-dot indicator reusing the EXISTING typing-dot animation (the
`typing` keyframes above — no new animation family), sized down for
the status row (4px dots, 3px gap): the element itself is the
middle dot (0.15s delay, like .typing span:nth-child(2)),
::before/::after the outer two. */
.turn-loader {
display: inline-flex;
align-items: center;
gap: 3px;
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--ink-soft);
opacity: 0.5;
align-self: center; /* the composer row is flex-end aligned — center the cue */
animation: typing 1.2s infinite ease-in-out;
animation-delay: 0.15s;
}
.turn-loader::before,
.turn-loader::after {
content: "";
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--ink-soft);
opacity: 0.5;
animation: typing 1.2s infinite ease-in-out;
}
.turn-loader::before { animation-delay: 0s; }
.turn-loader::after { animation-delay: 0.3s; }
/* Phase 109 (TODO.md L3, D16): reduced motion — the loader mirrors the
typing dots' treatment (static dots, no pulse — §7.2 house law).
This override MUST sit AFTER the main .turn-loader rule above:
equal specificity, and the later rule wins the cascade — a media
block placed before the main rule would lose to the `animation:
typing …` declaration and the dots would keep pulsing under
reduced motion (the phase-109 task-03 E2E caught it). */
@media (prefers-reduced-motion: reduce) {
.turn-loader,
.turn-loader::before,
.turn-loader::after { animation: none; opacity: 0.7; }
}
/* ---------- Empty state & suggestions ---------- */
.empty-state {
background: var(--surface);
+12
View File
@@ -295,6 +295,18 @@
<span class="btn-label" id="send-label">Send</span>
<span class="visually-hidden" aria-live="polite" id="send-status"></span>
</button>
<!-- Phase 109 (TODO.md L3, D16): the persistent in-turn loader —
ONE static element in the composer's status row, the
visible companion of the #send-status line. It is
DECORATIVE (aria-hidden): #send-status stays the sole a11y
announcer, so the state's meaning never moves (the L1791
house pattern). Hidden by default (idle on load); app.js's
setUiState is the SOLE writer of its hidden attribute —
shown iff the turn is in flight, hidden by construction in
every terminal state (PLAN §7.4 never-stale, no
per-handler cleanup). Never constructed in JS: the
createElement/textContent house rule. -->
<div id="turn-loader" class="turn-loader" aria-hidden="true" hidden></div>
</form>
</div>
</div>