diff --git a/docs/web-ui/README.md b/docs/web-ui/README.md
index 5cf78189..800d868f 100644
--- a/docs/web-ui/README.md
+++ b/docs/web-ui/README.md
@@ -12,7 +12,7 @@ what you actually do here.
Everything starts at the **dashboard**, served at `/`. A pages menu
(the `☰` button at the left of the tab strip) links to the other
-surfaces — Stats, the API docs and the swarm UI — and each of
+surfaces — Flow, Stats, the API docs and the swarm UI — and each of
those pages links back to the dashboard.
The dashboard is where you'll spend most of your time. It's a single
@@ -26,8 +26,9 @@ page with five tabs:
- **K3PT ST4T3** — kept state from agents with no container
(tombstones), with a purge button per entry.
-Usage stats live on their own page, reached from the pages menu:
-**Stats** (`/stats.html`, swarm-wide usage stats).
+Two things live on their own page instead, reached from the pages
+menu: **Flow** (`/flow.html`, the raw live message stream across the
+whole swarm) and **Stats** (`/stats.html`, swarm-wide usage stats).
Each agent also has its own page — a full terminal view of that
agent's session, reachable by selecting its name anywhere in the
@@ -84,7 +85,7 @@ JS renders — no full-page reloads.
side panel, slash commands, and per-agent endpoints.
- **[Shape (shared by both)](shape.md)** — the SPA skeleton, SSE
multiplexing, terminal pane, listener bind, per-agent relative
- paths, `data-async` form pattern, atomic repaint.
+ paths, `data-async` form pattern, side panel, atomic repaint.
- **[CSS theme variables](css-vars.md)** — the Catppuccin Mocha custom
properties declared once in `base.css` and the rule that per-page
stylesheets reference (never redeclare) them.
diff --git a/docs/web-ui/css-vars.md b/docs/web-ui/css-vars.md
index 90ba5f9c..4bd90e65 100644
--- a/docs/web-ui/css-vars.md
+++ b/docs/web-ui/css-vars.md
@@ -12,7 +12,7 @@ swap touches only the first:
what the app references and it **never changes on a swap**.
Both files live in `frontend/packages/shared/src/`. Per-page stylesheets
-(`common.css`, `dashboard.css`, `agent.css`)
+(`common.css`, `dashboard.css`, `flow.css`, `agent.css`)
reference the **semantic** names and must **not** redeclare them or reach
for the raw `--baseNN` slots directly. (`base.css` holds only the shared
`body` typography — it references the palette but no longer defines it.)
diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md
index 4e712c54..0a308514 100644
--- a/docs/web-ui/dashboard.md
+++ b/docs/web-ui/dashboard.md
@@ -12,21 +12,21 @@ hive-c0re serves the dashboard at `/`.
It has a fixed chrome header at the top and a `` that shows exactly
one tab pane at a time. The URL hash (`#agents`, `#rebuild`, `#meta`,
`#buildlogs`, `#kept`) drives which pane is
-active; hash changes don't reload the page. ST4TS
-is a standalone page reached from the pages menu, not from the
+active; hash changes don't reload the page. FL0W and ST4TS
+are standalone pages reached from the pages menu, not from the
dashboard tab strip.
**Chrome header** (fixed, overlays the active tab pane):
- **Pages menu**: a `☰` button at the left of the chrome (`links-menu.js`)
- opening a popover with links to **stats**
+ opening a popover with links to **flow** (`/flow.html`), **stats**
(`/stats.html`), **api docs** (`/api/docs/`) and **swarm** (the swarm
UI at the swarm apex domain, opened in a new tab). The swarm entry
comes from `state.swarm_public_url`, which hive-c0re reads from
`HIVE_SWARM_PUBLIC_URL` (set from `services.hyperhive.swarm.domain`);
with no domain set there is no swarm entry. The standalone pages link back to `/`.
- **Tab strip**: `◆ 4G3NTS ◆`, `◆ R3BU1LD QU3U3 ◆`, `◆ M3T4 1NPUTS ◆`,
- `◆ BUILD L0GS ◆`, `◆ K3PT ST4T3 ◆`. In-page tabs only — ST4TS
- lives on its own page, reached from the pages menu. Count pills on
+ `◆ BUILD L0GS ◆`, `◆ K3PT ST4T3 ◆`. In-page tabs only — FL0W / ST4TS
+ live on their own pages, reached from the pages menu. Count pills on
4G3NTS (stale containers), R3BU1LD QU3U3 (rebuilds in flight) and
K3PT ST4T3 (kept-state entries).
- **Banner-thin** (`░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░`)
@@ -46,6 +46,10 @@ dashboard tab strip.
to ` / // h1ve-c0re` once `hive_name` / `swarm_name`
arrive in the state snapshot.
+The FL0W page uses a slim header (a `← dashboard` back-link + the page
+title) rather than the dashboard tab strip — it's a standalone surface,
+not a tab pane.
+
## 4G3NTS tab
**C0NTAINERS** — live containers as a flat list, one row per
@@ -208,7 +212,7 @@ entry with that id pre-expanded.
Hive-wide turn statistics, aggregated across every agent's
`hyperhive-turn-stats.sqlite` for the selected window. Not a dashboard
tab — a standalone page reached from the dashboard's pages menu,
-minimal chrome (a back-link and a title). Distinct from
+same minimal chrome as `/flow.html`. Distinct from
each agent's own `/stats` page (which carries the per-agent trend
charts): ST4TS is the swarm-level rollup.
@@ -245,6 +249,42 @@ the model id, longest match wins) mapping to
`{ input, output, cache_read, cache_write }` USD-per-million-token
prices. Models not covered fall back to hive-c0re's built-in estimate.
+## FL0W page (`/flow.html`)
+
+A dedicated full-page terminal (not a tab pane — a separate HTML
+page). Slim chrome: a `← dashboard` back-link, the FL0W
+title, and the agent-filter select (see below). No dashboard tab
+strip — FL0W is a standalone surface reached from the pages menu.
+
+**MESS4GE FL0W** — live broker tail wrapped in a `.terminal-wrap`.
+Cold load backfills the last ~200 messages from `/api/dashboard/history`;
+live frames arrive on `/api/dashboard/stream`. Each row is one broker
+event — `sent` or `delivered` — with `from → to: body`. When a `sent`
+and `delivered` event for the same message arrive within 3 seconds
+(immediate delivery to a live recipient), the page upgrades the row in
+place (arrow becomes green ✓, title reads "sent + delivered") instead of
+rendering two near-identical lines — genuine delivery latency (recipient
+was busy) still appears as a second row. Each row carries `data-from` /
+`data-to` attributes; an **agent filter select in the FL0W header** narrows
+the timeline to messages involving the chosen agent (matched on `from` OR
+`to`), with non-matching rows hidden (`.flow-hidden` class). The selection
+persists in localStorage across reloads; new rows pick up the active filter
+at render time. The dropdown populates from the live container list and
+stays current on add/remove; a saved selection survives even if that agent
+isn't currently listed.
+
+The row is a `flex-wrap: wrap` container holding ts / arrow / from / sep
+/ to chips inline; the **body wraps to its own full-width line below** the
+chips (`flex: 1 1 100%`) so long timestamps and agent names never push the
+body into a narrow trailing column. `min-width: 0` keeps
+`word-break: break-word` effective so the body doesn't force the row wider
+than its container. Sticky-bottom autoscroll + "↓ N new" pill. Below the
+stream sits a terminal-style compose box: `@name` picks the recipient
+(sticky via localStorage; autocomplete from the live container list,
+Tab/Enter to confirm; `@*` broadcasts). `POST /api/op-send` drops
+`{from:"operator", to, body}` into the broker; the resulting SSE frame
+re-renders the terminal row. The operator addresses the root agent as `@root`.
+
## Container row
A full-height **square agent icon** (5em, capped) on the left. The
@@ -537,6 +577,11 @@ below — some endpoints aren't in it yet.
not yet delivered and sets `acked_at = now`. Returns
`{ "marked": N }`. Agent name validated against
`[a-z0-9_-]`, 1-63 chars; 400 on bad input.
+- `POST /api/op-send` (`to=`, `body=`) — drop an
+ operator-authored message into ``'s inbox. `to=*` fans
+ out to every registered agent. Returns 200; the broker
+ `Sent` event re-renders the message-flow terminal without a
+ snapshot refetch. Used by the compose textbox under MESS4GE FL0W.
- `GET /api/build-logs/{agent}?limit=N` — most-recent build log
headers for one agent, newest first. Returns
`Vec` (JSON): `id`, `agent`, `kind`, `cmdline`,
@@ -564,14 +609,20 @@ below — some endpoints aren't in it yet.
and the host form. Canonicalises + verifies the path stays
inside the allow-list, refuses anything but a regular file,
refuses `/agents//claude` / `config` subtrees, truncates
- bodies at 1 MiB. No dashboard page calls it since the flow page
- stopped existing.
+ bodies at 1 MiB. Selection-time backing for the inline path-link
+ preview.
- The broker forwarder still detects path tokens **server-side at
- broker-message ingest**: it calls `scan_validated_paths(body)` —
+
+ The broker forwarder detects which tokens *are* path links
+ **server-side at broker-message ingest**, not client-side:
+ it calls `scan_validated_paths(body)` —
same allow-list helper the read endpoint uses — and attaches
the verified file tokens to the event as `file_refs: Vec`.
- No dashboard page reads that list either.
+ The client trusts that list and linkifies only those tokens,
+ so directories, missing files, and forbidden subtrees never
+ become anchors. No probe endpoint, no client-side regex
+ heuristics. Historical messages get the same treatment on
+ `/api/dashboard/history` backfill.
- `GET /api/reminders` — list pending reminders for the
dashboard's queued-reminders panel.
- `GET /api/stats-hive?window=<1h|4h|24h|3d|7d|30d|all>` — hive-wide
@@ -588,6 +639,9 @@ below — some endpoints aren't in it yet.
- `GET /api/dashboard/stream` — unified live event channel:
broker `sent` / `delivered`, plus the mutation events listed
below. Each frame carries `seq`.
+- `GET /api/dashboard/history` — last ~200 broker messages
+ (wrapped as `{ seq, events }`) for the message-flow
+ terminal's backfill on page load.
## Dashboard event channel
@@ -598,7 +652,9 @@ payload):
- `sent` / `delivered` — broker traffic, mirrored from the
intra-process channel by a forwarder task. Both carry `id: i64`
(the broker row id) and `in_reply_to: Option` for thread
- rendering.
+ rendering. The dashboard message-flow terminal renders reply
+ rows with a `↳ reply` tag that scroll-highlights the parent
+ row on selection. Used by the message-flow terminal renderer.
- `transient_set` (name, transient_kind, since_unix) /
`transient_cleared` (name) — lifecycle action spinners. The
client ticks the elapsed-seconds badge off `since_unix`
@@ -640,7 +696,7 @@ payload):
forms that mutate non-event-derived state** (PURG3 +
meta-update, since tombstones + meta_inputs aren't event-
shaped yet). Every other section — transients,
-containers —
+containers, message flow —
derives from `/api/dashboard/stream` after the initial snapshot,
maintaining its own client-side store and applying events on
top.
diff --git a/docs/web-ui/shape.md b/docs/web-ui/shape.md
index a79560c5..45635d89 100644
--- a/docs/web-ui/shape.md
+++ b/docs/web-ui/shape.md
@@ -19,15 +19,15 @@
from `@hive/shared` (was `hive-fr0nt`); the dashboard ships one
CSS bundle per page (`colors.css` + `theme.css` + `common.css`,
loaded by every page, plus a page-specific bundle —
- `dashboard.css` /
+ `dashboard.css` / `flow.css` /
`stats.css`);
`common.css` inlines `@hive/shared`'s
`base.css` + `terminal.css` + `tabs.css` + `chrome.css` +
`pill.css` via esbuild's `@import` resolution.
`terminal.js` exports `{ create, linkify }` as ES module
members; a back-compat shim in the IIFE bodies still exposes
- a `window.HiveTerminal` global for callers that need it. The
- per-agent `#live` log runs on
+ a `window.HiveTerminal` global for callers that need it. The dashboard's
+ `#msgflow` and the per-agent `#live` log are both backed by
this terminal — sticky-bottom autoscroll, "↓ N new" pill,
history backfill, SSE plumbing all live there. Each page
registers a kind→renderer map; unknown kinds fall through to
@@ -51,8 +51,10 @@
dashboard stream carries broker `Sent` / `Delivered` (mirrored
by a forwarder task from the broker's intra-process channel)
plus mutation events (`transient_set` / `transient_cleared`). Each frame carries a `seq`. The
- agent's matching backfill endpoint is `GET /events/history` (last 2000
- `LiveEvent`s wrapped in `{ seq, events }`).
+ matching backfill endpoint is `GET /api/dashboard/history` (last
+ ~200 broker messages wrapped in `{ seq, events }`) on the
+ dashboard and `GET /events/history` (last 2000 `LiveEvent`s
+ also wrapped in `{ seq, events }`) on the agent.
**One unified channel**: browsers cap concurrent SSE
connections per origin (~6 in Chrome). Using one channel per
domain would exhaust this budget on a live hive; dispatching
@@ -85,8 +87,9 @@
## Shared terminal pane
-The shared terminal factory in `@hive/shared/terminal.js` backs the
-per-agent page's scrollable log stream (`#live`). The factory wires up
+The shared terminal factory in `@hive/shared/terminal.js` backs both
+surfaces' scrollable log streams (`#msgflow` on the dashboard, `#live`
+on the per-agent page). The factory wires up
sticky-bottom autoscroll, a "↓ N new" pill, history backfill, and
SSE replay. Pages register a `kind → renderer` map; unknown kinds
fall through to a JSON-dump note row. The factory ships three row
@@ -243,7 +246,9 @@ values rather than per-row caches.
**`` open-state preservation:** any collapsible element
tagged with `data-restore-key=""` survives the
refresh. `snapshotOpenDetails()` walks managed sections before
-render, `restoreOpenDetails()` re-applies after. The
+render, `restoreOpenDetails()` re-applies after. Long-content
+drill-ins (file previews, diffs) now open in the
+**side panel** (see below) rather than expanding inline. The
dashboard's own answered-questions history list was the only
`` consumer and is gone
— `snapshotOpenDetails()` / `restoreOpenDetails()` currently have
@@ -252,6 +257,35 @@ that needs it rather than ripped out along with their one caller.
+**Side panel (dashboard):** long content opens in a drawer that
+swipes in from the right — a singleton `#side-panel` with a
+titled header, a close button, and a scrollable body. Closes on
+the button, selecting the backdrop, or `Escape`. `Panel.open(title,
+node)` swaps the body; the JS builder for file previews
+renders into it. **The
+drawer width is drag-to-resize**: a thin 6px hit-strip on
+the left edge captures pointer events, resizes the drawer in
+real-time (pointer capture keeps dragging even if the cursor
+outpaces the handle), and persists the chosen width to
+`localStorage` (key `hyperhive:side-panel-width`) so it
+survives page reload. CSS clamps the width between `min-width: 320px`
+and `max-width: 96vw`; the viewport-resize handler re-clamps
+persisted values after a window shrink. File
+previews are type-aware:
+
+- **Markdown** (`.md` / `.markdown`) — a `rendered` / `plain`
+ tabbed view: `rendered` (default) is the vendored `marked`
+ bundle (`GET /static/marked.js`), `plain` is the raw source.
+- **SVG** (`.svg`) — a `rendered` / `source` tabbed view;
+ `rendered` shows the image via an `` `data:` URI (the
+ browser's secure static mode, so an untrusted SVG can't run
+ scripts), `source` shows the raw markup.
+- **Raster images** (`.png` / `.jpg` / `.gif` / `.webp` /
+ `.bmp` / `.ico` / `.avif`) — render as an `` pointed at
+ `/api/state-file`, which serves them as binary with their
+ real content-type (text files stay UTF-8-lossy `text/plain`).
+- **Everything else** — raw text in a `
`.
+
## Listener bind
Both bind their TCP listener with `SO_REUSEADDR` via
diff --git a/docs/web-ui/terminal-rendering.md b/docs/web-ui/terminal-rendering.md
index 85762198..aae8b9af 100644
--- a/docs/web-ui/terminal-rendering.md
+++ b/docs/web-ui/terminal-rendering.md
@@ -69,3 +69,7 @@ read that when you need the specifics, this doc doesn't duplicate it.
`marked.parse()` through DOMPurify into a `
`. Applied to
any `TermMsg` with `body_format: "markdown"`.
+## Dashboard side (not covered here)
+
+The main dashboard's message-flow pane is a different shape: broker
+messages render as `.msgrow` grid lines, not agent-terminal rows.
diff --git a/frontend/packages/dashboard/build.mjs b/frontend/packages/dashboard/build.mjs
index 92632559..18d6e14c 100644
--- a/frontend/packages/dashboard/build.mjs
+++ b/frontend/packages/dashboard/build.mjs
@@ -1,5 +1,5 @@
// esbuild build for @hive/dashboard: one JS+CSS bundle per page (the
-// dashboard at `/`, stats at its own
+// dashboard at `/`, flow/stats at their own
// `.html`), each named after its own entry point below — see the
// `entryPoints`/`for` lists for the exact map, not repeated here to
// avoid this comment drifting out of sync with the real build steps.
@@ -29,7 +29,7 @@ mkdirSync(staticDir(""), { recursive: true });
// follow-up once asset sizes warrant it). esbuild writes each entry
// to `static/.js` based on the entryPoint basename.
await build({
- entryPoints: [src("tabs.js"), src("stats.js")],
+ entryPoints: [src("tabs.js"), src("flow.js"), src("stats.js")],
outdir: staticDir(""),
bundle: true,
format: "esm",
@@ -58,7 +58,7 @@ await build({
// Stream-worker entry. Lives in a separate bundle: SharedWorker
// scripts run in a different global (`self` is the worker scope, no
-// `window`) so they can't be inlined into tabs.js / stats.js. Output is
+// `window`) so they can't be inlined into tabs.js / flow.js. Output is
// at `static/stream-worker.js`; common.js's `openStream` references
// `/static/stream-worker.js` as the SharedWorker URL. `format: 'iife'`
// matches the classic-script load (`new SharedWorker(url, name)` with
@@ -89,6 +89,7 @@ for (const entry of [
"theme.css",
"common.css",
"dashboard.css",
+ "flow.css",
"stats.css",
]) {
await build({
@@ -100,7 +101,7 @@ for (const entry of [
});
}
-for (const html of ["index.html", "stats.html"]) {
+for (const html of ["index.html", "flow.html", "stats.html"]) {
copyFileSync(src(html), dist(html));
}
diff --git a/frontend/packages/dashboard/src/agents.js b/frontend/packages/dashboard/src/agents.js
index 4e529a46..62d4f72e 100644
--- a/frontend/packages/dashboard/src/agents.js
+++ b/frontend/packages/dashboard/src/agents.js
@@ -588,7 +588,7 @@ function buildContainerLi(c, opts) {
export function renderContainers(s) {
const root = $("containers-section");
// #containers-section only exists on the dashboard. tabs.js is the
- // bundle for that page only.
+ // bundle for that page only (flow.html loads flow.js instead).
// Belt-and-suspenders for any future page that adds tabs.js
// without a #containers-section — matches the
// no-op-when-target-absent convention the other renderers
diff --git a/frontend/packages/dashboard/src/common.css b/frontend/packages/dashboard/src/common.css
index bc1d7c22..be07df35 100644
--- a/frontend/packages/dashboard/src/common.css
+++ b/frontend/packages/dashboard/src/common.css
@@ -265,14 +265,309 @@ code {
border-color: var(--amber);
}
+/* ─── file-preview / diff panel (common.js Panel) ───────────────────
+ Side-panel content rendered by openFilePanel / buildTabbedPreview
+ in common.js; all three pages load the Panel singleton. */
+.diff-panel {
+ display: flex;
+ flex-direction: column;
+ gap: 0.6em;
+}
+.diff-base-tabs {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4em;
+}
+.diff-base-tab {
+ background: transparent;
+ border: 1px solid var(--border);
+ color: var(--muted);
+ font: inherit;
+ font-size: 0.85em;
+ padding: 0.2em 0.7em;
+ cursor: pointer;
+}
+.diff-base-tab:hover {
+ color: var(--fg);
+}
+.diff-base-tab.active {
+ color: var(--purple);
+ border-color: var(--purple);
+ background: color-mix(in srgb, var(--purple) 8%, transparent);
+}
+/* Image / tabbed file preview */
+.preview-host {
+ margin-top: 0.5em;
+}
+.img-preview {
+ display: block;
+ max-width: 100%;
+ height: auto;
+ margin: 0 auto;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ /* checkerboard so transparent regions of the image read clearly */
+ background: repeating-conic-gradient(var(--border) 0% 25%, var(--bg) 0% 50%)
+ 50% / 18px 18px;
+}
+
+/* Path linkification — agents drop pointer strings into messages;
+ clicking the anchor opens the file in the side panel. */
+.path-link {
+ color: var(--blue);
+ text-decoration: underline dotted;
+ cursor: pointer;
+}
+.path-link:hover {
+ color: var(--amber);
+}
+/* File-preview body — rendered inside the side panel. */
+.path-preview-body {
+ background: var(--bg);
+ border: 1px solid var(--border);
+ padding: 0.5em 0.7em;
+ margin: 0;
+ white-space: pre-wrap;
+ word-break: break-word;
+ font-size: 0.85em;
+ color: var(--fg);
+}
+
+/* page chrome (.page-header / .page-back / .page-title — the standalone
+ pages' sticky back-link bar) moved to @hive/shared/chrome.css, imported
+ at the top of this file. */
+
+/* `#msgflow` is a shared `.live` pane inside `.terminal-wrap`. The
+ msgrow / msg-* rules below power the /flow.html full-page terminal. */
+.live .msgrow {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 0.5em;
+ padding: 0.1em 0;
+ text-indent: 0;
+}
+.live .msgrow .msg-body {
+ flex: 1 1 100%;
+ min-width: 0;
+}
+.live .msgrow.sent .msg-arrow {
+ color: var(--cyan);
+}
+.live .msgrow.delivered .msg-arrow {
+ color: var(--green);
+}
+/* Reply-thread rendering: indented border-left + muted reply tag. */
+.live .msgrow.msg-reply {
+ padding-left: 1.2em;
+ border-left: 2px solid var(--border);
+ margin-left: 0.6em;
+}
+.msg-reply-tag {
+ color: var(--muted);
+ font-size: 0.8em;
+ white-space: nowrap;
+ order: -1;
+}
+.msg-reply-tag a {
+ color: var(--muted);
+ text-shadow: none;
+ font-weight: normal;
+}
+.msg-reply-tag a:hover {
+ color: var(--fg);
+}
+@keyframes msg-highlight-fade {
+ from {
+ background: color-mix(in srgb, var(--purple) 18%, transparent);
+ }
+ to {
+ background: transparent;
+ }
+}
+.msg-highlight {
+ animation: msg-highlight-fade 1.5s ease-out forwards;
+}
+.msg-ts {
+ color: var(--muted);
+ font-size: 0.85em;
+}
+.msg-arrow {
+ font-weight: bold;
+}
+.msg-from {
+ color: var(--amber);
+}
+.msg-sep {
+ color: var(--muted);
+}
+.msg-to {
+ color: var(--pink);
+}
+.msg-body {
+ color: var(--fg);
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+/* ─── operator compose box ──────────────────────────────────────────
+ Sits inside `.terminal-wrap` on both /flow.html and the dashboard
+ CALL tab. Dashed top separator mirrors the agent terminal prompt. */
+.op-compose {
+ position: relative;
+ display: flex;
+ align-items: flex-start;
+ gap: 0.6em;
+ padding: 0.55em 0.8em;
+ border-top: 1px dashed var(--purple-dim);
+}
+.op-compose-prompt {
+ color: var(--purple);
+ text-shadow: 0 0 4px currentColor;
+ font-weight: bold;
+ white-space: nowrap;
+ user-select: none;
+ padding-top: 0.15em;
+}
+.op-compose-input {
+ flex: 1;
+ background: transparent;
+ color: var(--fg);
+ border: none;
+ font-family: inherit;
+ font-size: 1em;
+ line-height: 1.4;
+ resize: none;
+ overflow: hidden;
+ max-height: 8em;
+ padding: 0;
+}
+.op-compose-input:focus {
+ outline: none;
+}
+.op-compose-input::placeholder {
+ color: var(--muted);
+}
+.op-compose-suggest {
+ position: absolute;
+ left: 3em;
+ bottom: 100%;
+ z-index: 50;
+ background: var(--bg-elev);
+ border: 1px solid var(--purple-dim);
+ border-radius: 4px;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45);
+ list-style: none;
+ margin: 0;
+ padding: 0.3em 0;
+ min-width: 12em;
+ max-height: 14em;
+ overflow-y: auto;
+}
+.op-compose-suggest .item {
+ padding: 0.35em 0.9em;
+ cursor: pointer;
+ font-size: 0.9em;
+ color: var(--muted);
+}
+.op-compose-suggest .item.active,
+.op-compose-suggest .item:hover {
+ background: var(--border);
+ color: var(--purple);
+}
+
+/* ─── side panel ─────────────────────────────────────────────────
+ Long content (file previews, diffs, applied config) opens in
+ a drawer that swipes in from the right — used by both /flow.html and
+ the dashboard. The drawer chrome itself (backdrop, drawer, resize
+ handle, header, title, close button) is now the shared
+ `` element (@hive/shared/side-panel.js); what's left
+ here is the drag cursor-override escape hatch (has to be a global,
+ light-DOM rule — it reaches every element on the page during a drag,
+ not just the shared element's own shadow tree) and the styling for
+ the panel's own content types, which the shared element's shadow tree
+ can never reach (see @hive/shared/side-panel/hive-side-panel.css's
+ header comment). The `hive-side-panel .md …` rules below use the
+ element's own tag name as the selector root — no compatibility class
+ needed, the tag name already uniquely identifies the light-DOM
+ instance `common.js`'s `sidePanel` creates. */
+body.side-panel-resizing {
+ user-select: none;
+}
+body.side-panel-resizing * {
+ cursor: ew-resize !important;
+}
+hive-side-panel .md {
+ color: var(--fg);
+ line-height: 1.5;
+}
+hive-side-panel .md > :first-child {
+ margin-top: 0;
+}
+hive-side-panel .md > :last-child {
+ margin-bottom: 0;
+}
+hive-side-panel .md p {
+ margin: 0.5em 0;
+}
+hive-side-panel .md h1,
+hive-side-panel .md h2,
+hive-side-panel .md h3,
+hive-side-panel .md h4 {
+ color: var(--purple);
+ margin: 0.9em 0 0.4em;
+}
+hive-side-panel .md code {
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 2px;
+ padding: 0.1em 0.3em;
+ font-size: 0.9em;
+}
+hive-side-panel .md pre {
+ background: var(--crust);
+ border: 1px solid var(--border);
+ padding: 0.5em 0.7em;
+ overflow-x: auto;
+ margin: 0.5em 0;
+}
+hive-side-panel .md pre code {
+ background: none;
+ border: none;
+ padding: 0;
+}
+hive-side-panel .md a {
+ color: var(--cyan);
+}
+hive-side-panel .md ul,
+hive-side-panel .md ol {
+ margin: 0.4em 0;
+ padding-left: 1.5em;
+}
+hive-side-panel .md blockquote {
+ border-left: 3px solid var(--purple-dim);
+ padding-left: 0.8em;
+ margin: 0.4em 0;
+ color: var(--muted);
+}
+hive-side-panel .md table {
+ border-collapse: collapse;
+ margin: 0.5em 0;
+}
+hive-side-panel .md th,
+hive-side-panel .md td {
+ border: 1px solid var(--border);
+ padding: 0.2em 0.5em;
+}
+
/* ─── page-content gutter ─────────────────────────────────────────────
Inverted-gutter layout: the dashboard keeps `` full-bleed
and wraps their padded content in a single `.page-content` div that
carries the horizontal gutter. Full-width chrome (the tab strip, the
footer) then spans edge-to-edge for free,
with no negative-margin breakout hacks. Only matches on pages that opt
- in by adding the wrapper (index.html); ST4TS
- is full-bleed with its own `.-main` padding. */
+ in by adding the wrapper (index.html); FL0W / ST4TS
+ are full-bleed with their own `.-main` padding. */
.page-content {
padding: 0 1.5em;
}
diff --git a/frontend/packages/dashboard/src/common.js b/frontend/packages/dashboard/src/common.js
index b4b82031..3d1449fd 100644
--- a/frontend/packages/dashboard/src/common.js
+++ b/frontend/packages/dashboard/src/common.js
@@ -1,8 +1,12 @@
-// Shared dashboard helpers used by index.html (./tabs.js): pure DOM helpers,
-// the shared-worker SSE pipe, and the sticky top region.
+// Shared dashboard helpers used by both index.html (./tabs.js) and
+// flow.html (./flow.js): pure DOM helpers, the side-panel singleton,
+// and the path-link / file-preview infrastructure for the side panel.
+import { linkify as termLinkify } from "@hive/shared/terminal.js";
import { el } from "@hive/shared/dom.js";
+import "@hive/shared/side-panel.js"; // registers — side-effect import
import "@hive/shared/hive-warn.js"; // registers — side-effect import
+import DOMPurify from "dompurify";
// ─── helpers ────────────────────────────────────────────────────────────
export const $ = (id) => document.getElementById(id);
@@ -339,6 +343,225 @@ export function openBuildLogStream(id, pre, { onDone, onError } = {}) {
return es;
}
+// ─── side panel ─────────────────────────────────────────────────────────
+// Singleton drawer that swipes in from the right. Long content
+// (file previews, applied config)
+// opens here via `sidePanel.open(title, node)` instead of expanding
+// inline. Body is swapped on each open; closing just slides out so
+// the content stays visible through the transition.
+//
+// The instance is created once, eagerly, at module evaluation time — ES
+// modules run after the document is parsed (same timing `defer` scripts
+// get), so `document.body` already exists here. Exported directly (not
+// wrapped in a forwarding object) — callers use the element's own
+// `open`/`openNamed`/`refresh`/`close`/`currentOwner` methods, see
+// @hive/shared/side-panel.js for what they do. `common.css` reaches the
+// slotted content via a plain `hive-side-panel .md …` tag-name selector
+// (no compatibility class needed — the element's own tag name already
+// uniquely identifies it in the light DOM).
+export const sidePanel = document.createElement("hive-side-panel");
+document.body.append(sidePanel);
+
+// ─── path linkification ─────────────────────────────────────────────────
+// Agents constantly drop pointer strings into messages (it's the 1
+// KiB-cap escape hatch). Anything matching the
+// PATH_RE patterns becomes a clickable anchor; clicking expands an
+// inline 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//state/...` in agent
+// outputs and the link will resolve.
+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;
+}
+// A 2-tab file preview: a "rendered" tab (default) + a raw-text tab.
+// `renderRendered()` produces the rendered-tab node fresh on each
+// switch; `plainText` backs the raw tab; `plainLabel` names it.
+function buildTabbedPreview(renderRendered, plainText, plainLabel) {
+ const tabs = el("div", { class: "diff-base-tabs" });
+ const host = el("div", { class: "preview-host" });
+ function show(mode) {
+ for (const b of tabs.children) {
+ b.classList.toggle("active", b.dataset.mode === mode);
+ }
+ host.replaceChildren(
+ mode === "plain"
+ ? el("pre", { class: "path-preview-body" }, plainText)
+ : renderRendered(),
+ );
+ }
+ for (const [mode, label] of [
+ ["rendered", "rendered"],
+ ["plain", plainLabel],
+ ]) {
+ const b = el(
+ "button",
+ { type: "button", class: "diff-base-tab", "data-mode": mode },
+ label,
+ );
+ b.addEventListener("click", () => show(mode));
+ tabs.append(b);
+ }
+ show("rendered");
+ return el("div", {}, tabs, host);
+}
+// Rendered for an SVG, loaded via an data: URI —
+// -loaded SVG runs in the browser's secure static mode (no
+// scripts, no external fetches), so an untrusted SVG from an
+// agent's state dir can't execute code in the dashboard.
+function svgImage(text) {
+ const img = el("img", { class: "img-preview", alt: "SVG preview" });
+ img.addEventListener("error", () => {
+ img.replaceWith(
+ el("div", { class: "meta" }, "(could not render — see the source tab)"),
+ );
+ });
+ img.src = "data:image/svg+xml," + encodeURIComponent(text);
+ return img;
+}
+// Marked-rendered markdown node (raw text fallback if `marked`
+// failed to load). `text` is untrusted (agent-authored state files served
+// verbatim by /api/state-file) — the parsed HTML is run through DOMPurify
+// before it touches innerHTML, since markdown can carry raw HTML/script
+// tags that `marked` itself no longer strips (v5+ dropped the built-in
+// sanitizer).
+function mdNode(text) {
+ const div = el("div", { class: "md" });
+ if (window.marked && typeof window.marked.parse === "function") {
+ window.marked.setOptions({ breaks: true, gfm: true });
+ div.innerHTML = DOMPurify.sanitize(window.marked.parse(text));
+ // marked autolinks URLs but leaves them same-tab — open externally
+ // so a click never navigates away from the dashboard.
+ div.querySelectorAll("a[href]").forEach((a) => {
+ a.target = "_blank";
+ a.rel = "noopener noreferrer";
+ });
+ } else {
+ div.textContent = text;
+ }
+ return div;
+}
+// Raster image extensions the preview renders as an pointed
+// straight at /api/state-file (served binary with a real
+// content-type). SVG is handled on the text path instead.
+const RASTER_RE = /\.(png|jpe?g|gif|webp|bmp|ico|avif)$/i;
+// Lazy-load `path` from /api/state-file into the side panel.
+// Markdown + SVG get a rendered/plain tabbed view; raster images
+// render as an ; every other file stays raw text in a
.
+async function openFilePanel(path) {
+ if (RASTER_RE.test(path)) {
+ const img = el("img", { class: "img-preview", alt: path });
+ img.addEventListener("error", () => {
+ img.replaceWith(
+ el(
+ "pre",
+ { class: "path-preview-body" },
+ "(could not load image — it may be missing or over the preview size cap)",
+ ),
+ );
+ });
+ img.src = "/api/state-file?path=" + encodeURIComponent(path);
+ sidePanel.open("↳ " + path, img);
+ return;
+ }
+ const isMd = /\.(md|markdown)$/i.test(path);
+ const isSvg = /\.svg$/i.test(path);
+ const view = el("div");
+ view.textContent = "(fetching…)";
+ sidePanel.open("↳ " + path, view);
+ try {
+ const text = await fetchStateFile(path);
+ if (isSvg) {
+ view.replaceChildren(
+ buildTabbedPreview(() => svgImage(text), text, "source"),
+ );
+ } else if (isMd) {
+ view.replaceChildren(
+ buildTabbedPreview(() => mdNode(text), text, "plain"),
+ );
+ } else {
+ view.replaceChildren(el("pre", { class: "path-preview-body" }, text));
+ }
+ } catch (e) {
+ view.textContent = "error: " + (e.message || e);
+ }
+}
+export function makePathLink(path) {
+ const anchor = el(
+ "a",
+ {
+ href: "#",
+ class: "path-link",
+ title: "open " + path + " in panel",
+ },
+ path,
+ );
+ anchor.addEventListener("click", (e) => {
+ e.preventDefault();
+ openFilePanel(path);
+ });
+ return anchor;
+}
+// Append a plain-text run, with bare http(s) URLs turned into clickable
+// links via the shared terminal linkifier.
+export function appendText(parent, s) {
+ if (!s) return;
+ parent.appendChild(termLinkify(s));
+}
+// Append `text` to `parent` as a mix of text nodes + path anchors.
+// `refs` is the server-attached `file_refs` array (verified-file
+// tokens that appear in `text`); each occurrence of a ref becomes a
+// clickable anchor that opens the file in the side panel. Anything
+// not in `refs` stays plain text. No client-side regex, no probe
+// endpoint — the server saw the body first and made the call. When
+// `refs` is empty/missing we just emit plain text.
+export function appendLinkified(parent, text, refs) {
+ if (text == null) return;
+ const str = String(text);
+ const tokens = (refs || []).slice();
+ if (!tokens.length) {
+ appendText(parent, str);
+ return;
+ }
+ // Walk the string left-to-right, at each step looking for the
+ // next occurrence of any token. Longest-first tie-break so a
+ // ref like `/agents/foo/state/x.md` wins over a (hypothetical)
+ // shorter token that prefixes it. O(text * refs) worst case;
+ // refs is bounded server-side to whatever fits in a body, so
+ // this stays cheap.
+ tokens.sort((a, b) => b.length - a.length);
+ let i = 0;
+ while (i < str.length) {
+ let bestStart = -1;
+ let bestToken = null;
+ for (const t of tokens) {
+ const idx = str.indexOf(t, i);
+ if (idx === -1) continue;
+ if (
+ bestStart === -1 ||
+ idx < bestStart ||
+ (idx === bestStart && t.length > bestToken.length)
+ ) {
+ bestStart = idx;
+ bestToken = t;
+ }
+ }
+ if (bestStart === -1) {
+ appendText(parent, str.slice(i));
+ break;
+ }
+ if (bestStart > i) {
+ appendText(parent, str.slice(i, bestStart));
+ }
+ parent.appendChild(makePathLink(bestToken));
+ i = bestStart + bestToken.length;
+ }
+}
+
// ─── sticky top region ───────────────────────────────────────────────
// One sticky container wrapping the page's chrome (tab bar / page
// header), built once by wrapping the existing chrome element. A page
diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css
index e11e27bd..9dae6251 100644
--- a/frontend/packages/dashboard/src/dashboard.css
+++ b/frontend/packages/dashboard/src/dashboard.css
@@ -180,6 +180,8 @@ body.dashboard-shell {
display: none;
}
+/* FL0W is a separate page (`/flow.html`) — its full-viewport
+ styling lives in flow.css. */
.banner {
text-align: center;
margin: 0 0 1em 0;
@@ -617,7 +619,10 @@ footer .banner-thin {
flex-wrap: wrap;
gap: 0.6em;
padding: 0.55em 1em;
- background: color-mix(in srgb, var(--bg) 74%, transparent);
+ background: var(
+ --flow-frost-bg,
+ color-mix(in srgb, var(--bg) 74%, transparent)
+ );
-webkit-backdrop-filter: blur(12px) saturate(140%);
backdrop-filter: blur(12px) saturate(140%);
border-top: 1px solid var(--purple);
diff --git a/frontend/packages/dashboard/src/flow.css b/frontend/packages/dashboard/src/flow.css
new file mode 100644
index 00000000..e76d5094
--- /dev/null
+++ b/frontend/packages/dashboard/src/flow.css
@@ -0,0 +1,111 @@
+/* ─── /flow.html — full-page chat ─────────────────────────────────
+ See docs/web-ui/dashboard.md::FL0W page for the page-vs-pane rationale.
+ Shape mirrors the per-agent live page (frosted-glass header +
+ composer, full-viewport terminal). */
+
+:root {
+ /* Approximate height of the flow chrome (the slim back-link header).
+ The slug banner lives at the page footer on the dashboard, omitted
+ on /flow.html since the full-viewport terminal has no normal-flow
+ footer position. */
+ --flow-header-h: 3.6em;
+ --flow-composer-h: 3.6em;
+ --flow-frost-bg: color-mix(in srgb, var(--bg) 74%, transparent);
+ --flow-frost-blur: blur(12px) saturate(140%);
+}
+
+body.flow-shell {
+ /* Override the dashboard's centred-max-width layout — flow owns
+ the whole viewport. */
+ max-width: none;
+ margin: 0;
+ padding: 0;
+ height: 100vh;
+ overflow: hidden;
+ background:
+ radial-gradient(
+ ellipse 80% 60% at 50% 0%,
+ color-mix(in srgb, var(--purple) 6%, transparent) 0%,
+ transparent 60%
+ ),
+ var(--bg);
+}
+
+.flow-main {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ overflow: hidden;
+}
+/* flow.html now has a slim header (back link only) instead of the full
+ tabbar — reduce the top padding to match the smaller header height. */
+body.flow-shell .flow-main-slim {
+ padding-top: calc(var(--flow-header-h) + 0.4em);
+}
+.flow-main .terminal-wrap {
+ position: absolute;
+ inset: 0;
+ border: 0;
+ background: transparent;
+ box-shadow: none;
+ border-radius: 0;
+ margin: 0;
+ padding: 0;
+}
+.flow-main .live {
+ position: absolute;
+ inset: 0;
+ height: auto;
+ max-height: none;
+ padding-top: calc(var(--flow-header-h) + 0.8em);
+ padding-bottom: calc(var(--flow-composer-h) + 0.8em);
+ scroll-padding-top: calc(var(--flow-header-h) + 0.8em);
+ scroll-padding-bottom: calc(var(--flow-composer-h) + 0.8em);
+ overflow: auto;
+}
+/* Tail pill (↓ N new): bottom offset clears the floating composer.
+ Pill is anchored on .flow-main (not .terminal-wrap) so the
+ backdrop-filter stacking context doesn't trap its z-index — see
+ docs/web-ui/agent.md::Per-agent page (Terminal-wrap) for the same
+ gotcha on the agent page. */
+.flow-main .tail-pill {
+ bottom: calc(var(--flow-composer-h) + 0.6em);
+ z-index: 35;
+}
+
+.flow-composer {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ z-index: 30;
+ min-height: var(--flow-composer-h);
+ background: var(--flow-frost-bg);
+ -webkit-backdrop-filter: var(--flow-frost-blur);
+ backdrop-filter: var(--flow-frost-blur);
+ border-top: 1px solid var(--purple-dim);
+ box-shadow: 0 -6px 18px rgba(0, 0, 0, 0.35);
+ padding: 0.4em 1em;
+}
+
+/* Agent filter: a select in the FL0W header narrows the
+ timeline to messages involving one agent. Non-matching rows get
+ `.flow-hidden`. */
+.flow-filter {
+ font-family: inherit;
+ font-size: 0.85em;
+ background: var(--bg-elev);
+ color: var(--amber);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ padding: 0.25em 0.5em;
+ margin-left: auto;
+}
+.flow-filter:focus {
+ outline: 1px solid var(--purple);
+}
+.msgrow.flow-hidden {
+ display: none;
+}
diff --git a/frontend/packages/dashboard/src/flow.html b/frontend/packages/dashboard/src/flow.html
new file mode 100644
index 00000000..bb13ddba
--- /dev/null
+++ b/frontend/packages/dashboard/src/flow.html
@@ -0,0 +1,63 @@
+
+
+
+
+
+ hyperhive // FL0W
+
+
+
+
+
+
+
+
+
+ ← dashboard
+ FL0W
+
+
+
+
+
+
+
+
connecting…
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/packages/dashboard/src/flow.js b/frontend/packages/dashboard/src/flow.js
new file mode 100644
index 00000000..c721f695
--- /dev/null
+++ b/frontend/packages/dashboard/src/flow.js
@@ -0,0 +1,479 @@
+// /flow.html entry point. Owns the full-page broker terminal and the
+// @-mention compose box. Pulls shared infrastructure (DOM helpers,
+// side panel, path linkification) from `./common.js`. FL0W is the pure
+// event firehose.
+//
+// Does NOT contain the dashboard's tab renderers, mutation-event
+// dispatchers, or refreshState — that's `./tabs.js`, loaded only by
+// the dashboard. 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 { $, appendLinkified, openStream, initStickyTop } from "./common.js";
+import { el } from "@hive/shared/dom.js";
+import { epochSec } from "./util.js";
+
+(() => {
+ initStickyTop();
+
+ // ─── local containers cache (for compose autocomplete) ──────────────────
+ // The compose box's @-mention completion suggests known agent names.
+ // the dashboard (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 effect (banner pulse).
+ (() => {
+ 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
-
@@ -240,6 +240,12 @@
+
+
+ flow.html has its own flow.js bundle. esbuild folds
+ @hive/shared/terminal.js and the marked npm package into
+ tabs.js. -->