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);