From 9b607e285767c5fe6f3cf679459ffc7ce9b8df0f Mon Sep 17 00:00:00 2001 From: iris Date: Sat, 4 Jul 2026 11:28:32 +0200 Subject: [PATCH] refactor(dashboard): extract SW4RM domain from tabs.js into swarm.js tabs.js shrinks from 1651 to 457 lines. swarm.js is a new 1217-line module that owns the containers/selection-bar/peer-hives domain: - Container-state apply handlers (applyContainerStateChanged/Removed) - Rebuild-queue sync + apply (syncRebuildQueueFromSnapshot, applyRebuildQueueChanged) - Transient-state sync + apply (syncTransientsFromSnapshot, applyTransientSet/Cleared) - renderContainersFromState (re-render from cached snapshot) - Per-agent context menu (buildAgentMenu, agentMenuPost, closeAllAgentMenus) - Topology tree builder (buildAgentTree, treePrefixDom) - Container row (containerRowFingerprint, buildContainerLi, renderContainers) - Selection bar (renderSelectionBar, addMoveActions, validReparentCandidates, addBulkButton) - Peer hives section (renderPeerHives) - Status-age ticker (30s setInterval for .status-age[data-set-at] spans) - CTX_WARN/CTX_CAUTION constants - Selection event listeners (Esc to clear, click on selection-clear) - Agent-menu event listeners (click outside to close, Esc to close) tabs.js (coordinator) retains: - notifyDeltas + seenApprovals/seenQuestions/seededNotify - bindAsyncForms - Cross-domain ticker (approval request-age + reminder/schedule due-at) - refreshState, pollTimer, operatorIsTyping, snapshotOpenDetails - MUTATION_HANDLERS dispatch + bindDashboardStream - activateTab, createTabStrip, initCall, refreshTabCounts, setTabCount Behaviour-preserving code-move. Build verified. --- frontend/packages/dashboard/src/swarm.js | 1217 +++++++++++++++++++++ frontend/packages/dashboard/src/tabs.js | 1228 +--------------------- 2 files changed, 1234 insertions(+), 1211 deletions(-) create mode 100644 frontend/packages/dashboard/src/swarm.js diff --git a/frontend/packages/dashboard/src/swarm.js b/frontend/packages/dashboard/src/swarm.js new file mode 100644 index 00000000..ce2014d0 --- /dev/null +++ b/frontend/packages/dashboard/src/swarm.js @@ -0,0 +1,1217 @@ +// SW4RM (containers) domain — extracted from tabs.js. +// Agent topology, container-row rendering, selection bar, peer-hives block, +// and all live-update apply handlers for container-state, rebuild-queue, and +// transient ops. See docs/web-ui.md::Container row for the rendering contract. + +import { + $, el, form, fmtAgeSecs, +} from './common.js'; +import { themedConfirm, themedToast } from './modal.js'; +import { + containersState, questionsState, +} from './state.js'; + +// Context-window badge thresholds. Preferred source is each container's +// `context_window_tokens` from /api/state (the real window for the model +// it last ran on) — thresholds are then 75% / 50% of it, matching the +// harness compaction watermarks (compact at 75%, auto-reset at 50%). The +// fixed token constants are the fallback for when that field is absent +// (agent has no turns yet, or no per-model config matched the model). +const CTX_WARN_FRACTION = 0.75; // >= this share of the window -> red +const CTX_CAUTION_FRACTION = 0.50; // >= this share of the window -> yellow +const CTX_WARN_TOKENS = 150_000; // fallback red threshold (~= 75% of 200k) +const CTX_CAUTION_TOKENS = 100_000; // fallback yellow threshold (~= 50% of 200k) + +// ─── module-level state ───────────────────────────────────────────────────── + +let rebuildQueueState = []; + +// Keyed container row cache. Maps agent name -> { el:
  • , fingerprint }. +// Allows renderContainers to skip rebuilding rows whose displayed state +// hasn't changed — prevents full-wipe flicker + avoids redundant async +// dashboard-state fetches on every SSE event. +const containerRowCache = new Map(); + +// Derived transient state — cold-loaded from /api/state.transients, +// then mutated live by `transient_set` / `transient_cleared`. Keyed +// by agent name so add/remove are O(1). `since_unix` is wall-clock so +// the elapsed-seconds badge ticks without polling. +const transientsState = new Map(); + +// In-memory set of selected agent logical names backing the sticky +// #selection-bar. See docs/web-ui.md::Selection bar for the +// interaction model (icon-click toggle, Esc/clear button drop, +// tab-gated visibility). +const selectionState = new Set(); + +let openAgentMenu = null; // currently open dropdown element, or null + +// ─── rebuild queue ────────────────────────────────────────────────────────── + +export function syncRebuildQueueFromSnapshot(s) { + rebuildQueueState = (s.rebuild_queue || []).slice(); +} +export function applyRebuildQueueChanged(ev) { + rebuildQueueState = (ev.queue || []).slice(); + // Re-render the SW4RM tab so newly-queued / newly-running + // rebuild-queue ops light up the right card with a building... + // badge, and finished ops fall back to the regular state badges. + // See docs/web-ui.md::Container row for the badge taxonomy. + renderContainersFromState(); +} +// Map from agent name -> highest-priority in-flight queue entry +// (`running` beats `queued`). Used by the container row renderer +// to surface "building..." / "meta-updating..." badges on the +// SW4RM tab when an op is still in the rebuild queue but no +// operator-initiated transient is set. +function inFlightOpsByAgent() { + const out = new Map(); + for (const e of rebuildQueueState) { + if (e.state !== 'queued' && e.state !== 'running') continue; + // spawn ops target an agent that doesn't exist yet as a + // container — the transient store already drives the + // pending row for that case. Skip here to avoid double- + // surfacing if the spawn op happens to land in the queue + // while the row exists transiently. + if (e.kind === 'spawn') continue; + const cur = out.get(e.agent); + // Prefer running over queued; otherwise keep the first match. + if (!cur || (cur.state === 'queued' && e.state === 'running')) { + out.set(e.agent, e); + } + } + return out; +} + +// ─── transients ───────────────────────────────────────────────────────────── + +export function syncTransientsFromSnapshot(s) { + transientsState.clear(); + for (const t of s.transients || []) { + // Snapshot ships `secs` (server-computed); reconstruct an + // approximate since_unix so the live ticker keeps progressing + // without surprising jumps when the next snapshot lands. + const nowUnix = Math.floor(Date.now() / 1000); + transientsState.set(t.name, { + kind: t.kind, + since_unix: t.since_unix ?? (nowUnix - (t.secs || 0)), + }); + } +} +export function applyTransientSet(ev) { + transientsState.set(ev.name, { + kind: ev.transient_kind, + since_unix: ev.since_unix, + }); + renderContainersFromState(); +} +export function applyTransientCleared(ev) { + if (transientsState.delete(ev.name)) renderContainersFromState(); +} +// Re-render using the last cached snapshot (containers come from +// /api/state, transients overlay from the derived map). The snapshot +// is stashed on window.__hyperhive_state by refreshState; on cold +// load before the first snapshot we just skip. +export function renderContainersFromState() { + const s = window.__hyperhive_state; + if (s) renderContainers(s); +} + +// ─── container-state apply ────────────────────────────────────────────────── + +export function applyContainerStateChanged(ev) { + if (!ev.container || !ev.container.name) return; + containersState.set(ev.container.name, ev.container); + renderContainersFromState(); +} +export function applyContainerRemoved(ev) { + if (containersState.delete(ev.name)) renderContainersFromState(); +} + +// ─── selection ────────────────────────────────────────────────────────────── + +function toggleSelection(name) { + if (selectionState.has(name)) selectionState.delete(name); + else selectionState.add(name); + renderContainersFromState(); +} +function clearSelection() { + if (selectionState.size === 0) return; + selectionState.clear(); + renderContainersFromState(); +} +// Esc clears the current selection (operator escape hatch — mirrors +// the side-panel close pattern). Ignored when an editable element +// has focus so typing in compose / answer / journal-search isn't +// intercepted. +document.addEventListener('keydown', (e) => { + if (e.key !== 'Escape') return; + if (!selectionState.size) return; + const a = document.activeElement; + if (a && (a.isContentEditable + || a.tagName === 'INPUT' + || a.tagName === 'TEXTAREA' + || a.tagName === 'SELECT')) return; + e.preventDefault(); + clearSelection(); +}); +document.addEventListener('click', (e) => { + if (e.target && e.target.closest('#selection-clear')) { + clearSelection(); + } +}); + +// ─── per-agent context menu ────────────────────────────────────────────────── +// Three-dot (⋮) button on each agent card for quick single-agent +// lifecycle actions without needing to select first. State-aware: +// restart/stop only shown when running, start only shown when +// stopped; rebuild + destroy/purge always shown. +// The button is CSS-invisible until the row is hovered (or menu is +// open) so it doesn't clutter quiet rows. + +function closeAllAgentMenus() { + if (!openAgentMenu) return; + openAgentMenu.hidden = true; + const wrap = openAgentMenu.closest('.agent-menu'); + if (wrap) { + wrap.classList.remove('open'); + const btn = wrap.querySelector('.agent-menu-btn'); + if (btn) btn.setAttribute('aria-expanded', 'false'); + } + openAgentMenu = null; +} + +// Close on any click outside an agent-menu element. +document.addEventListener('click', (e) => { + if (!e.target.closest('.agent-menu')) closeAllAgentMenus(); +}, true); +// Close on Escape. stopImmediatePropagation so the selection-clear +// handler on the same element doesn't also fire when a menu is open. +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && openAgentMenu) { + closeAllAgentMenus(); + e.stopImmediatePropagation(); + } +}, true); + +// Single-agent POST helper shared by all menu items. +async function agentMenuPost(actionPath, name, body, graceful) { + const url = actionPath + encodeURIComponent(name) + (graceful ? '?graceful=true' : ''); + try { + const resp = await fetch(url, { + method: 'POST', + headers: body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {}, + body: body ? new URLSearchParams(body) : undefined, + redirect: 'manual', + }); + const ok = resp.ok || resp.type === 'opaqueredirect' + || (resp.status >= 200 && resp.status < 400); + if (!ok) { + const text = await resp.text().catch(() => ''); + themedToast('action failed: ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' }); + } + } catch (err) { + themedToast('action failed: ' + err, { type: 'error' }); + } +} + +function buildAgentMenu(c, forgeBase) { + const wrap = el('div', { class: 'agent-menu' }); + const btn = el('button', { + type: 'button', + class: 'agent-menu-btn', + title: `actions for ${c.name}`, + 'aria-label': `actions for ${c.name}`, + 'aria-haspopup': 'menu', + 'aria-expanded': 'false', + }, '⋮'); + + const dropdown = el('ul', { class: 'agent-menu-dropdown', hidden: true, role: 'menu' }); + + function menuItem(label, opts) { + const li = el('li', { role: 'presentation' }); + const item = el('button', { + type: 'button', + class: 'agent-menu-item', + role: 'menuitem', + }, label); + item.addEventListener('click', async () => { + closeAllAgentMenus(); + let graceful = false; + if (opts.confirm) { + const r = await themedConfirm({ + message: opts.confirm, + danger: true, + confirmLabel: opts.confirmLabel || 'confirm', + checkboxes: opts.graceful + ? [{ name: 'graceful', label: 'stop gracefully — let the agent finish its turn and flush state before the container stops' }] + : [], + }); + if (!r) return; + graceful = !!r.graceful; + } + await agentMenuPost(opts.action, c.name, opts.body || null, graceful); + }); + li.append(item); + return li; + } + + function menuSep() { + return el('li', { class: 'agent-menu-sep', role: 'separator' }); + } + + // Navigation link item (opens in same tab by default). + function menuLink(label, href, title) { + const li = el('li', { role: 'presentation' }); + const a = el('a', { + class: 'agent-menu-item', + href, + role: 'menuitem', + title: title || '', + }, label); + a.addEventListener('click', () => closeAllAgentMenus()); + li.append(a); + return li; + } + + // Show only actions that are applicable in the current state. + if (c.running) { + dropdown.append( + menuItem('↺ R3ST4RT', { action: '/api/restart/', confirm: `restart ${c.name}?` }), + menuItem('■ ST0P', { action: '/api/kill/', confirm: `stop ${c.name}?`, confirmLabel: '■ stop', graceful: true }), + ); + } else { + dropdown.append( + menuItem('▶ ST4RT', { action: '/api/start/', confirm: `start ${c.name}?` }), + ); + } + dropdown.append( + menuItem('↻ R3BU1LD', { action: '/api/rebuild/', confirm: `rebuild ${c.name}? hot-reloads the container.` }), + menuSep(), + // Deep-link to the AGENT log tab pre-filtered to this container. + // The ?agent= param is read by logs.js on load and pre-selects this + // agent's journal without extra clicks. + menuLink('journal logs →', + `/logs.html?agent=${encodeURIComponent(c.name)}#agent`, + `view ${c.name} journal logs`), + ); + + dropdown.append( + menuSep(), + menuItem('DESTR0Y', { + action: '/api/destroy/', + confirm: `destroy ${c.name}? container removed; state + creds kept.`, + }), + menuItem('PURG3', { + action: '/api/destroy/', + body: { purge: 'on' }, + confirm: `PURGE ${c.name}? WIPES container, config history, claude creds, and notes. no undo.`, + }), + ); + + if (c.deployed_sha && forgeBase) { + const li = el('li', { role: 'presentation' }); + const a = el('a', { + class: 'agent-menu-item', + href: `${forgeBase}/agent-configs/${encodeURIComponent(c.name)}/commit/${c.deployed_sha}`, + target: '_blank', + rel: 'noopener', + role: 'menuitem', + title: 'deployed config commit on forge', + }, `deployed:${c.deployed_sha} ↗`); + li.append(a); + dropdown.append(menuSep(), li); + } + + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const wasHidden = dropdown.hidden; + closeAllAgentMenus(); + if (wasHidden) { + dropdown.hidden = false; + btn.setAttribute('aria-expanded', 'true'); + wrap.classList.add('open'); + openAgentMenu = dropdown; + } + }); + + wrap.append(btn, dropdown); + return wrap; +} + +// ─── port conflicts ────────────────────────────────────────────────────────── + +// Re-derive port conflicts from the live containers map. Mirrors the +// server-side `build_port_conflicts` so the banner reacts to event +// updates instead of waiting for a /api/state refetch. +function derivePortConflicts(containers) { + const byPort = new Map(); + for (const c of containers) { + if (!byPort.has(c.port)) byPort.set(c.port, []); + byPort.get(c.port).push(c.name); + } + const out = []; + for (const [port, agents] of byPort) { + if (agents.length > 1) { + agents.sort(); + out.push({ port, agents }); + } + } + out.sort((a, b) => a.port - b.port); + return out; +} + +// ─── topology tree ─────────────────────────────────────────────────────────── +// See docs/web-ui.md::Topology tree for the rendering contract +// (forest walk, alphabetical sort, orphan + cycle handling). + +function buildAgentTree(containers) { + // Close any open context menu before replacing the DOM tree — the + // previous dropdown element would otherwise be a stale reference. + closeAllAgentMenus(); + const byName = new Map(); + for (const c of containers) byName.set(c.name, c); + const children = new Map(); // parent_name -> [child_name, ...] + const roots = []; + for (const c of containers) { + const p = c.parent || null; + if (p == null || !byName.has(p)) { + roots.push(c.name); + } else { + const list = children.get(p) || []; + list.push(c.name); + children.set(p, list); + } + } + roots.sort(); + for (const list of children.values()) list.sort(); + const out = []; + const visited = new Set(); + function visit(name, depth, ancestorIsLast, isLast) { + if (visited.has(name)) return; + visited.add(name); + const c = byName.get(name); + if (!c) return; + out.push({ container: c, depth, ancestorIsLast: [...ancestorIsLast], isLast }); + const kids = children.get(name) || []; + kids.forEach((kid, i) => + visit(kid, depth + 1, [...ancestorIsLast, isLast], i === kids.length - 1)); + } + roots.forEach((name, i) => visit(name, 0, [], i === roots.length - 1)); + // Cycle safety: anything not reached lands at root level so no + // agent silently disappears when a config is malformed. + for (const c of containers) { + if (!visited.has(c.name)) visit(c.name, 0, [], true); + } + return out; +} +// Builds the .tree-prefix DOM for a row at the given depth. +// See docs/web-ui.md::Topology tree for why this is DOM-painted +// (one positioned per lane) rather than text-glyph-painted. +function treePrefixDom({ depth, ancestorIsLast, isLast }) { + if (depth === 0) return null; + const prefix = el('span', { class: 'tree-prefix', 'aria-hidden': 'true' }); + // Ancestor columns (depth 1..depth-1). Skip depth 0 (root has no + // continuation column — top-level rows are separated visually as + // top-level rows already). + for (let d = 1; d < depth; d++) { + const cls = ancestorIsLast[d] ? 'tree-lane lane-blank' : 'tree-lane lane-line'; + prefix.append(el('span', { class: cls })); + } + const jointCls = 'tree-lane lane-joint ' + (isLast ? 'lane-joint-last' : 'lane-joint-branch'); + prefix.append(el('span', { class: jointCls })); + return prefix; +} + +// ─── container row ─────────────────────────────────────────────────────────── + +// Serialise the visible state of a container row into a stable string +// for change-detection. Includes everything that affects what the row +// renders — container fields, derived pending/selection state, tree +// position, and link-base context. The async dashboard-state (nav +// strip, ctx badge, status text) is intentionally excluded: it +// populates in-place and is preserved when a row is reused. +function containerRowFingerprint(c, node, pending, opRunning, selected, + askerCount, targetCount, gatewayLinks, hostname) { + return JSON.stringify({ + running: c.running, + needs_login: c.needs_login, + needs_update: c.needs_update, + pending_reminders: c.pending_reminders, + active_model: c.active_model, + port: c.port, + pending, + opRunning, + selected, + askerCount, + targetCount, + depth: node.depth, + isLast: node.isLast, + ancestorIsLast: node.ancestorIsLast, + gatewayLinks, + hostname, + }); +} + +// Build a single container-row
  • from scratch. Extracted so +// renderContainers can call this only for rows whose fingerprint +// changed (keyed cache), skipping the build + async dashboard-state +// fetch for stable rows. +function buildContainerLi(c, node, opts) { + const { + pending, opRunning, selected, + askerCount, targetCount, agentQCount, + url, containerBase, forgeBase, s, + } = opts; + const li = el('li', { + class: 'container-row' + + (pending ? ' pending' : '') + + (opRunning ? ' pending-running' : '') + + (selected ? ' selected' : ''), + }); + // Topology: depth contributes left-padding; the glyph string in + // the .tree-prefix span draws the ├─ / └─ joint + continuation + // lines (`│ `) for ancestors whose subtree extends below this + // row. Both are CSS-driven from the data attributes so the + // legacy flat layout (every container at depth 0) is bit- + // identical to today's render — no glyph, no indent. + if (node.depth > 0) li.dataset.depth = String(node.depth); + const prefix = treePrefixDom(node); + if (prefix) li.prepend(prefix); + + // Agent icon: 5em square wrapper with an absolutely-positioned + // + fire-and-forget load with /favicon.svg fallback. The + // wrapper doubles as the selection toggle (click / keyboard). + // See docs/web-ui.md::Container row for the layout + load-strategy + // rationale. + const iconImg = el('img', { class: 'container-icon-img', alt: '' }); + const icon = el('div', { + class: 'container-icon', + role: 'button', + tabindex: '0', + 'aria-pressed': selected ? 'true' : 'false', + title: selected + ? `deselect ${c.name} (or press Esc to clear all)` + : `select ${c.name} for bulk actions`, + }, iconImg); + icon.addEventListener('click', (e) => { + e.preventDefault(); + toggleSelection(c.name); + }); + icon.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleSelection(c.name); + } + }); + if (c.running) { + iconImg.src = `${url}icon`; + iconImg.addEventListener('error', () => { + if (iconImg.dataset.fallback) return; // guard: don't loop if the favicon itself 404s + iconImg.dataset.fallback = '1'; + icon.classList.add('icon-unreachable'); + iconImg.src = '/favicon.svg'; + }); + } else { + // Container stopped — skip the doomed `${url}icon` fetch and + // go straight to the dimmed hyperhive mark. Avoids a noisy + // failed request in the console + the brief broken-image flash. + icon.classList.add('icon-unreachable'); + iconImg.src = '/favicon.svg'; + } + // Card body: the three stacked content lines, right of the icon. + const body = el('div', { class: 'card-body' }); + + // -- identity ------------------------------------------------------- + const head = el('div', { class: 'head' }); + head.append( + el('a', { class: 'name', href: url, target: '_blank', rel: 'noopener' }, c.name), + ); + // Icon-only nav strip — populated async from the agent's own + // `GET /api/dashboard-state` (via gateway when enabled, direct + // TCP otherwise). The agent is the single source of truth for its + // link list: stats / screen (GUI agents only — c0re's disk-based + // fallback cannot detect this) / forge profile / agent-configs / + // extras. DOM-built — link strings come from the agent's process + // and must never reach the HTML parser. + const navStrip = el('span', { class: 'nav-strip' }); + head.append(navStrip); + if (c.running) { + // Fetch the lean dashboard-state snapshot from the agent directly. + // Populates: nav strip links (including the screen link that + // c0re's disk-based build cannot detect), rate_limited badge, + // ctx-window badge, and self-reported status text. + // Fails gracefully when the agent is starting up or the gateway + // is not yet routing to it — badges simply don't appear. + // Only runs when the row is first built (fingerprint changed) — + // reused rows keep their previously-fetched nav strip + badges. + fetch(`${containerBase}/api/dashboard-state`) + .then((r) => (r.ok ? r.json() : null)) + .then((ds) => { + if (!ds) return; + // -- nav strip ----------------------------------------------- + if (Array.isArray(ds.links)) { + for (const lnk of ds.links) { + const href = lnk.kind === 'forge' ? forgeBase + (lnk.url || '') + : lnk.kind === 'external' ? (lnk.url || '') + : /* container */ containerBase + '/' + (lnk.url || ''); + const a = el('a', { + class: 'nav-link', + href, + target: '_blank', + rel: 'noopener', + title: lnk.label || '', + }); + // Plain text — agent-controlled strings stay out of innerHTML. + a.textContent = lnk.icon || lnk.label || ''; + navStrip.append(a); + } + } + // -- agent-owned status badges -------------------------------- + // rate_limited: only show when no other critical badge is + // already shown (pending / not-running already handled sync). + if (ds.rate_limited) { + head.append(el('span', + { class: 'badge badge-rate-limited', title: 'API rate-limited — harness is parked, will retry automatically' }, + '⊘ rate limited')); + } + // ctx-window badge + if (ds.ctx_tokens != null) { + const k = Math.round(ds.ctx_tokens / 1000); + const win = ds.context_window_tokens; + const warn = win != null ? win * CTX_WARN_FRACTION : CTX_WARN_TOKENS; + const caution = win != null ? win * CTX_CAUTION_FRACTION : CTX_CAUTION_TOKENS; + const ctxClass = ds.ctx_tokens >= warn ? 'badge-ctx-warn' + : ds.ctx_tokens >= caution ? 'badge-ctx-caution' + : 'badge-ctx-ok'; + const title = win != null + ? `last turn context: ${ds.ctx_tokens.toLocaleString()} / ${win.toLocaleString()} ` + + `tokens (${Math.round((ds.ctx_tokens / win) * 100)}% of the window)` + : `last turn context size: ${ds.ctx_tokens.toLocaleString()} tokens`; + head.append(el('span', { class: `badge ${ctxClass}`, title }, `ctx·${k}k`)); + } + // -- agent status text (self-reported via set_status) --------- + if (ds.status_text) { + const nowUnix = Math.floor(Date.now() / 1000); + const ageStr = ds.status_set_at != null + ? ` (set ${fmtAgeSecs(nowUnix - ds.status_set_at)} ago)` : ''; + // Stamp data-set-at so the 30s ticker below keeps the age + // label current even when the row is reused from the cache. + const ageAttrs = ds.status_set_at != null + ? { class: 'status-age', 'data-set-at': String(ds.status_set_at) } + : { class: 'status-age' }; + // Icon + message are wrapped together in `.status-msg` so the + // CSS can clamp *just the message* to two lines; the age span is + // a separate flex sibling that is never clipped, so the "(set N + // ago)" stamp stays visible even when the status text is long. + body.append(el('div', { + class: 'agent-status', + title: `agent self-reported status${ageStr}`, + }, + el('span', { class: 'status-msg' }, + el('span', { class: 'status-icon' }, '◈ '), + ds.status_text, + ), + el('span', ageAttrs, ageStr), + )); + } + }) + .catch(() => { /* graceful: agent starting / gateway miss -> no data */ }); + } + // Status / runtime badges. Pending transients always win + // (start / stop / restart / rebuild is in progress). Otherwise, + // when the container is stopped, surface a single `■ not running` + // badge. `needs_login` is still c0re-owned (reads auth sentinel + // files on the host). rate_limited / ctx / status_text are + // agent-owned and rendered by the async dashboard-state fetch above. + if (pending) { + head.append(el('span', { class: 'pending-state' }, + el('span', { class: 'spinner' }, '◐'), ' ', pending + '…')); + } else if (!c.running) { + head.append(el('span', + { class: 'badge badge-muted', title: 'container is shut down — start it to bring the harness back up' }, + '■ not running')); + } else if (c.needs_login) { + head.append(el('a', + { class: 'badge badge-warn', href: url, target: '_blank', rel: 'noopener' }, + 'needs login →')); + } + if (c.needs_update) { + head.append(form( + '/api/rebuild/' + c.name, 'badge badge-warn btn-inline', 'needs update ↻', + 'rebuild ' + c.name + '? hot-reloads the container.', + {}, { noRefresh: true }, + )); + } + + if (c.active_model) { + head.append(el('span', + { class: 'badge badge-model', title: `active claude model: ${c.active_model}` }, + c.active_model)); + } + + if (c.pending_reminders && c.pending_reminders > 0) { + head.append(el('span', + { + class: 'badge badge-reminder', + title: 'pending reminders queued for this agent — see the reminders section to view / cancel', + }, + `⏰ ${c.pending_reminders}`)); + } + // Pending questions where this agent is the asker (awaiting an + // answer) or the target (owes a reply). Derived live from + // questionsState so the badge updates instantly on QuestionAdded / + // QuestionResolved without a separate backend field. + if (agentQCount > 0) { + const parts = []; + if (askerCount > 0) parts.push(`${askerCount} asked`); + if (targetCount > 0) parts.push(`${targetCount} to answer`); + head.append(el('span', + { + class: 'badge badge-loose-ends', + title: `pending questions: ${parts.join(', ')} — see the Q33R1ES tab`, + }, + `❓ ${agentQCount}`)); + } + body.append(head); + + // Per-card action buttons (R3ST4RT / ST0P / ST4RT / R3BU1LD / + // DESTR0Y / PURG3) moved to the selection bar — see + // docs/web-ui.md::Selection bar. The contextual `needs update ↻` + // chip in the head row stays — it's a state-hint, not an + // action button. + + li.append(icon, body, buildAgentMenu(c, forgeBase)); + return li; +} + +// ─── containers render ─────────────────────────────────────────────────────── + +export function renderContainers(s) { + const root = $('containers-section'); + // #containers-section only exists on /dashboard.html. tabs.js is the + // bundle for that page only (flow.html loads flow.js instead). + // Belt-and-suspenders for any future page that adds tabs.js + // without a #containers-section — matches the + // no-op-when-target-absent convention the other renderers + // (renderQuestions, renderApprovals, etc.) follow. + if (!root) return; + + // Containers come from the derived map (event-driven) rather than + // `s.containers`; `s` still supplies hostname (for the web-ui + // link) and tombstones/meta_inputs (not event-derived yet). The + // tree builder handles the ordering — we don't pre-sort here. + const containers = Array.from(containersState.values()); + const portConflicts = derivePortConflicts(containers); + const anyStale = containers.some((c) => c.needs_update); + + // Preserve the keyed container list across the header-section + // rebuild. We wipe the banners/buttons above the list on every + // render (simple), but recycle
  • elements for unchanged rows + // (keyed) to avoid full-DOM thrash and redundant async fetches. + const existingUl = root.querySelector('ul.containers'); + root.replaceChildren(); + + // Port-hash collisions: rename one of the listed agents and + // rebuild. The banner sits above the agent list so it's the + // first thing the operator sees when something's wedged. + if (portConflicts.length) { + const banner = el('div', { class: 'port-conflict' }, + el('strong', {}, '⚠ port collision'), ' — '); + const groups = portConflicts.map((c) => + `:${c.port} (${c.agents.join(' + ')})`).join('; '); + banner.append(groups + '. rename one of each and ↻ R3BU1LD.'); + root.append(banner); + } + + if (anyStale) { + root.append(form( + '/api/update-all', 'btn-rebuild', '↻ UPD4TE 4LL', + 'rebuild every stale container?', + {}, { noRefresh: true }, + )); + } + + // Queue-summary banner: when the rebuild queue has active work, + // show one compact at-a-glance line + a link to the full queue on the + // C0R3 page. Replaces the old per-transient spinner list — the actual + // running step is already visible per-agent on each card (transient + + // in-flight-queue badges), so the top of the tab only needs the summary. + const activeQueue = rebuildQueueState.filter( + (e) => e.state === 'queued' || e.state === 'running', + ); + if (activeQueue.length) { + const running = activeQueue.filter((e) => e.state === 'running').length; + const queued = activeQueue.length - running; + const parts = []; + if (running) parts.push(`${running} running`); + if (queued) parts.push(`${queued} queued`); + root.append(el('div', { class: 'queue-summary' }, + el('span', { class: 'glyph spinner' }, '◐'), ' ', + el('strong', {}, 'build queue'), ' — ', + parts.join(' · '), ' ', + el('a', { class: 'queue-summary-link', href: '/builds.html' }, 'view queue →'), + )); + } + + if (!containers.length && !transientsState.size) { + root.append(el('p', { class: 'empty' }, 'no managed containers')); + return; + } + + // Drop stale selections (agent destroyed while selected). Defensive — + // the action bar would otherwise loop POST against a gone agent. + const liveNames = new Set(containers.map((c) => c.name)); + for (const n of Array.from(selectionState)) { + if (!liveNames.has(n)) selectionState.delete(n); + } + + const hostname = (s && s.hostname) || window.location.hostname; + // When hive-gateway is in front of the dashboard, build same-origin + // `/agent//` URLs instead of the direct `http://:/` + // TCP fallback — the gateway proxies the prefix to the per-agent + // harness (unix-domain via `agent-sockets.json`, or a computed TCP + // loopback port while the socket marker is absent). See + // `docs/web-ui.md::Container row` + `docs/gateway.md::Vhost map`. + const gatewayLinks = !!(s && s.gateway_enabled); + // Forge public URL: prefer state.forge_public_url (set by the NixOS + // module when forge.behindGateway=true), fall back to + // ":3000" for gateway-off / local-dev deploys. + const forgeBase = (s && s.forge_public_url) || `http://${hostname}:3000`; + const ul = existingUl ?? el('ul', { class: 'containers' }); + const tree = buildAgentTree(containers); + // In-flight rebuild / meta-update / destroy ops per agent name — + // see docs/web-ui.md::Container row for the building... badge + // rationale (covers the SYST3M-shows-rebuild-but-SW4RM-shows-stopped + // gap when no operator transient is set). + const inFlight = inFlightOpsByAgent(); + + // Build the ordered list of
  • elements, reusing cached rows + // whose displayed state hasn't changed. + const orderedLis = []; + for (const node of tree) { + const c = node.container; + const url = gatewayLinks + ? `/agent/${encodeURIComponent(c.name)}/` + : `http://${hostname}:${c.port}/`; + // Container nav-strip base: gateway prefix or direct TCP. + const containerBase = gatewayLinks + ? `/agent/${encodeURIComponent(c.name)}` + : `http://${hostname}:${c.port}`; + // Pending-state derivation + queued-vs-running split — see + // docs/web-ui.md::Container row for the transient -> in-flight + // queue priority order and the opRunning rationale. + const transientKind = transientsState.get(c.name)?.kind || null; + const op = !transientKind ? inFlight.get(c.name) : null; + const pending = transientKind + || (op && (op.state === 'running' + ? (op.kind === 'meta_update' ? 'meta-updating' + : op.kind === 'destroy' ? 'destroying' + : op.kind === 'restart' ? 'restarting' + : op.kind === 'start' ? 'starting' + : op.kind === 'stop' ? 'stopping' + : op.kind === 'graceful_stop' ? 'stopping' + : 'rebuilding') + : (op.kind === 'meta_update' ? 'meta-update queued' + : op.kind === 'destroy' ? 'destroy queued' + : op.kind === 'restart' ? 'restart queued' + : op.kind === 'start' ? 'start queued' + : op.kind === 'stop' ? 'stop queued' + : op.kind === 'graceful_stop' ? 'stop queued' + : 'rebuild queued'))); + const opRunning = transientKind != null + || (op != null && op.state === 'running'); + const selected = selectionState.has(c.name); + // Pending questions where this agent is the asker (awaiting an + // answer) or the target (owes a reply). Derived live from + // questionsState so the badge updates instantly on QuestionAdded / + // QuestionResolved without a separate backend field. + const askerCount = questionsState.pending.filter((q) => q.asker === c.name).length; + const targetCount = questionsState.pending.filter((q) => q.target === c.name).length; + const agentQCount = questionsState.pending.filter( + (q) => q.asker === c.name || q.target === c.name, + ).length; + + const fp = containerRowFingerprint(c, node, pending, opRunning, selected, + askerCount, targetCount, gatewayLinks, hostname); + const cached = containerRowCache.get(c.name); + + let li; + if (cached && cached.fingerprint === fp) { + // Row unchanged — reuse the existing DOM node. The async + // dashboard-state (nav strip, ctx badge, status text) stays + // intact from the previous build, avoiding a redundant fetch. + li = cached.el; + } else { + li = buildContainerLi(c, node, { + pending, opRunning, selected, + askerCount, targetCount, agentQCount, + url, containerBase, forgeBase, s, + }); + containerRowCache.set(c.name, { el: li, fingerprint: fp }); + } + orderedLis.push(li); + } + + // Remove cache entries for agents that no longer exist. + for (const [name, entry] of containerRowCache) { + if (!liveNames.has(name)) { + entry.el.remove(); + containerRowCache.delete(name); + } + } + + // Apply correct DOM order without a full wipe. insertBefore is a + // no-op when the node is already at position i, so stable sections + // of the list cause zero layout work. + for (let i = 0; i < orderedLis.length; i++) { + if (ul.children[i] !== orderedLis[i]) { + ul.insertBefore(orderedLis[i], ul.children[i] ?? null); + } + } + // Trim any excess children (defensive — shouldn't happen after the + // cache-removal pass above, but keeps the ul length exact). + while (ul.children.length > orderedLis.length) ul.lastChild.remove(); + + root.append(ul); + renderSelectionBar(containers); +} + +// ─── selection bar ─────────────────────────────────────────────────────────── +// Sticky-bottom strip; visible when >=1 agent selected on the SW4RM +// tab. See docs/web-ui.md::Selection bar for the interaction model +// and the per-action availability rules (disabled-with-tooltip for +// actions that don't apply to the full selection). Actions POST per +// agent in a loop (endpoints are individually idempotent / +// event-covered, so no new bulk backend wire is needed). +export function renderSelectionBar(containers) { + const bar = $('selection-bar'); + if (!bar) return; + const countSpan = $('selection-count'); + const namesSpan = $('selection-names'); + const actions = $('selection-actions'); + if (!countSpan || !namesSpan || !actions) return; + + const selected = containers.filter((c) => selectionState.has(c.name)); + // Tab-gate: bar only renders on SW4RM (the only tab with agent + // cards to cross-reference). Selection state lives in-memory and + // the bar reappears on return to SW4RM if still non-empty. + const onSwarmTab = (document.body.dataset.activeTab || 'swarm') === 'swarm'; + if (!selected.length || !onSwarmTab) { + bar.hidden = true; + document.body.classList.remove('has-selection'); + return; + } + bar.hidden = false; + document.body.classList.add('has-selection'); + countSpan.textContent = selected.length === 1 + ? '1 agent selected' + : selected.length + ' agents selected'; + namesSpan.textContent = '· ' + selected.map((c) => c.name).join(', '); + + // Recompute action availability + tooltips per render. Each + // action declares which agents it CAN'T run on; the bar disables + // the button and surfaces the offending names in the tooltip. + actions.replaceChildren(); + const allRunning = selected.every((c) => c.running); + const allStopped = selected.every((c) => !c.running); + + const stoppedNames = selected.filter((c) => !c.running).map((c) => c.name); + const runningNames = selected.filter((c) => c.running).map((c) => c.name); + + function why(label, blockers) { + if (!blockers.length) return null; + return `${label} not available — ${blockers.join(', ')} ${blockers.length === 1 ? 'is' : 'are'} blocking it`; + } + + addBulkButton(actions, 'btn-restart', '↺ R3ST4RT', allRunning, selected, { + action: '/api/restart/', + confirm: (names) => `restart ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`, + disabledTitle: why('↺ R3ST4RT', stoppedNames.map((n) => `\`${n}\` is stopped`)), + }); + addBulkButton(actions, 'btn-stop', '■ ST0P', allRunning, selected, { + action: '/api/kill/', + confirm: (names) => `stop ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`, + confirmLabel: '■ stop', + graceful: true, + disabledTitle: why('■ ST0P', stoppedNames.map((n) => `\`${n}\` is already stopped`)), + }); + addBulkButton(actions, 'btn-start', '▶ ST4RT', allStopped, selected, { + action: '/api/start/', + confirm: (names) => `start ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`, + disabledTitle: why('▶ ST4RT', runningNames.map((n) => `\`${n}\` is already running`)), + }); + addBulkButton(actions, 'btn-rebuild', '↻ R3BU1LD', true, selected, { + action: '/api/rebuild/', + confirm: (names) => `rebuild ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? hot-reloads each container.`, + }); + addBulkButton(actions, 'btn-destroy', 'DESTR0Y', true, selected, { + action: '/api/destroy/', + confirm: (names) => `destroy ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? containers are removed; state + creds kept.`, + }); + addBulkButton(actions, 'btn-destroy', 'PURG3', true, selected, { + action: '/api/destroy/', + body: { purge: 'on' }, + confirm: (names) => `PURGE ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? containers, config history, claude creds, and notes are all WIPED. no undo.`, + }); + + // Move agent(s) in the topology tree — selecting an option in the + // M0V3 dropdown immediately confirms + executes the move. "(no parent)" + // promotes to root (empty new_parent on the backend). Cycle-safe: + // dropdown filters out self and descendants on the client side; the + // backend rechecks via `topology::set_parent`. + // + // Backend: POST /api/topology/set-parent (dashboard.rs), + // form-encoded `child=&new_parent=`. Re-emits + // container snapshots on success so the tree repaints without a + // separate refresh. + addMoveActions(actions, selected, containers); +} + +// Render the M0V3 picker in the selection bar. Selecting any real option +// (including "(no parent)") immediately fires a confirm + POST — no +// separate button. Backend `topology::set_parent` refuses invalid moves +// and the refusal surfaces in the alert roll-up. +function addMoveActions(parent, selected, containers) { + const candidates = validReparentCandidates(selected, containers); + const wrap = el('span', { class: 'move-picker' }); + const selectTitle = selected.length === 1 + ? `change ${selected[0].name}'s parent` + : `change ${selected.length} agents' parent`; + const sel = el('select', { class: 'move-picker-select', title: selectTitle }); + sel.append(el('option', { value: '' }, '⇢ M0V3 →')); + // "(no parent)" -> empty new_parent on the backend (promotes to root). + sel.append(el('option', { value: '__root__' }, '(no parent)')); + for (const name of candidates) { + sel.append(el('option', { value: name }, name)); + } + sel.addEventListener('change', async () => { + if (sel.selectedIndex === 0) return; + const newParent = sel.value === '__root__' ? '' : sel.value; + const newParentLabel = sel.value === '__root__' ? '(no parent)' : sel.value; + const names = selected.map((c) => c.name); + const promptMsg = names.length === 1 + ? `move ${names[0]} → ${newParentLabel}?` + : `move ${names.length} agents (${names.join(', ')}) → ${newParentLabel}?`; + if (!(await themedConfirm({ message: promptMsg, danger: true }))) { + sel.selectedIndex = 0; + return; + } + sel.disabled = true; + const failures = []; + if (names.length === 1) { + // Single agent — use the form-encoded endpoint (backwards compat). + try { + const body = new URLSearchParams({ child: names[0], new_parent: newParent }); + const resp = await fetch('/api/topology/set-parent', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + redirect: 'manual', + }); + const ok = resp.ok || resp.type === 'opaqueredirect' + || (resp.status >= 200 && resp.status < 400); + if (!ok) { + const text = await resp.text().catch(() => ''); + failures.push(`${names[0]}: http ${resp.status}${text ? ' — ' + text.slice(0, 200) : ''}`); + } + } catch (err) { + failures.push(`${names[0]}: ${err}`); + } + } else { + // Multiple agents — use the bulk endpoint so all moves land in + // a single git commit instead of one per agent. + try { + const payload = names.map((n) => ({ child: n, new_parent: newParent || null })); + const resp = await fetch('/api/topology/set-parent-bulk', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + redirect: 'manual', + }); + const ok = resp.ok || resp.type === 'opaqueredirect' + || (resp.status >= 200 && resp.status < 400); + if (!ok) { + const text = await resp.text().catch(() => ''); + failures.push(`bulk: http ${resp.status}${text ? ' — ' + text.slice(0, 200) : ''}`); + } + } catch (err) { + failures.push(`bulk: ${err}`); + } + } + sel.disabled = false; + sel.selectedIndex = 0; + if (failures.length) { + themedToast(`M0V3 completed with ${failures.length} failure${failures.length === 1 ? '' : 's'}:\n\n` + failures.join('\n'), { type: 'error', duration: 0 }); + } + }); + wrap.append(sel); + parent.append(wrap); +} + +// Filter the dashboard's container list to those that are valid +// re-parent targets for the `selected` agents: anyone who isn't IN +// the selection itself, isn't a descendant of any selected agent +// (cycle prevention across the whole batch). The backend re-checks +// per-agent via `topology::set_parent`; this client-side filter is +// purely UX so the operator can't pick an obviously-invalid option. +function validReparentCandidates(selected, containers) { + // Build child map once. + const childrenOf = new Map(); + for (const c of containers) { + const p = c.parent || null; + if (!childrenOf.has(p)) childrenOf.set(p, []); + childrenOf.get(p).push(c.name); + } + // Union descendant set across every selected agent (each agent's + // descendants AND itself). + const blocked = new Set(); + for (const t of selected) { + const queue = [t.name]; + blocked.add(t.name); + while (queue.length) { + const n = queue.shift(); + for (const child of (childrenOf.get(n) || [])) { + if (blocked.has(child)) continue; + blocked.add(child); + queue.push(child); + } + } + } + return containers + .filter((c) => !blocked.has(c.name)) + .map((c) => c.name) + .sort(); +} + +function addBulkButton(parent, btnClass, label, enabled, selected, opts) { + const names = selected.map((c) => c.name); + const btn = el('button', { + type: 'button', + class: 'btn ' + btnClass, + }, label); + if (!enabled) { + btn.disabled = true; + if (opts.disabledTitle) btn.title = opts.disabledTitle; + } + btn.addEventListener('click', async () => { + if (btn.disabled) return; + const msg = opts.confirm(names); + let graceful = false; + if (msg) { + const r = await themedConfirm({ + message: msg, + danger: true, + confirmLabel: opts.confirmLabel || 'confirm', + checkboxes: opts.graceful + ? [{ name: 'graceful', label: 'stop gracefully — let each agent finish its turn and flush state before the container stops' }] + : [], + }); + if (!r) return; + graceful = !!r.graceful; + } + btn.disabled = true; + const original = btn.innerHTML; + btn.innerHTML = ' ' + label; + const failures = []; + // Sequential POSTs to keep server-side serialisation predictable + // (rebuild_queue dedups but other endpoints don't); the loop is + // short — bulk selections are typically a handful of agents. + // + // Two URL shapes: + // - `opts.action` is a path prefix and the agent name gets + // appended (lifecycle endpoints: /start/, /rebuild/). + // `opts.body` is a static object applied to every POST. + // - `opts.perAgentBodyFor(name)` is set: `opts.action` is the + // full URL (no name appended) and the per-agent body comes + // from the callback. Used by /api/topology/set-parent, where + // the agent name is a body field rather than a URL component. + for (const name of names) { + const body = opts.perAgentBodyFor + ? new URLSearchParams(opts.perAgentBodyFor(name)) + : new URLSearchParams(opts.body || {}); + const url = opts.perAgentBodyFor + ? opts.action + : opts.action + encodeURIComponent(name) + (graceful ? '?graceful=true' : ''); + try { + const resp = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + redirect: 'manual', + }); + const ok = resp.ok || resp.type === 'opaqueredirect' + || (resp.status >= 200 && resp.status < 400); + if (!ok) { + const text = await resp.text().catch(() => ''); + failures.push(`${name}: http ${resp.status}${text ? ' — ' + text.slice(0, 200) : ''}`); + } + } catch (err) { + failures.push(`${name}: ${err}`); + } + } + btn.disabled = false; + btn.innerHTML = original; + if (failures.length) { + themedToast(`${label} completed with ${failures.length} failure${failures.length === 1 ? '' : 's'}:\n\n` + failures.join('\n'), { type: 'error', duration: 0 }); + } + // Container-lifecycle events (ContainerStateChanged / + // ContainerRemoved / RebuildQueueChanged) flow over the existing + // SSE channel and update the derived stores live — no manual + // refresh needed. + }); + parent.append(btn); +} + +// ─── peer hives ────────────────────────────────────────────────────────────── + +// Peer hives: render link cards as a headline section under SW4RM +// (state.peer_hives). Called on every state refresh. When nothing is +// federated, the "P33R H1V3S" headline block is hidden entirely and a +// quiet grey note replaces the cards. +export function renderPeerHives(peers) { + const block = $('peers-block'); + const root = $('peers-section'); + if (!root) return; + root.replaceChildren(); + const hasPeers = Array.isArray(peers) && peers.length > 0; + if (block) block.hidden = !hasPeers; + if (!hasPeers) { + root.append(el('p', { class: 'empty' }, 'no peer hives configured')); + return; + } + const ul = el('ul', { class: 'containers' }); + for (const p of peers) { + const li = el('li', { class: 'container-row' }); + const head = el('div', { class: 'head' }); + const icon = el('span', { class: 'container-icon' }, '⬡'); + const nameEl = el('span', { class: 'name' }, p.name || p.url); + const linkEl = el('a', { + href: p.url, + target: '_blank', + rel: 'noopener noreferrer', + class: 'meta', + title: 'open ' + p.name + ' dashboard', + }, p.url); + head.append(icon, nameEl); + li.append(head, el('div', { class: 'meta' }, linkEl)); + ul.append(li); + } + root.append(ul); +} + +// ─── tickers ───────────────────────────────────────────────────────────────── + +// 30s ticker for agent status-age chips. Renderers stamp `data-set-at` +// (unix seconds) on the `.status-age` span. Keyed container rows persist +// across re-renders, so without this ticker the "(set N ago)" label would +// become stale as time passes. 30s granularity matches fmtAgeSecs precision +// (sub-minute values round to seconds, coarser above that). +setInterval(() => { + const now = Math.floor(Date.now() / 1000); + document.querySelectorAll('.status-age[data-set-at]').forEach((node) => { + const setAt = Number(node.dataset.setAt); + if (!Number.isFinite(setAt) || setAt === 0) return; + node.textContent = ` (set ${fmtAgeSecs(now - setAt)} ago)`; + }); +}, 30_000); diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index a08520ea..be36e2e2 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -9,22 +9,19 @@ // live in `./common.js`; the flow-page surface (operator inbox, broker // terminal, @-mention composer) lives in `./flow.js` — `flow.html` and // `dashboard.html` each load their own bundle. +// SW4RM (containers) domain lives in `./swarm.js`. import { marked } from 'marked'; import { - $, el, esc, form, - fmtAgeSecs, + $, el, Panel, NOTIF, - makePathLink, appendText, appendLinkified, openStream, renderServerWarnings, bindAsyncForms, } from './common.js'; -import { themedConfirm, themedToast } from './modal.js'; import { createTabStrip } from '@hive/shared/tabs.js'; import { containersState, syncContainersFromSnapshot, - questionsState, } from './state.js'; -import { fmtAgo, truncate, fmtElapsed, fmtDuration } from './util.js'; +import { fmtAgo, fmtDuration } from './util.js'; import { applyCapabilitiesChanged, applyToolGroupsChanged, fetchAndRenderCapabilities, fetchAndRenderToolGroups, @@ -42,6 +39,13 @@ import { syncQuestionsFromSnapshot, applyQuestionAdded, applyQuestionResolved, renderQuestions, activeQuestionCount, } from './call.js'; +import { + syncRebuildQueueFromSnapshot, syncTransientsFromSnapshot, + applyRebuildQueueChanged, applyContainerStateChanged, applyContainerRemoved, + applyTransientSet, applyTransientCleared, + renderContainers, renderContainersFromState, + renderSelectionBar, renderPeerHives, +} from './swarm.js'; // mdNode (in common.js) reads `window.marked` for the markdown side // panel preview path. Set it here on the dashboard entry so file @@ -49,18 +53,6 @@ import { window.marked = marked; (() => { - // ─── constants ────────────────────────────────────────────────────────── - // Context-window badge thresholds. Preferred source is each container's - // `context_window_tokens` from /api/state (the real window for the model - // it last ran on) — thresholds are then 75% / 50% of it, matching the - // harness compaction watermarks (compact at 75%, auto-reset at 50%). The - // fixed token constants are the fallback for when that field is absent - // (agent has no turns yet, or no per-model config matched the model). - const CTX_WARN_FRACTION = 0.75; // ≥ this share of the window → red - const CTX_CAUTION_FRACTION = 0.50; // ≥ this share of the window → yellow - const CTX_WARN_TOKENS = 150_000; // fallback red threshold (≈ 75% of 200k) - const CTX_CAUTION_TOKENS = 100_000; // fallback yellow threshold (≈ 50% of 200k) - // Track which items we've already notified about so a re-render // doesn't re-fire for the same row. Keyed by stable ids; reset only // when the page reloads. @@ -108,1157 +100,6 @@ window.marked = marked; // opts out via `data-no-refresh` (its mutation arrives via an SSE event). bindAsyncForms(() => refreshState()); - // The live agent roster (`containersState`) + its snapshot-sync now - // live in `./state.js` — it's the one piece of cross-domain state, read - // by nearly every tab. It's still mutated live by `container_state_changed` - // (upsert by name) and `container_removed` (drop by name) via the apply - // handlers just below; the imported binding reflects those in place. - // - // Keyed container row cache. Maps agent name → { el:
  • , fingerprint }. - // Allows renderContainers to skip rebuilding rows whose displayed state - // hasn't changed — prevents full-wipe flicker + avoids redundant async - // dashboard-state fetches on every SSE event. Container-domain only, - // so it stays here (not in state.js). - const containerRowCache = new Map(); - function applyContainerStateChanged(ev) { - if (!ev.container || !ev.container.name) return; - containersState.set(ev.container.name, ev.container); - renderContainersFromState(); - } - function applyContainerRemoved(ev) { - if (containersState.delete(ev.name)) renderContainersFromState(); - } - - // Rebuild queue state — drives "building…" / "meta-updating…" badges on - // SW4RM agent cards (see inFlightOpsByAgent + docs/web-ui.md::Container row). - let rebuildQueueState = []; - function syncRebuildQueueFromSnapshot(s) { - rebuildQueueState = (s.rebuild_queue || []).slice(); - } - function applyRebuildQueueChanged(ev) { - rebuildQueueState = (ev.queue || []).slice(); - // Re-render the SW4RM tab so newly-queued / newly-running - // rebuild-queue ops light up the right card with a building… - // badge, and finished ops fall back to the regular state badges. - // See docs/web-ui.md::Container row for the badge taxonomy. - renderContainersFromState(); - } - // Map from agent name → highest-priority in-flight queue entry - // (`running` beats `queued`). Used by the container row renderer - // to surface "building..." / "meta-updating..." badges on the - // SW4RM tab when an op is still in the rebuild queue but no - // operator-initiated transient is set. - function inFlightOpsByAgent() { - const out = new Map(); - for (const e of rebuildQueueState) { - if (e.state !== 'queued' && e.state !== 'running') continue; - // spawn ops target an agent that doesn't exist yet as a - // container — the transient store already drives the - // pending row for that case. Skip here to avoid double- - // surfacing if the spawn op happens to land in the queue - // while the row exists transiently. - if (e.kind === 'spawn') continue; - const cur = out.get(e.agent); - // Prefer running over queued; otherwise keep the first match. - if (!cur || (cur.state === 'queued' && e.state === 'running')) { - out.set(e.agent, e); - } - } - return out; - } - - // Derived transient state — cold-loaded from /api/state.transients, - // then mutated live by `transient_set` / `transient_cleared`. Keyed - // by agent name so add/remove are O(1). `since_unix` is wall-clock so - // the elapsed-seconds badge ticks without polling. - const transientsState = new Map(); - function syncTransientsFromSnapshot(s) { - transientsState.clear(); - for (const t of s.transients || []) { - // Snapshot ships `secs` (server-computed); reconstruct an - // approximate since_unix so the live ticker keeps progressing - // without surprising jumps when the next snapshot lands. - const nowUnix = Math.floor(Date.now() / 1000); - transientsState.set(t.name, { - kind: t.kind, - since_unix: t.since_unix ?? (nowUnix - (t.secs || 0)), - }); - } - } - function applyTransientSet(ev) { - transientsState.set(ev.name, { - kind: ev.transient_kind, - since_unix: ev.since_unix, - }); - renderContainersFromState(); - } - function applyTransientCleared(ev) { - if (transientsState.delete(ev.name)) renderContainersFromState(); - } - // Re-render using the last cached snapshot (containers come from - // /api/state, transients overlay from the derived map). The snapshot - // is stashed on window.__hyperhive_state by refreshState; on cold - // load before the first snapshot we just skip. - function renderContainersFromState() { - const s = window.__hyperhive_state; - if (s) renderContainers(s); - } - - // ─── selection ────────────────────────────────────────────────────── - // In-memory set of selected agent logical names backing the sticky - // #selection-bar. See docs/web-ui.md::Selection bar for the - // interaction model (icon-click toggle, Esc/clear button drop, - // tab-gated visibility). - const selectionState = new Set(); - function toggleSelection(name) { - if (selectionState.has(name)) selectionState.delete(name); - else selectionState.add(name); - renderContainersFromState(); - } - function clearSelection() { - if (selectionState.size === 0) return; - selectionState.clear(); - renderContainersFromState(); - } - // Esc clears the current selection (operator escape hatch — mirrors - // the side-panel close pattern). Ignored when an editable element - // has focus so typing in compose / answer / journal-search isn't - // intercepted. - document.addEventListener('keydown', (e) => { - if (e.key !== 'Escape') return; - if (!selectionState.size) return; - const a = document.activeElement; - if (a && (a.isContentEditable - || a.tagName === 'INPUT' - || a.tagName === 'TEXTAREA' - || a.tagName === 'SELECT')) return; - e.preventDefault(); - clearSelection(); - }); - document.addEventListener('click', (e) => { - if (e.target && e.target.closest('#selection-clear')) { - clearSelection(); - } - }); - - // ─── per-agent context menu ────────────────────────────────────────── - // Three-dot (⋮) button on each agent card for quick single-agent - // lifecycle actions without needing to select first. State-aware: - // restart/stop only shown when running, start only shown when - // stopped; rebuild + destroy/purge always shown. - // The button is CSS-invisible until the row is hovered (or menu is - // open) so it doesn't clutter quiet rows. - - let openAgentMenu = null; // currently open dropdown element, or null - - function closeAllAgentMenus() { - if (!openAgentMenu) return; - openAgentMenu.hidden = true; - const wrap = openAgentMenu.closest('.agent-menu'); - if (wrap) { - wrap.classList.remove('open'); - const btn = wrap.querySelector('.agent-menu-btn'); - if (btn) btn.setAttribute('aria-expanded', 'false'); - } - openAgentMenu = null; - } - - // Close on any click outside an agent-menu element. - document.addEventListener('click', (e) => { - if (!e.target.closest('.agent-menu')) closeAllAgentMenus(); - }, true); - // Close on Escape. stopImmediatePropagation so the selection-clear - // handler on the same element doesn't also fire when a menu is open. - document.addEventListener('keydown', (e) => { - if (e.key === 'Escape' && openAgentMenu) { - closeAllAgentMenus(); - e.stopImmediatePropagation(); - } - }, true); - - // Single-agent POST helper shared by all menu items. - async function agentMenuPost(actionPath, name, body, graceful) { - const url = actionPath + encodeURIComponent(name) + (graceful ? '?graceful=true' : ''); - try { - const resp = await fetch(url, { - method: 'POST', - headers: body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {}, - body: body ? new URLSearchParams(body) : undefined, - redirect: 'manual', - }); - const ok = resp.ok || resp.type === 'opaqueredirect' - || (resp.status >= 200 && resp.status < 400); - if (!ok) { - const text = await resp.text().catch(() => ''); - themedToast('action failed: ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' }); - } - } catch (err) { - themedToast('action failed: ' + err, { type: 'error' }); - } - } - - function buildAgentMenu(c, forgeBase) { - const wrap = el('div', { class: 'agent-menu' }); - const btn = el('button', { - type: 'button', - class: 'agent-menu-btn', - title: `actions for ${c.name}`, - 'aria-label': `actions for ${c.name}`, - 'aria-haspopup': 'menu', - 'aria-expanded': 'false', - }, '⋮'); - - const dropdown = el('ul', { class: 'agent-menu-dropdown', hidden: true, role: 'menu' }); - - function menuItem(label, opts) { - const li = el('li', { role: 'presentation' }); - const item = el('button', { - type: 'button', - class: 'agent-menu-item', - role: 'menuitem', - }, label); - item.addEventListener('click', async () => { - closeAllAgentMenus(); - let graceful = false; - if (opts.confirm) { - const r = await themedConfirm({ - message: opts.confirm, - danger: true, - confirmLabel: opts.confirmLabel || 'confirm', - checkboxes: opts.graceful - ? [{ name: 'graceful', label: 'stop gracefully — let the agent finish its turn and flush state before the container stops' }] - : [], - }); - if (!r) return; - graceful = !!r.graceful; - } - await agentMenuPost(opts.action, c.name, opts.body || null, graceful); - }); - li.append(item); - return li; - } - - function menuSep() { - return el('li', { class: 'agent-menu-sep', role: 'separator' }); - } - - // Navigation link item (opens in same tab by default). - function menuLink(label, href, title) { - const li = el('li', { role: 'presentation' }); - const a = el('a', { - class: 'agent-menu-item', - href, - role: 'menuitem', - title: title || '', - }, label); - a.addEventListener('click', () => closeAllAgentMenus()); - li.append(a); - return li; - } - - // Show only actions that are applicable in the current state. - if (c.running) { - dropdown.append( - menuItem('↺ R3ST4RT', { action: '/api/restart/', confirm: `restart ${c.name}?` }), - menuItem('■ ST0P', { action: '/api/kill/', confirm: `stop ${c.name}?`, confirmLabel: '■ stop', graceful: true }), - ); - } else { - dropdown.append( - menuItem('▶ ST4RT', { action: '/api/start/', confirm: `start ${c.name}?` }), - ); - } - dropdown.append( - menuItem('↻ R3BU1LD', { action: '/api/rebuild/', confirm: `rebuild ${c.name}? hot-reloads the container.` }), - menuSep(), - // Deep-link to the AGENT log tab pre-filtered to this container. - // The ?agent= param is read by logs.js on load and pre-selects this - // agent's journal without extra clicks. - menuLink('journal logs →', - `/logs.html?agent=${encodeURIComponent(c.name)}#agent`, - `view ${c.name} journal logs`), - ); - - dropdown.append( - menuSep(), - menuItem('DESTR0Y', { - action: '/api/destroy/', - confirm: `destroy ${c.name}? container removed; state + creds kept.`, - }), - menuItem('PURG3', { - action: '/api/destroy/', - body: { purge: 'on' }, - confirm: `PURGE ${c.name}? WIPES container, config history, claude creds, and notes. no undo.`, - }), - ); - - if (c.deployed_sha && forgeBase) { - const li = el('li', { role: 'presentation' }); - const a = el('a', { - class: 'agent-menu-item', - href: `${forgeBase}/agent-configs/${encodeURIComponent(c.name)}/commit/${c.deployed_sha}`, - target: '_blank', - rel: 'noopener', - role: 'menuitem', - title: 'deployed config commit on forge', - }, `deployed:${c.deployed_sha} ↗`); - li.append(a); - dropdown.append(menuSep(), li); - } - - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const wasHidden = dropdown.hidden; - closeAllAgentMenus(); - if (wasHidden) { - dropdown.hidden = false; - btn.setAttribute('aria-expanded', 'true'); - wrap.classList.add('open'); - openAgentMenu = dropdown; - } - }); - - wrap.append(btn, dropdown); - return wrap; - } - - // Re-derive port conflicts from the live containers map. Mirrors the - // server-side `build_port_conflicts` so the banner reacts to event - // updates instead of waiting for a /api/state refetch. - function derivePortConflicts(containers) { - const byPort = new Map(); - for (const c of containers) { - if (!byPort.has(c.port)) byPort.set(c.port, []); - byPort.get(c.port).push(c.name); - } - const out = []; - for (const [port, agents] of byPort) { - if (agents.length > 1) { - agents.sort(); - out.push({ port, agents }); - } - } - out.sort((a, b) => a.port - b.port); - return out; - } - - // ─── state rendering ──────────────────────────────────────────────────── - // ─── agent topology ───────────────────────────────────────────────── - // See docs/web-ui.md::Topology tree for the rendering contract - // (forest walk, alphabetical sort, orphan + cycle handling). - function buildAgentTree(containers) { - // Close any open context menu before replacing the DOM tree — the - // previous dropdown element would otherwise be a stale reference. - closeAllAgentMenus(); - const byName = new Map(); - for (const c of containers) byName.set(c.name, c); - const children = new Map(); // parent_name → [child_name, …] - const roots = []; - for (const c of containers) { - const p = c.parent || null; - if (p == null || !byName.has(p)) { - roots.push(c.name); - } else { - const list = children.get(p) || []; - list.push(c.name); - children.set(p, list); - } - } - roots.sort(); - for (const list of children.values()) list.sort(); - const out = []; - const visited = new Set(); - function visit(name, depth, ancestorIsLast, isLast) { - if (visited.has(name)) return; - visited.add(name); - const c = byName.get(name); - if (!c) return; - out.push({ container: c, depth, ancestorIsLast: [...ancestorIsLast], isLast }); - const kids = children.get(name) || []; - kids.forEach((kid, i) => - visit(kid, depth + 1, [...ancestorIsLast, isLast], i === kids.length - 1)); - } - roots.forEach((name, i) => visit(name, 0, [], i === roots.length - 1)); - // Cycle safety: anything not reached lands at root level so no - // agent silently disappears when a config is malformed. - for (const c of containers) { - if (!visited.has(c.name)) visit(c.name, 0, [], true); - } - return out; - } - // Builds the .tree-prefix DOM for a row at the given depth. - // See docs/web-ui.md::Topology tree for why this is DOM-painted - // (one positioned per lane) rather than text-glyph-painted. - function treePrefixDom({ depth, ancestorIsLast, isLast }) { - if (depth === 0) return null; - const prefix = el('span', { class: 'tree-prefix', 'aria-hidden': 'true' }); - // Ancestor columns (depth 1..depth-1). Skip depth 0 (root has no - // continuation column — top-level rows are separated visually as - // top-level rows already). - for (let d = 1; d < depth; d++) { - const cls = ancestorIsLast[d] ? 'tree-lane lane-blank' : 'tree-lane lane-line'; - prefix.append(el('span', { class: cls })); - } - const jointCls = 'tree-lane lane-joint ' + (isLast ? 'lane-joint-last' : 'lane-joint-branch'); - prefix.append(el('span', { class: jointCls })); - return prefix; - } - - // Serialise the visible state of a container row into a stable string - // for change-detection. Includes everything that affects what the row - // renders — container fields, derived pending/selection state, tree - // position, and link-base context. The async dashboard-state (nav - // strip, ctx badge, status text) is intentionally excluded: it - // populates in-place and is preserved when a row is reused. - function containerRowFingerprint(c, node, pending, opRunning, selected, - askerCount, targetCount, gatewayLinks, hostname) { - return JSON.stringify({ - running: c.running, - needs_login: c.needs_login, - needs_update: c.needs_update, - pending_reminders: c.pending_reminders, - active_model: c.active_model, - port: c.port, - pending, - opRunning, - selected, - askerCount, - targetCount, - depth: node.depth, - isLast: node.isLast, - ancestorIsLast: node.ancestorIsLast, - gatewayLinks, - hostname, - }); - } - - // Build a single container-row
  • from scratch. Extracted so - // renderContainers can call this only for rows whose fingerprint - // changed (keyed cache), skipping the build + async dashboard-state - // fetch for stable rows. - function buildContainerLi(c, node, opts) { - const { - pending, opRunning, selected, - askerCount, targetCount, agentQCount, - url, containerBase, forgeBase, s, - } = opts; - const li = el('li', { - class: 'container-row' - + (pending ? ' pending' : '') - + (opRunning ? ' pending-running' : '') - + (selected ? ' selected' : ''), - }); - // Topology: depth contributes left-padding; the glyph string in - // the .tree-prefix span draws the ├─ / └─ joint + continuation - // lines (`│ `) for ancestors whose subtree extends below this - // row. Both are CSS-driven from the data attributes so the - // legacy flat layout (every container at depth 0) is bit- - // identical to today's render — no glyph, no indent. - if (node.depth > 0) li.dataset.depth = String(node.depth); - const prefix = treePrefixDom(node); - if (prefix) li.prepend(prefix); - - // Agent icon: 5em square wrapper with an absolutely-positioned - // + fire-and-forget load with /favicon.svg fallback. The - // wrapper doubles as the selection toggle (click / keyboard). - // See docs/web-ui.md::Container row for the layout + load-strategy - // rationale. - const iconImg = el('img', { class: 'container-icon-img', alt: '' }); - const icon = el('div', { - class: 'container-icon', - role: 'button', - tabindex: '0', - 'aria-pressed': selected ? 'true' : 'false', - title: selected - ? `deselect ${c.name} (or press Esc to clear all)` - : `select ${c.name} for bulk actions`, - }, iconImg); - icon.addEventListener('click', (e) => { - e.preventDefault(); - toggleSelection(c.name); - }); - icon.addEventListener('keydown', (e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - toggleSelection(c.name); - } - }); - if (c.running) { - iconImg.src = `${url}icon`; - iconImg.addEventListener('error', () => { - if (iconImg.dataset.fallback) return; // guard: don't loop if the favicon itself 404s - iconImg.dataset.fallback = '1'; - icon.classList.add('icon-unreachable'); - iconImg.src = '/favicon.svg'; - }); - } else { - // Container stopped — skip the doomed `${url}icon` fetch and - // go straight to the dimmed hyperhive mark. Avoids a noisy - // failed request in the console + the brief broken-image flash. - icon.classList.add('icon-unreachable'); - iconImg.src = '/favicon.svg'; - } - // Card body: the three stacked content lines, right of the icon. - const body = el('div', { class: 'card-body' }); - - // ── identity ───────────────────────────────────────────────── - const head = el('div', { class: 'head' }); - head.append( - el('a', { class: 'name', href: url, target: '_blank', rel: 'noopener' }, c.name), - ); - // Icon-only nav strip — populated async from the agent's own - // `GET /api/dashboard-state` (via gateway when enabled, direct - // TCP otherwise). The agent is the single source of truth for its - // link list: stats / screen (GUI agents only — c0re's disk-based - // fallback cannot detect this) / forge profile / agent-configs / - // extras. DOM-built — link strings come from the agent's process - // and must never reach the HTML parser. - const navStrip = el('span', { class: 'nav-strip' }); - head.append(navStrip); - if (c.running) { - // Fetch the lean dashboard-state snapshot from the agent directly. - // Populates: nav strip links (including the screen link that - // c0re's disk-based build cannot detect), rate_limited badge, - // ctx-window badge, and self-reported status text. - // Fails gracefully when the agent is starting up or the gateway - // is not yet routing to it — badges simply don't appear. - // Only runs when the row is first built (fingerprint changed) — - // reused rows keep their previously-fetched nav strip + badges. - fetch(`${containerBase}/api/dashboard-state`) - .then((r) => (r.ok ? r.json() : null)) - .then((ds) => { - if (!ds) return; - // ── nav strip ─────────────────────────────────────────── - if (Array.isArray(ds.links)) { - for (const lnk of ds.links) { - const href = lnk.kind === 'forge' ? forgeBase + (lnk.url || '') - : lnk.kind === 'external' ? (lnk.url || '') - : /* container */ containerBase + '/' + (lnk.url || ''); - const a = el('a', { - class: 'nav-link', - href, - target: '_blank', - rel: 'noopener', - title: lnk.label || '', - }); - // Plain text — agent-controlled strings stay out of innerHTML. - a.textContent = lnk.icon || lnk.label || ''; - navStrip.append(a); - } - } - // ── agent-owned status badges ──────────────────────────── - // rate_limited: only show when no other critical badge is - // already shown (pending / not-running already handled sync). - if (ds.rate_limited) { - head.append(el('span', - { class: 'badge badge-rate-limited', title: 'API rate-limited — harness is parked, will retry automatically' }, - '⊘ rate limited')); - } - // ctx-window badge - if (ds.ctx_tokens != null) { - const k = Math.round(ds.ctx_tokens / 1000); - const win = ds.context_window_tokens; - const warn = win != null ? win * CTX_WARN_FRACTION : CTX_WARN_TOKENS; - const caution = win != null ? win * CTX_CAUTION_FRACTION : CTX_CAUTION_TOKENS; - const ctxClass = ds.ctx_tokens >= warn ? 'badge-ctx-warn' - : ds.ctx_tokens >= caution ? 'badge-ctx-caution' - : 'badge-ctx-ok'; - const title = win != null - ? `last turn context: ${ds.ctx_tokens.toLocaleString()} / ${win.toLocaleString()} ` - + `tokens (${Math.round((ds.ctx_tokens / win) * 100)}% of the window)` - : `last turn context size: ${ds.ctx_tokens.toLocaleString()} tokens`; - head.append(el('span', { class: `badge ${ctxClass}`, title }, `ctx·${k}k`)); - } - // ── agent status text (self-reported via set_status) ───── - if (ds.status_text) { - const nowUnix = Math.floor(Date.now() / 1000); - const ageStr = ds.status_set_at != null - ? ` (set ${fmtAgeSecs(nowUnix - ds.status_set_at)} ago)` : ''; - // Stamp data-set-at so the 30s ticker below keeps the age - // label current even when the row is reused from the cache. - const ageAttrs = ds.status_set_at != null - ? { class: 'status-age', 'data-set-at': String(ds.status_set_at) } - : { class: 'status-age' }; - // Icon + message are wrapped together in `.status-msg` so the - // CSS can clamp *just the message* to two lines; the age span is - // a separate flex sibling that is never clipped, so the "(set N - // ago)" stamp stays visible even when the status text is long. - body.append(el('div', { - class: 'agent-status', - title: `agent self-reported status${ageStr}`, - }, - el('span', { class: 'status-msg' }, - el('span', { class: 'status-icon' }, '◈ '), - ds.status_text, - ), - el('span', ageAttrs, ageStr), - )); - } - }) - .catch(() => { /* graceful: agent starting / gateway miss → no data */ }); - } - // Status / runtime badges. Pending transients always win - // (start / stop / restart / rebuild is in progress). Otherwise, - // when the container is stopped, surface a single `■ not running` - // badge. `needs_login` is still c0re-owned (reads auth sentinel - // files on the host). rate_limited / ctx / status_text are - // agent-owned and rendered by the async dashboard-state fetch above. - if (pending) { - head.append(el('span', { class: 'pending-state' }, - el('span', { class: 'spinner' }, '◐'), ' ', pending + '…')); - } else if (!c.running) { - head.append(el('span', - { class: 'badge badge-muted', title: 'container is shut down — start it to bring the harness back up' }, - '■ not running')); - } else if (c.needs_login) { - head.append(el('a', - { class: 'badge badge-warn', href: url, target: '_blank', rel: 'noopener' }, - 'needs login →')); - } - if (c.needs_update) { - head.append(form( - '/api/rebuild/' + c.name, 'badge badge-warn btn-inline', 'needs update ↻', - 'rebuild ' + c.name + '? hot-reloads the container.', - {}, { noRefresh: true }, - )); - } - - if (c.active_model) { - head.append(el('span', - { class: 'badge badge-model', title: `active claude model: ${c.active_model}` }, - c.active_model)); - } - - if (c.pending_reminders && c.pending_reminders > 0) { - head.append(el('span', - { - class: 'badge badge-reminder', - title: 'pending reminders queued for this agent — see the reminders section to view / cancel', - }, - `⏰ ${c.pending_reminders}`)); - } - // Pending questions where this agent is the asker (awaiting an - // answer) or the target (owes a reply). Derived live from - // questionsState so the badge updates instantly on QuestionAdded / - // QuestionResolved without a separate backend field. - if (agentQCount > 0) { - const parts = []; - if (askerCount > 0) parts.push(`${askerCount} asked`); - if (targetCount > 0) parts.push(`${targetCount} to answer`); - head.append(el('span', - { - class: 'badge badge-loose-ends', - title: `pending questions: ${parts.join(', ')} — see the Q33R1ES tab`, - }, - `❓ ${agentQCount}`)); - } - body.append(head); - - // Per-card action buttons (R3ST4RT / ST0P / ST4RT / R3BU1LD / - // DESTR0Y / PURG3) moved to the selection bar — see - // docs/web-ui.md::Selection bar. The contextual `needs update ↻` - // chip in the head row stays — it's a state-hint, not an - // action button. - - li.append(icon, body, buildAgentMenu(c, forgeBase)); - return li; - } - - function renderContainers(s) { - const root = $('containers-section'); - // #containers-section only exists on /dashboard.html. tabs.js is the - // bundle for that page only (flow.html loads flow.js instead). - // Belt-and-suspenders for any future page that adds tabs.js - // without a #containers-section — matches the - // no-op-when-target-absent convention the other renderers - // (renderQuestions, renderApprovals, etc.) follow. - if (!root) return; - - // Containers come from the derived map (event-driven) rather than - // `s.containers`; `s` still supplies hostname (for the web-ui - // link) and tombstones/meta_inputs (not event-derived yet). The - // tree builder handles the ordering — we don't pre-sort here. - const containers = Array.from(containersState.values()); - const portConflicts = derivePortConflicts(containers); - const anyStale = containers.some((c) => c.needs_update); - - // Preserve the keyed container list across the header-section - // rebuild. We wipe the banners/buttons above the list on every - // render (simple), but recycle
  • elements for unchanged rows - // (keyed) to avoid full-DOM thrash and redundant async fetches. - const existingUl = root.querySelector('ul.containers'); - root.replaceChildren(); - - // Port-hash collisions: rename one of the listed agents and - // rebuild. The banner sits above the agent list so it's the - // first thing the operator sees when something's wedged. - if (portConflicts.length) { - const banner = el('div', { class: 'port-conflict' }, - el('strong', {}, '⚠ port collision'), ' — '); - const groups = portConflicts.map((c) => - `:${c.port} (${c.agents.join(' + ')})`).join('; '); - banner.append(groups + '. rename one of each and ↻ R3BU1LD.'); - root.append(banner); - } - - if (anyStale) { - root.append(form( - '/api/update-all', 'btn-rebuild', '↻ UPD4TE 4LL', - 'rebuild every stale container?', - {}, { noRefresh: true }, - )); - } - - // Queue-summary banner: when the rebuild queue has active work, - // show one compact at-a-glance line + a link to the full queue on the - // C0R3 page. Replaces the old per-transient spinner list — the actual - // running step is already visible per-agent on each card (transient + - // in-flight-queue badges), so the top of the tab only needs the summary. - const activeQueue = rebuildQueueState.filter( - (e) => e.state === 'queued' || e.state === 'running', - ); - if (activeQueue.length) { - const running = activeQueue.filter((e) => e.state === 'running').length; - const queued = activeQueue.length - running; - const parts = []; - if (running) parts.push(`${running} running`); - if (queued) parts.push(`${queued} queued`); - root.append(el('div', { class: 'queue-summary' }, - el('span', { class: 'glyph spinner' }, '◐'), ' ', - el('strong', {}, 'build queue'), ' — ', - parts.join(' · '), ' ', - el('a', { class: 'queue-summary-link', href: '/builds.html' }, 'view queue →'), - )); - } - - if (!containers.length && !transientsState.size) { - root.append(el('p', { class: 'empty' }, 'no managed containers')); - return; - } - - // Drop stale selections (agent destroyed while selected). Defensive — - // the action bar would otherwise loop POST against a gone agent. - const liveNames = new Set(containers.map((c) => c.name)); - for (const n of Array.from(selectionState)) { - if (!liveNames.has(n)) selectionState.delete(n); - } - - const hostname = (s && s.hostname) || window.location.hostname; - // When hive-gateway is in front of the dashboard, build same-origin - // `/agent//` URLs instead of the direct `http://:/` - // TCP fallback — the gateway proxies the prefix to the per-agent - // harness (unix-domain via `agent-sockets.json`, or a computed TCP - // loopback port while the socket marker is absent). See - // `docs/web-ui.md::Container row` + `docs/gateway.md::Vhost map`. - const gatewayLinks = !!(s && s.gateway_enabled); - // Forge public URL: prefer state.forge_public_url (set by the NixOS - // module when forge.behindGateway=true), fall back to - // ":3000" for gateway-off / local-dev deploys. - const forgeBase = (s && s.forge_public_url) || `http://${hostname}:3000`; - const ul = existingUl ?? el('ul', { class: 'containers' }); - const tree = buildAgentTree(containers); - // In-flight rebuild / meta-update / destroy ops per agent name — - // see docs/web-ui.md::Container row for the building… badge - // rationale (covers the SYST3M-shows-rebuild-but-SW4RM-shows-stopped - // gap when no operator transient is set). - const inFlight = inFlightOpsByAgent(); - - // Build the ordered list of
  • elements, reusing cached rows - // whose displayed state hasn't changed. - const orderedLis = []; - for (const node of tree) { - const c = node.container; - const url = gatewayLinks - ? `/agent/${encodeURIComponent(c.name)}/` - : `http://${hostname}:${c.port}/`; - // Container nav-strip base: gateway prefix or direct TCP. - const containerBase = gatewayLinks - ? `/agent/${encodeURIComponent(c.name)}` - : `http://${hostname}:${c.port}`; - // Pending-state derivation + queued-vs-running split — see - // docs/web-ui.md::Container row for the transient → in-flight - // queue priority order and the opRunning rationale. - const transientKind = transientsState.get(c.name)?.kind || null; - const op = !transientKind ? inFlight.get(c.name) : null; - const pending = transientKind - || (op && (op.state === 'running' - ? (op.kind === 'meta_update' ? 'meta-updating' - : op.kind === 'destroy' ? 'destroying' - : op.kind === 'restart' ? 'restarting' - : op.kind === 'start' ? 'starting' - : op.kind === 'stop' ? 'stopping' - : op.kind === 'graceful_stop' ? 'stopping' - : 'rebuilding') - : (op.kind === 'meta_update' ? 'meta-update queued' - : op.kind === 'destroy' ? 'destroy queued' - : op.kind === 'restart' ? 'restart queued' - : op.kind === 'start' ? 'start queued' - : op.kind === 'stop' ? 'stop queued' - : op.kind === 'graceful_stop' ? 'stop queued' - : 'rebuild queued'))); - const opRunning = transientKind != null - || (op != null && op.state === 'running'); - const selected = selectionState.has(c.name); - // Pending questions where this agent is the asker (awaiting an - // answer) or the target (owes a reply). Derived live from - // questionsState so the badge updates instantly on QuestionAdded / - // QuestionResolved without a separate backend field. - const askerCount = questionsState.pending.filter((q) => q.asker === c.name).length; - const targetCount = questionsState.pending.filter((q) => q.target === c.name).length; - const agentQCount = questionsState.pending.filter( - (q) => q.asker === c.name || q.target === c.name, - ).length; - - const fp = containerRowFingerprint(c, node, pending, opRunning, selected, - askerCount, targetCount, gatewayLinks, hostname); - const cached = containerRowCache.get(c.name); - - let li; - if (cached && cached.fingerprint === fp) { - // Row unchanged — reuse the existing DOM node. The async - // dashboard-state (nav strip, ctx badge, status text) stays - // intact from the previous build, avoiding a redundant fetch. - li = cached.el; - } else { - li = buildContainerLi(c, node, { - pending, opRunning, selected, - askerCount, targetCount, agentQCount, - url, containerBase, forgeBase, s, - }); - containerRowCache.set(c.name, { el: li, fingerprint: fp }); - } - orderedLis.push(li); - } - - // Remove cache entries for agents that no longer exist. - for (const [name, entry] of containerRowCache) { - if (!liveNames.has(name)) { - entry.el.remove(); - containerRowCache.delete(name); - } - } - - // Apply correct DOM order without a full wipe. insertBefore is a - // no-op when the node is already at position i, so stable sections - // of the list cause zero layout work. - for (let i = 0; i < orderedLis.length; i++) { - if (ul.children[i] !== orderedLis[i]) { - ul.insertBefore(orderedLis[i], ul.children[i] ?? null); - } - } - // Trim any excess children (defensive — shouldn't happen after the - // cache-removal pass above, but keeps the ul length exact). - while (ul.children.length > orderedLis.length) ul.lastChild.remove(); - - root.append(ul); - renderSelectionBar(containers); - } - - // ─── selection bar ────────────────────────────────────────────────── - // Sticky-bottom strip; visible when ≥1 agent selected on the SW4RM - // tab. See docs/web-ui.md::Selection bar for the interaction model - // and the per-action availability rules (disabled-with-tooltip for - // actions that don't apply to the full selection). Actions POST per - // agent in a loop (endpoints are individually idempotent / - // event-covered, so no new bulk backend wire is needed). - function renderSelectionBar(containers) { - const bar = $('selection-bar'); - if (!bar) return; - const countSpan = $('selection-count'); - const namesSpan = $('selection-names'); - const actions = $('selection-actions'); - if (!countSpan || !namesSpan || !actions) return; - - const selected = containers.filter((c) => selectionState.has(c.name)); - // Tab-gate: bar only renders on SW4RM (the only tab with agent - // cards to cross-reference). Selection state lives in-memory and - // the bar reappears on return to SW4RM if still non-empty. - const onSwarmTab = (document.body.dataset.activeTab || 'swarm') === 'swarm'; - if (!selected.length || !onSwarmTab) { - bar.hidden = true; - document.body.classList.remove('has-selection'); - return; - } - bar.hidden = false; - document.body.classList.add('has-selection'); - countSpan.textContent = selected.length === 1 - ? '1 agent selected' - : selected.length + ' agents selected'; - namesSpan.textContent = '· ' + selected.map((c) => c.name).join(', '); - - // Recompute action availability + tooltips per render. Each - // action declares which agents it CAN'T run on; the bar disables - // the button and surfaces the offending names in the tooltip. - actions.replaceChildren(); - const allRunning = selected.every((c) => c.running); - const allStopped = selected.every((c) => !c.running); - - const stoppedNames = selected.filter((c) => !c.running).map((c) => c.name); - const runningNames = selected.filter((c) => c.running).map((c) => c.name); - - function why(label, blockers) { - if (!blockers.length) return null; - return `${label} not available — ${blockers.join(', ')} ${blockers.length === 1 ? 'is' : 'are'} blocking it`; - } - - addBulkButton(actions, 'btn-restart', '↺ R3ST4RT', allRunning, selected, { - action: '/api/restart/', - confirm: (names) => `restart ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`, - disabledTitle: why('↺ R3ST4RT', stoppedNames.map((n) => `\`${n}\` is stopped`)), - }); - addBulkButton(actions, 'btn-stop', '■ ST0P', allRunning, selected, { - action: '/api/kill/', - confirm: (names) => `stop ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`, - confirmLabel: '■ stop', - graceful: true, - disabledTitle: why('■ ST0P', stoppedNames.map((n) => `\`${n}\` is already stopped`)), - }); - addBulkButton(actions, 'btn-start', '▶ ST4RT', allStopped, selected, { - action: '/api/start/', - confirm: (names) => `start ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`, - disabledTitle: why('▶ ST4RT', runningNames.map((n) => `\`${n}\` is already running`)), - }); - addBulkButton(actions, 'btn-rebuild', '↻ R3BU1LD', true, selected, { - action: '/api/rebuild/', - confirm: (names) => `rebuild ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? hot-reloads each container.`, - }); - addBulkButton(actions, 'btn-destroy', 'DESTR0Y', true, selected, { - action: '/api/destroy/', - confirm: (names) => `destroy ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? containers are removed; state + creds kept.`, - }); - addBulkButton(actions, 'btn-destroy', 'PURG3', true, selected, { - action: '/api/destroy/', - body: { purge: 'on' }, - confirm: (names) => `PURGE ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? containers, config history, claude creds, and notes are all WIPED. no undo.`, - }); - - // Move agent(s) in the topology tree — selecting an option in the - // M0V3 dropdown immediately confirms + executes the move. "(no parent)" - // promotes to root (empty new_parent on the backend). Cycle-safe: - // dropdown filters out self and descendants on the client side; the - // backend rechecks via `topology::set_parent`. - // - // Backend: POST /api/topology/set-parent (dashboard.rs), - // form-encoded `child=&new_parent=`. Re-emits - // container snapshots on success so the tree repaints without a - // separate refresh. - addMoveActions(actions, selected, containers); - } - - // Render the M0V3 picker in the selection bar. Selecting any real option - // (including "(no parent)") immediately fires a confirm + POST — no - // separate button. Backend `topology::set_parent` refuses invalid moves - // and the refusal surfaces in the alert roll-up. - function addMoveActions(parent, selected, containers) { - const candidates = validReparentCandidates(selected, containers); - const wrap = el('span', { class: 'move-picker' }); - const selectTitle = selected.length === 1 - ? `change ${selected[0].name}'s parent` - : `change ${selected.length} agents' parent`; - const sel = el('select', { class: 'move-picker-select', title: selectTitle }); - sel.append(el('option', { value: '' }, '⇢ M0V3 →')); - // "(no parent)" → empty new_parent on the backend (promotes to root). - sel.append(el('option', { value: '__root__' }, '(no parent)')); - for (const name of candidates) { - sel.append(el('option', { value: name }, name)); - } - sel.addEventListener('change', async () => { - if (sel.selectedIndex === 0) return; - const newParent = sel.value === '__root__' ? '' : sel.value; - const newParentLabel = sel.value === '__root__' ? '(no parent)' : sel.value; - const names = selected.map((c) => c.name); - const promptMsg = names.length === 1 - ? `move ${names[0]} → ${newParentLabel}?` - : `move ${names.length} agents (${names.join(', ')}) → ${newParentLabel}?`; - if (!(await themedConfirm({ message: promptMsg, danger: true }))) { - sel.selectedIndex = 0; - return; - } - sel.disabled = true; - const failures = []; - if (names.length === 1) { - // Single agent — use the form-encoded endpoint (backwards compat). - try { - const body = new URLSearchParams({ child: names[0], new_parent: newParent }); - const resp = await fetch('/api/topology/set-parent', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body, - redirect: 'manual', - }); - const ok = resp.ok || resp.type === 'opaqueredirect' - || (resp.status >= 200 && resp.status < 400); - if (!ok) { - const text = await resp.text().catch(() => ''); - failures.push(`${names[0]}: http ${resp.status}${text ? ' — ' + text.slice(0, 200) : ''}`); - } - } catch (err) { - failures.push(`${names[0]}: ${err}`); - } - } else { - // Multiple agents — use the bulk endpoint so all moves land in - // a single git commit instead of one per agent. - try { - const payload = names.map((n) => ({ child: n, new_parent: newParent || null })); - const resp = await fetch('/api/topology/set-parent-bulk', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - redirect: 'manual', - }); - const ok = resp.ok || resp.type === 'opaqueredirect' - || (resp.status >= 200 && resp.status < 400); - if (!ok) { - const text = await resp.text().catch(() => ''); - failures.push(`bulk: http ${resp.status}${text ? ' — ' + text.slice(0, 200) : ''}`); - } - } catch (err) { - failures.push(`bulk: ${err}`); - } - } - sel.disabled = false; - sel.selectedIndex = 0; - if (failures.length) { - themedToast(`M0V3 completed with ${failures.length} failure${failures.length === 1 ? '' : 's'}:\n\n` + failures.join('\n'), { type: 'error', duration: 0 }); - } - }); - wrap.append(sel); - parent.append(wrap); - } - - // Filter the dashboard's container list to those that are valid - // re-parent targets for the `selected` agents: anyone who isn't IN - // the selection itself, isn't a descendant of any selected agent - // (cycle prevention across the whole batch). The backend re-checks - // per-agent via `topology::set_parent`; this client-side filter is - // purely UX so the operator can't pick an obviously-invalid option. - function validReparentCandidates(selected, containers) { - // Build child map once. - const childrenOf = new Map(); - for (const c of containers) { - const p = c.parent || null; - if (!childrenOf.has(p)) childrenOf.set(p, []); - childrenOf.get(p).push(c.name); - } - // Union descendant set across every selected agent (each agent's - // descendants AND itself). - const blocked = new Set(); - for (const t of selected) { - const queue = [t.name]; - blocked.add(t.name); - while (queue.length) { - const n = queue.shift(); - for (const child of (childrenOf.get(n) || [])) { - if (blocked.has(child)) continue; - blocked.add(child); - queue.push(child); - } - } - } - return containers - .filter((c) => !blocked.has(c.name)) - .map((c) => c.name) - .sort(); - } - - function addBulkButton(parent, btnClass, label, enabled, selected, opts) { - const names = selected.map((c) => c.name); - const btn = el('button', { - type: 'button', - class: 'btn ' + btnClass, - }, label); - if (!enabled) { - btn.disabled = true; - if (opts.disabledTitle) btn.title = opts.disabledTitle; - } - btn.addEventListener('click', async () => { - if (btn.disabled) return; - const msg = opts.confirm(names); - let graceful = false; - if (msg) { - const r = await themedConfirm({ - message: msg, - danger: true, - confirmLabel: opts.confirmLabel || 'confirm', - checkboxes: opts.graceful - ? [{ name: 'graceful', label: 'stop gracefully — let each agent finish its turn and flush state before the container stops' }] - : [], - }); - if (!r) return; - graceful = !!r.graceful; - } - btn.disabled = true; - const original = btn.innerHTML; - btn.innerHTML = ' ' + label; - const failures = []; - // Sequential POSTs to keep server-side serialisation predictable - // (rebuild_queue dedups but other endpoints don't); the loop is - // short — bulk selections are typically a handful of agents. - // - // Two URL shapes: - // - `opts.action` is a path prefix and the agent name gets - // appended (lifecycle endpoints: /start/, /rebuild/). - // `opts.body` is a static object applied to every POST. - // - `opts.perAgentBodyFor(name)` is set: `opts.action` is the - // full URL (no name appended) and the per-agent body comes - // from the callback. Used by /api/topology/set-parent, where - // the agent name is a body field rather than a URL component. - for (const name of names) { - const body = opts.perAgentBodyFor - ? new URLSearchParams(opts.perAgentBodyFor(name)) - : new URLSearchParams(opts.body || {}); - const url = opts.perAgentBodyFor - ? opts.action - : opts.action + encodeURIComponent(name) + (graceful ? '?graceful=true' : ''); - try { - const resp = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body, - redirect: 'manual', - }); - const ok = resp.ok || resp.type === 'opaqueredirect' - || (resp.status >= 200 && resp.status < 400); - if (!ok) { - const text = await resp.text().catch(() => ''); - failures.push(`${name}: http ${resp.status}${text ? ' — ' + text.slice(0, 200) : ''}`); - } - } catch (err) { - failures.push(`${name}: ${err}`); - } - } - btn.disabled = false; - btn.innerHTML = original; - if (failures.length) { - themedToast(`${label} completed with ${failures.length} failure${failures.length === 1 ? '' : 's'}:\n\n` + failures.join('\n'), { type: 'error', duration: 0 }); - } - // Container-lifecycle events (ContainerStateChanged / - // ContainerRemoved / RebuildQueueChanged) flow over the existing - // SSE channel and update the derived stores live — no manual - // refresh needed. - }); - parent.append(btn); - } - - // The Y3R C4LL questions domain (sync/apply/render/filter/TTL) now lives in - // call.js — `syncQuestionsFromSnapshot`, `applyQuestionAdded`, - // `applyQuestionResolved`, `renderQuestions`, and `activeQuestionCount` are - // imported above. `questionsState` itself lives in state.js (the SW4RM - // container rows read `questionsState.pending` for per-agent count badges). - - // 30s ticker for agent status-age chips. Renderers stamp `data-set-at` - // (unix seconds) on the `.status-age` span. Keyed container rows persist - // across re-renders, so without this ticker the "(set N ago)" label would - // become stale as time passes. 30s granularity matches fmtAgeSecs precision - // (sub-minute values round to seconds, coarser above that). - setInterval(() => { - const now = Math.floor(Date.now() / 1000); - document.querySelectorAll('.status-age[data-set-at]').forEach((node) => { - const setAt = Number(node.dataset.setAt); - if (!Number.isFinite(setAt) || setAt === 0) return; - node.textContent = ` (set ${fmtAgeSecs(now - setAt)} ago)`; - }); - }, 30_000); - // Live ticker for approval request-age chips. Approval cards only // re-render on `approval_added`/`approval_resolved` SSE events, so // a request pending for an hour could still show "0s ago" without @@ -1267,8 +108,8 @@ window.marked = marked; // Live countdown for reminder due-at labels and schedule next-fire // cells. Both renderers stamp `data-due-at` on the element so this // single ticker keeps them fresh without triggering a full re-render. - // `.reminder-due` → "overdue X ago" / "in Y" - // `.sched-due` → same pattern + // `.reminder-due` -> "overdue X ago" / "in Y" + // `.sched-due` -> same pattern setInterval(() => { const now = Math.floor(Date.now() / 1000); document.querySelectorAll('.approval-ts[data-requested-at]').forEach((node) => { @@ -1288,41 +129,6 @@ window.marked = marked; }); }, 1000); - // Peer hives: render link cards as a headline section under SW4RM - // (state.peer_hives). Called on every state refresh. When nothing is - // federated, the "P33R H1V3S" headline block is hidden entirely and a - // quiet grey note replaces the cards. - function renderPeerHives(peers) { - const block = $('peers-block'); - const root = $('peers-section'); - if (!root) return; - root.replaceChildren(); - const hasPeers = Array.isArray(peers) && peers.length > 0; - if (block) block.hidden = !hasPeers; - if (!hasPeers) { - root.append(el('p', { class: 'empty' }, 'no peer hives configured')); - return; - } - const ul = el('ul', { class: 'containers' }); - for (const p of peers) { - const li = el('li', { class: 'container-row' }); - const head = el('div', { class: 'head' }); - const icon = el('span', { class: 'container-icon' }, '⬡'); - const nameEl = el('span', { class: 'name' }, p.name || p.url); - const linkEl = el('a', { - href: p.url, - target: '_blank', - rel: 'noopener noreferrer', - class: 'meta', - title: 'open ' + p.name + ' dashboard', - }, p.url); - head.append(icon, nameEl); - li.append(head, el('div', { class: 'meta' }, linkEl)); - ul.append(li); - } - root.append(ul); - } - // ─── state polling ────────────────────────────────────────────────────── let pollTimer = null; // Sections whose innerHTML gets blown away on each refresh. If the @@ -1465,7 +271,7 @@ window.marked = marked; } refreshState(); // Cold-load the operator inbox so the Y3R C4LL pill + browser - // title reflect unread agent→operator messages immediately, before the + // title reflect unread agent->operator messages immediately, before the // operator opens the tab. Live updates arrive via the broker stream. refreshOperatorInbox(); // NOTIF.bind() (the enable/mute/unmute toggle UI) moved to /settings.html @@ -1554,8 +360,8 @@ window.marked = marked; // The shared hash-routed tab strip (@hive/shared/tabs.js) owns the // tab/pane toggle + aria-selected + routing; `activateTab` runs the // per-tab side-effects via the strip's onShow. Constructed below (after - // the lazy-load fns it calls are defined). The responsive overflow ⋮ - // menu was dropped — if the strip ever runs out of room we move panes + // the lazy-load fns it calls are defined). The responsive overflow menu + // was dropped — if the strip ever runs out of room we move panes // to their own pages rather than hide them in a dropdown. function activateTab(target) { // Track active tab on the body so renderSelectionBar can gate @@ -1599,7 +405,7 @@ window.marked = marked; // stores so SSE-driven updates flow through without extra plumbing. // Set `hidden` when the count is zero so the pill doesn't draw // attention to an empty room. - // The operator inbox (unread agent→operator messages) now lives in + // The operator inbox (unread agent->operator messages) now lives in // call.js — `refreshOperatorInbox`, `operatorInboxAppendFromEvent`, and // `operatorInboxCount` are imported above. It calls back through the // `onCountsChanged` callback registered via `initCall` at boot. @@ -1622,7 +428,7 @@ window.marked = marked; } setTabCount('swarm', swarm); // Y3R C4LL — pending approvals + operator-targeted questions + - // unread agent→operator messages. + // unread agent->operator messages. const callCount = activeApprovalCount() + activeQuestionCount() +