Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06d57a61f8 | ||
|
|
8bb3bd82a1 | ||
|
|
9293fe3ac9 | ||
|
|
c187366961 | ||
|
|
a0fc7e1a9f | ||
|
|
d0ec0d2896 |
9 changed files with 1197 additions and 752 deletions
|
|
@ -200,6 +200,37 @@ omitted — agents share the host netns, so there is no per-container net
|
|||
counter (per-agent network needs the netns-isolation roadmap in
|
||||
`docs/network.md`).
|
||||
|
||||
## M4TR1X ACC0UNTS page (`/matrix-accounts.html`)
|
||||
|
||||
Operator surface to provision / log in a per-agent **external** matrix
|
||||
account and store its access token, without editing the agent's config
|
||||
repo. Standalone page reached from the **Matrix accounts** tile on the
|
||||
H0M3 hub, same minimal chrome as `/core.html` (a `← home` back-link +
|
||||
title). Its own esbuild bundle (`matrix-accounts.js`); no SSE — it reads
|
||||
`/api/state` once for the agent picker and otherwise works off two
|
||||
purpose-built endpoints.
|
||||
|
||||
An agent picker (populated from `state.agents`) drives a list of that
|
||||
agent's configured accounts — name, homeserver, and a token-status dot —
|
||||
read from `GET /api/matrix-accounts?agent=<name>` →
|
||||
`{ accounts: [ { name, homeserver, token_present } ] }`. The status
|
||||
reflects only whether a token is **stored** (labelled "token stored",
|
||||
not "online"); a true live up/down indicator needs the matrix daemon's
|
||||
account registry and is a follow-up.
|
||||
|
||||
The provision form (account name, homeserver, login method) posts
|
||||
`POST /matrix-account-login` (`x-www-form-urlencoded`, operator-auth):
|
||||
fields `agent, account, homeserver, mode=password|token, user_id?,
|
||||
password?, token?` → `2xx { ok, user_id }` on success or
|
||||
`4xx { error }` on failure. The host coordinator performs the login
|
||||
(password) or validates the token (`whoami`) and writes the bearer to
|
||||
the agent's `matrixAccounts.<account>.tokenFile` via the same
|
||||
privileged write path as the hive-internal `matrix-token`; the token is
|
||||
**never** echoed back, and the page clears the secret inputs on submit
|
||||
regardless of outcome. The account list reflects what is *provisioned*
|
||||
(an account with a stored token), so a config-declared-but-unprovisioned
|
||||
account appears only once it has been provisioned through the form.
|
||||
|
||||
## P3RM1SS10NS tab
|
||||
|
||||
Per-agent permission configuration. Two sections, each rendered as a
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ mkdirSync(staticDir(''), { recursive: true });
|
|||
// follow-up once asset sizes warrant it). esbuild writes each entry
|
||||
// to `static/<name>.js` based on the entryPoint basename.
|
||||
await build({
|
||||
entryPoints: [src('tabs.js'), src('flow.js'), src('logs.js'), src('home.js'), src('settings.js'), src('stats.js'), src('core.js')],
|
||||
entryPoints: [src('tabs.js'), src('flow.js'), src('logs.js'), src('home.js'), src('settings.js'), src('stats.js'), src('core.js'), src('matrix-accounts.js')],
|
||||
outdir: staticDir(''),
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
|
|
@ -94,7 +94,7 @@ await build({
|
|||
// so a swap replaces only it) + theme.css (the semantic derivation
|
||||
// layer) + common.css (shared typography, badges, buttons, inbox, side
|
||||
// panel) plus its own page-specific bundle.
|
||||
for (const entry of ['colors.css', 'theme.css', 'common.css', 'dashboard.css', 'flow.css', 'logs.css', 'home.css', 'settings.css', 'stats.css', 'core.css']) {
|
||||
for (const entry of ['colors.css', 'theme.css', 'common.css', 'dashboard.css', 'flow.css', 'logs.css', 'home.css', 'settings.css', 'stats.css', 'core.css', 'matrix-accounts.css']) {
|
||||
await build({
|
||||
entryPoints: [src(entry)],
|
||||
outfile: staticDir(entry),
|
||||
|
|
@ -104,7 +104,7 @@ for (const entry of ['colors.css', 'theme.css', 'common.css', 'dashboard.css', '
|
|||
});
|
||||
}
|
||||
|
||||
for (const html of ['index.html', 'dashboard.html', 'flow.html', 'logs.html', 'settings.html', 'stats.html', 'core.html']) {
|
||||
for (const html of ['index.html', 'dashboard.html', 'flow.html', 'logs.html', 'settings.html', 'stats.html', 'core.html', 'matrix-accounts.html']) {
|
||||
copyFileSync(src(html), dist(html));
|
||||
}
|
||||
|
||||
|
|
|
|||
782
frontend/packages/dashboard/src/call.js
Normal file
782
frontend/packages/dashboard/src/call.js
Normal file
|
|
@ -0,0 +1,782 @@
|
|||
// 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';
|
||||
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 = () => {};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// ─── 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 <li> 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:<name>` 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 <li>'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 <li>. 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 <details> state so SSE-triggered re-renders restore
|
||||
// any expanded sections. The keyed-cache approach reuses question
|
||||
// <li> 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 <li> 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 <details> 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);
|
||||
|
|
@ -76,6 +76,14 @@
|
|||
<span class="home-tile-desc">rebuild queue · meta inputs · kept state · container load</span>
|
||||
</a>
|
||||
|
||||
<a class="home-tile" href="/matrix-accounts.html">
|
||||
<span class="home-tile-head">
|
||||
<span class="home-tile-icon" aria-hidden="true">🔑</span>
|
||||
<span class="home-tile-label">Matrix accounts</span>
|
||||
</span>
|
||||
<span class="home-tile-desc">provision · log in · store per-agent matrix tokens</span>
|
||||
</a>
|
||||
|
||||
<!-- Forge tile: hidden until home.js confirms the hive-forge
|
||||
container is up (state.forge_present); home.js also fills the
|
||||
href from state.forge_public_url (or the :3000 fallback), so
|
||||
|
|
|
|||
76
frontend/packages/dashboard/src/matrix-accounts.css
Normal file
76
frontend/packages/dashboard/src/matrix-accounts.css
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/* M4TR1X ACC0UNTS page (/matrix-accounts.html) only. Page chrome
|
||||
(.page-header / .page-back / .page-title) comes from the shared
|
||||
chrome.css imported by common.css; this file holds the account-list
|
||||
+ provision-form styling specific to this surface. */
|
||||
|
||||
.ma-main {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem 1.25rem 3rem;
|
||||
}
|
||||
|
||||
.ma-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin: 0.55rem 0;
|
||||
}
|
||||
.ma-field > span { font-size: 0.8rem; color: var(--muted); }
|
||||
.ma-field input,
|
||||
.ma-field select {
|
||||
padding: 0.4rem 0.5rem;
|
||||
background: var(--bg-elev);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font: inherit;
|
||||
}
|
||||
.ma-field input:focus,
|
||||
.ma-field select:focus {
|
||||
outline: none;
|
||||
border-color: var(--purple);
|
||||
}
|
||||
|
||||
.ma-mode {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.45rem 0.75rem 0.6rem;
|
||||
margin: 0.85rem 0;
|
||||
}
|
||||
.ma-mode legend { font-size: 0.8rem; color: var(--muted); padding: 0 0.3rem; }
|
||||
.ma-mode label { margin-right: 1.3rem; cursor: pointer; }
|
||||
|
||||
.ma-modefields { margin: 0.4rem 0; }
|
||||
|
||||
.ma-list { margin: 0.5rem 0 1.2rem; }
|
||||
.ma-accounts { list-style: none; padding: 0; margin: 0; }
|
||||
.ma-account {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.45rem 0.2rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.ma-dot {
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
}
|
||||
.ma-dot.ok { background: var(--green); }
|
||||
.ma-dot.absent { background: var(--muted); }
|
||||
.ma-name { font-weight: 600; color: var(--fg); }
|
||||
.ma-hs { color: var(--muted); font-size: 0.85rem; }
|
||||
.ma-status { margin-left: auto; font-size: 0.8rem; }
|
||||
.ma-status.ok { color: var(--green); }
|
||||
.ma-status.absent { color: var(--muted); }
|
||||
|
||||
.ma-result {
|
||||
margin-top: 0.7rem;
|
||||
font-size: 0.9rem;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
.ma-result.ok { color: var(--green); }
|
||||
.ma-result.err { color: var(--red); }
|
||||
|
||||
.ma-list .err { color: var(--red); font-size: 0.9rem; }
|
||||
81
frontend/packages/dashboard/src/matrix-accounts.html
Normal file
81
frontend/packages/dashboard/src/matrix-accounts.html
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>hyperhive // M4TR1X ACC0UNTS</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="/static/colors.css">
|
||||
<link rel="stylesheet" href="/static/theme.css">
|
||||
<link rel="stylesheet" href="/static/common.css">
|
||||
<link rel="stylesheet" href="/static/matrix-accounts.css">
|
||||
</head>
|
||||
<body class="ma-shell">
|
||||
|
||||
<!-- Minimal chrome: back link + title, same pattern as settings.html /
|
||||
core.html. Back link points to the H0M3 hub (served at /). -->
|
||||
<header class="page-header">
|
||||
<a class="page-back" href="/">← home</a>
|
||||
<span class="page-title">M4TR1X ACC0UNTS</span>
|
||||
</header>
|
||||
|
||||
<main class="ma-main">
|
||||
<p class="meta">provision or log in an <strong>external</strong> matrix account for an agent and store its access token. the token is written to the agent's <code>matrixAccounts.<account>.tokenFile</code> by the host coordinator — it is never displayed back on this page.</p>
|
||||
|
||||
<h3>◇ agent</h3>
|
||||
<label class="ma-field">
|
||||
<span>agent</span>
|
||||
<select id="ma-agent"></select>
|
||||
</label>
|
||||
|
||||
<h3>◇ provisioned accounts</h3>
|
||||
<p class="meta">accounts that have a stored token (provision one below to add it here); a config-declared account that hasn't been provisioned yet won't appear until it has a token. status reflects whether a <em>token is stored</em>, not a live session — a true online/offline indicator is a follow-up that needs the daemon's account registry.</p>
|
||||
<div id="ma-list" class="ma-list"><p class="meta">select an agent to see its matrix accounts.</p></div>
|
||||
|
||||
<h3>◇ provision / log in</h3>
|
||||
<form id="ma-form" class="ma-form" autocomplete="off">
|
||||
<label class="ma-field">
|
||||
<span>account name</span>
|
||||
<input type="text" name="account" placeholder="e.g. public" required>
|
||||
</label>
|
||||
<label class="ma-field">
|
||||
<span>homeserver</span>
|
||||
<input type="text" name="homeserver" placeholder="https://matrix.org" required>
|
||||
</label>
|
||||
|
||||
<fieldset class="ma-mode">
|
||||
<legend>login method</legend>
|
||||
<label><input type="radio" name="mode" value="password" checked> password</label>
|
||||
<label><input type="radio" name="mode" value="token"> existing token</label>
|
||||
</fieldset>
|
||||
|
||||
<div id="ma-pw-fields" class="ma-modefields">
|
||||
<label class="ma-field">
|
||||
<span>user id</span>
|
||||
<input type="text" name="user_id" placeholder="@user:matrix.org" autocomplete="username">
|
||||
</label>
|
||||
<label class="ma-field">
|
||||
<span>password</span>
|
||||
<input type="password" name="password" autocomplete="new-password">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="ma-token-fields" class="ma-modefields" hidden>
|
||||
<label class="ma-field">
|
||||
<span>access token</span>
|
||||
<input type="password" name="token" autocomplete="off">
|
||||
</label>
|
||||
<label class="ma-field">
|
||||
<span>user id <span class="meta">(optional — derived via whoami)</span></span>
|
||||
<input type="text" name="user_id" placeholder="@user:matrix.org">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-spawn">log in & store token</button>
|
||||
<p id="ma-result" class="ma-result" aria-live="polite"></p>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/static/matrix-accounts.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
177
frontend/packages/dashboard/src/matrix-accounts.js
Normal file
177
frontend/packages/dashboard/src/matrix-accounts.js
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
// M4TR1X ACC0UNTS page entry (/matrix-accounts.html).
|
||||
//
|
||||
// Operator surface to provision / log in a per-agent EXTERNAL matrix
|
||||
// account and store its access token, without editing the agent's config
|
||||
// repo. Companion to the multi-account harness support.
|
||||
//
|
||||
// Backend contract (v1):
|
||||
// GET /api/matrix-accounts?agent=<name>
|
||||
// -> { accounts: [ { name, homeserver, token_present: bool } ] }
|
||||
// POST /matrix-account-login (x-www-form-urlencoded, operator-auth)
|
||||
// fields: agent, account, homeserver, mode=password|token,
|
||||
// user_id?, password?, token?
|
||||
// -> 2xx { ok: true, user_id } on success
|
||||
// -> 4xx { error: "<msg>" } on failure
|
||||
// The token is NEVER echoed back in any response, and this page never
|
||||
// re-renders a submitted secret.
|
||||
//
|
||||
// Live up/down (a true green/red dot) needs the daemon's account
|
||||
// registry; until that follow-up lands the dot only reflects whether a
|
||||
// token is STORED, labelled "token stored" rather than "online". The
|
||||
// account list shows what is provisioned (has a stored token), so a
|
||||
// config-declared-but-unprovisioned account appears only once provisioned.
|
||||
|
||||
import { $, el, esc, renderServerWarnings } from './common.js';
|
||||
|
||||
let agents = [];
|
||||
|
||||
async function loadState() {
|
||||
try {
|
||||
const resp = await fetch('/api/state');
|
||||
if (!resp.ok) return;
|
||||
const s = await resp.json();
|
||||
renderServerWarnings(s.server_warnings);
|
||||
agents = (s.agents || [])
|
||||
.map((a) => (typeof a === 'string' ? a : a && a.name))
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
} catch {
|
||||
// best-effort: on a failed state read the picker renders empty
|
||||
// ("— no agents —") and the submit guard blocks until an agent is
|
||||
// selected, rather than guessing a roster.
|
||||
}
|
||||
}
|
||||
|
||||
function renderAgentPicker() {
|
||||
const sel = $('ma-agent');
|
||||
sel.replaceChildren();
|
||||
if (!agents.length) {
|
||||
sel.append(el('option', { value: '' }, '— no agents —'));
|
||||
return;
|
||||
}
|
||||
sel.append(el('option', { value: '' }, '— select agent —'));
|
||||
for (const a of agents) sel.append(el('option', { value: a }, a));
|
||||
}
|
||||
|
||||
async function loadAccounts(agent) {
|
||||
const list = $('ma-list');
|
||||
if (!agent) {
|
||||
list.replaceChildren(el('p', { class: 'meta' }, 'select an agent to see its matrix accounts.'));
|
||||
return;
|
||||
}
|
||||
list.replaceChildren(el('p', { class: 'meta' }, 'loading…'));
|
||||
let data;
|
||||
try {
|
||||
const resp = await fetch('/api/matrix-accounts?agent=' + encodeURIComponent(agent));
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
data = await resp.json();
|
||||
} catch (err) {
|
||||
list.replaceChildren(el('p', { class: 'err' },
|
||||
'could not load accounts: ' + esc(String(err)) + ' (the backend endpoint may not be deployed yet).'));
|
||||
return;
|
||||
}
|
||||
const accounts = data.accounts || [];
|
||||
list.replaceChildren();
|
||||
if (!accounts.length) {
|
||||
list.append(el('p', { class: 'meta' }, 'no matrix accounts configured for this agent.'));
|
||||
return;
|
||||
}
|
||||
const ul = el('ul', { class: 'ma-accounts' });
|
||||
for (const acc of accounts) {
|
||||
const present = !!acc.token_present;
|
||||
ul.append(el('li', { class: 'ma-account' },
|
||||
el('span', {
|
||||
class: 'ma-dot ' + (present ? 'ok' : 'absent'),
|
||||
title: present ? 'token stored' : 'no token yet',
|
||||
}),
|
||||
el('span', { class: 'ma-name' }, acc.name || '(unnamed)'),
|
||||
el('span', { class: 'ma-hs' }, acc.homeserver || '—'),
|
||||
el('span', { class: 'ma-status ' + (present ? 'ok' : 'absent') },
|
||||
present ? 'token stored ✓' : 'no token'),
|
||||
));
|
||||
}
|
||||
list.append(ul);
|
||||
}
|
||||
|
||||
// Show only the fields for the selected login method, and DISABLE the
|
||||
// hidden section's inputs so they don't ride along in the FormData (both
|
||||
// sections carry a `user_id` field, so without this the wrong one — or
|
||||
// both — would be submitted).
|
||||
function toggleModeFields() {
|
||||
const mode = document.querySelector('input[name="mode"]:checked');
|
||||
const value = mode ? mode.value : 'password';
|
||||
const pw = $('ma-pw-fields');
|
||||
const tok = $('ma-token-fields');
|
||||
pw.hidden = value !== 'password';
|
||||
tok.hidden = value !== 'token';
|
||||
pw.querySelectorAll('input').forEach((i) => { i.disabled = pw.hidden; });
|
||||
tok.querySelectorAll('input').forEach((i) => { i.disabled = tok.hidden; });
|
||||
}
|
||||
|
||||
function clearSecrets(formEl) {
|
||||
formEl.querySelectorAll('input[type="password"], input[name="token"]')
|
||||
.forEach((i) => { i.value = ''; });
|
||||
}
|
||||
|
||||
async function submitLogin(e) {
|
||||
e.preventDefault();
|
||||
const formEl = e.target;
|
||||
const out = $('ma-result');
|
||||
out.className = 'ma-result';
|
||||
out.textContent = '';
|
||||
|
||||
const agent = $('ma-agent').value;
|
||||
if (!agent) {
|
||||
out.className = 'ma-result err';
|
||||
out.textContent = 'select an agent first.';
|
||||
return;
|
||||
}
|
||||
|
||||
const fd = new FormData(formEl);
|
||||
fd.set('agent', agent);
|
||||
|
||||
const btn = formEl.querySelector('button[type="submit"]');
|
||||
const orig = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'logging in…';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/matrix-account-login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams(fd),
|
||||
});
|
||||
let body = {};
|
||||
try { body = await resp.json(); } catch { /* tolerate non-JSON error pages */ }
|
||||
|
||||
if (resp.ok && body.ok) {
|
||||
out.className = 'ma-result ok';
|
||||
out.textContent = '✓ logged in as ' + (body.user_id || '(unknown)') + ' — token stored.';
|
||||
clearSecrets(formEl);
|
||||
loadAccounts(agent);
|
||||
} else {
|
||||
out.className = 'ma-result err';
|
||||
out.textContent = '✗ ' + (body.error || ('login failed (HTTP ' + resp.status + ')'));
|
||||
clearSecrets(formEl);
|
||||
}
|
||||
} catch (err) {
|
||||
out.className = 'ma-result err';
|
||||
out.textContent = '✗ request failed: ' + String(err) + ' (the backend endpoint may not be deployed yet).';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = orig;
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
await loadState();
|
||||
renderAgentPicker();
|
||||
$('ma-agent').addEventListener('change', (e) => loadAccounts(e.target.value));
|
||||
document.querySelectorAll('input[name="mode"]')
|
||||
.forEach((r) => r.addEventListener('change', toggleModeFields));
|
||||
toggleModeFields();
|
||||
$('ma-form').addEventListener('submit', submitLogin);
|
||||
loadAccounts('');
|
||||
}
|
||||
|
||||
init();
|
||||
|
|
@ -23,3 +23,13 @@ export function syncContainersFromSnapshot(s) {
|
|||
containersState.clear();
|
||||
for (const c of s.containers || []) containersState.set(c.name, c);
|
||||
}
|
||||
|
||||
// Derived question state — the other genuinely cross-domain store. Owned by
|
||||
// the Y3R C4LL questions domain (`call.js`: cold-loaded from /api/state, then
|
||||
// mutated live by `question_added` / `question_resolved` events), but also
|
||||
// read by the SW4RM container rows, which render per-agent asker/target
|
||||
// question-count badges off `questionsState.pending`. It lives here for the
|
||||
// same reason as `containersState`: a single source of truth both domains
|
||||
// import by reference rather than threading through call signatures.
|
||||
export const QUESTION_HISTORY_LIMIT = 20;
|
||||
export const questionsState = { pending: [], history: [] };
|
||||
|
|
|
|||
|
|
@ -19,7 +19,10 @@ import {
|
|||
openStream, renderServerWarnings, bindAsyncForms,
|
||||
} from './common.js';
|
||||
import { createTabStrip } from '@hive/shared/tabs.js';
|
||||
import { containersState, syncContainersFromSnapshot } from './state.js';
|
||||
import {
|
||||
containersState, syncContainersFromSnapshot,
|
||||
questionsState,
|
||||
} from './state.js';
|
||||
import { fmtAgo, truncate, fmtElapsed, fmtDuration } from './util.js';
|
||||
import {
|
||||
applyCapabilitiesChanged, applyToolGroupsChanged,
|
||||
|
|
@ -29,6 +32,14 @@ import {
|
|||
applySchedulesChanged, applyRemindersChanged,
|
||||
refreshSchedules, refreshReminders, activeScheduleCount,
|
||||
} from './schedules.js';
|
||||
import {
|
||||
initCall,
|
||||
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
|
||||
// panel preview path. Set it here on the dashboard entry so file
|
||||
|
|
@ -1181,376 +1192,11 @@ window.marked = marked;
|
|||
parent.append(btn);
|
||||
}
|
||||
|
||||
// Derived question state — cold-loaded from /api/state, then mutated
|
||||
// live by `question_added` / `question_resolved` dashboard events.
|
||||
const QUESTION_HISTORY_LIMIT = 20;
|
||||
const questionsState = { pending: [], history: [] };
|
||||
// Keyed row cache: question id → {el, fingerprint}. Allows renderQuestions
|
||||
// to reuse <li> 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:<name>` 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 <li>'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 <li>. 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 <details> state so SSE-triggered re-renders restore
|
||||
// any expanded sections. The keyed-cache approach reuses question
|
||||
// <li> 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 <li> 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 <details> 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
|
||||
|
|
@ -1595,125 +1241,6 @@ window.marked = marked;
|
|||
});
|
||||
}, 1000);
|
||||
|
||||
const APPROVAL_TAB_KEY = 'hyperhive:approvals:tab';
|
||||
// Derived approval state — cold-loaded from /api/state, then mutated
|
||||
// live by `approval_added` / `approval_resolved` dashboard events.
|
||||
// `pending` is the open queue (newest-first); `history` is the last
|
||||
// 30 resolved rows.
|
||||
const APPROVAL_HISTORY_LIMIT = 30;
|
||||
const approvalsState = { pending: [], history: [] };
|
||||
function syncApprovalsFromSnapshot(s) {
|
||||
approvalsState.pending = (s.approvals || []).slice();
|
||||
approvalsState.history = (s.approval_history || []).slice();
|
||||
}
|
||||
function applyApprovalAdded(ev) {
|
||||
// Upsert by id so a snapshot that already included the row (cold
|
||||
// load + event lands at the same tick) doesn't double it.
|
||||
const existing = approvalsState.pending.findIndex((a) => a.id === ev.id);
|
||||
const row = {
|
||||
id: ev.id,
|
||||
agent: ev.agent,
|
||||
kind: ev.approval_kind,
|
||||
sha_short: ev.sha_short || null,
|
||||
diff: ev.diff || null,
|
||||
description: ev.description || null,
|
||||
// The ApprovalAdded event carries no requested_at; a live-added
|
||||
// approval was queued just now, so client-now is accurate — and
|
||||
// consistent with how fmtAgo compares everything to client-now.
|
||||
// A later /api/state cold-load swaps in the server value.
|
||||
requested_at: ev.requested_at != null
|
||||
? ev.requested_at : Math.floor(Date.now() / 1000),
|
||||
};
|
||||
if (existing >= 0) approvalsState.pending[existing] = row;
|
||||
else approvalsState.pending.push(row);
|
||||
renderApprovals();
|
||||
}
|
||||
function applyApprovalResolved(ev) {
|
||||
// Drop from pending; prepend to history (newest-first), cap at 30.
|
||||
approvalsState.pending = approvalsState.pending.filter((a) => a.id !== ev.id);
|
||||
// Idempotent: a snapshot re-sync (post-disconnect SSE catchup) can
|
||||
// carry this same resolved row in `approval_history` while a live
|
||||
// event also delivers it — guard the unshift so history can't
|
||||
// double a row.
|
||||
if (!approvalsState.history.some((h) => h.id === ev.id)) {
|
||||
approvalsState.history.unshift({
|
||||
id: ev.id,
|
||||
agent: ev.agent,
|
||||
kind: ev.approval_kind,
|
||||
sha_short: ev.sha_short || null,
|
||||
status: ev.status,
|
||||
resolved_at: ev.resolved_at,
|
||||
note: ev.note || null,
|
||||
description: ev.description || null,
|
||||
});
|
||||
if (approvalsState.history.length > APPROVAL_HISTORY_LIMIT) {
|
||||
approvalsState.history.length = APPROVAL_HISTORY_LIMIT;
|
||||
}
|
||||
}
|
||||
renderApprovals();
|
||||
}
|
||||
// Classify each unified-diff line by its leading char so
|
||||
// `.diff-add` / `.diff-del` / `.diff-hunk` / `.diff-file` /
|
||||
// `.diff-ctx` colour the output. Built as text-only spans (no
|
||||
// innerHTML) so there's no HTML-escape surface.
|
||||
function buildDiffPre(text) {
|
||||
const pre = el('pre', { class: 'diff' });
|
||||
for (const raw of String(text).split('\n')) {
|
||||
let cls = 'diff-ctx';
|
||||
if (raw.startsWith('--- ') || raw.startsWith('+++ ')) cls = 'diff-file';
|
||||
else if (raw.startsWith('@')) cls = 'diff-hunk';
|
||||
else if (raw.startsWith('+')) cls = 'diff-add';
|
||||
else if (raw.startsWith('-')) cls = 'diff-del';
|
||||
const span = document.createElement('span');
|
||||
span.className = cls;
|
||||
span.textContent = raw + '\n';
|
||||
pre.appendChild(span);
|
||||
}
|
||||
return pre;
|
||||
}
|
||||
|
||||
// Open an approval's diff in the side panel with a 3-way base
|
||||
// toggle: vs applied (running tree), vs last-approved, vs previous
|
||||
// proposal. `applied` uses the diff already shipped on the approval
|
||||
// for instant paint; the other two fetch /api/approval-diff.
|
||||
function openDiffPanel(a) {
|
||||
const bases = [
|
||||
['applied', 'vs applied'],
|
||||
['approved', 'vs last-approved'],
|
||||
['previous', 'vs previous proposal'],
|
||||
];
|
||||
const tabs = el('div', { class: 'diff-base-tabs' });
|
||||
const host = el('div', { class: 'diff-host' });
|
||||
async function selectBase(base) {
|
||||
for (const btn of tabs.children) {
|
||||
btn.classList.toggle('active', btn.dataset.base === base);
|
||||
}
|
||||
if (base === 'applied' && a.diff != null) {
|
||||
host.replaceChildren(buildDiffPre(a.diff));
|
||||
return;
|
||||
}
|
||||
host.replaceChildren(el('div', { class: 'meta' }, 'loading…'));
|
||||
try {
|
||||
const resp = await fetch('/api/approval-diff/' + a.id + '?base=' + base);
|
||||
const text = await resp.text();
|
||||
host.replaceChildren(resp.ok
|
||||
? buildDiffPre(text)
|
||||
: el('div', { class: 'meta' }, 'error: ' + text));
|
||||
} catch (e) {
|
||||
host.replaceChildren(el('div', { class: 'meta' }, 'error: ' + e));
|
||||
}
|
||||
}
|
||||
for (const [base, label] of bases) {
|
||||
const btn = el('button',
|
||||
{ type: 'button', class: 'diff-base-tab', 'data-base': base }, label);
|
||||
btn.addEventListener('click', () => selectBase(base));
|
||||
tabs.append(btn);
|
||||
}
|
||||
const wrap = el('div', { class: 'diff-panel' }, tabs, host);
|
||||
Panel.open('diff · ' + a.agent + ' #' + a.id, wrap);
|
||||
selectBase('applied');
|
||||
}
|
||||
|
||||
// Peer hives: render link cards as a headline section under SW4RM
|
||||
// (state.peer_hives). Called on every state refresh. When nothing is
|
||||
// federated, the "P33R H1V3S" headline block is hidden entirely and a
|
||||
|
|
@ -1749,197 +1276,6 @@ window.marked = marked;
|
|||
root.append(ul);
|
||||
}
|
||||
|
||||
function renderApprovals() {
|
||||
const root = $('approvals-section');
|
||||
// #approvals-section only lives on /dashboard.html (Y3R C4LL tab);
|
||||
// no-op elsewhere — `approval_added` / `approval_resolved` SSE
|
||||
// events route through here on every page that loads the bundle.
|
||||
if (!root) return;
|
||||
// Save spawn form input + focus state before the DOM wipe so a live
|
||||
// approval_added/resolved event doesn't erase a partially-typed name
|
||||
// or steal focus from the operator.
|
||||
const savedSpawnName = root.querySelector('.spawnform input[name="name"]')?.value ?? '';
|
||||
const spawnHadFocus = document.activeElement === root.querySelector('.spawnform input[name="name"]');
|
||||
root.replaceChildren();
|
||||
|
||||
// Spawn request form: submitting it queues a Spawn approval that
|
||||
// lands in this same list, so the form belongs here rather than on
|
||||
// the containers list (the agent doesn't exist yet).
|
||||
const spawnNameInput = el('input', {
|
||||
name: 'name',
|
||||
placeholder: 'new agent name (≤9 chars)',
|
||||
maxlength: '9', required: '', autocomplete: 'off',
|
||||
});
|
||||
if (savedSpawnName) spawnNameInput.value = savedSpawnName;
|
||||
if (spawnHadFocus) spawnNameInput.focus();
|
||||
const spawn = el('form', {
|
||||
method: 'POST', action: '/request-spawn',
|
||||
class: 'spawnform', 'data-async': '', 'data-no-refresh': '',
|
||||
});
|
||||
spawn.append(
|
||||
spawnNameInput,
|
||||
el('button', { type: 'submit', class: 'btn btn-spawn' }, '◆ R3QU3ST SP4WN'),
|
||||
);
|
||||
root.append(spawn);
|
||||
|
||||
const pending = approvalsState.pending;
|
||||
const history = approvalsState.history;
|
||||
const active = localStorage.getItem(APPROVAL_TAB_KEY) || 'pending';
|
||||
const tabs = el('div', { class: 'approval-tabs' });
|
||||
const pendingTab = el(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: 'approval-tab' + (active === 'pending' ? ' active' : ''),
|
||||
},
|
||||
`pending · ${pending.length}`,
|
||||
);
|
||||
const historyTab = el(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: 'approval-tab' + (active === 'history' ? ' active' : ''),
|
||||
},
|
||||
`history · ${history.length}`,
|
||||
);
|
||||
pendingTab.addEventListener('click', () => {
|
||||
localStorage.setItem(APPROVAL_TAB_KEY, 'pending');
|
||||
renderApprovals();
|
||||
});
|
||||
historyTab.addEventListener('click', () => {
|
||||
localStorage.setItem(APPROVAL_TAB_KEY, 'history');
|
||||
renderApprovals();
|
||||
});
|
||||
tabs.append(pendingTab, historyTab);
|
||||
root.append(tabs);
|
||||
|
||||
if (active === 'history') {
|
||||
renderApprovalHistory(root, history);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pending.length) {
|
||||
root.append(el('p', { class: 'empty' }, 'queue empty'));
|
||||
return;
|
||||
}
|
||||
// forge link base — only when the hive-forge container is up.
|
||||
const fs = window.__hyperhive_state;
|
||||
const hostname = (fs && fs.hostname) || window.location.hostname;
|
||||
// Prefer state.forge_public_url (set when forge.behindGateway=true,
|
||||
// e.g. "https://forge.pr1ma.darkest.space") over the direct :3000 port.
|
||||
const forgeBase = (fs && fs.forge_present)
|
||||
? (fs.forge_public_url || `http://${hostname}:3000`)
|
||||
: null;
|
||||
|
||||
const ul = el('ul', { class: 'approvals' });
|
||||
for (const a of pending) {
|
||||
const isApply = a.kind === 'apply_commit';
|
||||
const isInit = a.kind === 'init_config';
|
||||
const li = el('li', { class: 'approval-card' });
|
||||
|
||||
// ── identity header ──────────────────────────────────────────
|
||||
const head = el('div', { class: 'approval-head' },
|
||||
el('span', { class: 'glyph' }, isApply ? '→' : '⊕'),
|
||||
el('span', { class: 'id' }, '#' + a.id),
|
||||
el('span', { class: 'agent' }, a.agent),
|
||||
el('span', { class: 'kind' + (isApply ? '' : ' kind-spawn') },
|
||||
isApply ? 'apply' : isInit ? 'init' : 'spawn'),
|
||||
);
|
||||
if (isApply && a.sha_short) head.append(el('code', {}, a.sha_short));
|
||||
// When the approval was requested — relative time, right-aligned.
|
||||
// Goes amber once it's been pending an hour so a stale request is
|
||||
// obvious at a glance (see docs/web-ui.md::Approval card).
|
||||
if (a.requested_at != null) {
|
||||
const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - a.requested_at));
|
||||
head.append(el('span', {
|
||||
class: 'approval-ts' + (ageSec >= 3600 ? ' stale' : ''),
|
||||
title: 'requested ' + new Date(a.requested_at * 1000).toLocaleString(),
|
||||
'data-requested-at': String(a.requested_at),
|
||||
}, 'requested ' + fmtAgo(a.requested_at)));
|
||||
}
|
||||
li.append(head);
|
||||
|
||||
// ── what-changed body ────────────────────────────────────────
|
||||
const body = el('div', { class: 'approval-body' });
|
||||
if (a.description) {
|
||||
body.append(el('div', { class: 'approval-description' }, a.description));
|
||||
}
|
||||
if (isApply) {
|
||||
const drill = el('div', { class: 'drill-ins' });
|
||||
const diffBtn = el('button', { type: 'button', class: 'panel-trigger' },
|
||||
'↳ view diff');
|
||||
diffBtn.addEventListener('click', () => openDiffPanel(a));
|
||||
drill.append(diffBtn);
|
||||
if (forgeBase && a.sha_short) {
|
||||
drill.append(el('a', {
|
||||
class: 'panel-trigger', target: '_blank', rel: 'noopener',
|
||||
href: `${forgeBase}/agent-configs/${a.agent}/commit/${a.sha_short}`,
|
||||
title: 'this proposal commit on the hive forge',
|
||||
}, '↳ commit on forge ↗'));
|
||||
}
|
||||
body.append(drill);
|
||||
} else {
|
||||
body.append(el('span', { class: 'meta' },
|
||||
isInit
|
||||
? 'scaffold proposed config repo — manager customises agent.nix before spawn'
|
||||
: 'new sub-agent — container will be created on approve'));
|
||||
}
|
||||
li.append(body);
|
||||
|
||||
// ── decision actions ─────────────────────────────────────────
|
||||
// Deny prompts the operator for an optional reason; the submit
|
||||
// handler stashes it into a hidden `note` input that rides along
|
||||
// on the POST and is surfaced to the manager via
|
||||
// HelperEvent::ApprovalResolved { note }.
|
||||
const denyForm = el('form', {
|
||||
method: 'POST', action: '/deny/' + a.id,
|
||||
class: 'inline', 'data-async': '', 'data-no-refresh': '',
|
||||
'data-prompt': 'reason for denying (optional, sent to manager):',
|
||||
});
|
||||
denyForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, 'DENY'));
|
||||
li.append(el('div', { class: 'approval-actions' },
|
||||
form('/approve/' + a.id, 'btn-approve', '◆ APPR0VE', null, {}, { noRefresh: true }),
|
||||
denyForm,
|
||||
));
|
||||
|
||||
ul.append(li);
|
||||
}
|
||||
root.append(ul);
|
||||
}
|
||||
|
||||
function renderApprovalHistory(root, history) {
|
||||
if (!history.length) {
|
||||
root.append(el('p', { class: 'empty' }, 'no resolved approvals yet'));
|
||||
return;
|
||||
}
|
||||
const ul = el('ul', { class: 'approvals approvals-history' });
|
||||
for (const a of history) {
|
||||
const li = el('li');
|
||||
const row = el('div', { class: 'row' });
|
||||
const glyph = a.status === 'approved' ? '✓'
|
||||
: a.status === 'denied' ? '✗'
|
||||
: a.status === 'cancelled' ? '⊘'
|
||||
: '⚠';
|
||||
row.append(
|
||||
el('span', { class: 'glyph glyph-' + a.status }, glyph), ' ',
|
||||
el('span', { class: 'id' }, '#' + a.id), ' ',
|
||||
el('span', { class: 'agent' }, a.agent), ' ',
|
||||
el('span', { class: 'kind' }, a.kind === 'apply_commit' ? 'apply' : a.kind === 'init_config' ? 'init' : 'spawn'), ' ',
|
||||
);
|
||||
if (a.sha_short) row.append(el('code', {}, a.sha_short), ' ');
|
||||
row.append(
|
||||
el('span', { class: 'status status-' + a.status }, a.status), ' ',
|
||||
el('span', { class: 'msg-ts' }, fmtAgo(a.resolved_at)),
|
||||
);
|
||||
li.append(row);
|
||||
if (a.note) {
|
||||
li.append(el('div', { class: 'history-note' }, a.note));
|
||||
}
|
||||
ul.append(li);
|
||||
}
|
||||
root.append(ul);
|
||||
}
|
||||
|
||||
// ─── state polling ──────────────────────────────────────────────────────
|
||||
let pollTimer = null;
|
||||
// Sections whose innerHTML gets blown away on each refresh. If the
|
||||
|
|
@ -2205,76 +1541,20 @@ window.marked = marked;
|
|||
// fires activateTab for the per-tab side-effects on every change.
|
||||
createTabStrip($('tabbar'), { defaultId: 'swarm', onShow: activateTab });
|
||||
|
||||
// Register the Y3R C4LL domain's count callback (call.js) — its live
|
||||
// mutations (inbox stream append, mark-read) trigger a tab-count refresh
|
||||
// through this instead of reaching back into the coordinator directly.
|
||||
initCall({ onCountsChanged: refreshTabCounts });
|
||||
|
||||
|
||||
// Tab count pills — pure derived data from the existing state
|
||||
// stores so SSE-driven updates flow through without extra plumbing.
|
||||
// Set `hidden` when the count is zero so the pill doesn't draw
|
||||
// attention to an empty room.
|
||||
// ─── operator inbox — unread agent→operator messages ────────────
|
||||
// The Y3R C4LL tab surfaces messages agents `send(to: "operator")` so
|
||||
// the operator stops missing them. Unread = broker rows to "operator"
|
||||
// with `acked_at IS NULL`; cold-loaded from `/api/operator-inbox`,
|
||||
// appended live from the broker `sent` stream, and cleared via the
|
||||
// existing per-recipient ack (`POST /api/agent/operator/mark-all-read`).
|
||||
// Count folds into the Y3R C4LL pill + browser-title prefix.
|
||||
let operatorInbox = []; // [{ id, from, body, at, file_refs }], newest-first
|
||||
async function refreshOperatorInbox() {
|
||||
try {
|
||||
const r = await fetch('/api/operator-inbox');
|
||||
if (r.ok) {
|
||||
const data = await r.json();
|
||||
operatorInbox = Array.isArray(data.messages) ? data.messages : [];
|
||||
}
|
||||
} catch { /* keep prior list on transient failure */ }
|
||||
renderOperatorInbox();
|
||||
refreshTabCounts();
|
||||
}
|
||||
function renderOperatorInbox() {
|
||||
const root = $('operator-inbox-section');
|
||||
if (!root) return;
|
||||
root.replaceChildren();
|
||||
if (!operatorInbox.length) {
|
||||
root.append(el('p', { class: 'meta' }, 'no unread messages'));
|
||||
return;
|
||||
}
|
||||
const mark = el('button', { type: 'button', class: 'btn', id: 'op-inbox-mark-read' },
|
||||
`✓ mark all read (${operatorInbox.length})`);
|
||||
mark.addEventListener('click', markOperatorInboxRead);
|
||||
root.append(el('div', { class: 'inbox-toolbar' }, mark));
|
||||
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
|
||||
const ul = el('ul', { class: 'inbox' });
|
||||
for (const m of operatorInbox) {
|
||||
const body = el('span', { class: 'msg-body' });
|
||||
appendLinkified(body, m.body, m.file_refs);
|
||||
ul.append(el('li', {},
|
||||
el('span', { class: 'msg-ts' }, fmt(m.at)), ' ',
|
||||
el('span', { class: 'msg-from' }, m.from), ' ',
|
||||
el('span', { class: 'msg-sep' }, '→ '),
|
||||
body,
|
||||
));
|
||||
}
|
||||
root.append(ul);
|
||||
}
|
||||
async function markOperatorInboxRead() {
|
||||
try { await fetch('/api/agent/operator/mark-all-read', { method: 'POST' }); }
|
||||
catch { /* best-effort; the next refresh reconciles */ }
|
||||
operatorInbox = [];
|
||||
renderOperatorInbox();
|
||||
refreshTabCounts();
|
||||
}
|
||||
// Live append from the broker stream — a `sent` frame addressed to
|
||||
// "operator". De-dupes on broker row id so a history/live overlap or
|
||||
// a refresh racing the stream doesn't double-list.
|
||||
function operatorInboxAppendFromEvent(ev) {
|
||||
if (ev.id != null && operatorInbox.some((m) => m.id === ev.id)) return;
|
||||
operatorInbox.unshift({
|
||||
id: ev.id, from: ev.from, body: ev.body, at: ev.at,
|
||||
file_refs: ev.file_refs || [],
|
||||
});
|
||||
if (operatorInbox.length > 100) operatorInbox.length = 100;
|
||||
renderOperatorInbox();
|
||||
refreshTabCounts();
|
||||
}
|
||||
// The operator inbox (unread agent→operator messages) now lives in
|
||||
// call.js — `refreshOperatorInbox`, `operatorInboxAppendFromEvent`, and
|
||||
// `operatorInboxCount` are imported above. It calls back through the
|
||||
// `onCountsChanged` callback registered via `initCall` at boot.
|
||||
|
||||
function setTabCount(tab, n) {
|
||||
const el_ = $('tab-count-' + tab);
|
||||
|
|
@ -2296,9 +1576,9 @@ window.marked = marked;
|
|||
// Y3R C4LL — pending approvals + operator-targeted questions +
|
||||
// unread agent→operator messages.
|
||||
const callCount =
|
||||
(approvalsState?.pending?.length ?? 0) +
|
||||
(questionsState?.pending?.length ?? 0) +
|
||||
operatorInbox.length;
|
||||
activeApprovalCount() +
|
||||
activeQuestionCount() +
|
||||
operatorInboxCount();
|
||||
setTabCount('call', callCount);
|
||||
// Browser tab title prefix — lets the operator see the pending
|
||||
// call count without switching to the window. Strips any existing
|
||||
|
|
|
|||
Loading…
Reference in a new issue