diff --git a/tests/test_coordinator_page.py b/tests/test_coordinator_page.py index 472394cb..cf0c2d39 100644 --- a/tests/test_coordinator_page.py +++ b/tests/test_coordinator_page.py @@ -55,13 +55,16 @@ def test_uppercase_hex_rejected(client): def test_coordinator_js_exposes_inline_approval_helpers(): - """Smoke guard for the Chunk 3 frontend wiring — the new helper - function names must remain reachable in the served JS so a refactor - accidentally renaming/removing them surfaces here instead of in - production where the children-tree's inline approve/deny buttons - silently stop rendering. Asserts string presence only — no DOM - parsing — since coord.js has no JS test framework today (per the - plan's testing notes).""" + """Smoke guard for two layers of the coord chat frontend: the + children-tree inline approve/deny block (the original Chunk 3 + landing) and the PR #447 tool-batch construct that replaced the + pinned approval dock for the coord-self surface. Both layers' + helper symbols must remain reachable in the served JS so a + refactor that accidentally renames or removes them surfaces here + instead of in production where the affected gates silently stop + rendering. Asserts string presence only — no DOM parsing — + since coord.js has no JS test framework today (per the plan's + testing notes).""" from pathlib import Path coord_js = Path(__file__).resolve().parent.parent / ( @@ -119,3 +122,33 @@ def test_coordinator_js_exposes_inline_approval_helpers(): # call short-circuits on non-visible rows, leaving them stuck. assert "_maybeStartJudgePoll" in body assert "_judgePollTick" in body + # Reload parity for the coord-self approval gate: init() must + # consume the authoritative GET /workstreams snapshot's + # pending_approval_detail so a freshly opened tab can render + # Approve/Deny before SSE replay arrives. + assert "wsSnapshot.pending_approval_detail" in body + assert "appendToolBatch(pendingDetail.items" in body + # Tool-batch construct (PR #447) — the inline replacement for the + # pinned approval-dock pattern. These helpers carry the + # state-machine that pairs each tool call with its result and + # embeds the approval flow. Refactors that rename or drop them + # silently regress the entire coord-self approval surface — the + # most novel and risky behavior in the PR. + assert "function appendToolBatch" in body + assert "function _morphBatchResolved" in body + assert "function _resolveBatchAction" in body + assert "function _refreshBatchTier" in body + assert "function _refreshRowStatus" in body + # State modifiers driven by the upgrade-in-place path + # (--running orphan promoted to --pending or --auto when SSE + # arrives with the authoritative shape). Both class names must + # remain reachable from JS — dropping either breaks the reload + # state machine that PR #447's review pass surfaced. + assert "coord-tool-batch--running" in body + assert "coord-tool-batch--pending" in body + # History replay's outcome classifier — denied / errored tool + # turns must render with the correct batch state on reload, not + # the contradictory "✓ approved" pill that pre-fix showed for + # any prior denial. bug-1 / bug-3 from the second /review pass. + assert "Denied by user" in body + assert "callOutcomes" in body diff --git a/tests/test_workstream_endpoints.py b/tests/test_workstream_endpoints.py index f83a09bc..07d082d1 100644 --- a/tests/test_workstream_endpoints.py +++ b/tests/test_workstream_endpoints.py @@ -10,6 +10,7 @@ import pytest from starlette.applications import Starlette from starlette.middleware import Middleware from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import JSONResponse from starlette.routing import Mount, Route from starlette.testclient import TestClient @@ -740,26 +741,38 @@ class TestUpdateInterfaceSetting: # These tests pin the interactive wiring against the same factory. -def _interactive_endpoint_cfg(mock_mgr: Any) -> SessionEndpointConfig: +def _interactive_endpoint_cfg( + mock_mgr: Any, + tenant_check: Any = None, +) -> SessionEndpointConfig: """Interactive-shaped cfg wired the same way ``server.py`` does. Shared by both :func:`_build_history_app` and :func:`_build_detail_app` — every field both factories actually read is present (the detail factory ignores ``list_kind`` since it relies on ``mgr.open()`` for cross-kind isolation, but the field is harmless to set). + + The optional ``tenant_check`` lets a regression test wire the same + cross-tenant gate ``server.py`` uses (``_interactive_tenant_check``) + so the lifted handlers can be exercised with the production-shape + auth posture, not just the bypass shape. """ return SessionEndpointConfig( permission_gate=None, # auth middleware covers it manager_lookup=lambda _r: (mock_mgr, None), - tenant_check=None, + tenant_check=tenant_check, not_found_label="Workstream not found", audit_action_prefix="workstream", list_kind=WorkstreamKind.INTERACTIVE, ) -def _build_history_app(mock_mgr: Any, storage: Any) -> TestClient: - cfg = _interactive_endpoint_cfg(mock_mgr) +def _build_history_app( + mock_mgr: Any, + storage: Any, + tenant_check: Any = None, +) -> TestClient: + cfg = _interactive_endpoint_cfg(mock_mgr, tenant_check=tenant_check) handler = make_history_handler(cfg) app = Starlette( routes=[ @@ -777,8 +790,11 @@ def _build_history_app(mock_mgr: Any, storage: Any) -> TestClient: return TestClient(app) -def _build_detail_app(mock_mgr: Any) -> TestClient: - cfg = _interactive_endpoint_cfg(mock_mgr) +def _build_detail_app( + mock_mgr: Any, + tenant_check: Any = None, +) -> TestClient: + cfg = _interactive_endpoint_cfg(mock_mgr, tenant_check=tenant_check) handler = make_detail_handler(cfg) app = Starlette( routes=[ @@ -906,6 +922,10 @@ class TestDetailInteractive: loaded_ws.state = ws_state loaded_ws.user_id = "test-user" loaded_ws.kind = "interactive" + # No pending approval — leave .ui's MagicMock attrs alone; the + # handler isinstance-checks ``_pending_approval`` against ``dict`` + # before treating it as live, so MagicMock attribute pollution + # doesn't trigger the pending path. mock_mgr = MagicMock() mock_mgr.get.return_value = loaded_ws client = _build_detail_app(mock_mgr) @@ -919,8 +939,102 @@ class TestDetailInteractive: "state": "idle", "user_id": "test-user", "kind": "interactive", + "pending_approval": False, + "pending_approval_detail": None, } + def test_pending_approval_fields_propagate_from_ui(self): + """When the workstream's UI is parked on an approval, the detail + response surfaces ``pending_approval=True`` + the serialized + ``pending_approval_detail`` so a freshly-loaded chat tab can + paint the inline gate without waiting for the SSE + ``approve_request`` replay (which would otherwise produce a + brief ``--running`` flash on reload).""" + ws_id = "ws-pending-1" + ws_state = MagicMock() + ws_state.value = "attention" + loaded_ws = MagicMock() + loaded_ws.id = ws_id + loaded_ws.name = "coord-1" + loaded_ws.state = ws_state + loaded_ws.user_id = "test-user" + loaded_ws.kind = "coordinator" + # Realistic _pending_approval shape (mirrors what + # SessionUIBase.approve_tools assigns) + a serializer that + # returns the merged-with-verdicts payload. + loaded_ws.ui._pending_approval = { + "type": "approve_request", + "items": [ + { + "call_id": "c-1", + "func_name": "spawn_workstream", + "needs_approval": True, + }, + ], + "judge_pending": True, + } + loaded_ws.ui.serialize_pending_approval_detail = MagicMock( + return_value={ + "call_id": "c-1", + "judge_pending": True, + "items": [ + { + "call_id": "c-1", + "func_name": "spawn_workstream", + "needs_approval": True, + "heuristic_verdict": { + "recommendation": "approve", + "risk_level": "low", + "confidence": 0.9, + }, + } + ], + } + ) + mock_mgr = MagicMock() + mock_mgr.get.return_value = loaded_ws + client = _build_detail_app(mock_mgr) + + r = client.get(f"/v1/api/workstreams/{ws_id}") + assert r.status_code == 200 + body = r.json() + assert body["pending_approval"] is True + assert body["pending_approval_detail"]["call_id"] == "c-1" + assert body["pending_approval_detail"]["judge_pending"] is True + items = body["pending_approval_detail"]["items"] + assert len(items) == 1 + assert items[0]["func_name"] == "spawn_workstream" + assert items[0]["needs_approval"] is True + + def test_pending_serializer_failure_falls_back_to_bool_only(self): + """A malformed verdict that crashes ``serialize_pending_approval_detail`` + must NOT fail the detail response — the boolean still informs + the UI that an approval is pending; SSE replay carries the + authoritative payload. Defensive against a future serializer + regression silently 500ing every page load.""" + ws_id = "ws-pending-broken" + ws_state = MagicMock() + ws_state.value = "attention" + loaded_ws = MagicMock() + loaded_ws.id = ws_id + loaded_ws.name = "coord-broken" + loaded_ws.state = ws_state + loaded_ws.user_id = "test-user" + loaded_ws.kind = "coordinator" + loaded_ws.ui._pending_approval = {"items": []} + loaded_ws.ui.serialize_pending_approval_detail = MagicMock( + side_effect=RuntimeError("verdict object is malformed"), + ) + mock_mgr = MagicMock() + mock_mgr.get.return_value = loaded_ws + client = _build_detail_app(mock_mgr) + + r = client.get(f"/v1/api/workstreams/{ws_id}") + assert r.status_code == 200 + body = r.json() + assert body["pending_approval"] is True + assert body["pending_approval_detail"] is None + def test_lazy_rehydrates_on_miss(self): """``mgr.get`` miss → ``mgr.open`` rehydrate. Same flow as coord; pre-lift interactive had no detail endpoint so this is the @@ -986,3 +1100,116 @@ class TestDetailInteractive: assert "correlation_id=" in body["error"] # Per-kind noun via cfg.audit_action_prefix. assert "workstream" in body["error"] + + +class TestTenantCheckOnReadEndpoints: + """Regression coverage for the cross-tenant gate on the lifted + ``GET /workstreams/{ws_id}`` (detail) and ``/history`` endpoints. + + Both handlers used to skip ``cfg.tenant_check`` while every other + lifted session verb invoked it. Pre-PR-447 the gap was a minor + info leak (5 display fields on detail; conversation history); PR + #447 made it real by adding ``pending_approval_detail`` to detail + (tool previews + LLM judge reasoning). These tests pin the gate + so a future cfg refactor can't silently regress it. + """ + + def test_detail_404s_when_tenant_check_rejects(self): + """A non-owning interactive caller reading another user's ws_id + through the detail endpoint must 404 before any data flows.""" + ws_id = "ws-other-user" + loaded_ws = MagicMock() + loaded_ws.id = ws_id + loaded_ws.name = "owned-by-stranger" + loaded_ws.state = MagicMock() + loaded_ws.state.value = "idle" + loaded_ws.user_id = "owner" + loaded_ws.kind = "interactive" + mock_mgr = MagicMock() + mock_mgr.get.return_value = loaded_ws + + # Tenant check returns a 404 just like ``_require_ws_access`` + # does on owner-mismatch. We can't import the production + # helper here (it pulls the whole server module into the test + # graph) so we ape its return shape. + def deny(_request: Any, _ws_id: str, _mgr: Any) -> JSONResponse: + return JSONResponse({"error": "Workstream not found"}, status_code=404) + + client = _build_detail_app(mock_mgr, tenant_check=deny) + + r = client.get(f"/v1/api/workstreams/{ws_id}") + assert r.status_code == 404 + body = r.json() + # Sensitive fields the PR added must not surface for a + # non-owning caller. + assert "name" not in body + assert "pending_approval_detail" not in body + assert "user_id" not in body + # And mgr.get was NEVER consulted — the gate fires first. + mock_mgr.get.assert_not_called() + mock_mgr.open.assert_not_called() + + def test_detail_succeeds_when_tenant_check_allows(self): + """A passing tenant_check (returns ``None``) lets the handler + proceed normally — the ``pending_approval`` defaults still + appear in the response.""" + ws_id = "ws-mine" + loaded_ws = MagicMock() + loaded_ws.id = ws_id + loaded_ws.name = "owned" + loaded_ws.state = MagicMock() + loaded_ws.state.value = "idle" + loaded_ws.user_id = "test-user" + loaded_ws.kind = "interactive" + mock_mgr = MagicMock() + mock_mgr.get.return_value = loaded_ws + + def allow(_request: Any, _ws_id: str, _mgr: Any) -> None: + return None + + client = _build_detail_app(mock_mgr, tenant_check=allow) + + r = client.get(f"/v1/api/workstreams/{ws_id}") + assert r.status_code == 200 + body = r.json() + assert body["ws_id"] == ws_id + assert body["pending_approval"] is False + assert body["pending_approval_detail"] is None + + def test_history_404s_when_tenant_check_rejects(self, _inject_storage): + """A non-owning interactive caller reading another user's ws_id + through the history endpoint must 404 before any storage + access — owner messages are sensitive content.""" + ws_id = "ws-other-user-hist" + _inject_storage.register_workstream(ws_id, kind="interactive", user_id="owner") + _inject_storage.save_message(ws_id, "user", "private message") + mock_mgr = MagicMock() + mock_mgr.get.return_value = None + + def deny(_request: Any, _ws_id: str, _mgr: Any) -> JSONResponse: + return JSONResponse({"error": "Workstream not found"}, status_code=404) + + client = _build_history_app(mock_mgr, _inject_storage, tenant_check=deny) + + r = client.get(f"/v1/api/workstreams/{ws_id}/history") + assert r.status_code == 404 + # Owner's content must not have leaked into the response. + assert "private message" not in r.text + + def test_history_succeeds_when_tenant_check_allows(self, _inject_storage): + ws_id = "ws-mine-hist" + _inject_storage.register_workstream(ws_id, kind="interactive", user_id="test-user") + _inject_storage.save_message(ws_id, "user", "hello") + mock_ws = MagicMock() + mock_ws.id = ws_id + mock_mgr = MagicMock() + mock_mgr.get.return_value = mock_ws + + def allow(_request: Any, _ws_id: str, _mgr: Any) -> None: + return None + + client = _build_history_app(mock_mgr, _inject_storage, tenant_check=allow) + + r = client.get(f"/v1/api/workstreams/{ws_id}/history") + assert r.status_code == 200 + assert any(m.get("content") == "hello" for m in r.json()["messages"]) diff --git a/turnstone/api/server_schemas.py b/turnstone/api/server_schemas.py index d1bde837..7a1f399b 100644 --- a/turnstone/api/server_schemas.py +++ b/turnstone/api/server_schemas.py @@ -424,6 +424,26 @@ class WorkstreamDetailResponse(BaseModel): state: str user_id: str kind: WorkstreamKind = WorkstreamKind.INTERACTIVE + pending_approval: bool = Field( + default=False, + description=( + "True when the workstream is parked on ``_approval_event`` " + "awaiting an operator approve/deny. Mirrors the same field " + "on ``DashboardWorkstream`` / cluster live projections so a " + "freshly-loaded chat tab can render the inline approval gate " + "from the detail snapshot before SSE replay arrives." + ), + ) + pending_approval_detail: PendingApprovalDetail | None = Field( + default=None, + description=( + "Inline approval payload — same shape as ``DashboardWorkstream" + ".pending_approval_detail``. ``None`` when no approval is " + "pending. Lets a reload paint the action row + judge " + "verdicts immediately instead of relying on the SSE " + "approve_request replay timing window." + ), + ) class WorkstreamHistoryResponse(BaseModel): diff --git a/turnstone/console/static/coordinator/coordinator.css b/turnstone/console/static/coordinator/coordinator.css index c1f8dbd1..8613e6c3 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,432 @@ 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--running  replay-time orphan (dispatched but no
+                                 matching tool_result yet) — no actions.
+                                 SSE upgrades to --pending or --auto
+                                 when it knows more.
+   ========================================================================== */
+.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--running {
+  /* Subtle accent stripe so the operator can tell a still-in-flight
+     replayed batch apart from a resolved one without it screaming
+     for attention.  Not warn (which would imply approval-needed). */
+  border-left-color: color-mix(in srgb, var(--accent) 50%, 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);
+}
+/* Tuck the rail 4px in from the very first / last row's edge so the
+   line doesn't butt against the batch's inner top/bottom.  Class
+   markers (set in JS at row-build time) instead of :first-of-type /
+   :last-of-type because the batch contains other ``
`` siblings + (.coord-tool-batch-head, .coord-tool-actions / .coord-tool-status) + that are also of type ``div`` — :first-of-type would never select + the first .coord-tool-row, and the rule would silently no-op. */ +.coord-tool-batch--parallel .coord-tool-row.coord-tool-row--first::before { + top: 4px; +} +.coord-tool-batch--parallel .coord-tool-row.coord-tool-row--last::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. Capped + at 240px with internal scroll so a long tool output doesn't push + the rest of the chat off-screen. The interactive UI uses a + click-to-expand "collapsed" affordance (see ui/static/style.css + .tool-output.collapsed) — coord deliberately doesn't, since the + construct is read-only history once results land and a scroll + pane is the lower-friction read for a diagnostic surface. */ +.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 +507,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..729a457a 100644 --- a/turnstone/console/static/coordinator/coordinator.js +++ b/turnstone/console/static/coordinator/coordinator.js @@ -102,14 +102,28 @@ 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"); const tasksEl = document.getElementById("coord-tasks"); const tasksCountEl = document.getElementById("coord-tasks-count"); const tasksRefreshBtn = document.getElementById("coord-tasks-refresh"); + // Off-screen aria-live="assertive" region — pending tool-batches + // append into the polite messages log, which gets flipped to + // aria-live="off" during streaming. Routing the action-required + // announcement through this dedicated region ensures SR users hear + // the gate land regardless of streaming state. + const srAnnouncerEl = document.getElementById("coord-sr-announcer"); + function _announceAssertive(text) { + if (!srAnnouncerEl) return; + // Briefly clearing then setting forces SR reading even if the + // text is identical to the previous announcement (some SRs only + // read on textContent change). + srAnnouncerEl.textContent = ""; + requestAnimationFrame(() => { + srAnnouncerEl.textContent = text; + }); + } // Status bar — model alias, token / context-window usage, tool calls // this turn, conversation turn. Driven by the connected + status @@ -123,24 +137,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 @@ -158,6 +179,18 @@ .replace(/'/g, "'"); } + // ANSI-escape stripper — mirrors ui/static/app.js so a tool that + // emits CSI sequences (rare on the coord tool surface, but bash + // through MCP / the underlying child node still can) lands as + // readable text inside the tool-batch result block. + function stripAnsi(s) { + return String(s == null ? "" : s).replace( + // eslint-disable-next-line no-control-regex + /\x1b(?:\[[0-9;?]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[()#][A-Za-z0-9]|.)/g, + "", + ); + } + // Post-process tool-output JSON: wrap known ws_id + node_id pairs in a // link pointing at /node/{node_id}/?ws_id={child_ws_id}. Only applies // when BOTH keys are present and look like valid hex ids. @@ -241,6 +274,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 +327,7 @@ body.innerHTML = html; el.appendChild(body); messagesEl.appendChild(el); - messagesEl.scrollTop = messagesEl.scrollHeight; + _scheduleScroll(); return el; } @@ -287,17 +335,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 +393,16 @@ } function appendToolResult(name, callId, output, isError) { + if (callId && toolRows.has(callId)) { + const entry = toolRows.get(callId); + _appendResultToRow(entry.row, output, isError); + // Result blocks grow scrollHeight; without this the user pinned + // at the bottom loses their pin when the row inflates. appendMsg + // already routes through _scheduleScroll on the legacy path; this + // branch was the gap. + _scheduleScroll(); + return entry.row; + } const html = renderToolOutput(output); const el = appendMsg(isError ? "error" : "tool", html, { label: (isError ? "error · " : "") + (name || "tool"), @@ -363,6 +411,806 @@ 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; + } + + // Single source of truth for the batch tier badge text. Both the + // initial-render path (_pickBatchTier reading from items[]) and the + // live-refresh path (_refreshBatchTier reading from row dataset) + // route through here so the literal label can't drift between + // surfaces. + function _formatTierLabel(llmModel, hasHeuristic) { + if (llmModel !== null) { + return "⚖ llm" + (llmModel ? ":" + llmModel : ""); + } + if (hasHeuristic) { + return "⚙ heuristic"; + } + return ""; + } + + // Pick the highest-tier verdict across items for the initial batch + // tier badge. LLM beats heuristic; first LLM verdict's judge_model + // wins (heterogeneous models within a single envelope is unusual but + // we default to the leading one for stability). + function _pickBatchTier(items) { + let llmModel = null; + let hasHeuristic = false; + for (const it of items) { + const v = it.judge_verdict || it.heuristic_verdict; + if (!v) continue; + const tier = v.tier || (it.judge_verdict ? "llm" : "heuristic"); + if (tier === "llm" && llmModel === null) { + llmModel = v.judge_model || ""; + } else if (tier === "heuristic") { + hasHeuristic = true; + } + } + return _formatTierLabel(llmModel, hasHeuristic); + } + + // Coalesce _refreshBatchTier scans across a microtask so a burst of + // verdict updates on the same batch (e.g. 10 intent_verdict SSE + // events for a 10-row fan-out arriving in the same tick) collapse + // into ONE querySelectorAll + DOM compare. Without this each + // verdict triggered an O(N-rows) scan, yielding O(N²) DOM walks + // per batch render or burst. + const _tierDirtyBatches = new Set(); + let _tierFlushScheduled = false; + function _refreshBatchTier(batch) { + if (!batch) return; + _tierDirtyBatches.add(batch); + if (_tierFlushScheduled) return; + _tierFlushScheduled = true; + queueMicrotask(() => { + _tierFlushScheduled = false; + const dirty = Array.from(_tierDirtyBatches); + _tierDirtyBatches.clear(); + dirty.forEach(_refreshBatchTierImmediate); + }); + } + + // Synchronous tier-badge recompute. Called from the microtask + // flush; do not invoke directly from render-hot paths — go through + // _refreshBatchTier for the burst-coalescing benefit. + function _refreshBatchTierImmediate(batch) { + if (!batch || !batch.isConnected) return; + let llmModel = null; + let hasHeuristic = false; + batch.querySelectorAll(".coord-tool-row").forEach((r) => { + const t = r.dataset.verdictTier; + if (t === "llm" && llmModel === null) { + llmModel = r.dataset.verdictModel || ""; + } else if (t === "heuristic") { + hasHeuristic = true; + } + }); + const head = batch.querySelector(".coord-tool-batch-head"); + if (!head) return; + let tierEl = head.querySelector(".coord-tool-batch-tier"); + const label = _formatTierLabel(llmModel, hasHeuristic); + if (label) { + if (!tierEl) { + tierEl = document.createElement("span"); + tierEl.className = "coord-tool-batch-tier"; + head.appendChild(tierEl); + } + if (tierEl.textContent !== label) tierEl.textContent = label; + } else if (tierEl) { + tierEl.remove(); + } + } + + // Apply / refresh per-row status state from an item payload: + // - data-needs-approval="1" when item.needs_approval && !item.error + // - .coord-tool-row-status--auto pill when item.auto_approved + // - .coord-tool-row-status--error pill + .error class when policy- + // blocked (item.error && !needs_approval) + // Idempotent: clears any prior status pills + the data attribute + // before re-applying so an upgrade-in-place (e.g. SSE folding tool + // _info / approve_request items into a previously rendered + // --running batch) doesn't leave stale markers. Runtime errors + // arriving via tool_result are tracked via row.classList.add("error") + // in _appendResultToRow and intentionally not cleared here — they + // reflect execution outcome, not the static item payload. + function _refreshRowStatus(row, item) { + if (!row || !item) return; + const callLine = row.querySelector(".coord-tool-row-call"); + if (callLine) { + callLine + .querySelectorAll(".coord-tool-row-status") + .forEach((p) => p.remove()); + } + delete row.dataset.needsApproval; + // Don't clear .error here when it came from a result (no + // matching item.error); only clear the static-policy marker. + // Approximate by re-deriving solely from item below. + row.classList.remove("error"); + if (item.needs_approval && !item.error) { + row.dataset.needsApproval = "1"; + } + if (callLine && 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 (callLine && 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"); + } + // If a runtime tool_result already landed an .error on this row, + // re-derive it from the result block's presence so we don't + // accidentally drop the cue when refreshing from a non-error + // item shape. + if ( + row.querySelector(".coord-tool-row-result.coord-tool-row-result--error") + ) { + row.classList.add("error"); + } + } + + function _renderBatchRow(item, indexLabel) { + const row = document.createElement("div"); + row.className = "coord-tool-row"; + if (item.call_id) row.dataset.callId = item.call_id; + + 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); + + row.appendChild(callLine); + // Apply status pills + data-needs-approval through the shared + // helper so initial render and upgrade-in-place can't drift. + _refreshRowStatus(row, item); + 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 ""; + // ``tier`` + ``judge_model`` are part of the signature because a + // heuristic→llm transition can otherwise share the four core + // fields (rec / risk / conf / reasoning). ``dataset.verdictTier`` + // is only updated when the rebuild runs, so dropping the tier from + // the signature would lock the header on ``⚙ heuristic`` even + // after the LLM verdict lands. + return [ + verdict.recommendation || "", + verdict.risk_level || "", + verdict.confidence != null ? String(verdict.confidence) : "", + verdict.reasoning || "", + verdict.tier || "", + verdict.judge_model || "", + ].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); + } + // Persist the verdict's tier on the row so the batch's header + // tier badge can escalate from ⚙ heuristic → ⚖ llm when a later + // intent_verdict lands an LLM verdict. Default to "heuristic" + // when tier is absent — heuristic verdicts ship without an + // explicit tier marker on every server emitter. + if (verdict) { + row.dataset.verdictTier = verdict.tier || "heuristic"; + if (verdict.judge_model) { + row.dataset.verdictModel = verdict.judge_model; + } else { + delete row.dataset.verdictModel; + } + } else { + delete row.dataset.verdictTier; + delete row.dataset.verdictModel; + } + _refreshBatchTier(row.closest(".coord-tool-batch")); + 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"; + // Marker class so _refreshRowStatus can preserve the row's + // .error state across upgrade-in-place when the error came from + // a tool_result (not a static item.error). + if (isError) block.classList.add("coord-tool-row-result--error"); + const lead = document.createElement("span"); + lead.className = "coord-tool-row-result-lead"; + lead.textContent = isError ? "✗ error: " : "↳ result: "; + block.appendChild(lead); + // Pretty-print JSON when the output parses; coord tool surfaces + // (list_nodes, tasks, spawn_workstream, ...) emit JSON by default, + // and a single-line dump is unreadable for a multi-key result. + // The parent .coord-tool-row-result has white-space: pre-wrap, so + // a textContent assignment with embedded \n preserves the + // formatting without needing a nested
.
+    const cleaned = stripAnsi(output || "");
+    const body = document.createElement("span");
+    let pretty = cleaned;
+    // Pretty-print JSON only when:
+    //  - the payload looks like JSON (first non-space char is { or [),
+    //    so we don't waste a parse on plain text or HTML, AND
+    //  - it's small enough that the parse + restringify is cheap
+    //    (cap at 32 KiB).  A 100 KiB tool output can deepen into a
+    //    multi-MB object graph and stall the main thread for hundreds
+    //    of ms; the parent CSS is white-space: pre-wrap so raw text
+    //    still wraps and stays readable past the cap.
+    const PRETTY_PRINT_CAP = 32 * 1024;
+    if (cleaned && cleaned.length <= PRETTY_PRINT_CAP) {
+      const head = cleaned.charCodeAt(0);
+      // 0x7B = '{', 0x5B = '['  — leading whitespace would also fail
+      // the cheap heuristic; that's intentional, the pretty-print is
+      // a UX nice-to-have not a contract.
+      if (head === 0x7b || head === 0x5b) {
+        try {
+          const parsed = JSON.parse(cleaned);
+          if (parsed && typeof parsed === "object") {
+            pretty = JSON.stringify(parsed, null, 2);
+          }
+        } catch (_) {
+          /* not JSON — fall through to raw text */
+        }
+      }
+    }
+    body.textContent = pretty;
+    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;
+  }
+
+  // Concise, screen-reader friendly summary of a pending batch — used
+  // both as the .coord-tool-batch[role=region] aria-label and as the
+  // text fed to the off-screen assertive announcer when the gate
+  // appears.  "Approval required: spawn_workstream + 9 more" reads
+  // cleanly through a SR without revealing every nested arg.
+  function _approvalAriaLabel(items) {
+    const first =
+      (items && items[0] && (items[0].func_name || items[0].approval_label)) ||
+      "tool";
+    const rest = items.length > 1 ? " + " + (items.length - 1) + " more" : "";
+    return "Approval required: " + first + rest;
+  }
+
+  // Header kicker text for a pending batch.  Both the upgrade-in-place
+  // and fresh-build paths in appendToolBatch render this; pulling the
+  // string into one place keeps the two paths from drifting on a
+  // future label tweak.
+  function _pendingKickerText(items) {
+    return items.length >= 2
+      ? "⚠ Approval · Parallel " + items.length
+      : "⚠ Approval";
+  }
+
+  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) {
+    // Pick a call_id from a row that's actually in the server's
+    // pending_items (data-needs-approval="1").  approve_request
+    // envelopes carry the FULL items list including auto-approved /
+    // policy-blocked siblings; resolving against one of those would
+    // 409 since the server's pending_items wouldn't recognise it.
+    const pendingRow = batch.querySelector(
+      '.coord-tool-row[data-needs-approval="1"][data-call-id]',
+    );
+    const callId = pendingRow && pendingRow.dataset.callId;
+    if (!callId) return;
+    _setBatchActionsDisabled(batch, true);
+    // Stash the "always" intent on the batch as a backward-compat
+    // fallback for the approval_resolved SSE handler.  Server now
+    // echoes `always` on the resolved event (post-PR-447) so peer
+    // tabs render the right status pill in cross-tab scenarios; the
+    // dataset is only consulted during a hot-deploy window where the
+    // SSE event might briefly omit the field.  Also 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",
+    );
+    // Drop the [role=region] approval landmark so the resolved batch
+    // stops claiming "Approval required" in SR landmark navigation.
+    batch.removeAttribute("role");
+    batch.removeAttribute("aria-label");
+    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 (and upgrades the batch's state class when SSE arrives
+  // with a more specific state than history replay used).
+  //
+  // opts (mutually exclusive states):
+  //   pending (bool)       — show approval action row, mark --pending
+  //   auto (bool)          — mark --auto (no actions; auto-approved)
+  //   running (bool)       — mark --running (no actions; replay-time
+  //                          orphan with no result yet — could be
+  //                          pending OR auto-approved + in-flight,
+  //                          ambiguous until SSE clarifies)
+  //   resolved ({approved,denied,feedback,always}) — historical
+  //                          resolved batch (status pill prefilled)
+  //   judgePending (bool)  — show "judge evaluating…" placeholders
+  //                          on rows with needs_approval=true (only
+  //                          meaningful with pending)
+  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),
+    );
+    // Partial-overlap guard.  If only SOME of the incoming call_ids
+    // are mapped (i.e. they belong to a different prior batch),
+    // overwriting toolRows in the create-new path below would orphan
+    // those prior rows — they'd stay in their old batch's DOM but
+    // tool_result / intent_verdict events for them would route into
+    // the new batch.  This shape doesn't occur in normal operation
+    // (the server never sends overlapping envelopes), so we log it +
+    // unmap the stale entries before the new batch claims them.
+    if (!allMapped) {
+      const partial = items.filter(
+        (it) => it.call_id && toolRows.has(it.call_id),
+      );
+      if (partial.length > 0) {
+        console.warn(
+          "coord_ui: partial-overlap envelope — unmapping",
+          partial.length,
+          "stale call_ids before new batch claims them",
+        );
+        partial.forEach((it) => toolRows.delete(it.call_id));
+      }
+    }
+    if (allMapped) {
+      const existing = toolRows.get(items[0].call_id).batch;
+      // Upgrade-in-place: when SSE arrives with a more specific state
+      // than the placeholder history replay rendered, morph the
+      // existing shell instead of leaving stale chrome.  The two real
+      // upgrade transitions:
+      //   --running → --pending  (SSE approve_request fires for an
+      //                            orphan turn that was actually
+      //                            awaiting approval at reload)
+      //   --running → --auto     (SSE tool_info fires for an orphan
+      //                            turn that was actually auto-
+      //                            approved + in-flight at reload)
+      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--running",
+          "coord-tool-batch--error",
+        );
+        existing.classList.add("coord-tool-batch--pending");
+        existing.setAttribute("role", "region");
+        existing.setAttribute("aria-label", _approvalAriaLabel(items));
+        const kicker = existing.querySelector(".coord-tool-batch-kicker");
+        if (kicker) {
+          kicker.textContent = _pendingKickerText(items);
+        }
+        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;
+        _announceAssertive(_approvalAriaLabel(items));
+      } else if (
+        opts.auto &&
+        existing.classList.contains("coord-tool-batch--running")
+      ) {
+        existing.classList.remove("coord-tool-batch--running");
+        existing.classList.add("coord-tool-batch--auto");
+      } else if (opts.pending) {
+        // Already pending — keep the action row, just refresh
+        // activeBatch so kb shortcut + approval_resolved routing
+        // target the right construct.  Don't re-announce; SR already
+        // heard about this gate.
+        activeBatch = existing;
+      }
+      items.forEach((it) => {
+        const entry = toolRows.get(it.call_id);
+        if (!entry) return;
+        // Refresh per-row status from the authoritative SSE item:
+        // clears any stale data-needs-approval / status pills the
+        // earlier shell rendered (e.g. replay-time orphan rows had
+        // no auto/error info; SSE tool_info / approve_request items
+        // do).  Preserves runtime tool_result errors via the
+        // .coord-tool-row-result--error marker.
+        _refreshRowStatus(entry.row, it);
+        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.running) batch.classList.add("coord-tool-batch--running");
+    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 = _pendingKickerText(items);
+    } else if (opts.running) {
+      kicker.textContent =
+        items.length >= 2 ? "Running · Parallel " + items.length : "Running";
+    } 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;
+    const renderedRows = [];
+    items.forEach((it, idx) => {
+      const indexLabel = items.length >= 2 ? idx + 1 + "/" + items.length : "";
+      const row = _renderBatchRow(it, indexLabel);
+      batch.appendChild(row);
+      renderedRows.push(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);
+      }
+    });
+    // Tuck the parallel rail 4px in from the first / last row's
+    // top + bottom edges via class markers (CSS :first-of-type would
+    // miss because the batch has other div siblings — head + actions
+    // — coming before/after the row group).
+    if (renderedRows.length > 0) {
+      renderedRows[0].classList.add("coord-tool-row--first");
+      renderedRows[renderedRows.length - 1].classList.add(
+        "coord-tool-row--last",
+      );
+    }
+    // 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));
+      // Mark the construct as a navigable landmark for SR users +
+      // route the action-required announcement through the dedicated
+      // assertive live region (the chat log itself is polite and
+      // gets muted during streaming).
+      batch.setAttribute("role", "region");
+      batch.setAttribute("aria-label", _approvalAriaLabel(items));
+      activeBatch = batch;
+      _announceAssertive(_approvalAriaLabel(items));
+    } else if (opts.resolved) {
+      batch.appendChild(_buildStatusPill(opts.resolved));
+    }
+
+    messagesEl.appendChild(batch);
+    _scheduleScroll();
+    return batch;
+  }
+
   // ------------------------------------------------------------------
   // Content streaming
   // ------------------------------------------------------------------
