phase: 158_thinking_timeline
Build and Push Containers / build-and-push-app (push) Successful in 8m38s
Build and Push Containers / build-and-push-db (push) Successful in 32s

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:
2026-10-05 02:52:55 -04:00
parent 3f752fb77f
commit 31bd903a93
49 changed files with 4280 additions and 482 deletions
+427 -156
View File
@@ -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
View File
@@ -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);