diff --git a/turnstone/console/static/coordinator/coordinator.css b/turnstone/console/static/coordinator/coordinator.css index c1f8dbd1..6b704b80 100644 --- a/turnstone/console/static/coordinator/coordinator.css +++ b/turnstone/console/static/coordinator/coordinator.css @@ -6,8 +6,9 @@ Tokens (--panel, --hair, --ok, --warn, etc.) come from shared_static/ base.css. Form controls + .btn / .ghost / .appbar primitives come from shared_static/ui-base.css. This file holds the patterns specific - to the coordinator view: the right-rail sidebar and the pinned approval - dock. + to the coordinator view: the right-rail sidebar, the inline tool-batch + construct (paired tool calls + approval flow + results), and the + drag-and-drop overlay. ========================================================================== */ /* ========================================================================== @@ -40,235 +41,6 @@ color: var(--ink-4); } -/* ========================================================================== - Approval dock — bottom-pinned strip that appears when pending approvals - exist. Signature product pattern: a neutral dock (not a modal, not - inline) that surfaces the approval contract without hijacking focus. - - Layout: - .approval-dock position: fixed bottom - .dhead 11px uppercase warn kicker + count on right - .dcall risk pill + function name + arg preview - .dctx context code snippets - .drow right-aligned action cluster + nav spacer - - Actions (action cluster): - button.act neutral default ("dismiss" / "view") - button.act.primary ok-tinted green per the .ts-approval-btn--approve - convention in shared_static/chat.css. The original - Claude Design spec preferred amber; turnstone - deliberately broke from it to keep colour-family - parity with the Approve button's existing green. - 1.5px border, --r-md squared. - button.act.always dashed border — "Always approve for this rule" - button.act.danger err-tinted red — "Deny" - - Keyboard shortcuts (wired in coordinator.js): - Enter → primary approve - D → deny - ⇧A → always approve - - Focus policy: when the dock opens, move focus to button.act.primary so - keyboard users can confirm without hunting. Do NOT trap focus. - ========================================================================== */ -.approval-dock { - position: fixed; - left: 0; - right: 0; - bottom: 22px; /* clears the statusbar if one is present */ - z-index: 20; - display: flex; - flex-direction: column; - gap: 10px; - padding: 14px 20px; - background: var(--panel); - border-top: 1px solid var(--hair); - box-shadow: 0 -6px 24px -12px rgba(21, 24, 27, 0.18); -} - -.approval-dock::before { - content: ""; - position: absolute; - top: -1px; - left: 0; - right: 0; - height: 2px; - background: linear-gradient( - to right, - transparent, - color-mix(in srgb, var(--warn) 50%, transparent), - transparent - ); -} - -.approval-dock .dhead { - display: flex; - align-items: center; - gap: 8px; - font-size: 11px; - font-weight: 600; - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--warn); -} - -.approval-dock .dhead::before { - content: "⚠"; - font-size: 12px; -} - -.approval-dock .dhead .dcount { - margin-left: auto; - font-family: var(--font-mono); - font-size: 10px; - font-weight: 500; - letter-spacing: 0; - text-transform: none; - color: var(--ink-3); -} - -/* Inline code-panel framing — the .dcall row reads as "the exact call you - are approving," so we frame it like a mini inspectable code line rather - than bare text on the dock surface. */ -.approval-dock .dcall { - display: flex; - align-items: center; - gap: 10px; - min-width: 0; - padding: 6px 10px; - background: var(--panel); - border: 1px solid var(--hair); - border-radius: var(--r-sm); -} - -.approval-dock .dcall .risk { flex-shrink: 0; } - -.approval-dock .dcall .dfn { - font-family: var(--font-mono); - font-weight: 600; - color: var(--ink); -} - -.approval-dock .dcall .dargs { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-family: var(--font-mono); - font-size: 12px; - color: var(--ink-3); -} - -.approval-dock .dctx { - display: flex; - gap: 14px; - flex-wrap: wrap; - font-size: 11px; - color: var(--ink-3); -} - -.approval-dock .dctx code { - padding: 0 4px; - font-family: var(--font-mono); - font-size: 10px; - color: var(--ink-2); - background: var(--panel-2); - border: 1px solid var(--hair); - border-radius: 3px; -} - -.approval-dock .drow { - display: flex; - align-items: center; - gap: 6px; -} - -.approval-dock .drow .spacer { flex: 1; } - -.approval-dock .drow .nav { - padding: 4px 8px; - font-family: var(--font-mono); - font-size: 11px; - color: var(--ink-3); - background: transparent; - border: 1px solid transparent; - border-radius: 3px; - cursor: pointer; -} - -.approval-dock .drow .nav:hover { - background: var(--panel-2); - color: var(--ink); -} - -/* Action buttons — 1.5px border, --r-md squared (NOT pill — these are - primary-action surfaces, not inline buttons). */ -.approval-dock button.act { - padding: 7px 16px; - font: inherit; - font-size: 12px; - font-weight: 500; - color: var(--ink-2); - background: var(--panel); - border: 1.5px solid var(--hair-2); - border-radius: var(--r-md); - cursor: pointer; - transition: background 120ms ease, border-color 120ms ease, color 120ms ease; -} - -.approval-dock button.act:hover { - color: var(--ink); - border-color: var(--ink-4); -} - -.approval-dock button.act:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} - -/* Approve and Always are siblings — same ok hue, differentiated by fill - (filled vs outlined) and border-style (solid vs dashed). Matches the - .ts-approval-btn--approve convention in shared_static/chat.css. Four - stacked non-colour cues for WCAG 1.4.1: fill state, border style, - label, position. */ -.approval-dock button.act.primary { - background: color-mix(in srgb, var(--ok) 28%, var(--panel)); - color: var(--ok-text); - border-color: color-mix(in srgb, var(--ok) 65%, var(--hair)); - font-weight: 600; -} - -.approval-dock button.act.primary:hover { - background: color-mix(in srgb, var(--ok) 40%, var(--panel)); - color: var(--ink); - border-color: var(--ok); -} - -.approval-dock button.act.always { - background: transparent; - border-style: dashed; - border-color: color-mix(in srgb, var(--ok) 65%, var(--hair)); - color: var(--ok-text); -} - -.approval-dock button.act.always:hover { - background: color-mix(in srgb, var(--ok) 15%, var(--panel)); - color: var(--ink); - border-color: var(--ok); -} - -.approval-dock button.act.danger { - color: var(--err); - border-color: color-mix(in srgb, var(--err) 42%, var(--hair)); -} - -.approval-dock button.act.danger:hover { - background: var(--err-soft); - color: var(--err); - border-color: var(--err); -} - /* ========================================================================== Drag-and-drop overlay — applied to #coord-main while the user is dragging files from the OS over the chat pane. Composer wires this on @@ -301,9 +73,411 @@ z-index: 10; } -/* Match .btn .kbd (in shared_static/ui-base.css) — --ink-3 clears AA at - 10px, --ink-4 is borderline on light panels. */ -.approval-dock button.act .kbd { +/* ========================================================================== + Tool batch construct — pairs tool calls with their results and + embeds the approval flow. Replaces the bottom approval dock + the + duplicate .msg.tool bubbles for tool-call rendering. + + One construct per dispatch turn: + - solo (1 call, serial): .coord-tool-batch--solo + - parallel (≥2 calls): .coord-tool-batch--parallel + rows share a left rail so the + operator reads them as siblings + of one assistant decision. + + Sub-elements: + .coord-tool-batch-head label + count + tier glyph + .coord-tool-row per-call row (call line + verdict + result) + .coord-tool-row-call [idx] name args ellipsized + .coord-tool-row-verdict judge verdict chip + rationale teaser + .coord-tool-row-result paired tool_result
under the row
+ .coord-tool-row-status per-row pill (auto-approved / error)
+ .coord-tool-actions approve / deny / always
+ .coord-tool-status resolved status pill (replaces actions)
+
+ States (modifiers on the batch):
+ .coord-tool-batch--pending approval gate visible
+ .coord-tool-batch--approved resolved approve
+ .coord-tool-batch--denied resolved deny — rows dimmed
+ .coord-tool-batch--auto all auto-approved, no gate ever shown
+ ========================================================================== */
+.coord-tool-batch {
+ margin: 4px 0;
+ background: var(--panel);
+ border: 1px solid var(--hair);
+ border-left: 3px solid var(--hair-2);
+ border-radius: var(--r-sm);
+ font-family: var(--font-mono);
+ font-size: 12px;
+ line-height: 1.5;
+ overflow: hidden;
+}
+
+/* State left-stripe — neutral default; warn when gating; ok when
+ resolved-approved; err when denied or any row errored. Three stacked
+ non-colour cues for WCAG 1.4.1: pill text in the head, rail colour,
+ row dimming on deny. */
+.coord-tool-batch--pending {
+ border-left-color: var(--warn);
+}
+.coord-tool-batch--approved {
+ border-left-color: color-mix(in srgb, var(--ok) 65%, var(--hair-2));
+}
+.coord-tool-batch--auto {
+ border-left-color: var(--hair-2);
+}
+.coord-tool-batch--denied,
+.coord-tool-batch--error {
+ border-left-color: var(--err);
+}
+.coord-tool-batch--denied .coord-tool-row {
+ opacity: 0.6;
+}
+
+/* Header strip — small uppercase kicker + per-batch metadata. */
+.coord-tool-batch-head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 8px;
+ padding: 6px 10px;
+ background: var(--panel-2);
+ border-bottom: 1px solid var(--hair);
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--ink-3);
+}
+.coord-tool-batch-kicker {
+ color: var(--ink-3);
+}
+.coord-tool-batch--pending .coord-tool-batch-kicker {
+ color: var(--warn);
+}
+.coord-tool-batch--approved .coord-tool-batch-kicker {
+ color: color-mix(in srgb, var(--ok) 70%, var(--ink-2));
+}
+.coord-tool-batch--denied .coord-tool-batch-kicker {
+ color: var(--err);
+}
+.coord-tool-batch-summary {
+ font-family: var(--font-mono);
+ font-weight: 500;
+ letter-spacing: 0;
+ text-transform: none;
+ color: var(--ink-2);
+}
+.coord-tool-batch-tier {
+ margin-left: auto;
+ font-family: var(--font-mono);
+ font-weight: 400;
+ letter-spacing: 0;
+ text-transform: none;
+ color: var(--ink-4);
+ font-size: 10px;
+}
+
+/* Row container. In parallel batches, rows are framed by a left rail
+ so they read as siblings of a single assistant decision; in solo
+ batches the rail is suppressed to keep visual weight low. */
+.coord-tool-row {
+ position: relative;
+ padding: 8px 10px;
+}
+.coord-tool-row + .coord-tool-row {
+ border-top: 1px solid var(--hair);
+}
+.coord-tool-batch--parallel .coord-tool-row {
+ padding-left: 28px;
+}
+.coord-tool-batch--parallel .coord-tool-row::before {
+ /* Vertical rail tick — connects rows visually as a parallel group.
+ Stops 4px short of the row's top + bottom edges so consecutive
+ rows look continuous; the dot at the row's center marks the call. */
+ content: "";
+ position: absolute;
+ left: 14px;
+ top: 0;
+ bottom: 0;
+ width: 1px;
+ background: var(--hair-2);
+}
+.coord-tool-batch--parallel .coord-tool-row:first-of-type::before {
+ top: 4px;
+}
+.coord-tool-batch--parallel .coord-tool-row:last-of-type::before {
+ bottom: 4px;
+}
+.coord-tool-batch--parallel .coord-tool-row::after {
+ content: "";
+ position: absolute;
+ left: 11px;
+ top: 14px;
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: var(--panel);
+ border: 1.5px solid var(--hair-2);
+}
+.coord-tool-batch--parallel.coord-tool-batch--approved .coord-tool-row::after {
+ border-color: color-mix(in srgb, var(--ok) 65%, var(--hair-2));
+}
+.coord-tool-batch--parallel.coord-tool-batch--denied .coord-tool-row::after,
+.coord-tool-row.error::after {
+ border-color: var(--err);
+}
+
+/* Call line — index/N pill, monospace tool name, ellipsized args. */
+.coord-tool-row-call {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ min-width: 0;
+}
+.coord-tool-row-idx {
+ flex-shrink: 0;
+ padding: 1px 6px;
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ color: var(--ink-3);
+ background: var(--panel-2);
+ border: 1px solid var(--hair);
+ border-radius: 3px;
+}
+.coord-tool-row-name {
+ flex-shrink: 0;
+ font-family: var(--font-mono);
+ font-weight: 600;
+ color: var(--ink);
+}
+.coord-tool-row.error .coord-tool-row-name {
+ color: var(--err);
+}
+.coord-tool-row-args {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-family: var(--font-mono);
+ color: var(--ink-3);
+}
+
+/* Verdict line — judge chip + optional rationale teaser. */
+.coord-tool-row-verdict {
+ margin-top: 4px;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+ font-size: 11px;
+ color: var(--ink-3);
+}
+.coord-tool-row-verdict code {
+ padding: 1px 6px;
+ font-family: var(--font-mono);
+ font-size: 10px;
+ color: var(--ink-2);
+ background: var(--panel-2);
+ border: 1px solid var(--hair);
+ border-radius: 3px;
+}
+.coord-tool-row-verdict code.rec-approve {
+ color: color-mix(in srgb, var(--ok) 70%, var(--ink-2));
+ border-color: color-mix(in srgb, var(--ok) 38%, var(--hair));
+ background: color-mix(in srgb, var(--ok) 12%, var(--panel-2));
+}
+.coord-tool-row-verdict code.rec-review {
+ color: color-mix(in srgb, var(--warn) 70%, var(--ink-2));
+ border-color: color-mix(in srgb, var(--warn) 38%, var(--hair));
+ background: var(--warn-tint);
+}
+.coord-tool-row-verdict code.rec-deny {
+ color: color-mix(in srgb, var(--err) 70%, var(--ink-2));
+ border-color: color-mix(in srgb, var(--err) 38%, var(--hair));
+ background: color-mix(in srgb, var(--err) 12%, var(--panel-2));
+}
+.coord-tool-row-verdict code.judging {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ color: var(--ink-3);
+}
+.coord-tool-row-verdict code.judging .spin {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ border: 1.5px solid var(--accent);
+ border-top-color: transparent;
+ animation: ts-spin 0.9s linear infinite;
+}
+@media (prefers-reduced-motion: reduce) {
+ .coord-tool-row-verdict code.judging .spin { animation: none; }
+}
+
+/* Rationale disclosure — collapsible block under a row. Renders the
+ judge's reasoning prose; `details` element so a click toggles without
+ stealing focus. */
+.coord-tool-row-rationale {
+ margin-top: 4px;
+ font-size: 11px;
+ color: var(--ink-3);
+}
+.coord-tool-row-rationale > summary {
+ cursor: pointer;
+ color: var(--accent);
+ font-size: 11px;
+ list-style: none;
+ user-select: none;
+}
+.coord-tool-row-rationale > summary::before {
+ content: "▸ ";
+ display: inline-block;
+ margin-right: 2px;
+ transition: transform 120ms ease;
+}
+.coord-tool-row-rationale[open] > summary::before {
+ transform: rotate(90deg);
+}
+.coord-tool-row-rationale-body {
+ margin: 4px 0 0 14px;
+ padding: 6px 10px;
+ font-family: var(--font-mono);
+ font-size: 11px;
+ line-height: 1.5;
+ color: var(--ink-3);
+ background: var(--panel-2);
+ border-left: 2px solid var(--hair);
+ border-radius: 0 3px 3px 0;
+ white-space: pre-wrap;
+}
+
+/* Per-row status pill (auto-approved, error). */
+.coord-tool-row-status {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 1px 6px;
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ border-radius: 3px;
+ border: 1px solid var(--hair);
+ color: var(--ink-3);
+}
+.coord-tool-row-status--auto {
+ color: color-mix(in srgb, var(--ok) 70%, var(--ink-2));
+ border-color: color-mix(in srgb, var(--ok) 38%, var(--hair));
+ background: color-mix(in srgb, var(--ok) 12%, var(--panel-2));
+}
+.coord-tool-row-status--error {
+ color: var(--err);
+ border-color: color-mix(in srgb, var(--err) 38%, var(--hair));
+ background: color-mix(in srgb, var(--err) 12%, var(--panel-2));
+}
+
+/* Tool result block — paired under its row, mono pre-block with
+ collapse-when-tall affordance. Maxes at 240px and reveals an
+ "expand" affordance like .tool-output.collapsed in the interactive UI. */
+.coord-tool-row-result {
+ margin-top: 6px;
+ padding: 6px 10px;
+ font-family: var(--font-mono);
+ font-size: 11px;
+ line-height: 1.5;
+ color: var(--ink-2);
+ background: var(--panel-2);
+ border-left: 2px solid var(--hair);
+ border-radius: 0 3px 3px 0;
+ white-space: pre-wrap;
+ word-break: break-word;
+ max-height: 240px;
+ overflow: auto;
+}
+.coord-tool-row.error .coord-tool-row-result {
+ border-left-color: var(--err);
+ color: color-mix(in srgb, var(--err) 75%, var(--ink-2));
+}
+.coord-tool-row-result-lead {
+ display: inline-block;
+ margin-right: 4px;
+ color: var(--ink-4);
+ font-weight: 600;
+}
+
+/* Action row — Approve / Deny / Always. Renders inside a pending
+ tool-batch construct as the operator's gate for the dispatch. The
+ .act button vocabulary (primary/always/danger) is local to this
+ surface; the children-tree's .ch-row .approval-actions reuses the
+ same colour/border treatment in compact .sm sizing. */
+.coord-tool-actions {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 10px;
+ background: var(--panel-2);
+ border-top: 1px solid var(--hair);
+}
+.coord-tool-actions .spacer { flex: 1; }
+.coord-tool-actions button.act {
+ padding: 6px 14px;
+ font: inherit;
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--ink-2);
+ background: var(--panel);
+ border: 1.5px solid var(--hair-2);
+ border-radius: var(--r-md);
+ cursor: pointer;
+ transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
+}
+.coord-tool-actions button.act:hover {
+ color: var(--ink);
+ border-color: var(--ink-4);
+}
+.coord-tool-actions button.act:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+.coord-tool-actions button.act:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+.coord-tool-actions button.act.primary {
+ background: color-mix(in srgb, var(--ok) 28%, var(--panel));
+ color: var(--ok-text);
+ border-color: color-mix(in srgb, var(--ok) 65%, var(--hair));
+ font-weight: 600;
+}
+.coord-tool-actions button.act.primary:hover {
+ background: color-mix(in srgb, var(--ok) 40%, var(--panel));
+ color: var(--ink);
+ border-color: var(--ok);
+}
+.coord-tool-actions button.act.always {
+ background: transparent;
+ border-style: dashed;
+ border-color: color-mix(in srgb, var(--ok) 65%, var(--hair));
+ color: var(--ok-text);
+}
+.coord-tool-actions button.act.always:hover {
+ background: color-mix(in srgb, var(--ok) 15%, var(--panel));
+ color: var(--ink);
+ border-color: var(--ok);
+}
+.coord-tool-actions button.act.danger {
+ color: var(--err);
+ border-color: color-mix(in srgb, var(--err) 42%, var(--hair));
+}
+.coord-tool-actions button.act.danger:hover {
+ background: var(--err-soft);
+ color: var(--err);
+ border-color: var(--err);
+}
+.coord-tool-actions button.act .kbd {
margin-left: 6px;
padding: 0 3px;
font-family: var(--font-mono);
@@ -312,10 +486,44 @@
border: 1px solid var(--hair);
border-radius: 2px;
}
-
-/* Tinted keycap on the primary Approve button — uses the parent's
- --ok hue so the keycap reads as part of the green action surface. */
-.approval-dock button.act.primary .kbd {
+.coord-tool-actions button.act.primary .kbd {
color: color-mix(in srgb, var(--ok) 70%, var(--ink-3));
border-color: color-mix(in srgb, var(--ok) 40%, var(--hair));
}
+
+/* Resolved status pill — replaces the action row after approve/deny. */
+.coord-tool-status {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 10px;
+ background: var(--panel-2);
+ border-top: 1px solid var(--hair);
+ font-size: 11px;
+ font-weight: 500;
+ color: var(--ink-3);
+}
+.coord-tool-status--approved {
+ color: color-mix(in srgb, var(--ok) 70%, var(--ink-2));
+}
+.coord-tool-status--denied,
+.coord-tool-status--error {
+ color: var(--err);
+}
+.coord-tool-status-feedback {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ color: var(--ink-3);
+}
+
+/* Mobile (<700px) — keep action targets ≥44px for WCAG 2.5.5. */
+@media (max-width: 700px) {
+ .coord-tool-actions {
+ flex-wrap: wrap;
+ }
+ .coord-tool-actions button.act {
+ flex: 1 1 30%;
+ min-height: 44px;
+ font-size: 13px;
+ }
+}
diff --git a/turnstone/console/static/coordinator/coordinator.js b/turnstone/console/static/coordinator/coordinator.js
index 5f7e7155..3e6798be 100644
--- a/turnstone/console/static/coordinator/coordinator.js
+++ b/turnstone/console/static/coordinator/coordinator.js
@@ -102,8 +102,6 @@
const statusEl = document.getElementById("coord-status");
const sseEl = document.getElementById("coord-sse-status");
const nameEl = document.getElementById("coord-name");
- const approvalBar = document.getElementById("coord-approval-bar");
- const approvalTools = document.getElementById("coord-approval-tools");
const childrenTreeEl = document.getElementById("coord-children-tree");
const childrenCountEl = document.getElementById("coord-children-count");
const childrenRefreshBtn = document.getElementById("coord-children-refresh");
@@ -123,24 +121,31 @@
let coordModelAlias = "";
let lastStatusEvt = null;
- let pendingApprovalCallId = null;
let evtSource = null;
let reconnectAttempts = 0;
let reconnectTimer = null;
- // Cache of judge verdicts keyed by call_id. Judge events (intent_verdict)
- // and approval events (approve_request) are async and can arrive in either
- // order; the cache lets each handler apply data to the other without
- // assuming ordering.
+ // Cache of judge verdicts keyed by call_id. intent_verdict and
+ // approve_request are async and may arrive in either order; the
+ // cache lets each handler apply data to the other without assuming
+ // ordering. Soft-capped at JUDGE_VERDICTS_CAP entries — Maps
+ // preserve insertion order, so the oldest entry is the one yielded
+ // by .keys().next() and is evicted when the cap is exceeded. Cap
+ // is generous because verdicts are small (~few hundred bytes each)
+ // and the only consumer is the rare race where SSE re-fires
+ // approve_request after the originally-cached entry has been
+ // applied.
+ const JUDGE_VERDICTS_CAP = 500;
const judgeVerdicts = new Map();
-
- // Approval focus is deferred until the judge returns a verdict so the
- // Approve button doesn't pre-emptively light up (could read as "already
- // approved"). No fallback — if the judge never responds (disabled /
- // slow), focus simply never moves; keyboard users tab from the
- // composer to reach the buttons manually. A fallback would produce
- // an ambiguous focus ring that could be misread as "judge approved."
- let approvalFocusClaimed = false;
+ function _cacheJudgeVerdict(callId, verdict) {
+ if (!callId) return;
+ judgeVerdicts.set(callId, verdict);
+ while (judgeVerdicts.size > JUDGE_VERDICTS_CAP) {
+ const oldest = judgeVerdicts.keys().next().value;
+ if (oldest === undefined) break;
+ judgeVerdicts.delete(oldest);
+ }
+ }
// ------------------------------------------------------------------
// HTML escaping and safe ws_id linkification
@@ -241,6 +246,21 @@
// Message append helpers
// ------------------------------------------------------------------
+ // Coalesce scrollTop writes through requestAnimationFrame so the
+ // bulk history-replay loop doesn't fire one synchronous reflow per
+ // appended message — for histories with hundreds of turns the
+ // un-coalesced version visibly stalls the page. Live SSE streaming
+ // also benefits: token-rate scrolls collapse into one paint.
+ let _scrollPending = false;
+ function _scheduleScroll() {
+ if (_scrollPending) return;
+ _scrollPending = true;
+ requestAnimationFrame(() => {
+ _scrollPending = false;
+ messagesEl.scrollTop = messagesEl.scrollHeight;
+ });
+ }
+
// Map raw role → .msg variant (DS primitives/message.css). "error"
// overloads the role slot for styling; opts.label still carries the
// tool name so SR text like "error · bash" stays meaningful on the
@@ -279,7 +299,7 @@
body.innerHTML = html;
el.appendChild(body);
messagesEl.appendChild(el);
- messagesEl.scrollTop = messagesEl.scrollHeight;
+ _scheduleScroll();
return el;
}
@@ -287,17 +307,7 @@
return appendMsg(role, esc(text), opts);
}
- function appendToolCall(item) {
- const label = item.func_name || "tool";
- const html =
- "" +
- esc(item.header || label) +
- "" +
- (item.preview ? "" + esc(item.preview) + "
" : "");
- return appendMsg("tool", html, { label: label, callId: item.call_id });
- }
-
- // Build an appendToolCall-shaped item from a persisted assistant
+ // Build a tool-batch item from a persisted assistant
// tool_call. Live calls land here with header / preview already
// computed by ChatSession._prepare_tool; history replay never sees
// those (only `function.name` + `function.arguments`), so synthesise
@@ -355,6 +365,11 @@
}
function appendToolResult(name, callId, output, isError) {
+ if (callId && toolRows.has(callId)) {
+ const entry = toolRows.get(callId);
+ _appendResultToRow(entry.row, output, isError);
+ return entry.row;
+ }
const html = renderToolOutput(output);
const el = appendMsg(isError ? "error" : "tool", html, {
label: (isError ? "error · " : "") + (name || "tool"),
@@ -363,6 +378,532 @@
return el;
}
+ // ------------------------------------------------------------------
+ // Tool batch construct — paired tool calls + approval + results
+ //
+ // Replaces the prior pinned approval dock + duplicate .msg.tool
+ // bubble pattern. One construct per dispatch turn:
+ // - solo (1 call, serial): .coord-tool-batch--solo
+ // - parallel (≥2 calls): .coord-tool-batch--parallel
+ // The approval gate, judge verdicts, and tool results all render
+ // inside the construct so the operator reads call → verdict → result
+ // as one cohesive unit.
+ // ------------------------------------------------------------------
+
+ // call_id → { batch, row }. Routes intent_verdict + tool_result
+ // events to the correct row. Holds DOM refs only; the originating
+ // item payload is intentionally not retained (long sessions would
+ // pin per-call preview / parsed-args memory for the page lifetime).
+ const toolRows = new Map();
+
+ // Most-recently-rendered batch with an open approval gate. Used for
+ // keyboard focus claiming and approval_resolved fallbacks.
+ let activeBatch = null;
+
+ function _formatBatchArgs(item) {
+ let s = item.header || item.approval_label || item.preview || "";
+ if (item.func_name && s.startsWith(item.func_name + ":")) {
+ s = s.slice(item.func_name.length + 1).trim();
+ } else if (item.func_name === "bash" && s.startsWith("$ ")) {
+ s = s.slice(2);
+ }
+ return s;
+ }
+
+ function _pickBatchTier(items) {
+ for (const it of items) {
+ const v = it.judge_verdict || it.heuristic_verdict;
+ if (v) {
+ const tierLabel = v.tier || (it.judge_verdict ? "llm" : "heuristic");
+ const glyph = tierLabel === "llm" ? "⚖" : "⚙";
+ const model = v.judge_model ? ":" + v.judge_model : "";
+ return glyph + " " + tierLabel + model;
+ }
+ }
+ return "";
+ }
+
+ function _renderBatchRow(item, indexLabel) {
+ const row = document.createElement("div");
+ row.className = "coord-tool-row";
+ if (item.call_id) row.dataset.callId = item.call_id;
+ if (item.func_name) row.dataset.funcName = item.func_name;
+
+ const callLine = document.createElement("div");
+ callLine.className = "coord-tool-row-call";
+
+ if (indexLabel) {
+ const idx = document.createElement("span");
+ idx.className = "coord-tool-row-idx";
+ idx.textContent = indexLabel;
+ callLine.appendChild(idx);
+ }
+
+ const name = document.createElement("span");
+ name.className = "coord-tool-row-name";
+ name.textContent = item.func_name || "(unknown tool)";
+ callLine.appendChild(name);
+
+ const args = document.createElement("span");
+ args.className = "coord-tool-row-args";
+ args.textContent = _formatBatchArgs(item);
+ callLine.appendChild(args);
+
+ if (item.auto_approved && !item.needs_approval) {
+ const pill = document.createElement("span");
+ pill.className = "coord-tool-row-status coord-tool-row-status--auto";
+ const reason = _normaliseAutoApproveReason(item.auto_approve_reason);
+ pill.textContent = "✓ " + reason;
+ pill.title = "auto-approved (no operator prompt) — reason: " + reason;
+ callLine.appendChild(pill);
+ }
+
+ if (item.error && !item.needs_approval) {
+ const errPill = document.createElement("span");
+ errPill.className = "coord-tool-row-status coord-tool-row-status--error";
+ errPill.textContent = "✗ " + (item.error || "blocked");
+ callLine.appendChild(errPill);
+ row.classList.add("error");
+ }
+
+ row.appendChild(callLine);
+ return row;
+ }
+
+ // Stable signature for a verdict — used to skip the DOM rebuild when
+ // an SSE replay (or duplicate intent_verdict event) carries the same
+ // verdict body we already painted. Any field change (rec/risk/conf
+ // /reasoning) flips the signature and re-renders.
+ function _verdictSig(verdict) {
+ if (!verdict) return "";
+ return [
+ verdict.recommendation || "",
+ verdict.risk_level || "",
+ verdict.confidence != null ? String(verdict.confidence) : "",
+ verdict.reasoning || "",
+ ].join("");
+ }
+
+ function _appendVerdictLineTo(row, verdict) {
+ // Dedupe by signature so reconnect storms don't tear down + rebuild
+ // an unchanged verdict line. _appendVerdictLineTo(row, null) is
+ // used by _appendJudgePendingLineTo to clear and start fresh — that
+ // path explicitly bypasses the dedupe (sig of null is "").
+ const sig = _verdictSig(verdict);
+ if (verdict && row.dataset.verdictSig === sig) {
+ return row.querySelector(".coord-tool-row-verdict");
+ }
+ row.dataset.verdictSig = sig;
+
+ let line = row.querySelector(".coord-tool-row-verdict");
+ if (!line) {
+ line = document.createElement("div");
+ line.className = "coord-tool-row-verdict";
+ const callEl = row.querySelector(".coord-tool-row-call");
+ if (callEl && callEl.nextSibling) {
+ row.insertBefore(line, callEl.nextSibling);
+ } else {
+ row.appendChild(line);
+ }
+ }
+ line.replaceChildren();
+ if (verdict) {
+ const chip = document.createElement("code");
+ const rec = verdict.recommendation || "?";
+ const risk = verdict.risk_level || "?";
+ chip.textContent = "judge: " + rec + " · risk: " + risk;
+ if (rec === "approve") chip.classList.add("rec-approve");
+ else if (rec === "review") chip.classList.add("rec-review");
+ else if (rec === "deny") chip.classList.add("rec-deny");
+ line.appendChild(chip);
+ if (verdict.confidence != null) {
+ const conf = document.createElement("code");
+ const v = verdict.confidence;
+ conf.textContent = typeof v === "number" ? v.toFixed(2) : String(v);
+ line.appendChild(conf);
+ }
+ }
+ const oldRationale = row.querySelector(".coord-tool-row-rationale");
+ if (oldRationale) oldRationale.remove();
+ if (verdict && verdict.reasoning) {
+ const det = document.createElement("details");
+ det.className = "coord-tool-row-rationale";
+ const sum = document.createElement("summary");
+ sum.textContent = "rationale";
+ det.appendChild(sum);
+ const body = document.createElement("div");
+ body.className = "coord-tool-row-rationale-body";
+ body.textContent = verdict.reasoning;
+ det.appendChild(body);
+ // Insert immediately after the verdict line so the rationale
+ // stays adjacent to the chip even when a result block has
+ // already landed below — DOM order otherwise reads as
+ // [call, verdict, result, rationale], which visually disconnects
+ // the rationale from the chip it explains.
+ line.insertAdjacentElement("afterend", det);
+ }
+ return line;
+ }
+
+ function _appendJudgePendingLineTo(row) {
+ const line = _appendVerdictLineTo(row, null);
+ const chip = document.createElement("code");
+ chip.className = "judging";
+ const spin = document.createElement("span");
+ spin.className = "spin";
+ spin.setAttribute("aria-hidden", "true");
+ chip.appendChild(spin);
+ chip.appendChild(document.createTextNode("judge evaluating…"));
+ line.appendChild(chip);
+ }
+
+ function _appendResultToRow(row, output, isError) {
+ if (!row) return;
+ const existing = row.querySelector(".coord-tool-row-result");
+ if (existing) existing.remove();
+ if (isError) {
+ row.classList.add("error");
+ // Lift the row's error onto the enclosing batch so the left
+ // stripe + status pill (--error) cue the operator at the batch
+ // level too. Idempotent — re-fires don't stack.
+ const batch = row.closest(".coord-tool-batch");
+ if (batch) batch.classList.add("coord-tool-batch--error");
+ }
+ const block = document.createElement("div");
+ block.className = "coord-tool-row-result";
+ const lead = document.createElement("span");
+ lead.className = "coord-tool-row-result-lead";
+ lead.textContent = isError ? "✗ error: " : "↳ result: ";
+ block.appendChild(lead);
+ const body = document.createElement("span");
+ body.textContent = stripAnsi(output || "");
+ block.appendChild(body);
+ row.appendChild(block);
+ }
+
+ function _makeActionButton(label, role, kbdHint, ariaLabel) {
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "act";
+ btn.dataset.role = role;
+ btn.appendChild(document.createTextNode(label));
+ if (kbdHint) {
+ const kbd = document.createElement("span");
+ kbd.className = "kbd";
+ kbd.setAttribute("aria-hidden", "true");
+ kbd.textContent = kbdHint;
+ btn.appendChild(kbd);
+ }
+ if (ariaLabel) btn.setAttribute("aria-label", ariaLabel);
+ return btn;
+ }
+
+ function _buildBatchActions(batch, items) {
+ const actions = document.createElement("div");
+ actions.className = "coord-tool-actions";
+ const spacer = document.createElement("div");
+ spacer.className = "spacer";
+ actions.appendChild(spacer);
+
+ const denyBtn = _makeActionButton("Deny", "deny", "D", "Deny (D)");
+ denyBtn.classList.add("danger");
+ denyBtn.addEventListener("click", () =>
+ _resolveBatchAction(batch, false, false),
+ );
+ actions.appendChild(denyBtn);
+
+ 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);
+ if (alwaysNames.length > 0) {
+ const ariaAlways = "Always approve " + alwaysNames.join(", ");
+ const alwaysBtn = _makeActionButton("Always", "always", "⇧A", ariaAlways);
+ alwaysBtn.classList.add("always");
+ alwaysBtn.title = ariaAlways;
+ alwaysBtn.addEventListener("click", () =>
+ _resolveBatchAction(batch, true, true),
+ );
+ actions.appendChild(alwaysBtn);
+ }
+
+ const approveBtn = _makeActionButton(
+ "Approve",
+ "approve",
+ "⏎",
+ "Approve (Enter)",
+ );
+ approveBtn.classList.add("primary");
+ approveBtn.addEventListener("click", () =>
+ _resolveBatchAction(batch, true, false),
+ );
+ actions.appendChild(approveBtn);
+
+ return actions;
+ }
+
+ async function _resolveBatchAction(batch, approved, always) {
+ const pendingRow = Array.from(
+ batch.querySelectorAll(".coord-tool-row"),
+ ).find((r) => r.dataset.callId && !r.classList.contains("error"));
+ const callId = pendingRow && pendingRow.dataset.callId;
+ if (!callId) return;
+ _setBatchActionsDisabled(batch, true);
+ // Stash the "always" intent on the batch so the approval_resolved
+ // SSE handler can render the right status pill — the server's
+ // resolved event doesn't echo the always flag, only `approved` +
+ // `feedback`. Echoes back to the operator that the click landed.
+ batch.dataset.requestedAlways = always ? "1" : "";
+ try {
+ const resp = await approveWorkstream(wsId, {
+ approved: !!approved,
+ always: !!always,
+ call_id: callId,
+ });
+ if (!resp.ok) throw new Error("approve failed: HTTP " + resp.status);
+ } catch (e) {
+ _setBatchActionsDisabled(batch, false);
+ delete batch.dataset.requestedAlways;
+ if (typeof toast !== "undefined" && toast.error) toast.error(String(e));
+ else console.error(e);
+ return;
+ }
+ // approval_resolved SSE event will morph the batch authoritatively.
+ }
+
+ function _setBatchActionsDisabled(batch, disabled) {
+ batch.querySelectorAll(".coord-tool-actions button").forEach((b) => {
+ b.disabled = !!disabled;
+ });
+ }
+
+ // Build the resolved-state status pill. Shared between the live
+ // _morphBatchResolved path (post-approve, post-deny) and the
+ // history-replay path inside appendToolBatch (renders resolved
+ // batches without ever showing actions).
+ function _buildStatusPill(opts) {
+ const status = document.createElement("div");
+ status.className = "coord-tool-status";
+ status.classList.add(
+ opts.approved
+ ? "coord-tool-status--approved"
+ : "coord-tool-status--denied",
+ );
+ const label = document.createElement("span");
+ if (opts.approved) {
+ label.textContent = opts.always ? "✓ approved · always" : "✓ approved";
+ } else {
+ label.textContent = "✗ denied";
+ }
+ status.appendChild(label);
+ if (opts.feedback) {
+ const fb = document.createElement("span");
+ fb.className = "coord-tool-status-feedback";
+ fb.textContent = "— " + opts.feedback;
+ status.appendChild(fb);
+ }
+ return status;
+ }
+
+ function _morphBatchResolved(batch, opts) {
+ if (!batch) return;
+ batch.classList.remove("coord-tool-batch--pending");
+ batch.classList.add(
+ opts.approved ? "coord-tool-batch--approved" : "coord-tool-batch--denied",
+ );
+ const actions = batch.querySelector(".coord-tool-actions");
+ if (actions) actions.replaceWith(_buildStatusPill(opts));
+ if (activeBatch === batch) activeBatch = null;
+ }
+
+ function _focusBatchPrimary(batch, prefer) {
+ if (!batch) return;
+ const role = prefer === "deny" ? "deny" : "approve";
+ const btn = batch.querySelector(
+ '.coord-tool-actions button[data-role="' + role + '"]',
+ );
+ if (btn) {
+ try {
+ btn.focus({ preventScroll: false });
+ } catch (_) {
+ /* noop */
+ }
+ }
+ }
+
+ // Build (or update) a batch construct for `items`. Idempotent on
+ // SSE reconnect: when every item's call_id already has a row in the
+ // DOM, returns the existing batch + folds in any newly-cached
+ // verdicts.
+ //
+ // opts:
+ // pending (bool) — show approval action row, mark pending
+ // auto (bool) — mark .coord-tool-batch--auto (no actions)
+ // judgePending (bool) — show "judge evaluating…" placeholders
+ // resolved ({approved,denied,feedback,always}) — replay of a
+ // historical resolved batch (status pill
+ // prefilled, no actions)
+ function appendToolBatch(items, opts) {
+ items = (items || []).filter(Boolean);
+ if (items.length === 0) return null;
+ opts = opts || {};
+
+ const allMapped = items.every(
+ (it) => it.call_id && toolRows.has(it.call_id),
+ );
+ if (allMapped) {
+ const existing = toolRows.get(items[0].call_id).batch;
+ // Upgrade-in-place: when the second emit promotes the existing
+ // batch to pending (e.g. history replay rendered the call_ids as
+ // orphan-pending placeholders before SSE connected, then the SSE
+ // approve_request replay arrives with opts.pending=true), morph
+ // the existing shell instead of leaving stale chrome. Without
+ // this, the replayed pending event was a no-op against any
+ // already-resolved or non-pending shell — the operator would
+ // never see action buttons in this tab.
+ if (
+ opts.pending &&
+ !existing.classList.contains("coord-tool-batch--pending")
+ ) {
+ existing.classList.remove(
+ "coord-tool-batch--approved",
+ "coord-tool-batch--denied",
+ "coord-tool-batch--auto",
+ "coord-tool-batch--error",
+ );
+ existing.classList.add("coord-tool-batch--pending");
+ const kicker = existing.querySelector(".coord-tool-batch-kicker");
+ if (kicker) {
+ kicker.textContent =
+ items.length >= 2
+ ? "⚠ Approval · Parallel " + items.length
+ : "⚠ Approval";
+ }
+ const statusEl = existing.querySelector(".coord-tool-status");
+ const actionsEl = existing.querySelector(".coord-tool-actions");
+ const newActions = _buildBatchActions(existing, items);
+ if (statusEl) statusEl.replaceWith(newActions);
+ else if (actionsEl) actionsEl.replaceWith(newActions);
+ else existing.appendChild(newActions);
+ activeBatch = existing;
+ } else if (opts.pending) {
+ // Already pending — keep the action row, just refresh
+ // activeBatch so kb shortcut + approval_resolved routing
+ // target the right construct.
+ activeBatch = existing;
+ }
+ items.forEach((it) => {
+ const entry = toolRows.get(it.call_id);
+ if (!entry) return;
+ const cached = judgeVerdicts.get(it.call_id);
+ const v = it.judge_verdict || it.heuristic_verdict || cached;
+ if (v) {
+ _appendVerdictLineTo(entry.row, v);
+ } else if (
+ it.needs_approval &&
+ opts.judgePending &&
+ !entry.row.querySelector(".coord-tool-row-verdict")
+ ) {
+ _appendJudgePendingLineTo(entry.row);
+ }
+ });
+ return existing;
+ }
+
+ const batch = document.createElement("div");
+ batch.className = "coord-tool-batch";
+ batch.classList.add(
+ items.length >= 2
+ ? "coord-tool-batch--parallel"
+ : "coord-tool-batch--solo",
+ );
+ if (opts.pending) batch.classList.add("coord-tool-batch--pending");
+ else if (opts.auto) batch.classList.add("coord-tool-batch--auto");
+ else if (opts.resolved) {
+ batch.classList.add(
+ opts.resolved.approved
+ ? "coord-tool-batch--approved"
+ : "coord-tool-batch--denied",
+ );
+ }
+
+ const head = document.createElement("div");
+ head.className = "coord-tool-batch-head";
+ const kicker = document.createElement("span");
+ kicker.className = "coord-tool-batch-kicker";
+ if (opts.pending) {
+ kicker.textContent =
+ items.length >= 2
+ ? "⚠ Approval · Parallel " + items.length
+ : "⚠ Approval";
+ } else if (items.length >= 2) {
+ kicker.textContent = "Parallel · " + items.length + " tools";
+ } else {
+ kicker.textContent = "Tool";
+ }
+ head.appendChild(kicker);
+
+ const summary = document.createElement("span");
+ summary.className = "coord-tool-batch-summary";
+ const firstName =
+ items[0] && (items[0].func_name || items[0].approval_label)
+ ? items[0].func_name || items[0].approval_label
+ : "tool";
+ summary.textContent =
+ items.length >= 2
+ ? firstName + " + " + (items.length - 1) + " more"
+ : firstName;
+ head.appendChild(summary);
+
+ const tier = _pickBatchTier(items);
+ if (tier) {
+ const tierEl = document.createElement("span");
+ tierEl.className = "coord-tool-batch-tier";
+ tierEl.textContent = tier;
+ head.appendChild(tierEl);
+ }
+ batch.appendChild(head);
+
+ let anyRowError = false;
+ items.forEach((it, idx) => {
+ const indexLabel = items.length >= 2 ? idx + 1 + "/" + items.length : "";
+ const row = _renderBatchRow(it, indexLabel);
+ batch.appendChild(row);
+ if (it.call_id) {
+ toolRows.set(it.call_id, { batch, row });
+ }
+ if (row.classList.contains("error")) anyRowError = true;
+ const cached = it.call_id ? judgeVerdicts.get(it.call_id) : null;
+ const verdict = it.judge_verdict || it.heuristic_verdict || cached;
+ if (verdict) {
+ _appendVerdictLineTo(row, verdict);
+ } else if (it.needs_approval && opts.judgePending) {
+ _appendJudgePendingLineTo(row);
+ }
+ });
+ // Lift any policy-blocked row's error onto the enclosing batch so
+ // the left stripe + status pill cue the operator at the batch
+ // level too. _appendResultToRow does the same for runtime errors
+ // arriving via tool_result.
+ if (anyRowError) batch.classList.add("coord-tool-batch--error");
+
+ if (opts.pending) {
+ batch.appendChild(_buildBatchActions(batch, items));
+ activeBatch = batch;
+ } else if (opts.resolved) {
+ batch.appendChild(_buildStatusPill(opts.resolved));
+ }
+
+ messagesEl.appendChild(batch);
+ _scheduleScroll();
+ return batch;
+ }
+
// ------------------------------------------------------------------
// Content streaming
// ------------------------------------------------------------------
@@ -397,7 +938,7 @@
} else if (body) {
body.textContent = currentAssistantBuf;
}
- messagesEl.scrollTop = messagesEl.scrollHeight;
+ _scheduleScroll();
}
function appendReasoningToken(text) {
@@ -414,7 +955,7 @@
currentReasoningBuf += text;
const body = currentReasoningEl.querySelector(".msg-body");
if (body) body.textContent = currentReasoningBuf;
- messagesEl.scrollTop = messagesEl.scrollHeight;
+ _scheduleScroll();
}
function finishAssistantStream() {
@@ -441,225 +982,40 @@
}
// ------------------------------------------------------------------
- // Approval UI
+ // Approval UI — entry point used by the approve_request SSE handler.
+ // The legacy dock + its public surface (hideApproval / coordApprove)
+ // were removed when approvals moved into the inline batch construct.
+ // The SSE approval_resolved handler now drives _morphBatchResolved
+ // directly, and inline action buttons drive _resolveBatchAction.
// ------------------------------------------------------------------
- function showApproval(items) {
- approvalTools.replaceChildren();
- const pending = (items || []).filter((it) => it.needs_approval);
- // Count badge — shown in the .dhead row's trailing .dcount slot.
- // The "Approval required" kicker is static in the HTML so the
- // screen-reader label stays stable across open/close cycles.
- const countEl = document.getElementById("coord-approval-count");
- if (countEl) {
- countEl.textContent = pending.length ? pending.length + " pending" : "";
- }
- let firstCallId = null;
- pending.forEach((it, idx) => {
- if (!firstCallId) firstCallId = it.call_id;
- // Each pending tool call renders as a .dcall row — the DS pattern
- // frames this like a mini inspectable call line. If we've already
- // received a judge verdict for this call_id, its risk_level takes
- // precedence; otherwise default to "low" until a verdict arrives.
- const row = document.createElement("div");
- row.className = "dcall";
- if (it.call_id) row.dataset.callId = it.call_id;
- if (pending.length > 1) {
- const idx_ = document.createElement("span");
- idx_.className = "risk low";
- idx_.textContent = idx + 1 + "/" + pending.length;
- row.appendChild(idx_);
- }
- const fn = document.createElement("span");
- fn.className = "dfn";
- fn.textContent = it.func_name || "(unknown tool)";
- row.appendChild(fn);
- const args = document.createElement("span");
- args.className = "dargs";
- const preview = it.header || it.approval_label || it.preview || "";
- args.textContent = preview;
- row.appendChild(args);
- approvalTools.appendChild(row);
- // If the judge already delivered a verdict for this call before the
- // approval surfaced, render it into the dock immediately. Otherwise
- // render a spinner placeholder so the reviewer sees "the judge is
- // still thinking" instead of silence.
- if (it.call_id && judgeVerdicts.has(it.call_id)) {
- applyJudgeVerdictToRow(row, judgeVerdicts.get(it.call_id));
- } else {
- applyJudgePendingToRow(row);
- }
+ function showApproval(items, judgePending) {
+ const list = (items || []).filter(Boolean);
+ if (list.length === 0) return;
+ const batch = appendToolBatch(list, {
+ pending: true,
+ judgePending: !!judgePending,
});
- pendingApprovalCallId = firstCallId;
- approvalBar.hidden = false;
- // Defer focus until the judge returns a verdict. If the verdict is
- // already cached (rare race), claim focus immediately. Otherwise
- // wait for intent_verdict — no fallback.
- approvalFocusClaimed = false;
- if (firstCallId && judgeVerdicts.has(firstCallId)) {
- claimApprovalFocusForVerdict(judgeVerdicts.get(firstCallId));
+ const firstPending = list.find((it) => it.needs_approval);
+ if (batch && firstPending && firstPending.call_id) {
+ const cached = judgeVerdicts.get(firstPending.call_id);
+ if (cached) _focusBatchPrimary(batch, cached.recommendation);
}
}
- // Claim approval-bar focus for a specific button. Idempotent — further
- // calls after the first are noops so we don't bounce focus across
- // multiple buttons as verdicts arrive for a batch.
- function claimApprovalFocus(btnId) {
- if (approvalFocusClaimed) return;
- approvalFocusClaimed = true;
- const btn = document.getElementById(btnId);
- if (btn) {
- try {
- btn.focus({ preventScroll: false });
- } catch (_) {
- /* noop */
- }
- }
- }
-
- // Focus the appropriate action based on the judge's recommendation:
- // deny → Deny button (judge is warning; reviewer should default to
- // blocking), approve/review/other → Approve button.
- function claimApprovalFocusForVerdict(verdict) {
- const rec = (verdict && verdict.recommendation) || "";
- claimApprovalFocus(rec === "deny" ? "coord-deny-btn" : "coord-approve-btn");
- }
-
- // Ensure a .dctx sibling exists for the given .dcall row, tagged with
- // the row's call_id. Returns the .dctx element ready to be populated.
- function ensureDctxAfterRow(row) {
- if (!row) return null;
- const callId = row.dataset.callId;
- let dctx = row.nextElementSibling;
- if (
- !dctx ||
- !dctx.classList.contains("dctx") ||
- dctx.dataset.forCall !== callId
- ) {
- dctx = document.createElement("div");
- dctx.className = "dctx";
- if (callId) dctx.dataset.forCall = callId;
- row.insertAdjacentElement("afterend", dctx);
- }
- return dctx;
- }
-
- // Remove any existing .drationale sibling for the given call_id so
- // repeated verdicts don't stack.
- function removeRationale(callId) {
- if (!callId) return;
- const existing = approvalTools.querySelector(
- '.drationale[data-for-call="' + cssEscape(callId) + '"]',
- );
- if (existing) existing.remove();
- }
-
- // Render a "judge evaluating…" placeholder into the .dctx so the
- // reviewer sees the judge is still thinking while awaiting verdict.
- function applyJudgePendingToRow(row) {
- const dctx = ensureDctxAfterRow(row);
- if (!dctx) return;
- removeRationale(row.dataset.callId);
- dctx.replaceChildren();
- const chip = document.createElement("code");
- chip.className = "judging";
- const spin = document.createElement("span");
- spin.className = "spin";
- spin.setAttribute("aria-hidden", "true");
- chip.appendChild(spin);
- chip.appendChild(document.createTextNode("judge evaluating…"));
- dctx.appendChild(chip);
- }
-
- // Render a judge verdict into a specific .dcall row. Builds:
- // .dctx judge: rec (risk: lvl) + optional confidence
- // .drationale judge.reasoning text (wrapped prose block)
- // The judge chip colour-codes by recommendation: approve=ok/green,
- // review=warn/amber, deny=err/red — so reviewers can triage at a
- // glance without reading. Repeated calls for the same row (e.g.
- // re-evaluation) replace prior content.
- function applyJudgeVerdictToRow(row, verdict) {
- if (!row || !verdict) return;
- const callId = row.dataset.callId;
- const dctx = ensureDctxAfterRow(row);
- removeRationale(callId);
- dctx.replaceChildren();
- const chip = document.createElement("code");
- const rec = verdict.recommendation || "?";
- const risk = verdict.risk_level || "?";
- chip.textContent = "judge: " + rec + " (risk: " + risk + ")";
- if (rec === "approve") chip.classList.add("rec-approve");
- else if (rec === "review") chip.classList.add("rec-review");
- else if (rec === "deny") chip.classList.add("rec-deny");
- dctx.appendChild(chip);
- if (verdict.confidence != null) {
- const conf = document.createElement("code");
- conf.textContent = "confidence: " + verdict.confidence;
- dctx.appendChild(conf);
- }
- if (verdict.reasoning) {
- const rationale = document.createElement("div");
- rationale.className = "drationale";
- if (callId) rationale.dataset.forCall = callId;
- rationale.textContent = verdict.reasoning;
- dctx.insertAdjacentElement("afterend", rationale);
- }
- }
-
- function hideApproval() {
- approvalBar.hidden = true;
- approvalTools.replaceChildren();
- const countEl = document.getElementById("coord-approval-count");
- if (countEl) countEl.textContent = "";
- pendingApprovalCallId = null;
- approvalFocusClaimed = false;
- // Prune the verdict cache — verdicts are only used while the dock
- // is visible, so keeping them across resolve cycles would leak
- // memory over long sessions.
- if (judgeVerdicts && typeof judgeVerdicts.clear === "function") {
- judgeVerdicts.clear();
- }
- setApprovalButtonsDisabled(false);
- // Return focus to the composer for keyboard users. Only if the
- // approval bar itself was the focus holder — don't steal focus from
- // e.g. a user who clicked into the history log.
- if (
- document.activeElement &&
- approvalBar.contains(document.activeElement)
- ) {
- try {
- composer.focus();
- } catch (_) {
- /* noop */
- }
- }
- }
-
- function setApprovalButtonsDisabled(disabled) {
- // Disable all three approval buttons during an in-flight POST to
- // prevent double-submit via double-click / Enter-hold. The bar
- // either dismisses on success or re-enables on error.
- ["coord-approve-btn", "coord-approve-always-btn", "coord-deny-btn"].forEach(
- (id) => {
- const btn = document.getElementById(id);
- if (btn) btn.disabled = !!disabled;
- },
- );
- }
-
- // Generic approve POST — usable for both the coord-self dock and
- // the per-child inline buttons in the children-tree. Returns the
+ // Generic approve POST — usable for both the coord-self batch and
+ // the per-child inline buttons in the children-tree. Returns the
// response so callers can inspect 409 (stale call_id) bodies and
// refresh their local state.
//
- // The path differs by target: the coord workstream is hosted on
- // the console process itself (lifted verbs at /v1/api/workstreams/
+ // The path differs by target: the coord workstream is hosted on the
+ // console process itself (lifted verbs at /v1/api/workstreams/
// {coord_ws_id}/approve), but child workstreams live on cluster
// nodes and need to round-trip through the routing proxy at
- // /v1/api/route/workstreams/{child_ws_id}/approve which resolves
- // the ws_id to its owning node and forwards the body verbatim.
- // Without the /route/ prefix children always 404 because the
- // console doesn't host them.
+ // /v1/api/route/workstreams/{child_ws_id}/approve which resolves the
+ // ws_id to its owning node and forwards the body verbatim. Without
+ // the /route/ prefix children always 404 because the console
+ // doesn't host them.
async function approveWorkstream(targetWsId, body) {
const isSelf = targetWsId === wsId;
const path = isSelf
@@ -670,26 +1026,6 @@
return postJSON(path, body);
}
- window.coordApprove = async function (approved, always) {
- if (!pendingApprovalCallId) return; // no-op if bar already resolved
- const body = {
- approved: !!approved,
- always: !!always,
- call_id: pendingApprovalCallId,
- };
- setApprovalButtonsDisabled(true);
- try {
- const resp = await approveWorkstream(wsId, body);
- if (!resp.ok) throw new Error("approve failed: HTTP " + resp.status);
- } catch (e) {
- setApprovalButtonsDisabled(false);
- if (typeof toast !== "undefined" && toast.error) toast.error(String(e));
- else console.error(e);
- return;
- }
- hideApproval();
- };
-
// ------------------------------------------------------------------
// Send / cancel / close
// ------------------------------------------------------------------
@@ -1103,50 +1439,54 @@
}
break;
case "approve_request":
- showApproval(ev.items);
- // Surface each tool for context. Dedupe by call_id: the console
- // replays _pending_approval into every new SSE subscriber (the
- // events SSE handler's coord-side replay), so without this check
- // an SSE reconnect would render each tool row a second time.
- (ev.items || []).forEach((it) => {
- if (!it.needs_approval) return;
- if (
- it.call_id &&
- document.querySelector(
- '.msg[data-call-id="' + cssEscape(it.call_id) + '"]',
- )
- ) {
- return; // already rendered in this pane — skip
- }
- appendToolCall(it);
- });
+ // appendToolBatch is idempotent on call_ids — the console replays
+ // _pending_approval into every new SSE subscriber, so reconnect
+ // won't double-render the construct.
+ showApproval(ev.items, !!ev.judge_pending);
break;
- case "approval_resolved":
- hideApproval();
+ case "approval_resolved": {
+ // Server-driven resolution. Morph the active pending batch
+ // (the construct that posted the approve POST). The server
+ // event carries `approved` + `feedback` only — the "always"
+ // intent was stashed on the batch by _resolveBatchAction so
+ // the status pill reads "approved · always" when set.
+ // Fall back to a DOM lookup if activeBatch was never set
+ // (e.g. cross-tab resolution where this tab never rendered
+ // the approval gate before the resolved event landed).
+ const target =
+ activeBatch ||
+ messagesEl.querySelector(
+ ".coord-tool-batch.coord-tool-batch--pending",
+ );
+ if (target) {
+ const wasAlways = target.dataset.requestedAlways === "1";
+ _morphBatchResolved(target, {
+ approved: ev.approved !== false,
+ always: wasAlways,
+ feedback: ev.feedback || null,
+ });
+ }
break;
+ }
case "intent_verdict":
- // Prefer rendering the verdict inside the approval dock — the
- // judge always evaluates a specific call_id, and the verdict is
- // decision context for that pending approval, not a chat
- // message. Cache the verdict so late-arriving approve_request
- // events can also pick it up (see showApproval).
+ // Cache the verdict so a late-arriving approve_request (or a
+ // SSE replay reorder) still surfaces it. _cacheJudgeVerdict
+ // soft-caps the Map to bound long-session memory growth.
if (ev.call_id) {
- judgeVerdicts.set(ev.call_id, {
+ _cacheJudgeVerdict(ev.call_id, {
recommendation: ev.recommendation,
risk_level: ev.risk_level,
confidence: ev.confidence,
reasoning: ev.reasoning,
});
- const row = approvalTools.querySelector(
- '.dcall[data-call-id="' + cssEscape(ev.call_id) + '"]',
- );
- if (row) {
- applyJudgeVerdictToRow(row, judgeVerdicts.get(ev.call_id));
- // Claim focus now that the reviewer has context to act on.
- // Only for the first-pending call so batch approvals don't
- // fight over focus as verdicts trickle in.
- if (ev.call_id === pendingApprovalCallId && !approvalFocusClaimed) {
- claimApprovalFocusForVerdict(judgeVerdicts.get(ev.call_id));
+ const entry = toolRows.get(ev.call_id);
+ if (entry) {
+ _appendVerdictLineTo(entry.row, judgeVerdicts.get(ev.call_id));
+ // Focus the construct's primary action once the verdict
+ // gives the reviewer context to act on — judge=deny defaults
+ // focus to Deny, otherwise Approve.
+ if (entry.batch.classList.contains("coord-tool-batch--pending")) {
+ _focusBatchPrimary(entry.batch, ev.recommendation);
}
break;
}
@@ -1275,8 +1615,11 @@
case "tool_info":
// Renamed from ``tools_auto_approved`` when ``approve_tools``
// unified onto SessionUIBase — the shared body emits ``tool_info``
- // for both kinds, matching the interactive payload name.
- (ev.items || []).forEach((it) => appendToolCall(it));
+ // for both kinds, matching the interactive payload name. All
+ // items in a single ``tool_info`` envelope share a dispatch
+ // turn, so render them as one batch construct (parallel when
+ // ≥2, solo otherwise) rather than N separate bubbles.
+ appendToolBatch(ev.items || [], { auto: true });
break;
// Child-workstream fan-out routed through the coordinator's own
// SSE stream. CoordinatorManager filters the cluster event bus
@@ -2638,21 +2981,43 @@
// tool result rendered with the literal label "tool", which
// looked like the tool calls had been replaced by raw JSON.
const toolNameByCallId = new Map();
+
+ // Pre-scan every tool message's tool_call_id so the
+ // assistant.tool_calls branch below knows whether each call_id
+ // already has a result persisted. An assistant tool_calls turn
+ // with NO matching tool result for some call_ids = orphan
+ // (approval was pending when the page reloaded); rendering it
+ // as resolved-approved would lock the operator out of the
+ // pending approval (the SSE approve_request replay would then
+ // fold into the "already mapped" branch and never paint
+ // actions). Pre-scanning lets us flag the turn as pending
+ // up-front so SSE either upgrades it in place or finds a
+ // pre-existing pending shell to merge with.
+ const resolvedCallIds = new Set();
+ (hist.messages || []).forEach((m) => {
+ if ((m.role || "tool") === "tool" && m.tool_call_id) {
+ resolvedCallIds.add(m.tool_call_id);
+ }
+ });
+
(hist.messages || []).forEach((m) => {
const role = m.role || "tool";
- // Assistant tool calls — render each as a synthesized
- // appendToolCall row with a header derived from the persisted
- // tool name + arguments, mirroring what the live preparer
- // would have shown when the call was first issued. Without
- // this branch the assistant row was empty (content="") and
- // the operator saw only the raw tool result below it.
+ // Assistant tool_calls — synthesize one batch construct per
+ // assistant turn so a parallel fan-out (tool_calls.length ≥ 2)
+ // reads as one cohesive dispatch, matching how live SSE
+ // renders the same flow via approve_request / tool_info.
+ // The batch is marked resolved-approved when every call_id has
+ // a matching tool result message; otherwise it's an orphan
+ // (approval pending at reload) and renders as pending so the
+ // operator can act + SSE can fold its approve_request replay
+ // into this same shell via the upgrade-in-place path.
if (
role === "assistant" &&
Array.isArray(m.tool_calls) &&
m.tool_calls.length
) {
- m.tool_calls.forEach((tc) => {
+ const items = m.tool_calls.map((tc) => {
const fn = (tc && tc.function) || {};
const name = String(fn.name || "tool");
const callId = String((tc && tc.id) || "");
@@ -2663,15 +3028,28 @@
} catch (_) {
/* malformed — fall back to raw string in preview */
}
+ if (callId) toolNameByCallId.set(callId, name);
const item = synthesizeHistoricalToolCall(
name,
callId,
parsedArgs,
argsRaw,
);
- if (callId) toolNameByCallId.set(callId, name);
- appendToolCall(item);
+ // Synthesized items default to needs_approval=true so the
+ // upgrade-in-place path (when SSE later folds in) can
+ // render judge placeholders and the approve POST has a
+ // call_id to send. Resolved-batch path ignores it.
+ item.needs_approval = true;
+ return item;
});
+ const allResolved = items.every(
+ (it) => !it.call_id || resolvedCallIds.has(it.call_id),
+ );
+ if (allResolved) {
+ appendToolBatch(items, { resolved: { approved: true } });
+ } else {
+ appendToolBatch(items, { pending: true, judgePending: true });
+ }
}
// User messages with attachments arrive as multipart list
diff --git a/turnstone/console/static/coordinator/index.html b/turnstone/console/static/coordinator/index.html
index 471e0cd2..03629437 100644
--- a/turnstone/console/static/coordinator/index.html
+++ b/turnstone/console/static/coordinator/index.html
@@ -14,14 +14,15 @@