phase: 161_reasoning_bubble
All gates verified. Final check — AGENTS.md rules against the project (commands, gates, protocols) — the quick-reference commands all exist and the rules (PLAN §2/§7/§9 references, phase protocol, No CDN, Debugpy, git gates) accurately describe the project; the reasoning bubble is a pure UI change with no new commands or env vars, so no AGENTS.md change is needed.
**Verification complete — full results:**
- **Verified** (implemented by prior task passes, all in working tree): `createReasoningWrap`/`ensureReasoningWrap` live container (D1), `settleReasoning` fold at the 3 settle sites + not at delta (D2), 320px `.reasoning-body` window + pinned-tail follow (D3), zero persistence change (D4), collapsed restore + shared-page parity with D6 step counts, `styles.css` family treatment + reduced-motion still, mock `REASONING_MIDROUND_TRIGGER` flow, 6-test story suite, phase-158 pin updates
- **Full suite:** `uv run pytest` → **3738 passed, 0 failed** · `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99% (>90%)** · `uv run ruff check .` → all checks passed · `uv run pyright` → **0 errors, 0 warnings**
- **E2E in isolation** (DB up; port-8123 collision with an unrelated `undercooked` service → `E2E_APP_PORT=8125`/`E2E_MOCK_PORT=8902`, no live change made): `test_reasoning_bubble.py` **6/6** · `test_thinking_timeline.py` ✓ · `test_thinking_display.py` ✓ · `test_tool_call_compact.py` ✓ · `test_big_read_progress.py` ✓ · `test_turn_progress_loader.py` ✓ · `test_share_chat.py` ✓ · `test_chat_persistence.py` ✓ · `test_smoke.py` ✓ (all exit 0, zero failures)
- **Completion criteria:** all 9 checked against the code + test outcomes — container open/live/nested ✓, stays open past mid-round delta ✓, folds at done ✓, restore + shared collapsed with "3 steps" ✓, plain turn = no container ✓, regression suites green ✓, suite/coverage/lint green ✓, diff scope limited to `frontend/assets/{app.js,shared.js,styles.css}` + `tests/` + `README.md` + phase files (no `app/` — D4) ✓
- **Documentation:** README.md "Thinking" section names the Reasoning bubble accurately (320px window, open-until-settle incl. mid-round deltas, "N steps", byte-identical no-container, collapsed on restore/shared) — verified consistent, no changes needed; AGENTS.md — verified consistent, no changes needed; all `.env.example` vars documented
- **Decisions:** commit + phase-dir move left to the harness (per pass rules — no git index/history commands executed); `undercooked` dev server on port 8123 untouched, E2E used the env port overrides
- **Next pending phase:** none — `161_reasoning_bubble` is the last in `todo/`
This commit is contained in:
+217
-20
@@ -413,6 +413,47 @@ function isThinkingNearBottom(textEl) {
|
||||
);
|
||||
}
|
||||
|
||||
/* Phase 161 (D3): the reasoning container's ONE bounded live window
|
||||
* (.reasoning-body, task 03's 320px) follows the SAME pre-update
|
||||
* capture / post-append apply contract as the phase-17 per-block
|
||||
* window: the pinned state is measured BEFORE the new chunk lands
|
||||
* (the 32px band — the same threshold as THINKING_NEAR_BOTTOM_PX),
|
||||
* and the scroll is applied AFTER the append. Inside the container
|
||||
* the inner per-block window is disabled (task 03's max-height: none),
|
||||
* so this is the single live scroll surface — the user watches the
|
||||
* live tail move without the timeline pushing the answer off-screen.
|
||||
* Exported so the band is unit-pinned (same pattern as TURN_TIMEOUT_MS). */
|
||||
export const REASONING_NEAR_BOTTOM_PX = 32;
|
||||
|
||||
/* The container window's pre-update pin capture (the phase-17
|
||||
* pre-measure convention — BEFORE the append grows the content; the
|
||||
* post-render reading measures the new chunk's height, not the
|
||||
* user's position). */
|
||||
function isReasoningPinned(el) {
|
||||
return (
|
||||
el.scrollTop + el.clientHeight >= el.scrollHeight - REASONING_NEAR_BOTTOM_PX
|
||||
);
|
||||
}
|
||||
|
||||
/* The container window's post-append apply: follow the live tail
|
||||
* (a scrolled-up reader is never re-pinned — returning to the bottom
|
||||
* re-arms the pin on the next frame). */
|
||||
function scrollReasoningToBottom(el) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
/* Phase 161 (D6): the live status setter — the span a frame fills
|
||||
* ("Thinking…" / "Calling <name>…" / "N steps" at the settle sites);
|
||||
* a no-op without a container (the plain-answer turn never creates
|
||||
* one). The setter goes through a LOCAL reference: a direct
|
||||
* `reasoningWrapEl?.…()?.textContent = x` left-hand side is a syntax
|
||||
* error (an LHS allows at most one trailing `?.` member — not a
|
||||
* call), which would kill the whole module at parse time. */
|
||||
function setReasoningStatus(text) {
|
||||
const el = reasoningWrapEl?.querySelector(".reasoning-status");
|
||||
if (el) el.textContent = text;
|
||||
}
|
||||
|
||||
/* No reply autoscroll (owner direction 2026-08-27, TODO.md L5 —
|
||||
* revising the phase 18 follow-the-bottom choice): the page never
|
||||
* auto-scrolls while a turn streams. The only scroll call sites are
|
||||
@@ -1362,6 +1403,21 @@ function removeTyping() {
|
||||
}
|
||||
|
||||
/* ---------- the per-run timeline (phase 158, D1/D2/D5) ----------
|
||||
* Phase 161 (D1, owner 2026-10-05): the ENTIRE per-run timeline
|
||||
* streams into ONE details.reasoning container (createReasoningWrap /
|
||||
* ensureReasoningWrap) per brain message — the container sits ABOVE
|
||||
* 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").
|
||||
* 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 answer bubble, and the tool calls stream into per-run
|
||||
* disclosures — one CONTIGUOUS thinking run gets its own
|
||||
@@ -1401,6 +1457,34 @@ function createThinkingBlock() {
|
||||
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
|
||||
* new static literal — + the EMPTY span.reasoning-status a frame
|
||||
* fills: "Thinking…" / "Calling <name>…" / "N steps") and the
|
||||
* bounded .reasoning-body live window (task 03: 320px + overflow-y).
|
||||
* Built with createElement only (innerHTML ABSENT — the house rule;
|
||||
* the "Reasoning" literal is a static text node, no data flows
|
||||
* through it). The caller owns open/fold + insertion: the live path
|
||||
* creates it OPEN (D1 — until the turn settles, D2); the
|
||||
* restore/shared path renders it COLLAPSED around the per-run
|
||||
* timeline (D5) with the "N steps" summary (D6). */
|
||||
function createReasoningWrap() {
|
||||
const cont = document.createElement("details");
|
||||
cont.className = "reasoning";
|
||||
const summary = document.createElement("summary");
|
||||
summary.className = "reasoning-summary";
|
||||
summary.textContent = "Reasoning"; // D6: the ONLY new static literal
|
||||
const status = document.createElement("span");
|
||||
status.className = "reasoning-status"; // empty until a frame sets it
|
||||
summary.appendChild(status);
|
||||
cont.appendChild(summary);
|
||||
const body = document.createElement("div");
|
||||
body.className = "reasoning-body";
|
||||
cont.appendChild(body);
|
||||
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
|
||||
@@ -1421,19 +1505,40 @@ function createToolDisclosure() {
|
||||
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
|
||||
* turn settles), inserts it BEFORE the bubble (the ONE new anchor —
|
||||
* the per-run blocks move from the bubble anchor into the
|
||||
* container's .reasoning-body), and stores it in reasoningWrapEl
|
||||
* (module scope — the pagehide partial reads the live container,
|
||||
* like segRuns). A turn with no thinking and no tools never calls
|
||||
* this (byte-identical DOM — D1). The container is created by the
|
||||
* FIRST run's ensureReasoningWrap call — no other call site. */
|
||||
function ensureReasoningWrap(wrap) {
|
||||
if (reasoningWrapEl) return reasoningWrapEl;
|
||||
const body = wrap.querySelector(".msg-body");
|
||||
const cont = createReasoningWrap();
|
||||
cont.open = true; // D1: open while the turn is live (D2: folds at settle)
|
||||
body.insertBefore(cont, body.querySelector(".bubble"));
|
||||
reasoningWrapEl = cont;
|
||||
return cont;
|
||||
}
|
||||
|
||||
/* 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), insert it BEFORE the bubble (D2 — the
|
||||
* anchor is the bubble, in arrival order), and open a fresh run. The
|
||||
* 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. */
|
||||
function ensureThinkingRun(wrap) {
|
||||
if (currentRun?.kind === "thinking") return currentRun.el;
|
||||
closeCurrentRun();
|
||||
const body = wrap.querySelector(".msg-body");
|
||||
const block = createThinkingBlock();
|
||||
block.open = true; // D5: open while the run's frames stream
|
||||
body.insertBefore(block, body.querySelector(".bubble"));
|
||||
ensureReasoningWrap(wrap).querySelector(".reasoning-body").appendChild(block);
|
||||
const run = { kind: "thinking", text: "", records: [] };
|
||||
segRuns.push(run);
|
||||
currentRun = { kind: "thinking", el: block, run };
|
||||
@@ -1442,17 +1547,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), insert it BEFORE the
|
||||
* bubble (D2, in arrival order), 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)"). */
|
||||
* 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)"). */
|
||||
function ensureToolRun(wrap) {
|
||||
if (currentRun?.kind === "tool") return currentRun.el;
|
||||
closeCurrentRun();
|
||||
const body = wrap.querySelector(".msg-body");
|
||||
const disc = createToolDisclosure();
|
||||
disc.open = true; // D5: open while the run's frames stream
|
||||
body.insertBefore(disc, body.querySelector(".bubble"));
|
||||
ensureReasoningWrap(wrap).querySelector(".reasoning-body").appendChild(disc);
|
||||
const run = { kind: "tool", text: "", records: [] };
|
||||
segRuns.push(run);
|
||||
currentRun = { kind: "tool", el: disc, run };
|
||||
@@ -1478,6 +1584,18 @@ function closeAllRuns(wrap) {
|
||||
)?.forEach((el) => (el.open = false));
|
||||
}
|
||||
|
||||
/* Phase 161 (D2): the container's FOLD — the turn's terminal
|
||||
* settle (done / the stop/abort / the failed-turn funnel), called
|
||||
* 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 stays open until the final response has been returned
|
||||
* and the session has stopped — the TODO's core contract). */
|
||||
function settleReasoning(wrap) {
|
||||
wrap?.querySelector("details.reasoning")?.removeAttribute("open");
|
||||
}
|
||||
|
||||
/* D4: the record's `segments` array, built at save time from the turn's
|
||||
* runs — in order. A thinking run stores its accumulated text ONLY when
|
||||
* it is non-empty (a zero-char run — e.g. a BOR_STREAM_THINKING-
|
||||
@@ -1789,6 +1907,12 @@ let thinkingAcc = ""; // accumulated thinking text (persisted with the turn)
|
||||
* through buildSegments at the save points (non-empty only). */
|
||||
let segRuns = []; // this turn's runs, in arrival order ([] = nothing to store)
|
||||
let currentRun = null; // the active run — { kind, el, run } (run = the segRuns entry); null when no run is open
|
||||
/* Phase 161 (D1): this turn's reasoning container — created on the
|
||||
* first thinking/tool frame (ensureReasoningWrap), open until the
|
||||
* settle (settleReasoning). Module scope, like segRuns: the
|
||||
* pagehide partial reads the live container; reset per turn in
|
||||
* runTurn. */
|
||||
let reasoningWrapEl = null;
|
||||
let persistedOnLeave = false; // pagehide partial-persist at most once
|
||||
/* Phase 120 verification (navigate-away regression): a pagehide has
|
||||
* fired. A REAL departure tears the document down and the browser
|
||||
@@ -2503,8 +2627,22 @@ export function clearStoredConversation() {
|
||||
* the per-run "Tool call(s) (N)" count riding appendToolLine's
|
||||
* summary update (the SAME container signature as the live path). All
|
||||
* blocks are created CLOSED (D5 — the restore renders collapsed; the
|
||||
* live path opens while its frames stream) and inserted BEFORE the
|
||||
* bubble (D2, in arrival order). The tOffset/tLimit/tPath
|
||||
* live path opens while its frames stream). Phase 161 (task 02,
|
||||
* D5/D6): the per-run timeline renders inside ONE details.reasoning
|
||||
* container — the SAME container as the live path (the shared
|
||||
* createReasoningWrap builder, task 01), created COLLAPSED (the
|
||||
* live open state is exclusively the live turn's; explicit
|
||||
* `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
|
||||
* 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
|
||||
* entries are TWO runs); the first entry of a new contiguous tool
|
||||
* group is ONE run (a group of N entries is ONE run). The literal is
|
||||
* uniform — `${runs} steps` (no singular/plural special-case). The
|
||||
* tOffset/tLimit/tPath
|
||||
* well-formedness guards (Number.isInteger(x) && x >= 1 / the string
|
||||
* guard) and the phase-95 appendTruncatedNote call move VERBATIM from
|
||||
* the legacy m.tools loop (the phase-139/146 restore precedent) — a
|
||||
@@ -2522,7 +2660,18 @@ function restoreTimeline(wrap, segments) {
|
||||
const body = wrap?.querySelector?.(".msg-body");
|
||||
if (!body) return;
|
||||
const bubble = body.querySelector(".bubble");
|
||||
// Phase 161 (task 02, D5/D6): the container — the shared task-01
|
||||
// builder, created CLOSED (D5 — the restore renders collapsed; the
|
||||
// live open state is exclusively the live turn's; explicit for the
|
||||
// pin) and inserted ONCE before the bubble (the ONE anchor — the
|
||||
// per-run blocks nest into its .reasoning-body, arrival order
|
||||
// preserved).
|
||||
const cont = createReasoningWrap();
|
||||
cont.open = false; // D5: the restore renders collapsed (explicit for the pin)
|
||||
const runsEl = cont.querySelector(".reasoning-body");
|
||||
body.insertBefore(cont, bubble); // the container sits ABOVE the answer bubble
|
||||
let toolDisc = null; // the CURRENT run of consecutive tool entries (one disclosure per run, D1)
|
||||
let runs = 0; // D6: the at-rest step count — CONTIGUOUS runs, not entry count
|
||||
for (const entry of segments || []) {
|
||||
// a malformed entry (or one with an unknown `kind`) is SKIPPED —
|
||||
// never render from storage (textContent only, the house rule).
|
||||
@@ -2530,6 +2679,7 @@ function restoreTimeline(wrap, segments) {
|
||||
if (entry.kind === "thinking") {
|
||||
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
|
||||
@@ -2541,14 +2691,15 @@ function restoreTimeline(wrap, segments) {
|
||||
textEl.className = "thinking-text";
|
||||
textEl.innerHTML = renderMarkdown(entry.text); // the existing restore convention — escape-first, XSS-safe
|
||||
block.append(summary, textEl);
|
||||
body.insertBefore(block, bubble); // D2: above the bubble, in arrival order
|
||||
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;
|
||||
body.insertBefore(toolDisc, bubble); // D2: above the bubble, in arrival order
|
||||
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
|
||||
}
|
||||
const arg =
|
||||
typeof entry.argument === "string" && entry.argument
|
||||
@@ -2574,6 +2725,10 @@ function restoreTimeline(wrap, segments) {
|
||||
// SKIPPED — never render from storage (textContent only, the
|
||||
// house rule); a guard comment names the skip.
|
||||
}
|
||||
// Phase 161 (task 02, D6): the at-rest step count — the UNIFORM
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -2609,12 +2764,16 @@ function renderStoredMessage(m) {
|
||||
// per-run timeline through restoreTimeline (collapsed) — the runs
|
||||
// carry the SAME data as `m.thinking` + `m.tools`, in order, so
|
||||
// the legacy blocks are SKIPPED (rendering both would double the
|
||||
// runs). A record WITHOUT `segments` — every pre-phase record —
|
||||
// restores BYTE-IDENTICALLY through the legacy path below; its
|
||||
// folded-at-rest result is the same as the pre-phase code's
|
||||
// (the pre-phase settle pair became closeAllRuns(wrap) — task 02 —
|
||||
// the legacy thinking block is created closed and the legacy
|
||||
// disclosure opens during the loop and folds below, phase 117 D3).
|
||||
// runs). Phase 161 (task 02, D5/D6): the segments path wraps the
|
||||
// per-run timeline in the COLLAPSED details.reasoning container
|
||||
// (restoreTimeline — the shared createReasoningWrap builder; the
|
||||
// "N steps" summary). A record WITHOUT `segments` — every
|
||||
// pre-phase record — restores BYTE-IDENTICALLY through the legacy
|
||||
// path below (no container — D4); its folded-at-rest result is the
|
||||
// same as the pre-phase code's (the pre-phase settle pair became
|
||||
// closeAllRuns(wrap) — phase 158 task 02 — the legacy thinking
|
||||
// block is created closed and the legacy disclosure opens during
|
||||
// the loop and folds below, phase 117 D3).
|
||||
if (Array.isArray(m.segments) && m.segments.length) {
|
||||
restoreTimeline(wrap, m.segments);
|
||||
} else {
|
||||
@@ -3578,6 +3737,11 @@ function finalizeFailedTurn(
|
||||
// open run closed (phase 158, D5 — the stop-finalize pattern),
|
||||
// mark it failed.
|
||||
closeAllRuns(wrap);
|
||||
// Phase 161 (D2): the failed-turn funnel is a settle — fold the
|
||||
// container alongside the runs; the summary reads the live run
|
||||
// count (D6 — segRuns, module scope).
|
||||
settleReasoning(wrap);
|
||||
setReasoningStatus(`${segRuns.length} steps`);
|
||||
appendFailedNote(wrap, error);
|
||||
rememberBrainTurn(
|
||||
acc,
|
||||
@@ -3667,6 +3831,7 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
thinkingAcc = "";
|
||||
segRuns = []; // phase 158: a fresh turn starts with no runs (the pagehide partial reads the current turn's runs)
|
||||
currentRun = null;
|
||||
reasoningWrapEl = null; // phase 161: a fresh turn starts with no container (the pagehide partial reads the live container, like segRuns)
|
||||
persistedOnLeave = false;
|
||||
leftThePage = false;
|
||||
leavePartialIndex = -1;
|
||||
@@ -3769,6 +3934,11 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
if (!wrap) wrap = addMessage("brain", "");
|
||||
removeTyping(); // the live block replaces the dots as feedback
|
||||
const block = ensureThinkingRun(wrap);
|
||||
// Phase 161 (D6): the container's live status — "Thinking…"
|
||||
// while a thinking frame streams (a tool frame overwrites it
|
||||
// with "Calling <name>…"; the settle sites overwrite it with
|
||||
// "N steps").
|
||||
setReasoningStatus("Thinking…");
|
||||
currentRun.run.text += ev.text || ""; // the RUN's text — the per-run block renders it
|
||||
const textEl = block.querySelector(".thinking-text");
|
||||
// Pin state is measured BEFORE the re-render: a chunk taller
|
||||
@@ -3782,6 +3952,13 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
// THIS block (per-run — each run's block pins its own tail;
|
||||
// the pre-update capture is verbatim).
|
||||
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
|
||||
@@ -3790,6 +3967,7 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
// returning to the bottom re-arms the pin on the next chunk.
|
||||
textEl.scrollTop = textEl.scrollHeight;
|
||||
}
|
||||
if (rPinned && rbody) scrollReasoningToBottom(rbody); // post-append apply
|
||||
} else if (ev.type === "tool") {
|
||||
// Phase 37 (PLAN §4 extension): an agent tool call. The UI
|
||||
// state stays "thinking" — the button stays the enabled "Stop"
|
||||
@@ -3872,6 +4050,14 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
// wiring.
|
||||
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,
|
||||
@@ -3880,6 +4066,7 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
frameLimit,
|
||||
framePath
|
||||
);
|
||||
if (tPinned && tbody) scrollReasoningToBottom(tbody); // post-append apply
|
||||
// Phase 87 (TODO.md L5): arm this line's elapsed clock — the
|
||||
// baseline resets per line, so each line counts its OWN
|
||||
// frameless silence. This live branch is the ONLY arm call site
|
||||
@@ -3936,6 +4123,11 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
// late double-close is a no-op). Replaces the phase-117
|
||||
// closeToolCalls fold + the D15 block close.
|
||||
closeAllRuns(wrap);
|
||||
// Phase 161 (D2): the turn settled — fold the container too
|
||||
// (the final response has been returned); the summary reads
|
||||
// the live run count (D6 — segRuns, module scope).
|
||||
settleReasoning(wrap);
|
||||
setReasoningStatus(`${segRuns.length} steps`);
|
||||
if (!wrap) {
|
||||
setUiState(UI_STATE.streaming);
|
||||
wrap = addMessage("brain", "…");
|
||||
@@ -4147,6 +4339,11 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
// (replaces the phase-117 closeToolCalls fold + the D15 block
|
||||
// close, like `done`).
|
||||
closeAllRuns(wrap);
|
||||
// Phase 161 (D2): the session stopped — fold the container
|
||||
// alongside the runs; the summary reads the live run count
|
||||
// (D6 — segRuns, module scope).
|
||||
settleReasoning(wrap);
|
||||
setReasoningStatus(`${segRuns.length} steps`);
|
||||
persistedOnLeave = true;
|
||||
appendTuneButton(wrap); // admin-only; parity with the restore path
|
||||
appendStoppedNote(wrap);
|
||||
|
||||
Reference in New Issue
Block a user