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