From d0ec0d289646067eed36125a903d05b1fdaaeb38 Mon Sep 17 00:00:00 2001 From: iris Date: Tue, 16 Jun 2026 09:37:01 +0200 Subject: [PATCH 1/6] 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 From a0fc7e1a9f31b80a9e2b3a73bc5de83c8e3c10cc Mon Sep 17 00:00:00 2001 From: iris Date: Tue, 16 Jun 2026 09:49:58 +0200 Subject: [PATCH 2/6] =?UTF-8?q?dashboard:=20extract=20questions=20domain?= =?UTF-8?q?=20into=20call.js=20=E2=80=94=20Y3R=20C4LL=20split=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final domain: move the operator question queue (questionRowCache, the sync/apply/filter/fingerprint/buildQuestionLi/renderQuestions fns, formatTtl, and the .q-ttl countdown ticker) from tabs.js into call.js. tabs.js imports syncQuestionsFromSnapshot, applyQuestionAdded, applyQuestionResolved, renderQuestions, and the activeQuestionCount getter; refreshTabCounts now sums activeApprovalCount()+activeQuestionCount()+operatorInboxCount(). The .status-age (SW4RM) and .approval-ts/.reminder-due/.sched-due (approvals+schedules) tickers stay in tabs.js — only the questions-specific .q-ttl ticker moved. questionsState stays in state.js (shared with the SW4RM badges). Behaviour-preserving; no visual change. Net: tabs.js 2323 to 1603 lines; the Y3R C4LL pane now lives in call.js, alongside permissions.js / schedules.js as a per-domain module. --- frontend/packages/dashboard/src/call.js | 372 +++++++++++++++++++++++ frontend/packages/dashboard/src/tabs.js | 380 +----------------------- 2 files changed, 381 insertions(+), 371 deletions(-) diff --git a/frontend/packages/dashboard/src/call.js b/frontend/packages/dashboard/src/call.js index 74472ee6..f0b68da3 100644 --- a/frontend/packages/dashboard/src/call.js +++ b/frontend/packages/dashboard/src/call.js @@ -16,6 +16,7 @@ import { $, el, form, Panel, appendLinkified } from './common.js'; import { fmtAgo } from './util.js'; +import { questionsState, QUESTION_HISTORY_LIMIT } from './state.js'; // Registered by the dashboard entry at boot; defaults to a no-op so the // module is safe to call before wiring. @@ -408,3 +409,374 @@ export function operatorInboxAppendFromEvent(ev) { } root.append(ul); } + +// ─── questions — the operator question queue (Y3R C4LL) ─────────────────── + // questionsState + QUESTION_HISTORY_LIMIT are imported from state.js (shared + // with the SW4RM per-agent question-count badges). + // 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 + // unrelated question arrives while the operator is composing a reply. + const questionRowCache = new Map(); + export function activeQuestionCount() { return questionsState.pending.length; } + export function syncQuestionsFromSnapshot(s) { + questionsState.pending = (s.questions || []).slice(); + questionsState.history = (s.question_history || []).slice(); + } + export function applyQuestionAdded(ev) { + if (questionsState.pending.some((q) => q.id === ev.id)) return; + questionsState.pending.push({ + id: ev.id, + asker: ev.asker, + question: ev.question, + options: ev.options || [], + multi: !!ev.multi, + asked_at: ev.asked_at, + deadline_at: ev.deadline_at ?? null, + target: ev.target || null, + question_refs: ev.question_refs || [], + }); + renderQuestions(); + renderContainersFromState(); + } + export function applyQuestionResolved(ev) { + const idx = questionsState.pending.findIndex((q) => q.id === ev.id); + const existing = idx >= 0 ? questionsState.pending[idx] : null; + if (idx >= 0) questionsState.pending.splice(idx, 1); + // Idempotent: a snapshot re-sync (post-disconnect SSE catchup) can + // carry this same answered row in `question_history` while a live + // event also delivers it — guard the unshift so history can't + // double a row. + if (!questionsState.history.some((h) => h.id === ev.id)) { + questionsState.history.unshift({ + id: ev.id, + asker: existing?.asker || '?', + question: existing?.question || '', + options: existing?.options || [], + multi: existing?.multi || false, + asked_at: existing?.asked_at || ev.answered_at, + answered_at: ev.answered_at, + answer: ev.answer, + answerer: ev.answerer, + target: existing?.target ?? ev.target ?? null, + question_refs: existing?.question_refs || [], + answer_refs: ev.answer_refs || [], + }); + if (questionsState.history.length > QUESTION_HISTORY_LIMIT) { + questionsState.history.length = QUESTION_HISTORY_LIMIT; + } + } + renderQuestions(); + renderContainersFromState(); + } + // Filter selection for the questions section. Persisted so the + // operator's preferred view (all / operator-targeted / peer) + // survives a reload. + const QUESTIONS_FILTER_KEY = 'hyperhive:questions:filter'; + function getQuestionsFilter() { + return localStorage.getItem(QUESTIONS_FILTER_KEY) || 'all'; + } + function setQuestionsFilter(v) { + localStorage.setItem(QUESTIONS_FILTER_KEY, v); + renderQuestions(); + } + function questionMatchesFilter(q, filter) { + if (filter === 'all') return true; + if (filter === 'operator') return !q.target; + if (filter === 'peer') return !!q.target; + // `agent:` matches when the agent appears as asker OR target. + if (filter.startsWith('agent:')) { + const name = filter.slice('agent:'.length); + return q.asker === name || q.target === name; + } + return true; + } + // Serialise the fields that determine a pending-question
  • 's DOM + // structure. Used by questionRowCache to skip rebuilds when nothing + // visible has changed. deadline_at controls whether the TTL chip node + // exists at all (its text is kept current by the global 1s ticker). + function questionRowFingerprint(q) { + return JSON.stringify({ + asker: q.asker, target: q.target, asked_at: q.asked_at, + deadline_at: q.deadline_at, question: q.question, + question_refs: q.question_refs, options: q.options, multi: q.multi, + }); + } + + // Build a single pending-question
  • . Extracted from renderQuestions so + // questionRowCache can reuse unchanged nodes without re-running this body. + // Event listeners attached here (keydown on textarea, submit on form) are + // preserved in the reused node — no re-attachment needed. + function buildQuestionLi(q) { + const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19); + const targetLabel = q.target || 'operator'; + const li = el('li', { class: 'question' + (q.target ? ' question-peer' : '') }); + const head = el('div', { class: 'q-head' }, + el('span', { class: 'msg-ts' }, fmt(q.asked_at)), ' ', + el('span', { class: 'msg-from' }, q.asker), ' ', + el('span', { class: 'msg-sep' }, '→'), ' ', + el('span', { class: q.target ? 'msg-to msg-to-peer' : 'msg-to' }, targetLabel), ' ', + el('span', { class: 'msg-sep' }, 'asks:'), + ); + if (q.deadline_at) { + // Tag the chip with its deadline so the global 1s ticker + // can refresh the text without re-rendering the questions section. + const ttlEl = el('span', { + class: 'q-ttl', 'data-deadline': String(q.deadline_at), + }); + ttlEl.textContent = formatTtl( + q.deadline_at - Math.floor(Date.now() / 1000), + ); + head.append(' ', ttlEl); + } + const qBody = el('div', { class: 'q-body' }); + appendLinkified(qBody, q.question, q.question_refs); + li.append(head, qBody); + const f = el('form', { + method: 'POST', action: '/answer-question/' + q.id, + class: 'qform', 'data-async': '', 'data-no-refresh': '', + }); + const hasOptions = q.options && q.options.length; + const isMulti = !!q.multi && hasOptions; + const freeText = el('textarea', { + name: 'answer-free', rows: '2', autocomplete: 'off', + placeholder: (hasOptions ? 'or type your own…' : 'your answer') + + ' (shift+enter for newline)', + }); + // Enter submits; shift+enter inserts a newline (textarea default). + freeText.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + f.requestSubmit(); + } + }); + const optionGroup = el('div', { class: 'q-options' }); + if (hasOptions) { + for (const opt of q.options) { + const inputType = isMulti ? 'checkbox' : 'radio'; + const id = 'q' + q.id + '-' + Math.random().toString(36).slice(2, 8); + const input = el('input', { type: inputType, name: 'choice', value: opt, id }); + const label = el('label', { for: id }, ' ' + opt); + optionGroup.append(el('div', { class: 'q-option' }, input, label)); + } + } + // On submit, build the final `answer` field from selected + // options + free-text, joined by ', '. This lets the operator + // pick options AND add free text in the same form. + f.addEventListener('submit', (ev) => { + const parts = []; + for (const cb of f.querySelectorAll('input[name="choice"]:checked')) { + parts.push(cb.value); + } + const ft = (freeText.value || '').trim(); + if (ft) parts.push(ft); + const merged = parts.join(', '); + // Replace the existing hidden `answer` (if any) with the merged value. + const existing = f.querySelector('input[name="answer"]'); + if (existing) existing.remove(); + f.append(el('input', { type: 'hidden', name: 'answer', value: merged })); + if (!merged) { ev.preventDefault(); alert('pick an option or type an answer'); } + }, true); + if (hasOptions) f.append(optionGroup); + const buttons = el('div', { class: 'q-buttons' }); + // On peer threads the operator's answer is an override — + // mark the button so it's clear what the click does (the + // backend permits it via OperatorQuestions::answer's + // answerer-auth rule). + const answerLabel = q.target + ? (isMulti ? '⤿ 0V3RR1D3 · ' + q.options.length + ' opts' : '⤿ 0V3RR1D3') + : (isMulti ? '▸ ANSW3R · ' + q.options.length + ' opts' : '▸ ANSW3R'); + buttons.append( + el('button', { + type: 'submit', + class: 'btn btn-approve' + (q.target ? ' btn-override' : ''), + title: q.target ? `override-answer on behalf of operator (target was ${q.target})` : '', + }, answerLabel), + ); + f.append( + el('div', { class: 'q-free' }, freeText), + buttons, + ); + li.append(f); + // Separate form so the cancel button doesn't get the answer + // merge-on-submit handler attached to the main form. + const cancelTargetLabel = q.target ? q.target : 'asker'; + const cancelForm = el('form', { + method: 'POST', action: '/cancel-question/' + q.id, + class: 'qform-cancel', 'data-async': '', 'data-no-refresh': '', + 'data-confirm': `cancel this question? ${cancelTargetLabel} will see ` + + '"[cancelled]" as the answer.', + }); + cancelForm.append( + el('button', { type: 'submit', class: 'btn btn-deny' }, '✗ CANC3L'), + ); + li.append(cancelForm); + return li; + } + + export function renderQuestions() { + const root = $('questions-section'); + // #questions-section only lives on /dashboard.html (Y3R C4LL tab); + // no-op when the section is missing. `question_added` / + // `question_resolved` SSE events route through here. + if (!root) return; + // Snapshot open
    state so SSE-triggered re-renders restore + // any expanded sections. The keyed-cache approach reuses question + //
  • nodes (preserving textarea/checkbox state) and only rebuilds + // cache-miss rows, so we no longer wipe the DOM at the start. + const openDetails = snapshotOpenDetails(); + const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19); + const allPending = questionsState.pending; + + // Filter chips. Always include `all` / `operator` / `peer`; add + // per-agent chips for any agent that appears as asker or target + // in the pending list so the operator can isolate a single + // thread without typing. + const participants = new Set(); + for (const q of allPending) { + participants.add(q.asker); + if (q.target) participants.add(q.target); + } + + // Auto-reset a stale per-agent filter: if the operator had `agent:foo` + // selected and all of foo's questions resolved, foo's chip disappears + // from the row. Without a reset the section would show "no questions + // match this filter" with no active chip visible — confusing. Fall back + // to `all` whenever the stored value is no longer a valid chip value. + let activeFilter = getQuestionsFilter(); + const validFilters = new Set(['all', 'operator', 'peer', + ...Array.from(participants).map((n) => 'agent:' + n)]); + if (!validFilters.has(activeFilter)) { + activeFilter = 'all'; + // Write directly to localStorage to avoid the re-render that + // setQuestionsFilter() triggers (we're already mid-render). + localStorage.setItem(QUESTIONS_FILTER_KEY, 'all'); + } + + const pending = allPending.filter((q) => questionMatchesFilter(q, activeFilter)); + + const filterRow = el('div', { class: 'questions-filters' }); + const mkChip = (value, label) => { + const b = el('button', { + type: 'button', + class: 'q-filter-chip' + (activeFilter === value ? ' active' : ''), + }, label); + b.addEventListener('click', () => setQuestionsFilter(value)); + return b; + }; + // Count pending questions per filter value so each chip shows its + // own hit count — the operator can see "operator · 2 / peer · 3" + // at a glance without clicking through each tab. + const operatorCount = allPending.filter((q) => !q.target).length; + const peerCount = allPending.filter((q) => !!q.target).length; + const agentCount = (name) => allPending.filter( + (q) => q.asker === name || q.target === name).length; + filterRow.append( + mkChip('all', `all · ${allPending.length}`), + mkChip('operator', `@operator · ${operatorCount}`), + mkChip('peer', `@peer · ${peerCount}`), + ); + for (const name of Array.from(participants).sort()) { + filterRow.append(mkChip('agent:' + name, `@${name} · ${agentCount(name)}`)); + } + + // Evict resolved/cancelled questions from the row cache. + const allPendingIds = new Set(allPending.map((q) => q.id)); + for (const id of questionRowCache.keys()) { + if (!allPendingIds.has(id)) questionRowCache.delete(id); + } + + // Build the ordered list of
  • elements, reusing cached nodes whose + // serialised state hasn't changed. This is what preserves textarea + // draft text and radio/checkbox selections across re-renders. + const orderedLis = pending.map((q) => { + const fp = questionRowFingerprint(q); + const cached = questionRowCache.get(q.id); + if (cached && cached.fingerprint === fp) return cached.el; + const li = buildQuestionLi(q); + questionRowCache.set(q.id, { el: li, fingerprint: fp }); + return li; + }); + + // Save the history
    open state before the DOM swap so it + // isn't collapsed every time a question arrives while it's open. + const historyWasOpen = root.querySelector('.q-history')?.open ?? false; + + const children = [filterRow]; + if (!pending.length) { + children.push(el('p', { class: 'empty' }, + activeFilter === 'all' ? 'no pending questions' : 'no questions match this filter')); + } else { + const ul = el('ul', { class: 'questions' }); + for (const li of orderedLis) ul.append(li); + children.push(ul); + } + + // Answered question history (read-only, no inputs — built fresh each render). + const hist = questionsState.history; + if (hist.length) { + const details = el('details', { class: 'q-history', 'data-restore-key': 'q-history' }); + details.append(el('summary', {}, '◆ answ3red (' + hist.length + ')')); + const hul = el('ul', { class: 'questions questions-answered' }); + for (const q of hist) { + const targetLabel = q.target || 'operator'; + const li = el('li', { class: 'question question-answered' + (q.target ? ' question-peer' : '') }); + const head = el('div', { class: 'q-head' }, + el('span', { class: 'msg-ts' }, fmt(q.answered_at)), ' ', + el('span', { class: 'msg-from' }, q.asker), ' ', + el('span', { class: 'msg-sep' }, '→'), ' ', + el('span', { class: q.target ? 'msg-to msg-to-peer' : 'msg-to' }, targetLabel), ' ', + el('span', { class: 'msg-sep' }, 'asked:'), + ); + const histBody = el('div', { class: 'q-body' }); + appendLinkified(histBody, q.question, q.question_refs); + const ansText = el('span', { class: 'q-answer-text' }); + appendLinkified(ansText, q.answer || '(none)', q.answer_refs); + const ansLine = el('div', { class: 'q-answer' }, + el('span', { class: 'msg-sep' }, `${q.answerer || '?'}: `), + ansText, + ); + li.append(head, histBody, ansLine); + hul.append(li); + } + details.append(hul); + children.push(details); + } + + root.replaceChildren(...children); + // Restore history open state after the DOM swap. + if (historyWasOpen) { + const histEl = root.querySelector('.q-history'); + if (histEl) histEl.open = true; + } + restoreOpenDetails(openDetails); + } + + // Format a remaining-seconds count as the `⏳ …` TTL chip text on a + // question card. Bucketed at minutes / hours so a long deadline stays + // readable; "expiring…" once the deadline has passed (the host-side + // ttl-watchdog will fire shortly). + function formatTtl(remaining) { + if (remaining <= 0) return 'expiring…'; + if (remaining < 60) return '⏳ ' + remaining + 's'; + if (remaining < 3600) { + return '⏳ ' + Math.floor(remaining / 60) + 'm ' + + (remaining % 60) + 's'; + } + return '⏳ ' + Math.floor(remaining / 3600) + 'h ' + + Math.floor((remaining % 3600) / 60) + 'm'; + } + + // Single page-wide ticker that refreshes every TTL chip in place + // each second. Renderers stamp `data-deadline` on the + // chip; this just updates `textContent`, no re-render of the + // questions section. No-op when no chips are on screen, so the + // cost is negligible. + setInterval(() => { + const now = Math.floor(Date.now() / 1000); + document.querySelectorAll('.q-ttl[data-deadline]').forEach((node) => { + const deadline = Number(node.getAttribute('data-deadline')); + if (!Number.isFinite(deadline)) return; + node.textContent = formatTtl(deadline - now); + }); + }, 1000); diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index f8446107..93475b34 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -21,7 +21,7 @@ import { import { createTabStrip } from '@hive/shared/tabs.js'; import { containersState, syncContainersFromSnapshot, - questionsState, QUESTION_HISTORY_LIMIT, + questionsState, } from './state.js'; import { fmtAgo, truncate, fmtElapsed, fmtDuration } from './util.js'; import { @@ -37,6 +37,8 @@ import { refreshOperatorInbox, operatorInboxAppendFromEvent, operatorInboxCount, syncApprovalsFromSnapshot, applyApprovalAdded, applyApprovalResolved, renderApprovals, activeApprovalCount, + syncQuestionsFromSnapshot, applyQuestionAdded, applyQuestionResolved, + renderQuestions, activeQuestionCount, } from './call.js'; // mdNode (in common.js) reads `window.marked` for the markdown side @@ -1190,375 +1192,11 @@ window.marked = marked; parent.append(btn); } - // `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 - // unrelated question arrives while the operator is composing a reply. - const questionRowCache = new Map(); - function syncQuestionsFromSnapshot(s) { - questionsState.pending = (s.questions || []).slice(); - questionsState.history = (s.question_history || []).slice(); - } - function applyQuestionAdded(ev) { - if (questionsState.pending.some((q) => q.id === ev.id)) return; - questionsState.pending.push({ - id: ev.id, - asker: ev.asker, - question: ev.question, - options: ev.options || [], - multi: !!ev.multi, - asked_at: ev.asked_at, - deadline_at: ev.deadline_at ?? null, - target: ev.target || null, - question_refs: ev.question_refs || [], - }); - renderQuestions(); - renderContainersFromState(); - } - function applyQuestionResolved(ev) { - const idx = questionsState.pending.findIndex((q) => q.id === ev.id); - const existing = idx >= 0 ? questionsState.pending[idx] : null; - if (idx >= 0) questionsState.pending.splice(idx, 1); - // Idempotent: a snapshot re-sync (post-disconnect SSE catchup) can - // carry this same answered row in `question_history` while a live - // event also delivers it — guard the unshift so history can't - // double a row. - if (!questionsState.history.some((h) => h.id === ev.id)) { - questionsState.history.unshift({ - id: ev.id, - asker: existing?.asker || '?', - question: existing?.question || '', - options: existing?.options || [], - multi: existing?.multi || false, - asked_at: existing?.asked_at || ev.answered_at, - answered_at: ev.answered_at, - answer: ev.answer, - answerer: ev.answerer, - target: existing?.target ?? ev.target ?? null, - question_refs: existing?.question_refs || [], - answer_refs: ev.answer_refs || [], - }); - if (questionsState.history.length > QUESTION_HISTORY_LIMIT) { - questionsState.history.length = QUESTION_HISTORY_LIMIT; - } - } - renderQuestions(); - renderContainersFromState(); - } - // Filter selection for the questions section. Persisted so the - // operator's preferred view (all / operator-targeted / peer) - // survives a reload. - const QUESTIONS_FILTER_KEY = 'hyperhive:questions:filter'; - function getQuestionsFilter() { - return localStorage.getItem(QUESTIONS_FILTER_KEY) || 'all'; - } - function setQuestionsFilter(v) { - localStorage.setItem(QUESTIONS_FILTER_KEY, v); - renderQuestions(); - } - function questionMatchesFilter(q, filter) { - if (filter === 'all') return true; - if (filter === 'operator') return !q.target; - if (filter === 'peer') return !!q.target; - // `agent:` matches when the agent appears as asker OR target. - if (filter.startsWith('agent:')) { - const name = filter.slice('agent:'.length); - return q.asker === name || q.target === name; - } - return true; - } - // Serialise the fields that determine a pending-question
  • 's DOM - // structure. Used by questionRowCache to skip rebuilds when nothing - // visible has changed. deadline_at controls whether the TTL chip node - // exists at all (its text is kept current by the global 1s ticker). - function questionRowFingerprint(q) { - return JSON.stringify({ - asker: q.asker, target: q.target, asked_at: q.asked_at, - deadline_at: q.deadline_at, question: q.question, - question_refs: q.question_refs, options: q.options, multi: q.multi, - }); - } - - // Build a single pending-question
  • . Extracted from renderQuestions so - // questionRowCache can reuse unchanged nodes without re-running this body. - // Event listeners attached here (keydown on textarea, submit on form) are - // preserved in the reused node — no re-attachment needed. - function buildQuestionLi(q) { - const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19); - const targetLabel = q.target || 'operator'; - const li = el('li', { class: 'question' + (q.target ? ' question-peer' : '') }); - const head = el('div', { class: 'q-head' }, - el('span', { class: 'msg-ts' }, fmt(q.asked_at)), ' ', - el('span', { class: 'msg-from' }, q.asker), ' ', - el('span', { class: 'msg-sep' }, '→'), ' ', - el('span', { class: q.target ? 'msg-to msg-to-peer' : 'msg-to' }, targetLabel), ' ', - el('span', { class: 'msg-sep' }, 'asks:'), - ); - if (q.deadline_at) { - // Tag the chip with its deadline so the global 1s ticker - // can refresh the text without re-rendering the questions section. - const ttlEl = el('span', { - class: 'q-ttl', 'data-deadline': String(q.deadline_at), - }); - ttlEl.textContent = formatTtl( - q.deadline_at - Math.floor(Date.now() / 1000), - ); - head.append(' ', ttlEl); - } - const qBody = el('div', { class: 'q-body' }); - appendLinkified(qBody, q.question, q.question_refs); - li.append(head, qBody); - const f = el('form', { - method: 'POST', action: '/answer-question/' + q.id, - class: 'qform', 'data-async': '', 'data-no-refresh': '', - }); - const hasOptions = q.options && q.options.length; - const isMulti = !!q.multi && hasOptions; - const freeText = el('textarea', { - name: 'answer-free', rows: '2', autocomplete: 'off', - placeholder: (hasOptions ? 'or type your own…' : 'your answer') - + ' (shift+enter for newline)', - }); - // Enter submits; shift+enter inserts a newline (textarea default). - freeText.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - f.requestSubmit(); - } - }); - const optionGroup = el('div', { class: 'q-options' }); - if (hasOptions) { - for (const opt of q.options) { - const inputType = isMulti ? 'checkbox' : 'radio'; - const id = 'q' + q.id + '-' + Math.random().toString(36).slice(2, 8); - const input = el('input', { type: inputType, name: 'choice', value: opt, id }); - const label = el('label', { for: id }, ' ' + opt); - optionGroup.append(el('div', { class: 'q-option' }, input, label)); - } - } - // On submit, build the final `answer` field from selected - // options + free-text, joined by ', '. This lets the operator - // pick options AND add free text in the same form. - f.addEventListener('submit', (ev) => { - const parts = []; - for (const cb of f.querySelectorAll('input[name="choice"]:checked')) { - parts.push(cb.value); - } - const ft = (freeText.value || '').trim(); - if (ft) parts.push(ft); - const merged = parts.join(', '); - // Replace the existing hidden `answer` (if any) with the merged value. - const existing = f.querySelector('input[name="answer"]'); - if (existing) existing.remove(); - f.append(el('input', { type: 'hidden', name: 'answer', value: merged })); - if (!merged) { ev.preventDefault(); alert('pick an option or type an answer'); } - }, true); - if (hasOptions) f.append(optionGroup); - const buttons = el('div', { class: 'q-buttons' }); - // On peer threads the operator's answer is an override — - // mark the button so it's clear what the click does (the - // backend permits it via OperatorQuestions::answer's - // answerer-auth rule). - const answerLabel = q.target - ? (isMulti ? '⤿ 0V3RR1D3 · ' + q.options.length + ' opts' : '⤿ 0V3RR1D3') - : (isMulti ? '▸ ANSW3R · ' + q.options.length + ' opts' : '▸ ANSW3R'); - buttons.append( - el('button', { - type: 'submit', - class: 'btn btn-approve' + (q.target ? ' btn-override' : ''), - title: q.target ? `override-answer on behalf of operator (target was ${q.target})` : '', - }, answerLabel), - ); - f.append( - el('div', { class: 'q-free' }, freeText), - buttons, - ); - li.append(f); - // Separate form so the cancel button doesn't get the answer - // merge-on-submit handler attached to the main form. - const cancelTargetLabel = q.target ? q.target : 'asker'; - const cancelForm = el('form', { - method: 'POST', action: '/cancel-question/' + q.id, - class: 'qform-cancel', 'data-async': '', 'data-no-refresh': '', - 'data-confirm': `cancel this question? ${cancelTargetLabel} will see ` - + '"[cancelled]" as the answer.', - }); - cancelForm.append( - el('button', { type: 'submit', class: 'btn btn-deny' }, '✗ CANC3L'), - ); - li.append(cancelForm); - return li; - } - - function renderQuestions() { - const root = $('questions-section'); - // #questions-section only lives on /dashboard.html (Y3R C4LL tab); - // no-op when the section is missing. `question_added` / - // `question_resolved` SSE events route through here. - if (!root) return; - // Snapshot open
    state so SSE-triggered re-renders restore - // any expanded sections. The keyed-cache approach reuses question - //
  • nodes (preserving textarea/checkbox state) and only rebuilds - // cache-miss rows, so we no longer wipe the DOM at the start. - const openDetails = snapshotOpenDetails(); - const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19); - const allPending = questionsState.pending; - - // Filter chips. Always include `all` / `operator` / `peer`; add - // per-agent chips for any agent that appears as asker or target - // in the pending list so the operator can isolate a single - // thread without typing. - const participants = new Set(); - for (const q of allPending) { - participants.add(q.asker); - if (q.target) participants.add(q.target); - } - - // Auto-reset a stale per-agent filter: if the operator had `agent:foo` - // selected and all of foo's questions resolved, foo's chip disappears - // from the row. Without a reset the section would show "no questions - // match this filter" with no active chip visible — confusing. Fall back - // to `all` whenever the stored value is no longer a valid chip value. - let activeFilter = getQuestionsFilter(); - const validFilters = new Set(['all', 'operator', 'peer', - ...Array.from(participants).map((n) => 'agent:' + n)]); - if (!validFilters.has(activeFilter)) { - activeFilter = 'all'; - // Write directly to localStorage to avoid the re-render that - // setQuestionsFilter() triggers (we're already mid-render). - localStorage.setItem(QUESTIONS_FILTER_KEY, 'all'); - } - - const pending = allPending.filter((q) => questionMatchesFilter(q, activeFilter)); - - const filterRow = el('div', { class: 'questions-filters' }); - const mkChip = (value, label) => { - const b = el('button', { - type: 'button', - class: 'q-filter-chip' + (activeFilter === value ? ' active' : ''), - }, label); - b.addEventListener('click', () => setQuestionsFilter(value)); - return b; - }; - // Count pending questions per filter value so each chip shows its - // own hit count — the operator can see "operator · 2 / peer · 3" - // at a glance without clicking through each tab. - const operatorCount = allPending.filter((q) => !q.target).length; - const peerCount = allPending.filter((q) => !!q.target).length; - const agentCount = (name) => allPending.filter( - (q) => q.asker === name || q.target === name).length; - filterRow.append( - mkChip('all', `all · ${allPending.length}`), - mkChip('operator', `@operator · ${operatorCount}`), - mkChip('peer', `@peer · ${peerCount}`), - ); - for (const name of Array.from(participants).sort()) { - filterRow.append(mkChip('agent:' + name, `@${name} · ${agentCount(name)}`)); - } - - // Evict resolved/cancelled questions from the row cache. - const allPendingIds = new Set(allPending.map((q) => q.id)); - for (const id of questionRowCache.keys()) { - if (!allPendingIds.has(id)) questionRowCache.delete(id); - } - - // Build the ordered list of
  • elements, reusing cached nodes whose - // serialised state hasn't changed. This is what preserves textarea - // draft text and radio/checkbox selections across re-renders. - const orderedLis = pending.map((q) => { - const fp = questionRowFingerprint(q); - const cached = questionRowCache.get(q.id); - if (cached && cached.fingerprint === fp) return cached.el; - const li = buildQuestionLi(q); - questionRowCache.set(q.id, { el: li, fingerprint: fp }); - return li; - }); - - // Save the history
    open state before the DOM swap so it - // isn't collapsed every time a question arrives while it's open. - const historyWasOpen = root.querySelector('.q-history')?.open ?? false; - - const children = [filterRow]; - if (!pending.length) { - children.push(el('p', { class: 'empty' }, - activeFilter === 'all' ? 'no pending questions' : 'no questions match this filter')); - } else { - const ul = el('ul', { class: 'questions' }); - for (const li of orderedLis) ul.append(li); - children.push(ul); - } - - // Answered question history (read-only, no inputs — built fresh each render). - const hist = questionsState.history; - if (hist.length) { - const details = el('details', { class: 'q-history', 'data-restore-key': 'q-history' }); - details.append(el('summary', {}, '◆ answ3red (' + hist.length + ')')); - const hul = el('ul', { class: 'questions questions-answered' }); - for (const q of hist) { - const targetLabel = q.target || 'operator'; - const li = el('li', { class: 'question question-answered' + (q.target ? ' question-peer' : '') }); - const head = el('div', { class: 'q-head' }, - el('span', { class: 'msg-ts' }, fmt(q.answered_at)), ' ', - el('span', { class: 'msg-from' }, q.asker), ' ', - el('span', { class: 'msg-sep' }, '→'), ' ', - el('span', { class: q.target ? 'msg-to msg-to-peer' : 'msg-to' }, targetLabel), ' ', - el('span', { class: 'msg-sep' }, 'asked:'), - ); - const histBody = el('div', { class: 'q-body' }); - appendLinkified(histBody, q.question, q.question_refs); - const ansText = el('span', { class: 'q-answer-text' }); - appendLinkified(ansText, q.answer || '(none)', q.answer_refs); - const ansLine = el('div', { class: 'q-answer' }, - el('span', { class: 'msg-sep' }, `${q.answerer || '?'}: `), - ansText, - ); - li.append(head, histBody, ansLine); - hul.append(li); - } - details.append(hul); - children.push(details); - } - - root.replaceChildren(...children); - // Restore history open state after the DOM swap. - if (historyWasOpen) { - const histEl = root.querySelector('.q-history'); - if (histEl) histEl.open = true; - } - restoreOpenDetails(openDetails); - } - - // Format a remaining-seconds count as the `⏳ …` TTL chip text on a - // question card. Bucketed at minutes / hours so a long deadline stays - // readable; "expiring…" once the deadline has passed (the host-side - // ttl-watchdog will fire shortly). - function formatTtl(remaining) { - if (remaining <= 0) return 'expiring…'; - if (remaining < 60) return '⏳ ' + remaining + 's'; - if (remaining < 3600) { - return '⏳ ' + Math.floor(remaining / 60) + 'm ' - + (remaining % 60) + 's'; - } - return '⏳ ' + Math.floor(remaining / 3600) + 'h ' - + Math.floor((remaining % 3600) / 60) + 'm'; - } - - // Single page-wide ticker that refreshes every TTL chip in place - // each second. Renderers stamp `data-deadline` on the - // chip; this just updates `textContent`, no re-render of the - // questions section. No-op when no chips are on screen, so the - // cost is negligible. - setInterval(() => { - const now = Math.floor(Date.now() / 1000); - document.querySelectorAll('.q-ttl[data-deadline]').forEach((node) => { - const deadline = Number(node.getAttribute('data-deadline')); - if (!Number.isFinite(deadline)) return; - node.textContent = formatTtl(deadline - now); - }); - }, 1000); + // 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 @@ -1939,7 +1577,7 @@ window.marked = marked; // unread agent→operator messages. const callCount = activeApprovalCount() + - (questionsState?.pending?.length ?? 0) + + activeQuestionCount() + operatorInboxCount(); setTabCount('call', callCount); // Browser tab title prefix — lets the operator see the pending From c1873669613e512b80a6fe7970c2e48b4c7c4183 Mon Sep 17 00:00:00 2001 From: iris Date: Tue, 16 Jun 2026 09:56:02 +0200 Subject: [PATCH 3/6] dashboard: dedent call.js moved blocks to module top level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cosmetic follow-up to the Y3R C4LL extraction (argus nit on #1705): the approvals + questions sections kept their original 2-space IIFE indentation from tabs.js, which read as if they were inside a block and was inconsistent with the unindented operator-inbox section + the other domain modules. Uniform dedent — no logic change, build unaffected. --- frontend/packages/dashboard/src/call.js | 1288 +++++++++++------------ 1 file changed, 644 insertions(+), 644 deletions(-) diff --git a/frontend/packages/dashboard/src/call.js b/frontend/packages/dashboard/src/call.js index f0b68da3..d71f812e 100644 --- a/frontend/packages/dashboard/src/call.js +++ b/frontend/packages/dashboard/src/call.js @@ -99,684 +99,684 @@ export function operatorInboxAppendFromEvent(ev) { } // ─── 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 = { +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, - diff: ev.diff || null, + status: ev.status, + resolved_at: ev.resolved_at, + note: ev.note || 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); + }); + 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(); - } - 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; - } - } + }); + historyTab.addEventListener('click', () => { + localStorage.setItem(APPROVAL_TAB_KEY, 'history'); 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; + }); + tabs.append(pendingTab, historyTab); + root.append(tabs); + + if (active === 'history') { + renderApprovalHistory(root, history); + return; } - // 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'); + 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; - 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(); + 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' }); - // 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'), + // ── 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'), ); - root.append(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); - 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(); + // ── 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):', }); - historyTab.addEventListener('click', () => { - localStorage.setItem(APPROVAL_TAB_KEY, 'history'); - renderApprovals(); - }); - tabs.append(pendingTab, historyTab); - root.append(tabs); + 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, + )); - 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); + 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); +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); +} // ─── questions — the operator question queue (Y3R C4LL) ─────────────────── - // questionsState + QUESTION_HISTORY_LIMIT are imported from state.js (shared - // with the SW4RM per-agent question-count badges). - // 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 - // unrelated question arrives while the operator is composing a reply. - const questionRowCache = new Map(); - export function activeQuestionCount() { return questionsState.pending.length; } - export function syncQuestionsFromSnapshot(s) { - questionsState.pending = (s.questions || []).slice(); - questionsState.history = (s.question_history || []).slice(); - } - export function applyQuestionAdded(ev) { - if (questionsState.pending.some((q) => q.id === ev.id)) return; - questionsState.pending.push({ +// questionsState + QUESTION_HISTORY_LIMIT are imported from state.js (shared +// with the SW4RM per-agent question-count badges). +// 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 +// unrelated question arrives while the operator is composing a reply. +const questionRowCache = new Map(); +export function activeQuestionCount() { return questionsState.pending.length; } +export function syncQuestionsFromSnapshot(s) { + questionsState.pending = (s.questions || []).slice(); + questionsState.history = (s.question_history || []).slice(); +} +export function applyQuestionAdded(ev) { + if (questionsState.pending.some((q) => q.id === ev.id)) return; + questionsState.pending.push({ + id: ev.id, + asker: ev.asker, + question: ev.question, + options: ev.options || [], + multi: !!ev.multi, + asked_at: ev.asked_at, + deadline_at: ev.deadline_at ?? null, + target: ev.target || null, + question_refs: ev.question_refs || [], + }); + renderQuestions(); + renderContainersFromState(); +} +export function applyQuestionResolved(ev) { + const idx = questionsState.pending.findIndex((q) => q.id === ev.id); + const existing = idx >= 0 ? questionsState.pending[idx] : null; + if (idx >= 0) questionsState.pending.splice(idx, 1); + // Idempotent: a snapshot re-sync (post-disconnect SSE catchup) can + // carry this same answered row in `question_history` while a live + // event also delivers it — guard the unshift so history can't + // double a row. + if (!questionsState.history.some((h) => h.id === ev.id)) { + questionsState.history.unshift({ id: ev.id, - asker: ev.asker, - question: ev.question, - options: ev.options || [], - multi: !!ev.multi, - asked_at: ev.asked_at, - deadline_at: ev.deadline_at ?? null, - target: ev.target || null, - question_refs: ev.question_refs || [], + asker: existing?.asker || '?', + question: existing?.question || '', + options: existing?.options || [], + multi: existing?.multi || false, + asked_at: existing?.asked_at || ev.answered_at, + answered_at: ev.answered_at, + answer: ev.answer, + answerer: ev.answerer, + target: existing?.target ?? ev.target ?? null, + question_refs: existing?.question_refs || [], + answer_refs: ev.answer_refs || [], }); - renderQuestions(); - renderContainersFromState(); - } - export function applyQuestionResolved(ev) { - const idx = questionsState.pending.findIndex((q) => q.id === ev.id); - const existing = idx >= 0 ? questionsState.pending[idx] : null; - if (idx >= 0) questionsState.pending.splice(idx, 1); - // Idempotent: a snapshot re-sync (post-disconnect SSE catchup) can - // carry this same answered row in `question_history` while a live - // event also delivers it — guard the unshift so history can't - // double a row. - if (!questionsState.history.some((h) => h.id === ev.id)) { - questionsState.history.unshift({ - id: ev.id, - asker: existing?.asker || '?', - question: existing?.question || '', - options: existing?.options || [], - multi: existing?.multi || false, - asked_at: existing?.asked_at || ev.answered_at, - answered_at: ev.answered_at, - answer: ev.answer, - answerer: ev.answerer, - target: existing?.target ?? ev.target ?? null, - question_refs: existing?.question_refs || [], - answer_refs: ev.answer_refs || [], - }); - if (questionsState.history.length > QUESTION_HISTORY_LIMIT) { - questionsState.history.length = QUESTION_HISTORY_LIMIT; - } + if (questionsState.history.length > QUESTION_HISTORY_LIMIT) { + questionsState.history.length = QUESTION_HISTORY_LIMIT; } - renderQuestions(); - renderContainersFromState(); } - // Filter selection for the questions section. Persisted so the - // operator's preferred view (all / operator-targeted / peer) - // survives a reload. - const QUESTIONS_FILTER_KEY = 'hyperhive:questions:filter'; - function getQuestionsFilter() { - return localStorage.getItem(QUESTIONS_FILTER_KEY) || 'all'; + renderQuestions(); + renderContainersFromState(); +} +// Filter selection for the questions section. Persisted so the +// operator's preferred view (all / operator-targeted / peer) +// survives a reload. +const QUESTIONS_FILTER_KEY = 'hyperhive:questions:filter'; +function getQuestionsFilter() { + return localStorage.getItem(QUESTIONS_FILTER_KEY) || 'all'; +} +function setQuestionsFilter(v) { + localStorage.setItem(QUESTIONS_FILTER_KEY, v); + renderQuestions(); +} +function questionMatchesFilter(q, filter) { + if (filter === 'all') return true; + if (filter === 'operator') return !q.target; + if (filter === 'peer') return !!q.target; + // `agent:` matches when the agent appears as asker OR target. + if (filter.startsWith('agent:')) { + const name = filter.slice('agent:'.length); + return q.asker === name || q.target === name; } - function setQuestionsFilter(v) { - localStorage.setItem(QUESTIONS_FILTER_KEY, v); - renderQuestions(); - } - function questionMatchesFilter(q, filter) { - if (filter === 'all') return true; - if (filter === 'operator') return !q.target; - if (filter === 'peer') return !!q.target; - // `agent:` matches when the agent appears as asker OR target. - if (filter.startsWith('agent:')) { - const name = filter.slice('agent:'.length); - return q.asker === name || q.target === name; - } - return true; - } - // Serialise the fields that determine a pending-question
  • 's DOM - // structure. Used by questionRowCache to skip rebuilds when nothing - // visible has changed. deadline_at controls whether the TTL chip node - // exists at all (its text is kept current by the global 1s ticker). - function questionRowFingerprint(q) { - return JSON.stringify({ - asker: q.asker, target: q.target, asked_at: q.asked_at, - deadline_at: q.deadline_at, question: q.question, - question_refs: q.question_refs, options: q.options, multi: q.multi, + return true; +} +// Serialise the fields that determine a pending-question
  • 's DOM +// structure. Used by questionRowCache to skip rebuilds when nothing +// visible has changed. deadline_at controls whether the TTL chip node +// exists at all (its text is kept current by the global 1s ticker). +function questionRowFingerprint(q) { + return JSON.stringify({ + asker: q.asker, target: q.target, asked_at: q.asked_at, + deadline_at: q.deadline_at, question: q.question, + question_refs: q.question_refs, options: q.options, multi: q.multi, + }); +} + +// Build a single pending-question
  • . Extracted from renderQuestions so +// questionRowCache can reuse unchanged nodes without re-running this body. +// Event listeners attached here (keydown on textarea, submit on form) are +// preserved in the reused node — no re-attachment needed. +function buildQuestionLi(q) { + const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19); + const targetLabel = q.target || 'operator'; + const li = el('li', { class: 'question' + (q.target ? ' question-peer' : '') }); + const head = el('div', { class: 'q-head' }, + el('span', { class: 'msg-ts' }, fmt(q.asked_at)), ' ', + el('span', { class: 'msg-from' }, q.asker), ' ', + el('span', { class: 'msg-sep' }, '→'), ' ', + el('span', { class: q.target ? 'msg-to msg-to-peer' : 'msg-to' }, targetLabel), ' ', + el('span', { class: 'msg-sep' }, 'asks:'), + ); + if (q.deadline_at) { + // Tag the chip with its deadline so the global 1s ticker + // can refresh the text without re-rendering the questions section. + const ttlEl = el('span', { + class: 'q-ttl', 'data-deadline': String(q.deadline_at), }); + ttlEl.textContent = formatTtl( + q.deadline_at - Math.floor(Date.now() / 1000), + ); + head.append(' ', ttlEl); + } + const qBody = el('div', { class: 'q-body' }); + appendLinkified(qBody, q.question, q.question_refs); + li.append(head, qBody); + const f = el('form', { + method: 'POST', action: '/answer-question/' + q.id, + class: 'qform', 'data-async': '', 'data-no-refresh': '', + }); + const hasOptions = q.options && q.options.length; + const isMulti = !!q.multi && hasOptions; + const freeText = el('textarea', { + name: 'answer-free', rows: '2', autocomplete: 'off', + placeholder: (hasOptions ? 'or type your own…' : 'your answer') + + ' (shift+enter for newline)', + }); + // Enter submits; shift+enter inserts a newline (textarea default). + freeText.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + f.requestSubmit(); + } + }); + const optionGroup = el('div', { class: 'q-options' }); + if (hasOptions) { + for (const opt of q.options) { + const inputType = isMulti ? 'checkbox' : 'radio'; + const id = 'q' + q.id + '-' + Math.random().toString(36).slice(2, 8); + const input = el('input', { type: inputType, name: 'choice', value: opt, id }); + const label = el('label', { for: id }, ' ' + opt); + optionGroup.append(el('div', { class: 'q-option' }, input, label)); + } + } + // On submit, build the final `answer` field from selected + // options + free-text, joined by ', '. This lets the operator + // pick options AND add free text in the same form. + f.addEventListener('submit', (ev) => { + const parts = []; + for (const cb of f.querySelectorAll('input[name="choice"]:checked')) { + parts.push(cb.value); + } + const ft = (freeText.value || '').trim(); + if (ft) parts.push(ft); + const merged = parts.join(', '); + // Replace the existing hidden `answer` (if any) with the merged value. + const existing = f.querySelector('input[name="answer"]'); + if (existing) existing.remove(); + f.append(el('input', { type: 'hidden', name: 'answer', value: merged })); + if (!merged) { ev.preventDefault(); alert('pick an option or type an answer'); } + }, true); + if (hasOptions) f.append(optionGroup); + const buttons = el('div', { class: 'q-buttons' }); + // On peer threads the operator's answer is an override — + // mark the button so it's clear what the click does (the + // backend permits it via OperatorQuestions::answer's + // answerer-auth rule). + const answerLabel = q.target + ? (isMulti ? '⤿ 0V3RR1D3 · ' + q.options.length + ' opts' : '⤿ 0V3RR1D3') + : (isMulti ? '▸ ANSW3R · ' + q.options.length + ' opts' : '▸ ANSW3R'); + buttons.append( + el('button', { + type: 'submit', + class: 'btn btn-approve' + (q.target ? ' btn-override' : ''), + title: q.target ? `override-answer on behalf of operator (target was ${q.target})` : '', + }, answerLabel), + ); + f.append( + el('div', { class: 'q-free' }, freeText), + buttons, + ); + li.append(f); + // Separate form so the cancel button doesn't get the answer + // merge-on-submit handler attached to the main form. + const cancelTargetLabel = q.target ? q.target : 'asker'; + const cancelForm = el('form', { + method: 'POST', action: '/cancel-question/' + q.id, + class: 'qform-cancel', 'data-async': '', 'data-no-refresh': '', + 'data-confirm': `cancel this question? ${cancelTargetLabel} will see ` + + '"[cancelled]" as the answer.', + }); + cancelForm.append( + el('button', { type: 'submit', class: 'btn btn-deny' }, '✗ CANC3L'), + ); + li.append(cancelForm); + return li; +} + +export function renderQuestions() { + const root = $('questions-section'); + // #questions-section only lives on /dashboard.html (Y3R C4LL tab); + // no-op when the section is missing. `question_added` / + // `question_resolved` SSE events route through here. + if (!root) return; + // Snapshot open
    state so SSE-triggered re-renders restore + // any expanded sections. The keyed-cache approach reuses question + //
  • nodes (preserving textarea/checkbox state) and only rebuilds + // cache-miss rows, so we no longer wipe the DOM at the start. + const openDetails = snapshotOpenDetails(); + const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19); + const allPending = questionsState.pending; + + // Filter chips. Always include `all` / `operator` / `peer`; add + // per-agent chips for any agent that appears as asker or target + // in the pending list so the operator can isolate a single + // thread without typing. + const participants = new Set(); + for (const q of allPending) { + participants.add(q.asker); + if (q.target) participants.add(q.target); } - // Build a single pending-question
  • . Extracted from renderQuestions so - // questionRowCache can reuse unchanged nodes without re-running this body. - // Event listeners attached here (keydown on textarea, submit on form) are - // preserved in the reused node — no re-attachment needed. - function buildQuestionLi(q) { - const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19); - const targetLabel = q.target || 'operator'; - const li = el('li', { class: 'question' + (q.target ? ' question-peer' : '') }); - const head = el('div', { class: 'q-head' }, - el('span', { class: 'msg-ts' }, fmt(q.asked_at)), ' ', - el('span', { class: 'msg-from' }, q.asker), ' ', - el('span', { class: 'msg-sep' }, '→'), ' ', - el('span', { class: q.target ? 'msg-to msg-to-peer' : 'msg-to' }, targetLabel), ' ', - el('span', { class: 'msg-sep' }, 'asks:'), - ); - if (q.deadline_at) { - // Tag the chip with its deadline so the global 1s ticker - // can refresh the text without re-rendering the questions section. - const ttlEl = el('span', { - class: 'q-ttl', 'data-deadline': String(q.deadline_at), - }); - ttlEl.textContent = formatTtl( - q.deadline_at - Math.floor(Date.now() / 1000), - ); - head.append(' ', ttlEl); - } - const qBody = el('div', { class: 'q-body' }); - appendLinkified(qBody, q.question, q.question_refs); - li.append(head, qBody); - const f = el('form', { - method: 'POST', action: '/answer-question/' + q.id, - class: 'qform', 'data-async': '', 'data-no-refresh': '', - }); - const hasOptions = q.options && q.options.length; - const isMulti = !!q.multi && hasOptions; - const freeText = el('textarea', { - name: 'answer-free', rows: '2', autocomplete: 'off', - placeholder: (hasOptions ? 'or type your own…' : 'your answer') - + ' (shift+enter for newline)', - }); - // Enter submits; shift+enter inserts a newline (textarea default). - freeText.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - f.requestSubmit(); - } - }); - const optionGroup = el('div', { class: 'q-options' }); - if (hasOptions) { - for (const opt of q.options) { - const inputType = isMulti ? 'checkbox' : 'radio'; - const id = 'q' + q.id + '-' + Math.random().toString(36).slice(2, 8); - const input = el('input', { type: inputType, name: 'choice', value: opt, id }); - const label = el('label', { for: id }, ' ' + opt); - optionGroup.append(el('div', { class: 'q-option' }, input, label)); - } - } - // On submit, build the final `answer` field from selected - // options + free-text, joined by ', '. This lets the operator - // pick options AND add free text in the same form. - f.addEventListener('submit', (ev) => { - const parts = []; - for (const cb of f.querySelectorAll('input[name="choice"]:checked')) { - parts.push(cb.value); - } - const ft = (freeText.value || '').trim(); - if (ft) parts.push(ft); - const merged = parts.join(', '); - // Replace the existing hidden `answer` (if any) with the merged value. - const existing = f.querySelector('input[name="answer"]'); - if (existing) existing.remove(); - f.append(el('input', { type: 'hidden', name: 'answer', value: merged })); - if (!merged) { ev.preventDefault(); alert('pick an option or type an answer'); } - }, true); - if (hasOptions) f.append(optionGroup); - const buttons = el('div', { class: 'q-buttons' }); - // On peer threads the operator's answer is an override — - // mark the button so it's clear what the click does (the - // backend permits it via OperatorQuestions::answer's - // answerer-auth rule). - const answerLabel = q.target - ? (isMulti ? '⤿ 0V3RR1D3 · ' + q.options.length + ' opts' : '⤿ 0V3RR1D3') - : (isMulti ? '▸ ANSW3R · ' + q.options.length + ' opts' : '▸ ANSW3R'); - buttons.append( - el('button', { - type: 'submit', - class: 'btn btn-approve' + (q.target ? ' btn-override' : ''), - title: q.target ? `override-answer on behalf of operator (target was ${q.target})` : '', - }, answerLabel), - ); - f.append( - el('div', { class: 'q-free' }, freeText), - buttons, - ); - li.append(f); - // Separate form so the cancel button doesn't get the answer - // merge-on-submit handler attached to the main form. - const cancelTargetLabel = q.target ? q.target : 'asker'; - const cancelForm = el('form', { - method: 'POST', action: '/cancel-question/' + q.id, - class: 'qform-cancel', 'data-async': '', 'data-no-refresh': '', - 'data-confirm': `cancel this question? ${cancelTargetLabel} will see ` - + '"[cancelled]" as the answer.', - }); - cancelForm.append( - el('button', { type: 'submit', class: 'btn btn-deny' }, '✗ CANC3L'), - ); - li.append(cancelForm); + // Auto-reset a stale per-agent filter: if the operator had `agent:foo` + // selected and all of foo's questions resolved, foo's chip disappears + // from the row. Without a reset the section would show "no questions + // match this filter" with no active chip visible — confusing. Fall back + // to `all` whenever the stored value is no longer a valid chip value. + let activeFilter = getQuestionsFilter(); + const validFilters = new Set(['all', 'operator', 'peer', + ...Array.from(participants).map((n) => 'agent:' + n)]); + if (!validFilters.has(activeFilter)) { + activeFilter = 'all'; + // Write directly to localStorage to avoid the re-render that + // setQuestionsFilter() triggers (we're already mid-render). + localStorage.setItem(QUESTIONS_FILTER_KEY, 'all'); + } + + const pending = allPending.filter((q) => questionMatchesFilter(q, activeFilter)); + + const filterRow = el('div', { class: 'questions-filters' }); + const mkChip = (value, label) => { + const b = el('button', { + type: 'button', + class: 'q-filter-chip' + (activeFilter === value ? ' active' : ''), + }, label); + b.addEventListener('click', () => setQuestionsFilter(value)); + return b; + }; + // Count pending questions per filter value so each chip shows its + // own hit count — the operator can see "operator · 2 / peer · 3" + // at a glance without clicking through each tab. + const operatorCount = allPending.filter((q) => !q.target).length; + const peerCount = allPending.filter((q) => !!q.target).length; + const agentCount = (name) => allPending.filter( + (q) => q.asker === name || q.target === name).length; + filterRow.append( + mkChip('all', `all · ${allPending.length}`), + mkChip('operator', `@operator · ${operatorCount}`), + mkChip('peer', `@peer · ${peerCount}`), + ); + for (const name of Array.from(participants).sort()) { + filterRow.append(mkChip('agent:' + name, `@${name} · ${agentCount(name)}`)); + } + + // Evict resolved/cancelled questions from the row cache. + const allPendingIds = new Set(allPending.map((q) => q.id)); + for (const id of questionRowCache.keys()) { + if (!allPendingIds.has(id)) questionRowCache.delete(id); + } + + // Build the ordered list of
  • elements, reusing cached nodes whose + // serialised state hasn't changed. This is what preserves textarea + // draft text and radio/checkbox selections across re-renders. + const orderedLis = pending.map((q) => { + const fp = questionRowFingerprint(q); + const cached = questionRowCache.get(q.id); + if (cached && cached.fingerprint === fp) return cached.el; + const li = buildQuestionLi(q); + questionRowCache.set(q.id, { el: li, fingerprint: fp }); return li; + }); + + // Save the history
    open state before the DOM swap so it + // isn't collapsed every time a question arrives while it's open. + const historyWasOpen = root.querySelector('.q-history')?.open ?? false; + + const children = [filterRow]; + if (!pending.length) { + children.push(el('p', { class: 'empty' }, + activeFilter === 'all' ? 'no pending questions' : 'no questions match this filter')); + } else { + const ul = el('ul', { class: 'questions' }); + for (const li of orderedLis) ul.append(li); + children.push(ul); } - export function renderQuestions() { - const root = $('questions-section'); - // #questions-section only lives on /dashboard.html (Y3R C4LL tab); - // no-op when the section is missing. `question_added` / - // `question_resolved` SSE events route through here. - if (!root) return; - // Snapshot open
    state so SSE-triggered re-renders restore - // any expanded sections. The keyed-cache approach reuses question - //
  • nodes (preserving textarea/checkbox state) and only rebuilds - // cache-miss rows, so we no longer wipe the DOM at the start. - const openDetails = snapshotOpenDetails(); - const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19); - const allPending = questionsState.pending; - - // Filter chips. Always include `all` / `operator` / `peer`; add - // per-agent chips for any agent that appears as asker or target - // in the pending list so the operator can isolate a single - // thread without typing. - const participants = new Set(); - for (const q of allPending) { - participants.add(q.asker); - if (q.target) participants.add(q.target); + // Answered question history (read-only, no inputs — built fresh each render). + const hist = questionsState.history; + if (hist.length) { + const details = el('details', { class: 'q-history', 'data-restore-key': 'q-history' }); + details.append(el('summary', {}, '◆ answ3red (' + hist.length + ')')); + const hul = el('ul', { class: 'questions questions-answered' }); + for (const q of hist) { + const targetLabel = q.target || 'operator'; + const li = el('li', { class: 'question question-answered' + (q.target ? ' question-peer' : '') }); + const head = el('div', { class: 'q-head' }, + el('span', { class: 'msg-ts' }, fmt(q.answered_at)), ' ', + el('span', { class: 'msg-from' }, q.asker), ' ', + el('span', { class: 'msg-sep' }, '→'), ' ', + el('span', { class: q.target ? 'msg-to msg-to-peer' : 'msg-to' }, targetLabel), ' ', + el('span', { class: 'msg-sep' }, 'asked:'), + ); + const histBody = el('div', { class: 'q-body' }); + appendLinkified(histBody, q.question, q.question_refs); + const ansText = el('span', { class: 'q-answer-text' }); + appendLinkified(ansText, q.answer || '(none)', q.answer_refs); + const ansLine = el('div', { class: 'q-answer' }, + el('span', { class: 'msg-sep' }, `${q.answerer || '?'}: `), + ansText, + ); + li.append(head, histBody, ansLine); + hul.append(li); } - - // Auto-reset a stale per-agent filter: if the operator had `agent:foo` - // selected and all of foo's questions resolved, foo's chip disappears - // from the row. Without a reset the section would show "no questions - // match this filter" with no active chip visible — confusing. Fall back - // to `all` whenever the stored value is no longer a valid chip value. - let activeFilter = getQuestionsFilter(); - const validFilters = new Set(['all', 'operator', 'peer', - ...Array.from(participants).map((n) => 'agent:' + n)]); - if (!validFilters.has(activeFilter)) { - activeFilter = 'all'; - // Write directly to localStorage to avoid the re-render that - // setQuestionsFilter() triggers (we're already mid-render). - localStorage.setItem(QUESTIONS_FILTER_KEY, 'all'); - } - - const pending = allPending.filter((q) => questionMatchesFilter(q, activeFilter)); - - const filterRow = el('div', { class: 'questions-filters' }); - const mkChip = (value, label) => { - const b = el('button', { - type: 'button', - class: 'q-filter-chip' + (activeFilter === value ? ' active' : ''), - }, label); - b.addEventListener('click', () => setQuestionsFilter(value)); - return b; - }; - // Count pending questions per filter value so each chip shows its - // own hit count — the operator can see "operator · 2 / peer · 3" - // at a glance without clicking through each tab. - const operatorCount = allPending.filter((q) => !q.target).length; - const peerCount = allPending.filter((q) => !!q.target).length; - const agentCount = (name) => allPending.filter( - (q) => q.asker === name || q.target === name).length; - filterRow.append( - mkChip('all', `all · ${allPending.length}`), - mkChip('operator', `@operator · ${operatorCount}`), - mkChip('peer', `@peer · ${peerCount}`), - ); - for (const name of Array.from(participants).sort()) { - filterRow.append(mkChip('agent:' + name, `@${name} · ${agentCount(name)}`)); - } - - // Evict resolved/cancelled questions from the row cache. - const allPendingIds = new Set(allPending.map((q) => q.id)); - for (const id of questionRowCache.keys()) { - if (!allPendingIds.has(id)) questionRowCache.delete(id); - } - - // Build the ordered list of
  • elements, reusing cached nodes whose - // serialised state hasn't changed. This is what preserves textarea - // draft text and radio/checkbox selections across re-renders. - const orderedLis = pending.map((q) => { - const fp = questionRowFingerprint(q); - const cached = questionRowCache.get(q.id); - if (cached && cached.fingerprint === fp) return cached.el; - const li = buildQuestionLi(q); - questionRowCache.set(q.id, { el: li, fingerprint: fp }); - return li; - }); - - // Save the history
    open state before the DOM swap so it - // isn't collapsed every time a question arrives while it's open. - const historyWasOpen = root.querySelector('.q-history')?.open ?? false; - - const children = [filterRow]; - if (!pending.length) { - children.push(el('p', { class: 'empty' }, - activeFilter === 'all' ? 'no pending questions' : 'no questions match this filter')); - } else { - const ul = el('ul', { class: 'questions' }); - for (const li of orderedLis) ul.append(li); - children.push(ul); - } - - // Answered question history (read-only, no inputs — built fresh each render). - const hist = questionsState.history; - if (hist.length) { - const details = el('details', { class: 'q-history', 'data-restore-key': 'q-history' }); - details.append(el('summary', {}, '◆ answ3red (' + hist.length + ')')); - const hul = el('ul', { class: 'questions questions-answered' }); - for (const q of hist) { - const targetLabel = q.target || 'operator'; - const li = el('li', { class: 'question question-answered' + (q.target ? ' question-peer' : '') }); - const head = el('div', { class: 'q-head' }, - el('span', { class: 'msg-ts' }, fmt(q.answered_at)), ' ', - el('span', { class: 'msg-from' }, q.asker), ' ', - el('span', { class: 'msg-sep' }, '→'), ' ', - el('span', { class: q.target ? 'msg-to msg-to-peer' : 'msg-to' }, targetLabel), ' ', - el('span', { class: 'msg-sep' }, 'asked:'), - ); - const histBody = el('div', { class: 'q-body' }); - appendLinkified(histBody, q.question, q.question_refs); - const ansText = el('span', { class: 'q-answer-text' }); - appendLinkified(ansText, q.answer || '(none)', q.answer_refs); - const ansLine = el('div', { class: 'q-answer' }, - el('span', { class: 'msg-sep' }, `${q.answerer || '?'}: `), - ansText, - ); - li.append(head, histBody, ansLine); - hul.append(li); - } - details.append(hul); - children.push(details); - } - - root.replaceChildren(...children); - // Restore history open state after the DOM swap. - if (historyWasOpen) { - const histEl = root.querySelector('.q-history'); - if (histEl) histEl.open = true; - } - restoreOpenDetails(openDetails); + details.append(hul); + children.push(details); } - // Format a remaining-seconds count as the `⏳ …` TTL chip text on a - // question card. Bucketed at minutes / hours so a long deadline stays - // readable; "expiring…" once the deadline has passed (the host-side - // ttl-watchdog will fire shortly). - function formatTtl(remaining) { - if (remaining <= 0) return 'expiring…'; - if (remaining < 60) return '⏳ ' + remaining + 's'; - if (remaining < 3600) { - return '⏳ ' + Math.floor(remaining / 60) + 'm ' - + (remaining % 60) + 's'; - } - return '⏳ ' + Math.floor(remaining / 3600) + 'h ' - + Math.floor((remaining % 3600) / 60) + 'm'; + root.replaceChildren(...children); + // Restore history open state after the DOM swap. + if (historyWasOpen) { + const histEl = root.querySelector('.q-history'); + if (histEl) histEl.open = true; } + restoreOpenDetails(openDetails); +} - // Single page-wide ticker that refreshes every TTL chip in place - // each second. Renderers stamp `data-deadline` on the - // chip; this just updates `textContent`, no re-render of the - // questions section. No-op when no chips are on screen, so the - // cost is negligible. - setInterval(() => { - const now = Math.floor(Date.now() / 1000); - document.querySelectorAll('.q-ttl[data-deadline]').forEach((node) => { - const deadline = Number(node.getAttribute('data-deadline')); - if (!Number.isFinite(deadline)) return; - node.textContent = formatTtl(deadline - now); - }); - }, 1000); +// Format a remaining-seconds count as the `⏳ …` TTL chip text on a +// question card. Bucketed at minutes / hours so a long deadline stays +// readable; "expiring…" once the deadline has passed (the host-side +// ttl-watchdog will fire shortly). +function formatTtl(remaining) { + if (remaining <= 0) return 'expiring…'; + if (remaining < 60) return '⏳ ' + remaining + 's'; + if (remaining < 3600) { + return '⏳ ' + Math.floor(remaining / 60) + 'm ' + + (remaining % 60) + 's'; + } + return '⏳ ' + Math.floor(remaining / 3600) + 'h ' + + Math.floor((remaining % 3600) / 60) + 'm'; +} + +// Single page-wide ticker that refreshes every TTL chip in place +// each second. Renderers stamp `data-deadline` on the +// chip; this just updates `textContent`, no re-render of the +// questions section. No-op when no chips are on screen, so the +// cost is negligible. +setInterval(() => { + const now = Math.floor(Date.now() / 1000); + document.querySelectorAll('.q-ttl[data-deadline]').forEach((node) => { + const deadline = Number(node.getAttribute('data-deadline')); + if (!Number.isFinite(deadline)) return; + node.textContent = formatTtl(deadline - now); + }); +}, 1000); From 9293fe3ac9d5c787bd57a45ffbe2da92b91a121b Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 15 Jun 2026 23:17:32 +0200 Subject: [PATCH 4/6] =?UTF-8?q?dashboard:=20matrix-accounts=20page=20?= =?UTF-8?q?=E2=80=94=20provision/login=20per-agent=20external=20matrix=20a?= =?UTF-8?q?ccounts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New standalone H0M3 page (/matrix-accounts.html) and tile. An agent picker drives a list of that agent's configured matrix accounts (name, homeserver, token-stored status) and a provision form that logs in by password or stores an existing token. Frontend half of the per-agent external matrix-account provisioning work. Built against the v1 backend contract: GET /matrix-accounts?agent= -> { accounts: [ { name, homeserver, token_present } ] } POST /matrix-account-login (x-www-form-urlencoded, operator-auth) fields: agent, account, homeserver, mode=password|token, user_id?, password?, token? -> 2xx { ok, user_id } | 4xx { error } The token is never echoed back; secret inputs are cleared on submit. Token-status dot reflects token-stored, not live session (a true up/down indicator needs the daemon account registry, a follow-up). The form carries an experimental notice pending per-account failure isolation on the matrix daemon. Blocked from merge on the backend endpoints and the daemon failure-isolation fix; opening for review + to pin the UI/backend wire contract. --- docs/web-ui/dashboard.md | 31 +++ frontend/packages/dashboard/build.mjs | 6 +- frontend/packages/dashboard/src/index.html | 8 + .../dashboard/src/matrix-accounts.css | 87 +++++++++ .../dashboard/src/matrix-accounts.html | 85 +++++++++ .../packages/dashboard/src/matrix-accounts.js | 180 ++++++++++++++++++ 6 files changed, 394 insertions(+), 3 deletions(-) create mode 100644 frontend/packages/dashboard/src/matrix-accounts.css create mode 100644 frontend/packages/dashboard/src/matrix-accounts.html create mode 100644 frontend/packages/dashboard/src/matrix-accounts.js diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index 6b13add1..9609acd8 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -200,6 +200,37 @@ omitted — agents share the host netns, so there is no per-container net counter (per-agent network needs the netns-isolation roadmap in `docs/network.md`). +## M4TR1X ACC0UNTS page (`/matrix-accounts.html`) + +Operator surface to provision / log in a per-agent **external** matrix +account and store its access token, without editing the agent's config +repo. Standalone page reached from the **Matrix accounts** tile on the +H0M3 hub, same minimal chrome as `/core.html` (a `← home` back-link + +title). Its own esbuild bundle (`matrix-accounts.js`); no SSE — it reads +`/api/state` once for the agent picker and otherwise works off two +purpose-built endpoints. + +An agent picker (populated from `state.agents`) drives a list of that +agent's configured accounts — name, homeserver, and a token-status dot — +read from `GET /matrix-accounts?agent=` → +`{ accounts: [ { name, homeserver, token_present } ] }`. The status +reflects only whether a token is **stored** (labelled "token stored", +not "online"); a true live up/down indicator needs the matrix daemon's +account registry and is a follow-up. + +The provision form (account name, homeserver, login method) posts +`POST /matrix-account-login` (`x-www-form-urlencoded`, operator-auth): +fields `agent, account, homeserver, mode=password|token, user_id?, +password?, token?` → `2xx { ok, user_id }` on success or +`4xx { error }` on failure. The host coordinator performs the login +(password) or validates the token (`whoami`) and writes the bearer to +the agent's `matrixAccounts..tokenFile` via the same +privileged write path as the hive-internal `matrix-token`; the token is +**never** echoed back, and the page clears the secret inputs on submit +regardless of outcome. Because a bad credential can currently disturb +the agent's whole matrix session until per-account failure isolation +lands on the daemon, the form carries an explicit experimental notice. + ## P3RM1SS10NS tab Per-agent permission configuration. Two sections, each rendered as a diff --git a/frontend/packages/dashboard/build.mjs b/frontend/packages/dashboard/build.mjs index 6814cd62..551360d6 100644 --- a/frontend/packages/dashboard/build.mjs +++ b/frontend/packages/dashboard/build.mjs @@ -55,7 +55,7 @@ mkdirSync(staticDir(''), { recursive: true }); // follow-up once asset sizes warrant it). esbuild writes each entry // to `static/.js` based on the entryPoint basename. await build({ - entryPoints: [src('tabs.js'), src('flow.js'), src('logs.js'), src('home.js'), src('settings.js'), src('stats.js'), src('core.js')], + entryPoints: [src('tabs.js'), src('flow.js'), src('logs.js'), src('home.js'), src('settings.js'), src('stats.js'), src('core.js'), src('matrix-accounts.js')], outdir: staticDir(''), bundle: true, format: 'esm', @@ -94,7 +94,7 @@ await build({ // so a swap replaces only it) + theme.css (the semantic derivation // layer) + common.css (shared typography, badges, buttons, inbox, side // panel) plus its own page-specific bundle. -for (const entry of ['colors.css', 'theme.css', 'common.css', 'dashboard.css', 'flow.css', 'logs.css', 'home.css', 'settings.css', 'stats.css', 'core.css']) { +for (const entry of ['colors.css', 'theme.css', 'common.css', 'dashboard.css', 'flow.css', 'logs.css', 'home.css', 'settings.css', 'stats.css', 'core.css', 'matrix-accounts.css']) { await build({ entryPoints: [src(entry)], outfile: staticDir(entry), @@ -104,7 +104,7 @@ for (const entry of ['colors.css', 'theme.css', 'common.css', 'dashboard.css', ' }); } -for (const html of ['index.html', 'dashboard.html', 'flow.html', 'logs.html', 'settings.html', 'stats.html', 'core.html']) { +for (const html of ['index.html', 'dashboard.html', 'flow.html', 'logs.html', 'settings.html', 'stats.html', 'core.html', 'matrix-accounts.html']) { copyFileSync(src(html), dist(html)); } diff --git a/frontend/packages/dashboard/src/index.html b/frontend/packages/dashboard/src/index.html index 5c3c5932..2c7ad188 100644 --- a/frontend/packages/dashboard/src/index.html +++ b/frontend/packages/dashboard/src/index.html @@ -76,6 +76,14 @@ rebuild queue · meta inputs · kept state · container load + + + + Matrix accounts + + provision · log in · store per-agent matrix tokens + + + + +
    +

    provision or log in an external matrix account for an agent and store its access token. the token is written to the agent's matrixAccounts.<account>.tokenFile by the host coordinator — it is never displayed back on this page.

    + +
    + ⚠ experimental. a wrong password or token entered here can currently disrupt the target agent's whole matrix session until per-account failure isolation lands on the daemon. use with care on a live agent. +
    + +

    ◇ agent

    + + +

    ◇ configured accounts

    +

    status reflects whether a token is stored, not a live session — a true online/offline indicator is a follow-up that needs the daemon's account registry.

    +

    select an agent to see its matrix accounts.

    + +

    ◇ provision / log in

    +
    + + + +
    + login method + + +
    + +
    + + +
    + + + + +

    +
    +
    + + + + diff --git a/frontend/packages/dashboard/src/matrix-accounts.js b/frontend/packages/dashboard/src/matrix-accounts.js new file mode 100644 index 00000000..d9c1e053 --- /dev/null +++ b/frontend/packages/dashboard/src/matrix-accounts.js @@ -0,0 +1,180 @@ +// M4TR1X ACC0UNTS page entry (/matrix-accounts.html). +// +// Operator surface to provision / log in a per-agent EXTERNAL matrix +// account and store its access token, without editing the agent's config +// repo. Companion to the multi-account harness support. +// +// Backend contract (v1): +// GET /matrix-accounts?agent= +// -> { accounts: [ { name, homeserver, token_present: bool } ] } +// POST /matrix-account-login (x-www-form-urlencoded, operator-auth) +// fields: agent, account, homeserver, mode=password|token, +// user_id?, password?, token? +// -> 2xx { ok: true, user_id } on success +// -> 4xx { error: "" } on failure +// The token is NEVER echoed back in any response, and this page never +// re-renders a submitted secret. +// +// Live up/down (a true green/red dot) needs the daemon's account +// registry; until that follow-up lands the dot only reflects whether a +// token is STORED, labelled "token stored" rather than "online". +// +// Hard dependency: per-account failure isolation on the matrix daemon. +// Until that lands a bad credential entered here can crash the agent's +// whole matrix session, so the form carries an explicit experimental +// notice in the markup. + +import { $, el, esc, renderServerWarnings } from './common.js'; + +let agents = []; + +async function loadState() { + try { + const resp = await fetch('/api/state'); + if (!resp.ok) return; + const s = await resp.json(); + renderServerWarnings(s.server_warnings); + agents = (s.agents || []) + .map((a) => (typeof a === 'string' ? a : a && a.name)) + .filter(Boolean) + .sort(); + } catch { + // best-effort: on a failed state read the picker renders empty + // ("— no agents —") and the submit guard blocks until an agent is + // selected, rather than guessing a roster. + } +} + +function renderAgentPicker() { + const sel = $('ma-agent'); + sel.replaceChildren(); + if (!agents.length) { + sel.append(el('option', { value: '' }, '— no agents —')); + return; + } + sel.append(el('option', { value: '' }, '— select agent —')); + for (const a of agents) sel.append(el('option', { value: a }, a)); +} + +async function loadAccounts(agent) { + const list = $('ma-list'); + if (!agent) { + list.replaceChildren(el('p', { class: 'meta' }, 'select an agent to see its matrix accounts.')); + return; + } + list.replaceChildren(el('p', { class: 'meta' }, 'loading…')); + let data; + try { + const resp = await fetch('/matrix-accounts?agent=' + encodeURIComponent(agent)); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + data = await resp.json(); + } catch (err) { + list.replaceChildren(el('p', { class: 'err' }, + 'could not load accounts: ' + esc(String(err)) + ' (the backend endpoint may not be deployed yet).')); + return; + } + const accounts = data.accounts || []; + list.replaceChildren(); + if (!accounts.length) { + list.append(el('p', { class: 'meta' }, 'no matrix accounts configured for this agent.')); + return; + } + const ul = el('ul', { class: 'ma-accounts' }); + for (const acc of accounts) { + const present = !!acc.token_present; + ul.append(el('li', { class: 'ma-account' }, + el('span', { + class: 'ma-dot ' + (present ? 'ok' : 'absent'), + title: present ? 'token stored' : 'no token yet', + }), + el('span', { class: 'ma-name' }, acc.name || '(unnamed)'), + el('span', { class: 'ma-hs' }, acc.homeserver || '—'), + el('span', { class: 'ma-status ' + (present ? 'ok' : 'absent') }, + present ? 'token stored ✓' : 'no token'), + )); + } + list.append(ul); +} + +// Show only the fields for the selected login method, and DISABLE the +// hidden section's inputs so they don't ride along in the FormData (both +// sections carry a `user_id` field, so without this the wrong one — or +// both — would be submitted). +function toggleModeFields() { + const mode = document.querySelector('input[name="mode"]:checked'); + const value = mode ? mode.value : 'password'; + const pw = $('ma-pw-fields'); + const tok = $('ma-token-fields'); + pw.hidden = value !== 'password'; + tok.hidden = value !== 'token'; + pw.querySelectorAll('input').forEach((i) => { i.disabled = pw.hidden; }); + tok.querySelectorAll('input').forEach((i) => { i.disabled = tok.hidden; }); +} + +function clearSecrets(formEl) { + formEl.querySelectorAll('input[type="password"], input[name="token"]') + .forEach((i) => { i.value = ''; }); +} + +async function submitLogin(e) { + e.preventDefault(); + const formEl = e.target; + const out = $('ma-result'); + out.className = 'ma-result'; + out.textContent = ''; + + const agent = $('ma-agent').value; + if (!agent) { + out.className = 'ma-result err'; + out.textContent = 'select an agent first.'; + return; + } + + const fd = new FormData(formEl); + fd.set('agent', agent); + + const btn = formEl.querySelector('button[type="submit"]'); + const orig = btn.textContent; + btn.disabled = true; + btn.textContent = 'logging in…'; + + try { + const resp = await fetch('/matrix-account-login', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(fd), + }); + let body = {}; + try { body = await resp.json(); } catch { /* tolerate non-JSON error pages */ } + + if (resp.ok && body.ok) { + out.className = 'ma-result ok'; + out.textContent = '✓ logged in as ' + (body.user_id || '(unknown)') + ' — token stored.'; + clearSecrets(formEl); + loadAccounts(agent); + } else { + out.className = 'ma-result err'; + out.textContent = '✗ ' + (body.error || ('login failed (HTTP ' + resp.status + ')')); + clearSecrets(formEl); + } + } catch (err) { + out.className = 'ma-result err'; + out.textContent = '✗ request failed: ' + String(err) + ' (the backend endpoint may not be deployed yet).'; + } finally { + btn.disabled = false; + btn.textContent = orig; + } +} + +async function init() { + await loadState(); + renderAgentPicker(); + $('ma-agent').addEventListener('change', (e) => loadAccounts(e.target.value)); + document.querySelectorAll('input[name="mode"]') + .forEach((r) => r.addEventListener('change', toggleModeFields)); + toggleModeFields(); + $('ma-form').addEventListener('submit', submitLogin); + loadAccounts(''); +} + +init(); From 8bb3bd82a17caadb751500b1557ff0bf58b779b7 Mon Sep 17 00:00:00 2001 From: iris Date: Tue, 16 Jun 2026 09:42:11 +0200 Subject: [PATCH 5/6] dashboard: drop matrix-accounts experimental notice; relabel list as provisioned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-account token-failure isolation is now live on the matrix daemon, so a bad credential entered via this form can no longer take down the agent's whole matrix session — remove the experimental notice (+ its now-unused CSS). Also relabel the account list 'configured accounts' -> 'provisioned accounts' to match the v1 read path (lists accounts with a stored token; a config-declared-but-unprovisioned account appears once provisioned through the form). Docs + module header updated to match. --- docs/web-ui/dashboard.md | 6 +++--- frontend/packages/dashboard/src/matrix-accounts.css | 11 ----------- frontend/packages/dashboard/src/matrix-accounts.html | 8 ++------ frontend/packages/dashboard/src/matrix-accounts.js | 9 +++------ 4 files changed, 8 insertions(+), 26 deletions(-) diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index 9609acd8..06006d81 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -227,9 +227,9 @@ password?, token?` → `2xx { ok, user_id }` on success or the agent's `matrixAccounts..tokenFile` via the same privileged write path as the hive-internal `matrix-token`; the token is **never** echoed back, and the page clears the secret inputs on submit -regardless of outcome. Because a bad credential can currently disturb -the agent's whole matrix session until per-account failure isolation -lands on the daemon, the form carries an explicit experimental notice. +regardless of outcome. The account list reflects what is *provisioned* +(an account with a stored token), so a config-declared-but-unprovisioned +account appears only once it has been provisioned through the form. ## P3RM1SS10NS tab diff --git a/frontend/packages/dashboard/src/matrix-accounts.css b/frontend/packages/dashboard/src/matrix-accounts.css index 240e5942..8a253e6e 100644 --- a/frontend/packages/dashboard/src/matrix-accounts.css +++ b/frontend/packages/dashboard/src/matrix-accounts.css @@ -9,17 +9,6 @@ padding: 1rem 1.25rem 3rem; } -.ma-experimental { - border: 1px solid var(--amber); - background: color-mix(in srgb, var(--amber) 10%, transparent); - color: var(--fg); - padding: 0.6rem 0.85rem; - border-radius: 6px; - margin: 0.8rem 0 1.4rem; - font-size: 0.9rem; - line-height: 1.4; -} - .ma-field { display: flex; flex-direction: column; diff --git a/frontend/packages/dashboard/src/matrix-accounts.html b/frontend/packages/dashboard/src/matrix-accounts.html index 7aa6d516..3958a784 100644 --- a/frontend/packages/dashboard/src/matrix-accounts.html +++ b/frontend/packages/dashboard/src/matrix-accounts.html @@ -22,18 +22,14 @@

    provision or log in an external matrix account for an agent and store its access token. the token is written to the agent's matrixAccounts.<account>.tokenFile by the host coordinator — it is never displayed back on this page.

    -
    - ⚠ experimental. a wrong password or token entered here can currently disrupt the target agent's whole matrix session until per-account failure isolation lands on the daemon. use with care on a live agent. -
    -

    ◇ agent

    -

    ◇ configured accounts

    -

    status reflects whether a token is stored, not a live session — a true online/offline indicator is a follow-up that needs the daemon's account registry.

    +

    ◇ provisioned accounts

    +

    accounts that have a stored token (provision one below to add it here); a config-declared account that hasn't been provisioned yet won't appear until it has a token. status reflects whether a token is stored, not a live session — a true online/offline indicator is a follow-up that needs the daemon's account registry.

    select an agent to see its matrix accounts.

    ◇ provision / log in

    diff --git a/frontend/packages/dashboard/src/matrix-accounts.js b/frontend/packages/dashboard/src/matrix-accounts.js index d9c1e053..9fa354be 100644 --- a/frontend/packages/dashboard/src/matrix-accounts.js +++ b/frontend/packages/dashboard/src/matrix-accounts.js @@ -17,12 +17,9 @@ // // Live up/down (a true green/red dot) needs the daemon's account // registry; until that follow-up lands the dot only reflects whether a -// token is STORED, labelled "token stored" rather than "online". -// -// Hard dependency: per-account failure isolation on the matrix daemon. -// Until that lands a bad credential entered here can crash the agent's -// whole matrix session, so the form carries an explicit experimental -// notice in the markup. +// token is STORED, labelled "token stored" rather than "online". The +// account list shows what is provisioned (has a stored token), so a +// config-declared-but-unprovisioned account appears only once provisioned. import { $, el, esc, renderServerWarnings } from './common.js'; From 06d57a61f8165ba9ab341bbfe05039bb2d12298c Mon Sep 17 00:00:00 2001 From: iris Date: Tue, 16 Jun 2026 09:53:18 +0200 Subject: [PATCH 6/6] dashboard: fix matrix-accounts GET path to /api/matrix-accounts BE-1 (the list endpoint) lives at GET /api/matrix-accounts (reads go under /api/, matching /api/state and /api/operator-inbox); the page was calling the un-prefixed /matrix-accounts. Correct the fetch + the contract comment + the docs reference. POST /matrix-account-login (mutation, root path like /approve) is unchanged. --- docs/web-ui/dashboard.md | 2 +- frontend/packages/dashboard/src/matrix-accounts.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index 06006d81..f44c7bad 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -212,7 +212,7 @@ purpose-built endpoints. An agent picker (populated from `state.agents`) drives a list of that agent's configured accounts — name, homeserver, and a token-status dot — -read from `GET /matrix-accounts?agent=` → +read from `GET /api/matrix-accounts?agent=` → `{ accounts: [ { name, homeserver, token_present } ] }`. The status reflects only whether a token is **stored** (labelled "token stored", not "online"); a true live up/down indicator needs the matrix daemon's diff --git a/frontend/packages/dashboard/src/matrix-accounts.js b/frontend/packages/dashboard/src/matrix-accounts.js index 9fa354be..a71a2dd9 100644 --- a/frontend/packages/dashboard/src/matrix-accounts.js +++ b/frontend/packages/dashboard/src/matrix-accounts.js @@ -5,7 +5,7 @@ // repo. Companion to the multi-account harness support. // // Backend contract (v1): -// GET /matrix-accounts?agent= +// GET /api/matrix-accounts?agent= // -> { accounts: [ { name, homeserver, token_present: bool } ] } // POST /matrix-account-login (x-www-form-urlencoded, operator-auth) // fields: agent, account, homeserver, mode=password|token, @@ -62,7 +62,7 @@ async function loadAccounts(agent) { list.replaceChildren(el('p', { class: 'meta' }, 'loading…')); let data; try { - const resp = await fetch('/matrix-accounts?agent=' + encodeURIComponent(agent)); + const resp = await fetch('/api/matrix-accounts?agent=' + encodeURIComponent(agent)); if (!resp.ok) throw new Error('HTTP ' + resp.status); data = await resp.json(); } catch (err) {