docs(#727): split docs/web-ui.md into shape / dashboard / agent

docs/web-ui.md (1315 lines) split into three sub-files:
- docs/web-ui/shape.md   — shared SPA skeleton, SSE multiplexing,
  Worker-death self-heal, terminal pane, listener bind, relative
  paths, atomic repaint, side panel
- docs/web-ui/dashboard.md — SW4RM/Y3R/SYST3M/SCH3DUL3S/S3TT1NGS
  tabs, container row, topology tree, selection bar, approval card,
  dashboard endpoints + event channel
- docs/web-ui/agent.md   — header, terminal, composer, inbox, live
  view, slash commands, per-agent endpoints, stats page

docs/web-ui.md replaced with a thin index linking all three.

Section anchors in docs (gateway.md, gotchas.md), Rust doc comments
(hive-ag3nt/src/web_ui.rs), and nix/templates/weston-vnc.nix updated
to point at the correct sub-file. README and CLAUDE.md file-map
updated with sub-file links. Inline // comments in frontend source
left unchanged (they reference the index which redirects to the right
sub-file).
This commit is contained in:
iris 2026-05-31 21:34:17 +02:00 committed by mara
commit c7360cf0bb
10 changed files with 1373 additions and 1319 deletions

336
docs/web-ui/agent.md Normal file
View file

@ -0,0 +1,336 @@
# Per-agent page
> Part of [Web UI](../web-ui.md). See also:
> [Shape (shared)](shape.md) · [Dashboard layout](dashboard.md)
Three fixed-position layers frame a full-viewport terminal:
**Fixed-overlay header** (`<header class="agent-header">`): frosted
glass — `backdrop-filter: blur` lets scrolled terminal rows show
through. Three flex columns:
- **Agent icon** (`<img class="agent-icon">`): fixed-size square
identity anchor — `width: 5em; height: 5em` with explicit pixel
sizing so the `<img>`'s intrinsic (large) dimensions don't push
the parent flex container open via `align-items: stretch`-driven
height feedback. 5em ≈ header content area (header `min-height: 6em`
minus `2 × 0.5em` padding). `align-self: flex-start` keeps the
icon stuck to the top so a state-row line-wrap doesn't drag it
down with it. Falls back to the dimmed hyperhive mark on load
error.
- **Main column** (`.agent-header-main`): two rows.
- Row 1 (`.agent-header-title-row`): title (`<h2 id="title">`) +
meta-nav (`<nav id="meta-links">`). Meta-nav renders
backend-supplied `StateSnapshot.links` as icon-only anchors — always
`📊 stats` (`kind = Container`); `🖥 screen` when VNC is enabled;
`⬡ forge` (profile) + `↳ config` (agent-configs mirror) when the
agent has a forge account; any `hyperhive.dashboardLinks` extras
(`kind = External`). A `↑ dashboard` link is prepended by the JS
so the host dashboard is one click away. `GET /api/agent/{name}/links`
is the single source of truth. Each `NavLink.kind` resolves
differently in the frontend: `Container` → same-origin path
(the agent page is itself container-local); `Forge`
`http://<host>:3000<url>`; `External` → already absolute.
All anchors are built via `el()` — agent-declared icon /
label / url strings never reach `innerHTML` (XSS-safe by
construction).
- Row 2 (`.agent-state-row`): alive badge + state badge + model chip
+ ctx badge + cost badge + last-turn chip + cancel 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` + 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.
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 — cost signal, not size signal).
- Both driven by `LiveEvent::TokenUsageChanged { ctx, cost }` at
turn-end.
- `■ cancel turn` (visible while thinking) → `POST /api/cancel`.
- **Right cluster** (`.agent-header-pills`): flyout pills + overflow.
- **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.
- **Overflow button** (`⋯`): always visible. Opens a frosted popover
(`#overflow-menu`, positioned outside the header to escape any
stacking context) with four rows: `↑ dashboard` (link), `↻ rebuild
container` (POST confirm, same action as the dashboard R3BU1LD
button), `↻ new claude session` (POST confirm → `POST
/api/new-session`; next turn drops `--continue`), `🔓 logout`
(POST confirm → `POST /api/logout`; SIGINTs any in-flight turn,
wipes OAuth credential files, flips the agent to `needs_login`
— session history preserved). All destructive actions require
one extra click to acknowledge — rare ops shouldn't live in the
primary state strip. The popover's display rules are scoped to
`:not([hidden])` so the `[hidden]` HTML attribute's UA `display:
none` isn't overridden by the author CSS's `display: flex` —
the popover stays hidden until JS removes the attribute.
`/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, and `qualified_label` — the hive-qualified agent name
(`name@domain` form when `HYPERHIVE_HIVE_DOMAIN` is set, otherwise
just `name`). The frontend uses `qualified_label` to set the browser
tab title so two tabs from different hives are distinguishable; the
header `<h2 id="title">` stays short.
**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_*`. The OAuth code input is
`type="password"` with a `👁 reveal` toggle that flips it back to
`text` on press so the operator can sanity-check the paste before
submit — avoids accidental on-screen token exposure to
shoulder-surfers or screenshots. `autocomplete="one-time-code"`
is the semantic value for OAuth codes (per WHATWG): browsers may
silently ignore `autocomplete="off"` on `type="password"`, but
`one-time-code` is honoured and suppresses the "save password
for this site?" prompt that would otherwise fire on submit.
- Terminal-wrap: live event tail (sticky-bottom auto-scroll +
`↓ N new` pill when not at bottom). The pill is **anchored in
`.agent-main`**, not in `log.parentElement = .terminal-wrap`:
`.terminal-wrap` applies `backdrop-filter: blur` for the frost
effect, which creates a CSS stacking context — anchoring the
pill inside that context would trap its `z-index` below the
fixed composer in the root stacking context, and it'd never
float. `.agent-main` has no backdrop-filter (no stacking-context
creators), so the pill's `z-index` reaches the root and properly
composites above the composer. Geometry is unchanged —
`.agent-main` and `.terminal-wrap` both `inset: 0` fill the same
area.
**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. A `✓ mark all read` button
appears in the flyout header when the inbox is non-empty;
clicking it confirms then POSTs cross-origin to the core
dashboard's `POST /api/agent/{name}/mark-all-read` — all pending
messages for this agent are acked, the harness won't receive
wake-prompts for them. A `{ marked: N }` pill surfaces the count.
The displayed message list stays put (it shows the most-recent N
regardless of ack state); the unread badge on the next turn-start
will reflect zero. 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`).
**Ask → operator inline-answer binding.** When the agent emits
`mcp__hyperhive__ask(to: "operator", ...)`, the tool_use renderer
mounts an empty slot (`<div class="ask-answer-inline-slot">`)
right under the `↳ ask → operator` row in the terminal scrollback
and pushes a reference into `pendingAskBinds`. The broker assigns
the question id asynchronously, so the slot waits — and the next
`/api/loose-ends` refresh runs `reconcileAskBinds()`, which walks
the slot list and pairs each unbound slot with the first unclaimed
pending operator-bound question whose `question` text matches the
slot's stashed `_askQuestion`. On match the slot mounts the
`buildAnswerForm` (same form shape as the loose-ends flyout —
POSTs to the core's `/answer-question/{id}` cross-origin). Slots
stay in the array after binding so the reconciler can flip them
to a neutral `[resolved]` tag when the question later disappears
from the pending list. Disappearance can mean answered, cancelled
by the asker, or TTL-expired — the neutral label avoids
mis-asserting "✓" on the cancel / expire paths; full resolution
state is visible via the side-panel history. A defensive prune
walks the slot list each tick and drops any whose DOM node has
been removed (e.g. via a future "clear single row" affordance),
so stale references don't accumulate. Slots whose question never
arrives (e.g. the agent cancelled the ask, or the question is
older than the loose-ends retention window) stay empty — the
operator can still answer via the side panel, no regression.
## Live view
Each agent runs an `events::Bus`: a `tokio::sync::broadcast<LiveEvent>`
plus a sqlite-backed history at `/state/hyperhive-events.sqlite`.
The harness emits `TurnStart { from, body, unread }`,
`Stream(value)` (one per parsed stream-json line), `Note`,
`TurnEnd { ok, note }`. The web UI:
- fetches `GET /events/history` on page load and replays the last
2000 events (oldest first, with `.no-anim` so they don't
stagger);
- then subscribes to `GET /events/stream` (SSE) for live tail;
- shows a granular state badge above the terminal, driven
authoritatively from `/api/state.turn_state`. SSE turn_start /
turn_end still flip the badge instantly between renders;
- sticky-bottom auto-scroll: scrolling up parks the view; new rows
surface a "↓ N new" pill instead of yanking;
- terminal-themed: phosphor mauve glow, Crust bg,
backdrop-filter blur, row fade-in slide-up.
Per-stream rendering:
- `Stream` `tool_use`
- `Write` / `Edit`: collapsed `<details>` with a +/- diff body
(`-` lines from `input.old_string`, `+` lines from
`input.new_string` or every line of `input.content`).
Summary carries the path + line counts.
- others (`Read /path`, `Bash $ cmd`, `mcp__hyperhive__send →
operator: "..."`, etc.): flat one-line per-tool format.
- `Stream` `tool_result` short → flat `← ...`; long → collapsed
`<details>` `▸ ← Nl · headline` (click to expand full body).
- `Stream` `thinking` → text content if claude provided one,
otherwise the bare `· thinking …` indicator.
- `Stream` `system init`, `result`, `rate_limit_event` are
dropped — too noisy.
- `Note``· text`.
- `TurnEnd``✓ turn ok` / `✗ turn fail — note`, triggers a
`refreshState()`.
## Terminal-embedded prompt
The operator input lives *inside* the terminal-wrap as a
prompt-style textarea below the live tail: multi-line (Enter
sends, Shift+Enter newlines), tab-completes slash commands.
Slash commands today:
- `/help` — list commands locally.
- `/clear` — wipe the local terminal view (server history kept).
- `/cancel``POST /api/cancel` → host shellouts `pkill -INT
claude`, emits a Note. Also surfaces as a `■ cancel turn`
button in the state row while state=thinking.
- `/compact``POST /api/compact` → host spawns
`turn::compact_session` in the background; output streams into
the live panel.
- `/model <name>``POST /api/model` flipping `Bus::set_model`.
Takes effect on the next turn; persisted to
`/state/hyperhive-model` so the override survives harness
restart / rebuild.
- `/new-session``POST /api/new-session` (confirms first).
Arms a one-shot on the Bus; next turn runs without
`--continue`, dropping the resume session entirely.
- `/logout``POST /api/logout` (confirms first). Wipes OAuth
credential files, parks the agent in `needs_login`. Session
history (`~/.claude/projects/`) is preserved.
Unknown `/foo` shows an error row instead of being silently sent.
## Per-agent endpoints
All POSTs return 200 (no 303 redirects). The matching mutations
fire `LiveEvent` variants on the per-agent bus, so the client
doesn't refetch `/api/state` on submit — the SSE stream
delivers the new state faster anyway. Only the login flow still
polls (session output streams in updates that aren't event-
shaped).
- `POST /send` — operator-injected message into this agent's inbox.
- `POST /login/{start,code,cancel}` — claude OAuth login flow.
Start/cancel emit `LiveEvent::StatusChanged` to flip the
badge to/from `needs_login_in_progress`.
- `POST /api/cancel` — SIGINT the in-flight claude turn. Emits a
`LiveEvent::Note`.
- `POST /api/compact` — run `/compact` on the persistent session
(same MCP config + system prompt + allowed tools as a normal
turn — only the stdin payload differs). Flips state to
`Compacting` via `Bus::set_state`, which emits
`TurnStateChanged`.
- `POST /api/model` (`model=<name>`) — switch the model for
future turns. `Bus::set_model` emits `ModelChanged`.
- `POST /api/new-session` — arm a one-shot for the next turn to
drop `--continue`. Emits a `LiveEvent::Note`.
- `POST /api/logout` — three-step teardown that re-uses the
existing `wait_for_login` resumption path:
1. SIGINT any running claude (matches `/api/cancel`'s pattern —
idempotent no-op when nothing is running) so the credential
wipe doesn't race a mid-API-call turn.
2. Delete only the OAuth credential files
(`.credentials.json` + `mcp-needs-auth-cache.json` under
`~/.claude/`). **Preserves** session history files
(`projects/<hash>/*.jsonl`), sessions, shell-snapshots,
plans, settings, telemetry, and the dir itself, so
`claude --continue` keeps working after a fresh login.
Wholesale `remove_dir_all` of `~/.claude/` was the previous
shape and broke session continuity; the narrowed allow-list
is the fix.
3. Flip `LoginState::NeedsLogin` + emit a `LiveEvent::Note`
describing exactly what was wiped, then emit
`needs_login_idle`. The turn-loop's next iteration parks
into `wait_for_login`, which snapshots the credential dir
(now missing the wiped files) and resumes when a fresh
credentials file appears via the dashboard's `/login/code`
flow (the same mtime-resumption path manual re-login uses).
Always returns 200 with a body describing what happened —
per-file errors are folded into the response + the Note so the
operator sees them in the live panel rather than as an HTTP
error. Missing files (already logged out) are treated as
idempotent.
- `GET /events/history` — replay buffer for the terminal.
- `GET /screen` — VNC viewer page (minimal RFB-over-WebSocket
renderer — deliberately thin, just enough to display the
desktop + forward pointer + keyboard. A production-grade viewer
would vendor noVNC; this file ships the minimal in-tree variant).
Only accessible when `hyperhive.gui.enable = true` in the agent's
`agent.nix`; the harness shows a 🖥 screen link in the state row
when `gui_vnc_port` is present. Toolbar: `⤢ fit` CSS-downscales
the canvas to the window via `relayoutCanvas()` setting explicit
pixel dimensions on the canvas — *not* CSS `max-width/max-height`,
because a flex item's automatic minimum size (`min-width: auto`
resolves to the canvas's intrinsic framebuffer resolution) silently
clamps `max-*` back up, making fit mode a no-op that just centred +
clipped the oversized canvas. The fit-mode rules pin the canvas
with `flex: none; min-width: 0; min-height: 0` so the JS-set size
sticks. `⤡ match size` sends an RFB `SetDesktopSize` request so the
server (weston) changes its real output resolution to the window
dimensions; enabled once the server advertises the
`ExtendedDesktopSize` pseudo-encoding (`-308` rect in the header).
Fit-mode state persists in `localStorage` (`screen-fit`); default
is on. Pointer coordinates are rescaled in `sendPointer` so clicks
land on the right pixel regardless of CSS scale.
- `GET /screen/ws` — raw RFB byte relay: proxies WebSocket
frames to the weston VNC server at `127.0.0.1:<vnc_port>`.
Transparent to any RFB variant. VNC port comes from
`/etc/hyperhive/gui.json` (written by the weston startup
script in `weston-vnc.nix`).
Bus events (new vocabulary on `/events/stream`):
- `status_changed { status }``online` / `rate_limited` /
`needs_login_idle` / `needs_login_in_progress`. Drives the
alive-badge. `rate_limited` is set when the harness detects a
429 response and cleared when the retry sleep expires.
- `model_changed { model }` — drives the model chip.
- `token_usage_changed { ctx: TokenUsage, cost: TokenUsage }`
— drives the ctx + cost badges. Emitted from
`Bus::record_turn_usage` at turn-end; `ctx` is the last
inference's usage (current context size), `cost` is the
cumulative across every inference (the `result` event's
totals).
- `turn_state_changed { state, since_unix }` — drives the
state badge (`idle`/`thinking`/`compacting`).
## Stats page
`GET /stats` is a separate per-agent page (served by the
harness, linked from the per-agent page's `📊 stats →` and from
each dashboard container row). Turn analytics, read-only, from
`/state/hyperhive-turn-stats.sqlite`. `GET /api/stats?window=
24h|7d|30d` returns a time-bucketed `Snapshot`; the page renders
it with Chart.js (vendored from a CDN). Charts: turns,
duration (p50 · p95 · avg), context tokens, token cost per
bucket, a **turns-by-model** stacked bar (model choice drives
token cost, so it sits directly under the cost chart), and
doughnuts for tool / wake-source / result mix. A summary chip
row carries window totals. `stats.rs` opens the sqlite db
read-only and degrades to an empty snapshot on any error — the
page is decorative, never authoritative.