From 06c23e0bdc97e0a2cb8c2f631c2211ec6b7e33d0 Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 25 May 2026 02:14:13 +0200 Subject: [PATCH] dashboard: extract flow.js as separate /flow.html entry (#406 step 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits the single bundle into two: `app.js` (entry for /index.html — tab renderers + tab routing + refreshState) and a new `flow.js` (entry for /flow.html — operator inbox derived store + inbox pill flyout + broker terminal + @-mention composer). Both bundles inline `./common.js` (DOM helpers, Panel, NOTIF, path linkification). ## What `flow.js` owns - Operator inbox derived store (`operatorInbox`, `INBOX_LIMIT`, `inboxAppendFromEvent`, `buildInboxListNode`, `renderInbox`) + inbox-pill click wiring - Broker terminal init (`HiveTerminal.create({ logEl: msgflow, ... })`) with `renderMsg`, `pulseBanner`, `msgRowMap` reply-thread indicator, and the renderers map for `sent` / `delivered` broker rows - @-mention composer (`#op-compose-input` IIFE — sticky recipient, autocomplete, parseAddressed, /op-send POST) - A small local `flowContainers` cache for the composer's autocomplete, refreshed on cold load + on every SSE reconnect via `onStreamOpen`, and live-updated by `container_state_changed` / `container_removed` SSE events (the dashboard's `containersState` lives in `app.js` and isn't available here) ## What `app.js` no longer does - Drops the inbox derived store, the bindFlowInboxPill IIFE, the broker-terminal IIFE, and the composer IIFE — all moved - Drops the `renderInbox()` call in `refreshState` (dashboard has no #inbox-section element) - Drops `setTabCount('flow', operatorInbox.length)` — the FL0W tab count lives in flow.js now (cross-page count broadcasting is a future follow-up; the slot currently stays hidden on /index.html) - Drops the `window.HiveTerminal` global — the bare-import pattern in common.js / flow.js made it unused on the dashboard ## What changes for /flow.html - ` + + 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