The dashboard has a themed modal/dialog system (modal.js: themedToast/ themedConfirm/themedPrompt) and a data-async form submit interceptor (bindAsyncForms) that every dashboard action routes through. The per-agent UI never adopted either — it had its own more primitive data-async handler using native window.confirm()/alert() (8 call sites) and a duplicated el() DOM helper. - Moved el() out of dashboard/common.js into shared/src/dom.js. - Moved modal.js + modal.css from dashboard/src/ to shared/src/, updating its internal el import. - Moved bindAsyncForms from dashboard/common.js into shared/forms.js, alongside the asyncBtn primitive it's built on. - Updated every dashboard file's imports to the new shared locations (no re-export shims). - agent.css now @imports shared/modal.css so the dialogs render themed there too. - agent/app.js: dropped its local el()/data-async duplicate, wired bindAsyncForms(), and replaced all 8 window.confirm() sites with themedConfirm (async, wrapped in a fire-and-forget IIFE where the call site needs a synchronous boolean return, e.g. the slash-command dispatcher). Closes hyperhive#2791. Verified with a full frontend build (npm run build) — both dashboard and agent bundles compile clean and agent.css picks up the .tc-* dialog styles it previously lacked.
466 lines
19 KiB
JavaScript
466 lines
19 KiB
JavaScript
// /flow.html entry point. Owns the full-page broker terminal and the
|
|
// @-mention compose box. Pulls shared infrastructure (DOM helpers,
|
|
// side panel, OS notifications, path linkification) from `./common.js`.
|
|
//
|
|
// The operator inbox lives on the dashboard's Y3R C4LL tab now; FL0W
|
|
// stays the pure event firehose.
|
|
//
|
|
// Does NOT contain the dashboard's tab renderers, mutation-event
|
|
// dispatchers, or refreshState — that's `./tabs.js`, loaded only by
|
|
// /dashboard.html. The flow page runs purely on the broker stream + an
|
|
// initial /api/state fetch (compose autocomplete needs the live
|
|
// container list).
|
|
|
|
import { create as termCreate } from '@hive/shared/terminal.js';
|
|
import {
|
|
$,
|
|
NOTIF,
|
|
appendLinkified,
|
|
openStream, initServerWarnings,
|
|
} from './common.js';
|
|
import { el } from '@hive/shared/dom.js';
|
|
import { epochSec } from './util.js';
|
|
|
|
(() => {
|
|
NOTIF.bind();
|
|
initServerWarnings();
|
|
|
|
// ─── local containers cache (for compose autocomplete) ──────────────────
|
|
// The compose box's @-mention completion suggests known agent names.
|
|
// /dashboard.html (tabs.js) maintains the canonical `containersState`
|
|
// from /api/state + SSE; here we keep a small local mirror updated
|
|
// by the same `container_state_changed` / `container_removed` events
|
|
// the dashboard would handle.
|
|
const flowContainers = new Map();
|
|
fetch('/api/state').then((r) => r.ok ? r.json() : null).then((s) => {
|
|
if (!s || !Array.isArray(s.containers)) return;
|
|
for (const c of s.containers) flowContainers.set(c.name, c);
|
|
populateAgentFilter();
|
|
}).catch(() => { /* graceful: compose just shows `*` and nothing else */ });
|
|
|
|
// ─── agent filter ───────────────────────────────────────────────
|
|
// A select in the FL0W header narrows the timeline to messages involving
|
|
// one agent (matched on `from` OR `to`). Each rendered row carries
|
|
// `data-from` / `data-to`; non-matching rows get `.flow-hidden`. New rows
|
|
// pick up the active filter at render time (see renderMsg); changing the
|
|
// filter re-scans existing rows. Selection persists in localStorage so a
|
|
// reload / tab-switch keeps the view. Uses `$('msgflow')` for row access
|
|
// so it works regardless of the message-flow IIFE's local scope.
|
|
let agentFilter = localStorage.getItem('flow-agent-filter') || '';
|
|
function rowMatchesFilter(from, to) {
|
|
return !agentFilter || from === agentFilter || to === agentFilter;
|
|
}
|
|
function applyAgentFilter() {
|
|
const flow = $('msgflow');
|
|
if (!flow) return;
|
|
for (const row of flow.children) {
|
|
const { from, to } = row.dataset;
|
|
if (from === undefined && to === undefined) continue; // non-message row
|
|
row.classList.toggle('flow-hidden', !rowMatchesFilter(from, to));
|
|
}
|
|
}
|
|
function populateAgentFilter() {
|
|
const sel = $('flow-agent-filter');
|
|
if (!sel) return;
|
|
const names = [...flowContainers.keys()].sort();
|
|
sel.replaceChildren();
|
|
sel.append(el('option', { value: '' }, 'all agents'));
|
|
for (const n of names) sel.append(el('option', { value: n }, n));
|
|
// Preserve a saved selection even if that agent isn't in the live
|
|
// container list (yet / anymore) so the filter doesn't silently reset.
|
|
if (agentFilter && !names.includes(agentFilter)) {
|
|
sel.append(el('option', { value: agentFilter }, agentFilter));
|
|
}
|
|
sel.value = agentFilter;
|
|
}
|
|
{
|
|
const sel = $('flow-agent-filter');
|
|
if (sel) {
|
|
sel.addEventListener('change', () => {
|
|
agentFilter = sel.value;
|
|
if (agentFilter) localStorage.setItem('flow-agent-filter', agentFilter);
|
|
else localStorage.removeItem('flow-agent-filter');
|
|
applyAgentFilter();
|
|
});
|
|
}
|
|
}
|
|
|
|
// ─── message flow: shared terminal pane ────────────────────────────────
|
|
// Scroll, pill, backfill + SSE plumbing live in @hive/shared/terminal.
|
|
// What stays here is the broker-message renderer + the page-local
|
|
// side effects (banner pulse, OS notifications on operator-bound
|
|
// traffic).
|
|
(() => {
|
|
const flow = $('msgflow');
|
|
if (!flow) return;
|
|
flow.replaceChildren();
|
|
const tsFmt = (ts) => new Date(ts).toISOString().slice(11, 19);
|
|
// Pulse the page banner whenever a broker event lands. The
|
|
// `.banner` element lives in the dashboard's <footer> rather than
|
|
// in the flow chrome — `pulseBanner` no-ops on /flow.html since
|
|
// there's no element to find. Kept for parity if a future chrome
|
|
// change reintroduces a banner.
|
|
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);
|
|
}
|
|
// Map of broker row id → rendered row element. Lets reply rows add
|
|
// a visual "↳ in reply to" indicator that links back to the parent.
|
|
// Bounded by the history window (~200 msgs from /dashboard/history),
|
|
// well within normal memory.
|
|
const msgRowMap = new Map();
|
|
|
|
function renderMsg(ev, api, glyph) {
|
|
const isReply = ev.in_reply_to != null;
|
|
const cls = 'msgrow ' + ev.kind + (isReply ? ' msg-reply' : '');
|
|
const row = api.row(cls, '');
|
|
// 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';
|
|
appendLinkified(body, ev.body, ev.file_refs);
|
|
// Reply thread indicator: a small "↳ reply to <from>" hint that
|
|
// shows which message this is responding to. If we have the parent
|
|
// in our row map, clicking scrolls it into view.
|
|
if (isReply) {
|
|
const replyTag = document.createElement('span');
|
|
replyTag.className = 'msg-reply-tag';
|
|
const parentRow = msgRowMap.get(ev.in_reply_to);
|
|
if (parentRow) {
|
|
const link = document.createElement('a');
|
|
link.href = '#';
|
|
link.textContent = '↳ reply';
|
|
link.title = 'scroll to parent message';
|
|
link.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
parentRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
parentRow.classList.add('msg-highlight');
|
|
setTimeout(() => parentRow.classList.remove('msg-highlight'), 1500);
|
|
});
|
|
replyTag.append(link);
|
|
} else {
|
|
replyTag.textContent = '↳ reply';
|
|
}
|
|
row.prepend(replyTag);
|
|
row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body);
|
|
} else {
|
|
row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body);
|
|
}
|
|
// Tag with the participants so the agent filter can match
|
|
// on `from`/`to`, and hide the row up-front if a filter is active.
|
|
row.dataset.from = ev.from;
|
|
row.dataset.to = ev.to;
|
|
if (!rowMatchesFilter(ev.from, ev.to)) row.classList.add('flow-hidden');
|
|
// Register this row so future replies can reference it.
|
|
if (ev.id != null && ev.id > 0) msgRowMap.set(ev.id, row);
|
|
return row;
|
|
}
|
|
|
|
// Sent→Delivered collapse. When a message directly wakes its
|
|
// recipient the broker emits `sent` then `delivered` for the same row
|
|
// id back-to-back, rendering two near-identical lines. We track each
|
|
// recent `sent` row and, when its `delivered` lands within
|
|
// COLLAPSE_SECS, upgrade that row in place (✓) instead of adding a
|
|
// second line. A delivery that arrives *later* (recipient was busy)
|
|
// stays a separate row so the latency remains visible.
|
|
const COLLAPSE_SECS = 3;
|
|
const recentSent = new Map(); // broker row id → { row, at }
|
|
function rememberSent(ev, row) {
|
|
if (ev.id == null || ev.id <= 0) return;
|
|
recentSent.set(ev.id, { row, at: epochSec(ev.at) });
|
|
// Bound the map — drop the oldest entries once it grows past a
|
|
// page of un-collapsed sends (insertion order = oldest first).
|
|
while (recentSent.size > 256) {
|
|
recentSent.delete(recentSent.keys().next().value);
|
|
}
|
|
}
|
|
// Returns true if the delivered event was folded into its sent row.
|
|
function collapseDelivered(ev) {
|
|
if (ev.id == null || ev.id <= 0) return false;
|
|
const s = recentSent.get(ev.id);
|
|
if (!s || epochSec(ev.at) - s.at > COLLAPSE_SECS) return false;
|
|
const arrow = s.row.querySelector('.msg-arrow');
|
|
if (arrow) arrow.textContent = '✓';
|
|
// Re-style the row as delivered (green ✓) — the collapsed line now
|
|
// represents the delivered state; it was sent + delivered as one.
|
|
s.row.classList.remove('sent');
|
|
s.row.classList.add('delivered');
|
|
s.row.title = 'sent + delivered';
|
|
recentSent.delete(ev.id);
|
|
return true;
|
|
}
|
|
// Anchor the `↓ N new` pill in `.flow-main` rather than the
|
|
// default `.terminal-wrap` parent — see docs/web-ui.md::Per-agent
|
|
// page (Terminal-wrap) for the backdrop-filter stacking-context
|
|
// gotcha (same shape on the flow page).
|
|
const flowMain = document.querySelector('.flow-main');
|
|
termCreate({
|
|
logEl: flow,
|
|
pillAnchor: flowMain,
|
|
historyUrl: '/api/dashboard/history',
|
|
// Server-side filter — only the kinds this page actually renders
|
|
// or routes (sent/delivered → broker terminal,
|
|
// container_state_changed/_removed → local autocomplete cache).
|
|
// Backend pre-parses the allow-list at subscribe time so the
|
|
// per-frame hot path is one HashSet::contains and the
|
|
// JSON-serialise is skipped entirely on irrelevant kinds. The
|
|
// dashboard tabs page (tabs.js) keeps the unfiltered subscribe
|
|
// since it routes every mutation kind into its derived stores.
|
|
streamUrl: '/api/dashboard/stream?kinds=sent,delivered,container_state_changed,container_removed',
|
|
// Route through the SharedWorker — see docs/web-ui.md (SSE
|
|
// multiplexing paragraph). Worker keys on the full URL incl.
|
|
// query string, so this filtered subscribe is its own upstream
|
|
// and won't accidentally share with tabs.js's wider subscribe.
|
|
streamFactory: openStream,
|
|
renderers: {
|
|
sent: (ev, api) => rememberSent(ev, renderMsg(ev, api, '→')),
|
|
delivered: (ev, api) => {
|
|
// Fold into the matching sent row when it just happened;
|
|
// otherwise render the delivery as its own line.
|
|
if (!collapseDelivered(ev)) renderMsg(ev, api, '✓');
|
|
},
|
|
// Maintain the local containers cache from the same stream
|
|
// (compose autocomplete reads from `flowContainers`). The
|
|
// dashboard's tab renderers aren't on this page, so we don't
|
|
// need to dispatch to applyContainerStateChanged etc. — just
|
|
// keep the autocomplete list current.
|
|
container_state_changed: (ev) => {
|
|
if (ev.container && ev.container.name) {
|
|
flowContainers.set(ev.container.name, ev.container);
|
|
populateAgentFilter(); // keep the filter dropdown current
|
|
}
|
|
},
|
|
container_removed: (ev) => {
|
|
flowContainers.delete(ev.name);
|
|
populateAgentFilter();
|
|
},
|
|
// Drop every other mutation kind silently — without this
|
|
// they'd fall through to the terminal module's default
|
|
// renderer and clutter the log with JSON dumps. The dashboard
|
|
// tabs handle these on /dashboard.html via tabs.js.
|
|
_default: () => {},
|
|
},
|
|
// Re-sync the local containers cache on every SSE (re)connect.
|
|
// Live mutation events that fired during a disconnect window
|
|
// are never replayed, so without this the compose autocomplete
|
|
// could drift stale.
|
|
onStreamOpen: () => {
|
|
fetch('/api/state').then((r) => r.ok ? r.json() : null).then((s) => {
|
|
if (!s || !Array.isArray(s.containers)) return;
|
|
flowContainers.clear();
|
|
for (const c of s.containers) flowContainers.set(c.name, c);
|
|
}).catch(() => {});
|
|
},
|
|
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 flow-local containers cache so newly-spawned
|
|
// agents become addressable without a manual reload.
|
|
const names = Array.from(flowContainers.values())
|
|
.map((c) => 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.replaceChildren();
|
|
suggestActive = -1;
|
|
}
|
|
function renderSuggest(matches) {
|
|
suggest.replaceChildren();
|
|
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 returns 200. The SSE channel carries the resulting
|
|
// MessageEvent → the terminal renders the sent row on its own;
|
|
// no /api/state refetch needed.
|
|
const resp = await fetch('/api/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;
|
|
// Append at the bottom so the error is visible — the terminal
|
|
// renders newest-last, so inserting before firstChild would place
|
|
// the error at the top (oldest end) and hide it from view.
|
|
flow.append(row);
|
|
// Scroll the terminal wrap so the error is in view.
|
|
const wrap = flow.parentElement;
|
|
if (wrap) wrap.scrollTop = wrap.scrollHeight;
|
|
}
|
|
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();
|
|
})();
|
|
})();
|