fix(rag): stop the per-run blocks from folding — the container is the one disclosure
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:
+187
-100
@@ -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
@@ -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
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user