Compare commits

..

6 Commits

Author SHA1 Message Date
Patrick Buckley 2d174cd71c perf(webui): bound the transcript — containment, block flow, windowing
The remaining steady-state cost after the wedge-proofing pass was
structural: every row of an unbounded transcript participated in every
layout, and full re-renders rebuilt all of it.

CSS pair (shared scroller + the ui/static duplicate):
- The messages scroller is BLOCK flow, not a column flexbox — flex
  relayouts all items when the streaming row's height changes, O(rows)
  per token; block flow dirties only the tail. The old per-child
  flex-shrink pin (and the min-height:auto squish hazard it suppressed)
  goes with it; inter-row rhythm moves to a sibling margin.
- overflow-anchor: none — the pane owns bottom pinning, and native
  anchoring kept re-selecting an anchor inside the innerHTML-replaced
  live bubble every frame.
- content-visibility: auto with contain-intrinsic-size: auto estimates
  on .msg (80px) and .conv-batch (200px) rows, exempting the last two
  children so the live tail never toggles skip-state mid-stream. The
  `auto` keyword memoizes each row's rendered size, keeping scrollHeight
  and the bottom pin stable once painted.

Transcript windowing (interactive pane):
- Full re-renders paint the most recent 300 messages, cut FORWARD to a
  user-turn boundary so an assistant tool_calls message is never split
  from the tool results that anchor to it. Hidden content sits behind a
  "Load earlier messages" pager; each click grows the window a step and
  refetches, restoring the scroll anchor by scrollHeight delta (the
  rAF pin re-checks the near-bottom flag at fire time, so no
  suppression is needed). Rewind/edit turn math is tail-relative and
  unaffected — pinned as such.
- Live appends are bounded at the idle edge: past 900 rendered rows the
  oldest rows are trimmed (again to a turn boundary), only while pinned
  to the bottom — a scrolled-up user is reading the rows a trim would
  remove. Trimmed content stays in /history and returns through the
  pager; detached agent-card entries are swept.

The perf page gains ?window= (and the runner --perf-extra) so windowing
and containment effects can be isolated.

Measured (n=3000 history + 20-turn storm; baseline -> previous branch
-> this change): full replay 1060ms -> 238ms -> 28ms windowed / 107ms
with the window grown to the full transcript; longtasks during the run
6/1080ms -> 4/495ms -> none windowed / 4/205ms unwindowed; per-turn
live-storm cost at n=3000 now equals n=300 (~220ms harness floor) even
with all 25k nodes live — the transcript-size tax is gone. Known
degraded-mode cost: the chunk path at a fully-grown window measures
~1017ms vs the 833ms floor; the shipped windowed config sits at the
floor.
2026-07-02 00:33:35 -07:00
Patrick Buckley df573b7314 fix(webui): address review feedback on roster eviction and dead guards
- applyRosterSnapshot: null-prototype membership map (a ws id colliding
  with an Object.prototype property name would read as always-seen and
  dodge eviction) and a stable Object.keys snapshot for the eviction
  walk — current-key deletion during for...in is spec-safe, but the
  snapshot is self-evidently order-safe and skips inherited keys.
- _streamingRenderApply: drop the tautological typeof guards around the
  post-render decorators — both are module-local declarations, and the
  surrounding try/catch owns decoration fault tolerance.
2026-07-02 00:32:11 -07:00
Patrick Buckley 3c7a3c1375 fix(webui): wedge-proof the live-session pipeline and de-O(N) hot paths
Long sessions (5000+ messages, several compactions) degraded steadily
and could stop rendering entirely while the backend stayed healthy.
Four hard failure mechanisms, each sufficient on its own:

- Unguarded event pipeline: one throw escaping onmessage/handleEvent
  (e.g. renderMarkdown stack overflow on a few KB of nested "> ")
  stranded the streaming refs, so every later delta painted into the
  poisoned segment. stream_end now resets segment refs BEFORE the
  finalize render with a plain-text fallback (the coordinator pane's
  existing pattern); onmessage guards both parse and dispatch;
  renderMarkdown is depth-capped with throw-safe footnote-scope
  accounting; the streaming buffer is marked rendered only on success.

- Rebuild-vs-live races: clear_ui/replay_truncated re-renders wiped
  events painted in the snapshot->replaceChildren window (never
  redelivered) and left deltas writing into detached nodes. Rebuilds
  now quiesce the event stream behind a token-owned queue flushed
  after the render; streaming refs reset on every rebuild path
  including refetch FAILURE; a mid-stream replay_truncated defers its
  re-sync to the idle edge instead of dropping the repair.

- Ignored recovery floor: the global stream now handles node_snapshot
  and replay_truncated. Roster eviction (with a "Session ended" toast
  for open panes) happens only from the stream-ordered snapshot; the
  REST resync is merge-only and r.ok-gated so a mid-restart 503 body
  cannot read as an authoritative empty roster.

- Unbounded growth: _agentCards released on rebuild — deliberately NOT
  on transport-only reconnects, which must preserve the maps or the
  next child event builds a duplicate card; orphan grace timers
  cancelled on full reload/destroy; toast queue capped with duplicate
  coalescing; diff previews capped at 400 rendered lines (the
  spread-append could throw RangeError before the approval gate
  painted) with the omission notice below the scroll box; raw results
  clamped at 64KiB.

Per-event O(N) work removed from the hot paths: thinking-indicator
instance ref; near-bottom cached from a passive scroll listener and
re-checked at rAF pin time (a user scroll-up landing in the coalescing
window wins; ResizeObserver re-engages follow after layout changes);
rAF-coalesced outer and per-stream scroll pins; self-healing
call_id->row/stream lookup caches; verdict lookup scoped to the row's
batch; tracked retry holder; queue-controller Set replaces the
whole-transcript idle sweep; rail renders rAF-coalesced; coordinator
child_ws_state ticks routed to single-row updates (full render only on
terminal-boundary crossings) with observer unobserve on replace.

Also: the coordinator SSE-error 401 probe is un-deadened (raw fetch —
authFetch never resolves a 401 — with the body inspected so a
version_mismatch still takes auth.js's upgrade-reload path via the new
noteVersionMismatch export); the console cluster-SSE reconnect timer
is tracked across logout; the mermaid render chain is rejection-proof
per link and paints errors on the containers the failing link had
already claimed.

Measured with scripts/livepass.py --perf (n=3000 history + 20-turn
live storm): full replay 1060ms -> 238ms; re-render cycles 836-1071ms
-> ~94ms flat; chunk path now flat vs transcript size; worst longtask
1080ms -> ~500ms; agent-card retention across rebuilds 4 -> 0.

Known limit (needs a server-side event watermark on /history): a turn
completing inside the refetch window can paint twice after the quiesce
flush — rare, visible, and strictly better than the silent loss it
replaces.
2026-07-02 00:32:11 -07:00
Patrick Buckley 41e7d5b7d7 feat(livepass): add long-session perf harness (--perf)
New /perf/livepass.html mounts the real InteractivePane at production
scroll geometry and drives production-shaped SSE events through
handleEvent/replayHistory in real time (no virtual-time budget, no
forced reduced-motion — both corrupt the measurement), reporting:
replayHistory wall time at N messages, per-turn live-storm cost on top
of that transcript, tool_output_chunk throughput, busy/idle churn,
heap + node + agent-card counts across repeated replay cycles (the
detached-DOM leak probe), and longtask counts.

The --perf runner builds, serves, and launches headless Chrome with
--js-flags=--expose-gc and --enable-precise-memory-info so heap
numbers are real floors; the page POSTs its JSON report to
/perf/report. Reports carry a per-attempt run token the runner
validates, so a straggler POST from a killed prior attempt cannot be
misattributed to the next size, and the wait loop polls the Chrome
process so a sandbox startup failure bails to the --no-sandbox
fallback in seconds instead of burning the full timeout.
2026-07-02 00:32:11 -07:00
Patrick Buckley ca23f2876c fix(ci): refuse fork PRs in the vendor-js dispatch path
The workflow_dispatch input is an arbitrary PR number, and the job used
only headRefName to pick the checkout ref. For a fork PR that is a bare
branch name that can collide with a branch in this repo, so the job
(contents:write, ends in git push) would operate on that unrelated
branch. Resolve isCrossRepository alongside headRefName and fail loudly
unless the PR head lives in this repository.
2026-07-01 21:32:45 -07:00
Patrick Buckley a9898fdd6c fix(ci): gate workflow_run publishing to same-repo tag pushes
The publish and docker workflows trigger on workflow_run of CI, which
fires for every CI completion — including CI runs for pull requests
from forks — and always executes with this repo's secrets, tokens, and
the pypi environment. The only gate was CI success, so fork-PR CI runs
spawned publish jobs in the upstream context; actions/checkout v7's
fork-checkout refusal was the only thing that stopped one on 2026-06-30.
A fork PR whose head is an upstream-tagged commit would have passed the
tag check and reached the upload with valid OIDC.

