Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
25 changed files with 1559 additions and 68 deletions

View file

@ -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.

View file

@ -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.)

View file

@ -12,21 +12,21 @@ hive-c0re serves the dashboard at `/`.
It has a fixed chrome header at the top and a `<main>` 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 `<swarm> / <hive> // 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=<name>`, `body=<text>`) — drop an
operator-authored message into `<name>`'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<BuildLogHeader>` (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/<n>/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)` —
<!-- vale write-good.Passive = NO -->
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<String>`.
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.
<!-- vale write-good.Passive = YES -->
## 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<i64>` 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.

View file

@ -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.
**`<details>` open-state preservation:** any collapsible element
tagged with `data-restore-key="<stable-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
`<details data-restore-key>` consumer and is gone
— `snapshotOpenDetails()` / `restoreOpenDetails()` currently have
@ -252,6 +257,35 @@ that needs it rather than ripped out along with their one caller.
<!-- vale write-good.Passive = YES -->
**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 `<img>` `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 `<img>` 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 `<pre>`.
## Listener bind
Both bind their TCP listener with `SO_REUSEADDR` via

View file

@ -69,3 +69,7 @@ read that when you need the specifics, this doc doesn't duplicate it.
`marked.parse()` through DOMPurify into a `<div class="md">`. 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.

View file

@ -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/<name>.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));
}

View file

@ -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

View file

@ -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
`<hive-side-panel>` 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 `<body>` 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 `.<page>-main` padding. */
in by adding the wrapper (index.html); FL0W / ST4TS
are full-bleed with their own `.<page>-main` padding. */
.page-content {
padding: 0 1.5em;
}

View file

@ -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 <hive-side-panel> — side-effect import
import "@hive/shared/hive-warn.js"; // registers <hive-warn> — 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 <details> 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/<name>/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 <img> for an SVG, loaded via an <img> data: URI —
// <img>-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 <img> 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 <img>; every other file stays raw text in a <pre>.
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

View file

@ -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);

View file

@ -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;
}

View file

@ -0,0 +1,63 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyperhive // FL0W</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="/static/colors.css" />
<link rel="stylesheet" href="/static/theme.css" />
<link rel="stylesheet" href="/static/common.css" />
<link rel="stylesheet" href="/static/flow.css" />
</head>
<body class="flow-shell">
<!-- Minimal chrome: just a back link to the dashboard (served at /).
No full tabbar — the flow page is a dedicated full-viewport
terminal surface; navigating back is the only chrome needed. -->
<header class="page-header">
<a class="page-back" href="/">← dashboard</a>
<span class="page-title">FL0W</span>
<select
id="flow-agent-filter"
class="flow-filter"
title="filter timeline by agent"
aria-label="filter timeline by agent"
>
<option value="">all agents</option>
</select>
</header>
<!-- Main content: the full-viewport terminal. Padded for the
overlay header + composer so the first/last rows stay
reachable. -->
<main class="flow-main flow-main-slim">
<div class="terminal-wrap">
<div id="msgflow" class="live terminal">
<div class="meta">connecting…</div>
</div>
</div>
</main>
<!-- Fixed-overlay composer at the bottom. Same frosted treatment
as the header — symmetric framing, terminal goes edge-to-edge
between them. -->
<footer class="flow-composer">
<div id="op-compose" class="op-compose">
<span id="op-compose-prompt" class="op-compose-prompt">@—&gt;</span>
<textarea
id="op-compose-input"
class="op-compose-input"
placeholder="@agent message… (enter sends, shift+enter newline, tab completes @-mention)"
rows="1"
autocomplete="off"
></textarea>
<div id="op-compose-suggest" class="op-compose-suggest" hidden></div>
</div>
</footer>
<!-- Flow-specific bundle. Contains the broker terminal init + the
@-mention composer. Tab renderers etc. live in
`/static/tabs.js` which /flow.html doesn't load. -->
<script type="module" src="/static/flow.js" defer></script>
</body>
</html>

View file

