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