fix(rag): stop the per-run blocks from folding — the container is the one disclosure
Build and Push Containers / build-and-push-app (push) Successful in 1m43s
Build and Push Containers / build-and-push-db (push) Successful in 15s

The owner refinement (2026-10-06): the per-run thinking and tool-call
blocks inside the Reasoning bubble were individually collapsible
details elements — a fold inside the fold. A tool frame closed the
previous thinking block, so the visible thinking vanished mid-turn;
and the pinned-tail follow died on the append of every run after the
first (the pin was captured AFTER the new block's height growth
contaminated the 32px reading).

- app.js: the in-container runs are now plain divs —
  createThinkingRunEl (div.thinking + static .thinking-summary label +
  .thinking-text) and createToolRunEl (div.tool-calls-disclosure +
  static .tool-calls-summary label + the .tool-calls list); they never
  open or fold (the container is the ONE open/fold control, open
  through the turn, folded at settle — unchanged); the pin capture
  moves BEFORE ensureThinkingRun/ensureToolRun append (plus the
  first-frame edge: no container yet -> always pinned);
  closeCurrentRun just ends the run; closeAllRuns stays the defensive
  legacy guard; restoreTimeline/addSegments (shared.js) use the div
  builders; the legacy no-segments restore path keeps the old
  details-based blocks byte-identical
- styles.css: the in-container runs are distinct by label + rail
  (brand-ink Thinking label on a brand-soft rail; accent-ink
  Tool call(s) (N) label on a neutral rail); the interactive
  treatment (cursor + chevron) is scoped to the legacy <summary>; the
  inner windows stay disabled (the container's .reasoning-body is the
  ONE live scroll surface)
- unit + E2E: the D5-refinement contract is pinned — one
  details.reasoning disclosure, per-run blocks as plain divs, zero
  nested <details> inside the container, the container as the only
  open/fold control, the .reasoning-body window auto-follows the
  tail while streaming

unit+integration 3738 passed (coverage 99%), ruff + pyright clean, all
five affected E2E suites pass in isolation (reasoning_bubble 6,
thinking_timeline 6, thinking_display 5, thinking_scroll 8,
tool_call_compact 4), verified live: the follow holds through a long
thinking stream, the tool-frame transition, and the second thinking
run; restore renders the div structure without client-side errors

LLM-Generated: true
This commit is contained in:
2026-10-06 14:44:41 -04:00
parent 051f62deb7
commit 690fd98036
15 changed files with 941 additions and 609 deletions
+11 -9
View File
@@ -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`
+187 -100
View File
@@ -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 <name>…" / "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 <name>…" / "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 <details> 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 `<summary>` and the in-container `<span>` 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 <name>…" — 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
+21 -17
View File
@@ -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 `<summary>` and the in-container `<span>` 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})`;
}
}
+61 -24
View File
@@ -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 <details> 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
<summary> 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 <summary> 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 <summary> — 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; }
}
+44 -54
View File
@@ -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 <name>…`` 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 ``<details>`` 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 ``<details>`` 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 ``<details>`` 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).
+74 -54
View File
@@ -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 <details> 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 <details> 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 <details> at all (zero
# <details> 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 <br>, 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 <details> 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)
+67 -47
View File
@@ -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 <details>).
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)
+57 -62
View File
@@ -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 <source/path>`` 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 ``<details>`` 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/<token>`` 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 <source/path>`` 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 <details> has no
# ``open`` attribute).
# D5 refinement (owner 2026-10-06): the inner runs are NOT
# collapsible — ZERO <details> 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 <details> 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 <summary> 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])
+58 -40
View File
@@ -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 ``<details>`` 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 + ``<code>`` 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
+ ``<code>`` 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 ``<details>``)
— 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 ``<details>``
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 ``<summary>`` (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 ``<summary>`` (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
``<details>``), "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 ``<details>`` has NO
def _container_open(page: Page) -> bool:
"""The container's open state: a closed ``<details>`` 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 ``<summary>`` 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 ``<summary>`` 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 <details> 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 <details> (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
# <details>).
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()
+87 -44
View File
@@ -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 <name>…" — 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
+27 -15
View File
@@ -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"')
+12 -2
View File
@@ -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:
+75 -36
View File
@@ -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
+107 -76
View File
@@ -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
+53 -29
View File
@@ -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 <summary> 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 ``<summary>`` 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 <summary> (the in-container label is a static span)"
)
assert "cursor: pointer" in toggle.group(1), "a toggle affordance"