From d0ec0d289646067eed36125a903d05b1fdaaeb38 Mon Sep 17 00:00:00 2001 From: iris Date: Tue, 16 Jun 2026 09:37:01 +0200 Subject: [PATCH] dashboard: extract operator-inbox + approvals into call.js (#1451) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Begin splitting the tabs.js monolith: lift the Y3R C4LL domain into a new call.js module (mirrors permissions.js / schedules.js). - questionsState + QUESTION_HISTORY_LIMIT move to state.js: they're read by both the SW4RM container rows (per-agent question-count badges) and the Y3R C4LL questions domain, so they're cross-domain shared state. - operator-inbox domain (state + refresh/render/mark/append) → call.js. - approvals domain (state + sync/apply/render/diff-panel/history) → call.js. - call.js exposes count getters (activeApprovalCount, operatorInboxCount); the entry's refreshTabCounts pulls them. Live-mutation paths call an injected onCountsChanged callback (registered via initCall at boot) instead of reaching into the coordinator (avoids a circular import). - renderPeerHives, physically interleaved in the moved range but part of the SW4RM/peers domain, stays in tabs.js. Behaviour-preserving; no visual change. Questions domain follows next. (Recreated after a harness-restart git-object corruption; identical content.) --- frontend/packages/dashboard/src/call.js | 410 +++++++++++++++++++++++ frontend/packages/dashboard/src/state.js | 10 + frontend/packages/dashboard/src/tabs.js | 406 ++-------------------- 3 files changed, 444 insertions(+), 382 deletions(-) create mode 100644 frontend/packages/dashboard/src/call.js diff --git a/frontend/packages/dashboard/src/call.js b/frontend/packages/dashboard/src/call.js new file mode 100644 index 00000000..74472ee6 --- /dev/null +++ b/frontend/packages/dashboard/src/call.js @@ -0,0 +1,410 @@ +// Y3R C4LL domain — the dashboard pane for things blocked on an operator +// decision: approvals, the operator's question queue, and the operator +// inbox (messages agents sent to `to="operator"`). Lifted out of the +// dashboard entry (tabs.js) into its own module so the entry stays a thin +// coordinator; mirrors the permissions.js / schedules.js extractions. +// +// State the tab owns lives here (module-local); the one genuinely +// cross-domain store, `questionsState`, lives in state.js because the +// SW4RM container rows read it too. +// +// Tab-count wiring: like schedules.js this module does not call the +// coordinator's `refreshTabCounts` directly (that would be a circular +// import). Instead it exposes count getters the coordinator pulls, and the +// live-mutation paths call an injected `onCountsChanged` callback the entry +// registers once via `initCall`. + +import { $, el, form, Panel, appendLinkified } from './common.js'; +import { fmtAgo } from './util.js'; + +// Registered by the dashboard entry at boot; defaults to a no-op so the +// module is safe to call before wiring. +let onCountsChanged = () => {}; + +export function initCall(opts = {}) { + if (typeof opts.onCountsChanged === 'function') onCountsChanged = opts.onCountsChanged; +} + +// ─── operator inbox — unread agent→operator messages ──────────── +// The Y3R C4LL tab surfaces messages agents `send(to: "operator")` so +// the operator stops missing them. Unread = broker rows to "operator" +// with `acked_at IS NULL`; cold-loaded from `/api/operator-inbox`, +// appended live from the broker `sent` stream, and cleared via the +// existing per-recipient ack (`POST /api/agent/operator/mark-all-read`). +// Count folds into the Y3R C4LL pill + browser-title prefix. +let operatorInbox = []; // [{ id, from, body, at, file_refs }], newest-first + +export function operatorInboxCount() { return operatorInbox.length; } + +export async function refreshOperatorInbox() { + try { + const r = await fetch('/api/operator-inbox'); + if (r.ok) { + const data = await r.json(); + operatorInbox = Array.isArray(data.messages) ? data.messages : []; + } + } catch { /* keep prior list on transient failure */ } + renderOperatorInbox(); + onCountsChanged(); +} + +function renderOperatorInbox() { + const root = $('operator-inbox-section'); + if (!root) return; + root.replaceChildren(); + if (!operatorInbox.length) { + root.append(el('p', { class: 'meta' }, 'no unread messages')); + return; + } + const mark = el('button', { type: 'button', class: 'btn', id: 'op-inbox-mark-read' }, + `✓ mark all read (${operatorInbox.length})`); + mark.addEventListener('click', markOperatorInboxRead); + root.append(el('div', { class: 'inbox-toolbar' }, mark)); + const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19); + const ul = el('ul', { class: 'inbox' }); + for (const m of operatorInbox) { + const body = el('span', { class: 'msg-body' }); + appendLinkified(body, m.body, m.file_refs); + ul.append(el('li', {}, + el('span', { class: 'msg-ts' }, fmt(m.at)), ' ', + el('span', { class: 'msg-from' }, m.from), ' ', + el('span', { class: 'msg-sep' }, '→ '), + body, + )); + } + root.append(ul); +} + +async function markOperatorInboxRead() { + try { await fetch('/api/agent/operator/mark-all-read', { method: 'POST' }); } + catch { /* best-effort; the next refresh reconciles */ } + operatorInbox = []; + renderOperatorInbox(); + onCountsChanged(); +} + +// Live append from the broker stream — a `sent` frame addressed to +// "operator". De-dupes on broker row id so a history/live overlap or +// a refresh racing the stream doesn't double-list. +export function operatorInboxAppendFromEvent(ev) { + if (ev.id != null && operatorInbox.some((m) => m.id === ev.id)) return; + operatorInbox.unshift({ + id: ev.id, from: ev.from, body: ev.body, at: ev.at, + file_refs: ev.file_refs || [], + }); + if (operatorInbox.length > 100) operatorInbox.length = 100; + renderOperatorInbox(); + onCountsChanged(); +} + +// ─── approvals — the operator config-change / spawn approval queue ──────── + const APPROVAL_TAB_KEY = 'hyperhive:approvals:tab'; + // Derived approval state — cold-loaded from /api/state, then mutated + // live by `approval_added` / `approval_resolved` dashboard events. + // `pending` is the open queue (newest-first); `history` is the last + // 30 resolved rows. + const APPROVAL_HISTORY_LIMIT = 30; + const approvalsState = { pending: [], history: [] }; + export function activeApprovalCount() { return approvalsState.pending.length; } + export function syncApprovalsFromSnapshot(s) { + approvalsState.pending = (s.approvals || []).slice(); + approvalsState.history = (s.approval_history || []).slice(); + } + export function applyApprovalAdded(ev) { + // Upsert by id so a snapshot that already included the row (cold + // load + event lands at the same tick) doesn't double it. + const existing = approvalsState.pending.findIndex((a) => a.id === ev.id); + const row = { + id: ev.id, + agent: ev.agent, + kind: ev.approval_kind, + sha_short: ev.sha_short || null, + diff: ev.diff || null, + description: ev.description || null, + // The ApprovalAdded event carries no requested_at; a live-added + // approval was queued just now, so client-now is accurate — and + // consistent with how fmtAgo compares everything to client-now. + // A later /api/state cold-load swaps in the server value. + requested_at: ev.requested_at != null + ? ev.requested_at : Math.floor(Date.now() / 1000), + }; + if (existing >= 0) approvalsState.pending[existing] = row; + else approvalsState.pending.push(row); + renderApprovals(); + } + export function applyApprovalResolved(ev) { + // Drop from pending; prepend to history (newest-first), cap at 30. + approvalsState.pending = approvalsState.pending.filter((a) => a.id !== ev.id); + // Idempotent: a snapshot re-sync (post-disconnect SSE catchup) can + // carry this same resolved row in `approval_history` while a live + // event also delivers it — guard the unshift so history can't + // double a row. + if (!approvalsState.history.some((h) => h.id === ev.id)) { + approvalsState.history.unshift({ + id: ev.id, + agent: ev.agent, + kind: ev.approval_kind, + sha_short: ev.sha_short || null, + status: ev.status, + resolved_at: ev.resolved_at, + note: ev.note || null, + description: ev.description || null, + }); + if (approvalsState.history.length > APPROVAL_HISTORY_LIMIT) { + approvalsState.history.length = APPROVAL_HISTORY_LIMIT; + } + } + renderApprovals(); + } + // Classify each unified-diff line by its leading char so + // `.diff-add` / `.diff-del` / `.diff-hunk` / `.diff-file` / + // `.diff-ctx` colour the output. Built as text-only spans (no + // innerHTML) so there's no HTML-escape surface. + function buildDiffPre(text) { + const pre = el('pre', { class: 'diff' }); + for (const raw of String(text).split('\n')) { + let cls = 'diff-ctx'; + if (raw.startsWith('--- ') || raw.startsWith('+++ ')) cls = 'diff-file'; + else if (raw.startsWith('@')) cls = 'diff-hunk'; + else if (raw.startsWith('+')) cls = 'diff-add'; + else if (raw.startsWith('-')) cls = 'diff-del'; + const span = document.createElement('span'); + span.className = cls; + span.textContent = raw + '\n'; + pre.appendChild(span); + } + return pre; + } + + // Open an approval's diff in the side panel with a 3-way base + // toggle: vs applied (running tree), vs last-approved, vs previous + // proposal. `applied` uses the diff already shipped on the approval + // for instant paint; the other two fetch /api/approval-diff. + function openDiffPanel(a) { + const bases = [ + ['applied', 'vs applied'], + ['approved', 'vs last-approved'], + ['previous', 'vs previous proposal'], + ]; + const tabs = el('div', { class: 'diff-base-tabs' }); + const host = el('div', { class: 'diff-host' }); + async function selectBase(base) { + for (const btn of tabs.children) { + btn.classList.toggle('active', btn.dataset.base === base); + } + if (base === 'applied' && a.diff != null) { + host.replaceChildren(buildDiffPre(a.diff)); + return; + } + host.replaceChildren(el('div', { class: 'meta' }, 'loading…')); + try { + const resp = await fetch('/api/approval-diff/' + a.id + '?base=' + base); + const text = await resp.text(); + host.replaceChildren(resp.ok + ? buildDiffPre(text) + : el('div', { class: 'meta' }, 'error: ' + text)); + } catch (e) { + host.replaceChildren(el('div', { class: 'meta' }, 'error: ' + e)); + } + } + for (const [base, label] of bases) { + const btn = el('button', + { type: 'button', class: 'diff-base-tab', 'data-base': base }, label); + btn.addEventListener('click', () => selectBase(base)); + tabs.append(btn); + } + const wrap = el('div', { class: 'diff-panel' }, tabs, host); + Panel.open('diff · ' + a.agent + ' #' + a.id, wrap); + selectBase('applied'); + } + + export function renderApprovals() { + const root = $('approvals-section'); + // #approvals-section only lives on /dashboard.html (Y3R C4LL tab); + // no-op elsewhere — `approval_added` / `approval_resolved` SSE + // events route through here on every page that loads the bundle. + if (!root) return; + // Save spawn form input + focus state before the DOM wipe so a live + // approval_added/resolved event doesn't erase a partially-typed name + // or steal focus from the operator. + const savedSpawnName = root.querySelector('.spawnform input[name="name"]')?.value ?? ''; + const spawnHadFocus = document.activeElement === root.querySelector('.spawnform input[name="name"]'); + root.replaceChildren(); + + // Spawn request form: submitting it queues a Spawn approval that + // lands in this same list, so the form belongs here rather than on + // the containers list (the agent doesn't exist yet). + const spawnNameInput = el('input', { + name: 'name', + placeholder: 'new agent name (≤9 chars)', + maxlength: '9', required: '', autocomplete: 'off', + }); + if (savedSpawnName) spawnNameInput.value = savedSpawnName; + if (spawnHadFocus) spawnNameInput.focus(); + const spawn = el('form', { + method: 'POST', action: '/request-spawn', + class: 'spawnform', 'data-async': '', 'data-no-refresh': '', + }); + spawn.append( + spawnNameInput, + el('button', { type: 'submit', class: 'btn btn-spawn' }, '◆ R3QU3ST SP4WN'), + ); + root.append(spawn); + + const pending = approvalsState.pending; + const history = approvalsState.history; + const active = localStorage.getItem(APPROVAL_TAB_KEY) || 'pending'; + const tabs = el('div', { class: 'approval-tabs' }); + const pendingTab = el( + 'button', + { + type: 'button', + class: 'approval-tab' + (active === 'pending' ? ' active' : ''), + }, + `pending · ${pending.length}`, + ); + const historyTab = el( + 'button', + { + type: 'button', + class: 'approval-tab' + (active === 'history' ? ' active' : ''), + }, + `history · ${history.length}`, + ); + pendingTab.addEventListener('click', () => { + localStorage.setItem(APPROVAL_TAB_KEY, 'pending'); + renderApprovals(); + }); + historyTab.addEventListener('click', () => { + localStorage.setItem(APPROVAL_TAB_KEY, 'history'); + renderApprovals(); + }); + tabs.append(pendingTab, historyTab); + root.append(tabs); + + if (active === 'history') { + renderApprovalHistory(root, history); + return; + } + + if (!pending.length) { + root.append(el('p', { class: 'empty' }, 'queue empty')); + return; + } + // forge link base — only when the hive-forge container is up. + const fs = window.__hyperhive_state; + const hostname = (fs && fs.hostname) || window.location.hostname; + // Prefer state.forge_public_url (set when forge.behindGateway=true, + // e.g. "https://forge.pr1ma.darkest.space") over the direct :3000 port. + const forgeBase = (fs && fs.forge_present) + ? (fs.forge_public_url || `http://${hostname}:3000`) + : null; + + const ul = el('ul', { class: 'approvals' }); + for (const a of pending) { + const isApply = a.kind === 'apply_commit'; + const isInit = a.kind === 'init_config'; + const li = el('li', { class: 'approval-card' }); + + // ── identity header ────────────────────────────────────────── + const head = el('div', { class: 'approval-head' }, + el('span', { class: 'glyph' }, isApply ? '→' : '⊕'), + el('span', { class: 'id' }, '#' + a.id), + el('span', { class: 'agent' }, a.agent), + el('span', { class: 'kind' + (isApply ? '' : ' kind-spawn') }, + isApply ? 'apply' : isInit ? 'init' : 'spawn'), + ); + if (isApply && a.sha_short) head.append(el('code', {}, a.sha_short)); + // When the approval was requested — relative time, right-aligned. + // Goes amber once it's been pending an hour so a stale request is + // obvious at a glance (see docs/web-ui.md::Approval card). + if (a.requested_at != null) { + const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - a.requested_at)); + head.append(el('span', { + class: 'approval-ts' + (ageSec >= 3600 ? ' stale' : ''), + title: 'requested ' + new Date(a.requested_at * 1000).toLocaleString(), + 'data-requested-at': String(a.requested_at), + }, 'requested ' + fmtAgo(a.requested_at))); + } + li.append(head); + + // ── what-changed body ──────────────────────────────────────── + const body = el('div', { class: 'approval-body' }); + if (a.description) { + body.append(el('div', { class: 'approval-description' }, a.description)); + } + if (isApply) { + const drill = el('div', { class: 'drill-ins' }); + const diffBtn = el('button', { type: 'button', class: 'panel-trigger' }, + '↳ view diff'); + diffBtn.addEventListener('click', () => openDiffPanel(a)); + drill.append(diffBtn); + if (forgeBase && a.sha_short) { + drill.append(el('a', { + class: 'panel-trigger', target: '_blank', rel: 'noopener', + href: `${forgeBase}/agent-configs/${a.agent}/commit/${a.sha_short}`, + title: 'this proposal commit on the hive forge', + }, '↳ commit on forge ↗')); + } + body.append(drill); + } else { + body.append(el('span', { class: 'meta' }, + isInit + ? 'scaffold proposed config repo — manager customises agent.nix before spawn' + : 'new sub-agent — container will be created on approve')); + } + li.append(body); + + // ── decision actions ───────────────────────────────────────── + // Deny prompts the operator for an optional reason; the submit + // handler stashes it into a hidden `note` input that rides along + // on the POST and is surfaced to the manager via + // HelperEvent::ApprovalResolved { note }. + const denyForm = el('form', { + method: 'POST', action: '/deny/' + a.id, + class: 'inline', 'data-async': '', 'data-no-refresh': '', + 'data-prompt': 'reason for denying (optional, sent to manager):', + }); + denyForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, 'DENY')); + li.append(el('div', { class: 'approval-actions' }, + form('/approve/' + a.id, 'btn-approve', '◆ APPR0VE', null, {}, { noRefresh: true }), + denyForm, + )); + + ul.append(li); + } + root.append(ul); + } + + function renderApprovalHistory(root, history) { + if (!history.length) { + root.append(el('p', { class: 'empty' }, 'no resolved approvals yet')); + return; + } + const ul = el('ul', { class: 'approvals approvals-history' }); + for (const a of history) { + const li = el('li'); + const row = el('div', { class: 'row' }); + const glyph = a.status === 'approved' ? '✓' + : a.status === 'denied' ? '✗' + : a.status === 'cancelled' ? '⊘' + : '⚠'; + row.append( + el('span', { class: 'glyph glyph-' + a.status }, glyph), ' ', + el('span', { class: 'id' }, '#' + a.id), ' ', + el('span', { class: 'agent' }, a.agent), ' ', + el('span', { class: 'kind' }, a.kind === 'apply_commit' ? 'apply' : a.kind === 'init_config' ? 'init' : 'spawn'), ' ', + ); + if (a.sha_short) row.append(el('code', {}, a.sha_short), ' '); + row.append( + el('span', { class: 'status status-' + a.status }, a.status), ' ', + el('span', { class: 'msg-ts' }, fmtAgo(a.resolved_at)), + ); + li.append(row); + if (a.note) { + li.append(el('div', { class: 'history-note' }, a.note)); + } + ul.append(li); + } + root.append(ul); + } diff --git a/frontend/packages/dashboard/src/state.js b/frontend/packages/dashboard/src/state.js index b23e256f..b53534d4 100644 --- a/frontend/packages/dashboard/src/state.js +++ b/frontend/packages/dashboard/src/state.js @@ -23,3 +23,13 @@ export function syncContainersFromSnapshot(s) { containersState.clear(); for (const c of s.containers || []) containersState.set(c.name, c); } + +// Derived question state — the other genuinely cross-domain store. Owned by +// the Y3R C4LL questions domain (`call.js`: cold-loaded from /api/state, then +// mutated live by `question_added` / `question_resolved` events), but also +// read by the SW4RM container rows, which render per-agent asker/target +// question-count badges off `questionsState.pending`. It lives here for the +// same reason as `containersState`: a single source of truth both domains +// import by reference rather than threading through call signatures. +export const QUESTION_HISTORY_LIMIT = 20; +export const questionsState = { pending: [], history: [] }; diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index cf00e721..f8446107 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -19,7 +19,10 @@ import { openStream, renderServerWarnings, bindAsyncForms, } from './common.js'; import { createTabStrip } from '@hive/shared/tabs.js'; -import { containersState, syncContainersFromSnapshot } from './state.js'; +import { + containersState, syncContainersFromSnapshot, + questionsState, QUESTION_HISTORY_LIMIT, +} from './state.js'; import { fmtAgo, truncate, fmtElapsed, fmtDuration } from './util.js'; import { applyCapabilitiesChanged, applyToolGroupsChanged, @@ -29,6 +32,12 @@ import { applySchedulesChanged, applyRemindersChanged, refreshSchedules, refreshReminders, activeScheduleCount, } from './schedules.js'; +import { + initCall, + refreshOperatorInbox, operatorInboxAppendFromEvent, operatorInboxCount, + syncApprovalsFromSnapshot, applyApprovalAdded, applyApprovalResolved, + renderApprovals, activeApprovalCount, +} from './call.js'; // mdNode (in common.js) reads `window.marked` for the markdown side // panel preview path. Set it here on the dashboard entry so file @@ -1181,10 +1190,9 @@ window.marked = marked; parent.append(btn); } - // Derived question state — cold-loaded from /api/state, then mutated - // live by `question_added` / `question_resolved` dashboard events. - const QUESTION_HISTORY_LIMIT = 20; - const questionsState = { pending: [], history: [] }; + // `questionsState` + `QUESTION_HISTORY_LIMIT` now live in state.js (imported + // above): the SW4RM container rows read `questionsState.pending` for per-agent + // question-count badges, so it's cross-domain shared state, not Y3R-C4LL-local. // Keyed row cache: question id → {el, fingerprint}. Allows renderQuestions // to reuse
  • elements whose state hasn't changed. The main benefit is // preserving textarea draft text and radio/checkbox selections when an @@ -1595,125 +1603,6 @@ window.marked = marked; }); }, 1000); - const APPROVAL_TAB_KEY = 'hyperhive:approvals:tab'; - // Derived approval state — cold-loaded from /api/state, then mutated - // live by `approval_added` / `approval_resolved` dashboard events. - // `pending` is the open queue (newest-first); `history` is the last - // 30 resolved rows. - const APPROVAL_HISTORY_LIMIT = 30; - const approvalsState = { pending: [], history: [] }; - function syncApprovalsFromSnapshot(s) { - approvalsState.pending = (s.approvals || []).slice(); - approvalsState.history = (s.approval_history || []).slice(); - } - function applyApprovalAdded(ev) { - // Upsert by id so a snapshot that already included the row (cold - // load + event lands at the same tick) doesn't double it. - const existing = approvalsState.pending.findIndex((a) => a.id === ev.id); - const row = { - id: ev.id, - agent: ev.agent, - kind: ev.approval_kind, - sha_short: ev.sha_short || null, - diff: ev.diff || null, - description: ev.description || null, - // The ApprovalAdded event carries no requested_at; a live-added - // approval was queued just now, so client-now is accurate — and - // consistent with how fmtAgo compares everything to client-now. - // A later /api/state cold-load swaps in the server value. - requested_at: ev.requested_at != null - ? ev.requested_at : Math.floor(Date.now() / 1000), - }; - if (existing >= 0) approvalsState.pending[existing] = row; - else approvalsState.pending.push(row); - renderApprovals(); - } - function applyApprovalResolved(ev) { - // Drop from pending; prepend to history (newest-first), cap at 30. - approvalsState.pending = approvalsState.pending.filter((a) => a.id !== ev.id); - // Idempotent: a snapshot re-sync (post-disconnect SSE catchup) can - // carry this same resolved row in `approval_history` while a live - // event also delivers it — guard the unshift so history can't - // double a row. - if (!approvalsState.history.some((h) => h.id === ev.id)) { - approvalsState.history.unshift({ - id: ev.id, - agent: ev.agent, - kind: ev.approval_kind, - sha_short: ev.sha_short || null, - status: ev.status, - resolved_at: ev.resolved_at, - note: ev.note || null, - description: ev.description || null, - }); - if (approvalsState.history.length > APPROVAL_HISTORY_LIMIT) { - approvalsState.history.length = APPROVAL_HISTORY_LIMIT; - } - } - renderApprovals(); - } - // Classify each unified-diff line by its leading char so - // `.diff-add` / `.diff-del` / `.diff-hunk` / `.diff-file` / - // `.diff-ctx` colour the output. Built as text-only spans (no - // innerHTML) so there's no HTML-escape surface. - function buildDiffPre(text) { - const pre = el('pre', { class: 'diff' }); - for (const raw of String(text).split('\n')) { - let cls = 'diff-ctx'; - if (raw.startsWith('--- ') || raw.startsWith('+++ ')) cls = 'diff-file'; - else if (raw.startsWith('@')) cls = 'diff-hunk'; - else if (raw.startsWith('+')) cls = 'diff-add'; - else if (raw.startsWith('-')) cls = 'diff-del'; - const span = document.createElement('span'); - span.className = cls; - span.textContent = raw + '\n'; - pre.appendChild(span); - } - return pre; - } - - // Open an approval's diff in the side panel with a 3-way base - // toggle: vs applied (running tree), vs last-approved, vs previous - // proposal. `applied` uses the diff already shipped on the approval - // for instant paint; the other two fetch /api/approval-diff. - function openDiffPanel(a) { - const bases = [ - ['applied', 'vs applied'], - ['approved', 'vs last-approved'], - ['previous', 'vs previous proposal'], - ]; - const tabs = el('div', { class: 'diff-base-tabs' }); - const host = el('div', { class: 'diff-host' }); - async function selectBase(base) { - for (const btn of tabs.children) { - btn.classList.toggle('active', btn.dataset.base === base); - } - if (base === 'applied' && a.diff != null) { - host.replaceChildren(buildDiffPre(a.diff)); - return; - } - host.replaceChildren(el('div', { class: 'meta' }, 'loading…')); - try { - const resp = await fetch('/api/approval-diff/' + a.id + '?base=' + base); - const text = await resp.text(); - host.replaceChildren(resp.ok - ? buildDiffPre(text) - : el('div', { class: 'meta' }, 'error: ' + text)); - } catch (e) { - host.replaceChildren(el('div', { class: 'meta' }, 'error: ' + e)); - } - } - for (const [base, label] of bases) { - const btn = el('button', - { type: 'button', class: 'diff-base-tab', 'data-base': base }, label); - btn.addEventListener('click', () => selectBase(base)); - tabs.append(btn); - } - const wrap = el('div', { class: 'diff-panel' }, tabs, host); - Panel.open('diff · ' + a.agent + ' #' + a.id, wrap); - selectBase('applied'); - } - // 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 @@ -1749,197 +1638,6 @@ window.marked = marked; root.append(ul); } - function renderApprovals() { - const root = $('approvals-section'); - // #approvals-section only lives on /dashboard.html (Y3R C4LL tab); - // no-op elsewhere — `approval_added` / `approval_resolved` SSE - // events route through here on every page that loads the bundle. - if (!root) return; - // Save spawn form input + focus state before the DOM wipe so a live - // approval_added/resolved event doesn't erase a partially-typed name - // or steal focus from the operator. - const savedSpawnName = root.querySelector('.spawnform input[name="name"]')?.value ?? ''; - const spawnHadFocus = document.activeElement === root.querySelector('.spawnform input[name="name"]'); - root.replaceChildren(); - - // Spawn request form: submitting it queues a Spawn approval that - // lands in this same list, so the form belongs here rather than on - // the containers list (the agent doesn't exist yet). - const spawnNameInput = el('input', { - name: 'name', - placeholder: 'new agent name (≤9 chars)', - maxlength: '9', required: '', autocomplete: 'off', - }); - if (savedSpawnName) spawnNameInput.value = savedSpawnName; - if (spawnHadFocus) spawnNameInput.focus(); - const spawn = el('form', { - method: 'POST', action: '/request-spawn', - class: 'spawnform', 'data-async': '', 'data-no-refresh': '', - }); - spawn.append( - spawnNameInput, - el('button', { type: 'submit', class: 'btn btn-spawn' }, '◆ R3QU3ST SP4WN'), - ); - root.append(spawn); - - const pending = approvalsState.pending; - const history = approvalsState.history; - const active = localStorage.getItem(APPROVAL_TAB_KEY) || 'pending'; - const tabs = el('div', { class: 'approval-tabs' }); - const pendingTab = el( - 'button', - { - type: 'button', - class: 'approval-tab' + (active === 'pending' ? ' active' : ''), - }, - `pending · ${pending.length}`, - ); - const historyTab = el( - 'button', - { - type: 'button', - class: 'approval-tab' + (active === 'history' ? ' active' : ''), - }, - `history · ${history.length}`, - ); - pendingTab.addEventListener('click', () => { - localStorage.setItem(APPROVAL_TAB_KEY, 'pending'); - renderApprovals(); - }); - historyTab.addEventListener('click', () => { - localStorage.setItem(APPROVAL_TAB_KEY, 'history'); - renderApprovals(); - }); - tabs.append(pendingTab, historyTab); - root.append(tabs); - - if (active === 'history') { - renderApprovalHistory(root, history); - return; - } - - if (!pending.length) { - root.append(el('p', { class: 'empty' }, 'queue empty')); - return; - } - // forge link base — only when the hive-forge container is up. - const fs = window.__hyperhive_state; - const hostname = (fs && fs.hostname) || window.location.hostname; - // Prefer state.forge_public_url (set when forge.behindGateway=true, - // e.g. "https://forge.pr1ma.darkest.space") over the direct :3000 port. - const forgeBase = (fs && fs.forge_present) - ? (fs.forge_public_url || `http://${hostname}:3000`) - : null; - - const ul = el('ul', { class: 'approvals' }); - for (const a of pending) { - const isApply = a.kind === 'apply_commit'; - const isInit = a.kind === 'init_config'; - const li = el('li', { class: 'approval-card' }); - - // ── identity header ────────────────────────────────────────── - const head = el('div', { class: 'approval-head' }, - el('span', { class: 'glyph' }, isApply ? '→' : '⊕'), - el('span', { class: 'id' }, '#' + a.id), - el('span', { class: 'agent' }, a.agent), - el('span', { class: 'kind' + (isApply ? '' : ' kind-spawn') }, - isApply ? 'apply' : isInit ? 'init' : 'spawn'), - ); - if (isApply && a.sha_short) head.append(el('code', {}, a.sha_short)); - // When the approval was requested — relative time, right-aligned. - // Goes amber once it's been pending an hour so a stale request is - // obvious at a glance (see docs/web-ui.md::Approval card). - if (a.requested_at != null) { - const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - a.requested_at)); - head.append(el('span', { - class: 'approval-ts' + (ageSec >= 3600 ? ' stale' : ''), - title: 'requested ' + new Date(a.requested_at * 1000).toLocaleString(), - 'data-requested-at': String(a.requested_at), - }, 'requested ' + fmtAgo(a.requested_at))); - } - li.append(head); - - // ── what-changed body ──────────────────────────────────────── - const body = el('div', { class: 'approval-body' }); - if (a.description) { - body.append(el('div', { class: 'approval-description' }, a.description)); - } - if (isApply) { - const drill = el('div', { class: 'drill-ins' }); - const diffBtn = el('button', { type: 'button', class: 'panel-trigger' }, - '↳ view diff'); - diffBtn.addEventListener('click', () => openDiffPanel(a)); - drill.append(diffBtn); - if (forgeBase && a.sha_short) { - drill.append(el('a', { - class: 'panel-trigger', target: '_blank', rel: 'noopener', - href: `${forgeBase}/agent-configs/${a.agent}/commit/${a.sha_short}`, - title: 'this proposal commit on the hive forge', - }, '↳ commit on forge ↗')); - } - body.append(drill); - } else { - body.append(el('span', { class: 'meta' }, - isInit - ? 'scaffold proposed config repo — manager customises agent.nix before spawn' - : 'new sub-agent — container will be created on approve')); - } - li.append(body); - - // ── decision actions ───────────────────────────────────────── - // Deny prompts the operator for an optional reason; the submit - // handler stashes it into a hidden `note` input that rides along - // on the POST and is surfaced to the manager via - // HelperEvent::ApprovalResolved { note }. - const denyForm = el('form', { - method: 'POST', action: '/deny/' + a.id, - class: 'inline', 'data-async': '', 'data-no-refresh': '', - 'data-prompt': 'reason for denying (optional, sent to manager):', - }); - denyForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, 'DENY')); - li.append(el('div', { class: 'approval-actions' }, - form('/approve/' + a.id, 'btn-approve', '◆ APPR0VE', null, {}, { noRefresh: true }), - denyForm, - )); - - ul.append(li); - } - root.append(ul); - } - - function renderApprovalHistory(root, history) { - if (!history.length) { - root.append(el('p', { class: 'empty' }, 'no resolved approvals yet')); - return; - } - const ul = el('ul', { class: 'approvals approvals-history' }); - for (const a of history) { - const li = el('li'); - const row = el('div', { class: 'row' }); - const glyph = a.status === 'approved' ? '✓' - : a.status === 'denied' ? '✗' - : a.status === 'cancelled' ? '⊘' - : '⚠'; - row.append( - el('span', { class: 'glyph glyph-' + a.status }, glyph), ' ', - el('span', { class: 'id' }, '#' + a.id), ' ', - el('span', { class: 'agent' }, a.agent), ' ', - el('span', { class: 'kind' }, a.kind === 'apply_commit' ? 'apply' : a.kind === 'init_config' ? 'init' : 'spawn'), ' ', - ); - if (a.sha_short) row.append(el('code', {}, a.sha_short), ' '); - row.append( - el('span', { class: 'status status-' + a.status }, a.status), ' ', - el('span', { class: 'msg-ts' }, fmtAgo(a.resolved_at)), - ); - li.append(row); - if (a.note) { - li.append(el('div', { class: 'history-note' }, a.note)); - } - ul.append(li); - } - root.append(ul); - } - // ─── state polling ────────────────────────────────────────────────────── let pollTimer = null; // Sections whose innerHTML gets blown away on each refresh. If the @@ -2205,76 +1903,20 @@ window.marked = marked; // fires activateTab for the per-tab side-effects on every change. createTabStrip($('tabbar'), { defaultId: 'swarm', onShow: activateTab }); + // Register the Y3R C4LL domain's count callback (call.js) — its live + // mutations (inbox stream append, mark-read) trigger a tab-count refresh + // through this instead of reaching back into the coordinator directly. + initCall({ onCountsChanged: refreshTabCounts }); + // Tab count pills — pure derived data from the existing state // 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. - // ─── operator inbox — unread agent→operator messages ──────────── - // The Y3R C4LL tab surfaces messages agents `send(to: "operator")` so - // the operator stops missing them. Unread = broker rows to "operator" - // with `acked_at IS NULL`; cold-loaded from `/api/operator-inbox`, - // appended live from the broker `sent` stream, and cleared via the - // existing per-recipient ack (`POST /api/agent/operator/mark-all-read`). - // Count folds into the Y3R C4LL pill + browser-title prefix. - let operatorInbox = []; // [{ id, from, body, at, file_refs }], newest-first - async function refreshOperatorInbox() { - try { - const r = await fetch('/api/operator-inbox'); - if (r.ok) { - const data = await r.json(); - operatorInbox = Array.isArray(data.messages) ? data.messages : []; - } - } catch { /* keep prior list on transient failure */ } - renderOperatorInbox(); - refreshTabCounts(); - } - function renderOperatorInbox() { - const root = $('operator-inbox-section'); - if (!root) return; - root.replaceChildren(); - if (!operatorInbox.length) { - root.append(el('p', { class: 'meta' }, 'no unread messages')); - return; - } - const mark = el('button', { type: 'button', class: 'btn', id: 'op-inbox-mark-read' }, - `✓ mark all read (${operatorInbox.length})`); - mark.addEventListener('click', markOperatorInboxRead); - root.append(el('div', { class: 'inbox-toolbar' }, mark)); - const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19); - const ul = el('ul', { class: 'inbox' }); - for (const m of operatorInbox) { - const body = el('span', { class: 'msg-body' }); - appendLinkified(body, m.body, m.file_refs); - ul.append(el('li', {}, - el('span', { class: 'msg-ts' }, fmt(m.at)), ' ', - el('span', { class: 'msg-from' }, m.from), ' ', - el('span', { class: 'msg-sep' }, '→ '), - body, - )); - } - root.append(ul); - } - async function markOperatorInboxRead() { - try { await fetch('/api/agent/operator/mark-all-read', { method: 'POST' }); } - catch { /* best-effort; the next refresh reconciles */ } - operatorInbox = []; - renderOperatorInbox(); - refreshTabCounts(); - } - // Live append from the broker stream — a `sent` frame addressed to - // "operator". De-dupes on broker row id so a history/live overlap or - // a refresh racing the stream doesn't double-list. - function operatorInboxAppendFromEvent(ev) { - if (ev.id != null && operatorInbox.some((m) => m.id === ev.id)) return; - operatorInbox.unshift({ - id: ev.id, from: ev.from, body: ev.body, at: ev.at, - file_refs: ev.file_refs || [], - }); - if (operatorInbox.length > 100) operatorInbox.length = 100; - renderOperatorInbox(); - refreshTabCounts(); - } + // 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. function setTabCount(tab, n) { const el_ = $('tab-count-' + tab); @@ -2296,9 +1938,9 @@ window.marked = marked; // Y3R C4LL — pending approvals + operator-targeted questions + // unread agent→operator messages. const callCount = - (approvalsState?.pending?.length ?? 0) + + activeApprovalCount() + (questionsState?.pending?.length ?? 0) + - operatorInbox.length; + operatorInboxCount(); setTabCount('call', callCount); // Browser tab title prefix — lets the operator see the pending // call count without switching to the window. Strips any existing