@ -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 <footer> rather than
// in the flow chrome — `pulseBanner` no-ops on /flow.html since
// there's no element to find. Kept for parity if a future chrome
// change reintroduces a banner.
const banner = document.querySelector(".banner");
let bannerOffTimer = null;
function pulseBanner() {
if (!banner) return;
banner.classList.add("active");
if (bannerOffTimer) clearTimeout(bannerOffTimer);
bannerOffTimer = setTimeout(
() => banner.classList.remove("active"),
4000,
);
}
// Map of broker row id → rendered row element. Lets reply rows add
// a visual "↳ in reply to" indicator that links back to the parent.
// Bounded by the history window (~200 msgs from /dashboard/history),
// well within normal memory.
const msgRowMap = new Map();
function renderMsg(ev, api, glyph) {
const isReply = ev.in_reply_to != null;
const cls = "msgrow " + ev.kind + (isReply ? " msg-reply" : "");
const row = api.row(cls, "");
// Build via DOM so path anchors stay live + escape rules are
// automatic (text nodes don't need esc()).
const ts = document.createElement("span");
ts.className = "msg-ts";
ts.textContent = tsFmt(ev.at);
const arrow = document.createElement("span");
arrow.className = "msg-arrow";
arrow.textContent = glyph;
const from = document.createElement("span");
from.className = "msg-from";
from.textContent = ev.from;
const sep = document.createElement("span");
sep.className = "msg-sep";
sep.textContent = "→";
const to = document.createElement("span");
to.className = "msg-to";
to.textContent = ev.to;
const body = document.createElement("span");
body.className = "msg-body";
appendLinkified(body, ev.body, ev.file_refs);
// Reply thread indicator: a small "↳ reply to <from>" hint that
// shows which message this is responding to. If we have the parent
// in our row map, clicking scrolls it into view.
if (isReply) {
const replyTag = document.createElement("span");
replyTag.className = "msg-reply-tag";
const parentRow = msgRowMap.get(ev.in_reply_to);
if (parentRow) {
const link = document.createElement("a");
link.href = "#";
link.textContent = "↳ reply";
link.title = "scroll to parent message";
link.addEventListener("click", (e) => {
e.preventDefault();
parentRow.scrollIntoView({ behavior: "smooth", block: "nearest" });
parentRow.classList.add("msg-highlight");
setTimeout(() => parentRow.classList.remove("msg-highlight"), 1500);
});
replyTag.append(link);
} else {
replyTag.textContent = "↳ reply";
}
row.prepend(replyTag);
row.append(ts, " ", arrow, " ", from, " ", sep, " ", to, " ", body);
} else {
row.append(ts, " ", arrow, " ", from, " ", sep, " ", to, " ", body);
}
// Tag with the participants so the agent filter can match
// on `from`/`to`, and hide the row up-front if a filter is active.
row.dataset.from = ev.from;
row.dataset.to = ev.to;
if (!rowMatchesFilter(ev.from, ev.to)) row.classList.add("flow-hidden");
// Register this row so future replies can reference it.
if (ev.id != null && ev.id > 0) msgRowMap.set(ev.id, row);
return row;
}
// Sent→Delivered collapse. When a message directly wakes its
// recipient the broker emits `sent` then `delivered` for the same row
// id back-to-back, rendering two near-identical lines. We track each
// recent `sent` row and, when its `delivered` lands within
// COLLAPSE_SECS, upgrade that row in place (✓) instead of adding a
// second line. A delivery that arrives *later* (recipient was busy)
// stays a separate row so the latency remains visible.
const COLLAPSE_SECS = 3;
const recentSent = new Map(); // broker row id → { row, at }
function rememberSent(ev, row) {
if (ev.id == null || ev.id <= 0) return;
recentSent.set(ev.id, { row, at: epochSec(ev.at) });
// Bound the map — drop the oldest entries once it grows past a
// page of un-collapsed sends (insertion order = oldest first).
while (recentSent.size > 256) {
recentSent.delete(recentSent.keys().next().value);
}
}
// Returns true if the delivered event was folded into its sent row.
function collapseDelivered(ev) {
if (ev.id == null || ev.id <= 0) return false;
const s = recentSent.get(ev.id);
if (!s || epochSec(ev.at) - s.at > COLLAPSE_SECS) return false;
const arrow = s.row.querySelector(".msg-arrow");
if (arrow) arrow.textContent = "✓";
// Re-style the row as delivered (green ✓) — the collapsed line now
// represents the delivered state; it was sent + delivered as one.
s.row.classList.remove("sent");
s.row.classList.add("delivered");
s.row.title = "sent + delivered";
recentSent.delete(ev.id);
return true;
}
// Anchor the `↓ N new` pill in `.flow-main` rather than the
// default `.terminal-wrap` parent — see docs/web-ui/agent.md::Per-agent
// page (Terminal-wrap) for the backdrop-filter stacking-context
// gotcha (same shape on the flow page).
const flowMain = document.querySelector(".flow-main");
termCreate({
logEl: flow,
pillAnchor: flowMain,
historyUrl: "/api/dashboard/history",
// Server-side filter — only the kinds this page actually renders
// or routes (sent/delivered → broker terminal,
// container_state_changed/_removed → local autocomplete cache).
// Backend pre-parses the allow-list at subscribe time so the
// per-frame hot path is one HashSet::contains and the
// JSON-serialise is skipped entirely on irrelevant kinds. The
// dashboard tabs page (tabs.js) keeps the unfiltered subscribe
// since it routes every mutation kind into its derived stores.
streamUrl:
"/api/dashboard/stream?kinds=sent,delivered,container_state_changed,container_removed",
// Route through the SharedWorker — see docs/web-ui/shape.md (SSE
// multiplexing paragraph). Worker keys on the full URL incl.
// query string, so this filtered subscribe is its own upstream
// and won't accidentally share with tabs.js's wider subscribe.
streamFactory: openStream,
renderers: {
sent: (ev, api) => rememberSent(ev, renderMsg(ev, api, "→")),
delivered: (ev, api) => {
// Fold into the matching sent row when it just happened;
// otherwise render the delivery as its own line.
if (!collapseDelivered(ev)) renderMsg(ev, api, "✓");
},
// Maintain the local containers cache from the same stream
// (compose autocomplete reads from `flowContainers`). The
// dashboard's tab renderers aren't on this page, so we don't
// need to dispatch to applyContainerStateChanged etc. — just
// keep the autocomplete list current.
container_state_changed: (ev) => {
if (ev.container && ev.container.name) {
flowContainers.set(ev.container.name, ev.container);
populateAgentFilter(); // keep the filter dropdown current
}
},
container_removed: (ev) => {
flowContainers.delete(ev.name);
populateAgentFilter();
},
// Drop every other mutation kind silently — without this
// they'd fall through to the terminal module's default
// renderer and clutter the log with JSON dumps. The dashboard
// tabs handle these on the dashboard via tabs.js.
_default: () => {},
},
// Re-sync the local containers cache on every SSE (re)connect.
// Live mutation events that fired during a disconnect window
// are never replayed, so without this the compose autocomplete
// could drift stale.
onStreamOpen: () => {
fetch("/api/state")
.then((r) => (r.ok ? r.json() : null))
.then((s) => {
if (!s || !Array.isArray(s.containers)) return;
flowContainers.clear();
for (const c of s.containers) flowContainers.set(c.name, c);
})
.catch(() => {});
},
onLiveEvent: () => pulseBanner(),
});
})();
// ─── compose: @-mention with sticky recipient ───────────────────────────
(() => {
const input = $("op-compose-input");
const prompt = $("op-compose-prompt");
const suggest = $("op-compose-suggest");
if (!input || !prompt || !suggest) return;
const STORAGE_KEY = "hyperhive:op-compose:to";
let stickyTo = localStorage.getItem(STORAGE_KEY) || "";
let suggestActive = -1;
function renderPrompt() {
prompt.textContent = stickyTo ? `@${stickyTo}>` : "@—>";
}
function knownAgents() {
// Read live from the flow-local containers cache so newly-spawned
// agents become addressable without a manual reload.
const names = Array.from(flowContainers.values()).map((c) => c.name);
// `*` fans out to every registered agent (server-side
// broadcast_send).
names.unshift("*");
return names;
}
function autosize() {
input.style.height = "auto";
input.style.height = `${input.scrollHeight}px`;
}
/// Parse "@name body…" — return {to, body} when the input opens
/// with a known @-mention, otherwise null.
function parseAddressed(raw) {
const m = raw.match(/^@([\w*-]+)\s+([\s\S]+)$/);
if (!m) return null;
return { to: m[1], body: m[2] };
}
function hideSuggest() {
suggest.hidden = true;
suggest.replaceChildren();
suggestActive = -1;
}
function renderSuggest(matches) {
suggest.replaceChildren();
if (!matches.length) {
hideSuggest();
return;
}
for (let i = 0; i < matches.length; i += 1) {
const item = document.createElement("div");
item.className = "item" + (i === suggestActive ? " active" : "");
item.textContent = "@" + matches[i];
item.addEventListener("mousedown", (e) => {
e.preventDefault();
applySuggestion(matches[i]);
});
suggest.append(item);
}
suggest.hidden = false;
}
function applySuggestion(name) {
// Replace the partial @-token at the start with the full name.
const v = input.value;
const m = v.match(/^@(\S*)/);
if (m) {
input.value = `@${name} ` + v.slice(m[0].length).replace(/^\s+/, "");
} else {
input.value = `@${name} ` + v;
}
hideSuggest();
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
autosize();
}
function updateSuggest() {
const v = input.value;
// Only suggest while the cursor is still inside the @-token at the
// very start of the input — switching recipient is always "redirect
// this whole line." Once whitespace follows the token the recipient
// is chosen: the list must close, or it swallows the Enter that
// should send. Mid-message @-mentions stay literal.
const m = v.match(/^@(\S*)$/);
if (!m) {
hideSuggest();
return;
}
const partial = m[1].toLowerCase();
const matches = knownAgents().filter((n) =>
n.toLowerCase().startsWith(partial),
);
if (!matches.length) {
hideSuggest();
return;
}
if (suggestActive < 0 || suggestActive >= matches.length)
suggestActive = 0;
renderSuggest(matches);
}
async function submit() {
const raw = input.value.trim();
if (!raw) return;
let to;
let body;
const addressed = parseAddressed(raw);
if (addressed) {
to = addressed.to;
body = addressed.body.trim();
} else if (stickyTo) {
to = stickyTo;
body = raw;
} else {
flashError("no recipient — start with @name to address a message");
return;
}
if (!body) return;
const fd = new FormData();
fd.append("to", to);
fd.append("body", body);
input.disabled = true;
try {
// /op-send returns 200. The SSE channel carries the resulting
// MessageEvent → the terminal renders the sent row on its own;
// no /api/state refetch needed.
const resp = await fetch("/api/op-send", {
method: "POST",
body: new URLSearchParams(fd),
});
if (!resp.ok) {
flashError(`send failed: http ${resp.status}`);
return;
}
} catch (err) {
flashError(`send failed: ${err}`);
return;
} finally {
input.disabled = false;
}
stickyTo = to;
localStorage.setItem(STORAGE_KEY, to);
input.value = "";
autosize();
renderPrompt();
input.focus();
}
function flashError(msg) {
const flow = $("msgflow");
if (!flow) return;
const row = document.createElement("div");
row.className = "msgrow meta";
row.textContent = "✗ " + msg;
// Append at the bottom so the error is visible — the terminal
// renders newest-last, so inserting before firstChild would place
// the error at the top (oldest end) and hide it from view.
flow.append(row);
// Scroll the terminal wrap so the error is in view.
const wrap = flow.parentElement;
if (wrap) wrap.scrollTop = wrap.scrollHeight;
}
input.addEventListener("input", () => {
autosize();
updateSuggest();
});
input.addEventListener("keydown", (e) => {
if (!suggest.hidden) {
if (e.key === "ArrowDown") {
const items = suggest.querySelectorAll(".item");
suggestActive = (suggestActive + 1) % items.length;
renderSuggest(Array.from(items).map((i) => i.textContent.slice(1)));
e.preventDefault();
return;
}
if (e.key === "ArrowUp") {
const items = suggest.querySelectorAll(".item");
suggestActive = (suggestActive - 1 + items.length) % items.length;
renderSuggest(Array.from(items).map((i) => i.textContent.slice(1)));
e.preventDefault();
return;
}
if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
const active = suggest.querySelector(".item.active");
if (active) {
applySuggestion(active.textContent.slice(1));
e.preventDefault();
return;
}
}
if (e.key === "Escape") {
hideSuggest();
e.preventDefault();
return;
}
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
});
input.addEventListener("blur", () => {
// Defer so a click on a suggestion item (mousedown) lands first.
setTimeout(hideSuggest, 100);
});
renderPrompt();
autosize();
})();
})();

