From a0fc7e1a9f31b80a9e2b3a73bc5de83c8e3c10cc Mon Sep 17 00:00:00 2001 From: iris Date: Tue, 16 Jun 2026 09:49:58 +0200 Subject: [PATCH] =?UTF-8?q?dashboard:=20extract=20questions=20domain=20int?= =?UTF-8?q?o=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