@@ -397,7 +1245,7 @@
     } else if (body) {
       body.textContent = currentAssistantBuf;
     }
-    messagesEl.scrollTop = messagesEl.scrollHeight;
+    _scheduleScroll();
   }
 
   function appendReasoningToken(text) {
@@ -414,7 +1262,7 @@
     currentReasoningBuf += text;
     const body = currentReasoningEl.querySelector(".msg-body");
     if (body) body.textContent = currentReasoningBuf;
-    messagesEl.scrollTop = messagesEl.scrollHeight;
+    _scheduleScroll();
   }
 
   function finishAssistantStream() {
@@ -441,225 +1289,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 +1333,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 +1746,66 @@
         }
         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.  Server now echoes ``always`` on the SSE payload
+        // (post-PR-447) so cross-tab resolution renders the right
+        // status pill on every subscribed tab — not just the one
+        // that clicked.  Fall back to this tab's stashed dataset
+        // flag for backward compat with a server hot-deploy where
+        // the SSE event might briefly omit the field.
+        // 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 =
+            ev.always === true ||
+            (ev.always === undefined && 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.
+        // intent_verdict is the LLM judge's signal by definition, so
+        // tag the cache entry as tier="llm" — _refreshBatchTier reads
+        // this off the row dataset to escalate the header badge from
+        // ⚙ heuristic → ⚖ llm when the late verdict lands.
         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,
+            tier: ev.tier || "llm",
+            judge_model: ev.judge_model || "",
           });
