Files
turnstone/turnstone/shared_static/interactive.js
T
Patrick Buckley 3c7a3c1375 fix(webui): wedge-proof the live-session pipeline and de-O(N) hot paths
Long sessions (5000+ messages, several compactions) degraded steadily
and could stop rendering entirely while the backend stayed healthy.
Four hard failure mechanisms, each sufficient on its own:

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

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

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

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

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

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

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

Known limit (needs a server-side event watermark on /history): a turn
completing inside the refetch window can paint twice after the quiesce
flush — rare, visible, and strictly better than the silent loss it
replaces.
2026-07-02 00:32:11 -07:00

4195 lines
161 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ===========================================================================
// turnstone interactive pane — shared module (shared_static/interactive.js)
//
// The per-workstream conversational `Pane` (chat + approval cards + composer +
// voice), extracted from `ui/static/app.js` so BOTH deployments can mount it:
// the standalone `turnstone-server` UI (its split-pane shell stays in app.js
// and constructs panes via `new Pane(wsId, opts)`) AND the console L-shell
// (which proxies a node-hosted interactive session through a per-pane Tier-2
// stream — see `createInteractivePane`). The one transport difference is the
// `opts.base` URL prefix: "" locally, "/node/{id}" when the console proxies a
// session living on a cluster node (the LOCALITY invariant).
//
// ES module — the first legacy pane lifted into a real module (step 5a; the
// coordinator pane followed in 5e.0, and the shared substrate it leans on —
// composer/renderer/auth/etc. — became modules too, imported below). The
// console shell.js imports the factory directly; the standalone loads it as
// `<script type="module">`.
//
// House style: programmatic DOM, NO innerHTML (the renderer is the sole
// sanctioned exception); pane code root-scopes to its own element.
// ===========================================================================
import {
stripAnsi,
buildWatchResultCard,
buildSystemNudgeMarker,
buildConvBatchShell,
buildConvRow,
buildConvCmd,
buildConvVerdict,
buildConvWarning,
buildConvActions,
buildConvStatus,
buildAgentCardBody,
batchKicker,
indexLabel,
} from "./conversation.js";
import { authFetch } from "./auth.js";
import { showToast } from "./toast.js";
import { Composer } from "./composer.js";
import {
createAttachmentController,
kindIcon,
} from "./composer_attachments.js";
import { createQueueController } from "./composer_queue.js";
import { StatusBar } from "./status_bar.js";
import { streamingRender, streamingRenderFinalize } from "./renderer.js";
import { setMarkdown, operatorSourceLabel } from "./utils.js";
let _paneCounter = 0;
// Voice-role availability comes from /v1/api/models (stt_default_alias /
// tts_default_alias — present only when an audio-capable model role is
// configured). Memoized so all panes share a single fetch; affordances stay
// hidden until it resolves.
// Memoized per transport base — a console pane proxies a node-hosted session,
// so its voice roles come from THAT node's /v1/api/models (base "/node/{id}"),
// not the console's; standalone panes use base "" and share one fetch.
const _voiceRolesPromises = {};
// Max child steps held per task agent while its parent row is unpainted (see
// InteractivePane._bufferAgentOrphan). A real agent's whole sub-trajectory is
// well under this; the cap only bounds a pathological never-arriving parent.
const _AGENT_ORPHAN_CAP = 256;
// Grace window before a child step whose task_agent row never paints (an id-
// correlation mismatch, or an agent that aborted before its row painted) is
// escaped to a top-level row so it stays VISIBLE rather than buffered forever.
// The ordering race the buffer targets resolves within a frame, far inside it.
const _AGENT_ORPHAN_GRACE_MS = 500;
function getVoiceRoles(base) {
base = base || "";
if (!_voiceRolesPromises[base]) {
_voiceRolesPromises[base] = authFetch(base + "/v1/api/models")
.then((r) => (r.ok ? r.json() : {}))
.then((d) => ({
stt: !!(d && d.stt_default_alias),
tts: !!(d && d.tts_default_alias),
}))
.catch(() => ({ stt: false, tts: false }));
}
return _voiceRolesPromises[base];
}
// Visually-hidden polite live region for voice status (recording / playback)
// so screen-reader users perceive state changes otherwise conveyed only by
// color/icon. Errors go through showToast (already a live region). Single
// shared node; clear-then-set so repeated identical messages re-announce.
let _voiceStatusEl = null;
function voiceAnnounce(msg) {
if (!_voiceStatusEl) {
_voiceStatusEl = document.createElement("div");
_voiceStatusEl.className = "sr-only";
_voiceStatusEl.setAttribute("role", "status");
_voiceStatusEl.setAttribute("aria-live", "polite");
document.body.appendChild(_voiceStatusEl);
}
_voiceStatusEl.textContent = "";
window.setTimeout(() => {
if (_voiceStatusEl) _voiceStatusEl.textContent = msg;
}, 30);
}
// Visually-hidden POLITE live region for the tool-call early paint
// (tool_pending) so screen-reader users hear a committed call land — and that
// they can Stop it — even though messagesEl is flipped to aria-live="off"
// during the token streaming that immediately precedes the call. Polite (not
// assertive): a committed call is worth surfacing but isn't the action-required
// human gate, which keeps its own assertive announcement. Separate node from
// the voice region so the two never clobber each other. Single shared node;
// clear-then-set so repeated identical messages re-announce.
let _toolStatusEl = null;
function toolAnnounce(msg) {
if (!msg) return;
if (!_toolStatusEl) {
_toolStatusEl = document.createElement("div");
_toolStatusEl.className = "sr-only";
_toolStatusEl.setAttribute("role", "status");
_toolStatusEl.setAttribute("aria-live", "polite");
document.body.appendChild(_toolStatusEl);
}
_toolStatusEl.textContent = "";
window.setTimeout(() => {
if (_toolStatusEl) _toolStatusEl.textContent = msg;
}, 30);
}
// Terse SR summary for a committed tool batch: tool name(s) (capped at 3) +
// the fact it's being judged and can be stopped. Empty string when there are
// no named tools (toolAnnounce then no-ops).
function _toolAnnounceText(items) {
const names = (items || []).map((it) => it && it.func_name).filter(Boolean);
if (!names.length) return "";
const n = names.length;
const shown = names.slice(0, 3).join(", ");
const list = n > 3 ? shown + ", and " + (n - 3) + " more" : shown;
const head =
n === 1 ? "Tool call pending: " + list : n + " tool calls pending: " + list;
// No "judge evaluating" claim — tool_pending fires unconditionally, so the
// intent judge may be disabled. "Pending + you can stop it" is always true.
return head + ". You can stop " + (n === 1 ? "it" : "them") + ".";
}
// Default host adapter — console-safe no-ops so a bare ``new Pane(wsId)`` never
// throws on a shell-only seam. The standalone shell (app.js) and the console
// factory (createInteractivePane) each pass a richer host.
const INTERACTIVE_DEFAULT_HOST = {
// Is THIS pane the user's current focus? Gates focus-stealing so a caret is
// never yanked into a backgrounded pane. A lone pane is always focused.
isFocused() {
return true;
},
// EventSource-error policy. Native auto-reconnect handles transient drops; a
// bare/console pane relies on it, so this is a no-op. The standalone focused
// pane additionally refetches + reassigns the ws list (see app.js).
onStreamError() {},
// EventSource (re)opened — the dual of onStreamError. The console pane host
// uses it to reset its terminal-failure counter (see createInteractivePane).
onStreamOpen() {},
// Where the ``--skip-permissions`` banner lands (standalone: #ui-header).
warningTarget(pane) {
return pane.messagesEl;
},
// An MCP server needs (re-)consent — the standalone shell drives its
// settings-gear badge; a bare/console pane surfaces it inline only.
onConsentDetected() {},
};
class Pane {
constructor(wsId, opts) {
opts = opts || {};
this.id = "p" + ++_paneCounter;
this.wsId = wsId || null;
// Transport + host seam. ``base`` is the node-proxy URL prefix ("" for a
// local session, "/node/{id}" when the console proxies a session that lives
// on a cluster node — the LOCALITY invariant). ``host`` supplies the few
// things only the surrounding shell knows (which pane is focused, the
// stream-error recovery policy, the warning-banner target); see
// createInteractivePane below. Every pane is L-shell-hosted — the
// standalone split-pane chrome (focus tracking, context menu, header with
// split/close buttons) was retired with the step-6 fork collapse.
this._base = opts.base || "";
this._host = opts.host || INTERACTIVE_DEFAULT_HOST;
this._onClose = typeof opts.onClose === "function" ? opts.onClose : null;
this.evtSource = null;
this.el = null;
this.messagesEl = null;
this.inputEl = null;
this.sendBtn = null;
this.stopBtn = null;
this.currentAssistantEl = null;
this.currentReasoningEl = null;
this.contentBuffer = "";
this.busy = false;
this.isThinking = false;
this.pendingApproval = false;
this.approvalBlockEl = null;
// Early-paint shell from a ``tool_pending`` event, awaiting its
// authoritative ``tool_info`` / ``approve_request`` upgrade.
this.announcedBlockEl = null;
this.retryDelay = 1000;
this.model = "";
this.modelAlias = "";
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;
this._cancelTimeout = null;
this._forceTimeout = null;
this._pendingEditSend = null;
// Voice I/O (mic STT + per-message TTS playback)
this._voiceRoles = { stt: false, tts: false };
this._micBtn = null;
this._micIcon = null;
this._micDenied = false;
this._recorder = null;
this._recordingStream = null;
this._isRecording = false;
this._discardRecording = false;
this._recordAborted = false;
this._recordTimer = null;
this._recordStartMs = 0;
this._ttsAudio = null;
this._ttsBtnActive = null;
this._ttsSeq = 0;
this._createDOM();
}
reset() {
this.currentAssistantEl = null;
this.currentReasoningEl = null;
this.contentBuffer = "";
this.setBusy(false);
this.pendingApproval = false;
this.approvalBlockEl = null;
this.announcedBlockEl = null;
this._pendingEditSend = null;
this.inputEl.disabled = false;
this.attachments.clearChips();
this._stopRecording(true);
this._stopTTS();
}
disconnectSSE() {
if (this._cancelTimeout) {
clearTimeout(this._cancelTimeout);
this._cancelTimeout = null;
}
if (this._forceTimeout) {
clearTimeout(this._forceTimeout);
this._forceTimeout = null;
}
if (this.evtSource) {
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();
}
// composer.setBusy runs unconditionally so the Stop button label /
// dataset.forceCancel / placeholder stay canonical even on a redundant
// call (Pane.reset() and any future caller relies on that idempotent
// reset). queue.onIdleEdge runs only on the actual edge — it carries
// the heavier work (querySelectorAll-driven promote sweep + cancel-
// timer cleanup wired via the queue's onIdle hook).
setBusy(b) {
const next = !!b;
this.composer.setBusy(next);
this.messagesEl.dataset.busy = next ? "true" : "false";
const edge = next !== this.busy;
this.busy = next;
if (edge && !next) this.queue.onIdleEdge();
// The mic produces composer input the user can only send when idle — keep
// it in lockstep with the send button (which composer.setBusy gates).
if (this._micBtn && !this._micDenied) {
if (next) {
this._stopRecording(true); // abandon any in-flight recording
this._micBtn.disabled = true;
} else if (!this._micBtn.classList.contains("is-busy")) {
this._micBtn.disabled = false;
}
}
}
showEmptyState() {
if (!this.messagesEl.querySelector(".empty-state")) {
const el = document.createElement("div");
el.className = "empty-state";
el.textContent = "Type a message to start";
this.messagesEl.appendChild(el);
}
}
removeEmptyState() {
const el = this.messagesEl.querySelector(".empty-state");
if (el) el.remove();
}
addThinkingIndicator() {
// 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() {
if (!this._thinkingEl) return;
this._thinkingEl.remove();
this._thinkingEl = null;
}
addSystemNudgeMarker() {
// The marker DOM is shared (conversation.buildSystemNudgeMarker); the Pane
// owns empty-state removal + placement.
this.removeEmptyState();
const el = buildSystemNudgeMarker();
this.messagesEl.appendChild(el);
return el;
}
addSystemContext(content, source, meta) {
// First-class operator-context system turn — the consolidation of the
// legacy metacognition reminders / user interjections / output-guard
// notes into one role="system" trajectory turn. Rendered as a distinct
// operator bubble in sequence (it FOLLOWS the turn it advises);
// `source` carries the kind (user_interjection / output_guard /
// tool_error / ...) for the bubble label. `watch_triggered` additionally
// carries structured `meta` (watch_name / command / poll counters) → the
// richer `.msg.watch-result` card instead of the plain operator bubble.
this.removeEmptyState();
if (source === "watch_triggered" && meta && typeof meta === "object") {
const card = buildWatchResultCard(meta, content || "");
this.messagesEl.appendChild(card);
this.scrollToBottom(true);
return card;
}
if (source === "output_guard" && meta && typeof meta === "object") {
const card = _buildGuardFindingBubble(meta);
this.messagesEl.appendChild(card);
this.scrollToBottom(true);
return card;
}
// user_interjection renders as a "queued message" bubble showing the user's
// RAW words (`meta.message`) rather than the model-directed framing in
// `content`, with brighter emphasis for `!!!`-important interjections.
const isInterjection =
source === "user_interjection" && meta && typeof meta === "object";
const important = isInterjection && meta.priority === "important";
const el = document.createElement("div");
el.className =
"msg system-context operator-context" + (important ? " important" : "");
const body = document.createElement("div");
body.className = "msg-body";
const labelEl = document.createElement("span");
labelEl.className = "msg-system-context-label";
labelEl.textContent = isInterjection
? "queued message" + (important ? " · important" : "")
: operatorSourceLabel(source);
const textEl = document.createElement("span");
textEl.className = "msg-system-context-text";
textEl.textContent =
isInterjection && meta.message != null
? String(meta.message)
: content || "";
body.appendChild(labelEl);
body.appendChild(textEl);
el.appendChild(body);
this.messagesEl.appendChild(el);
this.scrollToBottom(true);
return el;
}
addUserMessage(text, attachments) {
this.removeEmptyState();
const el = document.createElement("div");
el.className = "msg user";
const textEl = document.createElement("div");
textEl.className = "msg-user-text";
textEl.textContent = text;
el.appendChild(textEl);
if (Array.isArray(attachments) && attachments.length > 0) {
const pills = document.createElement("div");
pills.className = "msg-user-attach";
const attachWsId = this.wsId;
const attachBase = this._base;
attachments.forEach(function (a) {
const pill = document.createElement("span");
pill.className = "msg-user-attach-pill";
const icon = document.createElement("span");
icon.className = "msg-user-attach-icon";
icon.setAttribute("aria-hidden", "true");
icon.textContent = kindIcon(a.kind);
pill.appendChild(icon);
const nameEl = document.createElement("span");
nameEl.className = "msg-user-attach-name";
nameEl.textContent =
a.filename ||
(a.kind === "image"
? "image"
: a.kind === "audio"
? "audio"
: "document");
pill.appendChild(nameEl);
const prev =
typeof window.buildAttachmentPreview === "function"
? window.buildAttachmentPreview({
kind: a.kind,
wsId: attachWsId,
base: attachBase,
attachmentId: a.attachment_id,
filename: a.filename,
})
: null;
if (prev) {
if (a.kind === "image" || a.kind === "pdf") icon.replaceWith(prev);
else pill.appendChild(prev);
}
pills.appendChild(pill);
});
el.appendChild(pills);
}
this._addUserMsgActions(el, text);
this.messagesEl.appendChild(el);
this.scrollToBottom(true);
}
getFeedback() {
if (!this.approvalBlockEl) return null;
const inp = this.approvalBlockEl.querySelector(".conv-feedback");
return inp && inp.value.trim() ? inp.value.trim() : null;
}
appendToolOutputChunk(callId, chunk) {
if (!chunk) return;
const stripped = stripAnsi(chunk);
if (!stripped) return;
// Capture pin before the chunk grows the stream block — see
// announceToolBlock.
const stick = this.isNearBottom();
let el = this._streamEl(callId);
if (!el) {
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 —
// that mislabels a sub-tool's output as a main-harness tool's. Its row
// arrives via the orphan flush; skip the chunk until then.
if (callId && callId.includes("::")) return;
const blocks = this.messagesEl.querySelectorAll(".conv-batch");
if (!blocks.length) return;
const block = blocks[blocks.length - 1];
const tools = block.querySelectorAll(
'.conv-row[data-func-name="bash"]',
);
target = tools.length ? tools[tools.length - 1] : null;
if (!target) {
const allTools = block.querySelectorAll(".conv-row");
target = allTools.length ? allTools[allTools.length - 1] : null;
}
}
if (!target) return;
el = document.createElement("pre");
el.className = "tool-output tool-output-stream";
el.dataset.callId = callId;
el.setAttribute("aria-label", "Streaming command output");
el.setAttribute("aria-live", "off");
el.textContent = "";
target.after(el);
if (callId) this._streamElIndex.set(callId, el);
}
el.appendChild(document.createTextNode(stripped));
// 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 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
// server-side output_assessment dict AND the replay payload built by
// build_merged_output_assessment_payload (both via the shared
// merge_guard_display_payload), so the live chip and the refresh chip
// render identically (tier / judge_risk / confidence / reasoning).
const warning = _buildOutputWarningEl({
risk_level: evt.risk_level,
flags: evt.flags,
redacted: evt.redacted,
tier: evt.tier,
judge_risk: evt.judge_risk,
confidence: evt.confidence,
reasoning: evt.reasoning,
judge_model: evt.judge_model,
});
const nextEl = toolDiv.nextElementSibling;
if (nextEl && nextEl.classList.contains("tool-output")) {
nextEl.insertAdjacentElement("afterend", warning);
} else {
toolDiv.insertAdjacentElement("afterend", warning);
}
}
updateVerdictBadge(verdict) {
if (!verdict || !verdict.call_id) return;
const escapedId = CSS.escape(verdict.call_id);
// 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) {
// Badge no longer in DOM (tool block replaced by output) — toast the
// late-arriving verdict so the user still sees it.
const conf = Math.round((verdict.confidence || 0) * 100);
const rec = verdict.recommendation || "review";
const func = verdict.func_name || "";
showToast(
"Judge verdict for " + func + ": " + rec + " (" + conf + "%)",
rec === "approve" ? "success" : rec === "deny" ? "error" : "warning",
);
return;
}
// Replace the badge (+ its detail sibling) with a freshly-built one so the
// landed LLM verdict, its risk stripe, and the detail all refresh at once.
const detail = badge.nextElementSibling;
if (detail && detail.classList.contains("conv-verdict-detail")) {
detail.remove();
}
badge.replaceWith(buildConvVerdict(verdict, { judgePending: false }));
this.updateVerdictGlow(verdict.recommendation);
}
updateVerdictGlow(recommendation) {
if (!this.approvalBlockEl) return;
const actions = this.approvalBlockEl.querySelector(".conv-actions");
if (!actions) return;
// Collect all verdict badges currently visible in this approval block.
const badges = this.approvalBlockEl.querySelectorAll(".conv-verdict");
let worst = recommendation;
for (let i = 0; i < badges.length; i++) {
const recEl = badges[i].querySelector(".conv-verdict-rec");
if (recEl) {
const r = recEl.textContent;
if (r === "deny") {
worst = "deny";
break;
}
if (r === "review" && worst !== "deny") worst = "review";
}
}
actions.classList.remove(
"conv-verdict-glow--approve",
"conv-verdict-glow--deny",
"conv-verdict-glow--review",
);
if (worst === "approve")
actions.classList.add("conv-verdict-glow--approve");
else if (worst === "deny") actions.classList.add("conv-verdict-glow--deny");
else actions.classList.add("conv-verdict-glow--review");
}
addInfoMessage(text) {
const el = document.createElement("div");
el.className = "msg info";
el.textContent = stripAnsi(text);
this.messagesEl.appendChild(el);
this.scrollToBottom();
}
addErrorMessage(text) {
const el = document.createElement("div");
el.className = "msg error";
el.setAttribute("role", "alert");
el.textContent = stripAnsi(text);
this.messagesEl.appendChild(el);
this.scrollToBottom();
}
updateStatus(evt) {
StatusBar.paint(
{
rootEl: this.statusBarEl,
tokensEl: this._sbTokens,
toolsEl: this._sbTools,
turnsEl: this._sbTurns,
},
evt,
);
// Model moved out of the status bar into the composer chip; capture the
// (optional) effort off the status event and repaint the chip.
this._effort = (evt && evt.effort) || "";
this._paintModelChip();
this._lastStatusEvt = evt;
}
// Paint the composer's "model · effort" read-out chip. SILENT_EFFORTS
// mirror (status_bar.js): "medium" is the implicit default and "" means no
// knob — neither is worth showing, so only a non-medium effort appends the
// "· effort" suffix. An empty alias resets the chip to its placeholder.
_paintModelChip() {
if (!this.composer || !this.composer.setModel) return;
const alias = this.modelAlias || this.model || "";
const eff =
this._effort && this._effort !== "medium" ? " · " + this._effort : "";
this.composer.setModel(alias ? alias + eff : "");
}
// Paint the composer's "has a project" badge from the connected event's
// project_name ("" = no project → the chip stays hidden).
_paintProjectChip() {
if (this.composer && this.composer.setProject)
this.composer.setProject(this.projectName || "");
}
isNearBottom() {
// 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._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() {
this.el = document.createElement("div");
this.el.className = "pane pane--embedded";
this.el.dataset.paneId = this.id;
// Approval keyboard shortcuts. The converged card advertises y/n/a (+Enter/
// Esc) kbd hints, so route those keys to resolveApproval when this pane has a
// pending approval. Pane-owned (on this.el) so it works for every embedded
// L-shell pane without a global handler — the old standalone wired this via
// the app.js global keydown + getFocusedPane, which the fork collapse retired.
// The composer is disabled while pending, so the only typing surface is the
// feedback field: there Enter approves (with feedback) / Esc denies and other
// keys type; elsewhere y|Enter approve, n|Esc deny, a = approve-all.
this.el.addEventListener("keydown", (e) => {
if (!this.pendingApproval || !this.approvalBlockEl) return;
const fb = this.approvalBlockEl.querySelector(".conv-feedback");
if (fb && document.activeElement === fb) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
this.resolveApproval(true, false, this.getFeedback());
} else if (e.key === "Escape") {
e.preventDefault();
this.resolveApproval(false, false, this.getFeedback());
}
return;
}
const k = e.key.toLowerCase();
if (k === "y" || e.key === "Enter") {
e.preventDefault();
this.resolveApproval(true, false, this.getFeedback());
} else if (k === "n" || e.key === "Escape") {
e.preventDefault();
this.resolveApproval(false, false, this.getFeedback());
} else if (k === "a") {
e.preventDefault();
this.resolveApproval(true, true, this.getFeedback());
}
});
// Click-to-play for media embeds. Pane-owned (on this.el) and root-scoped
// via closest(".media-play-btn") so every embedded L-shell pane activates
// its own players — the old standalone wired this via a document-level
// delegated listener in app.js, which the console host never loaded (so the
// Play button was dead in console-hosted panes). Enter on a focused button
// routes through the same path, mirroring the approval keydown above.
this.el.addEventListener("click", (e) => {
const btn = e.target.closest(".media-play-btn");
if (!btn) return;
e.preventDefault();
activateMediaPlayButton(btn);
});
this.el.addEventListener("keydown", (e) => {
if (e.key !== "Enter") return;
const btn = e.target.closest(".media-play-btn");
if (!btn || btn.disabled) return;
// Single-path activation: preventDefault stops the browser's native
// Enter-to-click from dispatching a second activation behind ours.
e.preventDefault();
activateMediaPlayButton(btn);
});
// No pane header: the workstream name, persona, and state are shown by the
// tab and the rail (Workspaces); the --skip-permissions banner lands in
// messagesEl (see the host warningTarget). The standalone split-pane
// chrome that used to live here (focus tracking, right-click context menu,
// a header with split/close buttons) was retired with the step-6 fork
// collapse.
// Messages area
this.messagesEl = document.createElement("div");
this.messagesEl.className = "pane-messages";
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)
this.statusBarEl = document.createElement("div");
this.statusBarEl.className = "ws-status-bar";
this.statusBarEl.setAttribute("role", "status");
this.statusBarEl.setAttribute("aria-live", "polite");
this.statusBarEl.setAttribute("aria-atomic", "true");
this.statusBarEl.setAttribute("aria-label", "Workstream status");
this._sbTokens = document.createElement("span");
this._sbTokens.className = "ws-sb-tokens";
this._sbTokens.textContent = "0 / \u2014";
this._sbTokens.setAttribute("aria-label", "Token usage");
this._sbTools = document.createElement("span");
this._sbTools.className = "ws-sb-tools";
this._sbTools.textContent = "0 tools";
this._sbTools.setAttribute("aria-label", "Tool calls this turn");
this._sbTurns = document.createElement("span");
this._sbTurns.className = "ws-sb-turns";
this._sbTurns.textContent = "turn 0";
this._sbTurns.setAttribute("aria-label", "Conversation turn");
this.statusBarEl.appendChild(this._sbTokens);
this.statusBarEl.appendChild(this._sbTools);
this.statusBarEl.appendChild(this._sbTurns);
this.el.appendChild(this.statusBarEl);
// Input area — DOM + behavior comes from shared/composer.js. The
// pane keeps the attachment-upload pipeline (because attachments are
// pane-specific state) and routes file events through the composer's
// attach/paste/drop callbacks.
this.composer = new Composer(this.el, {
sendGlyph: "\u2191",
layout: "stacked",
modelChip: true,
projectChip: true,
attachments: {
onAttach: (file) => {
this.attachments.upload(file);
},
},
stopBtn: true,
queueWhileBusy: true,
busyPlaceholder: "Queue a message\u2026 (!!! for urgent)",
onSend: () => {
this.sendMessage();
},
onStop: () => {
this.cancelGeneration();
},
dragDrop: { targetEl: this.el, dropClass: "pane-drop-target" },
});
this.inputEl = this.composer.inputEl;
this.sendBtn = this.composer.sendBtn;
this.stopBtn = this.composer.stopBtn;
// Lazy wsId read \u2014 a tab swap (Pane re-bound to a new workstream)
// changes the closure target without re-instantiating the controllers.
this.attachments = createAttachmentController({
chipsEl: this.composer.chipsEl,
getWsId: () => {
return this.wsId;
},
getBase: () => {
return this._base;
},
onError: (msg) => {
showToast(msg);
},
});
this.queue = createQueueController({
messagesEl: this.messagesEl,
getWsId: () => {
return this.wsId;
},
// Node-proxy prefix — same seam the attachment controller uses, so
// the dequeue DELETE reaches the node owning the session (a proxied
// remote-node workstream has base "/node/{id}"; without this the
// x-delete hit the console root and silently failed).
getBase: () => {
return this._base;
},
onAfterDequeue: () => {
this.attachments.rehydrate();
},
onNotice: (msg) => {
showToast(msg);
},
// Idle-edge cleanup of the cancel/force-stop timers — without
// this they fire on the *next* busy turn, relabel Stop to "Force
// Stop", and surface a misleading "Cancel didn't complete in
// time" toast about a turn the user already moved past.
onIdle: () => {
if (this._cancelTimeout) {
clearTimeout(this._cancelTimeout);
this._cancelTimeout = null;
}
if (this._forceTimeout) {
clearTimeout(this._forceTimeout);
this._forceTimeout = null;
}
},
});
// Voice input: mic button, hidden until the STT role is confirmed
// available (so it never appears when voice isn't configured).
this._buildMicButton();
getVoiceRoles(this._base).then((roles) => {
this._voiceRoles = roles;
if (this._micBtn) this._micBtn.style.display = roles.stt ? "" : "none";
// Right-align the mic next to send (instead of stranded by the model
// chip) only when STT is actually available; .has-mic drives the CSS.
if (this.composer && this.composer.actionsRowEl)
this.composer.actionsRowEl.classList.toggle("has-mic", !!roles.stt);
});
}
connectSSE(wsId) {
this.disconnectSSE();
const wsChanged = this.wsId !== wsId;
this.wsId = wsId;
if (wsChanged) {
this.attachments.clearChips();
this.attachments.rehydrate();
}
// Build the events URL with a ``?last_event_id=N`` query param
// if we have a saved high-water mark from a prior connection.
// The EventSource constructor can't set custom headers, so the
// browser-native ``Last-Event-ID`` header isn't available here;
// the server accepts both forms. ``_lastEventId`` is captured
// from the prior source's onmessage handler.
let evtUrl =
this._base +
"/v1/api/workstreams/" +
encodeURIComponent(wsId) +
"/events";
// ``!= null`` (not truthiness): a resume cursor of 0 is valid — the
// ring buffer's first emitted event is id 1, so register_listener_with_replay(0)
// replays the whole in-flight turn. A brand-new ws seeds _event_id at 0,
// so its first user row (and thus a first-turn /history cursor) can be 0;
// a truthiness gate would silently drop it and fall back to the lossy
// fresh snapshot. Mirrors the ``data.cursor != null`` guard in _refetchHistory.
if (this._lastEventId != null) {
evtUrl += "?last_event_id=" + encodeURIComponent(this._lastEventId);
}
this.evtSource = new EventSource(evtUrl);
this.evtSource.onopen = () => {
this.retryDelay = 1000;
this.statusBarEl.classList.remove("ws-sb-disconnected");
if (this._lastStatusEvt) this.updateStatus(this._lastStatusEvt);
this._host.onStreamOpen(this);
};
this.evtSource.onmessage = (e) => {
// Capture lastEventId BEFORE JSON.parse so a (rare) malformed
// event doesn't desync the manual-reconnect fallback from
// native auto-reconnect (which advances lastEventId regardless
// of whether we successfully process the data). Server's
// stamping contract: ``id:`` only on events sourced from the
// per-ws ring buffer — synthetic replay events (history /
// state_change / in_progress_snapshot) don't advance the
// counter, so reconnect resumes from the last BUFFERED id (or
// none on a truly-fresh connect that never received one).
if (this.evtSource && this.evtSource.lastEventId) {
this._lastEventId = this.evtSource.lastEventId;
}
// 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;
try {
this.handleEvent(data);
} catch (err) {
console.error(
"interactive: handleEvent failed for " + (data && data.type),
err,
);
}
};
this.evtSource.onerror = () => {
// Do NOT close evtSource for transient network errors — native
// EventSource auto-reconnect handles them with the
// ``Last-Event-ID`` header automatically (now that the server
// emits ``id:`` on every buffered event). Closing here would
// force a CONNECTING -> CLOSED transition that defeats native
// reconnect, which is exactly the reconnect-with-replay defect
// PR-D ships to fix. See
// tests/test_app_js.py::test_pane_connectsse_onerror_preserves_native_reconnect.
const loginOverlay = document.getElementById("login-overlay");
if (loginOverlay && loginOverlay.style.display !== "none") return;
this.statusBarEl.classList.add("ws-sb-disconnected");
this._sbTokens.textContent = "Reconnecting\u2026";
// Stream-error recovery is the host's policy. Native EventSource
// auto-reconnect transparently covers the transient case for every
// pane; the standalone focused pane additionally refetches the global
// workstream list to reassign a ws evicted during the gap (app.js),
// while a console/embedded pane owns one ws and relies on native
// reconnect alone.
this._host.onStreamError(this);
};
}
_loadHistoryThenConnect(wsId) {
// Mirror coord's init() ordering: render history from REST first,
// THEN open the live stream. Disconnect any existing stream up
// front so stray events from the previously-assigned ws don't paint
// into the pane mid-fetch. History is no longer replayed over SSE,
// so this REST fetch is the sole first-paint source; connectSSE's
// in_progress_snapshot still covers a generation that lands between
// the fetch and the stream opening.
this.disconnectSSE();
// (Re)load of a (possibly different) ws: drop the per-ws SSE replay
// cursor + cached status. Sending the previous ws's last_event_id to a
// new ws mis-triggers the server's replay_ok path and skips the synthetic
// replay (connected / status / in_progress_snapshot); the fresh connect
// 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;
// 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.
const token = (this._historyLoadToken || 0) + 1;
this._historyLoadToken = token;
// ``seedCursor=true``: this is the initial-connect path (the
// ``.finally`` reconnects), so a resume cursor from /history should
// seed _lastEventId for that connect. The clear_ui / replay_truncated
// re-render callers pass it false — they run on an already-live stream
// and must NOT rewind _lastEventId off the live position.
this._refetchHistory(wsId, token, true).finally(() => {
if (token === this._historyLoadToken) this.connectSSE(wsId);
});
}
async _refetchHistory(wsId, token, seedCursor = false) {
// Fetch conversation history over REST. Used for first paint (before
// connecting SSE) and to re-render after a clear_ui signal (rewind /
// retry / resume / open). The FETCH is wrapped (network/parse failure
// → empty pane); the render is deliberately OUTSIDE the catch so a
// render bug surfaces loudly instead of being masked as an empty pane.
//
// ``seedCursor`` is true ONLY on the initial-connect path
// (_loadHistoryThenConnect, which reconnects via .finally). The
// re-render callers leave it false so a fast-forward cursor never
// rewinds the live stream's _lastEventId backward (which would
// double-render on a later transient reconnect, or — on a re-render
// that trims an orphan with no reconnect — strand the omitted turn).
const id = wsId || this.wsId;
let data = null;
try {
const r = await authFetch(
this._base +
"/v1/api/workstreams/" +
encodeURIComponent(id) +
"/history",
);
if (r && r.ok) data = await r.json();
} catch (err) {
data = null;
}
// 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) {
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
// returns a non-null ``cursor`` (a Last-Event-ID) and OMITS that
// turn from ``messages``. On the initial-connect path only
// (``seedCursor``), seed ``_lastEventId`` so the connectSSE below
// opens the initial stream with ?last_event_id=, taking the
// replay_ok delta path that rebuilds the in-flight turn (tool
// calls, results, prompts) through the live handlers — no synthetic
// snapshot, no /history-vs-delta double-render. Null cursor leaves
// _lastEventId untouched (fresh connect, already nulled by
// _loadHistoryThenConnect); the re-render callers never seed.
if (seedCursor && data.cursor != null) this._lastEventId = data.cursor;
// The REST /history payload is already the canonical projected wire
// 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. 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);
}
}
}
_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;
this.setBusy(true);
this.removeEmptyState();
this.addThinkingIndicator();
break;
case "thinking_stop":
this.isThinking = false;
this.removeThinkingIndicator();
break;
case "reasoning":
this.removeThinkingIndicator();
if (!this.currentReasoningEl) {
this.currentReasoningEl = document.createElement("div");
this.currentReasoningEl.className = "msg reasoning";
this.messagesEl.appendChild(this.currentReasoningEl);
}
this.currentReasoningEl.textContent += evt.text;
this.scrollToBottom();
break;
case "content":
this.removeThinkingIndicator();
if (this.currentReasoningEl) {
this.currentReasoningEl = null;
}
if (!this.currentAssistantEl) {
this.currentAssistantEl = document.createElement("div");
this.currentAssistantEl.className = "msg assistant";
this.currentAssistantBodyEl = document.createElement("div");
this.currentAssistantBodyEl.className = "msg-body";
this.currentAssistantEl.appendChild(this.currentAssistantBodyEl);
this.messagesEl.appendChild(this.currentAssistantEl);
}
this.contentBuffer += evt.text;
streamingRender(this.currentAssistantBodyEl, this.contentBuffer);
this.scrollToBottom();
break;
case "stream_end": {
if (this._cancelTimeout) {
clearTimeout(this._cancelTimeout);
this._cancelTimeout = null;
}
if (this._forceTimeout) {
clearTimeout(this._forceTimeout);
this._forceTimeout = null;
}
// 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
// when this client connects mid-stream (page refresh while the
// model is generating). Both fields may be empty; render only
// the non-empty halves. Idempotent on EventSource auto-reconnect:
// skip overwrite when the current buffer is already at-or-past
// the snapshot length, so a stale replay can't reset the live-
// streamed view back to a shorter prefix.
this.removeThinkingIndicator();
if (evt.reasoning) {
if (!this.currentReasoningEl) {
this.currentReasoningEl = document.createElement("div");
this.currentReasoningEl.className = "msg reasoning";
this.messagesEl.appendChild(this.currentReasoningEl);
}
const curReason = this.currentReasoningEl.textContent || "";
if (curReason.length < evt.reasoning.length) {
this.currentReasoningEl.textContent = evt.reasoning;
}
}
if (evt.content) {
// Content snapshot supersedes any reasoning bubble — matches
// the "case content" invariant of clearing currentReasoningEl
// when content begins.
if (this.currentReasoningEl) {
this.currentReasoningEl = null;
}
if (!this.currentAssistantEl) {
this.currentAssistantEl = document.createElement("div");
this.currentAssistantEl.className = "msg assistant";
this.currentAssistantBodyEl = document.createElement("div");
this.currentAssistantBodyEl.className = "msg-body";
this.currentAssistantEl.appendChild(this.currentAssistantBodyEl);
this.messagesEl.appendChild(this.currentAssistantEl);
}
if (this.contentBuffer.length < evt.content.length) {
this.contentBuffer = evt.content;
streamingRender(this.currentAssistantBodyEl, this.contentBuffer);
}
}
this.scrollToBottom();
break;
case "state_change":
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);
}
// Only steal focus if this is the active pane and no approval pending.
if (this._host.isFocused(this) && !this.pendingApproval) {
this.inputEl.focus();
}
} else if (
evt.state === "thinking" ||
evt.state === "running" ||
evt.state === "attention"
) {
this.setBusy(true);
}
break;
case "tool_pending":
// Early paint — render the pending call the instant the model
// commits to it, before the judge verdict + approval gate resolve,
// so the operator can Stop in an emergency. The authoritative
// tool_info / approve_request that follows upgrades THIS block in
// place (matched by call_id) rather than appending a duplicate.
// A sub-agent's steps (parent_call_id set) nest in the task card.
if (!this._routeAgentItems(evt.items, "pending")) {
this.announceToolBlock(evt.items);
}
break;
case "tool_info":
// A sub-agent's auto-resolved step (policy / "Always" auto-approval, or
// a policy block) arrives as a tool_info with parent_call_id stamped —
// route it into the task card like tool_pending / approve_request,
// instead of painting a duplicate top-level row. Top-level tool_info
// (no parent_call_id) falls through to the normal inline block.
if (!this._routeAgentItems(evt.items, "info")) {
this.showInlineToolBlock(evt.items, true);
}
break;
case "approve_request":
if (!this._routeAgentItems(evt.items, "approve", evt.judge_pending)) {
this.showInlineToolBlock(evt.items, false, evt.judge_pending);
}
break;
case "intent_verdict":
this.updateVerdictBadge(evt);
break;
case "output_warning":
this.showOutputWarning(evt);
break;
case "approval_resolved":
this.resolveApproval(evt.approved, false, evt.feedback, true);
break;
case "tool_output_chunk":
this.appendToolOutputChunk(evt.call_id || "", evt.chunk);
break;
case "tool_result":
this.appendToolOutput(
evt.call_id || "",
evt.name,
evt.output,
evt.is_error,
);
break;
case "status":
this.updateStatus(evt);
break;
case "info":
this.addInfoMessage(evt.message);
break;
case "error":
// Show the error but don't change busy state — state_change
// handles idle/error transitions. on_error fires for non-terminal
// errors (tool parse failures, truncation) mid-turn too.
this.addErrorMessage(evt.message);
break;
case "system_turn": {
// First-class operator-context system turn (output-guard finding,
// user interjection, metacognitive nudge — see
// tool_advisory.make_system_turn). Consolidates the legacy
// user_reminder / tool_reminder events into one operator bubble
// rendered in trajectory sequence (it FOLLOWS the turn it advises,
// so by the time this SSE event arrives the related turn already
// rendered). ``evt.source`` carries the kind for the bubble label;
// ``evt.meta`` the structured per-kind fields (watch-result card).
// Dedup: if this turn was already painted from /history (its row id
// matches this event's id) an SSE replay redelivered it — skip. With
// the resume-cursor fix this shouldn't recur, but the guard keeps the
// /history+replay seam idempotent for system turns regardless.
const sysEid = evt._event_id != null ? String(evt._event_id) : null;
if (
sysEid &&
this._renderedSystemEventIds &&
this._renderedSystemEventIds.has(sysEid)
) {
break;
}
this.addSystemContext(
evt.content || "",
evt.source || "",
evt.meta || null,
);
if (sysEid) {
if (!this._renderedSystemEventIds)
this._renderedSystemEventIds = new Set();
this._renderedSystemEventIds.add(sysEid);
}
break;
}
case "message_queued":
// Confirmation from server that a queued message was accepted.
// The UI already showed the message optimistically in addQueuedMessage.
break;
case "busy_error":
// Server is still busy — don't transition to send mode.
// Re-enable the stop button so the user can try cancelling.
this.addErrorMessage(evt.message);
this.stopBtn.textContent = "\u25a0 Stop";
this.stopBtn.setAttribute("aria-label", "Stop generation");
delete this.stopBtn.dataset.forceCancel;
this.stopBtn.disabled = false;
break;
case "cancelled":
// Cancel requested but worker thread may still be finishing.
// Show "Cancelling..." state; state_change will transition to ready.
// If state_change already arrived (busy is false), the cancel is
// already handled — don't re-enter the cancelling state.
if (!this.busy) break;
// Clear any prior timeouts first (duplicate cancelled events).
clearTimeout(this._cancelTimeout);
clearTimeout(this._forceTimeout);
this.currentAssistantEl = null;
this.currentReasoningEl = null;
this.contentBuffer = "";
this.stopBtn.disabled = true;
this.stopBtn.textContent = "Cancelling\u2026";
this.stopBtn.setAttribute("aria-label", "Cancelling generation");
this.scrollToBottom(true);
// After 2s, offer "Force Stop" for a harder cancel that abandons
// the stuck worker thread. Safety timeout at 10s auto-recovers
// if state_change never arrives (connection drop).
this._cancelTimeout = setTimeout(() => {
if (this.busy) {
this.stopBtn.disabled = false;
this.stopBtn.textContent = "\u26a0 Force Stop";
this.stopBtn.setAttribute("aria-label", "Force stop generation");
this.stopBtn.dataset.forceCancel = "true";
}
}, 2000);
this._forceTimeout = setTimeout(() => {
if (this.busy) {
this.addInfoMessage(
"Cancel didn\u2019t complete in time. You may need to resend your last message.",
);
this.setBusy(false);
}
}, 10000);
break;
case "connected":
this.model = evt.model || "";
this.modelAlias = evt.model_alias || evt.model || "";
this._paintModelChip();
this.projectName = evt.project_name || "";
this._paintProjectChip();
if (evt.skip_permissions) {
const existing = document.querySelector(".skip-permissions-warning");
if (!existing) {
const warn = document.createElement("div");
warn.className = "skip-permissions-warning";
warn.textContent =
"\u26a0 Running with --skip-permissions: all tool calls are auto-approved";
this._host.warningTarget(this).appendChild(warn);
}
}
break;
case "clear_ui": {
// Conversation was structurally reset (rewind / retry / resume /
// open / fork). Empty the pane for immediate feedback, then
// re-render from REST and dispatch any queued edit-and-resend
// once the (possibly truncated) history lands. The resend keys
// off this signal rather than an inline history SSE event. Capture
// 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;
if (!this._pendingEditSend) return;
const editText = this._pendingEditSend;
this._pendingEditSend = null;
this.setBusy(true);
this.addUserMessage(editText);
authFetch(
this._base +
"/v1/api/workstreams/" +
encodeURIComponent(this.wsId) +
"/send",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: editText }),
},
).catch((err) => {
this.addErrorMessage("Connection error: " + err.message);
this.setBusy(false);
});
})
.catch((err) => {
// The render runs outside _refetchHistory's try/catch by design;
// if it throws, don't strand the queued edit-and-resend — clear
// the latch + busy so the composer recovers.
this._pendingEditSend = null;
this.setBusy(false);
this.addErrorMessage("Failed to reload history: " + err.message);
});
break;
}
case "replay_truncated":
// Reconnect buffer evicted past our last-seen event id — the
// live recovery replay no longer carries history, so re-sync
// from REST. Skip while a turn is mid-stream: the recovery
// 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. 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;
}
}
_addUserMsgActions(el, text) {
const bar = document.createElement("div");
bar.className = "msg-actions";
bar.setAttribute("role", "toolbar");
bar.setAttribute("aria-label", "Message actions");
// Edit button
const editBtn = document.createElement("button");
editBtn.className = "msg-action-btn";
editBtn.title = "Edit & resend";
editBtn.setAttribute("aria-label", "Edit and resend this message");
const editIcon = document.createElement("span");
editIcon.className = "icon-edit";
editIcon.setAttribute("aria-hidden", "true");
editBtn.appendChild(editIcon);
editBtn.addEventListener("click", (e) => {
e.stopPropagation();
this._startEdit(el, text);
});
bar.appendChild(editBtn);
// Rewind-to-here button
const rewindBtn = document.createElement("button");
rewindBtn.className = "msg-action-btn";
rewindBtn.title = "Rewind to before this message";
rewindBtn.setAttribute(
"aria-label",
"Rewind conversation to before this message",
);
const rewindIcon = document.createElement("span");
rewindIcon.className = "icon-rewind";
rewindIcon.setAttribute("aria-hidden", "true");
rewindBtn.appendChild(rewindIcon);
rewindBtn.addEventListener("click", (e) => {
e.stopPropagation();
this._rewindToMessage(el);
});
bar.appendChild(rewindBtn);
el.appendChild(bar);
}
_addRetryAction(el) {
let bar = el.querySelector(".msg-actions");
if (!bar) {
bar = document.createElement("div");
bar.className = "msg-actions";
bar.setAttribute("role", "toolbar");
bar.setAttribute("aria-label", "Message actions");
el.appendChild(bar);
}
const btn = document.createElement("button");
btn.className = "msg-action-btn";
btn.title = "Retry (regenerate response)";
btn.setAttribute("aria-label", "Retry last response");
const icon = document.createElement("span");
icon.className = "icon-retry";
icon.setAttribute("aria-hidden", "true");
btn.appendChild(icon);
btn.addEventListener("click", (e) => {
e.stopPropagation();
this._retryLast();
});
bar.insertBefore(btn, bar.firstChild);
}
// -------------------------------------------------------------------------
// Voice I/O: microphone dictation (STT) + per-message playback (TTS)
// -------------------------------------------------------------------------
_buildMicButton() {
if (!this.composer || !this.composer.actionsRowEl) return;
const btn = document.createElement("button");
btn.type = "button";
btn.className = "composer-mic-btn";
btn.style.display = "none"; // revealed once the STT role is confirmed
btn.title = "Record speech to text";
btn.setAttribute("aria-label", "Record speech to text");
btn.setAttribute("aria-pressed", "false");
const icon = document.createElement("span");
icon.className = "composer-mic-icon icon-mic";
icon.setAttribute("aria-hidden", "true");
btn.appendChild(icon);
this._micIcon = icon;
btn.addEventListener("click", () => this._toggleRecording());
this.composer.actionsRowEl.insertBefore(btn, this.sendBtn || null);
this._micBtn = btn;
}
_syncMicButton() {
if (!this._micBtn) return;
const rec = !!this._isRecording;
this._micBtn.classList.toggle("is-recording", rec);
this._micBtn.setAttribute("aria-pressed", rec ? "true" : "false");
if (this._micIcon) {
this._micIcon.classList.toggle("icon-stop", rec);
this._micIcon.classList.toggle("icon-mic", !rec);
}
const label = rec
? "Stop recording and transcribe"
: "Record speech to text";
this._micBtn.title = label;
this._micBtn.setAttribute("aria-label", label);
}
_toggleRecording() {
if (this._isRecording) {
this._stopRecording(false);
} else {
this._startRecording();
}
}
_startRecording() {
if (this._isRecording || this.busy) return;
if (
!navigator.mediaDevices ||
!navigator.mediaDevices.getUserMedia ||
typeof MediaRecorder === "undefined"
) {
showToast("Microphone capture is not supported in this browser", "error");
return;
}
// Synchronous abort latch: if teardown (reset / pane switch) runs while the
// permission prompt is open, the stream the promise later hands us must be
// stopped instead of going hot after teardown.
this._recordAborted = false;
navigator.mediaDevices
.getUserMedia({ audio: true })
.then((stream) => {
if (this._recordAborted) {
stream.getTracks().forEach((t) => t.stop());
return;
}
this._recordingStream = stream;
let mimeType = "";
const candidates = [
"audio/webm;codecs=opus",
"audio/webm",
"audio/ogg;codecs=opus",
"audio/mp4",
];
for (let i = 0; i < candidates.length; i++) {
if (
MediaRecorder.isTypeSupported &&
MediaRecorder.isTypeSupported(candidates[i])
) {
mimeType = candidates[i];
break;
}
}
const rec = mimeType
? new MediaRecorder(stream, { mimeType })
: new MediaRecorder(stream);
const chunks = [];
rec.addEventListener("dataavailable", (e) => {
if (e.data && e.data.size) chunks.push(e.data);
});
rec.addEventListener("stop", () => {
this._teardownRecordingStream();
this._isRecording = false;
this._stopRecordTimer();
this._syncMicButton();
const discard = this._discardRecording;
this._discardRecording = false;
if (discard) return;
const blob = new Blob(chunks, {
type: rec.mimeType || mimeType || "audio/webm",
});
if (blob.size) this._uploadForTranscription(blob);
});
this._recorder = rec;
this._isRecording = true;
this._discardRecording = false;
this._startRecordTimer();
this._syncMicButton();
voiceAnnounce(
"Recording. Activate the microphone button again to stop.",
);
rec.start();
})
.catch(() => {
// Denied / hardware unavailable: leave a persistent disabled state with
// guidance — a hot button reads as dead once the browser blocks re-prompts.
this._teardownRecordingStream();
this._isRecording = false;
this._stopRecordTimer();
this._setMicDenied();
});
}
_setMicDenied() {
this._micDenied = true;
showToast("Microphone access was denied", "error");
this._syncMicButton();
if (this._micBtn) {
this._micBtn.disabled = true;
const msg =
"Microphone blocked — enable it in your browser's site settings";
this._micBtn.title = msg;
this._micBtn.setAttribute("aria-label", msg);
}
}
_startRecordTimer() {
this._recordStartMs = Date.now();
this._stopRecordTimer();
this._recordTimer = window.setInterval(() => this._tickRecordTimer(), 500);
}
_stopRecordTimer() {
if (this._recordTimer) {
window.clearInterval(this._recordTimer);
this._recordTimer = null;
}
}
_tickRecordTimer() {
if (!this._isRecording || !this._micBtn) return;
const secs = Math.max(
0,
Math.floor((Date.now() - this._recordStartMs) / 1000),
);
const mmss =
Math.floor(secs / 60) + ":" + String(secs % 60).padStart(2, "0");
this._micBtn.title = "Recording " + mmss + " — activate to stop";
}
_stopRecording(discard) {
this._discardRecording = !!discard;
this._recordAborted = true; // abort a getUserMedia still in flight
if (this._recorder && this._recorder.state !== "inactive") {
try {
this._recorder.stop();
} catch (e) {
/* already stopped */
}
return;
}
this._teardownRecordingStream();
this._stopRecordTimer();
if (this._isRecording) {
this._isRecording = false;
this._syncMicButton();
}
}
_teardownRecordingStream() {
if (this._recordingStream) {
this._recordingStream.getTracks().forEach((t) => t.stop());
this._recordingStream = null;
}
this._recorder = null;
}
_uploadForTranscription(blob) {
if (!this.wsId) return;
const ext =
blob.type.indexOf("ogg") !== -1
? "ogg"
: blob.type.indexOf("mp4") !== -1
? "mp4"
: "webm";
const fd = new FormData();
fd.append("audio", blob, "speech." + ext);
if (this._micBtn) {
this._micBtn.disabled = true;
this._micBtn.classList.add("is-busy");
}
voiceAnnounce("Transcribing…");
const resetMic = () => {
if (this._micBtn && !this._micDenied) {
this._micBtn.disabled = !!this.busy;
this._micBtn.classList.remove("is-busy");
}
};
authFetch(
this._base +
"/v1/api/workstreams/" +
encodeURIComponent(this.wsId) +
"/speech-to-text/stream",
{ method: "POST", body: fd },
)
.then(async (r) => {
if (!r.ok) {
let msg = "Transcription failed";
try {
const body = await r.json();
if (body && body.error) msg = body.error;
} catch (_e) {
/* non-JSON error body */
}
showToast(msg, "error");
return;
}
// Stream transcript deltas into the composer as they arrive (first word
// in ~0.3s) instead of waiting for the whole transcript.
const reader = r.body.getReader();
const decoder = new TextDecoder();
let started = false;
let got = false;
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
if (!chunk || !this.inputEl) continue;
got = true;
if (!started) {
// Read the composer's value now (not before the await) so text the
// user typed while transcribing isn't clobbered.
const cur = this.inputEl.value || "";
this.inputEl.value = cur
? cur.replace(/\s*$/, "") + " " + chunk
: chunk;
started = true;
} else {
this.inputEl.value += chunk;
}
// Drive the composer's auto-resize + send-enable listeners.
this.inputEl.dispatchEvent(new Event("input", { bubbles: true }));
}
if (got) {
if (this.inputEl) this.inputEl.focus();
voiceAnnounce("Transcript added to message.");
} else {
showToast("No speech detected", "error");
}
})
.catch((err) => {
showToast(
"Transcription failed: " + (err && err.message ? err.message : err),
"error",
);
})
.finally(resetMic);
}
_addTtsAction(el) {
let bar = el.querySelector(".msg-actions");
if (!bar) {
bar = document.createElement("div");
bar.className = "msg-actions";
bar.setAttribute("role", "toolbar");
bar.setAttribute("aria-label", "Message actions");
el.appendChild(bar);
}
if (bar.querySelector(".msg-tts-btn")) return; // already added
const btn = document.createElement("button");
btn.className = "msg-action-btn msg-tts-btn";
btn.title = "Play response aloud";
btn.setAttribute("aria-label", "Play response aloud");
btn.setAttribute("aria-pressed", "false");
const icon = document.createElement("span");
icon.className = "icon-speaker";
icon.setAttribute("aria-hidden", "true");
btn.appendChild(icon);
btn.addEventListener("click", (e) => {
e.stopPropagation();
this._playMessageTTS(el, btn);
});
bar.appendChild(btn);
}
// Strip code blocks / inline code / rendered math so TTS doesn't read source
// or KaTeX accessibility text out character-by-character.
_extractSpeakableText(bodyEl) {
const clone = bodyEl.cloneNode(true);
clone
.querySelectorAll("pre, code, .katex, .katex-display")
.forEach((n) =>
n.replaceWith(document.createTextNode(" (code omitted) ")),
);
return (clone.textContent || "").replace(/\s+/g, " ").trim();
}
_playMessageTTS(el, btn) {
// Toggle: clicking the active button (or any while playing) stops first.
if (this._ttsAudio) {
const wasThis = this._ttsBtnActive === btn;
this._stopTTS();
if (wasThis) return;
}
const bodyEl = el.querySelector(".msg-body") || el;
const text = this._extractSpeakableText(bodyEl);
if (!text) return;
// Serialize: a monotonic token guards against an earlier (slower) request
// resolving after a newer one — which would double-play and leak the blob.
const token = ++this._ttsSeq;
btn.classList.add("is-busy");
btn.disabled = true;
authFetch(this._base + "/v1/api/tts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
})
.then((r) => {
if (!r.ok) {
return r.json().then((b) => {
throw new Error((b && b.error) || "Speech synthesis failed");
});
}
return r.blob();
})
.then((audioBlob) => {
const url = URL.createObjectURL(audioBlob);
if (token !== this._ttsSeq) {
URL.revokeObjectURL(url); // superseded — don't play or leak
return;
}
const audio = new Audio(url);
this._ttsAudio = audio;
this._ttsBtnActive = btn;
btn.classList.add("is-playing");
btn.setAttribute("aria-pressed", "true");
voiceAnnounce("Playing response.");
audio.addEventListener("ended", () => this._stopTTS());
audio.addEventListener("error", () => this._stopTTS());
audio.play().catch(() => this._stopTTS());
})
.catch((err) => {
showToast(
err && err.message ? err.message : "Speech synthesis failed",
"error",
);
})
.finally(() => {
btn.classList.remove("is-busy");
btn.disabled = false;
});
}
_stopTTS() {
this._ttsSeq++; // invalidate any in-flight request
if (this._ttsAudio) {
try {
this._ttsAudio.pause();
} catch (e) {
/* ignore */
}
const src = this._ttsAudio.src || "";
if (src.indexOf("blob:") === 0) URL.revokeObjectURL(src);
this._ttsAudio = null;
}
if (this._ttsBtnActive) {
this._ttsBtnActive.classList.remove("is-playing");
this._ttsBtnActive.setAttribute("aria-pressed", "false");
this._ttsBtnActive = null;
}
}
_retryLast() {
if (this.busy) return;
// Path-keyed retry (#549). Truncation + re-dispatch happen
// server-side; the clear_ui event drives the history refetch.
authFetch(
this._base +
"/v1/api/workstreams/" +
encodeURIComponent(this.wsId) +
"/retry",
{ method: "POST" },
).catch((err) => {
this.addErrorMessage("Retry failed: " + err.message);
});
}
// Path-keyed rewind (#549) by absolute turn count. Shared by the
// per-message rewind button and the hand-typed /rewind reroute.
_rewindToTurns(turns) {
if (this.busy) return;
if (!Number.isInteger(turns) || turns < 1) return;
authFetch(
this._base +
"/v1/api/workstreams/" +
encodeURIComponent(this.wsId) +
"/rewind",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ turns }),
},
).catch((err) => {
this.addErrorMessage("Rewind failed: " + err.message);
});
}
_rewindToMessage(msgEl) {
if (this.busy) return;
// Count how many user messages come at or after this one. Bare
// ``.msg.user`` is intentional: system-nudge markers carry that
// class and the server's _find_turn_boundaries counts them as
// turns too, so this matches the server's rewind-N semantics.
const userMsgs = this.messagesEl.querySelectorAll(".msg.user");
const idx = Array.prototype.indexOf.call(userMsgs, msgEl);
if (idx < 0) return;
const turnsToRewind = userMsgs.length - idx;
this._rewindToTurns(turnsToRewind);
}
_startEdit(msgEl, originalText) {
if (this.busy) return;
// Save current child nodes for cancel restoration
const savedNodes = [];
while (msgEl.firstChild) {
savedNodes.push(msgEl.removeChild(msgEl.firstChild));
}
msgEl.classList.add("msg-editing");
const form = document.createElement("div");
form.className = "msg-edit-form";
const textarea = document.createElement("textarea");
textarea.className = "msg-edit-textarea";
textarea.setAttribute("aria-label", "Edit message text");
textarea.value = originalText;
textarea.rows = Math.min(originalText.split("\n").length + 1, 8);
form.appendChild(textarea);
const actions = document.createElement("div");
actions.className = "msg-edit-actions";
const cancelBtn = document.createElement("button");
cancelBtn.className = "msg-edit-btn";
cancelBtn.textContent = "Cancel";
cancelBtn.addEventListener("click", () => {
// Restore original nodes
while (msgEl.firstChild) msgEl.removeChild(msgEl.firstChild);
savedNodes.forEach((n) => {
msgEl.appendChild(n);
});
msgEl.classList.remove("msg-editing");
});
actions.appendChild(cancelBtn);
const sendBtn = document.createElement("button");
sendBtn.className = "msg-edit-btn msg-edit-btn-send";
sendBtn.textContent = "Send";
sendBtn.addEventListener("click", () => {
const newText = textarea.value.trim();
if (!newText) return;
this._editAndResend(msgEl, newText);
});
actions.appendChild(sendBtn);
// Ctrl+Enter to send, Escape to cancel
textarea.addEventListener("keydown", (e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
sendBtn.click();
} else if (e.key === "Escape") {
e.preventDefault();
cancelBtn.click();
}
});
form.appendChild(actions);
msgEl.appendChild(form);
textarea.focus();
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
}
_editAndResend(msgEl, newText) {
if (this.busy) return;
// Count turns to rewind (from this message onward). Bare
// ``.msg.user`` matches the server's turn semantics — see
// _rewindToMessage.
const userMsgs = this.messagesEl.querySelectorAll(".msg.user");
const idx = Array.prototype.indexOf.call(userMsgs, msgEl);
if (idx < 0) return;
const turnsToRewind = userMsgs.length - idx;
this.setBusy(true);
// Store pending send — dispatched from the clear_ui handler once
// the rewind's truncated history is re-fetched over REST.
this._pendingEditSend = newText;
authFetch(
this._base +
"/v1/api/workstreams/" +
encodeURIComponent(this.wsId) +
"/rewind",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ turns: turnsToRewind }),
},
)
.then(async (r) => {
if (r && !r.ok) {
this._pendingEditSend = null;
this.setBusy(false);
this.addErrorMessage(
"Rewind failed (HTTP " + r.status + " " + r.statusText + ")",
);
return;
}
// A 200 {"status":"busy"} means the rewind was rejected because a
// generation is in flight — no clear_ui fires, so the pending-edit
// latch + busy state would otherwise stay stuck (and the latch
// would later resend into the next clear_ui). Clear them here.
let data = null;
try {
data = await r.json();
} catch {
data = null;
}
if (data && data.status === "busy") {
this._pendingEditSend = null;
this.setBusy(false);
this.addErrorMessage(
"Cannot edit & resend while the workstream is processing.",
);
}
})
.catch((err) => {
this._pendingEditSend = null;
this.addErrorMessage("Rewind failed: " + err.message);
this.setBusy(false);
});
}
_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.
this._renderedSystemEventIds = new Set();
if (!messages.length) {
this.showEmptyState();
return;
}
// Suppress the polite live region while we batch-build the replay
// — messagesEl is aria-live="polite" so a fresh replay would otherwise
// queue an announcement for every approved/denied/verdict pill we
// insert. Restored after the loop so live SSE updates announce
// normally. WCAG 4.1.3 — historical content should not behave like
// real-time updates.
this.messagesEl.setAttribute("aria-busy", "true");
// pendingAssessments[call_id] = output_assessment dict. Populated
// from the assistant branch, consumed by the role==="tool" branch
// (or after the loop, for legacy rows missing tool_call_id).
// Replaces a JSON.stringify→dataset→JSON.parse round-trip with an
// in-memory map keyed by call_id.
const pendingAssessments = {};
// Task-agent recall: call_id -> card .conv-agent wrap, so the tool-result
// 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 = {};
let lastToolBlock = null;
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
if (msg.role === "user") {
if (msg.source === "system_nudge") {
// Wake-driven empty user turn: render the thin marker (replaces
// the previously-skipped synthetic empty bubble). The nudges it
// carried are now first-class system turns that follow it and
// render via their own `system` branch below.
this.addSystemNudgeMarker();
lastToolBlock = null;
continue;
}
this.addUserMessage(msg.content || "", msg.attachments || null);
lastToolBlock = null;
} else if (msg.role === "assistant") {
// Reasoning bubble (Phase 1 reasoning persistence) — render
// BEFORE the content bubble so the visual order matches the
// live SSE flow (reasoning_delta arrives before content_delta
// for thinking-enabled models). Mirrors the live-stream
// construction at the "case 'reasoning':" branch above. Only
// surfaces when the active model's surface_persisted_reasoning flag is
// true and the message round-tripped a thinking lane.
if (msg.reasoning && msg.reasoning.length) {
const reasonEl = document.createElement("div");
reasonEl.className = "msg reasoning";
reasonEl.textContent = msg.reasoning;
this.messagesEl.appendChild(reasonEl);
lastToolBlock = null;
}
// Render content BEFORE the tool block so the visual order
// matches the live SSE flow (stream_text streams content first,
// then tool_info / approve_request paints the tool block, then
// tool_result fills it in). Order also matters structurally:
// the tool-result message in the NEXT iteration anchors via
// lastToolBlock, which the tool-block branch sets last — so
// content must run first to avoid clobbering that anchor.
//
// Whitespace-only content (e.g. "\n\n" from a reasoning-parser
// model that strips <think>…</think> and leaves only trailing
// newlines before the tool call) is treated as empty — the
// live stream never accumulated a visible bubble for it, so
// surfacing one on replay would be a phantom card that diverges
// from what the originating tab saw.
if (msg.content && msg.content.trim()) {
const el = document.createElement("div");
el.className = "msg assistant";
const bodyEl = document.createElement("div");
bodyEl.className = "msg-body";
el.appendChild(bodyEl);
setMarkdown(bodyEl, msg.content);
this.messagesEl.appendChild(el);
lastToolBlock = null;
}
if (msg.tool_calls && msg.tool_calls.length) {
if (msg.pending) {
lastToolBlock = null;
} else {
const wasDenied = !!msg.denied;
const block = document.createElement("div");
block.className =
"conv-batch " +
(msg.tool_calls.length >= 2
? "conv-batch--parallel "
: "conv-batch--solo ") +
(wasDenied ? "conv-batch--denied" : "conv-batch--approved");
block.appendChild(
_convApprovalHead(
batchKicker("done", msg.tool_calls.length),
msg.tool_calls.map((tc) => ({ func_name: tc.name })),
),
);
msg.tool_calls.forEach((tc, idx) => {
// Synthesize the live `item` shape from the stored tool_call so
// replay renders the SAME .conv-row as the live path.
const row = buildToolDiv(
synthToolItem(tc),
indexLabel(idx, msg.tool_calls.length),
);
// Verdict anchored to THIS row; replay verdicts are final.
if (tc.verdict) {
row.appendChild(
buildConvVerdict(tc.verdict, { judgePending: false }),
);
}
block.appendChild(row);
// Task-agent recall: rebuild the collapsible card under this row
// from its stashed sub-trajectory (the /history `agent_steps`
// overlay). Absent ⇒ flat parent row (cold / not-retained).
if (tc.agent_steps && tc.agent_steps.length) {
const wrap = this._replayAgentCard(row, tc.agent_steps);
if (tc.id) agentCardWraps[tc.id] = wrap;
}
// Output-guard finding — deferred until the tool result lands so
// it anchors under the output (mirrors live showOutputWarning).
if (
tc.output_assessment &&
tc.output_assessment.risk_level &&
tc.output_assessment.risk_level !== "none"
) {
pendingAssessments[tc.id || ""] = {
assessment: tc.output_assessment,
toolDiv: row,
};
}
});
block.appendChild(
buildConvStatus(
wasDenied ? { approved: false } : { approved: true },
),
);
this.messagesEl.appendChild(block);
lastToolBlock = block;
}
}
} else if (msg.role === "tool") {
if (lastToolBlock) {
const stripped = stripAnsi(msg.content || "").trim();
const isDenied =
msg.denied ||
/^Denied by user/.test(stripped) ||
/^Blocked/.test(stripped);
const isToolError = !!msg.is_error;
// Anchor the rendered output to the specific .conv-row
// element matching this result's tool_call_id — mirrors the
// live appendToolOutput path so multi-tool batches show
// [hdr A][out A][hdr B][out B] rather than [A][B][out A][out B].
// Falls back to "before badge" when tool_call_id is absent
// (legacy rows pre-dating the wire-format addition).
let resultTarget = null;
if (msg.tool_call_id) {
resultTarget = lastToolBlock.querySelector(
'.conv-row[data-call-id="' + CSS.escape(msg.tool_call_id) + '"]',
);
}
// Cursor-style append: cursor advances after each insert so
// the next sibling lands AFTER the previous one. Fixes the
// bug where calling resultTarget.after(node) twice put the
// second node BETWEEN resultTarget and the first (the second
// .after call was always relative to the same anchor).
// Resulting order with all present:
// [tool div][output][output-warning]
let insertCursor = resultTarget;
const insertChained = (node) => {
if (insertCursor) {
insertCursor.after(node);
insertCursor = node;
} else {
const bdg = lastToolBlock.querySelector(".conv-status");
if (bdg) lastToolBlock.insertBefore(node, bdg);
else lastToolBlock.appendChild(node);
}
};
if (stripped && !isDenied) {
const media = !isToolError ? tryParseMedia(stripped) : null;
if (media) {
insertChained(buildMediaEmbed(media, stripped));
} else {
insertChained(renderCollapsibleOutput(stripped, isToolError));
}
}
if (
isToolError &&
!lastToolBlock.classList.contains("conv-batch--denied")
) {
lastToolBlock.classList.add("conv-batch--error");
appendToolErrorBadge(lastToolBlock);
}
// Output-guard warning — pull the assessment out of the
// function-local pendingAssessments map (populated in the
// assistant branch). Skip when the tool result was denied —
// the ✗ denied badge already signals the deny path.
if (!isDenied && msg.tool_call_id) {
const pending = pendingAssessments[msg.tool_call_id];
if (pending) {
insertChained(_buildOutputWarningEl(pending.assessment));
delete pendingAssessments[msg.tool_call_id];
}
}
// Task-agent recall: flip its card done/error from the task's OWN
// result (matching the live appendToolOutput) — NOT from sub-step
// errors, since a sub-tool can fail and the agent still synthesize.
if (msg.tool_call_id && agentCardWraps[msg.tool_call_id]) {
agentCardWraps[msg.tool_call_id].dataset.state = msg.is_error
? "error"
: "done";
}
}
} else if (msg.role === "system") {
// First-class operator-context turn (output-guard finding, user
// interjection, metacognitive nudge — see make_system_turn). These
// now FOLLOW the turn they advise as their own rows; tool-channel
// nudges and queued interjections that used to splice into the tool
// result render here in sequence. `source` is the kind; `meta` the
// structured per-kind fields (watch-result card) from /history.
this.addSystemContext(
msg.content || "",
msg.source || "",
msg.meta || null,
);
if (msg.event_id != null) {
this._renderedSystemEventIds.add(String(msg.event_id));
}
lastToolBlock = null;
}
}
// Flush any output_assessments left in the map — these correspond
// to assistant tool_calls whose tool result row didn't carry a
// tool_call_id (legacy / migrated rows pre-dating the wire-format
// addition). Render the warning under the tool div itself rather
// than dropping the safety information silently.
const leftoverIds = Object.keys(pendingAssessments);
for (let p = 0; p < leftoverIds.length; p++) {
const leftover = pendingAssessments[leftoverIds[p]];
if (!leftover) continue;
leftover.toolDiv.insertAdjacentElement(
"afterend",
_buildOutputWarningEl(leftover.assessment),
);
}
this._attachRetryToLastAssistant();
this.scrollToBottom();
// Focus the input so keyboard users land on the next-action target
// after replay finishes — but only when this is the focused pane,
// there's no pending approval competing for focus, and an input
// element actually exists. Skipping when not the focused pane
// avoids stealing focus from another tab the user is interacting
// with while a background replay completes.
if (
this._host.isFocused(this) &&
!this.pendingApproval &&
this.inputEl &&
!this.busy
) {
try {
this.inputEl.focus({ preventScroll: true });
} catch (_) {
this.inputEl.focus();
}
}
// Restore live-region semantics now that the batch build is done.
this.messagesEl.removeAttribute("aria-busy");
}
_attachRetryToLastAssistant() {
// 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.
//
// Skip retry attachment when the most recent semantic turn is
// tool-only — last DOM child is a .conv-batch block. Walk back past
// operator-context rows (the plain system bubble AND the structured
// watch-result / guard-finding cards — every operator row carries
// .operator-context) which FOLLOW the tool batch they advise, so the
// guard fires even when the tool turn carried a nudge / guard finding.
// Keying on the shared marker (not any single card class) keeps the skip
// correct as new card kinds are added. Without it, retry lands on a
// stale prior assistant content bubble belonging to an earlier turn.
let lastChild = this.messagesEl.lastElementChild;
while (lastChild && lastChild.classList.contains("operator-context")) {
lastChild = lastChild.previousElementSibling;
}
if (lastChild && lastChild.classList.contains("conv-batch")) {
return;
}
// 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;
}
}
announceToolBlock(items) {
const list = (items || []).filter(Boolean);
if (!list.length) return;
// Re-pin to the bottom only if we were already there — captured BEFORE the
// block grows scrollHeight. A tool batch is a tall one-shot append; if
// isNearBottom() were measured after appendChild (as scrollToBottom does on
// its own) the freshly-added height would read >80px from the new bottom,
// so auto-follow would silently disengage at exactly tool-call time. Token
// streaming stays pinned without this because each append is sub-threshold.
const stick = this.isNearBottom();
// Drop a previous un-consumed announce so shells don't pile up.
if (this.announcedBlockEl) {
this.announcedBlockEl.remove();
this.announcedBlockEl = null;
}
const block = document.createElement("div");
block.className =
"conv-batch " +
(list.length >= 2 ? "conv-batch--parallel" : "conv-batch--solo");
// Indeterminate region until the judge/gate resolves; the upgrade in
// showInlineToolBlock clears aria-busy.
block.setAttribute("aria-busy", "true");
block.dataset.callIds = JSON.stringify(
list.map((it) => it.call_id).filter(Boolean),
);
block.appendChild(
_convApprovalHead(batchKicker("evaluating", list.length), list),
);
list.forEach((item, idx) => {
block.appendChild(buildToolDiv(item, indexLabel(idx, list.length)));
const verdict =
item.judge_verdict || item.heuristic_verdict || item.verdict;
if (verdict) {
block.appendChild(buildConvVerdict(verdict, { judgePending: true }));
}
});
this.announcedBlockEl = block;
this.messagesEl.appendChild(block);
this._relinkAgentCards(list);
this.scrollToBottom(stick);
toolAnnounce(_toolAnnounceText(list));
}
// Hand back the early-paint shell to upgrade in place if it matches this
// batch's call_ids, else null (caller builds fresh). Clears the tracking
// ref so the shell is consumed exactly once. Matching by id set is
// defensive — the interactive pane is strictly serial, so an announce is
// always followed by ITS approve_request / tool_info — but it guarantees a
// stale shell can never capture a different batch.
_takeAnnouncedBlock(items) {
const block = this.announcedBlockEl;
if (!block) return null;
this.announcedBlockEl = null;
const want = (items || [])
.map((it) => it.call_id)
.filter(Boolean)
.sort();
let have = [];
try {
have = JSON.parse(block.dataset.callIds || "[]");
} catch (_e) {
have = [];
}
have = have.slice().sort();
const matches =
want.length === have.length && want.every((id, i) => id === have[i]);
if (!matches) {
block.remove(); // stale orphan — discard, caller builds fresh
return null;
}
return block;
}
showInlineToolBlock(items, autoApproved, judgePending) {
// Capture pin before _takeAnnouncedBlock/append change scrollHeight — see
// announceToolBlock for why post-append measurement breaks here.
const stick = this.isNearBottom();
// Reuse the early-paint announce shell if it's for this batch (upgrade in
// place); else build fresh.
const announced = this._takeAnnouncedBlock(items);
const block = announced || document.createElement("div");
if (announced) announced.replaceChildren();
block.removeAttribute("aria-busy");
block.className =
"conv-batch " +
(items.length >= 2 ? "conv-batch--parallel" : "conv-batch--solo") +
(autoApproved ? " conv-batch--auto" : "");
if (!autoApproved) {
block.setAttribute("role", "alertdialog");
block.setAttribute("aria-label", "Tool approval required");
}
block.appendChild(
_convApprovalHead(
autoApproved
? batchKicker("done", items.length)
: batchKicker("pending", items.length),
items,
),
);
let glowRec = null;
items.forEach((item, idx) => {
block.appendChild(buildToolDiv(item, indexLabel(idx, items.length)));
const verdict =
item.judge_verdict || item.heuristic_verdict || item.verdict;
if (verdict) {
block.appendChild(buildConvVerdict(verdict, { judgePending }));
const rec = verdict.recommendation || "review";
if (
!glowRec ||
rec === "deny" ||
(rec === "review" && glowRec === "approve")
) {
glowRec = rec;
}
}
});
if (autoApproved) {
block.appendChild(buildConvStatus({ auto: true }));
} else {
const alwaysNames = items
.filter(
(it) =>
it.needs_approval &&
it.func_name &&
it.func_name !== "__budget_override__" &&
!it.error,
)
.map((it) => it.approval_label || it.func_name);
block.dataset.alwaysNames = JSON.stringify(alwaysNames);
const actions = buildConvActions({
kbd: { approve: "y", deny: "n", always: "a" },
alwaysLabel: alwaysNames.length
? "Always approve " + alwaysNames.join(", ")
: "",
glowRec,
withFeedback: true,
onApprove: () => this.resolveApproval(true, false, this.getFeedback()),
onDeny: () => this.resolveApproval(false, false, this.getFeedback()),
onAlways: () => this.resolveApproval(true, true, this.getFeedback()),
});
block.appendChild(actions);
this.pendingApproval = true;
this.approvalBlockEl = block;
this.inputEl.disabled = true;
this.sendBtn.disabled = true;
const fb = actions.querySelector(".conv-feedback");
requestAnimationFrame(() => {
if (fb) fb.focus();
});
}
if (!announced) this.messagesEl.appendChild(block);
this._relinkAgentCards(items);
this.scrollToBottom(stick);
}
resolveApproval(approved, always, feedback, skipPost) {
if (!this.approvalBlockEl) return;
// Capture pin before the status badge reflows the block — see
// announceToolBlock.
const stick = this.isNearBottom();
this.pendingApproval = false;
const actions = this.approvalBlockEl.querySelector(".conv-actions");
if (actions) actions.remove();
this.approvalBlockEl.appendChild(
buildConvStatus({ approved, always, feedback: feedback || "" }),
);
this.approvalBlockEl.classList.add(
approved ? "conv-batch--approved" : "conv-batch--denied",
);
this.approvalBlockEl = null;
this.inputEl.disabled = false;
this.sendBtn.disabled = this.busy;
this.inputEl.focus();
// POST to server (skip when server already resolved, e.g. timeout).
if (!skipPost) {
authFetch(
this._base +
"/v1/api/workstreams/" +
encodeURIComponent(this.wsId) +
"/approve",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
approved: approved,
feedback: feedback || null,
always: !!always,
}),
},
).catch((err) => {
this.addErrorMessage("Connection error: " + err.message);
});
}
this.scrollToBottom(stick);
}
// --- Task-agent card: nest a sub-agent's sub-tool steps under its row -----
// Child events (tool_pending / approve_request) carry parent_call_id; route
// them into the task_agent row's collapsible body. tool_result /
// tool_output_chunk / intent_verdict / output_warning need no special-casing
// — their handlers find the row by call_id anywhere under messagesEl,
// including inside the card body. Returns true when handled (the caller then
// skips the top-level rendering path).
_routeAgentItems(items, mode, judgePending) {
if (!items || !items.length) return false;
const parentId = items[0] && items[0].parent_call_id;
if (!parentId) return false;
const card = this._ensureAgentCard(parentId);
if (!card) {
// Parent task_agent row isn't painted yet: under the 4-wide tool pool a
// sub-tool's SSE event can be handled before its task_agent row commits.
// Buffer the child step keyed by parent so it nests when the parent row
// lands (see _flushAgentOrphans), instead of escaping to a top-level row
// that looks like the main harness issued it. An approval prompt is NOT
// buffered — it must stay visible to unblock the gate — so "approve"
// falls through to the top-level paint.
if (mode !== "approve") {
this._bufferAgentOrphan(parentId, items, mode);
return true;
}
return false; // parent row not painted yet — fall back top-level
}
if (mode === "approve") {
// Cards default to collapsed, but a pending approval is BLOCKING — it
// can't hide behind the toggle or the turn stalls on a prompt the user
// never sees. Force the card open (sync aria with the data attribute).
card.wrap.dataset.collapsed = "false";
const toggle = card.wrap.querySelector(".conv-agent-toggle");
if (toggle) toggle.setAttribute("aria-expanded", "true");
}
const stick = this.isNearBottom();
items.forEach((item) => {
if (!item || !item.parent_call_id) return;
const escId = item.call_id ? CSS.escape(item.call_id) : "";
let row = escId
? card.body.querySelector('.conv-row[data-call-id="' + escId + '"]')
: null;
if (!row) {
row = buildToolDiv(item, "");
card.body.appendChild(row);
}
if (mode === "approve") {
const verdict =
item.judge_verdict || item.heuristic_verdict || item.verdict;
if (verdict) {
row.appendChild(buildConvVerdict(verdict, { judgePending }));
}
const actions = buildConvActions({
kbd: { approve: "y", deny: "n" },
glowRec: verdict && verdict.recommendation,
withFeedback: true,
onApprove: () =>
this.resolveApproval(true, false, this.getFeedback()),
onDeny: () => this.resolveApproval(false, false, this.getFeedback()),
});
row.appendChild(actions);
// Reuse the session-level approval resolution: the backend's
// approve_tools blocks on ONE pending decision, so pointing
// approvalBlockEl at this nested row is enough for resolveApproval to
// POST /approve and unblock it.
this.pendingApproval = true;
this.approvalBlockEl = row;
this.inputEl.disabled = true;
this.sendBtn.disabled = true;
}
});
this._updateAgentLabel(card);
this.scrollToBottom(stick);
return true;
}
_ensureAgentCard(parentCallId) {
// _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);
if (card) {
if (parentRow.contains(card.wrap)) return card;
if (!card.wrap.isConnected) {
// Same-turn row rebuild: showInlineToolBlock's replaceChildren on the
// pending->resolved upgrade detached our card. Re-attach the SAME card
// so its already-rendered steps survive the upgrade.
parentRow.appendChild(card.wrap);
return card;
}
// The cached card is still attached to a DIFFERENT (earlier) row — a
// call_id reused across turns (some local providers recycle ids). Fall
// through to build a fresh card for THIS row rather than stealing the
// prior agent's steps. (Full cross-turn id correctness is the task-agent
// id-consistency follow-up.)
}
card = buildAgentCardBody();
card.wrap.dataset.state = "running";
parentRow.appendChild(card.wrap);
this._agentCards.set(parentCallId, card);
return card;
}
_bufferAgentOrphan(parentId, items, mode) {
// Hold a child step whose task_agent row hasn't painted yet, keyed by
// parent, so it nests when the row lands (_flushAgentOrphans). Bounded two
// ways so a parent row that never arrives (an id-correlation mismatch, or
// an agent aborted before its row painted) can't leak or hide steps: the
// per-parent queue is capped (oldest dropped), and a grace timer escapes
// the queue to a top-level row (_escapeAgentOrphans) so the steps stay
// VISIBLE rather than buffered forever.
if (!this._agentOrphans) this._agentOrphans = new Map();
const entry = this._agentOrphans.get(parentId) || {
queue: [],
timer: null,
};
entry.queue.push({ items, mode });
while (entry.queue.length > _AGENT_ORPHAN_CAP) entry.queue.shift();
if (entry.timer == null) {
entry.timer = setTimeout(
() => this._escapeAgentOrphans(parentId),
_AGENT_ORPHAN_GRACE_MS,
);
}
this._agentOrphans.set(parentId, entry);
}
_flushAgentOrphans(parentIds) {
// Drain buffered child steps for the just-painted parents (their card now
// exists) — targeted by parentId so an unrelated tool paint doesn't replay
// every parent's queue. A step that still can't nest re-buffers (with a
// fresh grace timer) for the next paint / escape.
if (!this._agentOrphans || !this._agentOrphans.size) return;
parentIds.forEach((pid) => {
const entry = this._agentOrphans.get(pid);
if (!entry) return;
this._agentOrphans.delete(pid);
if (entry.timer != null) clearTimeout(entry.timer);
entry.queue.forEach((o) => this._routeAgentItems(o.items, o.mode));
});
}
_escapeAgentOrphans(parentId) {
// The parent row never painted within the grace window — render the
// buffered steps at top-level so they stay visible (the pre-buffer
// behaviour) instead of vanishing. Batched per mode to avoid the
// announce-shell churn of one paint per step.
const entry = this._agentOrphans && this._agentOrphans.get(parentId);
if (!entry) return;
this._agentOrphans.delete(parentId);
const pending = [];
const info = [];
entry.queue.forEach((o) =>
(o.mode === "info" ? info : pending).push(...o.items),
);
if (pending.length) this.announceToolBlock(pending);
if (info.length) this.showInlineToolBlock(info, true);
}
_relinkAgentCards(items) {
// After a top-level tool row is (re)painted: re-attach any agent card the
// rebuild detached, then drain buffered child steps for the parents that
// just painted. Closes the parallel-pool ordering race and keeps a task
// agent's nested card intact across its parent's pending->resolved upgrade.
const paintedIds = [];
(items || []).forEach((it) => {
if (!it || !it.call_id) return;
paintedIds.push(it.call_id);
if (this._agentCards && this._agentCards.has(it.call_id)) {
this._ensureAgentCard(it.call_id);
}
});
if (paintedIds.length) this._flushAgentOrphans(paintedIds);
}
_updateAgentLabel(card) {
const n = card.body.querySelectorAll(".conv-row").length;
card.label.textContent = n === 1 ? "1 step" : n + " steps";
}
_replayAgentCard(row, steps) {
// Rebuild a finished task agent's card from its recalled step items
// (/history `agent_steps`), mirroring the live _routeAgentItems nesting so a
// reload looks identical: a collapsed body of step rows, each with its
// result. Recall is terminal — no live approval affordances. Card state
// defaults to "done"; the caller flips it to "error" from the task's OWN
// result (the role==="tool" branch), matching the live path — sub-step
// errors don't decide it (an agent can recover and synthesize fine).
const card = buildAgentCardBody();
steps.forEach((step) => {
card.body.appendChild(buildToolDiv(synthToolItem(step), ""));
const out = stripAnsi(String(step.output || "")).trim();
if (out) {
card.body.appendChild(renderCollapsibleOutput(out, !!step.is_error));
}
});
card.wrap.dataset.state = "done";
this._updateAgentLabel(card);
row.appendChild(card.wrap);
return card.wrap;
}
appendToolOutput(callId, name, output, isError) {
// Capture pin before the streamEl removal + result insertion change
// scrollHeight — see announceToolBlock. The result block is the other
// tall one-shot append in the tool flow (up to 10 lines before collapse).
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";
}
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
// — that mislabels a sub-tool's result as a main-harness tool's. Its row
// arrives via the orphan flush; skip until then.
if (callId && callId.includes("::")) return;
const blocks = this.messagesEl.querySelectorAll(".conv-batch");
if (!blocks.length) return;
const block = blocks[blocks.length - 1];
const tools = block.querySelectorAll(".conv-row");
for (let i = tools.length - 1; i >= 0; i--) {
if (tools[i].dataset.funcName === name) {
target = tools[i];
break;
}
}
if (!target && tools.length) target = tools[tools.length - 1];
}
if (!target) return;
// Remove the streaming output element for this tool
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 (callId) this._streamElIndex.delete(callId);
}
const stripped = stripAnsi(output || "").trim();
if (!stripped) return;
// Skip rendering for denied/blocked tool results — the ✗ denied
// badge from resolveApproval already shows the denial reason; the
// SSE tool_result event would otherwise duplicate the text. Mirror
// the guard in the history-replay path (the live path used to be
// safe because no tool_result event was ever emitted for denied
// items, but we now emit one so _tool_error_flags gets set).
const parentBlock = target.closest(".conv-batch");
const isDenied =
(parentBlock && parentBlock.classList.contains("conv-batch--denied")) ||
/^Denied by user/.test(stripped) ||
/^Blocked/.test(stripped);
if (isDenied) return;
// Detect structured media output and render interactive embed
if (!isError) {
const media = tryParseMedia(stripped);
if (media) {
const embed = buildMediaEmbed(media, stripped);
target.after(embed);
this.scrollToBottom(stick);
return;
}
}
// Detect structured MCP error envelope and render an interactive
// consent / re-consent / forbidden / operator card. The existing
// ✗ error badge from appendToolErrorBadge still fires below.
if (isError) {
const mcpErr = tryParseMcpError(stripped);
if (mcpErr) {
if (
parentBlock &&
!parentBlock.classList.contains("conv-batch--denied")
) {
parentBlock.classList.add("conv-batch--error");
appendToolErrorBadge(parentBlock);
}
target.after(
buildMcpErrorEmbed(mcpErr, stripped, (s) =>
this._host.onConsentDetected(s),
),
);
this.scrollToBottom(stick);
return;
}
}
// The media / MCP-error dispatch above both early-return, so by here it's
// the plain-output path — the shared helper applies (test_app_js pins that
// tryParseMcpError precedes this renderer call).
const out = renderCollapsibleOutput(stripped, isError);
// Mark the parent approval block as errored
if (
isError &&
parentBlock &&
!parentBlock.classList.contains("conv-batch--denied")
) {
parentBlock.classList.add("conv-batch--error");
appendToolErrorBadge(parentBlock);
}
target.after(out);
this.scrollToBottom(stick);
}
sendMessage() {
const text = this.inputEl.value.trim();
if (!text) return;
if (text.startsWith("/")) {
if (this.busy) return; // commands not allowed while busy
// /rewind and /retry were lifted to path-keyed endpoints (#549);
// reroute hand-typed ones so they don't 400 against /command.
const parts = text.split(/\s+/);
const cmdWord = parts[0].toLowerCase();
if (cmdWord === "/rewind") {
const n = parseInt(parts[1], 10);
if (!Number.isInteger(n) || n < 1) {
this.addErrorMessage(
"Usage: /rewind <N> — N must be a positive integer",
);
this.composer.clear();
return;
}
this._rewindToTurns(n);
this.composer.clear();
return;
}
if (cmdWord === "/retry") {
this._retryLast();
this.composer.clear();
return;
}
authFetch(this._base + "/v1/api/command", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ command: text, ws_id: this.wsId }),
});
this.addUserMessage(text);
this.composer.clear();
return;
}
const isBusy = this.busy;
let queuedEl = null;
const snap = this.attachments.snapshot();
if (isBusy) {
// Server re-parses the !!! prefix to set queue priority — the
// optimistic bubble strips it for display.
let displayText = text;
let priority = "notice";
if (text.startsWith("!!!")) {
displayText = text.slice(3).trimStart();
priority = "important";
}
this.removeEmptyState();
queuedEl = this.queue.addQueuedMessage(displayText, priority);
} else {
this.setBusy(true);
this.addUserMessage(text, snap.attachments);
}
this.composer.clear();
// Bound the send POST with an AbortController + ~15s timeout (mirrors
// composer_queue.js _deleteRequest) so a wedged proxied node can't leave a
// pre-bind-dismissed card frozen forever — bind/promote/remove only run
// off this response, so the .catch must always eventually fire.
const sendCtrl =
typeof AbortController === "function" ? new AbortController() : null;
const sendInit = {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message: text,
attachment_ids: snap.attachment_ids,
}),
};
let sendTimer = null;
if (sendCtrl) {
sendInit.signal = sendCtrl.signal;
sendTimer = setTimeout(() => sendCtrl.abort(), 15000);
}
let sendReq = authFetch(
this._base +
"/v1/api/workstreams/" +
encodeURIComponent(this.wsId) +
"/send",
sendInit,
);
if (sendTimer) sendReq = sendReq.finally(() => clearTimeout(sendTimer));
sendReq
.then((r) => {
// A rejected send (4xx/5xx) carries {error}, not {status}; without
// this guard it falls through to the "unknown status" branch and gets
// promote()'d — a server-refused message shown as delivered (with a
// false "already sent" toast if it was dismissed). Route it to the
// .catch (removes the bubble + shows the error) instead, surfacing the
// server's {error} text ("No session", a rate-limit reason, etc.)
// rather than a bare status code. A wedged proxy can answer non-JSON
// (502/504 HTML); the parse-failure arm falls back to the status code
// so that can't surface as an "Unexpected token <" error.
if (!r.ok) {
return r.json().then(
(b) => {
throw new Error((b && b.error) || "send_http_" + r.status);
},
() => {
throw new Error("send_http_" + r.status);
},
);
}
return r.json();
})
.then((data) => {
if (data.status === "queued" && data.msg_id) {
// queuedEl-present path: bind() handles the three known races
// (pre-bind dismiss, promote sweep raced ahead, normal accept).
// queuedEl-absent path: client thought it was idle but the
// server saw a live worker (SSE state_change hadn't arrived
// yet). Flip busy so subsequent sends queue correctly; the
// optimistic user bubble is already in the log and the server
// still delivers the message on worker drain — accept the
// small UX gap (no in-UI dismiss for THIS message).
if (queuedEl) this.queue.bind(queuedEl, data.msg_id);
else this.setBusy(true);
this.attachments.consume(
data.attached_ids,
data.dropped_attachment_ids,
);
} else if (data.status === "busy") {
if (queuedEl) this.queue.remove(queuedEl);
this.addErrorMessage("Server is busy. Please wait.");
if (!isBusy) this.setBusy(false);
} else if (data.status === "queue_full") {
if (queuedEl) this.queue.remove(queuedEl);
this.addErrorMessage("Message queue full. Please wait.");
} else if (data.status === "attachments_busy") {
// Attachments can't ride a queued user turn — server held the
// chips' reservations long enough to bounce the request and
// released them. Surface to the user; chips stay in the
// composer so they can retry once the assistant finishes.
if (queuedEl) this.queue.remove(queuedEl);
this.addErrorMessage(
"Attachments can't be sent while the assistant is working. " +
"Send a text-only message now, or wait and resend with attachments.",
);
} else {
// Unknown / "ok" status (e.g. the stale-busy race: the client
// optimistically queued but the server ran the send on a fresh
// worker). Settle the optimistic bubble as a normal sent message
// so a pre-bind × can't strand it in the dismissing state;
// promote() notifies "already sent" if it was dismissed.
if (queuedEl) this.queue.promote(queuedEl);
this.attachments.consume(
data.attached_ids,
data.dropped_attachment_ids,
);
}
})
.catch((err) => {
if (queuedEl) this.queue.remove(queuedEl);
this.addErrorMessage("Connection error: " + err.message);
if (!isBusy) this.setBusy(false);
});
}
cancelGeneration() {
if (!this.busy || !this.wsId || this.stopBtn.disabled) return;
const isForce = this.stopBtn.dataset.forceCancel === "true";
this.stopBtn.disabled = true;
authFetch(
this._base +
"/v1/api/workstreams/" +
encodeURIComponent(this.wsId) +
"/cancel",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ force: isForce }),
},
)
.then(() => {
if (isForce) {
// Force cancel abandons the worker — transition immediately.
// Clear timeouts to prevent stale timers firing on next send.
if (this._cancelTimeout) {
clearTimeout(this._cancelTimeout);
this._cancelTimeout = null;
}
if (this._forceTimeout) {
clearTimeout(this._forceTimeout);
this._forceTimeout = null;
}
this.addInfoMessage("Force stopped. Previous generation abandoned.");
this.setBusy(false);
}
})
.catch((err) => {
this.addErrorMessage("Cancel error: " + err.message);
this.stopBtn.disabled = false;
});
}
}
// Build a structured ``.msg.guard-finding`` card for an ``output_guard``
// operator-context system turn. ``meta`` carries the structured finding
// ``{flags, risk_level, annotations, redacted}``. Reuses the tool-result
// warning chip (``_buildOutputWarningEl``) for the risk / flags / redaction
// header so the operator-context finding speaks the same visual vocabulary,
// then appends the annotations (matched-pattern detail) — which the inline
// tool chip omits to stay terse. All text via textContent.
function _buildGuardFindingBubble(meta) {
const el = document.createElement("div");
el.className = "msg guard-finding operator-context";
el.setAttribute("role", "article");
el.setAttribute("data-ts-role", "output_guard");
el.setAttribute("aria-label", "output guard");
el.appendChild(_buildOutputWarningEl(meta));
const anns = Array.isArray(meta.annotations) ? meta.annotations : [];
for (let i = 0; i < anns.length; i++) {
const a = document.createElement("div");
a.className = "msg-guard-annotation";
a.textContent = String(anns[i]);
el.appendChild(a);
}
return el;
}
// Shared output-warning DOM builder — used by both replayHistory
// (saved-workstream rendering) and the live appendToolOutput path
// via showOutputWarning. Single source of truth keeps the two
// surfaces from drifting on role / class / escape semantics.
function _buildOutputWarningEl(assessment) {
// Thin wrapper over the shared builder (risk normalized -> unknown=medium).
return buildConvWarning(assessment);
}
function _formatRuntime(item) {
let mins = 0;
if (typeof item.runtime_minutes === "number") {
mins = Math.round(item.runtime_minutes);
} else if (typeof item.runtime_ticks === "number") {
mins = Math.round(item.runtime_ticks / 600000000);
}
if (!mins) return "";
const h = Math.floor(mins / 60);
const m = mins % 60;
return h > 0 ? h + "h " + m + "m" : m + "m";
}
function _redactApiKeys(text) {
// Query-string style: api_key=VALUE
let redacted = text.replace(
/(?:api_key|apiKey|api-key|token)=[^&\s"]+/g,
function (m) {
return m.split("=")[0] + "=***";
},
);
// JSON style: "api_key": "VALUE"
redacted = redacted.replace(
/(["'](?:api_key|apiKey|api-key|token)["']\s*:\s*["'])([^"']*)(['"])/gi,
"$1***$3",
);
return redacted;
}
function _tryPrettyJson(text) {
let obj;
try {
obj = JSON.parse(text);
} catch (e) {
return null;
}
return _redactApiKeys(JSON.stringify(obj, null, 2));
}
// ---------------------------------------------------------------------------
// HLS lazy-loader + click-to-play (lifted from the standalone app.js so
// console-hosted panes activate media too). Follows the mermaid.js
// lazy-load pattern in /shared/renderer.js: the vendor is fetched by absolute
// /shared/ URL on first use, so it resolves in BOTH the standalone server and
// the console (where /shared is mounted at the root and node-proxied panes
// also reach it via /node/{id}/shared/).
// ---------------------------------------------------------------------------
let _hlsState = "idle";
let _hlsQueue = [];
function _loadHls(callback) {
if (_hlsState === "ready") {
callback();
return;
}
_hlsQueue.push(callback);
if (_hlsState === "loading") return;
_hlsState = "loading";
const script = document.createElement("script");
script.src = "/shared/hls-1.6.16/hls.min.js";
script.onload = function () {
_hlsState = "ready";
const q = _hlsQueue;
_hlsQueue = [];
for (let i = 0; i < q.length; i++) q[i]();
};
script.onerror = function () {
_hlsState = "idle";
const q = _hlsQueue;
_hlsQueue = [];
// Fall through — _activatePlayer will use stream_url since Hls is undefined
for (let i = 0; i < q.length; i++) q[i]();
};
document.head.appendChild(script);
}
function _isHlsUrl(url) {
return typeof url === "string" && /\.m3u8(\?|$)/i.test(url);
}
function _activatePlayer(btn) {
const url = btn.dataset.streamUrl;
const hlsUrl = btn.dataset.hlsUrl;
const isAudio = btn.dataset.audioOnly === "true";
const directStream = btn.dataset.directStream === "true";
const player = document.createElement(isAudio ? "audio" : "video");
player.controls = true;
player.autoplay = true;
player.className = "media-player";
// Held so the error handler can tear the instance down before the player
// node is replaced — otherwise its listeners/loader timers run detached.
let hls = null;
// Prefer direct stream when the source supports it; fall back to HLS
// only when transcoding is needed.
if (directStream && url) {
player.src = url;
} else if (
hlsUrl &&
!isAudio &&
typeof Hls !== "undefined" &&
Hls.isSupported()
) {
hls = new Hls();
hls.loadSource(hlsUrl);
hls.attachMedia(player);
} else if (
hlsUrl &&
!isAudio &&
player.canPlayType("application/vnd.apple.mpegurl")
) {
player.src = hlsUrl;
} else {
player.src = url;
}
player.addEventListener("error", function () {
if (hls) {
hls.destroy();
hls = null; // media error events can repeat — never double-destroy
}
const card = player.closest(".media-embed");
const titleEl = card ? card.querySelector(".media-card-title") : null;
const label = titleEl ? ": " + titleEl.textContent : "";
const err = document.createElement("div");
err.className = "media-player-error";
err.setAttribute("role", "alert");
err.textContent = "Failed to load stream" + label;
const retry = document.createElement("button");
retry.className = "media-play-btn";
retry.type = "button";
retry.dataset.streamUrl = url;
retry.dataset.hlsUrl = hlsUrl || "";
retry.dataset.audioOnly = String(isAudio);
retry.dataset.directStream = String(directStream);
retry.setAttribute("aria-label", "Retry" + label);
retry.appendChild(document.createTextNode("▶ Retry"));
const container = document.createElement("div");
container.appendChild(err);
container.appendChild(retry);
player.replaceWith(container);
});
btn.replaceWith(player);
}
// Activate a clicked/Enter-pressed play button: show the loading affordance,
// then ensure hls.js is loaded before swapping in the player when the source
// needs it. The pane wires this from a root-scoped this.el listener.
function activateMediaPlayButton(btn) {
btn.disabled = true;
const labelEl = btn.querySelector("span:last-child");
if (labelEl) {
labelEl.textContent = "Loading…";
} else {
btn.textContent = "▶ Loading…";
}
const hlsUrl = btn.dataset.hlsUrl;
const isAudio = btn.dataset.audioOnly === "true";
// If HLS URL present and not audio, ensure hls.js is loaded first
if (hlsUrl && !isAudio && _isHlsUrl(hlsUrl)) {
_loadHls(function () {
_activatePlayer(btn);
});
} else {
_activatePlayer(btn);
}
}
function buildMediaCard(item) {
const card = document.createElement("div");
card.className = "media-card";
// Thumbnail
const thumbUrl = item.thumbnail_url || item.image_url || "";
if (thumbUrl) {
const img = document.createElement("img");
img.className = "media-card-thumb";
img.loading = "lazy";
img.alt = item.title || item.name || "Media thumbnail";
img.onerror = function () {
this.style.display = "none";
};
img.src = thumbUrl;
card.appendChild(img);
}
// Info container
const info = document.createElement("div");
info.className = "media-card-info";
// Title (Year)
const title = document.createElement("div");
title.className = "media-card-title";
let titleText = item.title || item.name || "Untitled";
if (item.year || item.production_year) {
titleText += " (" + (item.year || item.production_year) + ")";
}
title.textContent = titleText;
info.appendChild(title);
// Metadata line: type, runtime, genres
const metaParts = [];
if (item.type || item.media_type) {
metaParts.push(item.type || item.media_type);
}
const runtime = _formatRuntime(item);
if (runtime) metaParts.push(runtime);
if (item.genres && item.genres.length) {
metaParts.push(item.genres.join(", "));
}
if (metaParts.length) {
const meta = document.createElement("div");
meta.className = "media-card-meta";
meta.textContent = metaParts.join(" \u00b7 ");
info.appendChild(meta);
}
card.appendChild(info);
return card;
}
function buildPlayButton(media) {
const btn = document.createElement("button");
btn.className = "media-play-btn";
btn.type = "button";
btn.dataset.streamUrl = media.stream_url || "";
btn.dataset.hlsUrl = media.hls_url || "";
btn.dataset.audioOnly =
media.audio_only === true ||
(media.container &&
/^(mp3|flac|ogg|aac|wma|wav|m4a|opus)$/i.test(media.container))
? "true"
: "false";
btn.dataset.directStream =
media.supports_direct_play || media.supports_direct_stream
? "true"
: "false";
btn.setAttribute(
"aria-label",
"Play " + (media.title || media.name || "media"),
);
const icon = document.createElement("span");
icon.textContent = "\u25b6";
btn.appendChild(icon);
const label = document.createElement("span");
label.textContent = "Play";
btn.appendChild(label);
return btn;
}
function buildMediaResultsList(results, totalCount) {
const container = document.createElement("div");
container.className = "media-results-list";
for (let i = 0; i < results.length; i++) {
const item = results[i];
const row = document.createElement("div");
row.className = "media-result-row";
// Small thumbnail
const thumbUrl = item.thumbnail_url || item.image_url || "";
if (thumbUrl) {
const img = document.createElement("img");
img.className = "media-result-thumb";
img.loading = "lazy";
img.alt = item.name || item.title || "Media thumbnail";
img.onerror = function () {
this.style.display = "none";
};
img.src = thumbUrl;
row.appendChild(img);
}
// Title (Year)
const titleSpan = document.createElement("span");
titleSpan.className = "media-result-title";
let titleText = item.name || item.title || "Untitled";
if (item.year || item.production_year) {
titleText += " (" + (item.year || item.production_year) + ")";
}
titleSpan.textContent = titleText;
row.appendChild(titleSpan);
// Metadata: type, runtime or season info
const metaParts = [];
if (item.type || item.media_type) {
metaParts.push(item.type || item.media_type);
}
const runtime = _formatRuntime(item);
if (runtime) metaParts.push(runtime);
if (item.season_name) metaParts.push(item.season_name);
if (
typeof item.index_number === "number" &&
typeof item.parent_index_number === "number"
) {
metaParts.push(
"S" +
String(item.parent_index_number).padStart(2, "0") +
"E" +
String(item.index_number).padStart(2, "0"),
);
}
if (metaParts.length) {
const metaSpan = document.createElement("span");
metaSpan.className = "media-result-meta";
metaSpan.textContent = " \u00b7 " + metaParts.join(" \u00b7 ");
row.appendChild(metaSpan);
}
container.appendChild(row);
}
// "showing X of Y results" footer
if (typeof totalCount === "number" && totalCount > results.length) {
const count = document.createElement("div");
count.className = "media-results-count";
count.textContent =
"showing " + results.length + " of " + totalCount + " results";
container.appendChild(count);
}
return container;
}
function _mcpErrorCategory(code) {
if (code === "mcp_consent_required" || code === "mcp_insufficient_scope") {
return "actionable";
}
if (
code === "mcp_token_undecryptable_key_unknown" ||
code === "mcp_oauth_url_insecure"
) {
return "operator";
}
if (code === "mcp_refresh_unavailable") {
// Soft, retryable state — a transient refresh failure, not a hard denial.
return "transient";
}
// Default for any other mcp_*_forbidden / unrecognised mcp_ code.
return "forbidden";
}
function _mcpErrorTitle(err) {
switch (err.code) {
case "mcp_consent_required":
return "Consent required";
case "mcp_insufficient_scope":
return "Re-consent required (insufficient scope)";
case "mcp_token_undecryptable_key_unknown":
case "mcp_oauth_url_insecure":
return "Operator action required";
case "mcp_refresh_unavailable":
return "Temporarily unavailable";
default:
return "Forbidden";
}
}
// ---------------------------------------------------------------------------
// Conversational rendering helpers — tool output, media embeds, MCP-error
// cards. Used only by the Pane. The approval-card builders (rows, verdict,
// warning, actions, status) were unified into the shared conversation.js
// (steps 5e.1/5e.2); buildToolDiv + the wrappers below delegate to them so
// both panes emit the SAME .conv-* card. The .tool-output / media result
// subsystem stays interactive-only (live stream / collapse / embeds).
// ---------------------------------------------------------------------------
// Converged approval-card head strip (kicker + summary). Module-level so
// both showInlineToolBlock and announceToolBlock (and replay) build the same
// head as the coordinator's buildConvBatchShell, without a fresh shell element.
function _convApprovalHead(kickerText, items) {
const head = document.createElement("div");
head.className = "conv-batch-head";
const k = document.createElement("span");
k.className = "conv-batch-kicker";
k.textContent = kickerText;
head.appendChild(k);
const first =
items[0] && (items[0].func_name || items[0].approval_label)
? items[0].func_name || items[0].approval_label
: "tool";
const summary = document.createElement("span");
summary.className = "conv-batch-summary";
summary.textContent =
items.length >= 2 ? first + " + " + (items.length - 1) + " more" : first;
head.appendChild(summary);
return head;
}
function buildToolDiv(item, indexLabel) {
// Tool row for the converged card: the shared call line (name + auto tag)
// plus the bash `$ cmd` / diff preview. data-func-name is stamped for the
// append-time finders (buildConvRow already stamps data-call-id/-tool-name).
// indexLabel ("1/3") numbers parallel rows so the head's "+ N more" reads
// as a label, not hidden calls (mirrors the coordinator).
const row = buildConvRow(item, indexLabel ? { indexLabel } : {});
row.dataset.funcName = item.func_name || "";
row.appendChild(buildConvCmd(item));
return row;
}
// Synthesize the live `item` shape ({func_name, call_id, header}) from a stored
// tool_call ({name, id, arguments}) so /history replay AND task-agent card
// recall render the SAME .conv-row as the live path. Header derivation mirrors
// the live serialize: bash shows the command; other tools show `key: value`
// lines, values clipped at 80 chars; unparseable args fall back to a raw clip.
function synthToolItem(tc) {
tc = tc || {};
let header = "";
try {
const args = JSON.parse(tc.arguments);
if (tc.name === "bash") {
header = String(Object.values(args)[0] || "");
} else {
const parts = [];
const keys = Object.keys(args);
for (let k = 0; k < keys.length; k++) {
const val = args[keys[k]];
let valStr = val === null || val === undefined ? "null" : String(val);
if (valStr.length > 80) valStr = valStr.substring(0, 77) + "...";
parts.push(keys[k] + ": " + valStr);
}
header = parts.join("\n");
}
} catch (e) {
header = String(tc.arguments || "").substring(0, 100);
}
return { func_name: tc.name, call_id: tc.id || "", header };
}
function renderVerdictBadge(verdict, judgePending) {
// Thin wrapper over the shared builder (returns a fragment [badge, detail]).
return buildConvVerdict(verdict, { judgePending });
}
// Append an "✗ error" pill to an approval block as a sibling of the
// existing approved/denied/auto-approved pill, so the approval verdict
// stays visible alongside the execution outcome. Idempotent — re-fires
// (live + history rerender) do not stack badges.
function appendToolErrorBadge(blockEl) {
if (!blockEl) return;
if (blockEl.querySelector(".conv-status--error")) return;
const errBadge = document.createElement("div");
errBadge.setAttribute("role", "status");
errBadge.className = "conv-status conv-status--error";
errBadge.textContent = "✗ error";
blockEl.appendChild(errBadge);
}
function makeCollapsible(el) {
el.classList.add("collapsed");
el.setAttribute("tabindex", "0");
el.setAttribute("role", "button");
el.setAttribute("aria-label", "Tool output (collapsed). Activate to expand.");
const handler = function () {
this.classList.remove("collapsed");
this.removeAttribute("tabindex");
this.removeAttribute("role");
this.removeAttribute("aria-label");
};
el.addEventListener("click", handler);
el.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handler.call(this);
}
});
}
function tryParseMedia(text) {
let obj;
try {
obj = JSON.parse(text);
} catch (e) {
return null;
}
if (obj && typeof obj.stream_url === "string") return obj;
if (obj && obj.name && obj.type && obj.id) return obj;
if (obj && Array.isArray(obj.results) && obj.results.length > 0) return obj;
if (obj && Array.isArray(obj.sessions)) return obj;
return null;
}
function renderToolOutput(stripped, isError) {
const out = document.createElement("div");
out.className = "tool-output" + (isError ? " tool-output-error" : "");
if (!isError) {
const pretty = _tryPrettyJson(stripped);
if (pretty) {
out.textContent = pretty;
return out;
}
}
out.textContent = _redactApiKeys(stripped);
return out;
}
// Shared recipe: render tool output and auto-collapse past 10 lines. The one
// source for the live appendToolOutput's plain-output path, the /history
// tool-result replay, and the task-agent card recall — so the collapse
// threshold can't drift between them.
function renderCollapsibleOutput(stripped, isError) {
const out = renderToolOutput(stripped, isError);
if (out.textContent.split("\n").length > 10) makeCollapsible(out);
return out;
}
function buildMediaEmbed(media, rawJson) {
const wrapper = document.createElement("div");
wrapper.className = "media-embed";
if (media.stream_url) {
const card = buildMediaCard(media);
card.querySelector(".media-card-info").appendChild(buildPlayButton(media));
wrapper.appendChild(card);
} else if (media.results) {
wrapper.appendChild(
buildMediaResultsList(media.results, media.total_count),
);
} else if (media.sessions) {
wrapper.appendChild(buildMediaResultsList(media.sessions, null));
} else if (media.name && media.type && media.id) {
wrapper.appendChild(buildMediaCard(media));
}
// Collapsed raw JSON for inspection (with redacted API keys)
const raw = document.createElement("div");
raw.className = "tool-output";
raw.textContent = _tryPrettyJson(rawJson) || _redactApiKeys(rawJson);
makeCollapsible(raw);
wrapper.appendChild(raw);
return wrapper;
}
function tryParseMcpError(text) {
let obj;
try {
obj = JSON.parse(text);
} catch (e) {
return null;
}
if (!obj || typeof obj !== "object") return null;
const err = obj.error;
if (!err || typeof err !== "object") return null;
if (typeof err.code !== "string" || err.code.indexOf("mcp_") !== 0)
return null;
return err;
}
function buildMcpErrorEmbed(err, rawJson, onConsent) {
const category = _mcpErrorCategory(err.code);
const wrapper = document.createElement("div");
wrapper.className = "mcp-error-card mcp-error-" + category;
const icon = document.createElement("div");
icon.className = "mcp-error-icon";
icon.setAttribute("aria-hidden", "true");
icon.textContent = "⚠";
wrapper.appendChild(icon);
const body = document.createElement("div");
body.className = "mcp-error-body";
const title = document.createElement("div");
title.className = "mcp-error-title";
title.textContent = _mcpErrorTitle(err);
body.appendChild(title);
if (err.detail) {
const detail = document.createElement("div");
detail.className = "mcp-error-detail";
detail.textContent = String(err.detail);
body.appendChild(detail);
}
if (err.server) {
const serverLine = document.createElement("div");
serverLine.className = "mcp-error-server";
serverLine.appendChild(document.createTextNode("server: "));
const serverCode = document.createElement("code");
serverCode.textContent = String(err.server);
serverLine.appendChild(serverCode);
body.appendChild(serverLine);
}
if (Array.isArray(err.scopes_required) && err.scopes_required.length) {
const scopesLine = document.createElement("div");
scopesLine.className = "mcp-error-scopes";
scopesLine.appendChild(document.createTextNode("scopes: "));
for (let i = 0; i < err.scopes_required.length; i++) {
const pill = document.createElement("span");
pill.className = "mcp-scope-pill";
pill.textContent = String(err.scopes_required[i]);
scopesLine.appendChild(pill);
}
body.appendChild(scopesLine);
}
if (category === "actionable") {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "mcp-error-action-btn";
const serverLabel = err.server ? String(err.server) : "server";
btn.textContent =
err.code === "mcp_insufficient_scope"
? "Re-consent with new scopes →"
: "Connect to " + serverLabel + " →";
btn.setAttribute(
"aria-label",
err.code === "mcp_insufficient_scope"
? "Re-consent with new scopes for " + serverLabel
: "Connect to " + serverLabel,
);
btn.addEventListener("click", function () {
const consentUrl = err.consent_url;
if (!consentUrl || typeof consentUrl !== "string") {
// Defensive: should always be present per the dispatcher. If a
// path forgets to include it the user can still connect via the
// Settings panel (gear icon).
showToast("No consent URL available; open Settings to connect.");
return;
}
// Defence-in-depth: reject anything that isn't path-relative to
// the dispatcher's known prefix. ``_build_consent_url`` always
// emits ``/v1/api/mcp/oauth/start?...`` — a non-prefix value
// would indicate a future producer drift or a compromised
// dispatcher, and ``window.open("javascript:...")`` would be
// catastrophic. Never rely on the producer-side guarantee alone.
if (!consentUrl.startsWith("/v1/api/mcp/oauth/start")) {
showToast("Invalid consent URL");
return;
}
const sep = consentUrl.indexOf("?") >= 0 ? "&" : "?";
const url =
consentUrl +
sep +
"return_url=" +
encodeURIComponent(window.location.href);
window.open(url, "_blank", "noopener");
});
body.appendChild(btn);
if (onConsent) onConsent(err.server);
}
wrapper.appendChild(body);
const details = document.createElement("details");
const summary = document.createElement("summary");
summary.textContent = "raw payload";
details.appendChild(summary);
const pre = document.createElement("pre");
pre.className = "tool-output";
pre.textContent = _tryPrettyJson(rawJson) || _redactApiKeys(rawJson);
details.appendChild(pre);
wrapper.appendChild(details);
return wrapper;
}
// ---------------------------------------------------------------------------
// createInteractivePane — the console L-shell factory.
//
// Mirrors createCoordinatorPane: it builds one embedded interactive Pane into a
// pane-host root and returns the lifecycle controller the shell drives
// (connect / deactivate / destroy / onLogin). Transport is node-proxied — an
// interactive session lives on a cluster node, so every request is prefixed
// with /node/{nodeId} (the LOCALITY invariant). No children/tasks affordance:
// an interactive persona has no spawn tools (that sidebar is the coordinator's).
// ---------------------------------------------------------------------------
function createInteractivePane(root, wsId, opts) {
opts = opts || {};
if (!wsId) {
const missing = document.createElement("div");
missing.className = "msg error";
missing.textContent = "Missing ws_id.";
root.replaceChildren(missing);
return null;
}
// A console pane proxies a node-hosted session, so its transport base is
// /node/{id}; opts.base ("" by default) is the pass-through for a future
// local standalone mount.
const base = opts.nodeId
? "/node/" + encodeURIComponent(opts.nodeId)
: opts.base || "";
let active = false;
let connected = false;
let recoverTimer = null;
// Terminal-failure tracking. Native EventSource auto-reconnect (plus the 5s
// CLOSED-state recovery below) covers transient drops — but a session that is
// GONE from its node (closed/evicted, node restarted, re-homed) 404s every
// reconnect forever. After 3 consecutive CLOSED checks the controller gives
// up: stream closed, timers dropped, status bar terminal, `opts.onDead()`
// fired ONCE so the shell can paint its reconnect affordance. Recovery is
// the shell's revive path (re-resolve node + POST /open + rebuild) — never
// automatic, so a deliberately-closed session is not resurrected by a timer.
// A successful stream open resets the counter (host.onStreamOpen).
let dead = false;
let failCount = 0;
const giveUp = function () {
if (dead) return;
dead = true;
failCount = 0;
if (recoverTimer) {
clearTimeout(recoverTimer);
recoverTimer = null;
}
// Invalidate any in-flight history load: its .finally would otherwise
// reopen a stream for a session we just declared dead.
pane._historyLoadToken = (pane._historyLoadToken || 0) + 1;
pane.disconnectSSE();
// Terminal wording — the transient error path says "Reconnecting…".
pane.statusBarEl.classList.add("ws-sb-disconnected");
pane._sbTokens.textContent = "Disconnected";
if (typeof opts.onDead === "function") {
try {
opts.onDead();
} catch (e) {
console.error("interactive pane: onDead callback failed", e);
}
}
};
const host = {
// Only the visible tab steals focus — never yank the caret into a
// backgrounded pane mid background-replay.
isFocused() {
return active;
},
// Native EventSource auto-reconnect handles transient drops (connectSSE
// deliberately does not close the source on error). Guard the terminal
// case: if the source is genuinely CLOSED after a beat, open a fresh
// same-ws stream. No global ws-list refetch — a console pane owns one ws.
// Three consecutive CLOSED beats = the session is gone, not flaky: give up
// (a dead session would otherwise be 404-polled every 5s indefinitely).
onStreamError(pane) {
if (dead) return;
if (recoverTimer) clearTimeout(recoverTimer);
recoverTimer = setTimeout(() => {
recoverTimer = null;
if (
pane.evtSource &&
pane.evtSource.readyState !== EventSource.CLOSED
) {
return; // native reconnect is still working the problem
}
failCount += 1;
if (failCount >= 3) giveUp();
else pane.connectSSE(pane.wsId);
}, 5000);
},
// Stream (re)opened — the session is reachable again; reset the give-up
// counter so unrelated future blips get a fresh allowance.
onStreamOpen() {
failCount = 0;
},
// The --skip-permissions banner lands in the pane's own slim header.
warningTarget(pane) {
return pane.messagesEl;
},
// MCP re-consent surfaces inline in the pane card; the STANDALONE additionally
// drives a Manage-row attention badge — bridged through the TS_APP seam so the
// shared factory stays deployment-agnostic (the console doesn't define the
// hook, so this stays a no-op there).
onConsentDetected(server) {
if (
window.TS_APP &&
typeof window.TS_APP.onConsentDetected === "function"
) {
window.TS_APP.onConsentDetected(server);
}
},
};
const pane = new Pane(wsId, {
base,
host,
onClose: opts.onClose,
});
root.appendChild(pane.el);
return {
wsId: wsId,
pane: pane,
// The transport base this controller talks through ("" local, "/node/{id}"
// proxied) — the shell's tab-menu verbs aim at the SAME backend the pane
// streams from.
base: base,
// First activation opens the Tier-2 stream (REST history first, then live);
// re-activations just re-mark focus. Idempotent — the shell calls it on
// every tab switch.
connect() {
active = true;
if (!connected) {
connected = true;
pane.showEmptyState();
pane._loadHistoryThenConnect(wsId);
}
},
// Tab backgrounded — stop stealing focus, keep the stream live.
deactivate() {
active = false;
},
// Re-auth fan-out: reconnect the stream. A dead controller stays dead —
// recovery is the shell's revive path (which may need a different node).
onLogin() {
if (connected && !dead) pane._loadHistoryThenConnect(wsId);
},
// Terminal-state surface for the shell: `isDead()` gates revive-vs-connect
// on activate/reopen; `markDead()` lets Tier-1 lifecycle (ws_closed) stop
// the retry loop NOW instead of after three failed beats.
isDead() {
return dead;
},
markDead: giveUp,
// Full teardown — close the stream + all timers/recording/tts, drop the
// recovery timer, detach the DOM. A backgrounded pane must not leak an
// upstream node connection.
destroy() {
active = false;
connected = false;
if (recoverTimer) {
clearTimeout(recoverTimer);
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);
}
},
};
}
// --- Shared-module exports -------------------------------------------------
// ES module: the shell (shared_static/shell.js) imports the factory directly in
// BOTH deployments (console + standalone), so there is no window bridge — step 6
// retired the classic standalone app.js path that read window.InteractivePane.
export { Pane as InteractivePane, createInteractivePane };