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,100 +100,136 @@ Both bind their listeners with `SO_REUSEADDR` via
exponential backoff capped at 2s) so an nspawn restart that races
the previous process's socket release resolves itself.
## Dashboard sections (top to bottom)
## Dashboard layout
1. **Notification row**`🔔 enable notifications` button when
permission ungranted; `🔕 mute / 🔔 unmute` toggle once granted;
inline "unsupported / blocked" message when applicable. Sits
under the banner.
2. **C0NTAINERS** — live containers with their action surface.
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.
3. **K3PT ST4T3** — destroyed-but-state-kept tombstones (size +
age + claude-creds badge). Two actions: `⊕ R3V1V3` (queues a
Spawn approval; existing state is reused), `PURG3` (wipes
state + applied dirs; `POST /purge-tombstone/{name}`).
4. **M3T4 1NPUTS** — inputs in `meta/flake.lock` the operator can
selectively `nix flake update`, rendered as an indented tree:
every fetched input at every depth (`hyperhive`,
`hyperhive/nixpkgs`, `agent-<n>`, `agent-<n>/mcp-<x>`, …), each
shown once at its shallowest path. `read_meta_inputs` walks the
lock graph with a `visited` set — `follows` aliases and rev-less
nodes are skipped (issue #275). A `select all / select none`
control sits above the tree. Checking inputs + submitting bumps
the lock in `/meta/` and rebuilds the selected agents in
sequence; each outcome reaches the manager as a `rebuilt`
system event.
`POST /meta-update`. The lock bump + rebuild ripple runs in the
background; while it does, the panel shows a pulsing "⏳
meta-update running" banner and the update button is disabled
(snapshot field `meta_update_running`, live event
`meta_update_running`).
5. **R3BU1LD QU3U3** — pending and recently-completed container
operations: rebuilds, meta-update cascades, and first-spawns.
One operation runs at a time; the worker drains FIFO. Each row
shows a state glyph (`⏸` queued / `▶` running / `✔` done /
`✖` failed / `⊘` cancelled), kind glyph + verb (`↻ rebuild`,
`◆ meta_update`, `✨ spawn`, `🗑 destroy`), agent name, source
chip (`manual | meta_update | auto_update | crash_recover`),
timing, and an optional reason / error. Meta-update cascade
rebuilds nest under their parent entry (`parent_id` grouping;
`rqe-child` CSS class). Dedup: re-enqueueing a still-queued op
for the same agent collapses into the existing entry. Running
entries tick elapsed seconds live (same pattern as the TTL
countdown). Cold-loaded from `/api/state.rebuild_queue`; live
updates via `rebuild_queue_changed` snapshot event.
6. **M1ND H4S QU3STI0NS** — pending operator-targeted `ask`
questions, i.e. rows with `target IS NULL` (peer-to-peer
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.
Each row shows the owner, due time, and message; a `CANC3L`
button hard-deletes (`POST /cancel-reminder/{id}`) and a
`R3TRY` button re-arms one whose delivery failed
(`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
section since submitting it immediately queues an approval
that lands directly below.
9. **0PER4T0R 1NB0X** — recent messages addressed to `operator`,
derived client-side from the dashboard event stream (no longer
a snapshot field). Cold load seeds from
`/dashboard/history`'s 200-message backfill; subsequent
`sent` events with `to == "operator"` are appended live. Cap
50, newest-first.
10. **MESS4GE FL0W** — live broker tail wrapped in a
`.terminal-wrap` (same chrome as the per-agent terminal).
Cold load backfills the last ~200 messages from
`/dashboard/history`; live frames arrive on
`/dashboard/stream` and dispatch through
`HiveTerminal.create`. Each row is one broker event —
`sent` or `delivered` — with `from → to: body`; per-agent
thinking / tool calls / claude chatter stay out of this
view, only what passes through hive-c0re's broker. Sticky-
bottom auto-scroll + "↓ N new" pill match the per-agent
page. Below the stream sits a terminal-style compose box:
`@name` picks the recipient (sticky across sends via
localStorage; auto-complete from the live container list,
Tab/Enter to confirm; `@*` broadcasts to every registered
agent), starting a message with `@<name> body` retargets
in one stroke, plain text sends to the sticky recipient.
`POST /op-send` drops `{from:"operator", to, body}` into
the broker and returns 200; the resulting SSE frame
re-renders both the terminal row and the inbox section
(no `/api/state` refetch). Manager is addressed as
`@manager` (the broker recipient string), not `@hm1nd`
(the container name); the auto-complete swaps automatically.
The dashboard (`/`) has a fixed chrome header at the top and a
`<main>` that shows exactly one tab pane at a time. The URL hash
(`#swarm`, `#call`, `#system`) drives which pane is active; hash
changes don't reload the page. FL0W is a separate full-page
terminal at `/flow.html` — its tab-strip entry is a cross-page
link (`◆ FL0W ◆ →`), not a pane swap.
**Chrome header** (fixed, overlays the active tab pane):
- **Tab strip**: `◆ SW4RM ◆`, `◆ Y3R C4LL ◆`, `◆ SYST3M ◆`, and
`◆ FL0W ◆ →` (page link). Count pills on SW4RM (container count)
and Y3R C4LL (pending approvals + questions); FL0W pill mirrors
the operator inbox length (hidden when zero).
- **Notification controls**: `🔔 enable notifications` when
permission ungranted; `🔕 mute / 🔔 unmute` toggle once granted.
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:
every fetched input at every depth (`hyperhive`,
`hyperhive/nixpkgs`, `agent-<n>`, `agent-<n>/mcp-<x>`, …), each
shown once at its shallowest path. `read_meta_inputs` walks the
lock graph with a `visited` set — `follows` aliases and rev-less
nodes are skipped (issue #275). A `select all / select none`
control sits above the tree. Checking inputs + submitting bumps
the lock in `/meta/` and rebuilds the selected agents in
sequence; each outcome reaches the manager as a `rebuilt`
system event. `POST /meta-update`. While a lock-bump ripple runs,
the panel shows a pulsing "⏳ meta-update running" banner and the
update button is disabled (snapshot field `meta_update_running`,
live event `meta_update_running`).
**R3BU1LD QU3U3** — pending and recently-completed container
operations: rebuilds, meta-update cascades, and first-spawns.
One operation runs at a time; the worker drains FIFO. Each row
shows a state glyph (`⏸` queued / `▶` running / `✔` done /
`✖` failed / `⊘` cancelled), kind glyph + verb (`↻ rebuild`,
`◆ meta_update`, `✨ spawn`, `🗑 destroy`), agent name, source
chip (`manual | meta_update | auto_update | crash_recover`),
timing, and an optional reason / error. Meta-update cascade
rebuilds nest under their parent entry (`parent_id` grouping;
`rqe-child` CSS class). Dedup: re-enqueueing a still-queued op
for the same agent collapses into the existing entry. Running
entries tick elapsed seconds live. Cold-loaded from
`/api/state.rebuild_queue`; live updates via `rebuild_queue_changed`
snapshot event.
**QU3U3D R3M1ND3RS** — reminders agents have scheduled for
themselves (via the `remind` tool) but not yet delivered.
Each row shows the owner, due time, and message; a `CANC3L`
button hard-deletes (`POST /cancel-reminder/{id}`) and a
`R3TRY` button re-arms one whose delivery failed
(`POST /retry-reminder/{id}`). Backed by `GET /api/reminders`.
**K3PT ST4T3** — destroyed-but-state-kept tombstones (size +
age + claude-creds badge). Two actions: `⊕ R3V1V3` (queues a
Spawn approval; existing state is reused), `PURG3` (wipes
state + applied dirs; `POST /purge-tombstone/{name}`).
### FL0W page (`/flow.html`)
A dedicated full-page terminal (not a tab pane — a separate HTML
page). Reuses the same `<header class="dashboard-chrome">` chrome
as the dashboard so the tab strip remains visible; SW4RM / Y3R
C4LL / SYST3M are cross-page links back to `/#<tab>`, and the
FL0W entry is marked active (`aria-current="page"`).
**0PER4T0R 1NB0X** — recent messages addressed to `operator`,
derived client-side from the dashboard event stream. Cold load
seeds from `/dashboard/history`'s 200-message backfill; subsequent
`sent` events with `to == "operator"` are appended live. Cap 50,
newest-first.
**MESS4GE FL0W** — live broker tail wrapped in a `.terminal-wrap`.
Cold load backfills the last ~200 messages from `/dashboard/history`;
live frames arrive on `/dashboard/stream`. Each row is one broker
event — `sent` or `delivered` — with `from → to: body`. Sticky-
bottom auto-scroll + "↓ N new" pill. Below the stream sits a
terminal-style compose box: `@name` picks the recipient (sticky via
localStorage; auto-complete from the live container list, Tab/Enter
to confirm; `@*` broadcasts). `POST /op-send` drops
`{from:"operator", to, body}` into the broker; the resulting SSE
frame re-renders both the terminal row and the inbox section.
Manager is addressed as `@manager` (the broker recipient string),
not `@hm1nd` (the container name).
### Container row
@ -436,101 +472,75 @@ Generalised form helpers: `form[data-confirm="…"]` pops
## Per-agent page
Layout, top to bottom:
Three fixed-position layers frame a full-viewport terminal:
- Banner (gradient shimmer while state=thinking).
- Title with `↑ DASHB04RD` back-link (new tab) + `↻ R3BU1LD`.
- Meta links row: backend-supplied `StateSnapshot.links` (issue
#262) rendered as `<icon> label →`. Always includes `📊 stats`
(`kind = Container`); `🖥 screen` when the VNC compositor is
enabled; `⬡ forge` (profile) + `↳ config` (agent-configs
mirror, repo root since the agent doesn't know its own deployed
sha) when the agent has a forge account, both `kind = Forge`;
followed by any `hyperhive.dashboardLinks` extras
(`kind = External`) read from
`{state_dir}/hyperhive-dashboard-links.json`. The same list
feeds the dashboard card's icon strip via the host's
`GET /api/agent/{name}/links` passthrough proxy — agent backend
is the single source of truth for what links it exposes.
Frontend resolves each `kind` (`container` → same-origin path,
`forge``http://host:3000`, `external` → absolute) via DOM
building, so agent-declared strings never reach `innerHTML`.
- Status section: empty when online (alive-badge in the state
row carries the signal), populated with the login form /
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.
**Fixed-overlay header** (`<header class="agent-header">`): frosted
glass — `backdrop-filter: blur` lets scrolled terminal rows show
through. Left to right:
- Agent icon (`<img src="/icon">`).
- Title (`<h2 id="title">`) + meta-nav (`<nav id="meta-links">`):
backend-supplied `StateSnapshot.links` rendered as icon-only
anchors. Always includes `📊 stats` (`kind = Container`);
`🖥 screen` when the VNC compositor is enabled; `⬡ forge` (profile)
+ `↳ config` (agent-configs mirror) when the agent has a forge
account; followed by any `hyperhive.dashboardLinks` extras
(`kind = External`). The dashboard card's icon strip is the same
list via `GET /api/agent/{name}/links` — agent backend is the
single source of truth.
- **State row** (`<div id="state-row">`): alive badge + state badge
+ model chip + ctx badge + cost badge + last-turn chip + cancel
button + new-session button.
- Alive badge: `● alive` (green) / `⊘ rate limited` (red) /
`◌ needs login` / `◌ logging in` / `○ offline` / `… connecting`.
Driven by `LiveEvent::StatusChanged`.
- State badge: `💤 idle` / `🧠 thinking` / `📦 compacting` /
`○ offline` / `… booting`, with an age suffix (`12s`,
`2m 14s`). Driven by `LiveEvent::TurnStateChanged`
(`{state, since_unix}`) — the bus emits on every
`Bus::set_state` so the badge updates without a /api/state
refetch. Cold-load via `/api/state.turn_state` +
`turn_state_since`.
- Model chip: `model · <name>` (e.g. `model · haiku`). Driven
by `LiveEvent::ModelChanged`; emitted from `Bus::set_model`.
- Ctx badge: `ctx · 142k` — last inference's prompt size
(input + cache_read + cache_write of the most recent
model call in the just-ended turn). This is the **actual
context window utilisation** — the number to watch when
deciding whether to compact. When `context_window_tokens`
is available from `/api/state`, the badge tooltip shows the
percentage of window used.
- Cost badge: `cost · 1.3M` — cumulative tokens billed
across **every inference** in the last turn (sum of all
per-call prompts). Tool-heavy turns rebill the cached
prefix per call, so this routinely exceeds the model's
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`.
`○ offline` / `… booting` + age suffix. Driven by
`LiveEvent::TurnStateChanged ({ state, since_unix })`.
- Model chip: `model · <name>`. Driven by `LiveEvent::ModelChanged`.
- Ctx badge: `ctx · 142k` — last inference's prompt size (the
context window utilisation number to watch before compacting).
Tooltip shows % of window when `context_window_tokens` is known.
- Cost badge: `cost · 1.3M` — cumulative tokens billed across every
inference in the last turn. Tool-heavy turns rebill the cached
prefix per call, so this routinely exceeds the window — cost
signal, not size signal.
- Both driven by `LiveEvent::TokenUsageChanged { ctx, cost }` at
turn-end.
- `■ cancel turn` (visible while thinking) → `POST /api/cancel`.
- `↻ new session` (always, amber) → `POST /api/new-session`; next
turn drops `--continue`.
- **Inbox pill** (`📬 inbox · N`): hidden when empty; click opens the
inbox flyout in the side panel.
- **Loose-ends pill** (`🪢 loose ends · N`): hidden when empty; click
opens the loose-ends flyout in the side panel.
Polling: `/api/state` is fetched **once** on cold load, and
again while `status === 'needs_login_in_progress'` (login
session output isn't event-shaped yet). Every other badge
updates from SSE; no periodic refresh timer runs. Snapshot
includes `context_window_tokens` (effective window size for
the agent's current model, from `events::context_window_tokens`)
used to compute percentage-of-window in the ctx badge tooltip.
- Inbox `<details>` block (collapsed): `inbox · N` — last 30
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`.
`/api/state` is fetched once on cold load (+ while
`status === 'needs_login_in_progress'`); all other updates arrive via
SSE. Snapshot includes `context_window_tokens` for the ctx badge tooltip.
**Main content** (`<main class="agent-main">`): fills the viewport
and scrolls behind the fixed header + footer.
- `#status` overlay: empty when online; shows the login form / OAuth
URL when `status` is `needs_login_*`.
- Terminal-wrap: live event tail (sticky-bottom auto-scroll +
`↓ N new` pill when not at bottom) followed by an
operator-input textarea acting as a prompt.
`↓ N new` pill when not at bottom).
**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