-          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 +1934,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
@@ -2617,12 +3279,13 @@
   // ------------------------------------------------------------------
 
   async function init() {
+    let wsSnapshot = null;
     try {
-      const data = await getJSON(
+      wsSnapshot = await getJSON(
         "/v1/api/workstreams/" + encodeURIComponent(wsId),
       );
-      nameEl.textContent = data.name || "";
-      statusEl.textContent = data.state || "";
+      nameEl.textContent = wsSnapshot.name || "";
+      statusEl.textContent = wsSnapshot.state || "";
     } catch (e) {
       appendText("error", "Failed to load coordinator: " + e.message);
       return;
@@ -2638,21 +3301,81 @@
       // 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: the
+      // tool was dispatched but didn't complete before the reload
+      // captured this history snapshot.  Orphans are ambiguous — the
+      // tool could have been (a) awaiting approval at reload, (b)
+      // auto-approved + still in flight, or (c) approved + still in
+      // flight.  We render orphans as a neutral --running shell with
+      // no actions; SSE then upgrades to --pending when it replays
+      // approve_request (case a) or to --auto when it replays
+      // tool_info (case b), and tool_result events land in the rows
+      // for case c.  Without this neutral state, painting Approve
+      // buttons on a non-pending orphan was misleading and could
+      // 409-on-submit because the call_id wasn't in pending_items.
+      // Pre-scan classifies each tool message's content as
+      // "denied" (server emits "Denied by user[: feedback]" /
+      // "Blocked by tool policy ..." for any deny path), "error"
+      // (Error: prefix from the standard tool error envelope), or
+      // "ok" (anything else).  Map keys are call_ids; the assistant
+      // tool_calls render below reads these to mark the resulting
+      // batch as resolved-denied (vs the default resolved-approved)
+      // and the tool-result render below propagates the error flag
+      // to appendToolResult so the row gets the .error class /
+      // --error stripe / "✗ error:" lead.  Without this both denied
+      // and errored tools rendered as plain "✓ approved" successes
+      // on every reload.
+      const callOutcomes = new Map();
+      (hist.messages || []).forEach((m) => {
+        if ((m.role || "tool") !== "tool" || !m.tool_call_id) return;
+        // Tool message content is usually a string but the multipart
+        // shape (text + image_url parts) is technically allowed.
+        let txt = "";
+        if (typeof m.content === "string") {
+          txt = m.content;
+        } else if (Array.isArray(m.content)) {
+          for (const part of m.content) {
+            if (part && typeof part === "object" && part.type === "text") {
+              txt += String(part.text || "");
+            }
+          }
+        }
+        // Server signals override prefix detection when present.
+        let outcome = "ok";
+        if (m.is_error) {
+          outcome = "error";
+        } else if (
+          txt.startsWith("Denied by user") ||
+          txt.startsWith("Blocked by tool policy")
+        ) {
+          outcome = "denied";
+        } else if (txt.startsWith("Error:")) {
+          outcome = "error";
+        }
+        callOutcomes.set(m.tool_call_id, outcome);
+      });
+
       (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.
+        // Resolved when every call_id has a matching tool result;
+        // otherwise --running (see the resolvedCallIds rationale
+        // above).  SSE upgrades --running in place when it knows
+        // more.
         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 +3386,40 @@
             } 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);
+            // needs_approval is unknown at replay time (the
+            // assistant.tool_calls history payload doesn't persist
+            // the bit).  Leave it unset; the upgrade-in-place path
+            // refreshes per-row state via _refreshRowStatus from the
+            // authoritative SSE item when approve_request /
+            // tool_info actually arrives, so we never tag the wrong
+            // row as needing approval.
+            return item;
           });
