phase: 158_thinking_timeline
All verification complete. Every gate is green and the docs check out. Final report:
## Phase 158 final verification pass — COMPLETE
**Verified (all tasks pre-completed; this pass re-ran the full gate):**
- Unit + integration: `uv run pytest --cov=app --cov-report=term-missing` → **3618 passed, 99% coverage on `app/`** (>90% gate)
- Phase E2E in isolation: `uv run pytest tests/e2e/test_thinking_timeline.py -v --no-cov` → **6/6 passed** (order pin, fold-at-rest, live-open, restore+`segments` array, shared page, no-thinking single disclosure)
- Regression E2E, each in isolation: `test_thinking_display` 5✓, `test_tool_call_compact` 4✓, `test_turn_progress_loader` 3✓ (per-run pin updated), `test_big_read_progress` 4✓, `test_agent_document_tools` 4✓, `test_share_chat` 4✓, `test_chat_persistence` 4✓, `test_smoke` 3✓
- Lint/types: `uv run ruff check . && uv run pyright` → **clean (0 errors)**
**Completion criteria:** all 9 met — timeline E2E pins ✓, restore/shared `segments` ✓, no-thinking single-disclosure + the three UNCHANGED regression suites green ✓, pre-phase byte-identical round-trip (integration pin: key absent, never null; >50 / >32 000 / empty text / unknown kind all 422) ✓, full suite + coverage + E2E + lint ✓, `git diff --stat` footprint limited to `app/schemas.py`, `frontend/assets/{app,shared}.js`, `tests/`, README, phase files (a pre-existing owner `TODO.md` edit is out of scope, not staged by the phase commit) ✓, D15→per-run supersession recorded in the updated pin ✓. (Commit + phase move are the harness's job; task dirs already in `complete/`.)
**Documentation:** README.md — Thinking section correctly describes the phase-158 timeline; config reference matches `.env.example` exactly; page table matches registered routes; no stale single-block wording. AGENTS.md — rules and quick-reference commands all accurate. **Verified consistent — no changes needed.**
**Decisions/deviations:** none. `test_tool_call_compact.py` carries a one-line docstring-only update (referenced the removed `closeToolCalls`); its test logic is byte-identical to the phase-117 contract and green.
**Next pending phase:** none — `todo/` contains only 158.
This commit is contained in:
+427
-156
@@ -1035,67 +1035,154 @@ function removeTyping() {
|
||||
document.querySelector("#typing-indicator")?.remove();
|
||||
}
|
||||
|
||||
/* ---------- thinking block (phase 17; phase 109 makes it a toggle) ----------
|
||||
* The model's reasoning streams into a collapsible <details> block ABOVE
|
||||
* the answer bubble: created OPEN on the first `thinking` event, then
|
||||
* user-toggleable (native <details>/<summary> — a real focusable
|
||||
* control).
|
||||
/* ---------- the per-run timeline (phase 158, D1/D2/D5) ----------
|
||||
* 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
|
||||
* details.thinking block and one CONTIGUOUS tool-call run gets its own
|
||||
* details.tool-calls-disclosure, in arrival order (D1). The phase-109
|
||||
* D15 "re-open the one shared block" layout is REPLACED (owner
|
||||
* direction 2026-10-04): the turn renders as a chronological timeline —
|
||||
* Thinking → Tool call (1) → Thinking → … → answer. All segments sit
|
||||
* ABOVE the answer bubble, in arrival order (D2, owner-chosen Option B
|
||||
* — including runs that start after the first `delta`, the mid-round
|
||||
* reply case); the bubble stays ONE element, its content accumulating
|
||||
* in arrival order.
|
||||
*
|
||||
* Phase 109 (TODO.md L3, D15) — the block is a TOGGLE, not a one-way
|
||||
* door: open-while-thinking, closed-while-answering. The `thinking` SSE
|
||||
* handler re-opens it (`block.open = true`, idempotent) — a `thinking`
|
||||
* frame after the answer has started (the next agent round: the model
|
||||
* answered, called a tool, then thinks again) re-opens the collapsed
|
||||
* scratchpad — and the `delta` handler closes it (closeThinkingBlock,
|
||||
* idempotent). The block reflects the model's current activity in every
|
||||
* agent round — the "frozen chat" repro from TODO.md L3 is gone. The
|
||||
* phase-14 restore path (renderStoredMessage) still renders stored
|
||||
* blocks COLLAPSED — untouched.
|
||||
* ensureThinkingBlock is idempotent (returns the existing block if any);
|
||||
* closeThinkingBlock is a no-op without a block. */
|
||||
function ensureThinkingBlock(wrap) {
|
||||
let block = wrap.querySelector(".thinking");
|
||||
if (!block) {
|
||||
block = document.createElement("details");
|
||||
block.className = "thinking";
|
||||
block.open = true;
|
||||
block.innerHTML =
|
||||
`<summary>Thinking</summary><div class="thinking-text"></div>`;
|
||||
const body = wrap.querySelector(".msg-body");
|
||||
// Phase 37: the scratchpad stays the TOP row of the wrap — if tool
|
||||
// lines are already there (a `tool` frame preceded the first
|
||||
// `thinking` frame), the block lands above them, not below.
|
||||
// Phase 117: the anchor is the WHOLE disclosure (the bare .tool-calls
|
||||
// list term stays as the pre-117 fallback).
|
||||
const anchor =
|
||||
body.querySelector(".tool-calls-disclosure") ??
|
||||
body.querySelector(".tool-calls") ?? body.querySelector(".bubble");
|
||||
body.insertBefore(block, anchor);
|
||||
}
|
||||
* 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.
|
||||
*
|
||||
* The thinking block's STATIC structure is the phase-17 template (a
|
||||
* static template with NO data — the plain `Thinking` literal, D3; the
|
||||
* data flows through renderMarkdown / textContent exactly as today).
|
||||
* The tool-calls disclosure keeps the phase-117 createElement/textContent
|
||||
* shape (innerHTML ABSENT). The runs are tracked in segRuns (module
|
||||
* scope — the pagehide partial reads it, like thinkingAcc); each entry
|
||||
* is { kind, text, records } and rides the save payloads through
|
||||
* buildSegments (D4). The per-tool-line elapsed clock (phase 87)
|
||||
* targets the TRACKED line reference (armToolLineClock's second param)
|
||||
* — the `:last-child` query breaks with multiple disclosures and is
|
||||
* gone (the re-target, phase 158 D1). */
|
||||
function createThinkingBlock() {
|
||||
const block = document.createElement("details");
|
||||
block.className = "thinking";
|
||||
block.innerHTML =
|
||||
`<summary>Thinking</summary><div class="thinking-text"></div>`;
|
||||
return block;
|
||||
}
|
||||
|
||||
function closeThinkingBlock(wrap) {
|
||||
const block = wrap?.querySelector?.(".thinking");
|
||||
if (block) block.open = false; // idempotent; no-op without a block
|
||||
/* 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. */
|
||||
function createToolDisclosure() {
|
||||
const container = document.createElement("details");
|
||||
container.className = "tool-calls-disclosure";
|
||||
const summary = document.createElement("summary");
|
||||
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;
|
||||
}
|
||||
|
||||
/* Phase 117 (D3): the tool-calls disclosure settles FOLDED at rest —
|
||||
* the same sites that settle the Thinking block: the answer began
|
||||
* (`delta`), the turn ended (`done`), or the turn was stopped (the
|
||||
* abort settle). The N-line record collapses to one compact
|
||||
* "Tool call(s) (N)" line. Idempotent; a no-op without a disclosure
|
||||
* (deflected answers, pre-tool turns, …). */
|
||||
function closeToolCalls(wrap) {
|
||||
const disc = wrap?.querySelector?.(".tool-calls-disclosure");
|
||||
if (disc) disc.open = false; // idempotent; no-op without a disclosure
|
||||
/* 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
|
||||
* 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"));
|
||||
const run = { kind: "thinking", text: "", records: [] };
|
||||
segRuns.push(run);
|
||||
currentRun = { kind: "thinking", el: block, run };
|
||||
return block;
|
||||
}
|
||||
|
||||
/* 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)"). */
|
||||
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"));
|
||||
const run = { kind: "tool", text: "", records: [] };
|
||||
segRuns.push(run);
|
||||
currentRun = { kind: "tool", el: disc, run };
|
||||
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
|
||||
* 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. */
|
||||
function closeAllRuns(wrap) {
|
||||
wrap?.querySelectorAll?.(
|
||||
"details.thinking, details.tool-calls-disclosure"
|
||||
)?.forEach((el) => (el.open = false));
|
||||
}
|
||||
|
||||
/* 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-
|
||||
* suppressed turn — stores nothing); a tool run stores one entry per
|
||||
* record, in call order (the record's exact shape: the phase-37
|
||||
* {name, argument} pair, the phase-139 offset/limit, the phase-146
|
||||
* path, and the phase-95 truncation stamp when present — the SAME
|
||||
* objects toolAcc holds, so the tool_result stamp rides through with
|
||||
* no extra wiring). A turn with no thinking and no tools returns [] —
|
||||
* the save sites drop the key for an empty array (the
|
||||
* undefined-drops-the-key house pattern). No re-validation on the
|
||||
* client: the server's ChatMessage boundary validates. */
|
||||
function buildSegments(runs) {
|
||||
const out = [];
|
||||
for (const r of runs) {
|
||||
if (r.kind === "thinking") {
|
||||
if (r.text) out.push({ kind: "thinking", text: r.text });
|
||||
continue;
|
||||
}
|
||||
for (const rec of r.records) out.push({ kind: "tool", ...rec });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ---------- tool-call lines (phase 37, PLAN §4 extension) ----------
|
||||
* One visible "calling tool" row per `tool` SSE frame, in the same wrap
|
||||
* the Thinking block uses — above the answer, below the Thinking
|
||||
* summary (ensureThinkingBlock keeps the scratchpad on top). The first
|
||||
* frame creates the .tool-calls list; later frames — any tool, any
|
||||
* One visible "calling tool" row per `tool` SSE frame. Phase 158
|
||||
* (D1): the CALLER passes the run's disclosure (ensureToolRun — one
|
||||
* details.tool-calls-disclosure per CONTIGUOUS tool-call run), which
|
||||
* sits above the answer, below the Thinking blocks (both insert
|
||||
* before the bubble, so document order is preserved). The first frame
|
||||
* of a run creates the disclosure; later frames — any tool, any
|
||||
* interleaving with thinking frames, even after the first delta (the
|
||||
* agent loop never emits one, but a late frame must not crash) — just
|
||||
* append another line, in order. The SAME helper re-renders the
|
||||
@@ -1106,11 +1193,13 @@ function closeToolCalls(wrap) {
|
||||
*
|
||||
* Phase 117 (owner report 2026-09-15): the list rides in a native
|
||||
* <details> disclosure (details.tool-calls-disclosure + a plain-text
|
||||
* "Tool call(s) (N)" summary) — OPEN while the turn is live, folded by
|
||||
* closeToolCalls (delta/done/stop) and by the restore path (a restored
|
||||
* turn is one compact line, not N stacked cards). The line bytes, the
|
||||
* four label literals, and the <code> textContent arguments are
|
||||
* untouched (phase-87 clock + emoji-guard pins stay green).
|
||||
* "Tool call(s) (N)" summary). Phase 158 moved the disclosure's BUILD
|
||||
* to createToolDisclosure (the caller — the live run builder or the
|
||||
* restore path — owns open/fold + the before-the-bubble insertion),
|
||||
* and the count rides the summary on every append — per run (one
|
||||
* disclosure per contiguous run, D3). The line bytes, the four label
|
||||
* literals, and the <code> textContent arguments are untouched
|
||||
* (phase-87 clock + emoji-guard pins stay green).
|
||||
*
|
||||
* Phase 70 (owner permission 2026-09-03): the server tools were remapped
|
||||
* to the harness-aligned surface — ls / read(path) / grep(pattern,
|
||||
@@ -1136,28 +1225,8 @@ function closeToolCalls(wrap) {
|
||||
* string: a bare grep's line stays byte-identical to pre-phase (the
|
||||
* phase-70/87/117 pins and the frontend emoji guard stay green — no
|
||||
* new emoji). */
|
||||
function appendToolLine(wrap, name, argument, offset = null, limit = null, scope = null) {
|
||||
const body = wrap?.querySelector?.(".msg-body");
|
||||
if (!body) return;
|
||||
// Phase 117 (D2): the lines ride in a native <details> disclosure —
|
||||
// one compact "Tool call(s) (N)" line instead of N stacked cards.
|
||||
let container = body.querySelector(".tool-calls-disclosure");
|
||||
if (!container) {
|
||||
container = document.createElement("details");
|
||||
container.className = "tool-calls-disclosure";
|
||||
container.open = true; // D3: open while the turn is live; closeToolCalls folds it
|
||||
const summary = document.createElement("summary");
|
||||
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);
|
||||
// Before the bubble; below an existing Thinking block (both insert
|
||||
// before the bubble, so document order is preserved).
|
||||
body.insertBefore(container, body.querySelector(".bubble"));
|
||||
}
|
||||
function appendToolLine(container, name, argument, offset = null, limit = null, scope = null) {
|
||||
if (!container) return;
|
||||
const list = container.querySelector(".tool-calls");
|
||||
const line = document.createElement("span");
|
||||
line.className = "tool-call";
|
||||
@@ -1214,10 +1283,13 @@ function appendToolLine(wrap, name, argument, offset = null, limit = null, scope
|
||||
}
|
||||
list.appendChild(line);
|
||||
// Phase 117 (D5): the count rides the summary — every append (live
|
||||
// frames and the restore path) re-renders through here.
|
||||
// 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)").
|
||||
const n = list.children.length;
|
||||
container.querySelector("summary").textContent =
|
||||
`Tool call${n === 1 ? "" : "s"} (${n})`;
|
||||
return line; // phase 158: the caller arms the line's clock with this element
|
||||
}
|
||||
|
||||
/* Phase 139 (task 01): the requested line range on the Reading line —
|
||||
@@ -1375,12 +1447,22 @@ let turnTimeoutCb = null; // the guard's callback — lets visibilitychange re-a
|
||||
let toolLineTimer = 0; // setInterval id — the latest tool line's "(Ns)" suffix
|
||||
let toolLineStart = 0; // Date.now() when the latest tool line armed the clock
|
||||
let toolLineWrap = null; // the live wrap the clock suffixes (null when stopped)
|
||||
let toolLineEl = null; // phase 158: the TRACKED line the clock suffixes (the :last-child query is gone — multi-disclosure)
|
||||
/* Turn accumulators + the navigate-away flag (phase 20, owner choice
|
||||
* 2026-08-24 A1): module scope because the `pagehide` handler reads them
|
||||
* while a turn is still in flight; reset per turn at the top of
|
||||
* handleSend, so they stay turn-scoped like the rest of the turn locals. */
|
||||
let acc = ""; // accumulated answer text this turn
|
||||
let thinkingAcc = ""; // accumulated thinking text (persisted with the turn)
|
||||
/* Phase 158 (D1/D4): the turn's CONTIGUOUS runs, in arrival order —
|
||||
* { kind: "thinking", text, records: [] } / { kind: "tool", text,
|
||||
* records } entries (a tool entry's records hold the SAME toolAcc
|
||||
* record objects, so the phase-95 tool_result stamp flows into both).
|
||||
* Module scope, like thinkingAcc: the pagehide partial reads them while
|
||||
* a turn is in flight. Reset per turn at the top of runTurn; persisted
|
||||
* 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
|
||||
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
|
||||
@@ -1489,22 +1571,30 @@ function clearTurnTimeout() {
|
||||
* + the <code> argument) — never a rewrite: appendToolLine's exact
|
||||
* line.textContent literals stay byte-identical (the unit + emoji-guard
|
||||
* pins), and the restore path (phase 14) re-renders lines with no clock
|
||||
* at all (A6 — the indication is live-only). */
|
||||
function armToolLineClock(wrap) {
|
||||
* at all (A6 — the indication is live-only).
|
||||
*
|
||||
* Phase 158 (D1): the re-target — with per-run disclosures (one
|
||||
* details.tool-calls-disclosure per contiguous tool-call run), the
|
||||
* phase-87 `.tool-calls .tool-call:last-child` query would find the
|
||||
* last line of the WRONG run's list. The suffix now targets the
|
||||
* TRACKED line reference (this function's second param — the line the
|
||||
* caller just appended; the re-arm resets it). The visible behavior is
|
||||
* byte-identical (the suffix on the newest line; settle still removes
|
||||
* every .tool-elapsed in the wrap). */
|
||||
function armToolLineClock(wrap, lineEl) {
|
||||
toolLineWrap = wrap;
|
||||
toolLineEl = lineEl; // the NEW target line (the re-arm resets it)
|
||||
toolLineStart = Date.now();
|
||||
if (!toolLineTimer) {
|
||||
toolLineTimer = setInterval(() => {
|
||||
const secs = Math.round((Date.now() - toolLineStart) / 1000);
|
||||
if (secs * 1000 < TOOL_LINE_ELAPSED_AFTER_MS) return; // A5: below 5s the gap reads as normal latency
|
||||
const line =
|
||||
toolLineWrap?.querySelector?.(".tool-calls .tool-call:last-child");
|
||||
if (!line) return; // the wrap was reset mid-gap (New Chat) — no-op
|
||||
let el = line.querySelector(".tool-elapsed");
|
||||
if (!toolLineEl) return; // the wrap was reset mid-gap (New Chat) — no-op
|
||||
let el = toolLineEl.querySelector(".tool-elapsed");
|
||||
if (!el) {
|
||||
el = document.createElement("span");
|
||||
el.className = "tool-elapsed";
|
||||
line.appendChild(el);
|
||||
toolLineEl.appendChild(el);
|
||||
}
|
||||
el.textContent = `(${secs}s)`;
|
||||
}, 1000);
|
||||
@@ -1521,6 +1611,7 @@ function settleToolLine() {
|
||||
toolLineTimer = 0;
|
||||
}
|
||||
toolLineWrap?.querySelectorAll?.(".tool-elapsed").forEach((el) => el.remove());
|
||||
toolLineEl = null; // phase 158: forget the line target (stopToolLineClock resets via here)
|
||||
}
|
||||
|
||||
/* The state-machine entry (next to stopThinkingClock / clearTurnTimeout
|
||||
@@ -2075,6 +2166,91 @@ export function clearStoredConversation() {
|
||||
}
|
||||
}
|
||||
|
||||
/* Phase 158 (task 03, D4/D5): the RESTORE of a record's `segments`
|
||||
* array — the per-run timeline, COLLAPSED. A record WITH segments
|
||||
* (the live per-run save — task 02's buildSegments) renders each
|
||||
* contiguous thinking run as its own details.thinking block (the
|
||||
* plain Thinking literal, D3 — position distinguishes the runs) and
|
||||
* each CONTIGUOUS run of tool entries as ONE
|
||||
* details.tool-calls-disclosure (the phase-117 structure via
|
||||
* createToolDisclosure — the shared builder, innerHTML ABSENT), with
|
||||
* 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
|
||||
* 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
|
||||
* malformed entry (no name / a non-string text) or an entry with an
|
||||
* UNKNOWN `kind` is SKIPPED: never render from storage — the
|
||||
* textContent-only house rule keeps a corrupted row from poisoning
|
||||
* the render. The thinking text flows through the SAME escape-first
|
||||
* renderMarkdown write the legacy path uses (XSS-safe; the structure
|
||||
* is createElement/textContent — the only HTML write is that
|
||||
* renderMarkdown into .thinking-text). A record WITHOUT segments
|
||||
* never reaches here — the legacy path restores it byte-identically.
|
||||
* The restore path NEVER arms the elapsed clock (A6 — the live
|
||||
* frames only do that). */
|
||||
function restoreTimeline(wrap, segments) {
|
||||
const body = wrap?.querySelector?.(".msg-body");
|
||||
if (!body) return;
|
||||
const bubble = body.querySelector(".bubble");
|
||||
let toolDisc = null; // the CURRENT run of consecutive tool entries (one disclosure per run, D1)
|
||||
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).
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
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
|
||||
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);
|
||||
body.insertBefore(block, bubble); // D2: above the bubble, 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
|
||||
}
|
||||
const arg =
|
||||
typeof entry.argument === "string" && entry.argument
|
||||
? entry.argument
|
||||
: null;
|
||||
// Phase 139/146 restore guards — VERBATIM from the legacy loop
|
||||
// (the phase-139/146 restore precedent): a record saved before
|
||||
// the phase (no fields) renders byte-identically.
|
||||
const tOffset =
|
||||
Number.isInteger(entry.offset) && entry.offset >= 1 ? entry.offset : null;
|
||||
const tLimit =
|
||||
Number.isInteger(entry.limit) && entry.limit >= 1 ? entry.limit : null;
|
||||
const tPath = typeof entry.path === "string" && entry.path ? entry.path : null;
|
||||
appendToolLine(toolDisc, entry.name, arg, tOffset, tLimit, tPath);
|
||||
// Phase 95: a stored truncation stamp re-renders the SAME marker
|
||||
// next to its Reading line (moved verbatim inside the grouping;
|
||||
// old records without the field render unchanged).
|
||||
if (entry.truncated && arg) {
|
||||
appendTruncatedNote(wrap, arg, Number(entry.chars_shown) || 0, Number(entry.chars_total) || 0);
|
||||
}
|
||||
}
|
||||
// an entry with an unknown `kind` (or a malformed shape) is
|
||||
// SKIPPED — never render from storage (textContent only, the
|
||||
// house rule); a guard comment names the skip.
|
||||
}
|
||||
if (toolDisc) toolDisc.open = false; // defensive — already closed by construction
|
||||
}
|
||||
|
||||
function renderStoredMessage(m) {
|
||||
// Phase-14 restore landing (kept by the phase-42 direction): the
|
||||
// one-shot load-time scroll — scroll=true so a restored conversation
|
||||
@@ -2102,47 +2278,83 @@ function renderStoredMessage(m) {
|
||||
return;
|
||||
}
|
||||
const wrap = addMessage("brain", renderMarkdown(m.text), true);
|
||||
if (m.thinking) {
|
||||
// Phase 17: restore the thinking block COLLAPSED above the bubble.
|
||||
const block = ensureThinkingBlock(wrap);
|
||||
block.open = false;
|
||||
block.querySelector(".thinking-text").innerHTML = renderMarkdown(m.thinking);
|
||||
}
|
||||
if (Array.isArray(m.tools)) {
|
||||
// Phase 37: restore the tool lines in saved order through the SAME
|
||||
// append helper as the live frames (no HTML from storage, ever).
|
||||
// Phase 95: a stored truncation record (truncated + the counts, the
|
||||
// live tool_result frame's stamp) re-renders the SAME marker next to
|
||||
// its Reading line — old records without the field render
|
||||
// unchanged (t.truncated falsy → no marker).
|
||||
for (const t of m.tools) {
|
||||
if (!t || typeof t.name !== "string") continue;
|
||||
const arg =
|
||||
typeof t.argument === "string" && t.argument ? t.argument : null;
|
||||
// Phase 139 (task 02): a saved range read (the live toolAcc
|
||||
// stamp — additive on the bor.chat.v1 record, the phase-95
|
||||
// truncated-fields precedent) re-renders the SAME suffix through
|
||||
// the SAME helper: the same Number.isInteger guard → a record
|
||||
// saved before the phase (no fields) renders byte-identically
|
||||
// (the phase-95 truncation note is untouched — it keys on the
|
||||
// <code> argument, not the range).
|
||||
const tOffset =
|
||||
Number.isInteger(t.offset) && t.offset >= 1 ? t.offset : null;
|
||||
const tLimit =
|
||||
Number.isInteger(t.limit) && t.limit >= 1 ? t.limit : null;
|
||||
// Phase 146 (task 02): a saved SCOPED grep (the live toolAcc
|
||||
// stamp — the additive `path` key on the bor.chat.v1 record)
|
||||
// re-renders the SAME scope through the SAME helper: the same
|
||||
// string guard → a record saved before the phase (no `path`
|
||||
// key) renders byte-identically (the phase-95 truncation note
|
||||
// is untouched — it keys on the <code> argument, not the scope).
|
||||
const tPath = typeof t.path === "string" && t.path ? t.path : null;
|
||||
appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath);
|
||||
if (t.truncated && arg) {
|
||||
appendTruncatedNote(wrap, arg, Number(t.chars_shown) || 0, Number(t.chars_total) || 0);
|
||||
// Phase 158 (task 03, D4): a record WITH `segments` renders the
|
||||
// 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).
|
||||
if (Array.isArray(m.segments) && m.segments.length) {
|
||||
restoreTimeline(wrap, m.segments);
|
||||
} else {
|
||||
if (m.thinking) {
|
||||
// Phase 17: restore the thinking block COLLAPSED above the bubble.
|
||||
// Phase 158 (task 02): the builder is SHARED with the live per-run
|
||||
// path (createThinkingBlock) — the legacy path keeps ONE collapsed
|
||||
// block (the full concatenation). A record WITH `segments` takes
|
||||
// the restoreTimeline dispatch instead (task 03 — segments take
|
||||
// precedence; this legacy path stays for pre-phase records).
|
||||
const block = createThinkingBlock();
|
||||
block.open = false;
|
||||
wrap
|
||||
.querySelector(".msg-body")
|
||||
.insertBefore(block, wrap.querySelector(".bubble"));
|
||||
block.querySelector(".thinking-text").innerHTML = renderMarkdown(m.thinking);
|
||||
}
|
||||
if (Array.isArray(m.tools)) {
|
||||
// Phase 37: restore the tool lines in saved order through the SAME
|
||||
// append helper as the live frames (no HTML from storage, ever).
|
||||
// Phase 95: a stored truncation record (truncated + the counts, the
|
||||
// live tool_result frame's stamp) re-renders the SAME marker next to
|
||||
// its Reading line — old records without the field render
|
||||
// unchanged (t.truncated falsy → no marker).
|
||||
// Phase 158 (task 02): the single-container derivation moved out of
|
||||
// appendToolLine (the caller passes the run's disclosure) — the
|
||||
// legacy path keeps ONE disclosure per message (all m.tools),
|
||||
// created with the live lifecycle (open during the loop, folded
|
||||
// below) so a restored turn is one compact "Tool calls (N)" line
|
||||
// (phase 117, D3), exactly as before.
|
||||
let restoreDisc = null;
|
||||
for (const t of m.tools) {
|
||||
if (!t || typeof t.name !== "string") continue;
|
||||
const arg =
|
||||
typeof t.argument === "string" && t.argument ? t.argument : null;
|
||||
// Phase 139 (task 02): a saved range read (the live toolAcc
|
||||
// stamp — additive on the bor.chat.v1 record, the phase-95
|
||||
// truncated-fields precedent) re-renders the SAME suffix through
|
||||
// the SAME helper: the same Number.isInteger guard → a record
|
||||
// saved before the phase (no fields) renders byte-identically
|
||||
// (the phase-95 truncation note is untouched — it keys on the
|
||||
// <code> argument, not the range).
|
||||
const tOffset =
|
||||
Number.isInteger(t.offset) && t.offset >= 1 ? t.offset : null;
|
||||
const tLimit =
|
||||
Number.isInteger(t.limit) && t.limit >= 1 ? t.limit : null;
|
||||
// Phase 146 (task 02): a saved SCOPED grep (the live toolAcc
|
||||
// stamp — the additive `path` key on the bor.chat.v1 record)
|
||||
// re-renders the SAME scope through the SAME helper: the same
|
||||
// string guard → a record saved before the phase (no `path`
|
||||
// key) renders byte-identically (the phase-95 truncation note
|
||||
// is untouched — it keys on the <code> argument, not the scope).
|
||||
const tPath = typeof t.path === "string" && t.path ? t.path : null;
|
||||
if (!restoreDisc) {
|
||||
restoreDisc = createToolDisclosure();
|
||||
restoreDisc.open = true; // the live lifecycle — folded below (D3)
|
||||
wrap
|
||||
.querySelector(".msg-body")
|
||||
.insertBefore(restoreDisc, wrap.querySelector(".bubble"));
|
||||
}
|
||||
appendToolLine(restoreDisc, t.name, arg, tOffset, tLimit, tPath);
|
||||
if (t.truncated && arg) {
|
||||
appendTruncatedNote(wrap, arg, Number(t.chars_shown) || 0, Number(t.chars_total) || 0);
|
||||
}
|
||||
}
|
||||
if (restoreDisc) restoreDisc.open = false; // phase 117 (D3): a restored turn renders folded
|
||||
}
|
||||
closeToolCalls(wrap); // phase 117 (D3): a restored turn renders folded
|
||||
}
|
||||
if (m.deflected) {
|
||||
wrap.classList.add("is-deflected");
|
||||
@@ -2994,19 +3206,25 @@ async function handleSend(e) {
|
||||
* the 300s abort (the `aborted` branch), and the zero-frame-but-completed
|
||||
* case (the stream settled — runTurn's inline fallback branch keeps its
|
||||
* EMPTY_ANSWER_FALLBACK bubble and only gains the failed marker). */
|
||||
function finalizeFailedTurn(detail, { acc, thinking, tools, wrap, leavePartialIndex }) {
|
||||
function finalizeFailedTurn(
|
||||
detail,
|
||||
{ acc, thinking, tools, segments, wrap, leavePartialIndex }
|
||||
) {
|
||||
const error = (detail || "").trim().slice(0, 500);
|
||||
if (wrap) {
|
||||
// Partial streamed: keep the answer text the user saw, settle the
|
||||
// block + calls closed (the stop-finalize pattern), mark it failed.
|
||||
closeThinkingBlock(wrap);
|
||||
closeToolCalls(wrap);
|
||||
// Partial streamed: keep the answer text the user saw, settle every
|
||||
// open run closed (phase 158, D5 — the stop-finalize pattern),
|
||||
// mark it failed.
|
||||
closeAllRuns(wrap);
|
||||
appendFailedNote(wrap, error);
|
||||
rememberBrainTurn(
|
||||
acc,
|
||||
{
|
||||
thinking: thinking || undefined,
|
||||
tools: tools.length ? tools : undefined,
|
||||
// Phase 158 (D4): the failed partial's timeline persists too
|
||||
// (non-empty only — the undefined-drops-the-key house pattern).
|
||||
segments: segments?.length ? segments : undefined,
|
||||
failed: true,
|
||||
error: error || undefined,
|
||||
},
|
||||
@@ -3083,6 +3301,8 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
// turn yet (the pagehide handler records it if one lands).
|
||||
acc = "";
|
||||
thinkingAcc = "";
|
||||
segRuns = []; // phase 158: a fresh turn starts with no runs (the pagehide partial reads the current turn's runs)
|
||||
currentRun = null;
|
||||
persistedOnLeave = false;
|
||||
leftThePage = false;
|
||||
leavePartialIndex = -1;
|
||||
@@ -3168,24 +3388,24 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
// "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 109 (TODO.md L3, D15): the block is a TOGGLE —
|
||||
// open-while-thinking, closed-while-answering. The re-open
|
||||
// assignment below (right after ensureThinkingBlock) re-opens a
|
||||
// block a previous `delta` closed — the next agent round's
|
||||
// thinking (the model answered, called a tool, then thinks
|
||||
// again) no longer lands in an invisible collapsed scratchpad;
|
||||
// while the block is already open (the pre-delta live flow) it
|
||||
// is a no-op — that flow is unchanged.
|
||||
// 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).
|
||||
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
|
||||
const block = ensureThinkingBlock(wrap);
|
||||
// Phase 109 (D15): open-while-thinking — idempotent: a no-op
|
||||
// while already open (the pre-delta live flow), a re-open after
|
||||
// a `delta` closed the block (the next agent round).
|
||||
block.open = true;
|
||||
const block = ensureThinkingRun(wrap);
|
||||
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
|
||||
// than the 32px band — any paragraph break ("\n\n") or a few
|
||||
@@ -3194,9 +3414,11 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
// 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.
|
||||
// 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).
|
||||
const pinned = block.open && isThinkingNearBottom(textEl);
|
||||
textEl.innerHTML = renderMarkdown(thinkingAcc); // escape-first, XSS-safe
|
||||
textEl.innerHTML = renderMarkdown(currentRun.run.text); // escape-first, XSS-safe
|
||||
if (pinned) {
|
||||
// Follow the live tail only while the user was pinned to the
|
||||
// window bottom before this chunk (owner direction
|
||||
@@ -3275,12 +3497,33 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
.querySelector("#typing-indicator .bubble")
|
||||
?.setAttribute("aria-label", toolStatus);
|
||||
}
|
||||
appendToolLine(wrap, name, argument, frameOffset, frameLimit, framePath);
|
||||
// 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
|
||||
// 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.
|
||||
const disc = ensureToolRun(wrap);
|
||||
currentRun.run.records.push(toolRecord);
|
||||
const line = appendToolLine(
|
||||
disc,
|
||||
name,
|
||||
argument,
|
||||
frameOffset,
|
||||
frameLimit,
|
||||
framePath
|
||||
);
|
||||
// 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
|
||||
// (A6): the phase-14 restore path re-renders lines without it.
|
||||
armToolLineClock(wrap);
|
||||
// Phase 158: the clock TARGETS this line (the tracked reference
|
||||
// — the `:last-child` query is gone; multiple disclosures make
|
||||
// it the wrong line).
|
||||
armToolLineClock(wrap, line);
|
||||
// No page scroll (phase 42): tool lines never yank the viewport.
|
||||
} else if (ev.type === "retry") {
|
||||
// Phase 87 (TODO.md L5): a frame arrived — settle the latest
|
||||
@@ -3315,14 +3558,20 @@ 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
|
||||
closeThinkingBlock(wrap); // auto-collapse; idempotent — the next `thinking` frame re-opens it (phase 109, D15)
|
||||
closeToolCalls(wrap); // the answer began — fold the tool record (phase 117, D3)
|
||||
// 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.
|
||||
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;
|
||||
closeThinkingBlock(wrap); // the turn is over: settle the block closed
|
||||
closeToolCalls(wrap); // the turn ended — settle the record folded (phase 117, D3)
|
||||
// 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
|
||||
// closeToolCalls fold + the D15 block close.
|
||||
closeAllRuns(wrap);
|
||||
if (!wrap) {
|
||||
setUiState(UI_STATE.streaming);
|
||||
wrap = addMessage("brain", "…");
|
||||
@@ -3365,9 +3614,16 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
// Phase 73 (task 02): if a pagehide partial landed mid-turn, the
|
||||
// final record REPLACES it in place (leavePartialIndex = -1 when
|
||||
// there was none — the append, exactly as before).
|
||||
// Phase 158 (D4): the turn's CHRONOLOGICAL run order persists
|
||||
// with the turn — segments (non-empty only: the
|
||||
// undefined-drops-the-key house pattern; a no-thinking/no-tools
|
||||
// turn stores no key). Additive: thinking + tools stay UNCHANGED
|
||||
// (the history wire + the old-client degradation path).
|
||||
const turnSegments = buildSegments(segRuns);
|
||||
rememberBrainTurn(finalText || acc, {
|
||||
thinking: thinkingAcc || undefined,
|
||||
tools: toolAcc.length ? toolAcc : undefined,
|
||||
segments: turnSegments.length ? turnSegments : undefined,
|
||||
deflected: !!ev.deflected,
|
||||
sources: ev.sources,
|
||||
// Phase 124 (task 06, TODO L3): the answer image persists
|
||||
@@ -3449,6 +3705,7 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
acc,
|
||||
thinking: thinkingAcc,
|
||||
tools: toolAcc,
|
||||
segments: buildSegments(segRuns), // phase 158 (D4): the partial's timeline
|
||||
wrap,
|
||||
leavePartialIndex,
|
||||
});
|
||||
@@ -3489,17 +3746,23 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
// The persistedOnLeave guard (phase 20) keeps a navigate-away —
|
||||
// which aborts the same fetch — from saving the partial twice.
|
||||
if (wrap && acc && !persistedOnLeave) {
|
||||
closeThinkingBlock(wrap); // settle the block closed, like `done`
|
||||
closeToolCalls(wrap); // the turn was stopped — fold the record (phase 117, D3)
|
||||
// Phase 158 (D5): the turn was stopped — fold every open run
|
||||
// (replaces the phase-117 closeToolCalls fold + the D15 block
|
||||
// close, like `done`).
|
||||
closeAllRuns(wrap);
|
||||
persistedOnLeave = true;
|
||||
appendTuneButton(wrap); // admin-only; parity with the restore path
|
||||
appendStoppedNote(wrap);
|
||||
// Phase 73 (task 02): the stopped partial REPLACES a pagehide
|
||||
// partial (leavePartialIndex) in place — one brain turn for the
|
||||
// question, marked stopped, never two records.
|
||||
// question, marked stopped, never two records. Phase 158
|
||||
// (D4): the stopped partial's timeline persists too
|
||||
// (non-empty only).
|
||||
const stopSegments = buildSegments(segRuns);
|
||||
rememberBrainTurn(acc, {
|
||||
thinking: thinkingAcc || undefined,
|
||||
tools: toolAcc.length ? toolAcc : undefined,
|
||||
segments: stopSegments.length ? stopSegments : undefined,
|
||||
stopped: true,
|
||||
}, leavePartialIndex);
|
||||
lastBrainWrap = wrap; // the stopped partial is the prime retry candidate
|
||||
@@ -3534,6 +3797,7 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
acc,
|
||||
thinking: thinkingAcc,
|
||||
tools: toolAcc,
|
||||
segments: buildSegments(segRuns), // phase 158 (D4): the partial's timeline
|
||||
wrap,
|
||||
leavePartialIndex,
|
||||
});
|
||||
@@ -3660,7 +3924,14 @@ window.addEventListener("pagehide", () => {
|
||||
return;
|
||||
if (!acc) return; // nothing brain-side to save yet
|
||||
persistedOnLeave = true;
|
||||
rememberBrainTurn(acc, { thinking: thinkingAcc || undefined });
|
||||
// Phase 158 (D4): the navigate-away partial's timeline persists too
|
||||
// (segRuns is module scope — readable here, like thinkingAcc;
|
||||
// non-empty only — the undefined-drops-the-key house pattern).
|
||||
const pagehideSegments = buildSegments(segRuns);
|
||||
rememberBrainTurn(acc, {
|
||||
thinking: thinkingAcc || undefined,
|
||||
segments: pagehideSegments.length ? pagehideSegments : undefined,
|
||||
});
|
||||
leavePartialIndex = conversation.length - 1; // the record it just pushed
|
||||
});
|
||||
|
||||
|
||||
+134
-3
@@ -247,6 +247,125 @@ function addToolLines(wrap, tools) {
|
||||
body.insertBefore(disclosure, body.querySelector(".bubble"));
|
||||
}
|
||||
|
||||
/* 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). The structure is
|
||||
* createElement/textContent; the line bytes mirror addToolLines
|
||||
* EXACTLY (the four pinned label literals + the phase-70 legacy
|
||||
* names + the phase-139 readRangeSuffix + the phase-95 truncation
|
||||
* note — the shared-page parity), and the thinking text flows
|
||||
* through the SAME escape-first renderMarkdown write addThinkingBlock
|
||||
* uses (XSS-safe). An entry with an UNKNOWN `kind` (or a malformed
|
||||
* shape) is SKIPPED — never render from storage (textContent only,
|
||||
* the house rule). A record WITHOUT `segments` never reaches here —
|
||||
* the dispatch keeps the legacy addThinkingBlock + addToolLines path
|
||||
* byte-identical. */
|
||||
function addSegments(wrap, segments) {
|
||||
const body = wrap.querySelector(".msg-body");
|
||||
if (!body) return;
|
||||
const bubble = body.querySelector(".bubble");
|
||||
let toolDisc = null; // the CURRENT run of consecutive tool entries (one disclosure per run, D1)
|
||||
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).
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
if (entry.kind === "thinking") {
|
||||
if (typeof entry.text !== "string") continue; // malformed: skipped
|
||||
if (toolDisc) toolDisc = null; // the run is sealed — the count is final
|
||||
const block = document.createElement("details");
|
||||
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 textEl = document.createElement("div");
|
||||
textEl.className = "thinking-text";
|
||||
textEl.innerHTML = renderMarkdown(entry.text); // escape-first, XSS-safe (parity with addThinkingBlock)
|
||||
block.append(summary, textEl);
|
||||
body.insertBefore(block, bubble); // D2: above the bubble, in arrival order
|
||||
} else if (entry.kind === "tool") {
|
||||
if (typeof entry.name !== "string") continue; // malformed: skipped
|
||||
if (!toolDisc) {
|
||||
toolDisc = document.createElement("details");
|
||||
toolDisc.className = "tool-calls-disclosure";
|
||||
toolDisc.open = false; // D5: collapsed (parity with addToolLines — phase 117 D3)
|
||||
const summary = document.createElement("summary");
|
||||
summary.className = "tool-calls-summary";
|
||||
toolDisc.appendChild(summary);
|
||||
const container = document.createElement("div");
|
||||
container.className = "tool-calls";
|
||||
container.setAttribute("role", "list");
|
||||
container.setAttribute("aria-label", "Tool calls");
|
||||
toolDisc.appendChild(container);
|
||||
body.insertBefore(toolDisc, bubble); // D2: above the bubble, in arrival order
|
||||
}
|
||||
const container = toolDisc.querySelector(".tool-calls");
|
||||
const line = document.createElement("span");
|
||||
line.className = "tool-call";
|
||||
line.setAttribute("role", "listitem");
|
||||
const argument =
|
||||
typeof entry.argument === "string" && entry.argument
|
||||
? entry.argument
|
||||
: null;
|
||||
// The line bytes MIRROR addToolLines exactly (the shared-page
|
||||
// parity — phase 70: new names first, legacy names kept; the
|
||||
// phase-139 range suffix; the phase-95 truncation note).
|
||||
if ((entry.name === "read" || entry.name === "read_document") && argument) {
|
||||
line.textContent = "📄 Reading ";
|
||||
const code = document.createElement("code");
|
||||
code.textContent = argument; // the path is data, never markup
|
||||
line.appendChild(code);
|
||||
const rangeSuffix = readRangeSuffix(entry.offset, entry.limit);
|
||||
if (rangeSuffix) {
|
||||
const range = document.createElement("span");
|
||||
range.className = "read-range";
|
||||
range.textContent = rangeSuffix; // the range is data, never markup
|
||||
line.appendChild(range);
|
||||
}
|
||||
} else if (
|
||||
(entry.name === "grep" || entry.name === "search_documents") && argument
|
||||
) {
|
||||
line.textContent = "🔎 Searching for ";
|
||||
const code = document.createElement("code");
|
||||
code.textContent = argument; // the pattern is data, never markup
|
||||
line.appendChild(code);
|
||||
} else if (entry.name === "ls" && argument) {
|
||||
line.textContent = "🔎 Listing documents in ";
|
||||
const code = document.createElement("code");
|
||||
code.textContent = argument; // the source scope is data, never markup
|
||||
line.appendChild(code);
|
||||
} else {
|
||||
line.textContent = "🔎 Listing documents";
|
||||
}
|
||||
container.appendChild(line);
|
||||
// Phase 95: the stored truncation record — the same marker
|
||||
// addToolLines renders (plain integers, no separators); only
|
||||
// argument-bearing (Reading) lines can carry it.
|
||||
if (entry.truncated && argument) {
|
||||
const note = document.createElement("span");
|
||||
note.className = "truncated-note";
|
||||
note.textContent =
|
||||
" (truncated — showing " + (Number(entry.chars_shown) || 0) + " of " + (Number(entry.chars_total) || 0) + " chars)";
|
||||
line.appendChild(note);
|
||||
}
|
||||
// The per-run count (parity with addToolLines — one disclosure
|
||||
// per contiguous run, D3) rides every append; the final
|
||||
// append leaves the final count.
|
||||
const n = container.children.length;
|
||||
toolDisc.querySelector("summary").textContent =
|
||||
`Tool call${n === 1 ? "" : "s"} (${n})`;
|
||||
}
|
||||
}
|
||||
// an entry with an unknown `kind` (or a malformed shape) is
|
||||
// SKIPPED — never render from storage (textContent only, the
|
||||
// house rule); a guard comment names the skip.
|
||||
}
|
||||
|
||||
/* Phase 139 (task 01): the shared page's parity copy of app.js's
|
||||
* readRangeSuffix — the requested line range on the Reading line. The
|
||||
* saved record now carries the chat page's offset/limit (the toolAcc
|
||||
@@ -453,10 +572,22 @@ function renderSharedMessage(m) {
|
||||
return;
|
||||
}
|
||||
const wrap = addSharedMessage("brain", renderMarkdown(m.text));
|
||||
if (typeof m.thinking === "string" && m.thinking) {
|
||||
addThinkingBlock(wrap, m.thinking);
|
||||
// Phase 158 (task 03, D4): a record WITH `segments` renders the
|
||||
// per-run timeline through addSegments (collapsed — the parity
|
||||
// copy of the chat page's restoreTimeline); the runs carry the
|
||||
// SAME data as `m.thinking` + `m.tools`, in order, so the legacy
|
||||
// addThinkingBlock + addToolLines are SKIPPED (rendering both
|
||||
// would double the runs). A record WITHOUT `segments` — every
|
||||
// pre-phase record — renders BYTE-IDENTICALLY through the legacy
|
||||
// path below.
|
||||
if (Array.isArray(m.segments) && m.segments.length) {
|
||||
addSegments(wrap, m.segments);
|
||||
} else {
|
||||
if (typeof m.thinking === "string" && m.thinking) {
|
||||
addThinkingBlock(wrap, m.thinking);
|
||||
}
|
||||
addToolLines(wrap, m.tools);
|
||||
}
|
||||
addToolLines(wrap, m.tools);
|
||||
if (m.deflected) {
|
||||
wrap.classList.add("is-deflected");
|
||||
addMaybeTry(wrap, m.suggestions);
|
||||
|
||||
Reference in New Issue
Block a user