// 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 { $, form, appendLinkified } from './common.js'; import { el } from '@hive/shared/dom.js'; import { themedToast } from '@hive/shared/modal.js'; import { epochSec, fmtAgo, fmtDuration } 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. let onCountsChanged = () => {}; // Re-render the SW4RM container rows (their per-agent question-count badges // read questionsState). `renderContainersFromState` is a tabs.js closure-local // — not in this module's scope — so the entry injects it here via `initCall`, // same pattern as `onCountsChanged`. Referencing it directly threw // `ReferenceError: renderContainersFromState is not defined` and aborted the // `question_added` / `question_resolved` SSE handlers. let onContainersDirty = () => {}; export function initCall(opts = {}) { if (typeof opts.onCountsChanged === 'function') onCountsChanged = opts.onCountsChanged; if (typeof opts.onContainersDirty === 'function') onContainersDirty = opts.onContainersDirty; } // ─── 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 = (ts) => new Date(ts).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, pr_number: ev.pr_number ?? 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(); } 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: '/api/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; // state.forge_public_url (set from services.hyperhive.forge.publicUrl) // or null — never guessed from ":3000". The PR-link builder // below already gates on forgeBase being truthy. const forgeBase = (fs && fs.forge_present && fs.forge_public_url) || null; const ul = el('ul', { class: 'approvals' }); for (const a of pending) { const isInit = a.kind === 'init_config'; const isMergePr = a.kind === 'merge_config_pr'; const isUpdateMeta = a.kind === 'update_meta_inputs'; const isSchedule = a.kind === 'schedule_prompt'; const li = el('li', { class: 'approval-card' }); // ── identity header ────────────────────────────────────────── const head = el('div', { class: 'approval-head' }, el('span', { class: 'glyph' }, isMergePr ? '⇒' : isUpdateMeta ? '↻' : isSchedule ? '⏱' : '⊕'), el('span', { class: 'id' }, '#' + a.id), el('span', { class: 'agent' }, a.agent), el('span', { class: 'kind' + ((isMergePr || isUpdateMeta || isSchedule) ? '' : ' kind-spawn') }, isMergePr ? 'merge-pr' : isUpdateMeta ? 'meta-update' : isSchedule ? 'schedule' : isInit ? 'init' : 'spawn'), ); if (isMergePr && 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 requestedSec = epochSec(a.requested_at); const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - requestedSec)); head.append(el('span', { class: 'approval-ts' + (ageSec >= 3600 ? ' stale' : ''), title: 'requested ' + new Date(a.requested_at).toLocaleString(), 'data-requested-at': String(requestedSec), }, '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 (isMergePr) { // PR-based config deploy: link to the reviewed PR on the forge. // The config diff lives on the forge PR itself. const drill = el('div', { class: 'drill-ins' }); if (forgeBase && a.pr_number != null) { drill.append(el('a', { class: 'panel-trigger', target: '_blank', rel: 'noopener', href: `${forgeBase}/agent-configs/${a.agent}/pulls/${a.pr_number}`, title: 'review this config PR on the hive forge', }, '↳ review PR on forge ↗')); } body.append(drill); } else if (isUpdateMeta) { let inputs; try { inputs = JSON.parse(a.commit_ref || '[]'); } catch (_) { inputs = []; } body.append(el('span', { class: 'meta' }, inputs.length ? 'bump flake inputs: ' + inputs.join(', ') : 'bump all flake inputs')); } else if (isSchedule) { let payload; try { payload = JSON.parse(a.commit_ref || '{}'); } catch (_) { payload = {}; } const targets = (payload.targets || []).join(', '); const firstFire = payload.first_fire_at_unix ? new Date(payload.first_fire_at_unix * 1000).toLocaleString() : '?'; const cadence = payload.interval_seconds ? ' · ↻ every ' + fmtDuration(payload.interval_seconds) : ' · one-shot'; body.append(el('div', { class: 'meta' }, '→ ' + targets + ' · first: ' + firstFire + cadence)); if (payload.body) { const excerpt = payload.body.length > 80 ? payload.body.slice(0, 80) + '…' : payload.body; body.append(el('div', { class: 'approval-description' }, excerpt)); } } else { body.append(el('span', { class: 'meta' }, isInit ? 'scaffold proposed config repo — submitting agent 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 submitting agent via // HelperEvent::ApprovalResolved { note }. const denyForm = el('form', { method: 'POST', action: '/api/deny/' + a.id, class: 'inline', 'data-async': '', 'data-no-refresh': '', 'data-prompt': 'reason for denying (optional, sent to submitter):', }); denyForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, 'DENY')); li.append(el('div', { class: 'approval-actions' }, form('/api/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 === 'merge_config_pr' ? 'merge-pr' : a.kind === 'update_meta_inputs' ? 'meta-update' : a.kind === 'schedule_prompt' ? 'schedule' : 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({ 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(); onContainersDirty(); } 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(); onContainersDirty(); } // 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 = (ts) => new Date(ts).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(epochSec(q.deadline_at)), }); ttlEl.textContent = formatTtl( epochSec(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: '/api/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(); themedToast('pick an option or type an answer', { type: 'error' }); } }, 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: '/api/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; } // Snapshot / restore open `
    ` state across a re-render, scoped to // one section root. renderQuestions only manages `#questions-section`, so it // keeps its own section-local pair rather than reaching into tabs.js's // `MANAGED_SECTION_IDS`-based helpers (which aren't in this module's scope — // referencing them threw `ReferenceError: snapshotOpenDetails is not defined` // and aborted refreshState). Sections that should survive a refresh carry a // stable `data-restore-key`. function snapshotOpenDetails(root) { const open = new Set(); for (const d of root.querySelectorAll('details[data-restore-key]')) { if (d.open) open.add(d.dataset.restoreKey); } return open; } function restoreOpenDetails(root, open) { if (!open.size) return; for (const d of root.querySelectorAll('details[data-restore-key]')) { if (open.has(d.dataset.restoreKey)) d.open = true; } } 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(root); const fmt = (ts) => new Date(ts).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(root, 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);