+          // Classify the batch as a whole:
+          //   - any call_id without an outcome at all → orphan,
+          //     render as --running (SSE will upgrade in place)
+          //   - any call_id outcome === "denied" → resolved-denied
+          //   - else → resolved-approved (a runtime error doesn't
+          //     change the approval verdict; the per-row .error class
+          //     comes from the tool_result branch below)
+          const outcomes = items.map((it) =>
+            it.call_id ? callOutcomes.get(it.call_id) : "ok",
+          );
+          const allResolved = outcomes.every((o) => o !== undefined);
+          if (!allResolved) {
+            appendToolBatch(items, { running: true });
+          } else if (outcomes.some((o) => o === "denied")) {
+            appendToolBatch(items, { resolved: { approved: false } });
+          } else {
+            appendToolBatch(items, { resolved: { approved: true } });
+          }
         }
 
         // User messages with attachments arrive as multipart list
@@ -2721,11 +3469,18 @@
           // tool that returned ""); still render it so the call_id
           // pairing stays visible.  Resolve the tool name from the
           // matching assistant tool_call so the label reads e.g.
-          // "bash" instead of "tool".
+          // "bash" instead of "tool".  Pass isError when the
+          // pre-scan classified this call_id as an error so the row
+          // gets the .error class / --error stripe / "✗ error:" lead
+          // — without it a failed tool reads on reload as a normal
+          // successful result.  Denials still get the deny-resolved
+          // batch state (no per-row error needed there; the row's
+          // content reads "Denied by user").
           const callId = m.tool_call_id || "";
           const toolName =
             (callId && toolNameByCallId.get(callId)) || m.tool_name || "tool";