View file

@ -17,7 +17,7 @@
button / shared links keep you on the same view. JS owns
the show/hide. -->
<header class="dashboard-chrome">
<!-- Pages menu: the standalone pages (stats, API docs) and the
<!-- Pages menu: the standalone pages (flow, stats, API docs) and the
swarm UI. Filled by links-menu.js from /api/state. The pages link
back to `/`, which is this dashboard. -->
<div class="links-menu" id="links-menu"></div>
@ -82,7 +82,7 @@
<!-- (Peer hives are no longer a tab — they render as a headline
under 4G3NTS. See tab-pane-agents.) -->
<!-- ST4TS is a standalone page reached from the pages
<!-- FL0W and ST4TS are standalone pages reached from the pages
menu at the left of this strip, not tabs — the strip holds only
in-page tabs. -->
</nav>
@ -216,8 +216,8 @@
</div>
</section>
<!-- ST4TS lives on its own page (`/stats.html`).
Its DOM only exists there — when tabs.js boots
<!-- ST4TS and FL0W live on their own pages (`/stats.html`,
`/flow.html`). Their DOM only exists there — when tabs.js boots
on this page the corresponding renderers no-op silently (each
guard is `if (!el) return`). -->
</main>
@ -240,6 +240,12 @@
<p class="meta" id="hive-rev" hidden></p>
</footer>
<!-- Slide-in detail panel (clicked file previews, applied config)
is a <hive-side-panel> element
(@hive/shared/side-panel.js) — the Panel singleton in common.js
creates + appends it to <body> lazily on first use, so nothing
needs to be pre-declared here. -->
<!-- Selection action bar. Sticky-bottom strip that slides into
view when one or more agent cards is selected (click the icon
to toggle). See docs/web-ui/dashboard.md::Selection bar for the bulk
@ -265,8 +271,9 @@
</div>
<!-- Single bundled entry — tabs.js is the dashboard tabs surface;
esbuild folds
@hive/shared/terminal.js into tabs.js. -->
flow.html has its own flow.js bundle. esbuild folds
@hive/shared/terminal.js and the marked npm package into
tabs.js. -->
<script type="module" src="/static/tabs.js" defer></script>
</body>
</html>

