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
+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; }
}