-          appendToolResult(toolName, callId, content || "", false);
+          const isError = callOutcomes.get(callId) === "error";
+          appendToolResult(toolName, callId, content || "", isError);
         } else if (role === "assistant") {
           // Empty content with tool_calls only means the assistant
           // turn was just tool dispatch — the synthesized tool-call
@@ -2758,6 +3513,24 @@
           appendText(role, content, { label: role });
         }
       });
+      // History alone can't tell whether an orphaned assistant
+      // tool_calls turn is awaiting approval or merely still running.
+      // The live workstream snapshot can: if pending_approval_detail is
+      // present, upgrade the matching batch immediately so a reload
+      // still exposes Approve/Deny even before SSE reconnects.
+      const pendingDetail =
+        wsSnapshot &&
+        wsSnapshot.pending_approval &&
+        wsSnapshot.pending_approval_detail &&
+        Array.isArray(wsSnapshot.pending_approval_detail.items)
+          ? wsSnapshot.pending_approval_detail
+          : null;
+      if (pendingDetail) {
+        appendToolBatch(pendingDetail.items, {
+          pending: true,
+          judgePending: !!pendingDetail.judge_pending,
+        });
+      }
     } catch (e) {
       console.warn("history load failed", e);
     }
diff --git a/turnstone/console/static/coordinator/index.html b/turnstone/console/static/coordinator/index.html
index 471e0cd2..ae4988e8 100644
--- a/turnstone/console/static/coordinator/index.html
+++ b/turnstone/console/static/coordinator/index.html
@@ -14,14 +14,15 @@