diff --git a/docs/web-ui/README.md b/docs/web-ui/README.md
index 800d868f..5cf78189 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 — Flow, Stats, the API docs and the swarm UI — and each of
+surfaces — 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,9 +26,8 @@ page with five tabs:
- **K3PT ST4T3** — kept state from agents with no container
(tombstones), with a purge button per entry.
-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).
+Usage stats live on their own page, reached from the pages menu:
+**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
@@ -85,7 +84,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, side panel, atomic repaint.
+ paths, `data-async` form pattern, 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 4bd90e65..90ba5f9c 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`, `flow.css`, `agent.css`)
+(`common.css`, `dashboard.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 0a308514..4e712c54 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. FL0W and ST4TS
-are standalone pages reached from the pages menu, not from the
+active; hash changes don't reload the page. ST4TS
+is a standalone page 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 **flow** (`/flow.html`), **stats**
+ opening a popover with links to **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 — FL0W / ST4TS
- live on their own pages, reached from the pages menu. Count pills on
+ `◆ BUILD L0GS ◆`, `◆ K3PT ST4T3 ◆`. In-page tabs only — ST4TS
+ lives on its own page, 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,10 +46,6 @@ 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
@@ -212,7 +208,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,
-same minimal chrome as `/flow.html`. Distinct from
+minimal chrome (a back-link and a title). Distinct from
each agent's own `/stats` page (which carries the per-agent trend
charts): ST4TS is the swarm-level rollup.
@@ -249,42 +245,6 @@ 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
@@ -577,11 +537,6 @@ 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`,
@@ -609,20 +564,14 @@ 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. Selection-time backing for the inline path-link
- preview.
+ bodies at 1 MiB. No dashboard page calls it since the flow page
+ stopped existing.
-
- The broker forwarder detects which tokens *are* path links
- **server-side at broker-message ingest**, not client-side:
- it calls `scan_validated_paths(body)` —
+ The broker forwarder still detects path tokens **server-side at
+ broker-message ingest**: 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`.
- 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.
+ No dashboard page reads that list either.
- `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
@@ -639,9 +588,6 @@ 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
@@ -652,9 +598,7 @@ 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. 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.
+ rendering.
- `transient_set` (name, transient_kind, since_unix) /
`transient_cleared` (name) — lifecycle action spinners. The
client ticks the elapsed-seconds badge off `since_unix`
@@ -696,7 +640,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, message flow —
+containers —
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 45635d89..a79560c5 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` / `flow.css` /
+ `dashboard.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 dashboard's
- `#msgflow` and the per-agent `#live` log are both backed by
+ a `window.HiveTerminal` global for callers that need it. The
+ per-agent `#live` log runs on
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,10 +51,8 @@
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
- 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.
+ agent's matching backfill endpoint is `GET /events/history` (last 2000
+ `LiveEvent`s wrapped in `{ seq, events }`).
**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
@@ -87,9 +85,8 @@
## Shared terminal pane
-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
+The shared terminal factory in `@hive/shared/terminal.js` backs the
+per-agent page's scrollable log stream (`#live`). 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
@@ -246,9 +243,7 @@ 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. Long-content
-drill-ins (file previews, diffs) now open in the
-**side panel** (see below) rather than expanding inline. The
+render, `restoreOpenDetails()` re-applies after. The
dashboard's own answered-questions history list was the only
`` consumer and is gone
— `snapshotOpenDetails()` / `restoreOpenDetails()` currently have
@@ -257,35 +252,6 @@ 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 aae8b9af..85762198 100644
--- a/docs/web-ui/terminal-rendering.md
+++ b/docs/web-ui/terminal-rendering.md
@@ -69,7 +69,3 @@ 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 18d6e14c..92632559 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 `/`, flow/stats at their own
+// dashboard at `/`, stats at its 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("flow.js"), src("stats.js")],
+ entryPoints: [src("tabs.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 / flow.js. Output is
+// `window`) so they can't be inlined into tabs.js / stats.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,7 +89,6 @@ for (const entry of [
"theme.css",
"common.css",
"dashboard.css",
- "flow.css",
"stats.css",
]) {
await build({
@@ -101,7 +100,7 @@ for (const entry of [
});
}
-for (const html of ["index.html", "flow.html", "stats.html"]) {
+for (const html of ["index.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 62d4f72e..4e529a46 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 (flow.html loads flow.js instead).
+ // bundle for that page only.
// 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 be07df35..bc1d7c22 100644
--- a/frontend/packages/dashboard/src/common.css
+++ b/frontend/packages/dashboard/src/common.css
@@ -265,309 +265,14 @@ 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); FL0W / ST4TS
- are full-bleed with their own `.-main` padding. */
+ in by adding the wrapper (index.html); ST4TS
+ is full-bleed with its 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 3d1449fd..b4b82031 100644
--- a/frontend/packages/dashboard/src/common.js
+++ b/frontend/packages/dashboard/src/common.js
@@ -1,12 +1,8 @@
-// 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.
+// Shared dashboard helpers used by index.html (./tabs.js): pure DOM helpers,
+// the shared-worker SSE pipe, and the sticky top region.
-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);
@@ -343,225 +339,6 @@ 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 9dae6251..e11e27bd 100644
--- a/frontend/packages/dashboard/src/dashboard.css
+++ b/frontend/packages/dashboard/src/dashboard.css
@@ -180,8 +180,6 @@ 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;
@@ -619,10 +617,7 @@ footer .banner-thin {
flex-wrap: wrap;
gap: 0.6em;
padding: 0.55em 1em;
- background: var(
- --flow-frost-bg,
- color-mix(in srgb, var(--bg) 74%, transparent)
- );
+ background: 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
deleted file mode 100644
index e76d5094..00000000
--- a/frontend/packages/dashboard/src/flow.css
+++ /dev/null
@@ -1,111 +0,0 @@
-/* ─── /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
deleted file mode 100644
index bb13ddba..00000000
--- a/frontend/packages/dashboard/src/flow.html
+++ /dev/null
@@ -1,63 +0,0 @@
-
-
-
-
-
- hyperhive // FL0W
-
-
-
-
-
-
-
-
-
- ← dashboard
- FL0W
-
-
-
-
-
-
-
-
connecting…
-
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend/packages/dashboard/src/flow.js b/frontend/packages/dashboard/src/flow.js
deleted file mode 100644
index c721f695..00000000
--- a/frontend/packages/dashboard/src/flow.js
+++ /dev/null
@@ -1,479 +0,0 @@
-// /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,12 +240,6 @@
-
-
+ esbuild folds
+ @hive/shared/terminal.js into tabs.js. -->