docs: update web-ui.md for tabbed layout, topology tree, per-agent overhaul (#366 #373 #381)

This commit is contained in:
lexis 2026-05-25 00:45:56 +02:00
parent 91a72b5cbb
commit e9cce17828

View file

@ -100,23 +100,69 @@ Both bind their listeners with `SO_REUSEADDR` via
exponential backoff capped at 2s) so an nspawn restart that races exponential backoff capped at 2s) so an nspawn restart that races
the previous process's socket release resolves itself. the previous process's socket release resolves itself.
## Dashboard sections (top to bottom) ## Dashboard layout
1. **Notification row**`🔔 enable notifications` button when The dashboard (`/`) has a fixed chrome header at the top and a
permission ungranted; `🔕 mute / 🔔 unmute` toggle once granted; `<main>` that shows exactly one tab pane at a time. The URL hash
inline "unsupported / blocked" message when applicable. Sits (`#swarm`, `#call`, `#system`) drives which pane is active; hash
under the banner. changes don't reload the page. FL0W is a separate full-page
2. **C0NTAINERS** — live containers with their action surface. terminal at `/flow.html` — its tab-strip entry is a cross-page
Pulsing red banner at the top of this section if any two link (`◆ FL0W ◆ →`), not a pane swap.
sub-agents hash to the same port (`port_conflicts` from
`/api/state`): the operator must rename one of them and **Chrome header** (fixed, overlays the active tab pane):
rebuild. `lifecycle::{spawn,rebuild}` also preflight this and - **Tab strip**: `◆ SW4RM ◆`, `◆ Y3R C4LL ◆`, `◆ SYST3M ◆`, and
refuse with a clear error message naming the conflicting agent. `◆ FL0W ◆ →` (page link). Count pills on SW4RM (container count)
3. **K3PT ST4T3** — destroyed-but-state-kept tombstones (size + and Y3R C4LL (pending approvals + questions); FL0W pill mirrors
age + claude-creds badge). Two actions: `⊕ R3V1V3` (queues a the operator inbox length (hidden when zero).
Spawn approval; existing state is reused), `PURG3` (wipes - **Notification controls**: `🔔 enable notifications` when
state + applied dirs; `POST /purge-tombstone/{name}`). permission ungranted; `🔕 mute / 🔔 unmute` toggle once granted.
4. **M3T4 1NPUTS** — inputs in `meta/flake.lock` the operator can Always visible in the chrome regardless of active tab.
- **Banner-thin** (`░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░`)
— sits below the tab strip.
### SW4RM tab
**C0NTAINERS** — live containers rendered as a depth-first
tree using `ContainerView.parent` (populated by `topology.rs`).
Each container's row is prefixed with ASCII tree glyphs (`├─`,
`└─`, `│ ` continuation columns) showing the agent
parent/child hierarchy. When every container has `parent = null`
(flat topology) the tree collapses to a plain list with no
glyphs. Children are sorted alphabetically within each parent;
roots likewise. Cycles in the parent graph are tolerated —
orphaned containers (not reachable from any root) are appended
as roots so no agent disappears. Pulsing red banner at the top
of this section if any two sub-agents hash to the same port
(`port_conflicts` from `/api/state`): the operator must rename
one of them and rebuild. `lifecycle::{spawn,rebuild}` also
preflight this and refuse with a clear error message naming the
conflicting agent.
`↻ UPD4TE 4LL` button appears above the containers list when any
agent is stale.
### Y3R C4LL tab
Things blocked on operator decision — approvals and questions
share a tab because they're the same concept ("something is
waiting on you").
**P3NDING APPR0VALS** — the queue (see "Approval card" below).
The R3QU3ST SP4WN form lives at the top of this section.
**M1ND H4S QU3STI0NS** — pending operator-targeted `ask`
questions (amber pulsing border). Free-text fallback always
rendered alongside any option list; `multi=true` renders options
as checkboxes; submit merges selections + free text
comma-joined. Each row has a `✗ CANC3L` button. Questions with
a `ttl_seconds` show a `⏳ MM:SS` chip; the host-side watchdog
auto-cancels with `[expired]` when the deadline fires.
### SYST3M tab
Passive / rare-interaction state.
**M3T4 1NPUTS** — inputs in `meta/flake.lock` the operator can
selectively `nix flake update`, rendered as an indented tree: selectively `nix flake update`, rendered as an indented tree:
every fetched input at every depth (`hyperhive`, every fetched input at every depth (`hyperhive`,
`hyperhive/nixpkgs`, `agent-<n>`, `agent-<n>/mcp-<x>`, …), each `hyperhive/nixpkgs`, `agent-<n>`, `agent-<n>/mcp-<x>`, …), each
@ -126,13 +172,12 @@ the previous process's socket release resolves itself.
control sits above the tree. Checking inputs + submitting bumps control sits above the tree. Checking inputs + submitting bumps
the lock in `/meta/` and rebuilds the selected agents in the lock in `/meta/` and rebuilds the selected agents in
sequence; each outcome reaches the manager as a `rebuilt` sequence; each outcome reaches the manager as a `rebuilt`
system event. system event. `POST /meta-update`. While a lock-bump ripple runs,
`POST /meta-update`. The lock bump + rebuild ripple runs in the the panel shows a pulsing "⏳ meta-update running" banner and the
background; while it does, the panel shows a pulsing "⏳ update button is disabled (snapshot field `meta_update_running`,
meta-update running" banner and the update button is disabled live event `meta_update_running`).
(snapshot field `meta_update_running`, live event
`meta_update_running`). **R3BU1LD QU3U3** — pending and recently-completed container
5. **R3BU1LD QU3U3** — pending and recently-completed container
operations: rebuilds, meta-update cascades, and first-spawns. operations: rebuilds, meta-update cascades, and first-spawns.
One operation runs at a time; the worker drains FIFO. Each row One operation runs at a time; the worker drains FIFO. Each row
shows a state glyph (`⏸` queued / `▶` running / `✔` done / shows a state glyph (`⏸` queued / `▶` running / `✔` done /
@ -143,57 +188,48 @@ the previous process's socket release resolves itself.
rebuilds nest under their parent entry (`parent_id` grouping; rebuilds nest under their parent entry (`parent_id` grouping;
`rqe-child` CSS class). Dedup: re-enqueueing a still-queued op `rqe-child` CSS class). Dedup: re-enqueueing a still-queued op
for the same agent collapses into the existing entry. Running for the same agent collapses into the existing entry. Running
entries tick elapsed seconds live (same pattern as the TTL entries tick elapsed seconds live. Cold-loaded from
countdown). Cold-loaded from `/api/state.rebuild_queue`; live `/api/state.rebuild_queue`; live updates via `rebuild_queue_changed`
updates via `rebuild_queue_changed` snapshot event. snapshot event.
6. **M1ND H4S QU3STI0NS** — pending operator-targeted `ask`
questions, i.e. rows with `target IS NULL` (peer-to-peer **QU3U3D R3M1ND3RS** — reminders agents have scheduled for
questions live in the same table but never surface here)
(amber pulsing border). Free-text fallback always rendered
alongside any option list; `multi=true` renders options as
checkboxes; submit merges selections + free text comma-joined.
Each row has a `✗ CANC3L` button that resolves the question
with `[cancelled]`. Questions with a `ttl_seconds` show a
`⏳ MM:SS` chip; the host-side watchdog auto-cancels with
`[expired]` when the deadline fires.
7. **QU3U3D R3M1ND3RS** — reminders agents have scheduled for
themselves (via the `remind` tool) but not yet delivered. themselves (via the `remind` tool) but not yet delivered.
Each row shows the owner, due time, and message; a `CANC3L` Each row shows the owner, due time, and message; a `CANC3L`
button hard-deletes (`POST /cancel-reminder/{id}`) and a button hard-deletes (`POST /cancel-reminder/{id}`) and a
`R3TRY` button re-arms one whose delivery failed `R3TRY` button re-arms one whose delivery failed
(`POST /retry-reminder/{id}`). Backed by `GET /api/reminders`. (`POST /retry-reminder/{id}`). Backed by `GET /api/reminders`.
8. **P3NDING APPR0VALS** — the queue (see "Approval card"
below). The R3QU3ST SP4WN form lives at the top of this **K3PT ST4T3** — destroyed-but-state-kept tombstones (size +
section since submitting it immediately queues an approval age + claude-creds badge). Two actions: `⊕ R3V1V3` (queues a
that lands directly below. Spawn approval; existing state is reused), `PURG3` (wipes
9. **0PER4T0R 1NB0X** — recent messages addressed to `operator`, state + applied dirs; `POST /purge-tombstone/{name}`).
derived client-side from the dashboard event stream (no longer
a snapshot field). Cold load seeds from ### FL0W page (`/flow.html`)
`/dashboard/history`'s 200-message backfill; subsequent
`sent` events with `to == "operator"` are appended live. Cap A dedicated full-page terminal (not a tab pane — a separate HTML
50, newest-first. page). Reuses the same `<header class="dashboard-chrome">` chrome
10. **MESS4GE FL0W** — live broker tail wrapped in a as the dashboard so the tab strip remains visible; SW4RM / Y3R
`.terminal-wrap` (same chrome as the per-agent terminal). C4LL / SYST3M are cross-page links back to `/#<tab>`, and the
Cold load backfills the last ~200 messages from FL0W entry is marked active (`aria-current="page"`).
`/dashboard/history`; live frames arrive on
`/dashboard/stream` and dispatch through **0PER4T0R 1NB0X** — recent messages addressed to `operator`,
`HiveTerminal.create`. Each row is one broker event — derived client-side from the dashboard event stream. Cold load
`sent` or `delivered` — with `from → to: body`; per-agent seeds from `/dashboard/history`'s 200-message backfill; subsequent
thinking / tool calls / claude chatter stay out of this `sent` events with `to == "operator"` are appended live. Cap 50,
view, only what passes through hive-c0re's broker. Sticky- newest-first.
bottom auto-scroll + "↓ N new" pill match the per-agent
page. Below the stream sits a terminal-style compose box: **MESS4GE FL0W** — live broker tail wrapped in a `.terminal-wrap`.
`@name` picks the recipient (sticky across sends via Cold load backfills the last ~200 messages from `/dashboard/history`;
localStorage; auto-complete from the live container list, live frames arrive on `/dashboard/stream`. Each row is one broker
Tab/Enter to confirm; `@*` broadcasts to every registered event — `sent` or `delivered` — with `from → to: body`. Sticky-
agent), starting a message with `@<name> body` retargets bottom auto-scroll + "↓ N new" pill. Below the stream sits a
in one stroke, plain text sends to the sticky recipient. terminal-style compose box: `@name` picks the recipient (sticky via
`POST /op-send` drops `{from:"operator", to, body}` into localStorage; auto-complete from the live container list, Tab/Enter
the broker and returns 200; the resulting SSE frame to confirm; `@*` broadcasts). `POST /op-send` drops
re-renders both the terminal row and the inbox section `{from:"operator", to, body}` into the broker; the resulting SSE
(no `/api/state` refetch). Manager is addressed as frame re-renders both the terminal row and the inbox section.
`@manager` (the broker recipient string), not `@hm1nd` Manager is addressed as `@manager` (the broker recipient string),
(the container name); the auto-complete swaps automatically. not `@hm1nd` (the container name).
### Container row ### Container row
@ -436,101 +472,75 @@ Generalised form helpers: `form[data-confirm="…"]` pops
## Per-agent page ## Per-agent page
Layout, top to bottom: Three fixed-position layers frame a full-viewport terminal:
- Banner (gradient shimmer while state=thinking). **Fixed-overlay header** (`<header class="agent-header">`): frosted
- Title with `↑ DASHB04RD` back-link (new tab) + `↻ R3BU1LD`. glass — `backdrop-filter: blur` lets scrolled terminal rows show
- Meta links row: backend-supplied `StateSnapshot.links` (issue through. Left to right:
#262) rendered as `<icon> label →`. Always includes `📊 stats` - Agent icon (`<img src="/icon">`).
(`kind = Container`); `🖥 screen` when the VNC compositor is - Title (`<h2 id="title">`) + meta-nav (`<nav id="meta-links">`):
enabled; `⬡ forge` (profile) + `↳ config` (agent-configs backend-supplied `StateSnapshot.links` rendered as icon-only
mirror, repo root since the agent doesn't know its own deployed anchors. Always includes `📊 stats` (`kind = Container`);
sha) when the agent has a forge account, both `kind = Forge`; `🖥 screen` when the VNC compositor is enabled; `⬡ forge` (profile)
followed by any `hyperhive.dashboardLinks` extras + `↳ config` (agent-configs mirror) when the agent has a forge
(`kind = External`) read from account; followed by any `hyperhive.dashboardLinks` extras
`{state_dir}/hyperhive-dashboard-links.json`. The same list (`kind = External`). The dashboard card's icon strip is the same
feeds the dashboard card's icon strip via the host's list via `GET /api/agent/{name}/links` — agent backend is the
`GET /api/agent/{name}/links` passthrough proxy — agent backend single source of truth.
is the single source of truth for what links it exposes. - **State row** (`<div id="state-row">`): alive badge + state badge
Frontend resolves each `kind` (`container` → same-origin path, + model chip + ctx badge + cost badge + last-turn chip + cancel
`forge``http://host:3000`, `external` → absolute) via DOM button + new-session button.
building, so agent-declared strings never reach `innerHTML`. - Alive badge: `● alive` (green) / `⊘ rate limited` (red) /
- Status section: empty when online (alive-badge in the state `◌ needs login` / `◌ logging in` / `○ offline` / `… connecting`.
row carries the signal), populated with the login form / Driven by `LiveEvent::StatusChanged`.
OAuth URL when `status` is `needs_login_*`.
- **State row**: alive badge + state badge + model chip + ctx
badge + last-turn timing + cancel-turn button + new-session
button. Every chip carries a `title=...` tooltip with the
detailed breakdown.
- Alive badge: `● alive` (green) / `⊘ rate limited` (red, while
the harness is parked after a 429 — clears automatically when
the sleep expires) / `◌ needs login` (amber) / `◌ logging in` /
`○ offline` / `… connecting`. Driven by
`LiveEvent::StatusChanged`; replaces the old "harness alive
— turn loop running" paragraph so the state row carries
every reachability signal.
- State badge: `💤 idle` / `🧠 thinking` / `📦 compacting` / - State badge: `💤 idle` / `🧠 thinking` / `📦 compacting` /
`○ offline` / `… booting`, with an age suffix (`12s`, `○ offline` / `… booting` + age suffix. Driven by
`2m 14s`). Driven by `LiveEvent::TurnStateChanged` `LiveEvent::TurnStateChanged ({ state, since_unix })`.
(`{state, since_unix}`) — the bus emits on every - Model chip: `model · <name>`. Driven by `LiveEvent::ModelChanged`.
`Bus::set_state` so the badge updates without a /api/state - Ctx badge: `ctx · 142k` — last inference's prompt size (the
refetch. Cold-load via `/api/state.turn_state` + context window utilisation number to watch before compacting).
`turn_state_since`. Tooltip shows % of window when `context_window_tokens` is known.
- Model chip: `model · <name>` (e.g. `model · haiku`). Driven - Cost badge: `cost · 1.3M` — cumulative tokens billed across every
by `LiveEvent::ModelChanged`; emitted from `Bus::set_model`. inference in the last turn. Tool-heavy turns rebill the cached
- Ctx badge: `ctx · 142k` — last inference's prompt size prefix per call, so this routinely exceeds the window — cost
(input + cache_read + cache_write of the most recent signal, not size signal.
model call in the just-ended turn). This is the **actual - Both driven by `LiveEvent::TokenUsageChanged { ctx, cost }` at
context window utilisation** — the number to watch when turn-end.
deciding whether to compact. When `context_window_tokens` - `■ cancel turn` (visible while thinking) → `POST /api/cancel`.
is available from `/api/state`, the badge tooltip shows the - `↻ new session` (always, amber) → `POST /api/new-session`; next
percentage of window used. turn drops `--continue`.
- Cost badge: `cost · 1.3M` — cumulative tokens billed - **Inbox pill** (`📬 inbox · N`): hidden when empty; click opens the
across **every inference** in the last turn (sum of all inbox flyout in the side panel.
per-call prompts). Tool-heavy turns rebill the cached - **Loose-ends pill** (`🪢 loose ends · N`): hidden when empty; click
prefix per call, so this routinely exceeds the model's opens the loose-ends flyout in the side panel.
window — it's a cost signal, not a size signal.
- Both badges driven by `LiveEvent::TokenUsageChanged {
ctx, cost }`, emitted once at turn-end from
`Bus::record_turn_usage`. The harness tracks per-inference
usage by walking `assistant` events in the stream-json
and updating `last_inference` on each one; the `result`
event supplies `cost` and triggers the emit.
- Last-turn chip: `last turn 12.3s` appears after the first
turn ends, computed from the state-since deltas.
- `■ cancel turn` button: visible only while state=thinking,
POSTs `/api/cancel`.
- `↻ new session` button: always visible, amber. Confirms
via `window.confirm()` then POSTs `/api/new-session` to
arm a one-shot Bus flag — the next turn drops
`--continue`, starting a fresh claude session. Subsequent
turns resume normal `--continue`.
Polling: `/api/state` is fetched **once** on cold load, and `/api/state` is fetched once on cold load (+ while
again while `status === 'needs_login_in_progress'` (login `status === 'needs_login_in_progress'`); all other updates arrive via
session output isn't event-shaped yet). Every other badge SSE. Snapshot includes `context_window_tokens` for the ctx badge tooltip.
updates from SSE; no periodic refresh timer runs. Snapshot
includes `context_window_tokens` (effective window size for **Main content** (`<main class="agent-main">`): fills the viewport
the agent's current model, from `events::context_window_tokens`) and scrolls behind the fixed header + footer.
used to compute percentage-of-window in the ctx badge tooltip. - `#status` overlay: empty when online; shows the login form / OAuth
- Inbox `<details>` block (collapsed): `inbox · N` — last 30 URL when `status` is `needs_login_*`.
messages addressed to this agent, fetched via
`AgentRequest::Recent { limit: 30 }`. Reply messages (those
with a non-null `in_reply_to`) are indented and prefixed with
`↳ reply ·` in amber. (Separate from
`AgentRequest::Recv { wait_seconds, max }` which the harness
uses internally to long-poll the broker.)
- Loose-ends `<details>` block: `loose ends · N` — questions,
approvals, and reminders pending against this agent (the
`get_loose_ends` data, via `GET /api/loose-ends`). Question
rows carry an inline answer form (textarea — Enter submits,
Shift+Enter newlines); submitting POSTs cross-origin to the
core dashboard's `/answer-question/{id}` so the operator
answers *as operator*. The per-agent socket deliberately gets
no operator-authority path — see `docs/boundary.md`.
- Terminal-wrap: live event tail (sticky-bottom auto-scroll + - Terminal-wrap: live event tail (sticky-bottom auto-scroll +
`↓ N new` pill when not at bottom) followed by an `↓ N new` pill when not at bottom).
operator-input textarea acting as a prompt.
**Fixed-overlay footer** (`<footer class="agent-composer">`): frosted
glass, symmetric with the header. Contains the operator-input
textarea (`#term-input`) — multi-line, Enter sends, Shift+Enter
newlines, Tab-completes slash commands (see "Terminal-embedded
prompt" below).
**Side panel** (slide-in from right): singleton shared with the
dashboard's side panel shape. Carries inbox and loose-ends flyouts
(opened via the header pills) as well as long content (file previews,
diffs, journald logs). Inbox flyout: last 30 messages addressed to
this agent (`AgentRequest::Recent { limit: 30 }`); reply messages
indented with `↳ reply ·` in amber. Loose-ends flyout: questions,
approvals, and reminders pending against this agent (`GET /api/loose-ends`);
question rows carry an inline answer form that POSTs cross-origin to
the core dashboard's `/answer-question/{id}` so the operator answers
*as operator* (see `docs/boundary.md`).
### Live view ### Live view