diff --git a/frontend/packages/dashboard/build.mjs b/frontend/packages/dashboard/build.mjs index 9b7d2a7..b18ebf0 100644 --- a/frontend/packages/dashboard/build.mjs +++ b/frontend/packages/dashboard/build.mjs @@ -1,15 +1,21 @@ // esbuild build for @hive/dashboard. Output layout (`dist/`): // // dist/index.html served by the Rust router at GET / -// 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/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/dashboard.css served at /static/dashboard.css // (@import resolved from @hive/shared) // -// 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. +// 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. import { build } from 'esbuild'; import { mkdirSync, copyFileSync, rmSync } from 'node:fs'; @@ -24,12 +30,13 @@ const staticDir = (p) => resolve(here, 'dist', 'static', p); rmSync(dist(''), { recursive: true, force: true }); mkdirSync(staticDir(''), { recursive: true }); -// Bundle the JS entry. ES-module output, browser target, no minify +// Bundle both JS entries. ES-module output, browser target, no minify // (line-aligned source aids debugging; minification belongs in a later -// follow-up once asset sizes warrant it). +// follow-up once asset sizes warrant it). esbuild writes each entry +// to `static/.js` based on the entryPoint basename. await build({ - entryPoints: [src('app.js')], - outfile: staticDir('app.js'), + entryPoints: [src('app.js'), src('flow.js')], + outdir: staticDir(''), bundle: true, format: 'esm', platform: 'browser', diff --git a/frontend/packages/dashboard/src/app.js b/frontend/packages/dashboard/src/app.js index 8fdc74f..53b0cd9 100644 --- a/frontend/packages/dashboard/src/app.js +++ b/frontend/packages/dashboard/src/app.js @@ -1,14 +1,17 @@ -// 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`. +// /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.) // -// #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. +// #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. -import { create as termCreate, linkify as termLinkify } from '@hive/shared/terminal.js'; import { marked } from 'marked'; import { $, el, esc, form, @@ -17,13 +20,9 @@ import { makePathLink, appendText, appendLinkified, } from './common.js'; -// 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 }; +// 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. window.marked = marked; (() => { @@ -1086,63 +1085,7 @@ window.marked = marked; }); }, 1000); - // ─── 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()); - } + // Operator-inbox derived store moved to ./flow.js (#406 step 2). const APPROVAL_TAB_KEY = 'hyperhive:approvals:tab'; // Derived approval state — cold-loaded from /api/state, then mutated @@ -1855,7 +1798,8 @@ window.marked = marked; // refetch. syncQuestionsFromSnapshot(s); renderQuestions(); - renderInbox(); + // (renderInbox now lives in ./flow.js — dashboard has no + // #inbox-section element to render into.) syncApprovalsFromSnapshot(s); renderApprovals(); renderMetaInputs(s); @@ -1943,8 +1887,11 @@ window.marked = marked; } } setTabCount('system', sysCount); - // FL0W — operator inbox count. - setTabCount('flow', operatorInbox.length); + // 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. } // Poll the state stores on a 1s tick to keep the pill counts in // sync. The state stores are mutated synchronously by every SSE @@ -1953,335 +1900,7 @@ window.marked = marked; refreshTabCounts(); setInterval(refreshTabCounts, 1000); - // 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(); - })(); + // 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. })(); diff --git a/frontend/packages/dashboard/src/flow.html b/frontend/packages/dashboard/src/flow.html index 4032dc9..195f1c1 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 new file mode 100644 index 0000000..07cfddd --- /dev/null +++ b/frontend/packages/dashboard/src/flow.js @@ -0,0 +1,428 @@ +// /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, linkify as termLinkify } from '@hive/shared/terminal.js'; +import { + $, el, + Panel, NOTIF, + appendLinkified, +} from './common.js'; + +// Common helpers (mdNode in common.js) probe `window.marked` at use +// time. Flow page doesn't actually call mdNode (it has no file-preview +// path); we set it anyway so any future preview-on-flow lands hot. +window.HiveTerminal = { create: termCreate, linkify: termLinkify }; + +(() => { + 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