mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 15:32:24 -06:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d174cd71c | |||
| df573b7314 | |||
| 3c7a3c1375 | |||
| 41e7d5b7d7 | |||
| ca23f2876c | |||
| a9898fdd6c |
@@ -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
|
||||
|
||||
@@ -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,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
@@ -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()
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1511,3 +1511,41 @@ def test_link_url_with_ampersand_not_double_escaped() -> None:
|
||||
"Expected single & encoding for `&`; got:\n" + out
|
||||
)
|
||||
assert "&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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user