Both workflows now require the triggering CI run to be a push event,
from this repository, with head_branch starting with 'v' — CI's push
trigger only matches main/stable/* branches and v* tags, so that is
necessarily a tag run (verified: tag-push runs report the tag name as
head_branch). Checkouts no longer persist the token while the tree's
build backend executes, and publishes are no longer cancellable
mid-upload (a half-uploaded release cannot be re-run cleanly because
PyPI rejects duplicate files).

vendor-js hardening in the same pass: gate on the immutable PR author
instead of github.actor, require a same-repo head before pushing to the
PR branch with contents:write, and pass github.head_ref through env
instead of interpolating it into the script body.
2026-07-01 21:32:45 -07:00
20 changed files with 1907 additions and 205 deletions
+13 -2
View File
@@ -7,7 +7,9 @@ on:
concurrency:
group: docker-${{ github.event.workflow_run.head_sha }}
cancel-in-progress: true
# Never cancel mid-push: an interrupted multi-tag push can leave the
# registry with a partial tag set (e.g. :latest moved, :stable not).
cancel-in-progress: false
permissions:
contents: read
@@ -19,15 +21,24 @@ env:
jobs:
docker:
# Same gate as publish.yml: workflow_run fires for every CI completion
# (including fork and same-repo PR runs) with this repo's token and
# packages:write. Only same-repo tag pushes may publish images; CI's
# push trigger matches main/stable/* and v* tags, so a head_branch
# starting with "v" is necessarily a tag run.
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_repository.full_name == github.repository
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_repository.full_name == github.repository &&
startsWith(github.event.workflow_run.head_branch, 'v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 0
# The docker build only reads the tree; keep the token out of it.
persist-credentials: false
- name: Resolve release tag
id: tag
+16 -2
View File
@@ -7,7 +7,9 @@ on:
concurrency:
group: publish-${{ github.event.workflow_run.head_sha }}
cancel-in-progress: true
# Never cancel a publish mid-upload: a half-uploaded release (sdist up,
# wheel missing) cannot be re-run cleanly because PyPI rejects duplicates.
cancel-in-progress: false
permissions:
contents: write
@@ -15,7 +17,16 @@ permissions:
jobs:
publish:
if: github.event.workflow_run.conclusion == 'success'
# workflow_run fires for EVERY CI completion — including CI runs for
# pull_requests from forks — and always executes here with this repo's
# secrets, tokens, and the pypi environment. Gate to same-repo tag
# pushes only: CI's push trigger matches branches main/stable/* and
# tags v*, so a head_branch starting with "v" is necessarily a tag run.
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_repository.full_name == github.repository &&
startsWith(github.event.workflow_run.head_branch, 'v')
runs-on: ubuntu-latest
environment: pypi
steps:
@@ -23,6 +34,9 @@ jobs:
with:
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 0
# python -m build executes the tree's build backend; don't leave
# the contents:write token sitting in .git/config while it runs.
persist-credentials: false
- name: Resolve release tag
id: tag
+25 -4
View File
@@ -25,18 +25,39 @@ permissions:
jobs:
vendor-js:
if: github.actor == 'renovate[bot]' || github.event_name == 'workflow_dispatch'
# Same-repo PRs only: this job checks out the PR head and pushes to it
# with contents:write, so it must never act on a fork's branch.
# Gate on the PR author (immutable), not github.actor (names whoever
# caused the latest event, which can be someone else re-running it).
if: >-
(github.event_name == 'pull_request' &&
github.event.pull_request.user.login == 'renovate[bot]' &&
github.event.pull_request.head.repo.full_name == github.repository) ||
github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- name: Resolve PR head ref
id: ref
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Branch names may contain shell metacharacters; pass via env,
# never interpolate ${{ }} into the script body.
HEAD_REF: ${{ github.head_ref }}
PR_NUMBER: ${{ inputs.pr_number }}
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
ref=$(gh pr view "${{ inputs.pr_number }}" --repo "${{ github.repository }}" --json headRefName -q .headRefName)
if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then
# The dispatch input is an arbitrary PR number; refuse fork PRs.
# A fork's headRefName is a bare branch name that may collide
# with a branch in this repo, and checkout+push would then hit
# that unrelated branch ("same-repo PRs only" applies here too).
pr_json=$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json headRefName,isCrossRepository)
if [[ "$(jq -r '.isCrossRepository' <<< "$pr_json")" != "false" ]]; then
echo "::error::PR #${PR_NUMBER} head is not a branch in this repository; refusing to complete it."
exit 1
fi
ref=$(jq -r '.headRefName' <<< "$pr_json")
else
ref="${{ github.head_ref }}"
ref="$HEAD_REF"
fi
echo "head_ref=${ref}" >> "$GITHUB_OUTPUT"
+619 -18
View File
@@ -79,6 +79,26 @@ Task-agent harness (/taskagent/livepass.html): the task_agent card — a task
success, TASKAGENT-FAILED-... / TASKAGENT-ERROR when routing breaks, so a
broken card can't screenshot green.
Perf harness (/perf/livepass.html): long-session performance baseline for the
interactive pane — mounts the REAL InteractivePane at real scroll geometry
(fixed-height mount, production CSS chain) and drives production-shaped
events through pane.handleEvent/replayHistory with rAF yields, measuring:
replayHistory wall time at N messages, live event-storm cost per turn on top
of that transcript (reasoning/content deltas + tool batches + task_agent
cards), tool_output_chunk throughput, busy/idle churn, heap + node count +
_agentCards size across repeated replay cycles (leak probe), and longtask
counts. Query params: ?n= (history size) &turns= &chunks= &cycles= &idle=
&post=1 (POST the JSON report to /perf/report — the --perf runner captures
it). Results land in <pre id="perf-json"> and document.title stamps
PERF-READY-<n> / PERF-FAILED-<phase>. MEASUREMENT RULES: never run with
--virtual-time-budget (it corrupts performance.now) and never pass
--force-prefers-reduced-motion (it disables the animations whose cost we
measure); the --perf runner passes --js-flags=--expose-gc and
--enable-precise-memory-info so heap numbers are stable and real.
python3 scripts/livepass.py --perf # 300 and 3000 msgs
python3 scripts/livepass.py --perf --perf-n 5000 # match the field run
Rebuild after ANY markup change: the dialog blocks are embedded at build
time. Assets are symlinked, so CSS/JS edits are live on refresh.
"""
@@ -86,7 +106,13 @@ time. Assets are symlinked, so CSS/JS edits are live on refresh.
from __future__ import annotations
import argparse
import http.server
import json
import re
import shutil
import subprocess
import time
import uuid
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
@@ -1005,6 +1031,335 @@ TASKAGENT_TEMPLATE = """<!doctype html>
"""
# --------------------------------------------------------------------------
# Perf harness — long-session performance baseline for the interactive pane.
# Mounts the REAL InteractivePane (production DOM via _createDOM, production
# CSS chain) in a fixed-height mount so .pane-messages has REAL scroll
# geometry — the forced-layout costs under measurement (isNearBottom /
# scrollToBottom / chunk-append scroll pins) only exist against live layout,
# which is why nothing here stubs scroll/geometry the way the task-agent
# harness does. All timing is real time (see MEASUREMENT RULES in the module
# docstring). Workload is deterministic (seeded LCG) so runs are comparable.
# --------------------------------------------------------------------------
PERF_TEMPLATE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>perf livepass</title>
<link rel="stylesheet" href="shared/base.css" />
<link rel="stylesheet" href="shared/ui-base.css" />
<link rel="stylesheet" href="shared/chat.css" />
<link rel="stylesheet" href="shared/conversation.css" />
<link rel="stylesheet" href="shared/cards.css" />
<link rel="stylesheet" href="static/style.css" />
<link rel="stylesheet" href="shared/interactive.css" />
<style>
/* Harness-only framing (NOT under review): a fixed-height mount so the
pane's .pane-messages scroller has real production geometry. */
body { margin: 0; background: var(--bg); color: var(--fg); }
#mount { height: 720px; width: 920px; display: flex; overflow: hidden; }
#mount > .pane { flex: 1; display: flex; flex-direction: column; min-height: 0; }
#perf-json { font: 11px monospace; white-space: pre-wrap; padding: 12px; }
</style>
</head>
<body>
<div id="mount"></div>
<pre id="perf-json">running…</pre>
<script>
window.toast = { error: function (m) { console.log("toast:", m); } };
// Collect every uncaught error/rejection into the report — a perf run
// that silently swallowed a pipeline exception must not read as clean.
window.__perfErrors = [];
window.onerror = function (msg, src, line) {
window.__perfErrors.push(String(msg) + " @ " + (src || "?") + ":" + (line || 0));
};
window.addEventListener("unhandledrejection", function (e) {
window.__perfErrors.push("unhandledrejection: " + String(e && e.reason));
});
window.__perfFetch = function () {
return Promise.resolve({
ok: true, status: 200,
json: function () { return Promise.resolve({}); },
text: function () { return Promise.resolve(""); },
});
};
window.authFetch = window.__perfFetch;
</script>
<script type="module">
import { InteractivePane } from "./shared/interactive.js";
// auth.js's legacy window bridge clobbers window.authFetch at module
// import time — reinstate the stub now imports have evaluated (same
// dance as the attachments harness).
window.authFetch = window.__perfFetch;
const q = new URLSearchParams(location.search);
const N = parseInt(q.get("n") || "1000", 10);
const TURNS = parseInt(q.get("turns") || "20", 10);
const CHUNKS = parseInt(q.get("chunks") || "300", 10);
const CYCLES = parseInt(q.get("cycles") || "3", 10);
const IDLE = parseInt(q.get("idle") || "20", 10);
// Long-task accounting across every phase (>50ms main-thread blocks).
const lt = { count: 0, total_ms: 0, max_ms: 0 };
try {
new PerformanceObserver(function (list) {
list.getEntries().forEach(function (e) {
lt.count += 1;
lt.total_ms += Math.round(e.duration);
lt.max_ms = Math.max(lt.max_ms, Math.round(e.duration));
});
}).observe({ type: "longtask", buffered: true });
} catch (e) { /* unsupported — longtasks stay zeroed */ }
// Deterministic workload (seeded LCG) so runs are comparable.
let _seed = 42;
function rnd() {
_seed = (_seed * 1664525 + 1013904223) >>> 0;
return _seed / 4294967296;
}
const WORDS = ("the retry loop grinds the dungeon server while the " +
"judge weighs verdicts and the coordinator shuffles children across " +
"nodes tokens accumulate compaction folds turns storage keeps the " +
"canon and the rail repaints").split(" ");
function sentence(w) {
const parts = [];
for (let i = 0; i < w; i++) parts.push(WORDS[(rnd() * WORDS.length) | 0]);
return parts.join(" ");
}
// Realistic assistant markdown: prose + list + fenced code (varying
// content so the hljs cache behaves as in production) + inline code.
function mdBody(i) {
return (
"Turn " + i + ": " + sentence(18) + ".\\n\\n" +
"- " + sentence(6) + "\\n- " + sentence(7) + "\\n\\n" +
"```python\\n" +
"def step_" + i + "(depth):\\n" +
" total = " + ((rnd() * 1000) | 0) + "\\n" +
" for k in range(depth):\\n" +
" total += k * " + (1 + ((rnd() * 9) | 0)) + "\\n" +
" return total\\n" +
"```\\n\\n" +
sentence(14) + " `inline_" + i + "` " + sentence(8) + "."
);
}
// History in the canonical projected wire shape replayHistory consumes
// (user / assistant content / assistant tool_calls / tool result), with
// periodic reasoning bubbles and task_agent cards (agent_steps overlay).
function buildHistory(n) {
const msgs = [];
let i = 0;
while (msgs.length < n) {
i += 1;
msgs.push({ role: "user", content: "Request " + i + ": " + sentence(10) + "?" });
if (msgs.length >= n) break;
if (i % 10 === 0) {
msgs.push({ role: "assistant", reasoning: sentence(40) + ".", content: mdBody(i) });
} else {
msgs.push({ role: "assistant", content: mdBody(i) });
}
if (msgs.length >= n) break;
const callId = "h" + i;
if (i % 8 === 0) {
msgs.push({ role: "assistant", tool_calls: [{
name: "task_agent", id: callId,
arguments: JSON.stringify({ prompt: "subtask " + i }),
agent_steps: [
{ id: callId + "::c1", name: "search",
arguments: JSON.stringify({ query: "q" + i }),
output: sentence(8), is_error: false },
{ id: callId + "::c2", name: "read_file",
arguments: JSON.stringify({ path: "core/f" + i + ".py" }),
output: sentence(6), is_error: false },
{ id: callId + "::c3", name: "bash",
arguments: JSON.stringify({ command: "pytest -k t" + i }),
output: sentence(7), is_error: false },
],
}] });
} else {
msgs.push({ role: "assistant", tool_calls: [{
name: "bash", id: callId,
arguments: JSON.stringify({ command: "grep -rn pattern_" + i + " src/" }),
}] });
}
if (msgs.length >= n) break;
msgs.push({ role: "tool", tool_call_id: callId,
content: "output " + i + ":\\n" + sentence(20) });
}
return msgs;
}
const tick = () => new Promise((r) => requestAnimationFrame(r));
// One live turn, production event mix: thinking indicator, reasoning
// deltas, content deltas (yield every few so streamingRender's internal
// rAF actually applies frames, as in a real token stream), stream_end,
// an auto-approved bash batch with streamed chunks, every 5th turn a
// task_agent card with routed children, then the idle edge.
async function stormTurn(pane, i) {
pane.handleEvent({ type: "state_change", state: "running" });
pane.handleEvent({ type: "thinking_start" });
const reason = sentence(50);
let d = 0;
for (let k = 0; k < reason.length; k += 20) {
pane.handleEvent({ type: "reasoning", text: reason.slice(k, k + 20) });
d += 1;
if (d % 4 === 3) await tick();
}
const body = mdBody(100000 + i);
d = 0;
for (let k = 0; k < body.length; k += 22) {
pane.handleEvent({ type: "content", text: body.slice(k, k + 22) });
d += 1;
if (d % 6 === 5) await tick();
}
pane.handleEvent({ type: "stream_end" });
const callId = "s" + i;
const item = { call_id: callId, func_name: "bash",
header: "bash: run step " + i, needs_approval: false };
pane.handleEvent({ type: "tool_pending", items: [item] });
pane.handleEvent({ type: "tool_info",
items: [Object.assign({ auto_approved: true }, item)] });
for (let k = 0; k < 24; k++) {
pane.handleEvent({ type: "tool_output_chunk", call_id: callId,
chunk: "line " + k + ": " + sentence(5) + "\\n" });
if (k % 6 === 5) await tick();
}
pane.handleEvent({ type: "tool_result", call_id: callId, name: "bash",
output: "done " + i + "\\n" + sentence(12) });
if (i % 5 === 4) {
const tid = "sa" + i;
const titem = { call_id: tid, func_name: "task_agent",
header: 'task_agent: "subtask ' + i + '"', needs_approval: false };
pane.handleEvent({ type: "tool_pending", items: [titem] });
pane.handleEvent({ type: "tool_info",
items: [Object.assign({ auto_approved: true }, titem)] });
for (let c = 1; c <= 3; c++) {
const cid = tid + "::c" + c;
pane.handleEvent({ type: "tool_pending", items: [{
call_id: cid, parent_call_id: tid, func_name: "search",
header: "search: q" + c, needs_approval: false }] });
pane.handleEvent({ type: "tool_result", call_id: cid,
parent_call_id: tid, name: "search", output: sentence(6) });
}
pane.handleEvent({ type: "tool_result", call_id: tid,
name: "task_agent", output: sentence(15) });
await tick();
}
pane.handleEvent({ type: "state_change", state: "idle" });
await tick();
}
function heapBytes() {
// --js-flags=--expose-gc makes this a real floor, not GC noise.
if (typeof window.gc === "function") {
try { window.gc(); window.gc(); } catch (e) { /* noop */ }
}
return (performance.memory && performance.memory.usedJSHeapSize) || null;
}
const report = {
n: N, turns: TURNS, chunks: CHUNKS, cycles: CYCLES, idle: IDLE,
// Echoed run token — the runner validates it so a straggler POST
// from a killed prior attempt can't be misattributed to this run.
run: q.get("run") || "",
errors: window.__perfErrors,
};
let phase = "mount";
try {
const pane = new InteractivePane("perf-ws");
// ?window= overrides the pane's transcript window (message count),
// e.g. ?window=100000 disables windowing to isolate the
// content-visibility/block-flow effect from the windowing effect.
// Default (0) measures shipped behavior.
const WINDOW = parseInt(q.get("window") || "0", 10);
if (WINDOW > 0) pane._historyWindow = WINDOW;
document.getElementById("mount").appendChild(pane.el);
const msgs = buildHistory(N);
report.heap_start = heapBytes();
phase = "replay";
let t0 = performance.now();
pane.replayHistory(msgs);
report.replay_ms = Math.round(performance.now() - t0);
await tick();
report.nodes_after_replay = pane.messagesEl.querySelectorAll("*").length;
phase = "storm";
t0 = performance.now();
for (let i = 0; i < TURNS; i++) await stormTurn(pane, i);
report.storm_ms = Math.round(performance.now() - t0);
report.storm_ms_per_turn = Math.round(report.storm_ms / TURNS);
phase = "chunkstorm";
const ccItem = { call_id: "cc1", func_name: "bash",
header: "bash: tail -f build.log", needs_approval: false };
pane.handleEvent({ type: "tool_pending", items: [ccItem] });
pane.handleEvent({ type: "tool_info",
items: [Object.assign({ auto_approved: true }, ccItem)] });
t0 = performance.now();
for (let k = 0; k < CHUNKS; k++) {
pane.handleEvent({ type: "tool_output_chunk", call_id: "cc1",
chunk: "log line " + k + "\\n" });
if (k % 6 === 5) await tick();
}
report.chunk_ms = Math.round(performance.now() - t0);
pane.handleEvent({ type: "tool_result", call_id: "cc1", name: "bash",
output: "tail done" });
phase = "idlechurn";
t0 = performance.now();
for (let k = 0; k < IDLE; k++) {
pane.handleEvent({ type: "state_change", state: "running" });
pane.handleEvent({ type: "state_change", state: "idle" });
if (k % 4 === 3) await tick();
}
report.idle_ms = Math.round(performance.now() - t0);
// Leak probe: repeated full replays of the SAME history should
// converge to a flat heap/node/agent-card profile; monotonic growth
// here is retained-detached-DOM (the _agentCards class of bug).
phase = "replaycycles";
report.cycle_stats = [];
for (let c = 0; c < CYCLES; c++) {
t0 = performance.now();
pane.replayHistory(msgs);
const ms = Math.round(performance.now() - t0);
await tick();
report.cycle_stats.push({
replay_ms: ms,
heap: heapBytes(),
nodes: pane.messagesEl.querySelectorAll("*").length,
agent_cards: pane._agentCards ? pane._agentCards.size : 0,
});
}
report.heap_end = heapBytes();
report.longtasks = lt;
document.title = "PERF-READY-" + N;
} catch (e) {
window.__perfErrors.push(
"phase " + phase + ": " + (e && e.message ? e.message : String(e)),
);
report.failed_phase = phase;
report.longtasks = lt;
document.title = "PERF-FAILED-" + phase;
}
document.getElementById("perf-json").textContent =
JSON.stringify(report, null, 2);
if (q.get("post")) {
try {
await fetch("/perf/report", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(report),
});
} catch (e) { /* runner captures the timeout instead */ }
}
</script>
</body>
</html>
"""
# Fixture media for the attachments harness. image/pdf thumbnails and the
# audio clip load via element .src (NOT authFetch), so the --serve dev server
# answers those paths directly with representative bytes: a photo-like image,
@@ -1127,34 +1482,280 @@ def build(out: Path) -> None:
(ta / "livepass.html").write_text(TASKAGENT_TEMPLATE, encoding="utf-8")
print(f"{ta}/livepass.html — task_agent card (real Pane.handleEvent routing)")
pf = out / "perf"
pf.mkdir(parents=True, exist_ok=True)
symlink(pf / "shared", ROOT / "turnstone/shared_static")
symlink(pf / "static", ROOT / "turnstone/ui/static")
(pf / "livepass.html").write_text(PERF_TEMPLATE, encoding="utf-8")
print(f"{pf}/livepass.html — long-session perf baseline (real InteractivePane)")
class _PerfStore:
"""Rendezvous for the perf page's POSTed JSON report."""
def __init__(self) -> None:
import threading
self.event = threading.Event()
self.data: dict[str, object] | None = None
class _HarnessHandler(http.server.SimpleHTTPRequestHandler):
"""Static file server + attachment media fixtures + perf-report sink.
The attachments harness loads thumbnails + the audio clip via element
.src; serve those from generated fixtures, fall through to static for
everything else. The perf harness POSTs its JSON report to /perf/report
when driven with ?post=1 — the --perf runner blocks on ``perf_store``.
"""
perf_store: _PerfStore | None = None
quiet = False
def do_GET(self) -> None: # noqa: N802 (stdlib casing)
blob = _fixture_for(self.path.split("?")[0])
if blob is None:
super().do_GET()
return
data, ctype = blob
self.send_response(200)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_POST(self) -> None: # noqa: N802 (stdlib casing)
store = type(self).perf_store
if self.path.split("?")[0] != "/perf/report" or store is None:
self.send_error(404)
return
length = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(length)
try:
store.data = json.loads(body)
except ValueError:
store.data = {"errors": ["runner: unparseable report body"]}
store.event.set()
self.send_response(204)
self.end_headers()
def log_message(self, format: str, *args: object) -> None: # noqa: A002 (stdlib signature)
if not type(self).quiet:
super().log_message(format, *args)
def _find_chrome() -> str | None:
for name in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser"):
path = shutil.which(name)
if path:
return path
return None
def _await_report(
store: _PerfStore, proc: subprocess.Popen[bytes], run_token: str, timeout: float
) -> dict[str, object] | None:
"""Wait for THIS attempt's report: validated by run token, bailing early
when Chrome exits without reporting (the sandbox-startup-failure case —
waiting the full timeout there cost minutes before the --no-sandbox
fallback could even start). A straggler POST from a previous attempt
(its handler thread can complete after the next attempt cleared the
store) carries the wrong token and is discarded instead of being
misattributed to this run."""
deadline = time.monotonic() + timeout
proc_exited_at: float | None = None
while time.monotonic() < deadline:
if store.event.wait(0.5):
data = store.data
store.event.clear()
store.data = None
if isinstance(data, dict) and data.get("run") == run_token:
return data
continue # stale straggler from a prior attempt — keep waiting
if proc.poll() is not None:
now = time.monotonic()
if proc_exited_at is None:
proc_exited_at = now # grace: an in-flight POST may still land
elif now - proc_exited_at > 3.0:
return None # exited without reporting — try the next attempt
return None
def _perf_run_one(
chrome: str,
out: Path,
port: int,
store: _PerfStore,
n: int,
turns: int,
timeout: float,
extra_query: str = "",
) -> dict[str, object] | None:
"""One headless-Chrome perf pass; returns the page's report or None."""
base_flags = [
"--headless=new",
"--disable-gpu",
"--hide-scrollbars",
"--window-size=1440,900",
"--no-first-run",
"--disable-extensions",
# Throttled timers/rAF in a backgrounded renderer would corrupt the
# measurement — pin the renderer foreground-scheduled.
"--disable-background-timer-throttling",
"--disable-renderer-backgrounding",
"--disable-backgrounding-occluded-windows",
# Stable, real heap numbers (heapBytes() calls window.gc() first).
"--js-flags=--expose-gc",
"--enable-precise-memory-info",
]
for attempt, extra in enumerate(
([], ["--no-sandbox"]) # sandboxed first, container fallback second
):
run_token = f"n{n}-a{attempt}-{uuid.uuid4().hex[:8]}"
url = (
f"http://127.0.0.1:{port}/perf/livepass.html?n={n}&turns={turns}&post=1&run={run_token}"
)
if extra_query:
url += "&" + extra_query.lstrip("&")
store.event.clear()
store.data = None
profile = out / f".chrome-perf-{n}"
proc = subprocess.Popen(
[chrome, *base_flags, *extra, f"--user-data-dir={profile}", url],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
try:
report = _await_report(store, proc, run_token, timeout)
if report is not None:
return report
finally:
if proc.poll() is None:
proc.terminate()
try:
proc.wait(10)
except subprocess.TimeoutExpired:
proc.kill()
return None
def run_perf(
out: Path, sizes: list[int], turns: int, timeout: float, extra_query: str = ""
) -> bool:
"""Build, serve, and run the perf page once per history size; print a table."""
import functools
import threading
chrome = _find_chrome()
if chrome is None:
print("perf: no chrome/chromium binary found on PATH")
return False
store = _PerfStore()
_HarnessHandler.perf_store = store
_HarnessHandler.quiet = True
handler = functools.partial(_HarnessHandler, directory=str(out))
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
port = server.server_address[1]
threading.Thread(target=server.serve_forever, daemon=True).start()
reports: dict[int, dict[str, object]] = {}
try:
for n in sizes:
print(f"perf: n={n} turns={turns}", end="", flush=True)
report = _perf_run_one(chrome, out, port, store, n, turns, timeout, extra_query)
if report is None:
print("FAILED (no report — timeout or chrome startup failure)")
continue
failed = report.get("failed_phase")
errors = report.get("errors") or []
status = f"failed in {failed}" if failed else "ok"
print(f"{status} ({len(errors) if isinstance(errors, list) else '?'} page errors)")
reports[n] = report
(out / f"perf-report-n{n}.json").write_text(
json.dumps(report, indent=2), encoding="utf-8"
)
finally:
server.shutdown()
_HarnessHandler.perf_store = None
_HarnessHandler.quiet = False
if not reports:
return False
_print_perf_table(reports)
print(f"\nraw reports: {out}/perf-report-n*.json")
return True
def _print_perf_table(reports: dict[int, dict[str, object]]) -> None:
sizes = sorted(reports)
def cell(n: int, key: str) -> str:
value = reports[n].get(key)
return "" if value is None else str(value)
def mb(value: object) -> str:
return f"{value / 1048576:.1f}MB" if isinstance(value, (int, float)) else ""
rows: list[tuple[str, list[str]]] = [
("replay_ms (full history build)", [cell(n, "replay_ms") for n in sizes]),
("nodes after replay", [cell(n, "nodes_after_replay") for n in sizes]),
("storm ms/turn (live mix)", [cell(n, "storm_ms_per_turn") for n in sizes]),
("chunk_ms (output chunks)", [cell(n, "chunk_ms") for n in sizes]),
("idle_ms (busy/idle churn)", [cell(n, "idle_ms") for n in sizes]),
("heap start → end", []),
("longtasks count/max_ms", []),
("replay cycles ms", []),
("agent_cards after cycles", []),
]
for n in sizes:
rep = reports[n]
rows[5][1].append(f"{mb(rep.get('heap_start'))}{mb(rep.get('heap_end'))}")
lt = rep.get("longtasks")
rows[6][1].append(f"{lt.get('count')}/{lt.get('max_ms')}" if isinstance(lt, dict) else "")
cycles = rep.get("cycle_stats")
if isinstance(cycles, list) and cycles:
rows[7][1].append(",".join(str(c.get("replay_ms", "?")) for c in cycles))
rows[8][1].append(str(cycles[-1].get("agent_cards", "?")))
else:
rows[7][1].append("")
rows[8][1].append("")
label_w = max(len(label) for label, _ in rows)
col_w = max(14, *(len(f"n={n}") for n in sizes))
header = " " * label_w + " " + " ".join(f"n={n}".rjust(col_w) for n in sizes)
print("\n" + header)
for label, cells in rows:
print(label.ljust(label_w) + " " + " ".join(c.rjust(col_w) for c in cells))
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--out", type=Path, default=Path("/tmp/livepass"))
ap.add_argument("--serve", type=int, metavar="PORT")
ap.add_argument("--perf", action="store_true", help="run the perf baseline and exit")
ap.add_argument(
"--perf-n",
default="300,3000",
help="comma-separated history sizes for --perf (default: 300,3000)",
)
ap.add_argument("--perf-turns", type=int, default=20)
ap.add_argument("--perf-timeout", type=float, default=420.0)
ap.add_argument(
"--perf-extra",
default="",
help="extra query params for the perf page (e.g. 'window=100000' to disable windowing)",
)
args = ap.parse_args()
build(args.out)
if args.perf:
sizes = [int(s) for s in str(args.perf_n).split(",") if s.strip()]
raise SystemExit(
0
if run_perf(args.out, sizes, args.perf_turns, args.perf_timeout, args.perf_extra)
else 1
)
if args.serve:
import functools
import http.server
class _FixtureHandler(http.server.SimpleHTTPRequestHandler):
# The attachments harness loads thumbnails + the audio clip via
# element .src; serve those from generated fixtures, fall through
# to static for everything else.
def do_GET(self) -> None: # noqa: N802 (stdlib casing)
blob = _fixture_for(self.path.split("?")[0])
if blob is None:
super().do_GET()
return
data, ctype = blob
self.send_response(200)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
handler = functools.partial(_FixtureHandler, directory=str(args.out))
handler = functools.partial(_HarnessHandler, directory=str(args.out))
print(f"serving {args.out} on http://localhost:{args.serve}/ — Ctrl+C stops")
http.server.ThreadingHTTPServer(("127.0.0.1", args.serve), handler).serve_forever()
+22
View File
@@ -1735,3 +1735,25 @@ def test_early_paint_screen_reader_announce() -> None:
assert "toolAnnounce(_toolAnnounceText(list))" in body
assert 'block.setAttribute("aria-busy", "true")' in body
assert 'block.removeAttribute("aria-busy")' in body
def test_global_stream_recovery_floor_and_render_coalescing() -> None:
"""Perf-audit P0/P1 for the Tier-1 global stream. The server's recovery
events for a truncated reconnect gap (``node_snapshot`` as the floor,
``replay_truncated`` as the marker) used to fall through the handler
silently — workstreams created during a long hidden-tab gap never
rendered again, and missed ``ws_closed`` left ghost rows forever. A
malformed frame is the same permanent drift (the cursor advances before
the parse), so it resyncs too. ``fireRender`` is rAF-coalesced: every
``ws_state`` (≥2 per tool round per workstream) used to trigger a
synchronous full rail rebuild."""
body = _APP_JS.read_text(encoding="utf-8")
assert 'data.type === "node_snapshot"' in body
assert 'data.type === "replay_truncated"' in body
assert "function applyRosterSnapshot(" in body
assert "function resyncRoster(" in body
assert "malformed frame" in body
fire = body.index("function fireRender()")
assert "requestAnimationFrame(" in body[fire : fire + 700], (
"fireRender must coalesce subscriber repaints to one per frame"
)
+18
View File
@@ -150,3 +150,21 @@ def test_warning_and_verdict_normalize_risk() -> None:
assert "normalizeRiskLevel(a.risk_level)" in body, "warning must normalize"
assert '"conv-warning conv-warning--" + risk' in body
assert 'badge.classList.add("conv-verdict--" + risk)' in body
def test_unbounded_render_inputs_are_capped() -> None:
"""Perf-audit P0: the two builders that used to render unbounded input.
The diff preview caps rendered lines and appends incrementally — the old
single ``diff.append(...nodes)`` spread threw RangeError past engine
spread-arity limits, killing the tool card (and the approval gate) for
the batch. The raw result body clamps at RAW_CAP so one multi-MB tool
output can't become a multi-MB pre-wrap text node rebuilt on every
re-render."""
body = _body()
assert "MAX_PREVIEW_LINES" in body
assert "diff.append(...nodes)" not in body, (
"preview nodes must append incrementally, not via one spread call"
)
assert "more preview lines not shown" in body
assert "RAW_CAP" in body
assert "truncated for display" in body
+165
View File
@@ -245,3 +245,168 @@ def test_controller_terminal_dead_state() -> None:
assert "base: base," in body, "the controller must expose its transport base"
# Dead controllers don't reconnect on re-auth.
assert "if (connected && !dead) pane._loadHistoryThenConnect(wsId);" in body
def test_stream_pipeline_is_wedge_proof() -> None:
"""Long-session hardening (perf audit P0): the SSE pipeline must not be
able to permanently wedge the pane. ``onmessage`` guards BOTH the
``JSON.parse`` and the ``handleEvent`` dispatch (an exception escaping it
doesn't close the EventSource, so an unguarded throw left the streaming
refs poisoned for the rest of the session), and ``stream_end`` resets the
segment refs BEFORE the finalize render, with a plain-text fallback —
with the old order a finalize throw skipped the clears and every later
delta painted into the dead segment."""
body = _INTERACTIVE.read_text(encoding="utf-8")
assert "dropping malformed SSE frame" in body
assert "handleEvent failed for" in body
case = body.index('case "stream_end"')
seg = body[case : body.index("break;", case)]
clears = seg.index("this.currentAssistantBodyEl = null;")
finalize = seg.index("streamingRenderFinalize(")
assert clears < finalize, (
"stream_end must clear segment refs BEFORE finalize — the old "
"finalize-first order wedged all later assistant output on a throw."
)
assert "doneBodyEl.textContent = doneBuffer;" in seg
def test_rebuild_quiesces_live_events_and_releases_agent_tracking() -> None:
"""clear_ui / replay_truncated re-render race (perf audit P0): live SSE
events painted between the history snapshot and ``replaceChildren()``
were wiped with no redelivery, and streaming refs kept pointing at
detached nodes. Pinned: the quiesce queue sits on the handleEvent hot
path, both re-render triggers arm it, ``replayHistory`` resets the
streaming refs and clears the agent-card/orphan maps (the detached-DOM
retention leak), and the mid-stream guard covers the reasoning bubble."""
body = _INTERACTIVE.read_text(encoding="utf-8")
assert "this._replayQueue.events.push(evt);" in body
assert body.count("this._beginReplayQuiesce(") >= 2, (
"both clear_ui and replay_truncated must arm the quiesce"
)
assert "!this.currentAssistantEl && !this.currentReasoningEl" in body
replay = body.index("replayHistory(messages) {")
seg = body[replay : replay + 1600]
for line in (
"this._resetStreamingRefs();",
"this._clearAgentTracking();",
):
assert line in seg, f"replayHistory must reset: {line!r}"
assert "this._agentCards.clear();" in body
# Review-hardened lifecycle: the card entry SURVIVES the terminal
# tool_result (a late child event finding no Map entry would rebuild a
# duplicate empty card beside the finished one), and transport-only
# reconnects preserve the maps + any armed quiesce queue — clearing them
# in disconnectSSE duplicated cards and dropped buffered orphan steps on
# every transient stream blip. Full-reload cleanup lives in
# _loadHistoryThenConnect; terminal cleanup in the factory's destroy().
assert "this._agentCards.delete(callId);" not in body
disc = body.index("disconnectSSE() {")
disc_seg = body[disc : body.index("_loadHistoryThenConnect(wsId) {", disc)]
assert "this._clearAgentTracking();" not in disc_seg
assert "this._replayQueue = null;" not in disc_seg
load = body.index("_loadHistoryThenConnect(wsId) {")
load_seg = body[load : load + 2200]
assert "this._clearAgentTracking();" in load_seg
assert "this._replayQueue = null;" in load_seg
# A mid-stream replay_truncated DEFERS the re-sync (flag consumed on the
# idle edge) instead of dropping it — skipping left the lost-event gap
# unrepaired for the rest of the session.
assert "this._pendingTruncatedResync = true;" in body
# The refetch FAILURE branch resets streaming refs too — it never reaches
# replayHistory, and stale refs there streamed the retried generation's
# first segment into a detached bubble.
fail = body.index("Failure path never reaches replayHistory")
assert "this._resetStreamingRefs();" in body[fail : fail + 400], (
"the refetch failure branch must reset streaming refs"
)
def test_per_token_hot_path_avoids_container_scans() -> None:
"""P1 (perf audit): per-token work must stay O(1) in transcript length.
The thinking indicator is an instance ref (the class-selector miss walked
the whole transcript on EVERY content/reasoning delta); near-bottom state
comes from the passive scroll listener instead of a forced-layout
geometry read per event; the scroll pin is rAF-coalesced; per-tool
row/stream lookups resolve through the self-healing caches."""
body = _INTERACTIVE.read_text(encoding="utf-8")
stripped = _strip_comments(body)
assert 'querySelector(".thinking-indicator")' not in stripped, (
"thinking indicator must use the instance ref, not a container scan"
)
assert "this._thinkingEl" in body
near = body.index("isNearBottom() {")
assert "return this._nearBottom;" in body[near : near + 700]
assert "passive: true" in body
# The rAF pin re-checks the flag AT FIRE TIME (a user scroll landing in
# the schedule→rAF window must win over a stale pin), with force
# requests latched across the coalescing window; resizes re-derive the
# flag via ResizeObserver since they move the bottom without a scroll.
assert "this._scrollPinForce = false;" in body
assert "ResizeObserver" in body
for helper in ("_toolRow(callId) {", "_streamEl(callId) {"):
assert helper in body, f"missing lookup-cache helper: {helper!r}"
_INTERACTIVE_CSS = _ROOT / "turnstone/shared_static/interactive.css"
_UI_STYLE_CSS = _ROOT / "turnstone/ui/static/style.css"
def test_transcript_scroller_is_block_flow_with_containment() -> None:
"""P2 (perf audit): the messages scroller is BLOCK flow — a column
flexbox relayouts every row when the streaming row's height changes,
O(rows) per token — with native scroll anchoring disabled (the pane owns
bottom pinning, and the browser's anchor node lives inside the
innerHTML-replaced live bubble). Off-screen rows carry
content-visibility:auto with `auto`-keyword intrinsic sizing; the live
tail (last two children) is exempt so the streaming bubble never toggles
skip-state mid-stream."""
css = _INTERACTIVE_CSS.read_text(encoding="utf-8")
rule = css.index(".pane--embedded .pane-messages {")
body = css[rule : css.index("}", rule)]
assert "display: flex" not in body, "scroller must be block flow"
assert "overflow-anchor: none" in body
assert ".pane--embedded .pane-messages > * + *" in css, (
"inter-row rhythm must come from sibling margins, not flex gap"
)
assert "content-visibility: auto" in css
assert "contain-intrinsic-size: auto" in css
assert ":nth-last-child(-n + 2)" in css, "live tail must be exempt"
ui = _UI_STYLE_CSS.read_text(encoding="utf-8")
ui_rule = ui.index(".pane-messages {")
ui_body = ui[ui_rule : ui.index("}", ui_rule)]
assert "display: flex" not in ui_body, "ui/static duplicate must match"
assert "overflow-anchor: none" in ui_body
def test_transcript_is_windowed_with_pager() -> None:
"""P2 (perf audit): full re-renders paint only the most recent
_HISTORY_WINDOW_STEP messages, cut FORWARD to a user-turn boundary so an
assistant tool_calls message is never split from the tool results that
anchor to it; hidden content sits behind the .msg-history-pager button
(click grows the window and refetches with a scroll-anchor restore).
Live appends are bounded at the idle edge by _LIVE_ROW_CAP, trimming
only while pinned (a scrolled-up user is reading the rows a trim would
remove) and sweeping detached agent-card entries."""
body = _INTERACTIVE.read_text(encoding="utf-8")
assert "const _HISTORY_WINDOW_STEP = 300;" in body
assert "const _LIVE_ROW_CAP = 900;" in body
replay = body.index("replayHistory(messages) {")
seg = body[replay : replay + 4200]
assert 'messages[start].role !== "user"' in seg, (
"the window cut must land on a user-turn boundary"
)
assert "_addHistoryPager" in seg
assert "for (let i = start; i < messages.length; i++)" in seg
assert 'pager.className = "msg-history-pager";' in body
assert "this._historyWindow += _HISTORY_WINDOW_STEP;" in body
trim = body.index("_trimLiveTranscript() {")
trim_seg = body[trim : trim + 2600]
assert "if (!this._nearBottom) return;" in trim_seg, (
"live trim must only run while pinned to the bottom"
)
assert "card.wrap.isConnected" in trim_seg, "live trim must sweep detached agent-card entries"
# Rewind/edit turn math is tail-relative (counts user rows at-or-AFTER
# the clicked one), which is what makes hiding EARLIER rows safe — pin
# the tail-relative form so a refactor to absolute indexing fails here
# and gets re-checked against windowing.
assert body.count("userMsgs.length - idx") >= 2
+38
View File
@@ -1511,3 +1511,41 @@ def test_link_url_with_ampersand_not_double_escaped() -> None:
"Expected single &amp; encoding for `&`; got:\n" + out
)
assert "&amp;amp;" not in out
def test_render_markdown_depth_capped_and_throw_safe() -> None:
"""Perf-audit P0: ``renderMarkdown`` recurses for blockquote/callout
bodies, and a few KB of nested ``"> "`` used to overflow the call stack
mid-render. The exported wrapper depth-caps the recursion (bailing to
escaped text) and keeps the ``_fnDepth`` accounting in a try/finally so a
body throw can't strand it elevated (which froze ``_fnScopeId`` and
collided footnote ids for every later message)."""
body = _RENDERER_JS.read_text(encoding="utf-8")
assert "var _MD_MAX_DEPTH" in body
assert "_fnDepth >= _MD_MAX_DEPTH" in body
wrapper = body.index("export function renderMarkdown(text)")
seg = body[wrapper : body.index("function _renderMarkdownBody(text)")]
assert "try {" in seg and "finally {" in seg and "_fnDepth--;" in seg, (
"depth accounting must ride a try/finally in the wrapper"
)
def test_streaming_apply_marks_buffer_only_on_success() -> None:
"""Perf-audit P0: ``_streamingRenderApply`` must set
``el._lastRenderedBuffer`` only AFTER a successful render, with a
plain-text fallback on throw. Marking before the render made an errored
frame look done — the finalize short-circuit then pinned the broken DOM
forever. The mermaid chain must also be rejection-proof (a sync throw in
a settle handler used to leave every later diagram stuck at 'Loading
diagram…')."""
body = _RENDERER_JS.read_text(encoding="utf-8")
apply_at = body.index("function _streamingRenderApply")
seg = body[apply_at : apply_at + 2000]
render_at = seg.index("renderMarkdown(buffer)")
mark_at = seg.index("el._lastRenderedBuffer = buffer;")
assert render_at < mark_at, "buffer must be marked rendered only on success"
assert "el.textContent = buffer;" in seg
chain_at = body.index("_mermaidRenderChain = _mermaidRenderChain")
assert ".catch(function (e) {" in body[chain_at : chain_at + 3500], (
"every mermaid chain link must settle back to fulfilled"
)
+14 -2
View File
@@ -21,6 +21,10 @@ window.onLoginSuccess = function () {
}
};
window.onLogout = function () {
if (sseReconnectTimer) {
clearTimeout(sseReconnectTimer);
sseReconnectTimer = null;
}
if (evtSource) {
evtSource.close();
evtSource = null;
@@ -67,6 +71,10 @@ let currentView = "home"; // "home" | "overview" | "filtered" | "admin"
let currentFilter = { state: null, node: null, page: 1, per_page: 50 };
let evtSource = null;
let retryDelay = 1000;
// Pending reconnect handle — tracked so logout (and a fresh connectSSE) can
// cancel it; an untracked timer fired post-logout and opened a new
// EventSource that 401s and re-probes in a loop.
let sseReconnectTimer = null;
let clusterState = null;
let _navigatingFromPopstate = false;
@@ -346,6 +354,10 @@ function _fireRenderSubs() {
// --- SSE Connection ---
function connectSSE() {
if (sseReconnectTimer) {
clearTimeout(sseReconnectTimer);
sseReconnectTimer = null;
}
if (evtSource) {
evtSource.close();
evtSource = null;
@@ -380,11 +392,11 @@ function connectSSE() {
showLogin();
return;
}
setTimeout(connectSSE, retryDelay);
sseReconnectTimer = setTimeout(connectSSE, retryDelay);
retryDelay = Math.min(retryDelay * 2, 30000);
})
.catch(function () {
setTimeout(connectSSE, retryDelay);
sseReconnectTimer = setTimeout(connectSSE, retryDelay);
retryDelay = Math.min(retryDelay * 2, 30000);
});
};
@@ -2207,31 +2207,64 @@ function createCoordinatorPane(root, wsId, opts) {
// Transient errors (network blips, intermediary timeouts) just
// let native reconnect run — no scheduleReconnect needed
// because the source isn't dead.
var probe = typeof authFetch === "function" ? authFetch : fetch;
probe("/v1/api/workstreams/" + encodeURIComponent(wsId)).then(
function (r) {
if (r.status === 401 && typeof showLogin === "function") {
try {
if (evtSource) evtSource.close();
} catch (_) {
/* noop */
}
evtSource = null;
// Cancel the pending CLOSED-state recovery timer (set
// below). Without this, 5 s later the timer would
// observe ``!evtSource`` and call ``scheduleReconnect``,
// which would open a new EventSource that gets 401 again
// → infinite reconnect loop while the login overlay is
// up. The login flow re-arms ``connectSSE`` after a
// successful sign-in via its own callback path.
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
showLogin("Session expired. Please sign in to reconnect.");
}
},
);
// Raw fetch (not authFetch) — need to inspect status before throwing.
// authFetch never RESOLVES with a 401 (it calls showLogin() itself and
// throws Error("auth")), so probing through it made this branch dead
// code: the close/cancel-timer handling below never ran and the
// CLOSED-state recovery kept cycling scheduleReconnect behind the
// login overlay — exactly the loop this branch exists to prevent.
// Mirrors the app.js dashboard probe. ``.catch``: a network-dead
// probe is the transient case; native/manual reconnect owns it.
//
// The 401 body is inspected BEFORE the generic-expiry handling: a
// code=version_mismatch body must take auth.js's upgrade path
// (reload-after-re-login flag + "upgrade" overlay). The old authFetch
// probe did that as a side effect of authFetch's own 401 handling; a
// raw fetch must do it explicitly or a server upgrade leaves stale
// pre-upgrade JS running after sign-in. NOTE the positive-form guard
// (r.status === 401) directly above the close(): the reconnect-
// contract pin (test_app_js._onerror_preserves_native_reconnect) keys
// on that marker within a short window to allow a terminal close.
fetch("/v1/api/workstreams/" + encodeURIComponent(wsId))
.then(function (r) {
if (!(r.status === 401 && typeof showLogin === "function")) return;
return r
.json()
.catch(function () {
return null;
})
.then(function (body) {
try {
if (evtSource) evtSource.close();
} catch (_) {
/* noop */
}
evtSource = null;
// Cancel the pending CLOSED-state recovery timer (set
// below). Without this, 5 s later the timer would
// observe ``!evtSource`` and call ``scheduleReconnect``,
// which would open a new EventSource that gets 401 again
// → infinite reconnect loop while the login overlay is
// up. The login flow re-arms ``connectSSE`` after a
// successful sign-in via its own callback path.
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (
body &&
body.code === "version_mismatch" &&
typeof noteVersionMismatch === "function"
) {
noteVersionMismatch();
} else {
showLogin("Session expired. Please sign in to reconnect.");
}
});
})
.catch(function () {
/* transient network failure — reconnect machinery handles it */
});
// CLOSED-state recovery: native auto-reconnect covers the
// transient case (source stays in CONNECTING and eventually
// re-opens). But if the browser gives up — hard 4xx after
@@ -3535,13 +3568,7 @@ function createCoordinatorPane(root, wsId, opts) {
// pending count is maintained incrementally on cache mutations
// (see ``pendingApprovalIds`` near the cache definition) so this
// is O(1) per render rather than an O(N) walk over the cache.
const pending = pendingApprovalIds.size;
childrenCountEl.textContent = rows.length
? "(" +
rows.length +
(pending > 0 ? " · " + pending + " pending" : "") +
")"
: "";
_refreshChildrenCount();
_restoreRowFocus(childrenTreeEl, focusKey);
}
@@ -3562,13 +3589,32 @@ function createCoordinatorPane(root, wsId, opts) {
const replacement = renderChildRow(entry);
row.replaceWith(replacement);
const obs = _getChildObserver();
if (obs) obs.observe(replacement);
if (obs) {
// Release the detached row from the persistent observer — this is
// now the hot path (every child_ws_state tick), and observed-but-
// detached rows are strong refs that would accumulate without bound
// between full renders (which reset targets via disconnect()).
obs.unobserve(row);
obs.observe(replacement);
}
_restoreRowFocus(replacement, focusKey);
// Keep the "(N · x pending)" annotation live on the targeted path —
// approval edges arrive as state ticks now that child_ws_state no
// longer takes the full render.
_refreshChildrenCount();
} else {
renderChildren();
}
}
function _refreshChildrenCount() {
const total = childrenState.size;
const pending = pendingApprovalIds.size;
childrenCountEl.textContent = total
? "(" + total + (pending > 0 ? " · " + pending + " pending" : "") + ")"
: "";
}
function renderTaskRow(task) {
const row = document.createElement("div");
row.className = "task-row";
@@ -3855,6 +3901,12 @@ function createCoordinatorPane(root, wsId, opts) {
ws_id: childId,
name: "",
};
// Terminal-bucket membership BEFORE the mutation: the tree sort keys on
// it (non-terminal first), so a state tick that crosses the boundary
// needs the full re-sorting render; everything else takes the targeted
// single-row path below.
const wasTerminal =
existing.state === "closed" || existing.state === "deleted";
existing.state = ev.state || existing.state;
existing.activity_state =
typeof ev.activity_state === "string"
@@ -3924,7 +3976,18 @@ function createCoordinatorPane(root, wsId, opts) {
? cached.sseUpdatedAt || 0
: 0,
});
renderChildren();
// child_ws_state is the HIGHEST-frequency child event (a tick per state/
// activity change of every child) — route it through the targeted
// single-row update instead of the full-tree rebuild. The full render
// (sort + replaceChildren + observer re-observe of every row) is
// reserved for membership/sort-order changes: a terminal-bucket
// crossing here, and created/closed/rename in their own handlers.
// _updateChildRow falls back to renderChildren() itself when the row
// isn't painted yet (a brand-new child).
const isTerminal =
existing.state === "closed" || existing.state === "deleted";
if (wasTerminal !== isTerminal) renderChildren();
else _updateChildRow(childId);
// Do NOT invalidateLiveBadge on routine state ticks — that
// defeats the 5s TTL cache and devolves rate-limiting to the
// 250ms debouncer. The TTL check in scheduleLiveFetch handles
+10
View File
@@ -47,6 +47,15 @@ if (_authChannel) {
};
}
// Raw-fetch callers (the SSE-error probes, which must inspect a 401's status
// without authFetch's throw-on-401 contract) route a version_mismatch body
// here so the post-re-login reload still picks up the new assets — the same
// flag+overlay path authFetch takes below.
export function noteVersionMismatch() {
_authUpgradeReload = true;
showLogin("upgrade");
}
export async function authFetch(url, opts) {
const maxRetries = 2;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
@@ -813,4 +822,5 @@ Object.assign(window, {
hideLogin,
logout,
initLogin,
noteVersionMismatch,
});
+19 -2
View File
@@ -95,6 +95,9 @@ export function createQueueController(opts) {
typeof opts.onAfterDequeue === "function" ? opts.onAfterDequeue : null;
var onIdle = typeof opts.onIdle === "function" ? opts.onIdle : null;
var onNotice = typeof opts.onNotice === "function" ? opts.onNotice : null;
// Live queued bubbles — the idle sweep iterates this instead of querying
// the whole messages container (see onIdleEdge).
var _liveQueued = new Set();
// Upper bound on the dequeue DELETE so a wedged proxied node (the exact
// case this flow targets) can't leave a card stuck "dismissing" forever.
var DELETE_TIMEOUT_MS = 15000;
@@ -199,6 +202,7 @@ export function createQueueController(opts) {
host.appendChild(dismiss);
messagesEl.appendChild(el);
_liveQueued.add(el);
_scrollIntoView();
return el;
}
@@ -319,6 +323,7 @@ export function createQueueController(opts) {
}
function remove(el) {
_liveQueued.delete(el);
if (el && el.parentNode) el.remove();
}
@@ -329,6 +334,7 @@ export function createQueueController(opts) {
// cancelled, so present it as sent. If the user had clicked × first
// (dismissAttempted), tell them it was too late.
function _promote(el) {
_liveQueued.delete(el);
var attempted = el.dataset.dismissAttempted;
el.classList.remove("msg-queued", "msg-queued-important");
delete el.dataset.msgId;
@@ -351,8 +357,19 @@ export function createQueueController(opts) {
// onIdle hook so the consumer can run edge-only cleanup (e.g. clearing
// cancel/force-stop timers).
function onIdleEdge() {
var queued = messagesEl.querySelectorAll(".msg-queued:not([aria-busy])");
queued.forEach(_promote);
// Sweep the controller-local live set, not the DOM: the old
// ".msg-queued:not([aria-busy])" query walked every element under the
// messages container (O(transcript) per busy→idle edge) to find the
// handful of queued bubbles that always sit in the tail. Bubbles wiped
// by a full re-render prune lazily via the isConnected check.
_liveQueued.forEach(function (el) {
if (!el.isConnected) {
_liveQueued.delete(el);
return;
}
if (el.hasAttribute("aria-busy")) return; // mid-dequeue — let it settle
_promote(el);
});
if (onIdle) onIdle();
}
+10
View File
@@ -293,6 +293,16 @@
.conv-diff-warn {
color: var(--warn);
}
/* Preview-omission notice rendered as a SIBLING below the .conv-row-diff
scroll box (never inside it, where the 240px fold hides it). Neutral ink,
not --warn: informational omission, and AA-safe on both themes. */
.conv-diff-omit {
margin-top: 2px;
padding: 2px 10px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--ink-3);
}
/* Verdict badge interactive's rich shape (risk + rec + conf, expandable
detail) in the coordinator's neutral idiom. Risk drives the left-stripe
+41 -6
View File
@@ -273,10 +273,19 @@ export function buildConvCmd(item) {
if (item.preview) {
const diff = document.createElement("div");
diff.className = "conv-row-diff";
const lines = stripAnsi(item.preview).split("\n");
const nodes = [];
// The preview is uncapped upstream (a whole multiline command / one line
// per edited line) — cap what we RENDER: past ~400 lines the preview
// carries no decision value, the DOM cost is ~2 nodes/line in every
// transcript row, and an argument-spread append of an unbounded node
// list can throw RangeError mid-paint (engines cap spread arity around
// 65k args), killing the tool card — and the approval gate — for the
// batch. Appended incrementally for the same reason.
const MAX_PREVIEW_LINES = 400;
let lines = stripAnsi(item.preview).split("\n");
const omitted = lines.length - MAX_PREVIEW_LINES;
if (omitted > 0) lines = lines.slice(0, MAX_PREVIEW_LINES);
lines.forEach((line, i) => {
if (i > 0) nodes.push("\n");
if (i > 0) diff.appendChild(document.createTextNode("\n"));
const trimmed = line.trim();
let cls = null;
if (trimmed.startsWith("-")) cls = "conv-diff-del";
@@ -286,13 +295,25 @@ export function buildConvCmd(item) {
const span = document.createElement("span");
span.className = cls;
span.textContent = line;
nodes.push(span);
diff.appendChild(span);
} else {
nodes.push(line);
diff.appendChild(document.createTextNode(line));
}
});
diff.append(...nodes);
frag.appendChild(diff);
// The omission notice sits BELOW the scroll box as a sibling, not as the
// diff's last child: .conv-row-diff is a 240px inner scroller, so an
// inline marker would sit thousands of pixels below its fold — invisible
// exactly at the approval moment, where the operator must know the
// preview is partial. Its own neutral class (not .conv-diff-warn):
// an omission is informational, not a command warning, and raw --warn
// fails AA on the light panel background.
if (omitted > 0) {
const more = document.createElement("div");
more.className = "conv-diff-omit";
more.textContent = "… " + omitted + " more preview lines not shown";
frag.appendChild(more);
}
}
return frag;
}
@@ -596,6 +617,20 @@ export function buildConvResult(output, opts) {
}
}
}
// Clamp the rendered body — same rationale as the JSON pretty-print cap
// above: the server ships tool output verbatim, and a single multi-MB
// result (an agent cat-ing a large file) becomes a multi-MB pre-wrap text
// node that stalls layout on insert and is rebuilt on every full
// re-render. The transcript shows the head; the full output stays in
// history/storage.
const RAW_CAP = 64 * 1024;
if (pretty.length > RAW_CAP) {
pretty =
pretty.slice(0, RAW_CAP) +
"\n… (" +
pretty.length.toLocaleString() +
" chars total — truncated for display)";
}
const body = document.createElement("span");
body.textContent = pretty;
block.appendChild(body);
+49 -15
View File
@@ -38,22 +38,56 @@
min-height: 0;
overflow-y: auto;
padding: 13px;
display: flex;
flex-direction: column;
/* Trimmed from 5px: the .msg turn boxes already carry a 4px margin-bottom, so
a 5px flex gap stacked ~9px of dead space between segments ("too thick").
2px gap + the 4px element margin lands at a compact ~6px between turns. */
gap: 2px;
/* BLOCK flow, deliberately not a flex column: a column flexbox relayouts
ALL items when the streaming row's height changes O(rows) per token at
long-session scale where block flow dirties only the appended tail.
Block flow also retires the flex min-height:auto squish hazard the old
per-child flex-shrink pin existed to suppress. Inter-row rhythm moves
to the sibling margin below (2px + the .msg 4px margin-bottom lands at
the same compact ~6px between turns as the old 2px gap). */
/* Native scroll anchoring is pure overhead here: the pane owns bottom
pinning (isNearBottom + rAF pin), and during streaming the anchor node
the browser picks sits inside the innerHTML-replaced live bubble
forcing anchor re-selection every frame and double-adjusting against
our pin. */
overflow-anchor: none;
}
/* The message list is a SCROLLING flex column (overflow-y:auto), so its children
must size to content and never shrink. Without this, an `overflow:hidden`
card the .conv-batch tool block has its flex `min-height:auto` resolve to
0 and gets squished to a ~2px stripe (just its border) once the column fills,
while plain .msg blocks (overflow visible) keep their height. That asymmetry
is the "tool calls collapse to an empty stripe" regression; pinning every row
makes the column scroll instead. */
.pane--embedded .pane-messages > * {
flex-shrink: 0;
.pane--embedded .pane-messages > * + * {
margin-top: 2px;
}
/* Off-screen rows skip style/layout/paint entirely; contain-intrinsic-size's
`auto` keyword remembers each row's last-rendered size, so scrollHeight
(and the bottom pin) stays stable once a row has painted the estimate
only covers never-rendered rows during upward scrubbing. The last two
children are exempt: the live tail (streaming bubble / filling tool batch)
mutates constantly and must never toggle skip-state mid-stream. */
.pane--embedded .pane-messages > .msg:not(:nth-last-child(-n + 2)) {
content-visibility: auto;
contain-intrinsic-size: auto 80px;
}
.pane--embedded .pane-messages > .conv-batch:not(:nth-last-child(-n + 2)) {
content-visibility: auto;
contain-intrinsic-size: auto 200px;
}
/* "Load earlier" pager the windowed transcript's top affordance. A real
button (keyboard/AT reachable); quiet dashed chrome so it reads as an
affordance, not a message row. */
.msg-history-pager {
display: block;
width: 100%;
padding: 6px 12px;
background: var(--panel-2);
color: var(--fg-dim);
border: 1px dashed var(--hair);
border-radius: var(--r-sm);
font-family: var(--font-ui);
font-size: 12px;
cursor: pointer;
}
.msg-history-pager:hover,
.msg-history-pager:focus-visible {
color: var(--fg);
border-color: var(--accent);
}
.pane--embedded .ws-status-bar {
flex-shrink: 0;
+499 -71
View File
@@ -69,6 +69,17 @@ const _AGENT_ORPHAN_CAP = 256;
// The ordering race the buffer targets resolves within a frame, far inside it.
const _AGENT_ORPHAN_GRACE_MS = 500;
// Transcript window: a full re-render paints only the most recent
// _HISTORY_WINDOW_STEP messages (cut forward to a turn boundary) behind a
// "load earlier" pager; each pager click grows the window by another step
// and re-fetches. Live appends are bounded separately: once the rendered
// row count passes _LIVE_ROW_CAP, the idle-edge trim removes the oldest
// rows (again to a turn boundary) — trimmed content stays in /history and
// comes back through the pager. ~3 rows per message keeps the two caps in
// the same ballpark.
const _HISTORY_WINDOW_STEP = 300;
const _LIVE_ROW_CAP = 900;
function getVoiceRoles(base) {
base = base || "";
if (!_voiceRolesPromises[base]) {
@@ -205,6 +216,32 @@ class Pane {
this.projectName = "";
this._lastStatusEvt = null;
this._historyLoadToken = 0;
// Event backlog while a clear_ui / replay_truncated rebuild is in
// flight — see _beginReplayQuiesce. {token, events[]} or null.
this._replayQueue = null;
// Hot-path caches — all invalidated by _clearAgentTracking/replayHistory.
// _nearBottom mirrors the scroller position via a passive scroll listener
// (no per-token geometry reads); the two Maps make per-event row/stream
// lookups O(1) instead of whole-transcript attribute-selector scans.
this._nearBottom = true;
this._scrollPinPending = false;
this._scrollPinForce = false;
this._thinkingEl = null;
this._retryHolderEl = null;
this._toolRowIndex = new Map();
this._streamElIndex = new Map();
this._resizeObs = null;
// Set when replay_truncated arrives mid-stream (refetching then would
// detach the live bubble); consumed on the next idle edge.
this._pendingTruncatedResync = false;
// Transcript window (messages rendered per replay) — grows by
// _HISTORY_WINDOW_STEP per pager click, resets on ws (re)assignment.
// _hiddenEarlier counts the messages above the window after a replay;
// the approx flag marks live-trim hides, whose message count is unknown
// (rows ≠ messages), so the pager label drops the number.
this._historyWindow = _HISTORY_WINDOW_STEP;
this._hiddenEarlier = 0;
this._hiddenEarlierApprox = false;
this._cancelTimeout = null;
this._forceTimeout = null;
this._pendingEditSend = null;
@@ -254,6 +291,14 @@ class Pane {
this.evtSource.close();
this.evtSource = null;
}
// Deliberately NOT cleared here: _agentCards/_agentOrphans and any armed
// _replayQueue. disconnectSSE also runs for transport-only reconnects
// (connectSSE's first line, the host's 5s recovery beat) where the DOM
// survives — wiping the card map there made the next child event build a
// DUPLICATE agent card beside the still-attached one, and cancelling
// orphan grace timers silently dropped buffered steps. Ws-switch and
// full-reload cleanup happens in _loadHistoryThenConnect; terminal
// cleanup in the factory's destroy().
this._stopRecording(true);
this._stopTTS();
}
@@ -298,17 +343,22 @@ class Pane {
}
addThinkingIndicator() {
if (this.messagesEl.querySelector(".thinking-indicator")) return;
// Instance ref, not a container query: removeThinkingIndicator runs on
// EVERY content/reasoning delta, and a class-selector miss walks the
// whole transcript subtree — O(N) per streamed token at 5000 messages.
if (this._thinkingEl) return;
const el = document.createElement("div");
el.className = "thinking-indicator";
el.textContent = "Thinking";
this._thinkingEl = el;
this.messagesEl.appendChild(el);
this.scrollToBottom();
}
removeThinkingIndicator() {
const el = this.messagesEl.querySelector(".thinking-indicator");
if (el) el.remove();
if (!this._thinkingEl) return;
this._thinkingEl.remove();
this._thinkingEl = null;
}
addSystemNudgeMarker() {
@@ -440,18 +490,9 @@ class Pane {
// announceToolBlock.
const stick = this.isNearBottom();
const escapedId = callId ? CSS.escape(callId) : "";
let el = escapedId
? this.messagesEl.querySelector(
'.tool-output-stream[data-call-id="' + escapedId + '"]',
)
: null;
let el = this._streamEl(callId);
if (!el) {
let target = escapedId
? this.messagesEl.querySelector(
'.conv-row[data-call-id="' + escapedId + '"]',
)
: null;
let target = this._toolRow(callId);
if (!target) {
// A namespaced sub-agent child id ("<parent>::<id>") whose row hasn't
// nested yet must NOT graft its stream onto the last top-level batch —
@@ -479,19 +520,26 @@ class Pane {
el.setAttribute("aria-live", "off");
el.textContent = "";
target.after(el);
if (callId) this._streamElIndex.set(callId, el);
}
el.appendChild(document.createTextNode(stripped));
el.scrollTop = el.scrollHeight;
// rAF-coalesced inner pin: the eager scrollTop=scrollHeight after every
// text append forced one whole-page reflow per chunk (geometry read on a
// just-dirtied layout). One pin per frame is visually identical.
if (!el._pinPending) {
el._pinPending = true;
requestAnimationFrame(() => {
el._pinPending = false;
el.scrollTop = el.scrollHeight;
});
}
this.scrollToBottom(stick);
}
showOutputWarning(evt) {
if (!evt.call_id || evt.risk_level === "none") return;
const escapedId = CSS.escape(evt.call_id);
const toolDiv = this.messagesEl.querySelector(
'.conv-row[data-call-id="' + escapedId + '"]',
);
const toolDiv = this._toolRow(evt.call_id);
if (!toolDiv) return;
// Shared DOM-builder with replayHistory \u2014 single source of truth for
// role / class / escape semantics. Argument shape mirrors the
@@ -520,7 +568,15 @@ class Pane {
updateVerdictBadge(verdict) {
if (!verdict || !verdict.call_id) return;
const escapedId = CSS.escape(verdict.call_id);
const badge = this.messagesEl.querySelector(
// Badges anchor either inside the row (solo verdicts, replay) or at the
// batch-block level (judge-pending panels) — scope the query to the
// row's batch, which covers both, instead of scanning the whole
// transcript per verdict event. Row-less lookups (row already replaced
// by output) fall back to the container scan so the late-verdict toast
// path keeps working.
const vRow = this._toolRow(verdict.call_id);
const vScope = (vRow && vRow.closest(".conv-batch")) || this.messagesEl;
const badge = vScope.querySelector(
'.conv-verdict[data-call-id="' + escapedId + '"]',
);
if (!badge) {
@@ -629,18 +685,40 @@ class Pane {
}
isNearBottom() {
return (
this.messagesEl.scrollHeight -
this.messagesEl.scrollTop -
this.messagesEl.clientHeight <
80
);
// Cached from the passive scroll listener (_createDOM) instead of read
// from geometry: the old scrollHeight/scrollTop/clientHeight triplet
// forced a synchronous layout of the whole transcript, and this runs on
// every streamed token and every tool chunk. Content growth without a
// scroll leaves the cache untouched — which is the DESIRED semantics:
// "pinned" is a statement about where the user last scrolled to, not
// about the current pixel distance (the old post-append measurement is
// exactly what used to silently disengage auto-follow at tool time).
return this._nearBottom;
}
scrollToBottom(force) {
if (force || this.isNearBottom()) {
this.messagesEl.scrollTop = this.messagesEl.scrollHeight;
}
if (force) this._scrollPinForce = true;
else if (!this._nearBottom) return;
// rAF-coalesced pin: at most one scrollHeight read + scrollTop write per
// frame no matter how many deltas arrived. The pin re-checks
// _nearBottom AT FIRE TIME: a user wheel-scroll can land between the
// schedule (when the cached flag was still true) and the rAF — pinning
// anyway would yank them back to the bottom, and the programmatic
// scroll's own event would re-mark the flag true, trapping them there
// for the rest of the stream. Scroll events fire before rAF callbacks
// within a frame, so the re-check sees the user's disengage. Force
// requests latch across the coalescing window (a forced pin must win
// even if a non-forced schedule got there first).
if (this._scrollPinPending) return;
this._scrollPinPending = true;
requestAnimationFrame(() => {
this._scrollPinPending = false;
const forced = this._scrollPinForce;
this._scrollPinForce = false;
if (forced || this._nearBottom) {
this.messagesEl.scrollTop = this.messagesEl.scrollHeight;
}
});
}
_createDOM() {
@@ -717,6 +795,35 @@ class Pane {
this.messagesEl.setAttribute("role", "log");
this.messagesEl.setAttribute("aria-live", "polite");
this.messagesEl.setAttribute("aria-label", "Chat messages");
// Track "pinned to bottom" from actual scrolls (user or programmatic)
// instead of reading scroller geometry per event — see isNearBottom().
// Passive: never blocks the compositor thread.
this.messagesEl.addEventListener(
"scroll",
() => {
this._nearBottom =
this.messagesEl.scrollHeight -
this.messagesEl.scrollTop -
this.messagesEl.clientHeight <
80;
},
{ passive: true },
);
// Layout changes that move the bottom WITHOUT a scroll event (window
// resize, split-drag, orientation change) would leave the cached flag
// stale — a user visually back at the bottom after growing the pane
// stayed disengaged until they nudged the scroller. Resizes are rare,
// so the geometry read here is off the hot path by construction.
if (typeof ResizeObserver === "function") {
this._resizeObs = new ResizeObserver(() => {
this._nearBottom =
this.messagesEl.scrollHeight -
this.messagesEl.scrollTop -
this.messagesEl.clientHeight <
80;
});
this._resizeObs.observe(this.messagesEl);
}
this.el.appendChild(this.messagesEl);
// Per-workstream status bar (above input)
@@ -885,13 +992,32 @@ class Pane {
if (this.evtSource && this.evtSource.lastEventId) {
this._lastEventId = this.evtSource.lastEventId;
}
const data = JSON.parse(e.data);
// Guarded parse + dispatch. onmessage is the pane's whole event
// pipeline: an exception escaping it doesn't close the EventSource, so
// pre-guard a single malformed frame (or one throwing handler case)
// left the streaming refs (currentAssistantEl / contentBuffer) stale
// and every later turn painted into the poisoned segment — the
// "output stops rendering while the backend is healthy" wedge.
let data = null;
try {
data = JSON.parse(e.data);
} catch (err) {
console.warn("interactive: dropping malformed SSE frame", err);
return;
}
// Tag the event with its own SSE id so the system_turn handler can
// dedup a turn already painted from /history against the same turn
// redelivered by an SSE replay. e.lastEventId is this event's id;
// buffered events (system_turn included) always carry one.
if (e.lastEventId) data._event_id = e.lastEventId;
this.handleEvent(data);
try {
this.handleEvent(data);
} catch (err) {
console.error(
"interactive: handleEvent failed for " + (data && data.type),
err,
);
}
};
this.evtSource.onerror = () => {
@@ -933,6 +1059,17 @@ class Pane {
// below gets the new ws's full initial state instead.
this._lastEventId = null;
this._lastStatusEvt = null;
// Full-reload cleanup (NOT in disconnectSSE — transport-only reconnects
// must preserve these): a stale quiesce queue would wedge the new load's
// events behind a flush that never comes, stale agent tracking points at
// the DOM this load is about to replace, and a pending truncated-resync
// is superseded by the full refetch below.
this._replayQueue = null;
this._clearAgentTracking();
this._pendingTruncatedResync = false;
this._historyWindow = _HISTORY_WINDOW_STEP;
this._hiddenEarlier = 0;
this._hiddenEarlierApprox = false;
// Generation token — a slow refetch (e.g. a large resumed session) must
// not render its history, reconnect its stream, or fire its resend after
// the pane has switched to another ws. Newest load wins; older ones drop.
@@ -977,7 +1114,10 @@ class Pane {
// Drop a superseded load: a newer _loadHistoryThenConnect (ws switch)
// bumped the token while this fetch was in flight, so rendering now would
// paint the wrong ws's history into the pane.
if (token !== undefined && token !== this._historyLoadToken) return;
if (token !== undefined && token !== this._historyLoadToken) {
this._endReplayQuiesce(token);
return;
}
if (data) {
// Fresh-connect fast-forward: when the trailing turn is an
// executing in-flight tool batch the server can replay, /history
@@ -995,17 +1135,203 @@ class Pane {
// shape (server-side projection in make_history_handler:
// flat tool_calls, top-level source/reminders/attachments, collapsed
// content, derived denied/is_error/pending) — feed it straight to
// replayHistory. No client-side normalisation.
this.replayHistory(data.messages || []);
// replayHistory. No client-side normalisation. The quiesce release
// rides a finally so a loud replay throw (deliberately uncaught, see
// above) can't strand the event queue and wedge the pane.
try {
this.replayHistory(data.messages || []);
} finally {
this._endReplayQuiesce(token);
}
} else {
// Failure path never reaches replayHistory — reset the streaming refs
// here too, or the flushed backlog and resumed live events would paint
// into the subtree clear_ui already wiped.
this._resetStreamingRefs();
this.showEmptyState();
this._endReplayQuiesce(token);
}
}
_beginReplayQuiesce(token) {
// Arm the handleEvent queue for a full re-render (clear_ui /
// replay_truncated). Token-owned: a newer load's quiesce replaces this
// one wholesale — events queued before the newer snapshot was fetched
// are covered by that snapshot, so dropping them is lossless.
this._replayQueue = { token: token, events: [] };
}
_endReplayQuiesce(token) {
const q = this._replayQueue;
if (!q || q.token !== token) return;
this._replayQueue = null;
// Replay the backlog in arrival order. A queued clear_ui re-arms the
// quiesce mid-flush and the remainder queues behind ITS rebuild. Each
// dispatch is guarded like onmessage: one bad event must not drop the
// rest of the backlog.
for (const evt of q.events) {
try {
this.handleEvent(evt);
} catch (err) {
console.error("interactive: queued event replay failed", err);
}
}
}
_historyPagerLabel() {
return this._hiddenEarlierApprox || !this._hiddenEarlier
? "Load earlier messages"
: "Load earlier messages (" + this._hiddenEarlier + " hidden)";
}
_addHistoryPager(beforeEl) {
const pager = document.createElement("button");
pager.type = "button";
pager.className = "msg-history-pager";
pager.textContent = this._historyPagerLabel();
pager.addEventListener("click", () => this._loadEarlierHistory());
if (beforeEl) this.messagesEl.insertBefore(pager, beforeEl);
else this.messagesEl.appendChild(pager);
}
_loadEarlierHistory() {
// Grow the window one step and re-render from REST, restoring the scroll
// anchor so the rows the user was looking at stay put under the newly
// prepended content (scrollHeight-delta restore; content-visibility's
// `auto` intrinsic sizing keeps the delta close enough for a pager
// click). The pin scheduled by replayHistory's trailing scrollToBottom
// is non-forced and re-checks _nearBottom at fire time, so it skips
// while we're mid-transcript — no suppression needed. Disabled while
// busy: an in-flight turn's refetch takes the cursor/omit path, and the
// pager's job (older content) can wait for the idle edge.
if (this.busy) return;
this._historyWindow += _HISTORY_WINDOW_STEP;
const token = this._historyLoadToken;
const prevScrollHeight = this.messagesEl.scrollHeight;
const prevScrollTop = this.messagesEl.scrollTop;
this._beginReplayQuiesce(token);
this._refetchHistory(this.wsId, token).finally(() => {
if (token !== this._historyLoadToken) return;
this.messagesEl.scrollTop =
this.messagesEl.scrollHeight - prevScrollHeight + prevScrollTop;
});
}
_trimLiveTranscript() {
// Idle-edge live-append bound: past _LIVE_ROW_CAP rendered rows, drop the
// oldest (extending to the next user turn so a turn is never split) and
// surface the pager — the content stays in /history. Only while pinned:
// trimming shifts content, and a user scrolled up is READING the rows
// this would remove. Runs at the idle edge, where no streaming refs or
// pending approval can point at the trimmed range.
if (!this._nearBottom) return;
let excess = this.messagesEl.childElementCount - _LIVE_ROW_CAP;
if (excess <= 0) return;
let node = this.messagesEl.firstElementChild;
if (node && node.classList.contains("msg-history-pager")) {
node = node.nextElementSibling;
}
let removed = 0;
const hardStop = excess + 200; // bound the boundary walk
while (node && removed < excess) {
const next = node.nextElementSibling;
node.remove();
removed++;
node = next;
}
while (
node &&
removed < hardStop &&
!(node.classList.contains("msg") && node.classList.contains("user"))
) {
const next = node.nextElementSibling;
node.remove();
removed++;
node = next;
}
if (!removed) return;
// Message-count for the trimmed rows is unknown (rows ≠ messages) — the
// pager label drops its number until the next windowed replay.
this._hiddenEarlierApprox = true;
const first = this.messagesEl.firstElementChild;
if (first && first.classList.contains("msg-history-pager")) {
first.textContent = this._historyPagerLabel();
} else {
this._addHistoryPager(first);
}
// Agent cards inside the trimmed range are now detached; the Map isn't
// self-healing (unlike _toolRowIndex/_streamElIndex), so sweep it.
if (this._agentCards) {
for (const [key, card] of this._agentCards) {
if (!card.wrap.isConnected) this._agentCards.delete(key);
}
}
}
_clearAgentTracking() {
// Release task-agent bookkeeping ahead of (or after) a full rebuild.
// Entries left in _agentCards would pin every replaced card subtree as
// reachable detached DOM — unbounded growth across an hours-long
// session's rewinds/compaction re-syncs — and a stale _agentOrphans
// grace timer would escape buffered steps into the rebuilt pane.
if (this._agentCards) this._agentCards.clear();
if (this._agentOrphans) {
for (const entry of this._agentOrphans.values()) {
if (entry.timer != null) clearTimeout(entry.timer);
}
this._agentOrphans.clear();
}
// The row/stream lookup caches share this exact lifecycle (entries are
// DOM refs into the subtree being replaced) — drop them together.
if (this._toolRowIndex) this._toolRowIndex.clear();
if (this._streamElIndex) this._streamElIndex.clear();
}
_toolRow(callId) {
// O(1) call_id → .conv-row resolution with a self-healing cache: a hit
// is validated for liveness (isConnected + id match) so a row replaced
// by the pending→resolved upgrade or a batch rebuild falls back to one
// scoped query and re-caches. The old per-event attribute-selector
// scan walked the whole transcript — O(N) per tool event.
if (!callId) return null;
let row = this._toolRowIndex.get(callId);
if (row && row.isConnected && row.dataset.callId === callId) return row;
row = this.messagesEl.querySelector(
'.conv-row[data-call-id="' + CSS.escape(callId) + '"]',
);
if (row) this._toolRowIndex.set(callId, row);
else this._toolRowIndex.delete(callId);
return row;
}
_streamEl(callId) {
// Same cache discipline as _toolRow for the per-tool streaming <pre> —
// resolved on every tool_output_chunk, the chattiest event in an agent
// session.
if (!callId) return null;
let el = this._streamElIndex.get(callId);
if (el && el.isConnected && el.dataset.callId === callId) return el;
el = this.messagesEl.querySelector(
'.tool-output-stream[data-call-id="' + CSS.escape(callId) + '"]',
);
if (el) this._streamElIndex.set(callId, el);
else this._streamElIndex.delete(callId);
return el;
}
handleEvent(evt) {
// Guard: drop events that belong to a different workstream.
// This prevents cross-contamination during tab switches and reconnects.
if (evt.ws_id && evt.ws_id !== this.wsId) return;
// While a clear_ui / replay_truncated rebuild is in flight, live events
// must not paint into a DOM the imminent replaceChildren() will wipe —
// anything painted in the [snapshot-fetch → rebuild] window is lost with
// no redelivery (the re-render callers never rewind _lastEventId). Queue
// them; _endReplayQuiesce replays the backlog once the rebuild lands.
if (this._replayQueue) {
this._replayQueue.events.push(evt);
return;
}
switch (evt.type) {
case "thinking_start":
this.isThinking = true;
@@ -1048,7 +1374,7 @@ class Pane {
this.scrollToBottom();
break;
case "stream_end":
case "stream_end": {
if (this._cancelTimeout) {
clearTimeout(this._cancelTimeout);
this._cancelTimeout = null;
@@ -1057,21 +1383,32 @@ class Pane {
clearTimeout(this._forceTimeout);
this._forceTimeout = null;
}
// Finalize the current streaming segment's markdown. This fires
// per-segment (between tool calls), NOT per-turn. Busy state is
// managed by state_change events instead.
if (this.currentAssistantBodyEl && this.contentBuffer) {
streamingRenderFinalize(
this.currentAssistantBodyEl,
this.contentBuffer,
);
}
// Reset the segment state BEFORE the finalize render, and guard the
// render with a plain-text fallback (mirrors coordinator.js). With
// the old order a finalize throw skipped these clears, so every
// later content delta appended into the poisoned buffer/bubble and
// no new assistant segment ever painted — the permanent-wedge shape
// of "output stops rendering while the backend stays healthy".
const doneBodyEl = this.currentAssistantBodyEl;
const doneBuffer = this.contentBuffer;
this.currentAssistantBodyEl = null;
this.currentAssistantEl = null;
this.currentReasoningEl = null;
this.contentBuffer = "";
// Finalize the completed streaming segment's markdown. This fires
// per-segment (between tool calls), NOT per-turn. Busy state is
// managed by state_change events instead.
if (doneBodyEl && doneBuffer) {
try {
streamingRenderFinalize(doneBodyEl, doneBuffer);
} catch (err) {
console.warn("interactive: streamingRenderFinalize failed", err);
doneBodyEl.textContent = doneBuffer;
}
}
this.scrollToBottom(true);
break;
}
case "in_progress_snapshot":
// One-shot replay of the in-progress turn's reasoning + content
@@ -1120,6 +1457,19 @@ class Pane {
if (evt.state === "idle" || evt.state === "error") {
this.setBusy(false);
this._attachRetryToLastAssistant();
// Deferred replay_truncated re-sync: the truncation arrived while
// a segment was streaming (refetching then would have detached the
// live bubble), so repair the lost-event gap now that the turn is
// settled and /history is complete.
if (this._pendingTruncatedResync) {
this._pendingTruncatedResync = false;
const rsToken = this._historyLoadToken;
this._beginReplayQuiesce(rsToken);
this._refetchHistory(this.wsId, rsToken);
}
// Live-append bound — see _trimLiveTranscript. The idle edge is
// the safe trim point: no streaming refs, no pending approval.
this._trimLiveTranscript();
// Only steal focus if this is the active pane and no approval pending.
if (this._host.isFocused(this) && !this.pendingApproval) {
this.inputEl.focus();
@@ -1315,7 +1665,9 @@ class Pane {
// the load token so a ws switch mid-flight discards both the
// re-render and the resend (no cross-ws send).
const token = this._historyLoadToken;
this._beginReplayQuiesce(token);
this.messagesEl.replaceChildren();
this._resetStreamingRefs();
this._refetchHistory(this.wsId, token)
.then(() => {
if (token !== this._historyLoadToken) return;
@@ -1357,9 +1709,19 @@ class Pane {
// floor's in_progress_snapshot already paints it, and an async
// refetch's replaceChildren() would detach the live bubble so
// content deltas render nowhere. Re-syncs on the next clean
// (re)connect.
if (!this.currentAssistantEl)
this._refetchHistory(this.wsId, this._historyLoadToken);
// (re)connect. The guard covers BOTH streaming targets — a
// reasoning-only segment (currentReasoningEl without a content
// bubble yet) is just as detachable as a content one. Mid-stream
// the resync is DEFERRED, not dropped: skipping outright left the
// lost-event gap unrepaired for the rest of the session (no clean
// reconnect may come for hours); the idle edge consumes the flag.
if (!this.currentAssistantEl && !this.currentReasoningEl) {
const rtToken = this._historyLoadToken;
this._beginReplayQuiesce(rtToken);
this._refetchHistory(this.wsId, rtToken);
} else {
this._pendingTruncatedResync = true;
}
break;
}
}
@@ -1980,8 +2342,30 @@ class Pane {
});
}
_resetStreamingRefs() {
// Null every ref that can point into a wiped subtree, so the next event
// creates fresh targets instead of writing invisibly into detached
// nodes. Called wherever the transcript DOM is (or is about to be)
// replaced — replayHistory, the clear_ui immediate wipe, and the
// refetch-FAILURE path (which shows the empty state without ever
// reaching replayHistory; leaving refs stale there made the retried
// generation's whole first segment stream into a detached bubble).
this.currentAssistantEl = null;
this.currentAssistantBodyEl = null;
this.currentReasoningEl = null;
this.contentBuffer = "";
this.announcedBlockEl = null;
this._thinkingEl = null;
this._retryHolderEl = null;
}
replayHistory(messages) {
this.messagesEl.replaceChildren();
// The rebuild just orphaned any in-flight streaming targets — reset them,
// and release the agent-card/orphan maps whose entries now point at
// replaced subtrees (detached-DOM retention).
this._resetStreamingRefs();
this._clearAgentTracking();
// Reset the per-pane dedup set: ids of operator-context system turns
// already painted from /history. A later SSE replay that redelivers one
// (resume-cursor overlap) is skipped by the system_turn handler.
@@ -2007,8 +2391,26 @@ class Pane {
// branch can flip the card's done/error state from the task's own result
// (mirroring the live appendToolOutput), not from sub-step errors.
const agentCardWraps = {};
// Transcript window: render only the most recent _historyWindow messages.
// The cut walks FORWARD to the next user turn so it can never split an
// assistant tool_calls message from the tool results that anchor to it
// via lastToolBlock (both anchors reset at user messages). Rewind/edit
// stay correct under the window: their turn math counts user rows at-or-
// AFTER the clicked one, and the window only hides earlier rows. No
// boundary in the tail (pathological) ⇒ render everything.
let start = 0;
if (messages.length > this._historyWindow) {
start = messages.length - this._historyWindow;
while (start < messages.length && messages[start].role !== "user") {
start++;
}
if (start >= messages.length) start = 0;
}
this._hiddenEarlier = start;
this._hiddenEarlierApprox = false;
if (start > 0) this._addHistoryPager();
let lastToolBlock = null;
for (let i = 0; i < messages.length; i++) {
for (let i = start; i < messages.length; i++) {
const msg = messages[i];
if (msg.role === "user") {
if (msg.source === "system_nudge") {
@@ -2252,9 +2654,15 @@ class Pane {
}
_attachRetryToLastAssistant() {
// Remove any previous retry buttons
const old = this.messagesEl.querySelectorAll(".msg.assistant .msg-actions");
for (let i = 0; i < old.length; i++) old[i].parentNode.removeChild(old[i]);
// Remove the previous holder's action bar via the tracked ref — the old
// whole-transcript ".msg.assistant .msg-actions" sweep was O(N) per
// busy→idle edge. At most one assistant bar exists (this method is its
// only writer); a holder detached by a rebuild no-ops harmlessly.
if (this._retryHolderEl) {
const oldBar = this._retryHolderEl.querySelector(".msg-actions");
if (oldBar) oldBar.remove();
this._retryHolderEl = null;
}
// Find the last assistant message with content and add retry.
// Reasoning blocks emit as .msg.reasoning (distinct modifier) so the
// .msg.assistant selector already excludes them — no extra guard needed.
@@ -2275,13 +2683,25 @@ class Pane {
if (lastChild && lastChild.classList.contains("conv-batch")) {
return;
}
const assistants = this.messagesEl.querySelectorAll(".msg.assistant");
if (assistants.length) {
const lastAssistant = assistants[assistants.length - 1];
// Walk backwards from the tail for the last assistant bubble — the
// match is at (or near) the end of the transcript, so this touches a
// handful of siblings instead of collecting all N assistant rows.
let lastAssistant = this.messagesEl.lastElementChild;
while (
lastAssistant &&
!(
lastAssistant.classList.contains("msg") &&
lastAssistant.classList.contains("assistant")
)
) {
lastAssistant = lastAssistant.previousElementSibling;
}
if (lastAssistant) {
this._addRetryAction(lastAssistant);
if (this._voiceRoles && this._voiceRoles.tts) {
this._addTtsAction(lastAssistant);
}
this._retryHolderEl = lastAssistant;
}
}
@@ -2565,10 +2985,9 @@ class Pane {
}
_ensureAgentCard(parentCallId) {
const escId = parentCallId ? CSS.escape(parentCallId) : "";
const parentRow = escId
? this.messagesEl.querySelector('.conv-row[data-call-id="' + escId + '"]')
: null;
// _toolRow cache: a busy task agent resolves its parent row once per
// child event — the uncached scan was O(transcript) per step.
const parentRow = this._toolRow(parentCallId);
if (!parentRow) return null;
if (!this._agentCards) this._agentCards = new Map();
let card = this._agentCards.get(parentCallId);
@@ -2700,17 +3119,17 @@ class Pane {
const stick = this.isNearBottom();
// A task_agent's OWN result completing flips its card running -> done/error
// (child sub-tool results carry namespaced ids, never keys of _agentCards).
// The entry deliberately SURVIVES the result: a late child event (SSE
// replay overlap) re-entering _ensureAgentCard with no Map entry would
// build a duplicate empty card beside the finished one. Entries hold
// attached DOM (not a leak); the detached-retention hazard is rebuilds,
// which _clearAgentTracking covers in replayHistory.
if (this._agentCards && this._agentCards.has(callId)) {
this._agentCards.get(callId).wrap.dataset.state = isError
? "error"
: "done";
}
const escapedId = callId ? CSS.escape(callId) : "";
let target = escapedId
? this.messagesEl.querySelector(
'.conv-row[data-call-id="' + escapedId + '"]',
)
: null;
let target = this._toolRow(callId);
if (!target) {
// A namespaced sub-agent child id ("<parent>::<id>") whose row hasn't
// nested yet must NOT graft its output onto the last top-level batch row
@@ -2732,18 +3151,17 @@ class Pane {
if (!target) return;
// Remove the streaming output element for this tool
let streamEl = null;
if (escapedId) {
streamEl = this.messagesEl.querySelector(
'.tool-output-stream[data-call-id="' + escapedId + '"]',
);
} else {
let streamEl = this._streamEl(callId);
if (!streamEl) {
const next = target.nextElementSibling;
if (next && next.classList.contains("tool-output-stream")) {
streamEl = next;
}
}
if (streamEl) streamEl.remove();
if (streamEl) {
streamEl.remove();
if (callId) this._streamElIndex.delete(callId);
}
const stripped = stripAnsi(output || "").trim();
if (!stripped) return;
@@ -3885,6 +4303,16 @@ function createInteractivePane(root, wsId, opts) {
recoverTimer = null;
}
pane.disconnectSSE();
// Terminal cleanup that transport-only reconnects must NOT do (see
// disconnectSSE): cancel orphan grace timers so a post-destroy escape
// can't paint into the detached pane / shared announcer, release the
// card maps, and stop observing the detached scroller.
pane._clearAgentTracking();
pane._replayQueue = null;
if (pane._resizeObs) {
pane._resizeObs.disconnect();
pane._resizeObs = null;
}
if (pane.el && pane.el.parentNode) {
pane.el.parentNode.removeChild(pane.el);
}
+103 -36
View File
@@ -261,11 +261,31 @@ function _langToCssClass(lang) {
// ---------------------------------------------------------------------------
// Main markdown renderer
// ---------------------------------------------------------------------------
// Hard cap on renderMarkdown re-entrancy. Blockquote/callout/list bodies
// recurse through renderMarkdown; a pathological input (a few KB of nested
// "> " prefixes) would otherwise overflow the call stack mid-render — an
// exception the streaming callers can only partially recover from. Beyond
// the cap the nested body renders as escaped plain text: degraded, visible.
var _MD_MAX_DEPTH = 100;
export function renderMarkdown(text) {
// Scope footnote IDs per top-level render call (prevents collisions across messages)
if (_fnDepth >= _MD_MAX_DEPTH) {
return "<p>" + escapeHtml(String(text == null ? "" : text)) + "</p>";
}
// Scope footnote IDs per top-level render call (prevents collisions across
// messages). Depth accounting rides a try/finally: a throw anywhere in the
// body used to strand _fnDepth elevated, freezing _fnScopeId so footnote
// anchor ids collided across every later message.
if (_fnDepth === 0) _fnScopeId++;
_fnDepth++;
try {
return _renderMarkdownBody(text);
} finally {
_fnDepth--;
}
}
function _renderMarkdownBody(text) {
// Pre-pass: extract blockquote blocks and recursively render.
// Must run FIRST (before code/math protection) so the recursive call
// processes raw markdown, not text with outer-scope placeholders.
@@ -742,7 +762,6 @@ export function renderMarkdown(text) {
return inlineMaths[parseInt(idx)];
});
_fnDepth--;
return result;
}
@@ -1119,37 +1138,72 @@ function _renderMermaidBlock(container, callback) {
return;
}
_mermaidPending.set(source, [container]);
_mermaidRenderChain = _mermaidRenderChain.then(function () {
var pending = _mermaidPending.get(source) || [];
_mermaidPending.delete(source);
var id = "mermaid-" + ++_mermaidIdCounter;
return mermaid.render(id, source).then(
function (result) {
_cacheFifoEntry(
_mermaidSvgCache,
source,
{ svg: result.svg, bindFunctions: result.bindFunctions },
_MERMAID_CACHE_MAX,
);
for (var i = 0; i < pending.length; i++) {
var c = pending[i];
if (c.isConnected) {
_applyMermaidSvg(c, result.svg, result.bindFunctions);
// ``linkPending`` is hoisted to the link's closure so the rejection-proof
// .catch below can paint the error on the containers THIS link captured —
// by the time it runs, the link already removed them from _mermaidPending,
// so without the hoist they'd sit at "Loading diagram…" forever.
var linkPending = null;
_mermaidRenderChain = _mermaidRenderChain
.then(function () {
var pending = _mermaidPending.get(source) || [];
linkPending = pending;
_mermaidPending.delete(source);
var id = "mermaid-" + ++_mermaidIdCounter;
return mermaid.render(id, source).then(
function (result) {
_cacheFifoEntry(
_mermaidSvgCache,
source,
{ svg: result.svg, bindFunctions: result.bindFunctions },
_MERMAID_CACHE_MAX,
);
for (var i = 0; i < pending.length; i++) {
var c = pending[i];
if (c.isConnected) {
// Per-container guard: one bad apply (a bindFunctions throw)
// must not skip the remaining containers for this source.
try {
_applyMermaidSvg(c, result.svg, result.bindFunctions);
} catch (e) {
_applyMermaidError(c, source, "diagram apply failed");
}
}
}
},
function (err) {
var orphan = document.getElementById(id);
if (orphan) orphan.remove();
var msg = err && err.message ? err.message : "Diagram error";
_cacheFifoEntry(_mermaidErrorCache, source, msg, _MERMAID_CACHE_MAX);
for (var i = 0; i < pending.length; i++) {
var c = pending[i];
if (c.isConnected) _applyMermaidError(c, source, msg);
}
},
);
})
.catch(function (e) {
// Rejection-proof every link: a sync throw escaping the link body
// (e.g. mermaid.render throwing on malformed input before returning a
// promise) would otherwise reject the shared chain, and every later
// diagram would silently sit at "Loading diagram…" forever. Settle
// back to fulfilled and paint the error on the containers this link
// had already claimed. Deliberately NO _mermaidPending.delete(source)
// here: the link deleted its own entry up top, and any entry present
// NOW belongs to a newer re-entry for the same source — deleting it
// would orphan THAT link's containers.
var msg = (e && e.message) || "Diagram error";
_cacheFifoEntry(_mermaidErrorCache, source, msg, _MERMAID_CACHE_MAX);
(linkPending || []).forEach(function (c) {
if (!c.isConnected) return;
try {
_applyMermaidError(c, source, msg);
} catch (_) {
/* container-level failure — nothing left to degrade to */
}
},
function (err) {
var orphan = document.getElementById(id);
if (orphan) orphan.remove();
var msg = err && err.message ? err.message : "Diagram error";
_cacheFifoEntry(_mermaidErrorCache, source, msg, _MERMAID_CACHE_MAX);
for (var i = 0; i < pending.length; i++) {
var c = pending[i];
if (c.isConnected) _applyMermaidError(c, source, msg);
}
},
);
});
});
console.warn("renderer: mermaid render chain error", e);
});
if (callback) callback();
}
@@ -1253,19 +1307,32 @@ export function reRenderAllMermaid() {
// ---------------------------------------------------------------------------
function _streamingRenderApply(el, buffer) {
if (el._lastRenderedBuffer === buffer) return;
try {
el.innerHTML = renderMarkdown(buffer);
} catch (e) {
// A render failure must not wedge the stream: show THIS frame as plain
// text and leave the buffer UN-marked, so the next delta / the finalize
// pass re-attempts a full render (partial-input throws heal themselves
// once the closing tokens arrive). Marking before the render used to
// make an errored frame look done — the finalize short-circuit then
// pinned the stale DOM forever.
console.warn("renderer: streaming render failed; plain-text frame", e);
el.textContent = buffer;
return;
}
el._lastRenderedBuffer = buffer;
var html = renderMarkdown(buffer);
el.innerHTML = html;
// Progressive hljs + mermaid render — see comment above. Both are
// no-ops when the element has no matching code blocks, and their
// source-keyed caches avoid re-tokenizing / re-rendering for
// sources we've already processed. Subsequent rAF ticks that
// re-extract the same closed fence hit the cache synchronously.
if (typeof postRenderHljs === "function") {
// Guarded: decoration failures degrade to undecorated markup, never to
// a broken segment state upstream.
try {
postRenderHljs(el);
}
if (typeof postRenderMermaid === "function") {
postRenderMermaid(el);
} catch (e) {
console.warn("renderer: post-render decoration failed", e);
}
}
+9
View File
@@ -11,6 +11,15 @@ export function showToast(message, type) {
const el = document.getElementById("toast");
if (!el) return;
if (_toastShowing) {
// Coalesce + cap: the queue drains at one toast per ~3.3s, so any
// sustained source (verdict toasts during an auto-approved tool storm)
// would otherwise grow it for the rest of the session and keep
// surfacing hours-stale notices. Identical consecutive messages
// collapse; beyond the cap the OLDEST queued toast drops (newest wins —
// it reflects current state).
const last = _toastQueue[_toastQueue.length - 1];
if (last && last.message === message && last.type === type) return;
if (_toastQueue.length >= 5) _toastQueue.shift();
_toastQueue.push({ message: message, type: type });
return;
}
+133 -8
View File
@@ -1527,8 +1527,34 @@ function connectGlobalSSE() {
if (globalEvtSource && globalEvtSource.lastEventId) {
globalLastEventId = globalEvtSource.lastEventId;
}
const data = JSON.parse(e.data);
if (data.type === "ws_state") {
// Guarded parse: the cursor above has already advanced past this frame,
// so a parse failure is a permanently-lost roster mutation — resync the
// roster from REST instead of silently drifting (a dropped ws_created
// renders as a conversation that never appears; a dropped ws_closed as
// a ghost row forever).
let data = null;
try {
data = JSON.parse(e.data);
} catch (err) {
console.warn("global SSE: malformed frame — resyncing roster", err);
resyncRoster();
return;
}
if (data.type === "node_snapshot") {
// Recovery floor: the server emits this when our resume cursor
// predates its ring buffer (fresh connect, or a truncated gap after
// hidden-tab/sleep). The snapshot carries the FULL workstream
// inventory — rebuild the roster wholesale; per-ws panes re-sync
// through their own Tier-2 streams. Eviction is safe here (and only
// here): the snapshot is serialized with ws_created/ws_closed on the
// stream itself.
applyRosterSnapshot(data.workstreams || [], { evict: true });
} else if (data.type === "replay_truncated") {
// Events between our cursor and the buffer head are gone for good.
// The node_snapshot that follows rebuilds the roster; refetch too so
// recovery doesn't depend on event ordering.
resyncRoster();
} else if (data.type === "ws_state") {
updateTabIndicator(data.ws_id, data.state, {
tokens: data.tokens,
context_ratio: data.context_ratio,
@@ -2065,6 +2091,93 @@ document.addEventListener("keydown", function (e) {
// 16. Init
// ===========================================================================
// Rebuild the roster from a node_snapshot payload (workstream items keyed by
// ``id`` — the snapshot mirrors the console-collector projection, not the
// REST list's ``ws_id``). ``opts.evict``: remove roster entries missing
// from the list and close their panes. Eviction is ONLY safe for the
// in-stream node_snapshot — it is serialized with ws_created/ws_closed on
// the SSE stream, so it can't race a roster mutation. An out-of-band REST
// snapshot (resyncRoster) can be built server-side BEFORE a create whose
// ws_created the client already consumed; evicting from it would close a
// live, freshly-opened conversation. REST resyncs therefore merge only;
// missed-ws_closed ghosts heal on the next in-stream snapshot.
function applyRosterSnapshot(list, opts) {
const evict = !!(opts && opts.evict);
// Null-prototype membership map: a ws id that happened to collide with an
// Object.prototype property name would read as always-seen on a plain
// object and dodge eviction.
const seen = Object.create(null);
(list || []).forEach(function (ws) {
if (!ws || !ws.id) return;
seen[ws.id] = true;
const cur = workstreams[ws.id] || {};
cur.name = ws.name || cur.name || ws.id.slice(0, 6);
cur.state = ws.state || cur.state || "idle";
cur.parent_ws_id = ws.parent_ws_id || null;
cur.project_id = ws.project_id || null;
workstreams[ws.id] = cur;
});
if (evict) {
const pm = window.TS_SHELL && window.TS_SHELL.panes;
// Stable key snapshot: mutating the roster mid-walk is well-defined for
// the currently-visited key, but the snapshot makes the eviction loop
// self-evidently order-safe and skips inherited keys.
for (const id of Object.keys(workstreams)) {
if (!seen[id]) {
// Gap recovery can retire a session the user is LOOKING at — the
// live ws_closed (and its eviction toast) is exactly what was missed
// during the gap — so closing the pane wordlessly would yank it
// mid-read. Toast only when an open pane goes away; mass ghost-row
// cleanup in the rail stays quiet.
const wasOpen = !!(pm && pm.hasPane("interactive", id));
const name =
(workstreams[id] && workstreams[id].name) || id.slice(0, 6);
delete workstreams[id];
closeSessionPane(id);
if (wasOpen) showToast("Session ended: " + name);
}
}
}
fireRender();
}
// REST fallback for the same recovery (replay_truncated / a malformed frame
// whose cursor already advanced). MERGE-ONLY (see applyRosterSnapshot) and
// gated on r.ok — a 503 during a node restart parses as a JSON error body
// with no ``workstreams``, which must not read as an authoritative empty
// roster. In-flight latch: one resync at a time — repeated triggers during
// an outage must not stack fetches.
let _rosterResyncInflight = null;
function resyncRoster() {
if (_rosterResyncInflight) return _rosterResyncInflight;
_rosterResyncInflight = authFetch("/v1/api/workstreams")
.then(function (r) {
if (!r.ok) return null;
return r.json();
})
.then(function (data) {
if (!data || !data.workstreams) return;
applyRosterSnapshot(
data.workstreams.map(function (ws) {
return {
id: ws.ws_id,
name: ws.name,
state: ws.state,
parent_ws_id: ws.parent_ws_id,
project_id: ws.project_id,
};
}),
);
})
.catch(function () {
/* transient — the next snapshot or reconnect heals the roster */
})
.finally(function () {
_rosterResyncInflight = null;
});
return _rosterResyncInflight;
}
function initWorkstreams() {
return authFetch("/v1/api/workstreams")
.then(function (r) {
@@ -2230,15 +2343,27 @@ window.addEventListener("popstate", function (e) {
// Rail re-render fan-out — the rail subscribes via TS_APP.onRender; every
// roster mutation calls fireRender() so the Workspaces section stays live.
// rAF-coalesced: the server emits ws_state at least twice per tool round for
// EVERY workstream on the node, and each subscriber repaint rebuilds the
// whole rail (replaceChildren + a listener per row) — uncoalesced, a busy
// session drove thousands of full rebuilds per hour, O(#workstreams) each.
// All subscribers are snapshot-driven repaints, so batching to one repaint
// per frame is lossless.
const _renderSubs = [];
let _renderScheduled = false;
function fireRender() {
for (const cb of _renderSubs) {
try {
cb();
} catch (e) {
console.error("TS_APP render subscriber failed", e);
if (_renderScheduled) return;
_renderScheduled = true;
requestAnimationFrame(function () {
_renderScheduled = false;
for (const cb of _renderSubs) {
try {
cb();
} catch (e) {
console.error("TS_APP render subscriber failed", e);
}
}
}
});
}
// Open / focus an interactive session as a pane (base="" local transport — the
+7 -5
View File
@@ -78,11 +78,13 @@
min-height: 0;
overflow-y: auto;
padding: 16px 12px;
display: flex;
flex-direction: column;
/* No flexbox gap .msg supplies its own margin-bottom for inter-card
spacing. A 14px gap here was stacking with the 4px card
margin-bottom and pushing the per-card spacing to ~18px. */
/* Block flow + no native scroll anchoring mirrors the shared
interactive.css scroller (which also carries the per-row
content-visibility rules): a flex column relayouts every row on each
streaming height change, and native anchoring fights the pane's own
bottom pin. .msg supplies its own margin-bottom for inter-card
spacing. */
overflow-anchor: none;
}
/* .msg (shared_static/chat.css) provides padding / border / radius /
line-height / word-wrap / margin / background. The interactive UI