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).
This commit is contained in:
+38
-7
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user