View file

@ -1,5 +1,5 @@
// Pages menu in the dashboard chrome: one button that opens a popover
// linking the standalone pages (stats, API docs) and the swarm UI.
// linking the standalone pages (flow, stats, API docs) and the swarm UI.
// Modelled on the swarm UI's `LinksMenu`. The swarm entry only exists
// when `/api/state` carries `swarm_public_url`, so a hive without a
// configured swarm domain shows no dead link.
@ -10,6 +10,7 @@ import { el } from "@hive/shared/dom.js";
/** Menu entries for a state snapshot. `external` opens in a new tab. */
export function menuLinks(state) {
const links = [
{ icon: "📡", label: "flow", url: "/flow.html" },
{ icon: "📊", label: "stats", url: "/stats.html" },
{ icon: "🧬", label: "api docs", url: "/api/docs/" },
];

View file

@ -1,6 +1,6 @@
/* ─── /stats.html — hive-wide turn-stats rollup ────────────────────
Extracted from the dashboard ST4TS tab. Minimal chrome:
the back-link header +
Extracted from the dashboard ST4TS tab. Same minimal-
chrome pattern as /flow.html: the back-link header +
title (.page-header / .page-back / .page-title) live in
@hive/shared/chrome.css.
`.hive-stats-table` lives in common.css. Only the ST4TS-specific

View file

@ -11,7 +11,7 @@
<link rel="stylesheet" href="/static/stats.css" />
</head>
<body class="stats-shell">
<!-- Minimal chrome: back link + title —
<!-- Minimal chrome: back link + title. Same pattern as flow.html —
no full dashboard tabbar. Back link points to the
dashboard (served at /). -->
<header class="page-header">

View file

@ -5,9 +5,12 @@
// docs/web-ui/shape.md::Shape (shared by both) for the broader contract.
//
// Pure helpers (DOM, side panel, path linkification)
// live in `./common.js`.
// live in `./common.js`; the flow-page surface (broker
// terminal, @-mention composer) lives in `./flow.js` — `flow.html` and
// `index.html` each load their own bundle.
// 4G3NTS (containers) domain lives in `./agents.js`.
import { marked } from "marked";
import { $, openStream, initStickyTop } from "./common.js";
import { el } from "@hive/shared/dom.js";
import { bindAsyncForms } from "@hive/shared/forms.js";
@ -41,6 +44,11 @@ import {
} from "./kept.js";
import { renderLinksMenu } from "./links-menu.js";
// mdNode (in common.js) reads `window.marked` for the markdown side
// panel preview path. Set it here on the dashboard entry so file
// previews work; flow.js does the same for the flow-page entry.
window.marked = marked;
(() => {
initStickyTop();
@ -198,10 +206,12 @@ import { renderLinksMenu } from "./links-menu.js";
// looked up against `MUTATION_HANDLERS`; unknown kinds (broker
// `sent` / `delivered`, anything new the backend adds) silently
// no-op. On (re)connect we kick a refreshState() to recover events
// lost during the disconnect window.
// lost during the disconnect window (same pattern as flow.js's
// onStreamOpen).
//
// The dashboard subscribes to `/api/dashboard/stream` and ignores broker
// traffic client-side.
// Both the dashboard and /flow.html subscribe to `/api/dashboard/stream`
// and filter client-side — the dashboard ignores broker traffic
// and FL0W ignores mutation events.
const MUTATION_HANDLERS = {
transient_set: applyTransientSet,
transient_cleared: applyTransientCleared,
@ -266,9 +276,11 @@ import { renderLinksMenu } from "./links-menu.js";
// ─── tab routing ───────────────────────────────────────────────────────
// Hash-based: `#agents` / `#rebuild` / `#meta` / `#buildlogs` / `#kept`
// activate the matching pane on the dashboard. Empty
// hash defaults to 4G3NTS. ST4TS is NOT a tab — it is a separate page
// (`/stats.html`) reached from the pages menu (links-menu.js). Tab routing
// only applies when the tab DOM is present.
// hash defaults to 4G3NTS. FL0W and ST4TS are NOT tabs —
// they're separate pages (`/flow.html`, `/stats.html`) reached from
// the pages menu (links-menu.js). Tab routing only
// applies when the tab DOM is present (e.g. not on the flow page
// itself, where these elements don't exist and the loop no-ops).
// The shared hash-routed tab strip (@hive/shared/tabs.js) owns the
// tab/pane toggle + aria-selected + routing; `activateTab` runs the
// per-tab side-effects via the strip's onShow. Constructed below (after
@ -325,4 +337,7 @@ import { renderLinksMenu } from "./links-menu.js";
// — no per-renderer hookup needed.
refreshTabCounts();
setInterval(refreshTabCounts, 1000);
// Flow-specific code (broker terminal init + the @-mention composer)
// lives in ./flow.js — loaded only by /flow.html.
})();

View file

@ -1,6 +1,6 @@
/* ─── page chrome: back-link header ────────────────────────────────
The minimal sticky "frosted bar" header used by every standalone page
reached from the dashboard's pages menu — /stats. A back
reached from the dashboard's pages menu — /flow, /stats. A back
link to the dashboard on the left, then a page title and, on /stats, a
sub-tab nav. The operator dashboard uses its own richer
sticky chrome (`.dashboard-chrome`); these classes cover the simple

View file

@ -1,6 +1,7 @@
// <MessagesPage> — `/messages`: every message between agents, and from the
// operator, reminders and compaction to an agent, as the swarm's controller
// streams them (`GET /api/messages/stream`), across all hives.
// streams them (`GET /api/messages/stream`). The read side of the hive
// dashboard's flow page, across all hives.
//
// The stream sends the chosen window of history first, then the live tail. A
// message an agent drained with an in-turn `recv` is missing, because it starts

Some files were not shown because too many files have changed in this diff Show more