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