diff --git a/frontend/packages/dashboard/build.mjs b/frontend/packages/dashboard/build.mjs index b18ebf0..9b7d2a7 100644 --- a/frontend/packages/dashboard/build.mjs +++ b/frontend/packages/dashboard/build.mjs @@ -1,21 +1,15 @@ // esbuild build for @hive/dashboard. Output layout (`dist/`): // // dist/index.html served by the Rust router at GET / -// dist/flow.html served at GET /flow.html -// dist/static/app.js /index.html entry — tab renderers + -// tab routing + refreshState -// dist/static/flow.js /flow.html entry — broker terminal + -// operator inbox + @-mention composer -// dist/static/{app,flow}.js.map source map siblings +// dist/static/app.js served at /static/app.js (ESM bundle, +// pulls in @hive/shared + marked) +// dist/static/app.js.map source map sibling // dist/static/dashboard.css served at /static/dashboard.css // (@import resolved from @hive/shared) // -// Both JS entries inline `./common.js` (DOM helpers, Panel singleton, -// NOTIF, path linkification) — esbuild dedupes the shared module -// between bundles. The Rust binary mounts `dist/` as a -// `tower_http::ServeDir` fallback; the layout above keeps every URL -// the HTML files reference reachable without rewriting paths in the -// HTML. +// The Rust binary mounts `dist/` as a `tower_http::ServeDir` fallback; +// the layout above keeps every URL the index.html references reachable +// without rewriting paths in the HTML. import { build } from 'esbuild'; import { mkdirSync, copyFileSync, rmSync } from 'node:fs'; @@ -30,13 +24,12 @@ const staticDir = (p) => resolve(here, 'dist', 'static', p); rmSync(dist(''), { recursive: true, force: true }); mkdirSync(staticDir(''), { recursive: true }); -// Bundle both JS entries. ES-module output, browser target, no minify +// Bundle the JS entry. ES-module output, browser target, no minify // (line-aligned source aids debugging; minification belongs in a later -// follow-up once asset sizes warrant it). esbuild writes each entry -// to `static/.js` based on the entryPoint basename. +// follow-up once asset sizes warrant it). await build({ - entryPoints: [src('app.js'), src('flow.js')], - outdir: staticDir(''), + entryPoints: [src('app.js')], + outfile: staticDir('app.js'), bundle: true, format: 'esm', platform: 'browser', diff --git a/frontend/packages/dashboard/src/app.js b/frontend/packages/dashboard/src/app.js index 53b0cd9..8fdc74f 100644 --- a/frontend/packages/dashboard/src/app.js +++ b/frontend/packages/dashboard/src/app.js @@ -1,17 +1,14 @@ -// /index.html entry point: tab renderers + tab routing + refreshState -// + notification deltas. Reads /api/state on cold load and after every -// async-form submit; live updates run through `applyXxx` mutation -// handlers triggered by the dashboard event stream. (Live SSE -// subscription on /index.html is still missing — see #406 step 3 / #408.) +// 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`. // -// #406 step 1: pure helpers + side panel + OS notifications + path -// linkification moved to `./common.js`. -// #406 step 2: the flow-only IIFEs (operator inbox, inbox-pill, broker -// terminal, @-mention composer) moved to `./flow.js`. /flow.html now -// loads `flow.js` as its own bundle entry; this file (still named -// `app.js` for the moment; rename to `tabs.js` is the last step -// of #406) is loaded only by /index.html. +// #406 step 1: pure helpers (DOM, formatters), the side-panel singleton, +// the OS-notification module, and the path-link / file-preview +// infrastructure all moved into `./common.js`. The follow-up step splits +// the broker-terminal / inbox / compose bits into a separate `flow.js` +// entry so /flow.html stops loading the tab renderers it doesn't use. +import { create as termCreate, linkify as termLinkify } from '@hive/shared/terminal.js'; import { marked } from 'marked'; import { $, el, esc, form, @@ -20,9 +17,13 @@ import { makePathLink, appendText, appendLinkified, } from './common.js'; -// mdNode (in common.js) reads `window.marked` for the markdown side -// panel preview path. Set it here on the dashboard entry so file -// previews work; flow.js does the same for the flow-page entry. +// Expose the previously-script-tag-provided globals so the IIFE below +// keeps working unchanged. Pre-split these were attached by +// `/static/hive-fr0nt.js` (HiveTerminal) and `/static/marked.js` +// (marked) loading before app.js. The bundle now pulls them in via ES +// imports; once the IIFE is opened up these aliases can be dropped in +// favour of direct named imports. +window.HiveTerminal = { create: termCreate, linkify: termLinkify }; window.marked = marked; (() => { @@ -1085,7 +1086,63 @@ window.marked = marked; }); }, 1000); - // Operator-inbox derived store moved to ./flow.js (#406 step 2). + // ─── 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, + file_refs: ev.file_refs || [], + }); + if (operatorInbox.length > INBOX_LIMIT) operatorInbox.length = INBOX_LIMIT; + return true; + } + function buildInboxListNode() { + if (!operatorInbox.length) return el('p', { class: 'empty' }, 'no messages'); + 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' }); + appendLinkified(body, m.body, m.file_refs); + li.append( + el('span', { class: 'msg-ts' }, fmt(m.at)), ' ', + el('span', { class: 'msg-from' }, m.from), ' ', + el('span', { class: 'msg-sep' }, '→ '), + body, + ); + ul.append(li); + } + return ul; + } + function renderInbox() { + // Inline section on the dashboard (#inbox-section). Hidden / + // headless on the flow page; the flow page surfaces inbox via + // the pill + side-panel flyout instead. + const root = $('inbox-section'); + if (root && !root.hidden) { + root.innerHTML = ''; + root.append(buildInboxListNode()); + } + // Flow-page pill: visible when there's at least one message, + // count tracks operatorInbox length, click opens the side + // panel. The element only exists on flow.html; on the + // dashboard this no-ops. + const pill = $('inbox-pill'); + const pillCount = $('inbox-pill-count'); + if (pillCount) pillCount.textContent = String(operatorInbox.length); + if (pill) pill.hidden = operatorInbox.length === 0; + // If the side panel is currently showing the inbox view, refresh + // its body in place so live messages land without a re-open. + Panel.refresh('inbox', 'inbox · ' + operatorInbox.length, buildInboxListNode()); + } const APPROVAL_TAB_KEY = 'hyperhive:approvals:tab'; // Derived approval state — cold-loaded from /api/state, then mutated @@ -1798,8 +1855,7 @@ window.marked = marked; // refetch. syncQuestionsFromSnapshot(s); renderQuestions(); - // (renderInbox now lives in ./flow.js — dashboard has no - // #inbox-section element to render into.) + renderInbox(); syncApprovalsFromSnapshot(s); renderApprovals(); renderMetaInputs(s); @@ -1887,11 +1943,8 @@ window.marked = marked; } } setTabCount('system', sysCount); - // FL0W pill count: lives in ./flow.js now (it has the inbox - // derived store). Dashboard tab strip's `#tab-count-flow` slot - // stays hidden by default; future plumbing could broadcast the - // count via localStorage / BroadcastChannel if both pages are - // open. + // FL0W — operator inbox count. + setTabCount('flow', operatorInbox.length); } // Poll the state stores on a 1s tick to keep the pill counts in // sync. The state stores are mutated synchronously by every SSE @@ -1900,7 +1953,335 @@ window.marked = marked; refreshTabCounts(); setInterval(refreshTabCounts, 1000); - // Flow-specific IIFEs moved to ./flow.js (#406 step 2): inbox-pill - // wiring, the broker terminal init, and the @-mention composer. - // /index.html no longer loads them — only /flow.html does. + // Flow page: wire the inbox pill to open the side-panel flyout + // with the operator inbox. Only triggers when the pill exists + // (i.e. we're on flow.html); on the dashboard this no-ops. + (function bindFlowInboxPill() { + const pill = $('inbox-pill'); + if (!pill) return; + pill.addEventListener('click', () => { + Panel.openNamed('inbox', 'inbox · ' + operatorInbox.length, + buildInboxListNode()); + }); + })(); + + // ─── 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); + } + // 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 " 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); + } + // Register this row so future replies can reference it. + if (ev.id != null && ev.id > 0) msgRowMap.set(ev.id, row); + } + // Anchor the `↓ N new` pill in `.flow-main` (NOT the default + // `log.parentElement` = `.terminal-wrap`). `.terminal-wrap` + // applies `backdrop-filter`, which creates a CSS stacking + // context — the pill's z-index would otherwise be trapped + // inside and clipped under the fixed composer (issue #375). + // `.flow-main` has no backdrop-filter / stacking-context + // creators, so the pill's z-index reaches the root and floats + // above the composer. + const flowMain = document.querySelector('.flow-main'); + HiveTerminal.create({ + logEl: flow, + pillAnchor: flowMain, + 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); }, + tombstones_changed: (ev) => { applyTombstonesChanged(ev); }, + meta_inputs_changed: (ev) => { applyMetaInputsChanged(ev); }, + meta_update_running: (ev) => { applyMetaUpdateRunning(ev); }, + rebuild_queue_changed: (ev) => { applyRebuildQueueChanged(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(); + }, + // Re-sync the full /api/state snapshot on every SSE (re)connect. + // Live mutation events that fired during a disconnect window are + // never replayed, so without this the derived stores (approvals, + // questions, containers, …) would drift stale until a manual + // reload (issue #163). refreshState() replaces every store from + // the snapshot, so a missed event self-heals on reconnect. + onStreamOpen: () => { refreshState(); }, + 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(); + })(); })(); diff --git a/frontend/packages/dashboard/src/flow.html b/frontend/packages/dashboard/src/flow.html index 195f1c1..4032dc9 100644 --- a/frontend/packages/dashboard/src/flow.html +++ b/frontend/packages/dashboard/src/flow.html @@ -106,10 +106,10 @@ - - + + diff --git a/frontend/packages/dashboard/src/flow.js b/frontend/packages/dashboard/src/flow.js deleted file mode 100644 index 18f0a0f..0000000 --- a/frontend/packages/dashboard/src/flow.js +++ /dev/null @@ -1,423 +0,0 @@ -// /flow.html entry point (#406 step 2 — flow-specific split from app.js). -// -// Owns the full-page broker terminal, the operator-inbox derived store -// (populated from the broker stream), the inbox pill flyout, and the -// @-mention compose box. Pulls shared infrastructure (DOM helpers, side -// panel, OS notifications, path linkification) from `./common.js`. -// -// Does NOT contain the dashboard's tab renderers, mutation-event -// dispatchers, or refreshState — that's `./app.js` (the legacy entry -// kept until step 3 renames it to `tabs.js`). For now 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 { - $, el, - Panel, NOTIF, - appendLinkified, -} from './common.js'; - -(() => { - Panel.bind(); - NOTIF.bind(); - - // ─── operator inbox (derived from the broker message stream) ─────────── - // No longer shipped on `/api/state.operator_inbox`. The broker - // terminal 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, - file_refs: ev.file_refs || [], - }); - if (operatorInbox.length > INBOX_LIMIT) operatorInbox.length = INBOX_LIMIT; - return true; - } - function buildInboxListNode() { - if (!operatorInbox.length) return el('p', { class: 'empty' }, 'no messages'); - 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' }); - appendLinkified(body, m.body, m.file_refs); - li.append( - el('span', { class: 'msg-ts' }, fmt(m.at)), ' ', - el('span', { class: 'msg-from' }, m.from), ' ', - el('span', { class: 'msg-sep' }, '→ '), - body, - ); - ul.append(li); - } - return ul; - } - function renderInbox() { - // Flow page surfaces inbox as a pill that opens the side-panel - // flyout. Pill is hidden when empty; click handler below opens - // the panel with the freshest list. If the panel is already - // showing the inbox view, refresh its body in place so live - // messages land without a re-open. - const pill = $('inbox-pill'); - const pillCount = $('inbox-pill-count'); - if (pillCount) pillCount.textContent = String(operatorInbox.length); - if (pill) pill.hidden = operatorInbox.length === 0; - Panel.refresh('inbox', 'inbox · ' + operatorInbox.length, buildInboxListNode()); - } - - // Wire the inbox pill to open the side-panel flyout with the - // operator inbox. - const inboxPill = $('inbox-pill'); - if (inboxPill) { - inboxPill.addEventListener('click', () => { - Panel.openNamed('inbox', 'inbox · ' + operatorInbox.length, - buildInboxListNode()); - }); - } - - // ─── local containers cache (for compose autocomplete) ────────────────── - // The compose box's @-mention completion suggests known agent names. - // /index.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); - }).catch(() => { /* graceful: compose just shows `*` and nothing else */ }); - - // ─── 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, inbox refresh on operator-bound - // traffic, OS notifications). - (() => { - const flow = $('msgflow'); - if (!flow) return; - flow.innerHTML = ''; - const tsFmt = (n) => new Date(n * 1000).toISOString().slice(11, 19); - // Pulse the page banner whenever a broker event lands. (Note: - // post-#389 the `.banner` lives in the dashboard's