diff --git a/README.md b/README.md index dfcab8c..45e7ba9 100644 --- a/README.md +++ b/README.md @@ -457,17 +457,19 @@ open, its summary carries a live status ("Thinking…" / settles; a turn with no thinking and no tools renders no bubble at all (the answer layout is byte-identical to pre-phase). -The per-run blocks keep their own open/fold behavior: each block opens -and fills in live while its run streams, and the runs tuck themselves -away when the first answer token lands, when the turn ends, or when the -turn is stopped/failed — and every block and tool-call disclosure stays -click-toggleable afterwards (the bubble's summary is the at-rest entry -point). +The per-run blocks are plain and always visible inside the open +bubble: each block appears and fills in live while its run streams, +and the runs simply stop accumulating frames when the first answer +token lands, when the turn ends, or when the turn is +stopped/failed. They are distinct from each other by label + rail: +the brand-ink "Thinking" label on a brand rail, the accent-ink +"Tool call(s) (N)" label on a neutral rail. The tool calls ride the same timeline: each contiguous run of tool -calls gets its own collapsed-when-idle "Tool call(s) (N)" disclosure -(one per run, in arrival order), and the per-line elapsed-seconds -suffix targets the newest line of the active run. +calls gets its own "Tool call(s) (N)" block (one per run, in +arrival order; always visible while the bubble is open), and the +per-line elapsed-seconds suffix targets the newest line of the +active run. The timeline is persisted with the saved turn (an optional `segments` array — the per-run order; the concatenated `thinking` and the `tools` diff --git a/frontend/assets/app.js b/frontend/assets/app.js index 1d62dc5..06b968e 100644 --- a/frontend/assets/app.js +++ b/frontend/assets/app.js @@ -1409,16 +1409,19 @@ function removeTyping() { * the answer bubble, is OPEN from creation until the turn settles * (done / stop / the failed-turn funnel — settleReasoning, D2), and * is condensed in its ONE bounded .reasoning-body live window (320px - * task 03) with the pinned-tail follow (D3). The per-run blocks keep - * their structure and open/fold behavior (phase 158 D5) — only their - * PARENT changed: they append into the container's .reasoning-body - * (the per-run bubble anchor is superseded), arrival order preserved. - * The container's summary carries the D6 status literals: "Reasoning" - * (static) + a live span ("Thinking…" / "Calling …" / "N steps"). + * task 03) with the pinned-tail follow (D3). The per-run blocks are + * plain divs — NOT collapsible (the D5 refinement, owner 2026-10-06: + * a nested fold inside the container's fold was the defect — a tool + * frame used to fold the live thinking block and the visible + * reasoning vanished mid-turn); they append into the container's + * .reasoning-body (the per-run bubble anchor is superseded), arrival + * order preserved. The container's summary carries the D6 status + * literals: "Reasoning" (static) + a live span ("Thinking…" / + * "Calling …" / "N steps"). * A turn with no thinking and no tools creates NO container * (byte-identical DOM — D1/D4). * - * The model's reasoning streams into collapsible
blocks ABOVE + * The model's reasoning streams into per-run blocks ABOVE * the answer bubble, and the tool calls stream into per-run * disclosures — one CONTIGUOUS thinking run gets its own * details.thinking block and one CONTIGUOUS tool-call run gets its own @@ -1431,12 +1434,18 @@ function removeTyping() { * reply case); the bubble stays ONE element, its content accumulating * in arrival order. * - * D5: the active run's block is OPEN while its frames stream; the first - * frame of a new kind closes the previous run and opens the new one; - * the first `delta`, `done`, and the stop/abort settle fold the open - * runs (closeCurrentRun / closeAllRuns). The restore path - * (renderStoredMessage) and the shared page render everything - * COLLAPSED. + * D5 (owner 2026-10-06 — the phase-161 D5 refinement): the per-run + * blocks inside the container are NOT collapsible — they are always + * visible (the container is the ONE disclosure; a nested fold inside + * a fold was the defect: a tool frame used to fold the live thinking + * block and the visible reasoning vanished mid-turn). The block + * renders as a plain div (createThinkingRunEl / createToolRunEl) — + * always shown, the "Thinking" / "Tool call(s) (N)" label rides a + * static (non-interactive) header row. The PRE-phase-161 + * details-based blocks (createThinkingBlock / createToolDisclosure) + * stay for the LEGACY restore path (records WITHOUT `segments` + * render byte-identically — no container, the phase-17/117 + * disclosures). * * The thinking block's STATIC structure is the phase-17 template (a * static template with NO data — the plain `Thinking` literal, D3; the @@ -1457,6 +1466,25 @@ function createThinkingBlock() { return block; } +/* The per-run thinking block INSIDE the reasoning container (owner + * 2026-10-06, the D5 refinement): a plain div — NOT collapsible + * (the container is the ONE disclosure). A static .thinking-summary + * label (the phase-17 `Thinking` literal — position distinguishes the + * runs) + the .thinking-text scratchpad (the SAME class the phase-17 + * block uses — the renderMarkdown write is unchanged). Built with + * createElement only (innerHTML ABSENT — the house rule). */ +function createThinkingRunEl() { + const block = document.createElement("div"); + block.className = "thinking"; + const label = document.createElement("span"); + label.className = "thinking-summary"; + label.textContent = "Thinking"; // the phase-17 literal (D3) + const textEl = document.createElement("div"); + textEl.className = "thinking-text"; + block.append(label, textEl); + return block; +} + /* Phase 161 (D1/D6, owner 2026-10-05): the ONE reasoning * container's STATIC structure — details.reasoning wrapping * summary.reasoning-summary (the "Reasoning" literal — D6's ONLY @@ -1485,12 +1513,12 @@ function createReasoningWrap() { return cont; } -/* The tool-calls disclosure's STATIC structure — the phase-117 shape - * (summary.tool-calls-summary first + the .tool-calls list, role=list - * + aria-label "Tool calls"), built with createElement only (innerHTML - * ABSENT — the house rule). The caller owns open/fold (the live - * per-run path opens it, D5; the restore path creates it collapsed, - * phase 117 D3) and inserts it before the bubble. */ +/* The per-run tool disclosure's STATIC structure — the phase-117 + * shape (summary.tool-calls-summary first + the .tool-calls list, + * role=list + aria-label "Tool calls"), built with createElement only + * (innerHTML ABSENT — the house rule). The caller owns open/fold (the + * live per-run path opens it, D5; the restore path creates it + * collapsed, phase 117 D3) and inserts it before the bubble. */ function createToolDisclosure() { const container = document.createElement("details"); container.className = "tool-calls-disclosure"; @@ -1505,6 +1533,27 @@ function createToolDisclosure() { return container; } +/* The per-run tool block INSIDE the reasoning container (owner + * 2026-10-06, the D5 refinement): a plain div — NOT collapsible (the + * container is the ONE disclosure). A static .tool-calls-summary + * label (a span — the count rides it on every append, the phase-117 + * convention) + the SAME .tool-calls list (role=list + aria-label + * "Tool calls" — the appendToolLine shape is unchanged). Built with + * createElement only (innerHTML ABSENT). */ +function createToolRunEl() { + const container = document.createElement("div"); + container.className = "tool-calls-disclosure"; + const summary = document.createElement("span"); + summary.className = "tool-calls-summary"; + container.appendChild(summary); + const listEl = document.createElement("div"); + listEl.className = "tool-calls"; + listEl.setAttribute("role", "list"); + listEl.setAttribute("aria-label", "Tool calls"); + container.appendChild(listEl); + return container; +} + /* The turn's reasoning container — get-or-create (phase 161, D1): * on the turn's FIRST thinking/tool frame it creates the container * (createReasoningWrap), opens it (D1: open from creation until the @@ -1526,18 +1575,19 @@ function ensureReasoningWrap(wrap) { } /* The active thinking run's block — or, when the previous run is a - * different kind (or there is none): close it, create a NEW - * details.thinking (OPEN — D5), append it into the phase-161 - * reasoning container's .reasoning-body (D1 — the container sits - * before the bubble; a new run appends after the previous run's - * block, so arrival order is preserved), and open a fresh run. The - * phase-109 D15 re-open of one shared block is superseded: a - * post-delta thinking frame lands in a NEW block, not a re-open. */ + * different kind (or there is none): end it, create a NEW plain + * div.thinking (always visible — the D5 refinement: the container is + * the ONE disclosure, the inner runs never fold), append it into the + * phase-161 reasoning container's .reasoning-body (D1 — the + * container sits before the bubble; a new run appends after the + * previous run's block, so arrival order is preserved), and open a + * fresh run. The phase-109 D15 re-open of one shared block is + * superseded: a post-delta thinking frame lands in a NEW block, not + * a re-open. */ function ensureThinkingRun(wrap) { if (currentRun?.kind === "thinking") return currentRun.el; closeCurrentRun(); - const block = createThinkingBlock(); - block.open = true; // D5: open while the run's frames stream + const block = createThinkingRunEl(); ensureReasoningWrap(wrap).querySelector(".reasoning-body").appendChild(block); const run = { kind: "thinking", text: "", records: [] }; segRuns.push(run); @@ -1546,18 +1596,18 @@ function ensureThinkingRun(wrap) { } /* The active tool run's disclosure — or, when the previous run is a - * different kind (or there is none): close it, create a NEW - * details.tool-calls-disclosure (OPEN — D5), append it into the - * phase-161 reasoning container's .reasoning-body (D1 — the - * container sits before the bubble; arrival order preserved), and - * open a fresh run. One disclosure per CONTIGUOUS tool-call run - * (the phase-117 structure, now per run; the summary count is per - * run — "Tool call(s) (N)"). */ + * different kind (or there is none): end it, create a NEW plain + * div.tool-calls-disclosure (always visible — the D5 refinement: the + * container is the ONE disclosure, the inner runs never fold), + * append it into the phase-161 reasoning container's .reasoning-body + * (D1 — the container sits before the bubble; arrival order + * preserved), and open a fresh run. One disclosure per CONTIGUOUS + * tool-call run (the phase-117 structure, now per run; the summary + * count is per run — "Tool call(s) (N)"). */ function ensureToolRun(wrap) { if (currentRun?.kind === "tool") return currentRun.el; closeCurrentRun(); - const disc = createToolDisclosure(); - disc.open = true; // D5: open while the run's frames stream + const disc = createToolRunEl(); ensureReasoningWrap(wrap).querySelector(".reasoning-body").appendChild(disc); const run = { kind: "tool", text: "", records: [] }; segRuns.push(run); @@ -1565,19 +1615,24 @@ function ensureToolRun(wrap) { return disc; } -/* D5's fold side: close the ACTIVE run (idempotent — an already - * closed block is a no-op; a no-op without a run). The run itself - * stays in segRuns — the fold only settles the element; the save +/* D5's end side: the ACTIVE run ends (idempotent — a no-op without a + * run). The D5 refinement (owner 2026-10-06): the inner runs are + * plain divs (NOT collapsible), so there is nothing to fold — the + * run simply stops accumulating frames (a frame of a new kind starts + * a NEW run below). The run itself stays in segRuns — the save * payloads still carry every run. */ function closeCurrentRun() { - if (currentRun) currentRun.el.open = false; currentRun = null; } /* The turn's terminal settle (done + the stop/abort + the failed-turn - * funnel): close EVERY open details in the wrap — defensive: by - * construction at most the last run is open, but a late double-close - * must be a no-op. Idempotent; a no-op without a wrap. */ + * funnel): the D5 refinement (owner 2026-10-06) made the inner runs + * non-collapsible divs, so there is nothing to fold inside the + * container (the container's own fold is settleReasoning's). The + * query is kept as the DEFENSIVE legacy guard: a pre-phase-161 + * details-based block (the legacy restore path creates them closed) + * would fold here — by construction they are already closed, so this + * is a no-op in every live path. Idempotent; a no-op without a wrap. */ function closeAllRuns(wrap) { wrap?.querySelectorAll?.( "details.thinking, details.tool-calls-disclosure" @@ -1589,7 +1644,8 @@ function closeAllRuns(wrap) { * alongside closeAllRuns at the three settle sites. Idempotent; * a no-op without a container (the plain-answer turn settles * byte-identically). The mid-round delta does NOT fold the - * container (D2 — closeCurrentRun folds the ACTIVE RUN; the + * container (D2 — closeCurrentRun only ENDS the active run — the + * D5 refinement made the inner runs non-collapsible divs — and the * container stays open until the final response has been returned * and the session has stopped — the TODO's core contract). */ function settleReasoning(wrap) { @@ -1729,9 +1785,12 @@ function appendToolLine(container, name, argument, offset = null, limit = null, // Phase 117 (D5): the count rides the summary — every append (live // frames and the restore path) re-renders through here. Phase 158 // (D3): the count is PER RUN (one disclosure per contiguous run — - // the per-run "Tool call(s) (N)"). + // the per-run "Tool call(s) (N)"). The D5 refinement (owner + // 2026-10-06): the target is the .tool-calls-summary CLASS — the + // phase-117 `` and the in-container `` both carry + // it, so ONE query serves both generations. const n = list.children.length; - container.querySelector("summary").textContent = + container.querySelector(".tool-calls-summary").textContent = `Tool call${n === 1 ? "" : "s"} (${n})`; return line; // phase 158: the caller arms the line's clock with this element } @@ -2635,7 +2694,10 @@ export function clearStoredConversation() { * `open = false` for the pin) and inserted ONCE before the bubble; * every per-run block appends into the container's .reasoning-body * (the per-run bubble anchor is superseded — arrival order - * preserved). The D6 at-rest summary is the step count, computed + * preserved). The D5 refinement (owner 2026-10-06): the in-container + * runs are plain divs (createThinkingRunEl / createToolRunEl — + * NEVER collapsible; the container is the ONE disclosure — the + * per-run blocks are always visible while the container is open). The D6 at-rest summary is the step count, computed * incrementally with the LIVE segRuns.length semantics — CONTIGUOUS * runs, NOT entry count: each thinking entry is ONE run (a thinking * entry after a tool group starts a new run; two adjacent thinking @@ -2680,24 +2742,13 @@ function restoreTimeline(wrap, segments) { if (typeof entry.text !== "string") continue; // malformed: skipped if (toolDisc) toolDisc = null; // the run is sealed — its count rode the last append runs += 1; // D6: each thinking entry is ONE run (a thinking entry after a tool group starts a run; two adjacent thinking entries are TWO runs) - const block = document.createElement("details"); - block.className = "thinking"; - // D5: created CLOSED — the restore renders collapsed (the - // live per-run path owns the open state; the live builders - // are the only place a block starts open). - const summary = document.createElement("summary"); - summary.textContent = "Thinking"; // the phase-17 literal (D3 — position distinguishes the runs) - const textEl = document.createElement("div"); - textEl.className = "thinking-text"; - textEl.innerHTML = renderMarkdown(entry.text); // the existing restore convention — escape-first, XSS-safe - block.append(summary, textEl); + const block = createThinkingRunEl(); // D5 refinement (owner 2026-10-06): the in-container run is a plain div — never collapsible (the container is the ONE disclosure) + block.querySelector(".thinking-text").innerHTML = renderMarkdown(entry.text); // the existing restore convention — escape-first, XSS-safe runsEl.appendChild(block); // Phase 161 D1/D5: inside the container, in arrival order } else if (entry.kind === "tool") { if (typeof entry.name !== "string") continue; // malformed: skipped if (!toolDisc) { - toolDisc = createToolDisclosure(); // the phase-117 structure (innerHTML ABSENT) - // D5: created CLOSED — details defaults closed; explicit for the pin. - toolDisc.open = false; + toolDisc = createToolRunEl(); // D5 refinement: the in-container run is a plain div (never collapsible) runs += 1; // D6: the first entry of a NEW contiguous group is ONE run (a group of N entries is ONE run) runsEl.appendChild(toolDisc); // Phase 161 D1/D5: inside the container, in arrival order } @@ -2729,7 +2780,8 @@ function restoreTimeline(wrap, segments) { // literal (no singular/plural special-case — `${runs} steps` is // pinned exactly). cont.querySelector(".reasoning-status").textContent = `${runs} steps`; - if (toolDisc) toolDisc.open = false; // defensive — already closed by construction + // the defensive close of the legacy details disclosure is GONE (the + // D5 refinement — the in-container runs are plain divs). } function renderStoredMessage(m) { @@ -3912,27 +3964,46 @@ async function runTurn(text, { reask = false, image = null } = {}) { // is no longer "processing"; settle its "(Ns)" suffix (the // visible indication moves to the thinking block). settleToolLine(); - // Phase 17: model reasoning — stream it live into the collapsible - // Thinking block. No setUiState here: the UI state stays + // Phase 17: model reasoning — stream it live into the per-run + // Thinking block (the D5 refinement, owner 2026-10-06 — a + // plain div, always visible; the container is the ONE + // collapsible). No setUiState here: the UI state stays // "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 158 (D1, owner direction 2026-10-04): PER-RUN blocks — // the D15 "re-open the one shared block" is superseded. Each - // CONTIGUOUS thinking run streams into its OWN - // details.thinking (ensureThinkingRun: a frame of a new kind - // closes the previous run and opens a new block; D5 — the new - // block is OPEN while its frames stream). A post-delta - // thinking frame (the next agent round: the model answered, - // called a tool, then thinks again) lands in a NEW block above - // the answer — not a re-open of the block that sits on top of - // it (the owner's "thinking resumed above the answer" awkwardness - // is gone; the runs stay in arrival order, D2). + // CONTIGUOUS thinking run streams into its OWN block + // (ensureThinkingRun: a frame of a new kind ends the previous + // run and opens a new block — always visible inside the + // container; D5 refinement). A post-delta thinking frame (the + // next agent round: the model answered, called a tool, then + // thinks again) lands in a NEW block — not a re-open of the + // block that sits on top of it (the owner's "thinking resumed + // above the answer" awkwardness is gone; the runs stay in + // arrival order, D2). 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 + // Phase 161 (D3): the container window's PRE-update pin + // capture — BEFORE ensureThinkingRun appends the (new) block: + // the append's own height growth must not leak into the + // "pre" reading (an append taller than the 32px band would + // read as "the user scrolled up" and kill the follow at the + // first frame of every run after the first — the D5 + // refinement, owner 2026-10-06, exposed the ordering defect: + // the old details collapse used to reset the window to a + // fitting state, masking it). The phase-17 pre-measure + // convention, verbatim. + // convention, verbatim. A turn's FIRST frame (the container + // did not exist yet — reasoningWrapEl is null) is always + // pinned: the fresh window starts at its bottom (st=0), and + // a first chunk taller than the 320px window must still + // land on the tail, not the top. + const rbody = reasoningWrapEl?.querySelector(".reasoning-body"); + const rPinned = rbody ? isReasoningPinned(rbody) : true; const block = ensureThinkingRun(wrap); // Phase 161 (D6): the container's live status — "Thinking…" // while a thinking frame streams (a tool frame overwrites it @@ -3947,18 +4018,17 @@ async function runTurn(text, { reask = false, image = null } = {}) { // grows the window's content below the old bottom, so measuring // the distance AFTER the update reads the chunk's height, not // the user's position, and the follow died at the first - // 2-newline gap. Pre-update, the distance is where the user - // actually is. Phase 158: the same pin logic, now operating on - // THIS block (per-run — each run's block pins its own tail; - // the pre-update capture is verbatim). + // 2-newline gap. The container capture above is taken BEFORE + // the block append for the same reason (the D5 refinement's + // exposed ordering defect). Phase 158: the same pin logic, now + // operating on THIS block (per-run — each run's block pins its + // own tail; the pre-update capture is verbatim). The D5 + // refinement (owner 2026-10-06): the in-container block is a + // plain div (block.open is undefined → the gate is false) and + // its .thinking-text is not scrollable, so this capture + // no-ops there — the container window's follow below is the + // live scroll surface. const pinned = block.open && isThinkingNearBottom(textEl); - // Phase 161 (D3): the container window's PRE-update pin - // capture — the phase-17 pre-measure convention, BEFORE the - // re-render grows the content (the inner per-block follow - // above stays — inside the container the .thinking-text is - // no longer scrollable, so it no-ops there; task 03). - const rbody = reasoningWrapEl?.querySelector(".reasoning-body"); - const rPinned = !!rbody && isReasoningPinned(rbody); textEl.innerHTML = renderMarkdown(currentRun.run.text); // escape-first, XSS-safe if (pinned) { // Follow the live tail only while the user was pinned to the @@ -4040,24 +4110,37 @@ async function runTurn(text, { reask = false, image = null } = {}) { ?.setAttribute("aria-label", toolStatus); } // Phase 158 (D1): PER-RUN disclosure — each CONTIGUOUS tool-call - // run streams into its OWN details.tool-calls-disclosure - // (ensureToolRun: a frame of a new kind closes the previous run - // and opens a new disclosure; D5 — OPEN while its frames stream), - // inserted BEFORE the bubble in arrival order (D2). The record + // run streams into its OWN block (ensureToolRun: a frame of a + // new kind ends the previous run and opens a new disclosure — + // the D5 refinement, owner 2026-10-06: a plain div, always + // visible inside the container; the container is the ONE + // collapsible), inserted in arrival order (D2). The record // rides the run's `records` too — the SAME toolAcc objects, so // the phase-95 tool_result stamp (truncated / chars_shown / // chars_total) flows into the segments payload with no extra // wiring. + // Phase 161 (D3): the container window's PRE-update pin + // capture — BEFORE ensureToolRun appends the (new) block: + // the append's own height growth must not leak into the + // "pre" reading (an append taller than the 32px band would + // read as "the user scrolled up" and kill the follow — the + // D5 refinement, owner 2026-10-06, exposed the ordering + // defect: the old details collapse used to reset the window + // to a fitting state, masking it). The phase-17 pre-measure + // convention, verbatim. + // convention, verbatim. A turn's FIRST frame (the container + // did not exist yet — reasoningWrapEl is null) is always + // pinned: the fresh window starts at its bottom (st=0), and + // a first line taller than the 320px window must still land + // on the tail, not the top. + const tbody = reasoningWrapEl?.querySelector(".reasoning-body"); + const tPinned = tbody ? isReasoningPinned(tbody) : true; const disc = ensureToolRun(wrap); currentRun.run.records.push(toolRecord); // Phase 161 (D6): "Calling …" — the display name the // tool line already shows (the phase-70 legacy-name mapping // produced `name` before this point). setReasoningStatus(`Calling ${name}…`); - // Phase 161 (D3): the container window's PRE-update pin - // capture — before the line append grows the content. - const tbody = reasoningWrapEl?.querySelector(".reasoning-body"); - const tPinned = !!tbody && isReasoningPinned(tbody); const line = appendToolLine( disc, name, @@ -4109,18 +4192,22 @@ async function runTurn(text, { reask = false, image = null } = {}) { acc += ev.text || ""; if (uiState === UI_STATE.thinking) setUiState(UI_STATE.streaming); if (!wrap) wrap = addMessage("brain", ""); // first token: live bubble in - // Phase 158 (D5): the first delta FOLDS the open run (the - // active block closes; a later thinking frame opens a NEW run - // above the bubble — the D15 re-open of the shared block is - // gone). Idempotent — a no-op without an open run. + // Phase 158 (D5): the first delta ENDS the active run (the + // D5 refinement, owner 2026-10-06 — the in-container runs are + // non-collapsible divs, so nothing folds; the run simply + // stops accumulating frames — a later thinking frame opens a + // NEW run in the container — the D15 re-open of the shared + // block is gone). Idempotent — a no-op without an active run. closeCurrentRun(); wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc); // No page scroll (phase 42): the answer never follows the viewport. } else if (ev.type === "done") { sawDone = true; - // Phase 158 (D5): the turn is over — fold EVERY open run - // (defensive: by construction at most the last run is open; a - // late double-close is a no-op). Replaces the phase-117 + // Phase 158 (D5): the turn is over — the D5 refinement (owner + // 2026-10-06) made the in-container runs non-collapsible divs, + // so this is the DEFENSIVE legacy guard (the pre-phase-161 + // details blocks the restore path creates, already closed — a + // no-op in every live path). Replaces the phase-117 // closeToolCalls fold + the D15 block close. closeAllRuns(wrap); // Phase 161 (D2): the turn settled — fold the container too diff --git a/frontend/assets/shared.js b/frontend/assets/shared.js index 5377a61..7219987 100644 --- a/frontend/assets/shared.js +++ b/frontend/assets/shared.js @@ -277,18 +277,20 @@ function createReasoningWrap() { /* Phase 158 (task 03, D4/D5): the SHARED page's per-run timeline — * the parity copy of the chat page's restoreTimeline: the record's * `segments` array (the per-run order, task 01's ChatMessage field) - * renders each CONTIGUOUS thinking run as its own details.thinking - * block and each CONTIGUOUS run of tool entries as ONE - * details.tool-calls-disclosure ("Tool call(s) (N)" per run), in - * arrival order, ABOVE the answer — a pure render, so everything is - * created CLOSED (D5; the same collapsed convention as - * addThinkingBlock / addToolLines). Phase 161 (task 02, D5/D6): the + * renders each CONTIGUOUS thinking run as its own .thinking block and + * each CONTIGUOUS run of tool entries as ONE .tool-calls-disclosure + * ("Tool call(s) (N)" per run), in arrival order, ABOVE the answer — + * a pure render. Phase 161 (task 02, D5/D6): the * per-run timeline renders inside ONE details.reasoning container — * the local parity builder above, created CLOSED (the pure-render * convention) and inserted ONCE before the bubble (the shared * page's existing insertion convention); every per-run block * appends into the container's .reasoning-body (the per-run bubble - * anchor is superseded — arrival order preserved). The D6 at-rest + * anchor is superseded — arrival order preserved). The D5 refinement + * (owner 2026-10-06, parity with app.js): the in-container runs are + * plain divs (NEVER collapsible — the container is the ONE + * disclosure; the per-run blocks are always visible while the + * container is open). The D6 at-rest * step count uses the SAME two-increment semantics as app.js's * restoreTimeline (a parity copy, not an import — contiguous runs: * each thinking entry is ONE run; the first entry of a new @@ -327,24 +329,23 @@ function addSegments(wrap, segments) { if (typeof entry.text !== "string") continue; // malformed: skipped if (toolDisc) toolDisc = null; // the run is sealed — the count is final runs += 1; // D6: each thinking entry is ONE run (parity with app.js — a thinking entry after a tool group starts a run; two adjacent thinking entries are TWO runs) - const block = document.createElement("details"); + const block = document.createElement("div"); // D5 refinement (owner 2026-10-06): the in-container run is a plain div — never collapsible (the container is the ONE disclosure) block.className = "thinking"; - block.open = false; // D5: collapsed (the addThinkingBlock convention) - const summary = document.createElement("summary"); - summary.textContent = "Thinking"; // the phase-17 literal (D3 — position distinguishes the runs) + const label = document.createElement("span"); + label.className = "thinking-summary"; + label.textContent = "Thinking"; // the phase-17 literal (D3 — position distinguishes the runs) const textEl = document.createElement("div"); textEl.className = "thinking-text"; textEl.innerHTML = renderMarkdown(entry.text); // escape-first, XSS-safe (parity with addThinkingBlock) - block.append(summary, textEl); + block.append(label, textEl); runsEl.appendChild(block); // Phase 161 D5: inside the container, in arrival order } else if (entry.kind === "tool") { if (typeof entry.name !== "string") continue; // malformed: skipped if (!toolDisc) { - toolDisc = document.createElement("details"); + toolDisc = document.createElement("div"); // D5 refinement: the in-container run is a plain div (never collapsible) toolDisc.className = "tool-calls-disclosure"; - toolDisc.open = false; // D5: collapsed (parity with addToolLines — phase 117 D3) runs += 1; // D6: the first entry of a NEW contiguous group is ONE run (parity with app.js — a group of N entries is ONE run) - const summary = document.createElement("summary"); + const summary = document.createElement("span"); summary.className = "tool-calls-summary"; toolDisc.appendChild(summary); const container = document.createElement("div"); @@ -405,9 +406,12 @@ function addSegments(wrap, segments) { } // The per-run count (parity with addToolLines — one disclosure // per contiguous run, D3) rides every append; the final - // append leaves the final count. + // append leaves the final count. The D5 refinement (owner + // 2026-10-06): the target is the .tool-calls-summary CLASS — + // the legacy `` and the in-container `` both + // carry it, so ONE query serves both. const n = container.children.length; - toolDisc.querySelector("summary").textContent = + toolDisc.querySelector(".tool-calls-summary").textContent = `Tool call${n === 1 ? "" : "s"} (${n})`; } } diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index 59cb41d..6dc3d60 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -753,27 +753,53 @@ summary.reasoning-summary:focus-visible { max-height: 320px; overflow-y: auto; } -/* D3 inner-run compacting: the per-run blocks nest de-bordered inside - the container — the nested border weight is removed (a 2px line - indent keeps the family rhythm) and the inner 320px windows are - disabled (max-height: none) so the container's window is the single - scroll surface (the phase-17 per-block follow in app.js no-ops on - the now-unscrollable .thinking-text). */ -details.reasoning details.thinking { - border: 0; - border-left: 2px solid var(--line); - border-radius: 0; - margin: 0.25rem 0; - background: transparent; +/* D3 inner-run compacting + the D5 refinement (owner 2026-10-06): + the per-run blocks nest as plain divs inside the container — + NEVER collapsible (the container is the ONE disclosure; a nested + fold inside the fold was the defect). The blocks are distinct + from each other by LABEL + RAIL: the thinking run carries the + family's brand-soft rail + the brand-ink "Thinking" label; the + tool run carries the neutral --line rail + the accent-ink + "Tool call(s) (N)" label (the phase-117 accent pairing). The + inner 320px windows are disabled (max-height: none) so the + container's window is the single scroll surface (the phase-17 + per-block follow in app.js no-ops on the now-unscrollable + .thinking-text). */ +details.reasoning .thinking { + border-left: 2px solid var(--brand-soft); + padding-left: 0.75rem; + margin: 0.5rem 0; } -details.reasoning details.thinking .thinking-text { +/* The static (non-interactive) "Thinking" label — brand-ink on the + surface ≈8.7:1 (the family's summary pairing, AA); no cursor, no + focus target (it is not a control — the container's summary is). + */ +details.reasoning .thinking .thinking-summary { + display: block; + color: var(--brand-ink); + font-size: 0.8rem; + line-height: 1.4; + margin-bottom: 0.15rem; +} +details.reasoning .thinking .thinking-text { + padding: 0 0 0.75rem; + color: var(--ink-soft); /* 6.9:1 on --surface — the scratchpad pairing */ + font-size: 0.875rem; + line-height: 1.55; max-height: none; overflow-y: visible; } -/* The nested tool-calls disclosure is already compact — the rule - documents the intent (one window, one rhythm) without a visual - change. */ -details.reasoning .tool-calls-disclosure { margin: 0.25rem 0; } +details.reasoning .thinking .thinking-text p, +details.reasoning .thinking .thinking-text ul { margin: 0 0 0.5rem; } +/* The tool run — the neutral rail (distinct from the thinking run's + brand-soft rail) + the phase-117 accent-ink label (≈10.4:1 on the + surface, AA). The .tool-calls rows keep the exact phase-37/117 + treatment below (the shared rule — no new bytes). */ +details.reasoning .tool-calls-disclosure { + border-left: 2px solid var(--line); + padding-left: 0.75rem; + margin: 0.5rem 0; +} /* Phase 117: the tool-call record folds into a single disclosure line, reusing the Thinking block's native
convention. Open while @@ -787,24 +813,33 @@ details.reasoning .tool-calls-disclosure { margin: 0.25rem 0; } label either way. */ .tool-calls-disclosure { margin: 0.25rem 0; } .tool-calls-summary { - cursor: pointer; color: var(--accent-ink); font-size: 0.8rem; line-height: 1.4; padding: 0.15rem 0; +} +/* The legacy (out-of-container) disclosure's summary is a native + focusable toggle — the interactive treatment is scoped to the + element: the in-container run's label is a plain span + (the D5 refinement — the container is the ONE disclosure, so the + run's label is not a control). */ +summary.tool-calls-summary { + cursor: pointer; list-style: none; /* the native marker is redundant with the count text */ } -.tool-calls-summary::-webkit-details-marker { display: none; } +summary.tool-calls-summary::-webkit-details-marker { display: none; } /* CSS chevron: ▸ rotates 90° when open — the same family treatment as details.thinking summary::before (the transition stills under - prefers-reduced-motion — see the reduced-motion block below). */ -.tool-calls-summary::before { + prefers-reduced-motion — see the reduced-motion block below). Scoped + to the element: the in-container run's label (a span with + the same class) carries no chevron. */ +summary.tool-calls-summary::before { content: "▸"; display: inline-block; margin-right: 0.5rem; transition: transform 0.15s ease; } -.tool-calls-disclosure[open] .tool-calls-summary::before { +details.tool-calls-disclosure[open] summary.tool-calls-summary::before { transform: rotate(90deg); } @@ -1733,8 +1768,10 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und .typing span.typing-elapsed { opacity: 1; } /* Phase 17 thinking block: the chevron stills (no rotation motion). */ details.thinking summary::before { transition: none; } - /* Phase 117 tool-calls disclosure: the same chevron, the same still. */ - .tool-calls-summary::before { transition: none; } + /* Phase 117 tool-calls disclosure: the same chevron, the same still + (scoped to the — the in-container span label has no + chevron to still). */ + summary.tool-calls-summary::before { transition: none; } /* Phase 161 reasoning bubble: the family chevron, the family still. */ summary.reasoning-summary::before { transition: none; } } diff --git a/tests/e2e/test_reasoning_bubble.py b/tests/e2e/test_reasoning_bubble.py index f8961b1..f440037 100644 --- a/tests/e2e/test_reasoning_bubble.py +++ b/tests/e2e/test_reasoning_bubble.py @@ -23,12 +23,15 @@ further tool call). The client renders the ENTIRE per-run timeline inside ONE ``details.reasoning`` container (D1): open from creation until the turn settles (D2 — it STAYS OPEN through a mid-round delta; only ``done`` / stop / the failed-turn funnel fold it), -condensed in its bounded 320px ``.reasoning-body`` live window (D3), -the summary carrying the D6 status literals (``Thinking…`` / -``Calling …`` live, ``N steps`` at rest). The restore path and -the shared page render the SAME container COLLAPSED (D5) with the -at-rest step count (D6). A turn with no thinking and no tools creates -NO container (D1/D4 — the byte-identical DOM). +condensed in its bounded 320px ``.reasoning-body`` live window (D3, +the pinned-tail follow — the window follows the live tail while the +user stays near the bottom). The per-run blocks inside the container +are plain divs — NOT collapsible (the D5 refinement, owner +2026-10-06: the container is the ONE disclosure; a nested fold +inside the fold used to hide the live reasoning mid-turn). The +restore path and the shared page render the SAME container COLLAPSED +(D5) with the at-rest step count (D6). A turn with no thinking and +no tools creates NO container (D1/D4 — the byte-identical DOM). KB fixture — mirrored from ``test_thinking_timeline.py`` (the deterministic two-document pair): the marker question cosines @@ -53,9 +56,9 @@ Test → phase mapping (Playwright Mapping Rule): ``Stop`` control), the container is STILL ``open`` with a run visible. 3. ``test_container_folds_at_done`` — D2/D5/D6: after ``done`` the - container has NO ``open`` attribute, its summary is visible, EVERY - inner run has no ``open`` attribute (the phase-158 fold-at-rest - contract preserved inside the fold), and the status is ``3 steps`` + container has NO ``open`` attribute, its summary is visible, the + inner runs are NOT collapsible (zero ``
`` inside the + container — the D5 refinement), and the status is ``3 steps`` (the flow's contiguous-run count — ``thinking (Step 1)``, ``tool (ls)``, ``thinking (Step 2)`` — the D6 live-settle literal); the bubble carries the mid-round + closing answer. @@ -281,8 +284,8 @@ CONTAINER_LIVE_JS = """ const live = st === 'Thinking…' || st.indexOf('Calling ') === 0; if (!live) return false; return !!cont.querySelector( - '.reasoning-body details.thinking, ' + - '.reasoning-body details.tool-calls-disclosure'); + '.reasoning-body div.thinking, ' + + '.reasoning-body div.tool-calls-disclosure'); } """ @@ -305,8 +308,8 @@ MIDROUND_STILL_OPEN_JS = """ const cont = wrap.querySelector('details.reasoning'); if (!cont || !cont.hasAttribute('open')) return false; return !!cont.querySelector( - '.reasoning-body details.thinking, ' + - '.reasoning-body details.tool-calls-disclosure'); + '.reasoning-body div.thinking, ' + + '.reasoning-body div.tool-calls-disclosure'); } """ @@ -322,7 +325,7 @@ TOOL_ONLY_LIVE_JS = """ const cont = wrap.querySelector('details.reasoning'); if (!cont || !cont.hasAttribute('open')) return false; return !!cont.querySelector( - '.reasoning-body details.tool-calls-disclosure'); + '.reasoning-body div.tool-calls-disclosure'); } """ @@ -567,17 +570,13 @@ def test_container_folds_at_done( _wait_settled(page) # D2/D5: at rest the container has NO ``open`` attribute (the - # done settle folded it) — and EVERY inner run has no ``open`` - # attribute either (the phase-158 fold-at-rest contract - # preserved inside the fold). + # done settle folded it) — and the inner runs are NOT + # collapsible (zero ``
`` inside the container — the D5 + # refinement, owner 2026-10-06: the per-run blocks are plain + # divs, always visible while the container is open). expect(page.locator("#messages .msg.brain details.reasoning[open]")).to_have_count(0) expect( - page.locator("#messages .msg.brain details.reasoning details.thinking[open]") - ).to_have_count(0) - expect( - page.locator( - "#messages .msg.brain details.reasoning details.tool-calls-disclosure[open]" - ) + page.locator("#messages .msg.brain details.reasoning details") ).to_have_count(0) # The collapsed summary is still visible (the bubble stays @@ -649,19 +648,17 @@ def test_restored_container_collapsed_with_step_count( expect(page.locator("#messages .msg.brain details.reasoning")).to_have_count(1) expect(page.locator("#messages .msg.brain details.reasoning[open]")).to_have_count(0) - # The SAME per-run timeline, nested in the container (all - # collapsed — the D5 restore convention). + # The SAME per-run timeline, nested in the container (plain divs + # — the D5 refinement; the at-rest fold is the container's). expect( - page.locator("#messages .msg.brain details.reasoning details.thinking") + page.locator("#messages .msg.brain details.reasoning div.thinking") ).to_have_count(2) expect( - page.locator( - "#messages .msg.brain details.reasoning details.thinking[open]" - ) + page.locator("#messages .msg.brain details.reasoning details") ).to_have_count(0) expect( page.locator( - "#messages .msg.brain details.reasoning details.tool-calls-disclosure" + "#messages .msg.brain details.reasoning div.tool-calls-disclosure" ) ).to_have_count(1) texts = page.locator( @@ -737,30 +734,23 @@ def test_shared_page_container_collapsed( # D5 (the shared-page half): ONE ``details.reasoning``, NO # ``open`` attribute (the pure-render convention — the - # shared page is a finished conversation), wrapping the SAME - # per-run timeline, all collapsed. + # shared page is a finished conversation), wrapping the + # SAME per-run timeline as plain divs (the D5 refinement — + # the inner runs are not collapsible). expect(anon.locator("#messages .msg.brain details.reasoning")).to_have_count(1) expect( anon.locator("#messages .msg.brain details.reasoning[open]") ).to_have_count(0) expect( - anon.locator( - "#messages .msg.brain details.reasoning details.thinking[open]" - ) + anon.locator("#messages .msg.brain details.reasoning details") ).to_have_count(0) expect( - anon.locator( - "#messages .msg.brain details.reasoning " - "details.tool-calls-disclosure[open]" - ) - ).to_have_count(0) - expect( - anon.locator("#messages .msg.brain details.reasoning details.thinking") + anon.locator("#messages .msg.brain details.reasoning div.thinking") ).to_have_count(2) expect( anon.locator( "#messages .msg.brain details.reasoning " - "details.tool-calls-disclosure" + "div.tool-calls-disclosure" ) ).to_have_count(1) anon_texts = anon.locator( @@ -807,9 +797,9 @@ def test_no_reasoning_turn_has_no_container( _submit(page, PLAIN_ANSWER_QUESTION) _wait_settled(page) expect(page.locator("#messages .msg.brain details.reasoning")).to_have_count(0) - expect(page.locator("#messages .msg.brain details.thinking")).to_have_count(0) + expect(page.locator("#messages .msg.brain .thinking")).to_have_count(0) expect( - page.locator("#messages .msg.brain details.tool-calls-disclosure") + page.locator("#messages .msg.brain .tool-calls-disclosure") ).to_have_count(0) expect(page.locator(".msg.brain .bubble").last).not_to_have_text("") @@ -851,13 +841,18 @@ def test_no_reasoning_turn_has_no_container( ).to_have_text("1 steps") # The phase-117 single-disclosure behavior, preserved and now - # wrapped: ONE disclosure with the 3 ``.tool-call`` lines, zero - # thinking blocks, folded at rest. + # wrapped: ONE disclosure — a plain div (the D5 refinement — + # not collapsible; zero ``
`` inside the container) with + # the 3 ``.tool-call`` lines, zero thinking blocks, folded at + # rest (the container's fold). expect( page.locator( - "#messages .msg.brain details.reasoning details.tool-calls-disclosure" + "#messages .msg.brain details.reasoning div.tool-calls-disclosure" ) ).to_have_count(1) + expect( + page.locator("#messages .msg.brain details.reasoning details") + ).to_have_count(0) expect( page.locator( "#messages .msg.brain details.reasoning .tool-call" @@ -869,12 +864,7 @@ def test_no_reasoning_turn_has_no_container( ) ).to_have_text("Tool calls (3)") expect( - page.locator("#messages .msg.brain details.reasoning details.thinking") - ).to_have_count(0) - expect( - page.locator( - "#messages .msg.brain details.reasoning details.thinking[open]" - ) + page.locator("#messages .msg.brain details.reasoning div.thinking") ).to_have_count(0) # The phase-37 answer echo (the read document reached the model). diff --git a/tests/e2e/test_thinking_display.py b/tests/e2e/test_thinking_display.py index c970b71..4573d11 100644 --- a/tests/e2e/test_thinking_display.py +++ b/tests/e2e/test_thinking_display.py @@ -11,7 +11,7 @@ regression test (scenario 4) — the deterministic mock's ``think out loud`` trigger (mock_llm.py) keeps all five scenarios reproducible. Test → story mapping (Playwright Mapping Rule): -1. ``test_thinking_block_streams_open_then_collapses`` +1. ``test_thinking_block_streams_in_the_open_container`` 2. ``test_thinking_toggle_after_done`` 3. ``test_thinking_restored_after_reload`` 4. ``test_no_thinking_block_without_trigger`` @@ -123,12 +123,15 @@ def send_and_wait(page: Page, question: str) -> None: # --------------------------------------------------------------------------- -# 1. Streaming: the block attaches OPEN at the first thinking event, then -# auto-collapses when the first answer token lands +# 1. Streaming: the block attaches at the first thinking event (nested +# in the open reasoning container — D1); the container STAYS OPEN +# through the first answer token (D2) and the block is ALWAYS +# visible (the D5 refinement — a plain div, not collapsible); the +# container folds at done. # --------------------------------------------------------------------------- -def test_thinking_block_streams_open_then_collapses( +def test_thinking_block_streams_in_the_open_container( page: Page, app_url: str, seeded_kb: None ) -> None: page.set_default_timeout(30_000) @@ -137,25 +140,35 @@ def test_thinking_block_streams_open_then_collapses( page.evaluate("() => document.querySelector('#composer').requestSubmit()") expect(page.locator(".msg.user .bubble").last).to_contain_text(THINK_QUESTION) - # The block attaches at the FIRST thinking event — before any answer - # token — and is created OPEN. - details = page.locator(".msg.brain").last.locator("details.thinking") - details.wait_for(state="attached", timeout=10_000) - # The ~2 700-char thinking stream (≈4.5s, phase 21) keeps the block - # open right after attach — assert while it is still streaming. - expect(details).to_have_attribute("open", "") - expect(details.locator(".thinking-text")).not_to_have_text("") + # The block attaches at the FIRST thinking event — before any + # answer token — nested in the container created OPEN (D1). + block = page.locator(".msg.brain").last.locator("div.thinking") + block.wait_for(state="attached", timeout=10_000) + container = page.locator(".msg.brain").last.locator("details.reasoning") + expect(container).to_have_attribute("open", "") + # The ~2 700-char thinking stream (≈4.5s, phase 21) keeps the + # container open right after attach — assert while it is still + # streaming (the D5 refinement: the container is the ONE open + # disclosure — zero
inside it). + expect(container).to_have_attribute("open", "") + expect( + page.locator(".msg.brain").last.locator("details.reasoning details") + ).to_have_count(0) + expect(block.locator(".thinking-text")).not_to_have_text("") - # First answer token: the block auto-collapses and stays closed. + # First answer token: the container STAYS OPEN (D2 — the mid-round + # delta does not fold it; only done / stop / the failed-turn + # funnel do). bubble = page.locator(".msg.brain .bubble").last expect(bubble).not_to_have_text("", timeout=30_000) - expect(details).not_to_have_attribute("open") + expect(container).to_have_attribute("open", "") - # Settled: full scratchpad, grounded mock answer, ZERO citation - # chips (phase 119, LOCKED A1 — the zero-read turn chips nothing; - # the retired phase-118 A4 union is gone), and the re-enabled send - # button. - expect(details.locator(".thinking-text")).to_contain_text(THINKING_FRAGMENT) + # Settled: the container FOLDS (done); the block is not + # collapsible (a div — the D5 refinement); the full scratchpad is + # intact, the grounded mock answer is present, ZERO citation chips + # (phase 119, LOCKED A1), and the re-enabled send button. + expect(container).not_to_have_attribute("open") + expect(block.locator(".thinking-text")).to_contain_text(THINKING_FRAGMENT) expect(bubble).to_contain_text(MOCK_ANSWER_MARKER) expect(page.locator(".msg.brain .source-chip")).to_have_count(0) expect(page.locator("#send-btn")).to_be_enabled() @@ -163,13 +176,12 @@ def test_thinking_block_streams_open_then_collapses( # --------------------------------------------------------------------------- -# 2. Toggle: after settle the block is closed; the summary re-opens it -# (a real keyboard-focusable control) and closes it again. -# Phase 161 (D2): at rest the run sits INSIDE the folded reasoning -# container — the container's summary is the at-rest focusable -# surface (native
nesting semantics: a closed details -# hides its content, so the inner summary is focusable only after -# the container opens). +# 2. Toggle: after settle the container is folded; its summary +# re-opens it (a real keyboard-focusable control) and closes it +# again. The D5 refinement (owner 2026-10-06): the inner run is a +# plain div — NOT collapsible — so the container's summary is the +# ONE toggle (the per-run "summary" click is gone; the block is +# always visible once the container opens). # --------------------------------------------------------------------------- @@ -179,12 +191,14 @@ def test_thinking_toggle_after_done(page: Page, app_url: str, seeded_kb: None) - send_and_wait(page, THINK_QUESTION) last = page.locator(".msg.brain").last - details = last.locator("details.thinking") - expect(details).to_have_count(1) - expect(details).not_to_have_attribute("open") # auto-collapsed at first token + block = last.locator("div.thinking") + expect(block).to_have_count(1) + # The D5 refinement: the inner run is not a
at all (zero + #
inside the container — the block is a plain div). + expect(last.locator("details.reasoning details")).to_have_count(0) # Phase 161 (D2): the run nests in the reasoning container, which # folds at done (the at-rest focusable surface is the container's - # summary — open it to reach the inner run's summary). + # summary — the ONE toggle). container = last.locator("details.reasoning") expect(container).to_have_count(1) expect(container).not_to_have_attribute("open") @@ -193,21 +207,18 @@ def test_thinking_toggle_after_done(page: Page, app_url: str, seeded_kb: None) - container.locator("summary.reasoning-summary").click() expect(container).to_have_attribute("open", "") - # The inner run's summary is a real, keyboard-focusable control. - details.locator("summary").focus() - assert page.evaluate("() => document.activeElement.tagName") == "SUMMARY" - - # Open: the full scratchpad is visible. - details.locator("summary").click() - expect(details).to_have_attribute("open", "") - text_el = details.locator(".thinking-text") + # Open: the full scratchpad is visible — ONE toggle (the D5 + # refinement — no second, per-run toggle; the block is always + # visible once the container opens). + text_el = block.locator(".thinking-text") expect(text_el).to_be_visible() expect(text_el).to_contain_text(THINKING_FRAGMENT) expect(text_el).to_contain_text("nothing is invented") - # Closed again — user control in both directions. - details.locator("summary").click() - expect(details).not_to_have_attribute("open") + # Closed again — user control in both directions (the container's + # summary hides the always-visible block with it). + container.locator("summary.reasoning-summary").click() + expect(container).not_to_have_attribute("open") expect(text_el).not_to_be_visible() @@ -222,11 +233,12 @@ def test_thinking_restored_after_reload(page: Page, app_url: str, seeded_kb: Non login(page, app_url, next="/") send_and_wait(page, THINK_QUESTION) - # The live block is collapsed; capture what it shows and what the - # turn persisted (raw text, same as what was rendered). - details = page.locator(".msg.brain").last.locator("details.thinking") - expect(details).not_to_have_attribute("open") - captured = details.locator(".thinking-text").text_content() + # The live block is a plain div (the D5 refinement — always visible + # while the container is live-open); capture what it shows and what + # the turn persisted (raw text, same as what was rendered). + block = page.locator(".msg.brain").last.locator("div.thinking") + expect(block).to_have_count(1) + captured = block.locator(".thinking-text").text_content() assert captured # The persisted raw text is the same scratchpad (renderMarkdown turns # the line breaks into
, which textContent drops — compare without @@ -239,9 +251,15 @@ def test_thinking_restored_after_reload(page: Page, app_url: str, seeded_kb: Non page.reload() expect(page.locator("#empty-state")).to_be_hidden() - restored = page.locator(".msg.brain").last.locator("details.thinking") + restored = page.locator(".msg.brain").last.locator("div.thinking") expect(restored).to_have_count(1) - expect(restored).not_to_have_attribute("open") # restored COLLAPSED + # Restored COLLAPSED — the at-rest fold is the container's (the + # D5 refinement — the inner block is not a
at all). + restored_wrap = page.locator(".msg.brain").last + expect( + restored_wrap.locator("details.reasoning") + ).not_to_have_attribute("open") + expect(restored_wrap.locator("details.reasoning details")).to_have_count(0) expect(restored.locator(".thinking-text")).to_have_text(captured) # Answer bubble is intact after the restore (phase-14 restore path) @@ -264,7 +282,7 @@ def test_no_thinking_block_without_trigger(page: Page, app_url: str, seeded_kb: send_and_wait(page, PLAIN_QUESTION) # No trigger → no thinking events → no block anywhere on the page. - expect(page.locator("details.thinking")).to_have_count(0) + expect(page.locator(".thinking")).to_have_count(0) # The turn itself is complete and grounded, exactly as before phase # 17 — with ZERO citation chips (phase 119, LOCKED A1: the zero-read @@ -293,8 +311,10 @@ def test_thinking_with_deflection(page: Page, app_url: str, seeded_kb: None) -> expect(chips.first).to_be_visible() assert chips.count() >= 2 - # And the thinking block came along, closed, with its scratchpad. - details = last.locator("details.thinking") - expect(details).to_have_count(1) - expect(details).not_to_have_attribute("open") - expect(details.locator(".thinking-text")).to_contain_text(THINKING_FRAGMENT) + # And the thinking block came along — a plain div (the D5 + # refinement — not collapsible; the container is the ONE fold) — + # with its scratchpad. + block = last.locator("div.thinking") + expect(block).to_have_count(1) + expect(last.locator("details.reasoning details")).to_have_count(0) + expect(block.locator(".thinking-text")).to_contain_text(THINKING_FRAGMENT) diff --git a/tests/e2e/test_thinking_scroll.py b/tests/e2e/test_thinking_scroll.py index 959ed2a..5cc22f0 100644 --- a/tests/e2e/test_thinking_scroll.py +++ b/tests/e2e/test_thinking_scroll.py @@ -14,11 +14,16 @@ scratchpad (phase-21 length: ``compose_thinking`` ≈ 2 700 chars ≈ 4.5s of This suite REPLACES the deleted phase-21 ``test_thinking_no_scroll.py`` (owner direction 2026-08-27, ``TODO.md`` L7, roadmap A2 — the 2026-08-24 -no-scroll choice is reversed): ``details.thinking .thinking-text`` is -user-scrollable again (``overflow-y: auto``, 320px clip kept), and the -phase-17 bottom-pin is GATED in app.js (``THINKING_NEAR_BOTTOM_PX = 32``) -— the window follows the live tail only while the user is pinned near -its bottom; scrolling up pauses the follow, returning resumes it. +no-scroll choice is reversed): the ONE live window is the reasoning +container's ``.reasoning-body`` (phase 161 — the per-run blocks nest +inside it; the D5 refinement, owner 2026-10-06 — the per-run blocks +are plain divs, always visible, and the container is the ONE +disclosure) — user-scrollable (``overflow-y: auto``, 320px clip +kept), and the bottom-pin is GATED in app.js (the phase-17 +``THINKING_NEAR_BOTTOM_PX = 32`` band, reused on the container +window as ``REASONING_NEAR_BOTTOM_PX``) — the window follows the live +tail only while the user is pinned near its bottom; scrolling up +pauses the follow, returning resumes it. Determinism notes: * Tests 1–4 key off the mock's ``think out loud then hesitate`` trigger @@ -119,7 +124,11 @@ THINKING_FRAGMENT = "Step 2: Check my notes" PARAS_QUESTION = "think in paragraphs — how is my kubernetes cluster set up?" STORAGE_KEY = "bor.chat.v1" -SELECTOR = ".msg.brain details.thinking .thinking-text" +#: The ONE live window: the reasoning container's bounded 320px +#: ``.reasoning-body`` (phase 161 — the per-run blocks nest inside +#: it; the D5 refinement — the per-run blocks are plain divs, always +#: visible; the container is the ONE disclosure). +SELECTOR = ".msg.brain details.reasoning .reasoning-body" #: The story's "within 1px" tolerance for tail-pin claims. TAIL_TOL = 1 @@ -264,11 +273,14 @@ def _rendered_len(page: Page) -> int: def _first_text_node(page: Page) -> dict[str, Any]: """Rect of the scratchpad's FIRST text node vs the window's box — the "earlier content is visible" probe (the mock's text starts with - ``Step 1: Read the question carefully``).""" + ``Step 1: Read the question carefully``; the probe walks the + scratchpad's own first ``.thinking-text`` — not the window's raw + first node, which is the "Thinking" label).""" return page.evaluate( f"""() => {{ const el = document.querySelector('{SELECTOR}'); const box = el.getBoundingClientRect(); - const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); + const t = el.querySelector('.thinking-text'); + const walker = document.createTreeWalker(t, NodeFilter.SHOW_TEXT); const first = walker.nextNode(); const range = document.createRange(); range.selectNodeContents(first); @@ -293,11 +305,11 @@ def test_thinking_window_user_scrollable( submit(page, HESITATE_QUESTION) expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION) - details = page.locator(".msg.brain").last.locator("details.thinking") - details.wait_for(state="attached", timeout=10_000) + container = page.locator(".msg.brain").last.locator("details.reasoning") + container.wait_for(state="attached", timeout=10_000) - # Premise: the long scratchpad overflows the 320px clip (the story's - # "window" is only real once it clips). + # Premise: the long scratchpad overflows the 320px clip (the + # story's "window" is only real once it clips). page.wait_for_function( f"() => {{ const el = document.querySelector('{SELECTOR}');" " return !!el && el.scrollHeight > el.clientHeight; }", @@ -308,7 +320,7 @@ def test_thinking_window_user_scrollable( # pin frames, the block is still open, and the tail is frozen — any # user scroll persists and is observable here. _wait_text_stable(page) - expect(details).to_have_attribute("open", "") + expect(container).to_have_attribute("open", "") expect(page.locator(".msg.brain .bubble").last).to_have_text("") # Precondition: the gated pin left the window at the live tail. @@ -316,7 +328,7 @@ def test_thinking_window_user_scrollable( assert before["height"] > before["client"], "the window must overflow" assert _at_tail(before), "the pin must have left the window at the tail" - box = details.locator(".thinking-text").bounding_box() + box = container.locator(".reasoning-body").bounding_box() assert box cx, cy = box["x"] + box["width"] / 2, box["y"] + box["height"] / 2 page.mouse.move(cx, cy) @@ -337,7 +349,7 @@ def test_thinking_window_user_scrollable( # it so (test scaffolding), give it REAL focus with a click, and press # Home: the window must jump to its top (proven 1:1 on headless # Chromium 151: click + Home -> 0, End -> maxScroll). - details.locator(".thinking-text").evaluate( + container.locator(".reasoning-body").evaluate( "el => { el.setAttribute('tabindex', '0'); }" ) page.mouse.click(cx, cy) @@ -376,8 +388,9 @@ def test_thinking_window_user_scrollable( ) assert not _at_tail(after), "the window must stay scrolled up (no re-pin)" - # Still inside the pure-thinking window. - expect(details).to_have_attribute("open", "") + # Still inside the live-thinking window (the container is the ONE + # open disclosure — the D5 refinement). + expect(container).to_have_attribute("open", "") expect(page.locator(".msg.brain .bubble").last).to_have_text("") @@ -396,8 +409,8 @@ def test_thinking_window_follows_while_pinned( submit(page, HESITATE_QUESTION) expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION) - details = page.locator(".msg.brain").last.locator("details.thinking") - details.wait_for(state="attached", timeout=10_000) + container = page.locator(".msg.brain").last.locator("details.reasoning") + container.wait_for(state="attached", timeout=10_000) # The exact deterministic scratchpad the mock will stream, sliced the # same way the mock's _sse_stream does (12-char chunks). The user @@ -424,7 +437,7 @@ def test_thinking_window_follows_while_pinned( # the block still open — sample the tail pin, then the geometry. _wait_text_contains(page, marker_last) _wait_text_stable(page) - expect(details).to_have_attribute("open", "") + expect(container).to_have_attribute("open", "") expect(page.locator(".msg.brain .bubble").last).to_have_text("") sample = _scroll_sample(page) assert _at_tail(sample), f"not at the tail after the last chunk: {sample}" @@ -467,8 +480,8 @@ def test_thinking_window_stops_on_scroll_up( submit(page, HESITATE_QUESTION) expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION) - details = page.locator(".msg.brain").last.locator("details.thinking") - details.wait_for(state="attached", timeout=10_000) + container = page.locator(".msg.brain").last.locator("details.reasoning") + container.wait_for(state="attached", timeout=10_000) # Mid-stream: the window has REAL headroom (≥100px of hidden content # — the first overflowing frame, a few px, is not the story's @@ -508,9 +521,10 @@ def test_thinking_window_stops_on_scroll_up( f"{top0} -> {after['top']}" ) - # Still inside the pure-thinking window (the stability claim is only - # meaningful while the block is open and no content frame has landed). - expect(details).to_have_attribute("open", "") + # Still inside the live-thinking window (the stability claim is only + # meaningful while the container is open and no content frame has + # landed). + expect(container).to_have_attribute("open", "") expect(page.locator(".msg.brain .bubble").last).to_have_text("") @@ -528,8 +542,8 @@ def test_thinking_window_resumes_on_return( submit(page, HESITATE_QUESTION) expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION) - details = page.locator(".msg.brain").last.locator("details.thinking") - details.wait_for(state="attached", timeout=10_000) + container = page.locator(".msg.brain").last.locator("details.reasoning") + container.wait_for(state="attached", timeout=10_000) # Mid-stream: real window headroom (≥100px) + pinned tail (test-3 # premises). @@ -581,8 +595,8 @@ def test_thinking_window_resumes_on_return( assert _at_tail(after), f"the window must be re-pinned on the next chunk: {after}" assert after["top"] > top0, "the window must have returned toward the tail" - # Still inside the pure-thinking window. - expect(details).to_have_attribute("open", "") + # Still inside the live-thinking window. + expect(container).to_have_attribute("open", "") expect(page.locator(".msg.brain .bubble").last).to_have_text("") @@ -599,10 +613,10 @@ def test_thinking_window_css_contract( login(page, app_url, next="/") send_and_wait(page, THINK_QUESTION) - details = page.locator(".msg.brain").last.locator("details.thinking") - expect(details).not_to_have_attribute("open") # auto-collapsed - details.locator("summary").click() # open for measurement - expect(details).to_have_attribute("open", "") + container = page.locator(".msg.brain").last.locator("details.reasoning") + expect(container).not_to_have_attribute("open") # folded at done + container.locator("summary.reasoning-summary").click() # open for measurement + expect(container).to_have_attribute("open", "") style = page.evaluate( f"""() => {{ const el = document.querySelector('{SELECTOR}'); @@ -694,23 +708,29 @@ def test_restored_collapsed_thinking_unaffected( login(page, app_url, next="/") send_and_wait(page, THINK_QUESTION) - details = page.locator(".msg.brain").last.locator("details.thinking") - expect(details).not_to_have_attribute("open") # auto-collapsed - captured = details.locator(".thinking-text").text_content() + block = page.locator(".msg.brain").last.locator("div.thinking") + expect(block).to_have_count(1) + captured = block.locator(".thinking-text").text_content() assert captured page.reload() expect(page.locator("#empty-state")).to_be_hidden() - restored = page.locator(".msg.brain").last.locator("details.thinking") + restored = page.locator(".msg.brain").last.locator("div.thinking") expect(restored).to_have_count(1) - expect(restored).not_to_have_attribute("open") # restored COLLAPSED + # Restored COLLAPSED — the at-rest fold is the container's (the + # D5 refinement — the inner block is a plain div, not a
). + container = page.locator(".msg.brain").last.locator("details.reasoning") + expect(container).not_to_have_attribute("open") + expect( + page.locator(".msg.brain").last.locator("details.reasoning details") + ).to_have_count(0) expect(restored.locator(".thinking-text")).to_have_text(captured) - # Opening the restored block still shows the full scratchpad, and the - # answer + persistence are intact. - restored.locator("summary").click() - expect(restored).to_have_attribute("open", "") + # Opening the restored container still shows the full scratchpad, + # and the answer + persistence are intact. + container.locator("summary.reasoning-summary").click() + expect(container).to_have_attribute("open", "") expect(restored.locator(".thinking-text")).to_contain_text(THINKING_FRAGMENT) expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER) raw = json.loads(page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')"))[ @@ -739,8 +759,8 @@ def test_thinking_window_follows_across_paragraph_breaks( submit(page, PARAS_QUESTION) expect(page.locator(".msg.user .bubble").last).to_contain_text(PARAS_QUESTION) - details = page.locator(".msg.brain").last.locator("details.thinking") - details.wait_for(state="attached", timeout=10_000) + container = page.locator(".msg.brain").last.locator("details.reasoning") + container.wait_for(state="attached", timeout=10_000) # Two preconditions, in one poll: the window is REAL (content past # the 320px clip — a "window" only exists once it clips) AND the @@ -773,13 +793,13 @@ def test_thinking_window_follows_across_paragraph_breaks( # paragraph break is present, and the pin survived it. sample = page.evaluate( f"""() => {{ const el = document.querySelector('{SELECTOR}'); - const block = document.querySelector('details.thinking'); + const block = document.querySelector('details.reasoning'); const wrap = block ? block.closest('.msg.brain') : null; const bubble = wrap ? wrap.querySelector('.bubble') : null; return {{ top: el.scrollTop, height: el.scrollHeight, client: el.clientHeight, p: el.querySelectorAll('p').length, - open: !!(block && block.open), + open: !!(block && block.hasAttribute('open')), bubble: bubble ? bubble.innerText.length : 0 }}; }}""" ) assert sample["p"] >= 2, "a real paragraph break must be in the scratchpad" @@ -796,5 +816,5 @@ def test_thinking_window_follows_across_paragraph_breaks( # disabled, so to_be_enabled no longer blocks until the turn ends). expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000) expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000) - expect(details.locator(".thinking-text")).to_contain_text("Step 3") + expect(container.locator(".thinking-text").first).to_contain_text("Step 3") expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER) diff --git a/tests/e2e/test_thinking_timeline.py b/tests/e2e/test_thinking_timeline.py index 53dbd6f..bb13f9e 100644 --- a/tests/e2e/test_thinking_timeline.py +++ b/tests/e2e/test_thinking_timeline.py @@ -21,17 +21,18 @@ shape (list → drill → read → the quoted answer) with a DISTINCT ``reasoning_content`` run streamed AHEAD of every tool call and ahead of the final answer — the ``Step N`` markers make each run individually assertable. The client renders each CONTIGUOUS run in its -own ``details.thinking`` / ``details.tool-calls-disclosure`` (D1), all -above the answer bubble in arrival order (D2 — Option B, -owner-chosen), open while the run's frames stream and folded at rest -(D5), and persists the run order in the record's ``segments`` array -(D4). Phase 161 (the documented supersession, owner TODO direction -2026-10-05): the per-run blocks are NESTED inside ONE +own per-run block (D1) in arrival order (D2 — Option B, +owner-chosen) and persists the run order in the record's ``segments`` +array (D4). Phase 161 (the documented supersession, owner TODO +direction 2026-10-05): the per-run blocks are NESTED inside ONE ``details.reasoning`` container (``.reasoning-body``) that sits before the bubble — open during the turn, folded at done / on -restore / on the shared page; the per-run open/fold behavior itself -is unchanged. The sequence pin is shape-unchanged, read from the -container's ``.reasoning-body`` children (the container is the one +restore / on the shared page. The D5 refinement (owner 2026-10-06): +the per-run blocks are plain divs — NOT collapsible (the container +is the ONE disclosure; a nested fold inside the fold used to hide +the live reasoning mid-turn), always visible while the turn is live. +The sequence pin is shape-unchanged, read from the container's +``.reasoning-body`` children (the container is the one new parent — the pre-bubble ``.msg-body`` children are exactly ``["details.reasoning"]``). @@ -54,15 +55,17 @@ Test → phase mapping (Playwright Mapping Rule): one lump); each disclosure summary is ``Tool call (1)``; the answer bubble carries the ``Read `` echo. 2. ``test_runs_fold_at_rest`` — D5 (phase 161 D2): after ``done`` - the container AND every ``.thinking`` / - ``.tool-calls-disclosure`` inside it has NO ``open`` attribute - (the settle folded the runs + the container); the at-rest visible - line is the container's ``Reasoning`` summary (``7 steps``); - opening it reveals the folded run summaries. + the container has NO ``open`` attribute (the settle folded it) and + the inner runs are NOT collapsible (zero ``
`` inside the + container — the D5 refinement); the at-rest visible line is the + container's ``Reasoning`` summary (``7 steps``); opening it + reveals the always-visible run blocks. 3. ``test_live_runs_open_while_streaming`` — D5 (the live half): DURING the turn (the mock's pacing window — the 0.1 s tool frames + - the thinking stream, the answer bubble still EMPTY) the active run's - block is ``open`` (the live feedback the owner keeps). + the thinking stream, the answer bubble still EMPTY) the container + is ``open`` with a run visible in its ``.reasoning-body`` (the + live feedback the owner keeps — the container is the ONE open + disclosure). 4. ``test_restored_timeline_and_record`` — the restore path + D4: a same-context RELOAD re-renders the same 7-element sequence in ONE collapsed reasoning container (``7 steps``, phase 161 D5/D6), and @@ -75,8 +78,9 @@ Test → phase mapping (Playwright Mapping Rule): 5. ``test_shared_page_timeline`` — the shared-page parity (phase 51 save + share flow): a FRESH context on ``/shared/`` renders the same 7-element sequence in ONE collapsed reasoning container - (``addSegments``), the ``Step N`` markers visible on container + - summary click (expand ONE each), the answer bubble carries the + (``addSegments``), the ``Step N`` markers visible on container + click (the D5 refinement — the inner blocks are always visible + once the container opens), the answer bubble carries the ``Read `` echo. 6. ``test_no_thinking_turn_single_disclosure`` — the run-grouping contract for no-thinking turns (the phase-117 behavior preserved, @@ -192,13 +196,13 @@ STEP_MARKERS = ("Step 1", "Step 2", "Step 3", "Step 4") #: sequence is shape-unchanged — the container is the one new parent) #: — tests 1/4 (chat page) and 5 (shared page) pin it. TIMELINE_SEQ = [ - "details.thinking", - "details.tool-calls-disclosure", - "details.thinking", - "details.tool-calls-disclosure", - "details.thinking", - "details.tool-calls-disclosure", - "details.thinking", + "div.thinking", + "div.tool-calls-disclosure", + "div.thinking", + "div.tool-calls-disclosure", + "div.thinking", + "div.tool-calls-disclosure", + "div.thinking", ] SHARE_URL_RE = re.compile(r"^/shared/[0-9a-f-]{36}$") @@ -325,18 +329,23 @@ BODY_SEQUENCE_JS = """ } """ -#: D5's live-open predicate — the active run's block is ``open`` while -#: the answer bubble is STILL EMPTY (the turn is in flight; the mock's -#: pacing window — the 0.1 s tool frames + the thinking stream — keeps -#: the window comfortably open for a 100 ms poll). +#: D5's live predicate (the D5 refinement — the container is the ONE +#: open disclosure): the container is ``open`` with a per-run block +#: nested in its ``.reasoning-body`` while the answer bubble is STILL +#: EMPTY (the turn is in flight; the mock's pacing window — the 0.1 s +#: tool frames + the thinking stream — keeps the window comfortably +#: open for a 100 ms poll). LIVE_OPEN_JS = """ () => { const wrap = document.querySelector('#messages .msg.brain'); if (!wrap) return false; const bubble = wrap.querySelector('.msg-body .bubble'); if (!bubble || bubble.textContent !== '') return false; - return !!wrap.querySelector( - 'details.thinking[open], details.tool-calls-disclosure[open]'); + const cont = wrap.querySelector('details.reasoning'); + if (!cont || !cont.hasAttribute('open')) return false; + return !!cont.querySelector( + '.reasoning-body div.thinking, ' + + '.reasoning-body div.tool-calls-disclosure'); } """ @@ -540,25 +549,23 @@ def test_runs_fold_at_rest( page.locator("#messages .msg.brain .reasoning-status") ).to_have_text("7 steps") - # D5: at rest NOTHING inside is open — the ``done`` settle - # (closeAllRuns) folded every run (a closed
has no - # ``open`` attribute). + # D5 refinement (owner 2026-10-06): the inner runs are NOT + # collapsible — ZERO
inside the container (the + # per-run blocks are plain divs, always visible while the + # container is open). expect( - page.locator("#messages .msg.brain details.thinking[open]") - ).to_have_count(0) - expect( - page.locator( - "#messages .msg.brain details.tool-calls-disclosure[open]" - ) + page.locator("#messages .msg.brain details.reasoning details") ).to_have_count(0) # The inner runs stay in the DOM inside the fold — opening the # container (the native toggle) reveals the 4 "Thinking" + 3 - # "Tool call (1)" lines (the timeline stays readable at rest). + # "Tool call (1)" labels (the timeline stays readable at rest; + # the D5 refinement — no second toggle: the blocks are always + # visible once the container opens). page.locator("#messages .msg.brain .reasoning-summary").click() expect(container).to_have_attribute("open", "") expect( - page.locator("#messages .msg.brain details.thinking > summary").first + page.locator("#messages .msg.brain .thinking .thinking-summary").first ).to_be_visible() expect( page.locator("#messages .msg.brain .tool-calls-summary").first @@ -652,12 +659,7 @@ def test_restored_timeline_and_record( f"the restored in-container sequence is not the timeline: {seq}" ) expect( - page.locator("#messages .msg.brain details.thinking[open]") - ).to_have_count(0) - expect( - page.locator( - "#messages .msg.brain details.tool-calls-disclosure[open]" - ) + page.locator("#messages .msg.brain details.reasoning details") ).to_have_count(0) # The restored answer (the restore is synchronous at boot). expect( @@ -778,24 +780,17 @@ def test_shared_page_timeline( assert seq == TIMELINE_SEQ, ( f"the shared in-container sequence is not the timeline: {seq}" ) + # D5 refinement (owner 2026-10-06): the inner runs are NOT + # collapsible — ZERO
inside the container (the + # per-run blocks are plain divs). expect( - anon.locator("#messages .msg.brain details.thinking[open]") - ).to_have_count(0) - expect( - anon.locator( - "#messages .msg.brain details.tool-calls-disclosure[open]" - ) + anon.locator("#messages .msg.brain details.reasoning details") ).to_have_count(0) - # The ``Step N`` markers are visible on a summary click (expand - # ONE — the native is a real focusable toggle; phase - # 161: the container's own summary first — the inner runs' - # summaries sit inside its closed fold). + # The ``Step N`` markers are visible on the container's summary + # click (the D5 refinement — ONE toggle: the inner blocks are + # always visible once the container opens; no second toggle). anon.locator("#messages .msg.brain .reasoning-summary").click() - anon.locator("#messages .msg.brain details.thinking > summary").first.click() - expect( - anon.locator("#messages .msg.brain details.thinking").first - ).to_have_attribute("open") first_text = anon.locator("#messages .msg.brain .thinking-text").first expect(first_text).to_be_visible() expect(first_text).to_contain_text(STEP_MARKERS[0]) diff --git a/tests/e2e/test_tool_call_compact.py b/tests/e2e/test_tool_call_compact.py index 6f09472..3c2810f 100644 --- a/tests/e2e/test_tool_call_compact.py +++ b/tests/e2e/test_tool_call_compact.py @@ -10,12 +10,18 @@ item breaking mid-word (``Rea``/``ding``) and the path wrapping to three lines indented to the right of that broken label. Phase 117's fix (frontend-only, D1–D6 in ``00_phase.md``): the -per-call lines ride in ONE native ``
`` disclosure — a compact -"Tool calls (N)" summary, open while the turn is live and FOLDED when -the answer begins (the first ``delta``), on ``done``, on stop/abort, -and on restore — and the lines themselves are deboxed inline-flow -text (no card, no flex: the label + ```` run as one continuous -sentence and the path wraps to the left edge). +per-call lines ride in ONE compact "Tool calls (N)" disclosure, open +while the turn is live and FOLDED when the answer begins (the first +``delta``), on ``done``, on stop/abort, and on restore — and the lines +themselves are deboxed inline-flow text (no card, no flex: the label ++ ```` run as one continuous sentence and the path wraps to the +left edge). Phase 161 (D2): the disclosure sits INSIDE the ONE +reasoning container (``details.reasoning``), which is the only +collapsible. The D5 refinement (owner 2026-10-06) goes one step +further: the per-run disclosure is now a PLAIN div (no ``
``) +— always visible while the container is open; the container is the +ONE disclosure (open while the turn is live, folded at done/stop/ +restore). Run in isolation (DB must be up: ``podman compose up -d db``): @@ -41,22 +47,26 @@ already folded (phase 117, D3: the fold rides the first delta). Test → phase mapping (Playwright Mapping Rule): 1. ``test_tool_calls_fold_to_one_line_after_turn`` — pin 1: a - completed 3-call turn renders ONE visible "Tool calls (3)" summary; - the disclosure is CLOSED (no ``open`` attribute); the three - ``.tool-call`` lines are present in the DOM but folded (the first - line is not visible); the answer is present. + completed 3-call turn renders ONE visible "Tool calls (3)" summary + (revealed by opening the container — the ONE disclosure, folded at + rest); the in-container runs are plain divs (zero ``
`` + nested in the container — the D5 refinement); the three + ``.tool-call`` lines are present in the DOM but hidden (the + container is folded); the answer is present. 2. ``test_summary_click_expands_the_calls`` — pin 2: tapping the - native ```` (a real focusable toggle, AA) opens the - disclosure; the three lines become visible, in order, with the - phase-37/94 pinned text. + container's native ```` (the ONE real focusable toggle, + AA) opens the container; the three lines become visible, in order, + with the phase-37/94 pinned text. 3. ``test_expanded_line_is_deboxed_inline_flow`` — pin 3: the expanded "Reading" line's computed ``display`` is NOT ``flex`` (the label + path are one inline run, not two flex items — the debox), and the label runs directly into the path in the same run. 4. ``test_restored_turn_renders_folded`` — pin 4: a same-context RELOAD (the phase-14/50 persisted conversation restores) renders - the turn FOLDED — closed disclosure, "Tool calls (3)" summary, - three lines in the DOM, first line hidden — no auto-expand on load. + the turn FOLDED — folded container, plain div runs (zero nested + ``
``), "Tool calls (3)" summary (revealed by opening the + container), three lines in the DOM, first line hidden — no + auto-expand on load. """ from __future__ import annotations @@ -232,11 +242,13 @@ def _reset_db(seed: Callable[[Session], None] | None = None) -> None: # -------------------------------------------------------------------------- -def _disclosure_open(page: Page) -> bool: - """The disclosure's open state: a closed ``
`` has NO +def _container_open(page: Page) -> bool: + """The container's open state: a closed ``
`` has NO ``open`` attribute (``get_attribute`` → ``None``); an open one - carries it (any value — the native boolean attribute is empty).""" - return page.get_attribute(DISCLOSURE, "open") is not None + carries it (any value — the native boolean attribute is empty). + Phase 161 (D2): the container is the ONE disclosure — the + per-run disclosure is a plain div (the D5 refinement).""" + return page.get_attribute(REASONING, "open") is not None def _submit_tools_turn(page: Page, app_url: str) -> None: @@ -264,14 +276,11 @@ def _submit_tools_turn(page: Page, app_url: str) -> None: def _expand_disclosure(page: Page) -> None: - """Tap the summary — the native ```` is a real focusable - toggle (AA), so a plain click is the user contract. Phase 161 - (D2): at rest the disclosure sits INSIDE the folded reasoning - container, so the user flow is two native toggles — open the - container first (its own summary — a direct child, so the inner - runs' summaries are not matched), then the disclosure's summary.""" + """Tap the container's summary — the native ```` is a + real focusable toggle (AA), so a plain click is the user + contract. The D5 refinement: ONE toggle — the per-run disclosure + is a plain div, always visible while the container is open.""" page.locator(f"{REASONING} > summary").click() - page.locator(SUMMARY).click() # -------------------------------------------------------------------------- @@ -301,6 +310,11 @@ def test_tool_calls_fold_to_one_line_after_turn( container = page.locator(REASONING) expect(container).to_have_count(1) expect(container).not_to_have_attribute("open") + # Folded at rest: the record's lines are hidden (the D5 refinement — + # the at-rest fold is the container's; the per-run runs are plain + # divs that render on reveal). + expect(page.locator(LINES)).to_have_count(3) + expect(page.locator(LINES).first).not_to_be_visible() page.locator(f"{REASONING} > summary").click() expect(container).to_have_attribute("open", "") @@ -309,13 +323,15 @@ def test_tool_calls_fold_to_one_line_after_turn( expect(summary).to_be_visible() expect(summary).to_contain_text("Tool calls (3)") - # Folded at rest: a closed
carries no `open` attribute. - assert page.get_attribute(DISCLOSURE, "open") is None + # The D5 refinement: the in-container runs are plain divs — the + # per-run disclosure is NOT a
(the container is the ONE + # disclosure). + expect(page.locator(f"{REASONING} details")).to_have_count(0) - # The lines are present in the DOM (the permanent record) but - # folded: the FIRST line is not rendered. + # The lines are present in the DOM (the permanent record) and + # render on reveal — always visible inside the open container (D5). expect(page.locator(LINES)).to_have_count(3) - expect(page.locator(LINES).first).not_to_be_visible() + expect(page.locator(LINES).first).to_be_visible() # The answer is present (the mock's deterministic single-read quote). expect(page.locator(".msg.brain .bubble").last).to_contain_text( @@ -339,13 +355,13 @@ def test_summary_click_expands_the_calls( page.set_default_timeout(30_000) _reset_db(_seed) _submit_tools_turn(page, app_url) - assert not _disclosure_open(page) # folded after the turn (pin 1) + assert not _container_open(page) # folded after the turn (pin 1) _expand_disclosure(page) # The native boolean attribute is now present (any value — it is # empty in the DOM; "true" is the attribute-present shorthand). - assert page.get_attribute(DISCLOSURE, "open") is not None + assert _container_open(page) # All three lines, in order, visible with their pinned text. lines = page.locator(LINES) @@ -447,15 +463,17 @@ def test_restored_turn_renders_folded( expect(container.locator("> summary")).to_be_visible() expect(container.locator("> summary")).to_contain_text("Reasoning") - # The disclosure is CLOSED (no auto-expand on load) — and the - # in-container record is revealed by opening the container (the - # two-native-toggle user flow). - assert page.get_attribute(DISCLOSURE, "open") is None + # No auto-expand on load (the container is folded) — and the + # in-container record is revealed by opening the ONE toggle. + assert not _container_open(page) + # The D5 refinement: the restored runs are plain divs (no nested + #
). + expect(page.locator(f"{REASONING} details")).to_have_count(0) page.locator(f"{REASONING} > summary").click() expect(page.locator(SUMMARY)).to_be_visible() expect(page.locator(SUMMARY)).to_contain_text("Tool calls (3)") - # The three lines are in the DOM (the permanent record) but folded: - # the first line is not rendered — no auto-expand. + # The three lines are in the DOM (the permanent record) and render + # on reveal — always visible inside the open container (D5). expect(page.locator(LINES)).to_have_count(3) - expect(page.locator(LINES).first).not_to_be_visible() + expect(page.locator(LINES).first).to_be_visible() diff --git a/tests/unit/test_frontend_edit_message.py b/tests/unit/test_frontend_edit_message.py index 8ae1f3c..48bf632 100644 --- a/tests/unit/test_frontend_edit_message.py +++ b/tests/unit/test_frontend_edit_message.py @@ -935,27 +935,46 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {} // is no longer "processing"; settle its "(Ns)" suffix (the // visible indication moves to the thinking block). settleToolLine(); - // Phase 17: model reasoning — stream it live into the collapsible - // Thinking block. No setUiState here: the UI state stays + // Phase 17: model reasoning — stream it live into the per-run + // Thinking block (the D5 refinement, owner 2026-10-06 — a + // plain div, always visible; the container is the ONE + // collapsible). No setUiState here: the UI state stays // "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 158 (D1, owner direction 2026-10-04): PER-RUN blocks — // the D15 "re-open the one shared block" is superseded. Each - // CONTIGUOUS thinking run streams into its OWN - // details.thinking (ensureThinkingRun: a frame of a new kind - // closes the previous run and opens a new block; D5 — the new - // block is OPEN while its frames stream). A post-delta - // thinking frame (the next agent round: the model answered, - // called a tool, then thinks again) lands in a NEW block above - // the answer — not a re-open of the block that sits on top of - // it (the owner's "thinking resumed above the answer" awkwardness - // is gone; the runs stay in arrival order, D2). + // CONTIGUOUS thinking run streams into its OWN block + // (ensureThinkingRun: a frame of a new kind ends the previous + // run and opens a new block — always visible inside the + // container; D5 refinement). A post-delta thinking frame (the + // next agent round: the model answered, called a tool, then + // thinks again) lands in a NEW block — not a re-open of the + // block that sits on top of it (the owner's "thinking resumed + // above the answer" awkwardness is gone; the runs stay in + // arrival order, D2). 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 + // Phase 161 (D3): the container window's PRE-update pin + // capture — BEFORE ensureThinkingRun appends the (new) block: + // the append's own height growth must not leak into the + // "pre" reading (an append taller than the 32px band would + // read as "the user scrolled up" and kill the follow at the + // first frame of every run after the first — the D5 + // refinement, owner 2026-10-06, exposed the ordering defect: + // the old details collapse used to reset the window to a + // fitting state, masking it). The phase-17 pre-measure + // convention, verbatim. + // convention, verbatim. A turn's FIRST frame (the container + // did not exist yet — reasoningWrapEl is null) is always + // pinned: the fresh window starts at its bottom (st=0), and + // a first chunk taller than the 320px window must still + // land on the tail, not the top. + const rbody = reasoningWrapEl?.querySelector(".reasoning-body"); + const rPinned = rbody ? isReasoningPinned(rbody) : true; const block = ensureThinkingRun(wrap); // Phase 161 (D6): the container's live status — "Thinking…" // while a thinking frame streams (a tool frame overwrites it @@ -970,18 +989,17 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {} // grows the window's content below the old bottom, so measuring // the distance AFTER the update reads the chunk's height, not // the user's position, and the follow died at the first - // 2-newline gap. Pre-update, the distance is where the user - // actually is. Phase 158: the same pin logic, now operating on - // THIS block (per-run — each run's block pins its own tail; - // the pre-update capture is verbatim). + // 2-newline gap. The container capture above is taken BEFORE + // the block append for the same reason (the D5 refinement's + // exposed ordering defect). Phase 158: the same pin logic, now + // operating on THIS block (per-run — each run's block pins its + // own tail; the pre-update capture is verbatim). The D5 + // refinement (owner 2026-10-06): the in-container block is a + // plain div (block.open is undefined → the gate is false) and + // its .thinking-text is not scrollable, so this capture + // no-ops there — the container window's follow below is the + // live scroll surface. const pinned = block.open && isThinkingNearBottom(textEl); - // Phase 161 (D3): the container window's PRE-update pin - // capture — the phase-17 pre-measure convention, BEFORE the - // re-render grows the content (the inner per-block follow - // above stays — inside the container the .thinking-text is - // no longer scrollable, so it no-ops there; task 03). - const rbody = reasoningWrapEl?.querySelector(".reasoning-body"); - const rPinned = !!rbody && isReasoningPinned(rbody); textEl.innerHTML = renderMarkdown(currentRun.run.text); // escape-first, XSS-safe if (pinned) { // Follow the live tail only while the user was pinned to the @@ -1063,24 +1081,37 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {} ?.setAttribute("aria-label", toolStatus); } // Phase 158 (D1): PER-RUN disclosure — each CONTIGUOUS tool-call - // run streams into its OWN details.tool-calls-disclosure - // (ensureToolRun: a frame of a new kind closes the previous run - // and opens a new disclosure; D5 — OPEN while its frames stream), - // inserted BEFORE the bubble in arrival order (D2). The record + // run streams into its OWN block (ensureToolRun: a frame of a + // new kind ends the previous run and opens a new disclosure — + // the D5 refinement, owner 2026-10-06: a plain div, always + // visible inside the container; the container is the ONE + // collapsible), inserted in arrival order (D2). The record // rides the run's `records` too — the SAME toolAcc objects, so // the phase-95 tool_result stamp (truncated / chars_shown / // chars_total) flows into the segments payload with no extra // wiring. + // Phase 161 (D3): the container window's PRE-update pin + // capture — BEFORE ensureToolRun appends the (new) block: + // the append's own height growth must not leak into the + // "pre" reading (an append taller than the 32px band would + // read as "the user scrolled up" and kill the follow — the + // D5 refinement, owner 2026-10-06, exposed the ordering + // defect: the old details collapse used to reset the window + // to a fitting state, masking it). The phase-17 pre-measure + // convention, verbatim. + // convention, verbatim. A turn's FIRST frame (the container + // did not exist yet — reasoningWrapEl is null) is always + // pinned: the fresh window starts at its bottom (st=0), and + // a first line taller than the 320px window must still land + // on the tail, not the top. + const tbody = reasoningWrapEl?.querySelector(".reasoning-body"); + const tPinned = tbody ? isReasoningPinned(tbody) : true; const disc = ensureToolRun(wrap); currentRun.run.records.push(toolRecord); // Phase 161 (D6): "Calling …" — the display name the // tool line already shows (the phase-70 legacy-name mapping // produced `name` before this point). setReasoningStatus(`Calling ${name}…`); - // Phase 161 (D3): the container window's PRE-update pin - // capture — before the line append grows the content. - const tbody = reasoningWrapEl?.querySelector(".reasoning-body"); - const tPinned = !!tbody && isReasoningPinned(tbody); const line = appendToolLine( disc, name, @@ -1132,18 +1163,22 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {} acc += ev.text || ""; if (uiState === UI_STATE.thinking) setUiState(UI_STATE.streaming); if (!wrap) wrap = addMessage("brain", ""); // first token: live bubble in - // Phase 158 (D5): the first delta FOLDS the open run (the - // active block closes; a later thinking frame opens a NEW run - // above the bubble — the D15 re-open of the shared block is - // gone). Idempotent — a no-op without an open run. + // Phase 158 (D5): the first delta ENDS the active run (the + // D5 refinement, owner 2026-10-06 — the in-container runs are + // non-collapsible divs, so nothing folds; the run simply + // stops accumulating frames — a later thinking frame opens a + // NEW run in the container — the D15 re-open of the shared + // block is gone). Idempotent — a no-op without an active run. closeCurrentRun(); wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc); // No page scroll (phase 42): the answer never follows the viewport. } else if (ev.type === "done") { sawDone = true; - // Phase 158 (D5): the turn is over — fold EVERY open run - // (defensive: by construction at most the last run is open; a - // late double-close is a no-op). Replaces the phase-117 + // Phase 158 (D5): the turn is over — the D5 refinement (owner + // 2026-10-06) made the in-container runs non-collapsible divs, + // so this is the DEFENSIVE legacy guard (the pre-phase-161 + // details blocks the restore path creates, already closed — a + // no-op in every live path). Replaces the phase-117 // closeToolCalls fold + the D15 block close. closeAllRuns(wrap); // Phase 161 (D2): the turn settled — fold the container too @@ -1468,13 +1503,21 @@ def test_run_turn_is_pinned_at_phase_159_state() -> None: the live container, like segRuns; the D6 status writes + the D3 pre/post follow in the thinking/tool frame branches; the ``settleReasoning(wrap)`` fold at the done / stop / failed - settle sites — the container's live-path contract, D1/D2/D3/D6): - the full source must match the pin — the ``reask`` path, the - turn-local resets, the SSE contract, the save points, and the - sanctioned additions (the task-01 ``lastUserWrap`` assignment + - the idle-settle mark pass, the task-03 aborted-settle mark pair, - the phase-160 done-settle pair, and the phase-161 live-container - additions) included.""" + settle sites — the container's live-path contract, D1/D2/D3/D6) + and the D5 refinement's (owner 2026-10-06) sanctioned changes + (the per-run blocks are plain divs — ``ensureThinkingRun`` / + ``ensureToolRun`` create ``div``s through the run builders, never + open/foldable; the container window's PRE-update pin capture + moves BEFORE the block append — the append's height growth must + not leak into the "pre" reading; the first-frame edge case — a + fresh window is always pinned; the legacy block-level pin + capture stays, no-op'ing on the divs): the full source must + match the pin — the ``reask`` path, the turn-local resets, the + SSE contract, the save points, and the sanctioned additions (the + task-01 ``lastUserWrap`` assignment + the idle-settle mark + pass, the task-03 aborted-settle mark pair, the phase-160 + done-settle pair, the phase-161 live-container additions, and + the D5 refinement) included.""" assert _fn_source(_js(), "runTurn") == PINNED_RUN_TURN diff --git a/tests/unit/test_frontend_feedback.py b/tests/unit/test_frontend_feedback.py index e477cab..efa02fe 100644 --- a/tests/unit/test_frontend_feedback.py +++ b/tests/unit/test_frontend_feedback.py @@ -145,20 +145,25 @@ def test_thinking_event_is_a_first_class_turn_branch() -> None: def test_thinking_run_helpers_are_idempotent() -> None: """Phase 158 (D1/D5 — the phase-109 D15 pin, replaced by owner direction 2026-10-04): ``ensureThinkingRun`` returns the active - run's block or creates a NEW one OPEN (phase 161, D1: appended - into the reasoning container's ``.reasoning-body`` — the - per-run bubble anchor is superseded; a frame of a new kind - closes the previous run first); ``closeCurrentRun`` is a no-op - without a run and folds the active one (the D15 re-open of the - shared block is gone — a post-delta thinking frame opens a NEW - block).""" + run's block or creates a NEW one (the D5 refinement, owner + 2026-10-06 — a plain ``div.thinking`` through the run builder, + NEVER open/foldable: the container is the ONE disclosure); it is + appended into the reasoning container's ``.reasoning-body`` (phase + 161, D1 — the per-run bubble anchor is superseded; a frame of a + new kind ends the previous run first); ``closeCurrentRun`` is a + no-op without a run and ends the active one (nothing folds — the + D5 refinement; the D15 re-open of the shared block is gone — a + post-delta thinking frame opens a NEW block).""" js = _js() fn = js.find("function ensureThinkingRun") assert fn != -1, "ensureThinkingRun must exist (near addTyping/removeTyping)" body = js[fn : js.find("\n}\n", fn)] - assert "closeCurrentRun()" in body, ("a frame of a new kind closes the " + assert "closeCurrentRun()" in body, ("a frame of a new kind ends the " "previous run (D1)") - assert "block.open = true" in body, "D5: created open — the stream is the show" + assert "createThinkingRunEl()" in body, ( + "D5 refinement: a plain div — never collapsible (the container " + "is the ONE disclosure)" + ) assert ( 'ensureReasoningWrap(wrap).querySelector(".reasoning-body")' ".appendChild(block)" in body @@ -169,15 +174,20 @@ def test_thinking_run_helpers_are_idempotent() -> None: fn2 = js.find("function closeCurrentRun") assert fn2 != -1, "closeCurrentRun must exist" body2 = js[fn2 : js.find("\n}\n", fn2)] - assert "currentRun.el.open = false" in body2 + assert ".open" not in body2, ( + "D5 refinement: nothing folds — the run simply ends (the " + "in-container runs are non-collapsible divs)" + ) assert "currentRun = null" in body2, "a no-op without a run (idempotent)" def test_delta_branch_collapses_block_and_transitions_to_streaming() -> None: """The first answer delta transitions thinking → streaming (even when - thinking created the wrap first) and folds the active run — phase 158 - (D5): ``closeCurrentRun()`` (a later thinking frame opens a NEW run; - the D15 re-open of the shared block is gone).""" + thinking created the wrap first) and ends the active run — phase 158 + (D5): ``closeCurrentRun()`` (the D5 refinement, owner 2026-10-06 — + the in-container runs are non-collapsible divs, so nothing folds; + a later thinking frame opens a NEW run; the D15 re-open of the + shared block is gone).""" js = _js() delta_idx = js.find('ev.type === "delta"') done_idx = js.find('ev.type === "done"') @@ -190,8 +200,10 @@ def test_delta_branch_collapses_block_and_transitions_to_streaming() -> None: def test_done_branch_sets_sawdone_and_closes_block() -> None: """On `done` the turn marks itself complete (sawDone — the stream-drop - guard keys off it) and settles EVERY open run closed (phase 158, D5: - ``closeAllRuns``).""" + guard keys off it) and settles the runs (phase 158, D5: + ``closeAllRuns`` — the D5 refinement, owner 2026-10-06 — the + in-container runs are non-collapsible divs, so the defensive + details query no-ops in every live path).""" js = _js() done_idx = js.find('ev.type === "done"') error_idx = js.find('ev.type === "error"') diff --git a/tests/unit/test_frontend_tool_states.py b/tests/unit/test_frontend_tool_states.py index 7a565f8..7294156 100644 --- a/tests/unit/test_frontend_tool_states.py +++ b/tests/unit/test_frontend_tool_states.py @@ -300,8 +300,18 @@ def test_segments_keep_arrival_order_above_the_bubble() -> None: ) assert "??" not in t, ("the old anchor chain is GONE — the container " "append is the only anchor") - assert "block.open = true" in t - assert "disc.open = true" in d + assert "createThinkingRunEl()" in t, ( + "D5 refinement: a plain div — never collapsible" + ) + assert "createToolRunEl()" in d, ( + "D5 refinement: a plain div — never collapsible" + ) + assert ".open =" not in t, ( + "D5 refinement: the in-container runs are non-collapsible divs" + ) + assert ".open =" not in d, ( + "D5 refinement: the in-container runs are non-collapsible divs" + ) def test_tool_call_style_is_accent_and_contrast_safe() -> None: diff --git a/tests/unit/test_reasoning_bubble.py b/tests/unit/test_reasoning_bubble.py index cfb553f..a3562c0 100644 --- a/tests/unit/test_reasoning_bubble.py +++ b/tests/unit/test_reasoning_bubble.py @@ -211,9 +211,12 @@ def test_per_run_inserts_append_into_the_reasoning_body() -> None: ``.reasoning-body`` — ``ensureThinkingRun`` / ``ensureToolRun`` insert via ``ensureReasoningWrap(wrap).querySelector( ".reasoning-body").appendChild(…)`` (arrival order preserved: a - new run appends after the previous run's block). EVERYTHING ELSE - in both functions is unchanged (closeCurrentRun, ``open = true`` - D5, the segRuns push, currentRun).""" + new run appends after the previous run's block). The D5 + refinement (owner 2026-10-06): the per-run elements are plain + ``div``s through the run builders (never open/foldable — the + container is the ONE disclosure); everything else in both + functions is unchanged (closeCurrentRun, the segRuns push, + currentRun).""" js = _js() t = _fn(js, "ensureThinkingRun") assert ( @@ -221,7 +224,9 @@ def test_per_run_inserts_append_into_the_reasoning_body() -> None: ".appendChild(block)" in t ), "the thinking run appends into the container's body" assert "closeCurrentRun()" in t - assert "block.open = true" in t, "D5: the per-run open/fold is unchanged" + assert "createThinkingRunEl()" in t, ( + "D5 refinement: a plain div — never collapsible" + ) assert "segRuns.push(run)" in t assert "currentRun =" in t d = _fn(js, "ensureToolRun") @@ -230,8 +235,16 @@ def test_per_run_inserts_append_into_the_reasoning_body() -> None: ".appendChild(disc)" in d ), "the tool run appends into the container's body" assert "closeCurrentRun()" in d - assert "disc.open = true" in d, "D5: the per-run open/fold is unchanged" + assert "createToolRunEl()" in d, ( + "D5 refinement: a plain div — never collapsible" + ) assert "segRuns.push(run)" in d + assert ".open =" not in t, ( + "D5 refinement: the in-container runs are non-collapsible divs" + ) + assert ".open =" not in d, ( + "D5 refinement: the in-container runs are non-collapsible divs" + ) def test_the_per_run_bubble_anchor_is_gone() -> None: @@ -484,10 +497,13 @@ def test_restore_per_run_blocks_append_into_the_reasoning_body() -> None: append into the container's ``.reasoning-body`` — the per-run ``insertBefore(…, bubble)`` anchor is GONE from ``restoreTimeline`` (the container sits before the bubble; the - runs nest inside it, arrival order preserved). EVERYTHING ELSE - in the loop is unchanged (the per-run create, the closed state, - the tool-run seal, the well-formedness guards, the truncation - note).""" + runs nest inside it, arrival order preserved). The D5 + refinement (owner 2026-10-06, parity with app.js): the + in-container runs are plain ``div``s through the run builders + (never collapsible — the container is the ONE disclosure, + created COLLAPSED by the restore); everything else in the loop + is unchanged (the tool-run seal, the well-formedness guards, + the truncation note).""" body = _fn(_js(), "restoreTimeline") assert "runsEl.appendChild(block)" in body, ( "the thinking block appends into the container's body" @@ -501,12 +517,19 @@ def test_restore_per_run_blocks_append_into_the_reasoning_body() -> None: assert "insertBefore(toolDisc" not in body, ( "the per-run bubble anchor is GONE from restoreTimeline (tool)" ) - # the per-run create + the closed state are unchanged. - assert 'summary.textContent = "Thinking"' in body, ( - "the per-run thinking block structure is unchanged" + # the per-run create is the div builder (the D5 refinement). + assert "createThinkingRunEl()" in body, ( + "D5 refinement: the in-container run is a plain div (never " + "collapsible — the container is the ONE disclosure)" + ) + assert "createToolRunEl()" in body, ( + "D5 refinement: the in-container run is a plain div (never " + "collapsible)" + ) + assert "block.open = true" not in body, "the in-container runs never open" + assert "toolDisc.open" not in body, ( + "D5 refinement: the in-container runs are non-collapsible divs" ) - assert "block.open = true" not in body, "D5: created COLLAPSED (unchanged)" - assert "toolDisc.open = false" in body assert "if (toolDisc) toolDisc = null" in body, "the tool-run seal is unchanged" @@ -768,15 +791,20 @@ def test_inner_html_is_absent_from_both_wrappers() -> None: "innerHTML ABSENT from the shared.js builder (the house rule)" ) # the ONLY HTML write in each timeline function is the - # escape-first renderMarkdown into .thinking-text. + # escape-first renderMarkdown into .thinking-text (the shared + # builder's local textEl; app.js's restore targets the div + # block's .thinking-text through querySelector). for fn_name, js in (("restoreTimeline", _js()), ("addSegments", _js_shared())): body = _fn(js, fn_name) - writes = re.findall(r"\b(\w+)\.innerHTML\s*=\s*([^;\n]+)", body) + writes = re.findall(r"([\w.$()\"'-]+\.?)\.innerHTML\s*=\s*([^;\n]+)", body) assert len(writes) == 1, ( f"{fn_name}: exactly ONE innerHTML write (the structure is " f"createElement): {writes}" ) - assert writes[0][0] == "textEl", "the single write is into .thinking-text" + assert ( + writes[0][0].rstrip(".") == "textEl" + or 'querySelector(".thinking-text")' in writes[0][0] + ), "the single write is into .thinking-text" assert writes[0][1].strip() == "renderMarkdown(entry.text)", ( "the single write is the escape-first renderMarkdown" ) @@ -889,29 +917,37 @@ def test_the_reasoning_body_is_the_one_bounded_live_window() -> None: def test_the_inner_runs_are_compacted_to_one_scroll_surface() -> None: - """Task 03 item 5: the inner-run compacting (D3 — ONE scroll - surface, the nested border weight removed): - ``details.reasoning details.thinking`` is de-bordered (a 2px - ``--line`` indent keeps the family rhythm, transparent - background, tight margins); the inner ``.thinking-text`` - 320px window is DISABLED (``max-height: none`` + - ``overflow-y: visible``) so the container's window is the single - scroll surface; the nested ``.tool-calls-disclosure`` keeps its - compact margin (the documented-intent rule).""" + """Task 03 item 5 + the D5 refinement (owner 2026-10-06): the + inner runs nest as PLAIN DIVS inside ONE scroll surface — + ``details.reasoning .thinking`` (the family's brand-soft rail, + the 2px weight — distinct from the tool run's neutral rail) and + ``details.reasoning .tool-calls-disclosure`` (the neutral + ``--line`` rail), de-bordered (transparent background, no nested + box); the inner ``.thinking-text`` 320px window is DISABLED + (``max-height: none`` + ``overflow-y: visible``) so the + container's window is the single scroll surface. The runs are + distinct from each other by LABEL + RAIL: the brand-ink + "Thinking" label vs the accent-ink "Tool call(s) (N)" label. + """ css = _css() - run = _rule(css, "details.reasoning details.thinking") - assert "border: 0" in run, ("the nested border weight is removed") - assert "border-left: 2px solid var(--line)" in run - assert "border-radius: 0" in run - assert "margin: 0.25rem 0" in run - assert "background: transparent" in run - inner_text = _rule(css, "details.reasoning details.thinking .thinking-text") + run = _rule(css, "details.reasoning .thinking") + assert "border-left: 2px solid var(--brand-soft)" in run, ( + "the thinking run carries the brand-soft rail" + ) + inner_text = _rule(css, "details.reasoning .thinking .thinking-text") assert "max-height: none" in inner_text, ( "the inner 320px window is disabled" ) assert "overflow-y: visible" in inner_text disc = _rule(css, "details.reasoning .tool-calls-disclosure") - assert "margin: 0.25rem 0" in disc + assert "border-left: 2px solid var(--line)" in disc, ( + "the tool run carries the neutral rail (distinct from the " + "thinking run's brand-soft rail)" + ) + label = _rule(css, "details.reasoning .thinking .thinking-summary") + assert "var(--brand-ink)" in label, ( + "the thinking label is brand-ink (the family pairing)" + ) def test_the_reasoning_still_is_in_the_house_reduced_motion_block() -> None: @@ -949,8 +985,11 @@ def test_the_legacy_no_container_rules_are_untouched() -> None: # line-anchored: the nested details.reasoning selector is a # different rule). assert len(re.findall(r"(?m)^\.tool-calls-disclosure \{", css)) == 1 - disc = _rule(css, ".tool-calls-disclosure") - assert "margin: 0.25rem 0" in disc + disc = re.search( + r"(?m)^\.tool-calls-disclosure \{([^}]*)\}", css + ) + assert disc, "styles.css must style .tool-calls-disclosure (base)" + assert "margin: 0.25rem 0" in disc.group(1) chevron = _rule(css, ".tool-calls-summary::before") assert 'content: "▸"' in chevron assert "margin-right: 0.5rem" in chevron diff --git a/tests/unit/test_thinking_timeline.py b/tests/unit/test_thinking_timeline.py index e520852..8c84f4f 100644 --- a/tests/unit/test_thinking_timeline.py +++ b/tests/unit/test_thinking_timeline.py @@ -78,27 +78,34 @@ def _branch(js: str, open_type: str, close_type: str) -> str: # ---------- the run builders (D1/D2/D3/D5) ---------- -def test_ensure_thinking_run_builds_a_new_open_block_in_the_container() -> None: - """D1/D3/D5: ``ensureThinkingRun`` returns the active thinking - run's block or — a NEW ``details.thinking`` (the phase-17 static - structure via ``createThinkingBlock``) created OPEN (D5), - appended into the phase-161 reasoning container's +def test_ensure_thinking_run_builds_a_new_div_block_in_the_container() -> None: + """D1/D3 + the D5 refinement (owner 2026-10-06): + ``ensureThinkingRun`` returns the active thinking run's block or + — a NEW plain ``div.thinking`` (the run builder — + ``createThinkingRunEl``: the static ``.thinking-summary`` + "Thinking" literal + the ``.thinking-text``; NEVER open/foldable + — the container is the ONE disclosure, always visible while it + is open), appended into the phase-161 reasoning container's ``.reasoning-body`` (D1 — the container sits before the bubble, a new run appends after the previous run's block, so arrival - order is preserved; the per-run bubble anchor is superseded). The - plain ``Thinking`` literal (D3 — the - phase-17 literal, not a numbered variant) rides the static - template; the fresh run joins segRuns + currentRun.""" + order is preserved; the per-run bubble anchor is superseded). + The plain ``Thinking`` literal (D3 — the + phase-17 literal, not a numbered variant) rides the builder; + the fresh run joins segRuns + currentRun.""" js = _js() body = _fn(js, "ensureThinkingRun") assert 'currentRun?.kind === "thinking"' in body, ( "the active thinking run is extended, not recreated" ) assert "closeCurrentRun()" in body, ( - "a frame of a new kind CLOSES the previous run first (D1)" + "a frame of a new kind ends the previous run first (D1)" + ) + assert "createThinkingRunEl()" in body, ( + "the D5 refinement: a plain div — never collapsible" + ) + assert ".open =" not in body, ( + "the in-container runs are non-collapsible divs" ) - assert "createThinkingBlock()" in body, "the shared builder is reused" - assert "block.open = true" in body, "D5: created OPEN while its frames stream" assert ( 'ensureReasoningWrap(wrap).querySelector(".reasoning-body")' ".appendChild(block)" in body @@ -139,13 +146,15 @@ def test_create_thinking_block_is_the_phase_17_static_template_without_data() -> ) -def test_ensure_tool_run_builds_a_new_open_disclosure_in_the_container() -> None: - """D1/D5: ``ensureToolRun`` returns the active tool run's - disclosure or — a NEW ``details.tool-calls-disclosure`` (the - phase-117 structure via ``createToolDisclosure`` — - summary.tool-calls-summary + the .tool-calls list, role=list + - aria-label "Tool calls") created OPEN (D5), appended into the - phase-161 reasoning container's ``.reasoning-body`` (D1 — the +def test_ensure_tool_run_builds_a_new_div_disclosure_in_the_container() -> None: + """D1 + the D5 refinement (owner 2026-10-06): ``ensureToolRun`` + returns the active tool run's disclosure or — a NEW plain + ``div.tool-calls-disclosure`` (the run builder — + ``createToolRunEl``: the static ``.tool-calls-summary`` + the + .tool-calls list, role=list + aria-label "Tool calls"; NEVER + open/foldable — the container is the ONE disclosure, always + visible while it is open), appended into the phase-161 + reasoning container's ``.reasoning-body`` (D1 — the container sits before the bubble, arrival order preserved; the per-run bubble anchor is superseded). One disclosure per CONTIGUOUS tool-call run; the per-run "Tool call(s) (N)" count @@ -154,8 +163,12 @@ def test_ensure_tool_run_builds_a_new_open_disclosure_in_the_container() -> None body = _fn(js, "ensureToolRun") assert 'currentRun?.kind === "tool"' in body assert "closeCurrentRun()" in body - assert "createToolDisclosure()" in body - assert "disc.open = true" in body, "D5: created OPEN while its frames stream" + assert "createToolRunEl()" in body, ( + "the D5 refinement: a plain div — never collapsible" + ) + assert ".open =" not in body, ( + "the in-container runs are non-collapsible divs" + ) assert ( 'ensureReasoningWrap(wrap).querySelector(".reasoning-body")' ".appendChild(disc)" in body @@ -321,14 +334,19 @@ def test_the_arm_follows_the_line_append_with_the_new_signature() -> None: def test_close_current_run_and_close_all_runs_are_defined() -> None: - """D5's fold side: ``closeCurrentRun`` folds the ACTIVE run - (idempotent; a no-op without a run) and ``closeAllRuns`` closes - EVERY open ``details.thinking`` + ``details.tool-calls-disclosure`` - in the wrap (defensive — a late double-close is a no-op; a no-op - without a wrap).""" + """The D5 refinement's (owner 2026-10-06) fold side: + ``closeCurrentRun`` ENDS the ACTIVE run (nothing folds — the + in-container runs are non-collapsible divs; idempotent; a + no-op without a run) and ``closeAllRuns`` closes EVERY open + ``details.thinking`` + ``details.tool-calls-disclosure`` in the + wrap (the DEFENSIVE legacy guard — the pre-phase-161 + details blocks the restore path creates, already closed; a + no-op in every live path).""" js = _js() cur = _fn(js, "closeCurrentRun") - assert "currentRun.el.open = false" in cur + assert ".open" not in cur, ( + "D5 refinement: nothing folds — the run simply ends" + ) assert "currentRun = null" in cur, "the run is cleared (a later close is a no-op)" all_runs = _fn(js, "closeAllRuns") assert "details.thinking, details.tool-calls-disclosure" in all_runs @@ -581,34 +599,35 @@ def test_the_pre_phase_narrative_is_replaced() -> None: def test_restore_timeline_builds_the_collapsed_per_run_structure() -> None: """D5: ``restoreTimeline`` renders the record's ``segments`` - COLLAPSED — a ``kind:"thinking"`` entry gets its OWN - ``details.thinking`` (createElement structure, the plain - ``Thinking`` literal via textContent — D3; NO ``open = true`` — - created closed), and CONSECUTIVE ``kind:"tool"`` entries group - into ONE ``details.tool-calls-disclosure`` (the shared - phase-117 builder — ``createToolDisclosure``, innerHTML ABSENT — - created closed). Phase 161 (task 02, D5/D6): the per-run blocks - nest inside ONE details.reasoning container — the shared - task-01 builder, created CLOSED and inserted ONCE before the - bubble (the per-run bubble anchor is superseded; arrival order + inside the container COLLAPSED — a ``kind:"thinking"`` entry + gets its OWN plain ``div.thinking`` (the D5 refinement — the + run builder, never collapsible), and CONSECUTIVE + ``kind:"tool"`` entries group into ONE plain + ``div.tool-calls-disclosure`` (the run builder — the static + label + the .tool-calls list; innerHTML ABSENT). Phase 161 + (task 02, D5/D6): the per-run blocks nest inside ONE + details.reasoning container — the shared task-01 builder, + created CLOSED and inserted ONCE before the bubble (the + per-run bubble anchor is superseded; arrival order preserved). The per-run count rides ``appendToolLine``'s summary update; a kind change seals the tool run (one disclosure per run, D1).""" body = _fn(_js(), "restoreTimeline") - assert "document.createElement(\"details\")" in body, "the structure is createElement" - assert 'block.className = "thinking"' in body - assert "block.open = true" not in body, "D5: created COLLAPSED (no open state)" - assert 'summary.textContent = "Thinking"' in body, ( - "the plain Thinking literal (D3 — the phase-17 literal; " - "position distinguishes the runs) via textContent" + assert "createThinkingRunEl()" in body, ( + "the D5 refinement: the in-container run is a plain div " + "(never collapsible — the container is the ONE disclosure)" + ) + assert "createToolRunEl()" in body, ( + "the D5 refinement: the in-container run is a plain div " + "(never collapsible)" + ) + assert "block.open = true" not in body, "the in-container runs never open" + assert "toolDisc.open" not in body, ( + "the in-container runs are non-collapsible divs" ) - assert 'textEl.className = "thinking-text"' in body # the consecutive-tool grouping (one disclosure per run — the # grouping loop): create on the first entry, seal on a kind - # change, insert before the bubble. - assert "createToolDisclosure()" in body, "the shared phase-117 builder (innerHTML ABSENT)" - assert "toolDisc.open = true" not in body, "D5: created COLLAPSED (no open state)" - assert "toolDisc.open = false" in body + # change, append into the container's body. assert "if (toolDisc) toolDisc = null" in body, ( "a thinking entry seals the tool run — ONE disclosure per run (D1)" ) @@ -682,11 +701,14 @@ def _single_escape_first_write(body: str) -> None: into ``.thinking-text`` (the existing restore convention; renderMarkdown's escape-first contract keeps storage XSS-safe).""" - writes = re.findall(r"\b(\w+)\.innerHTML\s*=\s*([^;\n]+)", body) + writes = re.findall(r"([\w.$()\"'-]+\.?)\.innerHTML\s*=\s*([^;\n]+)", body) assert len(writes) == 1, ( f"exactly ONE innerHTML write (the structure is createElement): {writes}" ) - assert writes[0][0] == "textEl", "the single write is into .thinking-text" + assert ( + writes[0][0].rstrip(".") == "textEl" + or 'querySelector(".thinking-text")' in writes[0][0] + ), "the single write is into .thinking-text" assert writes[0][1].strip() == "renderMarkdown(entry.text)", ( "the single write is the escape-first renderMarkdown" ) @@ -694,19 +716,22 @@ def _single_escape_first_write(body: str) -> None: def test_restore_timeline_structure_is_create_element_and_the_only_write_is_escape_first() -> None: """innerHTML ABSENT from the STRUCTURE — it is built via - createElement/textContent (the thinking block's summary is - textContent; the disclosure rides the innerHTML-absent - ``createToolDisclosure`` — the task-02 pin). The ONLY HTML + createElement/textContent (the in-container runs ride the + innerHTML-absent D5-refinement run builders — the static + label via textContent; the .tool-calls list, role=list + + aria-label "Tool calls" — the task-02 pin). The ONLY HTML write in the function is the escape-first renderMarkdown into ``.thinking-text`` (the existing restore convention — the phase-17 pattern; renderMarkdown's escape-first contract keeps storage XSS-safe).""" body = _fn(_js(), "restoreTimeline") _single_escape_first_write(body) - assert "document.createElement(\"summary\")" in body, ( - "the summary is createElement + textContent" + assert "createThinkingRunEl()" in body, ( + "the in-container run rides the innerHTML-absent run builder" + ) + assert "createToolRunEl()" in body, ( + "the in-container run rides the innerHTML-absent run builder" ) - assert "document.createElement(\"div\")" in body, "the .thinking-text is createElement" def test_the_restore_dispatch_gives_segments_precedence_over_the_legacy_path() -> None: @@ -756,31 +781,37 @@ def test_the_restore_dispatch_gives_segments_precedence_over_the_legacy_path() - def test_add_segments_builds_the_collapsed_parity_timeline() -> None: """The shared-page parity copy (D5 — pure render, always - COLLAPSED): the same structure (a per-run details.thinking with - the plain ``Thinking`` literal via textContent + the phase-117 - disclosure structure — summary.tool-calls-summary + the - .tool-calls list, role=list + aria-label "Tool calls"), the same - LINE BYTES as ``addToolLines`` (the four pinned label literals - + the phase-70 legacy names + the phase-139 readRangeSuffix + - the phase-95 truncation note), the per-run - ``Tool call(s) (N)`` count, and nesting inside the phase-161 - details.reasoning container (the local parity builder, created - CLOSED and inserted ONCE before the bubble — the per-run bubble - anchor is superseded, arrival order preserved) — all created - CLOSED (no ``open = true`` anywhere; the addThinkingBlock / - addToolLines collapsed convention).""" + COLLAPSED): the same structure (a per-run plain div.thinking + with the static ``.thinking-summary`` "Thinking" label via + textContent + the phase-117 disclosure structure as a plain + div — ``.tool-calls-summary`` + the .tool-calls list, + role=list + aria-label "Tool calls"; the D5 refinement — + NEVER collapsible, always visible while the container is + open), the same LINE BYTES as ``addToolLines`` (the four + pinned label literals + the phase-70 legacy names + the + phase-139 readRangeSuffix + the phase-95 truncation note), the + per-run ``Tool call(s) (N)`` count, and nesting inside the + phase-161 details.reasoning container (the local parity + builder, created CLOSED and inserted ONCE before the bubble — + the per-run bubble anchor is superseded, arrival order + preserved) — all created COLLAPSED (the container is the ONE + disclosure; the addThinkingBlock / addToolLines collapsed + convention).""" body = _fn(_js_shared(), "addSegments") - assert "document.createElement(\"details\")" in body, "the structure is createElement" + assert "document.createElement(\"div\")" in body, "the structure is createElement" assert 'block.className = "thinking"' in body - assert "block.open = true" not in body, "D5: created COLLAPSED" - assert "block.open = false" in body - assert 'summary.textContent = "Thinking"' in body, "the plain Thinking literal (D3)" + assert "block.open" not in body, ( + "D5 refinement: the in-container runs are non-collapsible divs" + ) + assert 'label.textContent = "Thinking"' in body, "the plain Thinking literal (D3)" assert 'textEl.className = "thinking-text"' in body assert "renderMarkdown(entry.text)" in body, "the escape-first renderer (parity)" - # the phase-117 disclosure structure. + # the phase-117 disclosure structure (the D5 refinement — a plain + # div, never collapsible). assert 'toolDisc.className = "tool-calls-disclosure"' in body - assert "toolDisc.open = true" not in body, "D5: created COLLAPSED" - assert "toolDisc.open = false" in body + assert "toolDisc.open" not in body, ( + "D5 refinement: the in-container runs are non-collapsible divs" + ) assert 'summary.className = "tool-calls-summary"' in body assert 'container.className = "tool-calls"' in body assert 'setAttribute("role", "list")' in body diff --git a/tests/unit/test_tool_call_compact.py b/tests/unit/test_tool_call_compact.py index 2a54525..18d1fbf 100644 --- a/tests/unit/test_tool_call_compact.py +++ b/tests/unit/test_tool_call_compact.py @@ -62,23 +62,29 @@ def _fn(src: str, name: str) -> str: return src[fn : src.find("\n}\n", fn)] -def test_tool_run_builds_an_open_details_disclosure() -> None: - """Phase 158 (D1/D5) + 161 (D1): ``ensureToolRun`` creates a - native ``details.tool-calls-disclosure`` via - ``createToolDisclosure`` — a ``summary.tool-calls-summary`` - (first child) + the existing ``.tool-calls`` list (role=list + - aria-label "Tool calls", second child) — appended into the - phase-161 reasoning container's ``.reasoning-body`` in arrival - order (D1 — the container sits above the bubble; the per-run - bubble anchor is superseded). It starts OPEN while the run is - live; every append re-renders the plain-text per-run "Tool - call(s) (N)" count on the summary through textContent (no - innerHTML anywhere).""" +def test_tool_run_builds_a_div_disclosure_in_the_container() -> None: + """Phase 158 (D1) + 161 (D1) + the D5 refinement (owner + 2026-10-06): ``ensureToolRun`` creates a plain + ``div.tool-calls-disclosure`` via ``createToolRunEl`` — a + ``span.tool-calls-summary`` (first child — the static label, NOT + a control: the container is the ONE disclosure) + the existing + ``.tool-calls`` list (role=list + aria-label "Tool calls", + second child) — appended into the phase-161 reasoning + container's ``.reasoning-body`` in arrival order (D1 — the + container sits above the bubble; the per-run bubble anchor is + superseded). The run is ALWAYS VISIBLE while the container is + open (never open/foldable — no ``.open`` anywhere); every + append re-renders the plain-text per-run "Tool call(s) (N)" count + on the summary through textContent (no innerHTML anywhere).""" js = _js() - disc = _fn(js, "createToolDisclosure") - assert "document.createElement(\"details\")" in disc + disc = _fn(js, "createToolRunEl") + assert "document.createElement(\"div\")" in disc, ( + "the D5 refinement: a plain div — never collapsible" + ) assert "className = \"tool-calls-disclosure\"" in disc - assert "document.createElement(\"summary\")" in disc + assert "document.createElement(\"span\")" in disc, ( + "the label is a static span (not a control)" + ) assert "className = \"tool-calls-summary\"" in disc # The list keeps its exact phase-37 shape, nested in the disclosure. assert "className = \"tool-calls\"" in disc @@ -89,11 +95,9 @@ def test_tool_run_builds_an_open_details_disclosure() -> None: "style — innerHTML ABSENT)" ) run = _fn(js, "ensureToolRun") - assert "createToolDisclosure()" in run - assert "disc.open = true", ( - "D5: created OPEN on the first live tool frame of the run — the " - "calls and the phase-87 .tool-elapsed suffix stay visible during " - "the turn" + assert "createToolRunEl()" in run + assert ".open =" not in run, ( + "D5 refinement: the in-container runs are non-collapsible divs" ) assert 'insertBefore(disc, body.querySelector(".bubble"))' not in run, ( "the per-run bubble anchor is GONE (phase 161 D1)" @@ -186,22 +190,33 @@ def test_run_anchors_are_the_container_in_arrival_order() -> None: append into its ``.reasoning-body``) — the old ``.tool-calls-disclosure ?? .tool-calls ?? .bubble`` anchor chain is GONE (with per-run disclosures, "above the tool lines" was the - wrong order — arrival order is the contract), and the runs still - open while active (D5).""" + wrong order — arrival order is the contract), and the runs are + ALWAYS VISIBLE inside the open container (the D5 refinement — + never open/foldable).""" js = _js() run = _fn(js, "ensureThinkingRun") assert ( 'ensureReasoningWrap(wrap).querySelector(".reasoning-body")' ".appendChild(block)" in run ), "phase 161 (D1): the thinking run anchors on the container" - assert "block.open = true" in run + assert "createThinkingRunEl()" in run, ( + "the D5 refinement: a plain div — never collapsible" + ) + assert ".open =" not in run, ( + "the in-container runs are non-collapsible divs" + ) assert "??" not in run, "the old anchor chain is GONE" d = _fn(js, "ensureToolRun") assert ( 'ensureReasoningWrap(wrap).querySelector(".reasoning-body")' ".appendChild(disc)" in d ), "phase 161 (D1): the tool run anchors on the container" - assert "disc.open = true" in d + assert "createToolRunEl()" in d, ( + "the D5 refinement: a plain div — never collapsible" + ) + assert ".open =" not in d, ( + "the in-container runs are non-collapsible divs" + ) def test_shared_page_add_tool_lines_builds_a_closed_disclosure() -> None: @@ -277,12 +292,16 @@ def test_tool_call_code_rule_is_dechipped() -> None: def test_tool_calls_disclosure_and_summary_rules_exist() -> None: - """Phase 117 (D2/D4): the NEW ``.tool-calls-disclosure`` and - ``.tool-calls-summary`` rules exist — the summary is a native - focusable toggle carrying the accent TEXT (``var(--accent-ink)``, + """Phase 117 (D2/D4) + the D5 refinement (owner 2026-10-06): the + NEW ``.tool-calls-disclosure`` and ``.tool-calls-summary`` rules + exist — the label carries the accent TEXT (``var(--accent-ink)``, ≈10.4:1 on the surface; text + color, never color alone, B5), modeled on the details.thinking family, with no new color literal - (the phase-92 zero-literal invariant).""" + (the phase-92 zero-literal invariant). The D5 refinement: the + INTERACTIVE treatment (cursor + the chevron) is scoped to the + legacy ```` element — the in-container run's label is a + static span, not a control (the container is the ONE + disclosure).""" css = _css() assert re.search(r"\.tool-calls-disclosure \{", css), ( "the .tool-calls-disclosure rule must exist" @@ -293,4 +312,9 @@ def test_tool_calls_disclosure_and_summary_rules_exist() -> None: assert "var(--accent-ink)" in summary, ( "the accent rides the summary text (≈10.4:1 on the surface)" ) - assert "cursor: pointer" in summary, "a toggle affordance" + toggle = re.search(r"summary\.tool-calls-summary \{([^}]*)\}", css) + assert toggle, ( + "the D5 refinement: the toggle affordance is scoped to the " + "legacy (the in-container label is a static span)" + ) + assert "cursor: pointer" in toggle.group(1), "a toggle affordance"