agents constantly emit pointer strings to /agents/<n>/state/foo.md since broker bodies cap at 1 KiB. now those tokens linkify in the message flow, question bodies, answer text, and operator inbox; clicking expands an inline <details> that lazy-fetches via the new /api/state-file?path=... endpoint. endpoint allow-list: per-agent state dirs + shared docs, both in their container-mount form (/agents/<n>/state, /shared) and host form (/var/lib/hyperhive/...). 1 MiB read cap; canonicalises before the prefix check so `..` / symlinks can't escape. legacy bare `/state/...` is deliberately not matched — ambiguous from the host's perspective (we'd need to know which agent the message references to translate). agents should use the qualified form going forward.
1614 lines
65 KiB
JavaScript
1614 lines
65 KiB
JavaScript
// Dashboard SPA. Renders containers + approvals from `/api/state`, wires
|
|
// up async-form submission (URL-encoded POST + spinner + state refresh),
|
|
// and tails the unified dashboard event channel over `/dashboard/stream`.
|
|
|
|
(() => {
|
|
// ─── helpers ────────────────────────────────────────────────────────────
|
|
const $ = (id) => document.getElementById(id);
|
|
const esc = (s) => String(s).replace(/[&<>"]/g, (c) =>
|
|
({ '&':'&', '<':'<', '>':'>', '"':'"' }[c])
|
|
);
|
|
const el = (tag, attrs = {}, ...children) => {
|
|
const e = document.createElement(tag);
|
|
for (const [k, v] of Object.entries(attrs)) {
|
|
if (k === 'class') e.className = v;
|
|
else if (k === 'html') e.innerHTML = v;
|
|
else if (k.startsWith('data-')) e.setAttribute(k, v);
|
|
else e.setAttribute(k, v);
|
|
}
|
|
for (const c of children) {
|
|
if (c == null) continue;
|
|
e.append(c.nodeType ? c : document.createTextNode(c));
|
|
}
|
|
return e;
|
|
};
|
|
const form = (action, btnClass, btnLabel, confirmMsg, extra = {}, opts = {}) => {
|
|
const f = el('form', {
|
|
method: 'POST', action, class: 'inline', 'data-async': '',
|
|
...(confirmMsg ? { 'data-confirm': confirmMsg } : {}),
|
|
// Endpoints whose mutation fires a DashboardEvent (and whose
|
|
// derived store applies it live) opt out of the post-submit
|
|
// /api/state refetch. See the async-form handler.
|
|
...(opts.noRefresh ? { 'data-no-refresh': '' } : {}),
|
|
});
|
|
for (const [name, value] of Object.entries(extra)) {
|
|
f.append(el('input', { type: 'hidden', name, value }));
|
|
}
|
|
f.append(el('button', { type: 'submit', class: 'btn ' + btnClass }, btnLabel));
|
|
return f;
|
|
};
|
|
|
|
// ─── path linkification ─────────────────────────────────────────────────
|
|
// Agents constantly drop pointer strings into messages + question
|
|
// bodies (it's the 1 KiB-cap escape hatch). Anything matching the
|
|
// PATH_RE patterns becomes a clickable anchor; clicking expands an
|
|
// inline <details> with the file's contents, fetched lazily from
|
|
// /api/state-file. The legacy in-container `/state/...` prefix is
|
|
// deliberately not matched — it's ambiguous from the host's
|
|
// perspective (we'd need to know which agent the message is about
|
|
// to translate it). Prefer `/agents/<name>/state/...` in agent
|
|
// outputs and the link will resolve.
|
|
const PATH_RE = /(\/var\/lib\/hyperhive\/agents\/[\w.-]+\/state\/[\w./-]+|\/var\/lib\/hyperhive\/shared\/[\w./-]+|\/agents\/[\w.-]+\/state\/[\w./-]+|\/shared\/[\w./-]+)/g;
|
|
async function fetchStateFile(path) {
|
|
const resp = await fetch('/api/state-file?path=' + encodeURIComponent(path));
|
|
const text = await resp.text();
|
|
if (!resp.ok) throw new Error(text || ('HTTP ' + resp.status));
|
|
return text;
|
|
}
|
|
function makePathPreview(path) {
|
|
// Inline anchor + a sibling <details> that lazy-loads the file
|
|
// on first open. Caller appends both: the anchor inline with the
|
|
// surrounding text, the details as a block sibling after the
|
|
// line so the layout doesn't get awkward.
|
|
const anchor = el('a', {
|
|
href: '#', class: 'path-link', title: 'click to preview ' + path,
|
|
}, path);
|
|
const details = el('details', { class: 'path-preview' });
|
|
const summary = el('summary', {}, '↳ ' + path);
|
|
const pre = el('pre', { class: 'path-preview-body' }, '(fetching…)');
|
|
details.append(summary, pre);
|
|
let fetched = false;
|
|
async function doFetch() {
|
|
if (fetched) return;
|
|
fetched = true;
|
|
try {
|
|
pre.textContent = await fetchStateFile(path);
|
|
} catch (e) {
|
|
pre.textContent = 'error: ' + (e.message || e);
|
|
fetched = false; // allow retry on next open
|
|
}
|
|
}
|
|
details.addEventListener('toggle', () => { if (details.open) doFetch(); });
|
|
anchor.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
details.open = !details.open;
|
|
});
|
|
return { anchor, details };
|
|
}
|
|
// Append `text` to `parent` as a mix of text nodes + path anchors.
|
|
// Returns the array of generated `<details>` previews so the
|
|
// caller can append them as block siblings under the row.
|
|
function appendLinkified(parent, text) {
|
|
const previews = [];
|
|
if (text == null) return previews;
|
|
const str = String(text);
|
|
let lastIdx = 0;
|
|
PATH_RE.lastIndex = 0;
|
|
let m;
|
|
while ((m = PATH_RE.exec(str)) !== null) {
|
|
if (m.index > lastIdx) {
|
|
parent.appendChild(document.createTextNode(str.slice(lastIdx, m.index)));
|
|
}
|
|
const { anchor, details } = makePathPreview(m[0]);
|
|
parent.appendChild(anchor);
|
|
previews.push(details);
|
|
lastIdx = m.index + m[0].length;
|
|
}
|
|
if (lastIdx < str.length) {
|
|
parent.appendChild(document.createTextNode(str.slice(lastIdx)));
|
|
}
|
|
return previews;
|
|
}
|
|
|
|
// ─── browser notifications ──────────────────────────────────────────────
|
|
// Fires OS notifications on three operator-bound signals:
|
|
// - new approval landed in the queue
|
|
// - new operator question queued (ask, target IS NULL)
|
|
// - broker message sent `to: "operator"`
|
|
// permission grant is per-browser; a localStorage "muted" toggle lets
|
|
// the operator silence without revoking. Secure-context only (HTTPS /
|
|
// localhost) — on other origins the API is unavailable and we hide
|
|
// the controls.
|
|
const NOTIF = (() => {
|
|
const supported = typeof Notification !== 'undefined';
|
|
const MUTED_KEY = 'hyperhive.notify.muted';
|
|
const isMuted = () => localStorage.getItem(MUTED_KEY) === '1';
|
|
const setMuted = (v) => v
|
|
? localStorage.setItem(MUTED_KEY, '1')
|
|
: localStorage.removeItem(MUTED_KEY);
|
|
function renderControls() {
|
|
const enable = $('notif-enable');
|
|
const mute = $('notif-mute');
|
|
const unmute = $('notif-unmute');
|
|
const status = $('notif-status');
|
|
if (!enable || !mute || !unmute || !status) return;
|
|
if (!supported) {
|
|
enable.hidden = mute.hidden = unmute.hidden = true;
|
|
status.hidden = false;
|
|
status.textContent = 'notifications unsupported in this browser';
|
|
return;
|
|
}
|
|
const perm = Notification.permission;
|
|
enable.hidden = perm === 'granted';
|
|
mute.hidden = perm !== 'granted' || isMuted();
|
|
unmute.hidden = perm !== 'granted' || !isMuted();
|
|
status.hidden = perm !== 'denied';
|
|
if (perm === 'denied') status.textContent = 'notifications blocked — grant in site settings';
|
|
}
|
|
function bind() {
|
|
const enable = $('notif-enable');
|
|
const mute = $('notif-mute');
|
|
const unmute = $('notif-unmute');
|
|
if (!supported || !enable || !mute || !unmute) return;
|
|
enable.addEventListener('click', async () => {
|
|
await Notification.requestPermission();
|
|
renderControls();
|
|
});
|
|
mute.addEventListener('click', () => { setMuted(true); renderControls(); });
|
|
unmute.addEventListener('click', () => { setMuted(false); renderControls(); });
|
|
renderControls();
|
|
}
|
|
function show(title, body, tag) {
|
|
if (!supported) {
|
|
console.debug('notify: Notification API not supported');
|
|
return;
|
|
}
|
|
if (Notification.permission !== 'granted') {
|
|
console.debug('notify: permission not granted', Notification.permission);
|
|
return;
|
|
}
|
|
if (isMuted()) {
|
|
console.debug('notify: muted');
|
|
return;
|
|
}
|
|
try {
|
|
// Per-event tag so distinct messages stack instead of
|
|
// collapsing into one slot. Caller passes a unique tag per
|
|
// notification kind/id; we don't fall back to 'hyperhive'
|
|
// because that one tag would replace itself on every fire.
|
|
const n = new Notification(title, {
|
|
body,
|
|
tag: tag || ('hyperhive:' + Date.now()),
|
|
});
|
|
n.onclick = () => { window.focus(); n.close(); };
|
|
console.debug('notify: shown', title, 'tag=', tag);
|
|
} catch (err) {
|
|
console.warn('notification show failed', err);
|
|
}
|
|
}
|
|
return { bind, show, renderControls };
|
|
})();
|
|
|
|
// Track which items we've already notified about so a re-render
|
|
// doesn't re-fire for the same row. Keyed by stable ids; reset only
|
|
// when the page reloads.
|
|
const seenApprovals = new Set();
|
|
const seenQuestions = new Set();
|
|
let seededNotify = false;
|
|
|
|
function notifyDeltas(s) {
|
|
const approvals = s.approvals || [];
|
|
const questions = s.questions || [];
|
|
if (!seededNotify) {
|
|
// First render after page load — fill the "seen" sets without
|
|
// firing notifications. We only want to notify on NEW items
|
|
// that arrived while the page is open. The inbox no longer
|
|
// needs seeding here: it's derived from the broker stream which
|
|
// does its own per-event notification on live arrival, and
|
|
// history-replayed events are silent by virtue of `fromHistory`.
|
|
for (const a of approvals) seenApprovals.add(a.id);
|
|
for (const q of questions) seenQuestions.add(q.id);
|
|
seededNotify = true;
|
|
return;
|
|
}
|
|
for (const a of approvals) {
|
|
if (seenApprovals.has(a.id)) continue;
|
|
seenApprovals.add(a.id);
|
|
const verb = a.kind === 'spawn' ? 'spawn approval' : 'config commit';
|
|
NOTIF.show('◆ approval #' + a.id, `${verb} for ${a.agent}`,
|
|
'hyperhive:approval:' + a.id);
|
|
}
|
|
for (const q of questions) {
|
|
if (seenQuestions.has(q.id)) continue;
|
|
seenQuestions.add(q.id);
|
|
const targetLabel = q.target || 'operator';
|
|
NOTIF.show(`◆ ${q.asker} → ${targetLabel} asks`,
|
|
q.question.slice(0, 120),
|
|
'hyperhive:question:' + q.id);
|
|
}
|
|
}
|
|
|
|
// ─── async forms ────────────────────────────────────────────────────────
|
|
document.addEventListener('submit', async (e) => {
|
|
const f = e.target;
|
|
if (!(f instanceof HTMLFormElement) || !f.hasAttribute('data-async')) return;
|
|
e.preventDefault();
|
|
if (f.dataset.confirm && !confirm(f.dataset.confirm)) return;
|
|
if (f.dataset.prompt) {
|
|
const ans = prompt(f.dataset.prompt, '');
|
|
if (ans === null) return; // operator hit Cancel
|
|
// Drop into a hidden input named after `data-prompt-field` (or
|
|
// 'note' by default) so the value rides along on the POST.
|
|
const field = f.dataset.promptField || 'note';
|
|
let input = f.querySelector(`input[name="${field}"]`);
|
|
if (!input) {
|
|
input = document.createElement('input');
|
|
input.type = 'hidden';
|
|
input.name = field;
|
|
f.append(input);
|
|
}
|
|
input.value = ans;
|
|
}
|
|
const btn = f.querySelector('button[type="submit"], button:not([type]), .btn-inline');
|
|
const original = btn ? btn.innerHTML : '';
|
|
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner">◐</span>'; }
|
|
try {
|
|
const resp = await fetch(f.action, {
|
|
method: f.method || 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams(new FormData(f)),
|
|
redirect: 'manual',
|
|
});
|
|
const ok = resp.ok || resp.type === 'opaqueredirect'
|
|
|| (resp.status >= 200 && resp.status < 400);
|
|
if (!ok) {
|
|
const text = await resp.text().catch(() => '');
|
|
alert('action failed: ' + resp.status + (text ? '\n\n' + text : ''));
|
|
if (btn) { btn.disabled = false; btn.innerHTML = original; }
|
|
return;
|
|
}
|
|
// Re-enable the button — refreshState() rebuilds most lists but
|
|
// skips forms that didn't change (e.g. the spawn form), so without
|
|
// this the spinner sticks and the button can't be clicked again.
|
|
if (btn) { btn.disabled = false; btn.innerHTML = original; }
|
|
// Clear text inputs whose value was just submitted.
|
|
f.querySelectorAll('input[type="text"], input:not([type]), textarea').forEach((i) => { i.value = ''; });
|
|
// Forms whose endpoint already emits a DashboardEvent that
|
|
// updates the derived store can opt out of the post-submit
|
|
// /api/state refetch (the event delivers the new row faster
|
|
// than the snapshot poll anyway). Container-lifecycle forms
|
|
// still rely on the refresh since `ContainerView` isn't yet
|
|
// event-derivable.
|
|
if (!f.hasAttribute('data-no-refresh')) {
|
|
refreshState();
|
|
}
|
|
} catch (err) {
|
|
alert('action failed: ' + err);
|
|
if (btn) { btn.disabled = false; btn.innerHTML = original; }
|
|
}
|
|
});
|
|
|
|
// Derived container state — cold-loaded from /api/state.containers,
|
|
// then mutated live by `container_state_changed` (upsert by name)
|
|
// and `container_removed` (drop by name). The coordinator's rescan
|
|
// helper fires these after every mutation site + on a periodic poll
|
|
// in crash_watch. Keyed by ContainerView.name so the lifecycle
|
|
// forms' POST → 200 → matching event flips the row without a
|
|
// snapshot refetch.
|
|
const containersState = new Map();
|
|
function syncContainersFromSnapshot(s) {
|
|
containersState.clear();
|
|
for (const c of s.containers || []) containersState.set(c.name, c);
|
|
}
|
|
function applyContainerStateChanged(ev) {
|
|
if (!ev.container || !ev.container.name) return;
|
|
containersState.set(ev.container.name, ev.container);
|
|
renderContainersFromState();
|
|
}
|
|
function applyContainerRemoved(ev) {
|
|
if (containersState.delete(ev.name)) renderContainersFromState();
|
|
}
|
|
|
|
// Derived transient state — cold-loaded from /api/state.transients,
|
|
// then mutated live by `transient_set` / `transient_cleared`. Keyed
|
|
// by agent name so add/remove are O(1). `since_unix` is wall-clock so
|
|
// the elapsed-seconds badge ticks without polling.
|
|
const transientsState = new Map();
|
|
function syncTransientsFromSnapshot(s) {
|
|
transientsState.clear();
|
|
for (const t of s.transients || []) {
|
|
// Snapshot ships `secs` (server-computed); reconstruct an
|
|
// approximate since_unix so the live ticker keeps progressing
|
|
// without surprising jumps when the next snapshot lands.
|
|
const nowUnix = Math.floor(Date.now() / 1000);
|
|
transientsState.set(t.name, {
|
|
kind: t.kind,
|
|
since_unix: t.since_unix ?? (nowUnix - (t.secs || 0)),
|
|
});
|
|
}
|
|
}
|
|
function applyTransientSet(ev) {
|
|
transientsState.set(ev.name, {
|
|
kind: ev.transient_kind,
|
|
since_unix: ev.since_unix,
|
|
});
|
|
renderContainersFromState();
|
|
}
|
|
function applyTransientCleared(ev) {
|
|
if (transientsState.delete(ev.name)) renderContainersFromState();
|
|
}
|
|
// Re-render using the last cached snapshot (containers come from
|
|
// /api/state, transients overlay from the derived map). The snapshot
|
|
// is stashed on window.__hyperhive_state by refreshState; on cold
|
|
// load before the first snapshot we just skip.
|
|
function renderContainersFromState() {
|
|
const s = window.__hyperhive_state;
|
|
if (s) renderContainers(s);
|
|
}
|
|
|
|
// Re-derive port conflicts from the live containers map. Mirrors the
|
|
// server-side `build_port_conflicts` so the banner reacts to event
|
|
// updates instead of waiting for a /api/state refetch.
|
|
function derivePortConflicts(containers) {
|
|
const byPort = new Map();
|
|
for (const c of containers) {
|
|
if (!byPort.has(c.port)) byPort.set(c.port, []);
|
|
byPort.get(c.port).push(c.name);
|
|
}
|
|
const out = [];
|
|
for (const [port, agents] of byPort) {
|
|
if (agents.length > 1) {
|
|
agents.sort();
|
|
out.push({ port, agents });
|
|
}
|
|
}
|
|
out.sort((a, b) => a.port - b.port);
|
|
return out;
|
|
}
|
|
|
|
// ─── state rendering ────────────────────────────────────────────────────
|
|
function renderContainers(s) {
|
|
const root = $('containers-section');
|
|
root.innerHTML = '';
|
|
|
|
// Containers come from the derived map (event-driven) rather than
|
|
// `s.containers`; `s` still supplies hostname (for the web-ui
|
|
// link) and tombstones/meta_inputs (not event-derived yet).
|
|
const containers = Array.from(containersState.values())
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
const portConflicts = derivePortConflicts(containers);
|
|
const anyStale = containers.some((c) => c.needs_update);
|
|
|
|
// Port-hash collisions: rename one of the listed agents and
|
|
// rebuild. The banner sits above the agent list so it's the
|
|
// first thing the operator sees when something's wedged.
|
|
if (portConflicts.length) {
|
|
const banner = el('div', { class: 'port-conflict' },
|
|
el('strong', {}, '⚠ port collision'), ' — ');
|
|
const groups = portConflicts.map((c) =>
|
|
`:${c.port} (${c.agents.join(' + ')})`).join('; ');
|
|
banner.append(groups + '. rename one of each and ↻ R3BU1LD.');
|
|
root.append(banner);
|
|
}
|
|
|
|
if (anyStale) {
|
|
root.append(form(
|
|
'/update-all', 'btn-rebuild', '↻ UPD4TE 4LL',
|
|
'rebuild every stale container?',
|
|
{}, { noRefresh: true },
|
|
));
|
|
}
|
|
|
|
if (transientsState.size) {
|
|
const ul = el('ul');
|
|
const nowUnix = Math.floor(Date.now() / 1000);
|
|
for (const [name, t] of transientsState) {
|
|
const secs = Math.max(0, nowUnix - t.since_unix);
|
|
ul.append(el('li', {},
|
|
el('span', { class: 'glyph spinner' }, '◐'), ' ',
|
|
el('span', { class: 'agent' }, name), ' ',
|
|
el('span', { class: 'role role-pending' }, t.kind + '…'), ' ',
|
|
el('span', { class: 'meta' }, `nixos-container create + start (${secs}s)`),
|
|
));
|
|
}
|
|
root.append(ul);
|
|
}
|
|
|
|
if (!containers.length && !transientsState.size) {
|
|
root.append(el('p', { class: 'empty' }, 'no managed containers'));
|
|
return;
|
|
}
|
|
|
|
const hostname = (s && s.hostname) || window.location.hostname;
|
|
const ul = el('ul', { class: 'containers' });
|
|
for (const c of containers) {
|
|
const url = `http://${hostname}:${c.port}/`;
|
|
// Pending state is overlaid from the transient store, not from
|
|
// the container row — `ContainerStateChanged` doesn't carry it,
|
|
// `TransientSet` / `TransientCleared` do.
|
|
const pending = transientsState.get(c.name)?.kind || null;
|
|
const li = el('li', { class: 'container-row' + (pending ? ' pending' : '') });
|
|
|
|
// ── line 1: identity ─────────────────────────────────────────
|
|
const head = el('div', { class: 'head' });
|
|
head.append(
|
|
el('a', { class: 'name', href: url, target: '_blank', rel: 'noopener' }, c.name),
|
|
el('span', { class: c.is_manager ? 'role role-m1nd' : 'role role-ag3nt' },
|
|
c.is_manager ? 'm1nd' : 'ag3nt'),
|
|
);
|
|
if (pending) {
|
|
head.append(el('span', { class: 'pending-state' },
|
|
el('span', { class: 'spinner' }, '◐'), ' ', pending + '…'));
|
|
} else if (c.needs_login) {
|
|
head.append(el('a',
|
|
{ class: 'badge badge-warn', href: url, target: '_blank', rel: 'noopener' },
|
|
'needs login →'));
|
|
}
|
|
if (c.needs_update) {
|
|
head.append(form(
|
|
'/rebuild/' + c.name, 'badge badge-warn btn-inline', 'needs update ↻',
|
|
'rebuild ' + c.name + '? hot-reloads the container.',
|
|
{}, { noRefresh: true },
|
|
));
|
|
}
|
|
head.append(el('span', { class: 'meta' }, `${c.container} :${c.port}`));
|
|
if (c.deployed_sha) {
|
|
head.append(el('span',
|
|
{ class: 'meta', title: 'sha currently locked in /meta/flake.lock' },
|
|
`deployed:${c.deployed_sha}`));
|
|
}
|
|
li.append(head);
|
|
|
|
// ── line 2: action buttons ───────────────────────────────────
|
|
const actions = el('div', { class: 'actions' });
|
|
if (c.running) {
|
|
actions.append(
|
|
form('/restart/' + c.name, 'btn-restart', '↺ R3ST4RT',
|
|
'restart ' + c.name + '?', {}, { noRefresh: true }),
|
|
);
|
|
if (!c.is_manager) {
|
|
actions.append(
|
|
form('/kill/' + c.name, 'btn-stop', '■ ST0P',
|
|
'stop ' + c.name + '?', {}, { noRefresh: true }),
|
|
);
|
|
}
|
|
} else {
|
|
actions.append(
|
|
form('/start/' + c.name, 'btn-start', '▶ ST4RT',
|
|
'start ' + c.name + '?', {}, { noRefresh: true }),
|
|
);
|
|
}
|
|
actions.append(
|
|
form('/rebuild/' + c.name, 'btn-rebuild', '↻ R3BU1LD',
|
|
'rebuild ' + c.name + '? hot-reloads the container.',
|
|
{}, { noRefresh: true }),
|
|
);
|
|
if (!c.is_manager) {
|
|
// DESTR0Y is event-covered (ContainerRemoved); PURG3 also
|
|
// wipes tombstone state which isn't event-derived yet, so it
|
|
// keeps the post-submit refetch.
|
|
actions.append(
|
|
form('/destroy/' + c.name, 'btn-destroy', 'DESTR0Y',
|
|
'destroy ' + c.name + '? container is removed; state + creds kept.',
|
|
{}, { noRefresh: true }),
|
|
form('/destroy/' + c.name, 'btn-destroy', 'PURG3',
|
|
'PURGE ' + c.name + '? container, config history, claude creds, '
|
|
+ 'and notes are all WIPED. no undo.', { purge: 'on' }),
|
|
);
|
|
}
|
|
li.append(actions);
|
|
|
|
// Per-container journald viewer. Expand to fetch + render the
|
|
// last N lines; refresh button re-fetches; unit selector
|
|
// narrows to the harness service (or empty = full machine).
|
|
const journalUnit = c.is_manager ? 'hive-m1nd.service' : 'hive-ag3nt.service';
|
|
li.append(buildJournalDetails(c.container, journalUnit));
|
|
// Per-container applied config viewer. Shows the agent.nix
|
|
// the container is actually built against.
|
|
li.append(buildConfigDetails(c.name));
|
|
|
|
ul.append(li);
|
|
}
|
|
root.append(ul);
|
|
}
|
|
|
|
// Build the per-container journald <details>. Lazy-fetches when the
|
|
// operator expands; refresh re-fetches; unit toggle switches
|
|
// between the harness service and the full machine journal.
|
|
function buildJournalDetails(containerName, defaultUnit) {
|
|
const details = el('details', {
|
|
class: 'journal',
|
|
'data-restore-key': 'journal:' + containerName,
|
|
});
|
|
const summary = el('summary', {}, '↳ logs · ' + containerName);
|
|
const body = el('div', { class: 'journal-body' });
|
|
const controls = el('div', { class: 'journal-controls' });
|
|
const unitSelect = el('select', { class: 'journal-unit' });
|
|
unitSelect.append(
|
|
el('option', { value: defaultUnit }, defaultUnit),
|
|
el('option', { value: '' }, '(full machine journal)'),
|
|
);
|
|
const refresh = el('button', { type: 'button', class: 'btn btn-restart journal-refresh' },
|
|
'↻ refresh');
|
|
const pre = el('pre', { class: 'journal-output' }, 'fetching…');
|
|
let fetching = false;
|
|
async function fetchLogs() {
|
|
if (fetching) return;
|
|
fetching = true;
|
|
pre.textContent = 'fetching…';
|
|
const unit = unitSelect.value;
|
|
const params = new URLSearchParams({ lines: '500' });
|
|
if (unit) params.set('unit', unit);
|
|
try {
|
|
const resp = await fetch('/api/journal/' + containerName + '?' + params);
|
|
const text = await resp.text();
|
|
if (!resp.ok) {
|
|
pre.textContent = 'error: ' + resp.status + '\n' + text;
|
|
} else {
|
|
pre.textContent = text || '(empty)';
|
|
// Auto-scroll to bottom on fresh fetch.
|
|
pre.scrollTop = pre.scrollHeight;
|
|
}
|
|
} catch (err) {
|
|
pre.textContent = 'fetch failed: ' + err;
|
|
} finally {
|
|
fetching = false;
|
|
}
|
|
}
|
|
details.addEventListener('toggle', () => { if (details.open) fetchLogs(); });
|
|
refresh.addEventListener('click', (e) => { e.preventDefault(); fetchLogs(); });
|
|
unitSelect.addEventListener('change', fetchLogs);
|
|
controls.append(unitSelect, refresh);
|
|
body.append(controls, pre);
|
|
details.append(summary, body);
|
|
return details;
|
|
}
|
|
|
|
// Per-container applied-config viewer. Lazy-fetches on expand;
|
|
// refresh button re-fetches. Read-only — the file is hive-c0re's
|
|
// applied repo, mutated only via the approval flow.
|
|
function buildConfigDetails(agentName) {
|
|
const details = el('details', {
|
|
class: 'journal',
|
|
'data-restore-key': 'agent-config:' + agentName,
|
|
});
|
|
const summary = el('summary', {}, '↳ agent.nix · ' + agentName);
|
|
const body = el('div', { class: 'journal-body' });
|
|
const controls = el('div', { class: 'journal-controls' });
|
|
const refresh = el('button', { type: 'button', class: 'btn btn-restart journal-refresh' },
|
|
'↻ refresh');
|
|
const pre = el('pre', { class: 'journal-output' }, 'fetching…');
|
|
let fetching = false;
|
|
async function fetchConfig() {
|
|
if (fetching) return;
|
|
fetching = true;
|
|
pre.textContent = 'fetching…';
|
|
try {
|
|
const resp = await fetch('/api/agent-config/' + agentName);
|
|
const text = await resp.text();
|
|
if (!resp.ok) {
|
|
pre.textContent = 'error: ' + resp.status + '\n' + text;
|
|
} else {
|
|
pre.textContent = text || '(empty)';
|
|
pre.scrollTop = 0;
|
|
}
|
|
} catch (err) {
|
|
pre.textContent = 'fetch failed: ' + err;
|
|
} finally {
|
|
fetching = false;
|
|
}
|
|
}
|
|
details.addEventListener('toggle', () => { if (details.open) fetchConfig(); });
|
|
refresh.addEventListener('click', (e) => { e.preventDefault(); fetchConfig(); });
|
|
controls.append(refresh);
|
|
body.append(controls, pre);
|
|
details.append(summary, body);
|
|
return details;
|
|
}
|
|
|
|
function renderTombstones(s) {
|
|
const root = $('tombstones-section');
|
|
root.innerHTML = '';
|
|
if (!s.tombstones || !s.tombstones.length) {
|
|
root.append(el('p', { class: 'empty' }, 'no kept state — clean'));
|
|
return;
|
|
}
|
|
const fmtBytes = (n) => {
|
|
if (n < 1024) return n + ' B';
|
|
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
|
|
if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + ' MB';
|
|
return (n / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
|
};
|
|
const fmtAge = (ts) => {
|
|
if (!ts) return '?';
|
|
const d = Math.floor((Date.now() / 1000 - ts) / 86400);
|
|
if (d <= 0) return 'today';
|
|
if (d === 1) return '1 day ago';
|
|
return d + ' days ago';
|
|
};
|
|
const ul = el('ul', { class: 'containers' });
|
|
for (const t of s.tombstones) {
|
|
const li = el('li', { class: 'container-row tombstone' });
|
|
const head = el('div', { class: 'head' });
|
|
head.append(
|
|
el('span', { class: 'name' }, t.name),
|
|
el('span', { class: 'badge badge-muted' }, 'destroyed'),
|
|
);
|
|
if (t.has_creds) {
|
|
head.append(el('span', { class: 'badge badge-muted' }, 'creds kept'));
|
|
}
|
|
head.append(el('span', { class: 'meta' },
|
|
`${fmtBytes(t.state_bytes)} · ${fmtAge(t.last_seen)}`));
|
|
li.append(head);
|
|
|
|
const actions = el('div', { class: 'actions' });
|
|
// Reuse the existing spawn form pattern via /request-spawn — operator
|
|
// can queue an approval that recreates the agent with the same name
|
|
// and reuses the kept state.
|
|
const respawn = el('form', {
|
|
method: 'POST', action: '/request-spawn',
|
|
class: 'inline', 'data-async': '',
|
|
'data-confirm': 'queue spawn approval for ' + t.name + '? state will be reused.',
|
|
});
|
|
respawn.append(
|
|
el('input', { type: 'hidden', name: 'name', value: t.name }),
|
|
el('button', { type: 'submit', class: 'btn btn-start' }, '⊕ R3V1V3'),
|
|
);
|
|
actions.append(respawn);
|
|
actions.append(form(
|
|
'/purge-tombstone/' + t.name, 'btn-destroy', 'PURG3',
|
|
'PURGE ' + t.name + '? config history, claude creds, /state/ notes '
|
|
+ 'are all WIPED. no undo.',
|
|
));
|
|
li.append(actions);
|
|
ul.append(li);
|
|
}
|
|
root.append(ul);
|
|
}
|
|
|
|
// 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: [] };
|
|
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,
|
|
});
|
|
renderQuestions();
|
|
}
|
|
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);
|
|
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,
|
|
});
|
|
if (questionsState.history.length > QUESTION_HISTORY_LIMIT) {
|
|
questionsState.history.length = QUESTION_HISTORY_LIMIT;
|
|
}
|
|
renderQuestions();
|
|
}
|
|
// 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;
|
|
}
|
|
function renderQuestions() {
|
|
const root = $('questions-section');
|
|
root.innerHTML = '';
|
|
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
|
|
const allPending = questionsState.pending;
|
|
const activeFilter = getQuestionsFilter();
|
|
const pending = allPending.filter((q) => questionMatchesFilter(q, activeFilter));
|
|
|
|
// 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);
|
|
}
|
|
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;
|
|
};
|
|
filterRow.append(
|
|
mkChip('all', `all · ${allPending.length}`),
|
|
mkChip('operator', '@operator'),
|
|
mkChip('peer', '@peer'),
|
|
);
|
|
for (const name of Array.from(participants).sort()) {
|
|
filterRow.append(mkChip('agent:' + name, '@' + name));
|
|
}
|
|
root.append(filterRow);
|
|
|
|
if (!pending.length) {
|
|
root.append(el('p', { class: 'empty' },
|
|
activeFilter === 'all' ? 'no pending questions' : 'no questions match this filter'));
|
|
}
|
|
const ul = el('ul', { class: 'questions' });
|
|
for (const q of pending) {
|
|
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) {
|
|
const remaining = q.deadline_at - Math.floor(Date.now() / 1000);
|
|
let txt;
|
|
if (remaining <= 0) txt = 'expiring…';
|
|
else if (remaining < 60) txt = '⏳ ' + remaining + 's';
|
|
else if (remaining < 3600) txt = '⏳ ' + Math.floor(remaining / 60) + 'm '
|
|
+ (remaining % 60) + 's';
|
|
else txt = '⏳ ' + Math.floor(remaining / 3600) + 'h '
|
|
+ Math.floor((remaining % 3600) / 60) + 'm';
|
|
head.append(' ', el('span', { class: 'q-ttl' }, txt));
|
|
}
|
|
const qBody = el('div', { class: 'q-body' });
|
|
const qPreviews = appendLinkified(qBody, q.question);
|
|
li.append(head, qBody);
|
|
for (const d of qPreviews) li.appendChild(d);
|
|
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('input', {
|
|
type: 'text', name: 'answer-free',
|
|
placeholder: hasOptions ? 'or type your own…' : 'your answer',
|
|
autocomplete: 'off',
|
|
});
|
|
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);
|
|
ul.append(li);
|
|
}
|
|
if (pending.length) root.append(ul);
|
|
|
|
// Answered question history
|
|
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' });
|
|
const histBodyPreviews = appendLinkified(histBody, q.question);
|
|
const ansText = el('span', { class: 'q-answer-text' });
|
|
const histAnsPreviews = appendLinkified(ansText, q.answer || '(none)');
|
|
const ansLine = el('div', { class: 'q-answer' },
|
|
el('span', { class: 'msg-sep' }, `${q.answerer || '?'}: `),
|
|
ansText,
|
|
);
|
|
li.append(head, histBody);
|
|
for (const d of histBodyPreviews) li.appendChild(d);
|
|
li.append(ansLine);
|
|
for (const d of histAnsPreviews) li.appendChild(d);
|
|
hul.append(li);
|
|
}
|
|
details.append(hul);
|
|
root.append(details);
|
|
}
|
|
}
|
|
|
|
// ─── operator inbox (derived from the broker message stream) ───────────
|
|
// No longer shipped on `/api/state.operator_inbox`. The dashboard
|
|
// terminal's HiveTerminal feeds this via `onAnyEvent` — backfill from
|
|
// `/dashboard/history` populates on load, live SSE keeps it current.
|
|
// Newest-first to match the previous behaviour.
|
|
const INBOX_LIMIT = 50;
|
|
const operatorInbox = [];
|
|
function inboxAppendFromEvent(ev) {
|
|
if (ev.kind !== 'sent' || ev.to !== 'operator') return false;
|
|
operatorInbox.unshift({ from: ev.from, body: ev.body, at: ev.at });
|
|
if (operatorInbox.length > INBOX_LIMIT) operatorInbox.length = INBOX_LIMIT;
|
|
return true;
|
|
}
|
|
function renderInbox() {
|
|
const root = $('inbox-section');
|
|
if (!root) return;
|
|
root.innerHTML = '';
|
|
if (!operatorInbox.length) {
|
|
root.append(el('p', { class: 'empty' }, 'no messages'));
|
|
return;
|
|
}
|
|
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 li = el('li');
|
|
const body = el('span', { class: 'msg-body' });
|
|
const previews = appendLinkified(body, m.body);
|
|
li.append(
|
|
el('span', { class: 'msg-ts' }, fmt(m.at)), ' ',
|
|
el('span', { class: 'msg-from' }, m.from), ' ',
|
|
el('span', { class: 'msg-sep' }, '→ '),
|
|
body,
|
|
);
|
|
for (const d of previews) li.appendChild(d);
|
|
ul.append(li);
|
|
}
|
|
root.append(ul);
|
|
}
|
|
|
|
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,
|
|
};
|
|
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);
|
|
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();
|
|
}
|
|
function renderApprovals() {
|
|
const root = $('approvals-section');
|
|
root.innerHTML = '';
|
|
|
|
// 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 spawn = el('form', {
|
|
method: 'POST', action: '/request-spawn',
|
|
class: 'spawnform', 'data-async': '', 'data-no-refresh': '',
|
|
});
|
|
spawn.append(
|
|
el('input', {
|
|
name: 'name',
|
|
placeholder: 'new agent name (≤9 chars)',
|
|
maxlength: '9', required: '', autocomplete: 'off',
|
|
}),
|
|
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;
|
|
}
|
|
const ul = el('ul', { class: 'approvals' });
|
|
for (const a of pending) {
|
|
const li = el('li');
|
|
const row = el('div', { class: 'row' });
|
|
if (a.kind === 'apply_commit') {
|
|
row.append(
|
|
el('span', { class: 'glyph' }, '→'), ' ',
|
|
el('span', { class: 'id' }, '#' + a.id), ' ',
|
|
el('span', { class: 'agent' }, a.agent), ' ',
|
|
el('span', { class: 'kind' }, 'apply'), ' ',
|
|
el('code', {}, a.sha_short || ''),
|
|
);
|
|
} else {
|
|
row.append(
|
|
el('span', { class: 'glyph' }, '⊕'), ' ',
|
|
el('span', { class: 'id' }, '#' + a.id), ' ',
|
|
el('span', { class: 'agent' }, a.agent), ' ',
|
|
el('span', { class: 'kind kind-spawn' }, 'spawn'), ' ',
|
|
el('span', { class: 'meta' },
|
|
'new sub-agent — container will be created on approve'),
|
|
);
|
|
}
|
|
if (a.description) {
|
|
li.append(el('div', { class: 'approval-description' }, a.description));
|
|
}
|
|
// 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'));
|
|
row.append(
|
|
' ',
|
|
form('/approve/' + a.id, 'btn-approve', '◆ APPR0VE', null, {}, { noRefresh: true }),
|
|
' ',
|
|
denyForm,
|
|
);
|
|
li.append(row);
|
|
if (a.diff) {
|
|
const details = el('details', {
|
|
'data-restore-key': 'approval-diff:' + a.id,
|
|
});
|
|
details.append(el('summary', {}, 'diff vs applied'));
|
|
// Server ships the raw unified diff; classify each line by its
|
|
// leading char so `.diff-add` / `.diff-del` / `.diff-hunk` /
|
|
// `.diff-file` / `.diff-ctx` colour the output. Building spans
|
|
// here (instead of innerHTML-ing pre-rendered markup) keeps
|
|
// the snapshot wire format text-only and one less HTML-escape
|
|
// surface server-side.
|
|
const pre = el('pre', { class: 'diff' });
|
|
for (const raw of a.diff.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);
|
|
}
|
|
details.append(pre);
|
|
li.append(details);
|
|
}
|
|
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' ? '✗'
|
|
: '⚠';
|
|
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' : '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);
|
|
}
|
|
|
|
// Relative time, anchored to now. resolved_at is unix seconds (server-
|
|
// authored), so we don't have to worry about client/server clock skew
|
|
// for sub-minute precision.
|
|
function fmtAgo(unixSecs) {
|
|
const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSecs));
|
|
if (ageSec < 60) return ageSec + 's ago';
|
|
if (ageSec < 3600) return Math.floor(ageSec / 60) + 'm ago';
|
|
if (ageSec < 86400) return Math.floor(ageSec / 3600) + 'h ago';
|
|
return Math.floor(ageSec / 86400) + 'd ago';
|
|
}
|
|
|
|
function renderMetaInputs(s) {
|
|
const root = $('meta-inputs-section');
|
|
if (!root) return;
|
|
root.innerHTML = '';
|
|
const inputs = s.meta_inputs || [];
|
|
if (!inputs.length) {
|
|
root.append(el('p', { class: 'empty' }, 'meta repo not seeded yet'));
|
|
return;
|
|
}
|
|
const form = el('form', {
|
|
method: 'POST',
|
|
action: '/meta-update',
|
|
class: 'meta-inputs-form',
|
|
'data-async': '',
|
|
'data-confirm': 'update selected meta flake inputs + rebuild affected agents?',
|
|
});
|
|
const ul = el('ul', { class: 'meta-inputs' });
|
|
for (const inp of inputs) {
|
|
const li = el('li');
|
|
const id = 'meta-input-' + inp.name.replace(/[^a-z0-9-]/gi, '_');
|
|
const cb = el('input', {
|
|
type: 'checkbox',
|
|
name: 'meta_input_' + inp.name,
|
|
id,
|
|
value: inp.name,
|
|
'data-meta-input': inp.name,
|
|
});
|
|
const label = el('label', { for: id });
|
|
label.append(
|
|
cb,
|
|
el('span', { class: 'meta-input-name' }, inp.name), ' ',
|
|
el('code', { class: 'meta-input-rev' }, inp.rev.slice(0, 12)), ' ',
|
|
el('span', { class: 'meta-input-ts' }, fmtAgo(inp.last_modified)),
|
|
);
|
|
if (inp.url) {
|
|
label.append(' ', el('span', { class: 'meta-input-url', title: inp.url },
|
|
'· ' + truncate(inp.url, 48)));
|
|
}
|
|
li.append(label);
|
|
ul.append(li);
|
|
}
|
|
form.append(ul);
|
|
// Hidden input the POST handler reads — populated at submit
|
|
// time from the checkbox states. axum's Form extractor doesn't
|
|
// natively decode repeated keys, so we join into one CSV.
|
|
const hidden = el('input', { type: 'hidden', name: 'inputs', value: '' });
|
|
form.append(hidden);
|
|
const btn = el('button', {
|
|
type: 'submit',
|
|
class: 'btn btn-meta-update',
|
|
disabled: '',
|
|
}, '◆ UPD4TE & R3BU1LD');
|
|
form.append(btn);
|
|
function refreshDisabled() {
|
|
const any = form.querySelectorAll('input[data-meta-input]:checked').length > 0;
|
|
if (any) btn.removeAttribute('disabled');
|
|
else btn.setAttribute('disabled', '');
|
|
}
|
|
form.addEventListener('change', refreshDisabled);
|
|
form.addEventListener('submit', () => {
|
|
const selected = Array.from(form.querySelectorAll('input[data-meta-input]:checked'))
|
|
.map((b) => b.dataset.metaInput);
|
|
hidden.value = selected.join(',');
|
|
});
|
|
root.append(form);
|
|
}
|
|
|
|
function truncate(s, n) {
|
|
return s.length <= n ? s : s.slice(0, n - 1) + '…';
|
|
}
|
|
|
|
// ─── state polling ──────────────────────────────────────────────────────
|
|
let pollTimer = null;
|
|
// Sections whose innerHTML gets blown away on each refresh. If the
|
|
// operator is typing in one of them, skip the refresh — the next
|
|
// tick (or a manual action) will pick it up after they blur.
|
|
const MANAGED_SECTION_IDS = [
|
|
'containers-section',
|
|
'tombstones-section',
|
|
'questions-section',
|
|
'inbox-section',
|
|
'approvals-section',
|
|
'meta-inputs-section',
|
|
];
|
|
// <details> sections that should survive a refresh need a stable
|
|
// `data-restore-key` attribute. snapshotOpenDetails walks managed
|
|
// sections and records which keys are currently open; restoreOpenDetails
|
|
// re-applies after the render. The `toggle` event fires on
|
|
// programmatic open changes too, so any onload-fetch wired up via
|
|
// a toggle listener (e.g. journald) re-fires cleanly.
|
|
function snapshotOpenDetails() {
|
|
const open = new Set();
|
|
for (const id of MANAGED_SECTION_IDS) {
|
|
const sect = document.getElementById(id);
|
|
if (!sect) continue;
|
|
for (const d of sect.querySelectorAll('details[data-restore-key]')) {
|
|
if (d.open) open.add(d.dataset.restoreKey);
|
|
}
|
|
}
|
|
return open;
|
|
}
|
|
function restoreOpenDetails(open) {
|
|
if (!open.size) return;
|
|
for (const id of MANAGED_SECTION_IDS) {
|
|
const sect = document.getElementById(id);
|
|
if (!sect) continue;
|
|
for (const d of sect.querySelectorAll('details[data-restore-key]')) {
|
|
if (open.has(d.dataset.restoreKey)) d.open = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
function operatorIsTyping() {
|
|
const el_ = document.activeElement;
|
|
if (!el_ || el_ === document.body) return false;
|
|
const tag = el_.tagName;
|
|
if (tag !== 'INPUT' && tag !== 'TEXTAREA' && tag !== 'SELECT') return false;
|
|
return MANAGED_SECTION_IDS.some((id) => {
|
|
const sect = document.getElementById(id);
|
|
return sect && sect.contains(el_);
|
|
});
|
|
}
|
|
async function refreshState() {
|
|
// Don't yank the form out from under the operator. Try again
|
|
// shortly on the next tick; eventually they'll blur and the
|
|
// refresh lands.
|
|
if (operatorIsTyping()) {
|
|
if (pollTimer) clearTimeout(pollTimer);
|
|
pollTimer = setTimeout(refreshState, 2000);
|
|
return;
|
|
}
|
|
try {
|
|
const resp = await fetch('/api/state');
|
|
if (!resp.ok) throw new Error('http ' + resp.status);
|
|
const s = await resp.json();
|
|
// Stash the latest snapshot for any sub-widget that wants a
|
|
// synchronous read (e.g. the compose autocomplete pulls agent
|
|
// names from here instead of refetching on every keystroke).
|
|
window.__hyperhive_state = s;
|
|
const openDetails = snapshotOpenDetails();
|
|
// Sync transients + containers first so renderContainers below
|
|
// sees the current derived maps (it reads from
|
|
// `transientsState` + `containersState`, not from `s.*`).
|
|
syncTransientsFromSnapshot(s);
|
|
syncContainersFromSnapshot(s);
|
|
renderContainers(s);
|
|
renderTombstones(s);
|
|
// Sync the derived approvals + questions stores from the
|
|
// snapshot, then render. Live `*_added` / `*_resolved` events
|
|
// mutate the stores directly and re-render without a snapshot
|
|
// refetch.
|
|
syncQuestionsFromSnapshot(s);
|
|
renderQuestions();
|
|
renderInbox();
|
|
syncApprovalsFromSnapshot(s);
|
|
renderApprovals();
|
|
renderMetaInputs(s);
|
|
restoreOpenDetails(openDetails);
|
|
notifyDeltas(s);
|
|
// No periodic refresh timer. Phase 6 covers every container
|
|
// mutation with `ContainerStateChanged` / `ContainerRemoved`
|
|
// (lifecycle ops, destroy, rebuild, crash_watch's 10s poll);
|
|
// approvals + questions + transients have their own events;
|
|
// broker traffic flows through the SSE channel. The only
|
|
// /api/state fetches are the initial cold load and the
|
|
// post-submit refetch on forms without `data-no-refresh`
|
|
// (tombstones, meta-input updates).
|
|
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
|
|
} catch (err) {
|
|
console.error('refreshState failed', err);
|
|
// Schedule a single retry on transient errors so the page
|
|
// recovers from a brief network blip without making the
|
|
// operator reload.
|
|
pollTimer = setTimeout(refreshState, 5000);
|
|
}
|
|
}
|
|
refreshState();
|
|
NOTIF.bind();
|
|
|
|
// ─── message flow: shared terminal pane ────────────────────────────────
|
|
// Scroll, pill, backfill + SSE plumbing live in hive-fr0nt::TERMINAL_JS
|
|
// (window.HiveTerminal). What stays here is the broker-message
|
|
// renderer + the page-local side effects (banner pulse, inbox refresh
|
|
// on operator-bound traffic, OS notifications).
|
|
(() => {
|
|
const flow = $('msgflow');
|
|
if (!flow || !window.HiveTerminal) return;
|
|
flow.innerHTML = '';
|
|
const tsFmt = (n) => new Date(n * 1000).toISOString().slice(11, 19);
|
|
// Pulse the page banner whenever a broker event lands. Each event
|
|
// nudges the shimmer window; if traffic stops, the class falls off
|
|
// after the grace timer.
|
|
const banner = document.querySelector('.banner');
|
|
let bannerOffTimer = null;
|
|
function pulseBanner() {
|
|
if (!banner) return;
|
|
banner.classList.add('active');
|
|
if (bannerOffTimer) clearTimeout(bannerOffTimer);
|
|
bannerOffTimer = setTimeout(() => banner.classList.remove('active'), 4000);
|
|
}
|
|
function renderMsg(ev, api, glyph) {
|
|
const row = api.row('msgrow ' + ev.kind, '');
|
|
// Build via DOM so path anchors stay live + escape rules are
|
|
// automatic (text nodes don't need esc()).
|
|
const ts = document.createElement('span');
|
|
ts.className = 'msg-ts'; ts.textContent = tsFmt(ev.at);
|
|
const arrow = document.createElement('span');
|
|
arrow.className = 'msg-arrow'; arrow.textContent = glyph;
|
|
const from = document.createElement('span');
|
|
from.className = 'msg-from'; from.textContent = ev.from;
|
|
const sep = document.createElement('span');
|
|
sep.className = 'msg-sep'; sep.textContent = '→';
|
|
const to = document.createElement('span');
|
|
to.className = 'msg-to'; to.textContent = ev.to;
|
|
const body = document.createElement('span');
|
|
body.className = 'msg-body';
|
|
const previews = appendLinkified(body, ev.body);
|
|
row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body);
|
|
for (const d of previews) row.appendChild(d);
|
|
}
|
|
HiveTerminal.create({
|
|
logEl: flow,
|
|
historyUrl: '/dashboard/history',
|
|
streamUrl: '/dashboard/stream',
|
|
renderers: {
|
|
sent: (ev, api) => renderMsg(ev, api, '→'),
|
|
delivered: (ev, api) => renderMsg(ev, api, '✓'),
|
|
// Mutation events update derived state and trigger a
|
|
// section re-render — no terminal log row (the terminal is
|
|
// for broker traffic, not state-change chatter).
|
|
approval_added: (ev) => { applyApprovalAdded(ev); },
|
|
approval_resolved: (ev) => { applyApprovalResolved(ev); },
|
|
question_added: (ev) => { applyQuestionAdded(ev); },
|
|
question_resolved: (ev) => { applyQuestionResolved(ev); },
|
|
transient_set: (ev) => { applyTransientSet(ev); },
|
|
transient_cleared: (ev) => { applyTransientCleared(ev); },
|
|
container_state_changed: (ev) => { applyContainerStateChanged(ev); },
|
|
container_removed: (ev) => { applyContainerRemoved(ev); },
|
|
},
|
|
// Both history backfill and live frames flow through here, so the
|
|
// inbox section ends up populated correctly on first paint and
|
|
// updated thereafter — no /api/state refetch needed for inbox
|
|
// freshness (which used to be the workaround for the
|
|
// double-render bug).
|
|
onAnyEvent: (ev /* , { fromHistory } */) => {
|
|
if (inboxAppendFromEvent(ev)) renderInbox();
|
|
},
|
|
onLiveEvent: (ev) => {
|
|
pulseBanner();
|
|
if (ev.kind === 'sent' && ev.to === 'operator') {
|
|
NOTIF.show(
|
|
'◆ ' + ev.from + ' → operator',
|
|
String(ev.body || '').slice(0, 200),
|
|
// Unique-per-arrival tag so a burst stacks instead of
|
|
// overwriting itself in the OS notification center.
|
|
'hyperhive:msg:' + ev.at + ':' + Math.random().toString(36).slice(2, 6),
|
|
);
|
|
}
|
|
},
|
|
});
|
|
})();
|
|
|
|
// ─── compose: @-mention with sticky recipient ───────────────────────────
|
|
(() => {
|
|
const input = $('op-compose-input');
|
|
const prompt = $('op-compose-prompt');
|
|
const suggest = $('op-compose-suggest');
|
|
if (!input || !prompt || !suggest) return;
|
|
const STORAGE_KEY = 'hyperhive:op-compose:to';
|
|
let stickyTo = localStorage.getItem(STORAGE_KEY) || '';
|
|
let suggestActive = -1;
|
|
function renderPrompt() {
|
|
prompt.textContent = stickyTo ? `@${stickyTo}>` : '@—>';
|
|
}
|
|
function knownAgents() {
|
|
// Read live from the derived containers map so newly-spawned
|
|
// agents become addressable without an /api/state refetch.
|
|
// Broker uses the literal recipient `manager` for the manager's
|
|
// inbox, not the container name `hm1nd`.
|
|
const names = Array.from(containersState.values())
|
|
.map((c) => (c.is_manager ? 'manager' : c.name));
|
|
// `*` fans out to every registered agent (server-side
|
|
// broadcast_send).
|
|
names.unshift('*');
|
|
return names;
|
|
}
|
|
function autosize() {
|
|
input.style.height = 'auto';
|
|
input.style.height = `${input.scrollHeight}px`;
|
|
}
|
|
/// Parse "@name body…" — return {to, body} when the input opens
|
|
/// with a known @-mention, otherwise null.
|
|
function parseAddressed(raw) {
|
|
const m = raw.match(/^@([\w*-]+)\s+([\s\S]+)$/);
|
|
if (!m) return null;
|
|
return { to: m[1], body: m[2] };
|
|
}
|
|
function hideSuggest() {
|
|
suggest.hidden = true;
|
|
suggest.innerHTML = '';
|
|
suggestActive = -1;
|
|
}
|
|
function renderSuggest(matches) {
|
|
suggest.innerHTML = '';
|
|
if (!matches.length) { hideSuggest(); return; }
|
|
for (let i = 0; i < matches.length; i += 1) {
|
|
const item = document.createElement('div');
|
|
item.className = 'item' + (i === suggestActive ? ' active' : '');
|
|
item.textContent = '@' + matches[i];
|
|
item.addEventListener('mousedown', (e) => {
|
|
e.preventDefault();
|
|
applySuggestion(matches[i]);
|
|
});
|
|
suggest.append(item);
|
|
}
|
|
suggest.hidden = false;
|
|
}
|
|
function applySuggestion(name) {
|
|
// Replace the partial @-token at the start with the full name.
|
|
const v = input.value;
|
|
const m = v.match(/^@(\S*)/);
|
|
if (m) {
|
|
input.value = `@${name} ` + v.slice(m[0].length).replace(/^\s+/, '');
|
|
} else {
|
|
input.value = `@${name} ` + v;
|
|
}
|
|
hideSuggest();
|
|
input.focus();
|
|
input.setSelectionRange(input.value.length, input.value.length);
|
|
autosize();
|
|
}
|
|
function updateSuggest() {
|
|
const v = input.value;
|
|
// Only suggest when an @-token sits at the very start of the
|
|
// input — switching recipient is always "redirect this whole
|
|
// line." Mid-message @-mentions stay literal.
|
|
const m = v.match(/^@(\S*)/);
|
|
if (!m) { hideSuggest(); return; }
|
|
const partial = m[1].toLowerCase();
|
|
const matches = knownAgents().filter((n) => n.toLowerCase().startsWith(partial));
|
|
if (!matches.length) { hideSuggest(); return; }
|
|
if (suggestActive < 0 || suggestActive >= matches.length) suggestActive = 0;
|
|
renderSuggest(matches);
|
|
}
|
|
async function submit() {
|
|
const raw = input.value.trim();
|
|
if (!raw) return;
|
|
let to;
|
|
let body;
|
|
const addressed = parseAddressed(raw);
|
|
if (addressed) {
|
|
to = addressed.to;
|
|
body = addressed.body.trim();
|
|
} else if (stickyTo) {
|
|
to = stickyTo;
|
|
body = raw;
|
|
} else {
|
|
flashError('no recipient — start with @name to address a message');
|
|
return;
|
|
}
|
|
if (!body) return;
|
|
const fd = new FormData();
|
|
fd.append('to', to);
|
|
fd.append('body', body);
|
|
input.disabled = true;
|
|
try {
|
|
// /op-send now returns 200 (no more 303-to-/). The SSE channel
|
|
// carries the resulting MessageEvent → the terminal renders the
|
|
// sent row + the inbox updates on its own; no /api/state
|
|
// refetch needed.
|
|
const resp = await fetch('/op-send', {
|
|
method: 'POST',
|
|
body: new URLSearchParams(fd),
|
|
});
|
|
if (!resp.ok) {
|
|
flashError(`send failed: http ${resp.status}`);
|
|
return;
|
|
}
|
|
} catch (err) {
|
|
flashError(`send failed: ${err}`);
|
|
return;
|
|
} finally {
|
|
input.disabled = false;
|
|
}
|
|
stickyTo = to;
|
|
localStorage.setItem(STORAGE_KEY, to);
|
|
input.value = '';
|
|
autosize();
|
|
renderPrompt();
|
|
input.focus();
|
|
}
|
|
function flashError(msg) {
|
|
const flow = $('msgflow');
|
|
if (!flow) return;
|
|
const row = document.createElement('div');
|
|
row.className = 'msgrow meta';
|
|
row.textContent = msg;
|
|
flow.insertBefore(row, flow.firstChild);
|
|
}
|
|
input.addEventListener('input', () => { autosize(); updateSuggest(); });
|
|
input.addEventListener('keydown', (e) => {
|
|
if (!suggest.hidden) {
|
|
if (e.key === 'ArrowDown') {
|
|
const items = suggest.querySelectorAll('.item');
|
|
suggestActive = (suggestActive + 1) % items.length;
|
|
renderSuggest(Array.from(items).map((i) => i.textContent.slice(1)));
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
if (e.key === 'ArrowUp') {
|
|
const items = suggest.querySelectorAll('.item');
|
|
suggestActive = (suggestActive - 1 + items.length) % items.length;
|
|
renderSuggest(Array.from(items).map((i) => i.textContent.slice(1)));
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey)) {
|
|
const active = suggest.querySelector('.item.active');
|
|
if (active) {
|
|
applySuggestion(active.textContent.slice(1));
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
}
|
|
if (e.key === 'Escape') {
|
|
hideSuggest();
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
}
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
submit();
|
|
}
|
|
});
|
|
input.addEventListener('blur', () => {
|
|
// Defer so a click on a suggestion item (mousedown) lands first.
|
|
setTimeout(hideSuggest, 100);
|
|
});
|
|
renderPrompt();
|
|
autosize();
|
|
})();
|
|
})();
|