mara (#3704): 'remove rebuild button, move link to dashboards into links menu.' The overflow (⋯) menu existed for exactly two items: the dashboard back-link and a rebuild-container action. Rebuild is gone outright — the dashboard's own R3BU1LD button already covers it, this was just a rarely-used shortcut not worth its own menu. The dashboard link moves into MetaNav's links popover (now the first item, above stats/forge/ config/extras) instead. With both gone, OverflowMenu had nothing left to justify existing as a separate component — deleted along with its CSS and the now-unused rebuildAction.ts (only consumer). MetaNav gained a dashboardBase prop (Root.tsx already computes this via resolveDashboardBase for InboxPanel/pause — reused, not duplicated) and renders the dashboard link as a real <a>, same treatment as every other item in that popover — no dangling window.open()-only affordance. Updated docs/web-ui/agent.md's Header section and the couple of now-stale OverflowMenu references in index.html's/MetaNav.css's own comments. Verified: header now shows a single trailing icon-badge (was two), popover opens with dashboard first then the agent_links() set. tsc --noEmit clean, build clean, both pre-push lints clean.
25 KiB
Per-agent page
Part of Web UI. See also: Shape (shared) · Dashboard layout
Three fixed-position layers frame a full-viewport terminal: a header, scrollable main content, and a footer composer — plus a slide-in side panel for flyouts and long content.
Header
Preact component tree (Header.tsx + StatusChips.tsx +
MetaNav.tsx + OverflowMenu.tsx + HeaderPill.tsx, wired together
in Root.tsx) — see frontend/packages/agent/src/components/. This
section describes the rendered result, not the DOM ids the pre-Preact
page used (there are none any more — every element is component
output, not something a selector reaches by id).
Fixed-overlay header (<header class="agent-header">): frosted
glass — backdrop-filter: blur lets scrolled terminal rows show
through. Measures its own rendered height via ResizeObserver
(Header.tsx) and writes it to a CSS custom property the content
below reads for its offset — a fixed 6em guess used to be baked into
agent.css, which silently broke (content overlapping the header) the
moment any row of badges/pills wrapped onto an extra line at some
viewport width; measuring instead of guessing closes that bug class
structurally rather than for one specific trigger. Two columns:
- Agent icon (
<img class="agent-icon">): fixed-size square identity anchor —width: 5em; height: 5emwith explicit pixel sizing so the<img>'s intrinsic (large) dimensions don't push the parent flex container open viaalign-items: stretch-driven height feedback. Falls back to the dimmed hyperhive mark (/favicon.svg) on load error (/icon404s when the agent has nohyperhive.iconoverride — there is no server-side default image). - Main column (
.agent-header-main,Header.tsx): two rows — title (◆ <label> ◆) and, when set, the "swarm / hive" identity line. Nothing else lives here; both rows are short, fixed-shape text that doesn't wrap in practice, so this column's height stays stable regardless of how many badges or pills are showing (a design correction mid-review — the first pass put the variable-width content here instead, which is exactly the kind of thing theResizeObservermeasurement above exists to catch even when a layout choice reintroduces it). - Pills cluster (
.agent-header-pills, right-aligned): status badges (StatusChips.tsx) and flyout triggers together in one row — a deliberate choice (not the historical default) so the header reads as one identity zone + one status/actions zone rather than several separate clusters:- Alive badge:
● alive(green) /⊘ rate limited(red) /◌ needs login/◌ logging in/○ offline/… connecting. - State badge:
💤 idle/🧠 thinking/📦 compacting/○ offline/… booting+ age suffix. - Model badge (
model · <name> ▾): a real picker — click opens aDropdownofstate.available_models, selecting one POSTs/api/modelimmediately (same endpoint the/model <name>slash command uses). No longer buried in the overflow menu — the design guide's own named anti-example (control disconnected from display) this rewrite exists to fix. - Effort badge (
effort · <level> ▾): same shape,/api/effort, shown whenstate.available_effortsis non-empty. - Ctx / cost badges:
ctx · 142k(last inference's prompt size, tooltip shows % of context window) /cost · 1.3M(cumulative tokens billed across every inference in the last turn). - Pause badge (
⏸ pause/▶ resume): toggles via the same hive-c0re-owned/api/pause/<name>//api/resume/<name>endpoints the dashboard uses (pausing needs a write this unprivileged process can't make directly). - Inbox / todos pills (
📬 inbox · N/📋 todos · N): hidden when empty; click opens the matching flyout in the side panel. - Links badge (
🔗): opens a popover listing↑ dashboard(back-link to the host dashboard,${dashboardBase}dashboard.html) followed by this agent'sagent_links()-sourced navigation (stats, screen when VNC is enabled, forge profile + config-repo mirror when the agent has a forge account, anyhyperhive.dashboardLinksextras) — all as real<a>elements, not aDropdown-style command list, so ctrl/middle-click and "copy link address" keep working. EachAgentLink.kindresolves differently:container→ same-origin path;forge→state.forge_public_url + url, and the link is dropped entirely when that's unset (never guessed from<host>:3000);external→ already absolute. Same source (GET /api/state'slinksfield) also feedsDashboardState.linksfor the dashboard card's icon strip —agent_links()in hive-agent is the single source of truth for both. There is no separate overflow (⋯) menu any more — it used to hold exactly this dashboard link plus a rebuild-container action (mara, hyperhive#3704: "remove rebuild button, move link to dashboards into links menu") — rebuild had no real discoverability need of its own (the dashboard's own R3BU1LD button already covers it) so it's gone outright, and the dashboard link moved here, leaving nothing to justify a separate menu. Everything else that used to live in the old overflow menu (model/effort pickers, new-session, logout) already had a better home before this: pickers are real badges above, and/new-session//logoutare typed slash commands with their own type-twice confirm (see below) — a modal doesn't fit a text-input flow, and burying rare-but-important actions in one flat menu was the design guide's own named anti-example. - No header cancel-turn button any more —
/cancel(slash command, below) is the only path; the turn-loop state badge already showsthinkingas the discoverability cue.
- Alive badge:
Values throughout come from GET /api/state's cold-load snapshot,
kept in sync afterwards by the SSE stream (see Live view below) —
context_window_tokens for the ctx badge tooltip, qualified_label
(the hive-qualified name@domain form, used for the browser tab title
so two tabs from different hives are distinguishable; the header's own
◆ <label> ◆ stays short).
Main content
Main content (<main class="agent-main">): fills the viewport
and scrolls behind the fixed header + footer.
#statusoverlay: empty when online; shows the login form / OAuth URL whenstatusisneeds_login_*. The OAuth code input istype="password"with a👁 revealtoggle that flips it back totexton 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 ignoreautocomplete="off"ontype="password", butone-time-codeis 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 newpill when not at bottom). The pill is anchored in.agent-main, not inlog.parentElement = .terminal-wrap:.terminal-wrapappliesbackdrop-filter: blurfor the frost effect, which creates a CSS stacking context — anchoring the pill inside that context would trap itsz-indexbelow the fixed composer in the root stacking context, and it'd never float..agent-mainhas no backdrop-filter (no stacking-context creators), so the pill'sz-indexreaches the root and properly composites above the composer. Geometry is unchanged —.agent-mainand.terminal-wrapbothinset: 0fill the same area.
Footer / composer
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
Side panel (slide-in from right): singleton shared with the
dashboard's side panel shape. Carries inbox and todos flyouts (opened
via the header pills) as well as long content (file previews, diffs,
journald logs). Inbox flyout: unread messages addressed to this agent
(acked_at IS NULL, newest-first, up to 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. After the drain the inbox list
empties on reload (the filter is acked_at IS NULL, so drained
messages disappear). Todos flyout ("loose-ends v2"): the harness-local
todos other subsystems push at this agent (GET /api/todos) — matrix,
forge, and bash are the built-in producers, but any user-configured MCP
server can push its own via the same in-agent socket. Each row shows the
producing subsystem, an
optional source label, a summary, and age. A checkbox per row plus a
select-all / select-none / ✓ mark done bulk row above the list POSTs
the checked ids to POST /api/todos/mark-done, which dismisses them
from the harness-local store (same effect as cancel_loose_end(kind: "todo"), just from the web UI instead of the agent's own tool calls).
The todos flyout is the only per-agent flyout — there is no separate
"loose-ends" or "tasks" list. There is also no inline answer form for
ask tool calls in this terminal: an ask renders like any other
tool call, and the operator answers a pending question from the
dashboard's Y3R C4LL tab instead (see
terminal-rendering.md).
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 }. Each event also carries a ts (unix
seconds) — a flattened sibling of the event tag on both the live
SSE frame and the replayed history rows. The web UI:
- fetches
GET /events/historyon page load and replays the last 2000 events (oldest first); - 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; - terminal-themed: phosphor mauve glow, Crust bg, backdrop-filter blur, row fade-in slide-up.
The backfill/live-tail dedupe, sticky-bottom auto-scroll, and "↓ N new" pill are the shared terminal-pane mechanics described in Shape — this page's log is one instance of that same factory.
Per-stream rendering (see docs/terminal-rendering.md for
the full row taxonomy and dispatch logic):
Streamtool_use→Write/Edit: collapsed<details>with a +/- diff body (-lines frominput.old_string,+lines frominput.new_stringor every line ofinput.content). Summary carries the path + line counts.- others (
Read /path,Bash $ cmd,mcp__hyperhive__send → operator: "...", etc.): flat one-line per-tool format with per-tool salient-arg extraction (fmtToolUse).
Streamtool_resultshort → flat← ...; long → collapsed<details>▸ ← Nl · headline(click to expand full body).Streamthinking→.thinkingrow with a💭 thinking …indicator.Streamsystem→ handled by subtype:plugin_installandcompact_boundaryemit muted notes;commands_changedemits an expandable details row listing slash commands;thinking_tokensupdates a single in-place🧠counter;init,result, andrate_limit_eventare dropped (noise / used elsewhere); other subtypes → muted⚙ <subtype>note.Note→· text.TurnStart→◆ TURN ← <from>with the wake-prompt body; a muted· HH:MM:SStime suffix from the eventts.TurnEnd→✓ turn ok/✗ turn fail — note, with a· HH:MM:SS · <duration>suffix (duration = end − the paired turn-start), triggers arefreshState(). The time suffix is guarded on a numericts, so rows degrade cleanly when absent.
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 shelloutspkill -INT claude, emits a Note. Also surfaces as a■ cancel turnbutton in the state row while state=thinking./compact—POST /api/compact→ sets the deferredBus::request_compact()flag and returns immediately. The harness runs the/compactat the next turn boundary (end of the in-flight turn, orturn::run_pending_compactwhen idle); output streams into the live panel. Works mid-turn, not only when idle./model <name>—POST /api/modelflippingBus::set_model. Takes effect on the next turn; persisted to/state/hyperhive-modelso the override survives harness restart / rebuild./effort <level>—POST /api/effortsetting the claude effort level (low/medium/high/xhigh/max). Takes effect on the next turn. The overflow menu surfaces an effort picker that calls the same endpoint; both stay in sync viaStateSnapshot.effort./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 inneeds_login. Session history (~/.claude/projects/) is preserved.
Unknown /foo shows an error row instead of being silently sent.
Per-agent endpoints
Successful POSTs return 200 (no 303 redirects). Error responses
use semantic status codes: 400 for missing/invalid input
(body required, unknown model name, invalid effort level),
409 for retryable state conflicts (turn in flight when
/compact is called), 500 only for genuine server/transport
failures. 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 emitLiveEvent::StatusChangedto flip the badge to/fromneeds_login_in_progress. -
POST /api/cancel— SIGINT the in-flight claude turn. Emits aLiveEvent::Note. -
POST /api/compact— run/compacton the persistent session (same MCP config + system prompt + allowed tools as a normal turn — only the stdin payload differs). Flips state toCompactingviaBus::set_state, which emitsTurnStateChanged. -
POST /api/model(model=<name>) — switch the model for future turns.Bus::set_modelemitsModelChanged. -
POST /api/effort(effort=<level>) — switch the claude effort level for future sessions. Validated server-side againstEFFORT_LEVELS(low/medium/high/xhigh/max) — unknown values are rejected rather than forwarded toclaude --effort. Persists viaBus::set_effort, which emitsEffortChanged. Applies on the next session start (no mid-session swap). -
POST /api/new-session— arm a one-shot for the next turn to drop--continue. Emits aLiveEvent::Note. -
POST /api/logout— three-step teardown that re-uses the existingwait_for_loginresumption path:- 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. - Delete only the OAuth credential files
(
.credentials.json+mcp-needs-auth-cache.jsonunder~/.claude/). Preserves session history files (projects/<hash>/*.jsonl), sessions, shell-snapshots, plans, settings, telemetry, and the dir itself, soclaude --continuekeeps working after a fresh login. ⚠️ Deleting the whole~/.claude/directory instead breaks session continuity — logout must stay narrowed to just the credential files. - Flip
LoginState::NeedsLogin+ emit aLiveEvent::Notedescribing exactly what was wiped, then emitneeds_login_idle. The turn-loop's next iteration parks intowait_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/codeflow (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.
- SIGINT any running claude (matches
-
GET /api/state— cold-load snapshot (StateSnapshot) consumed by the frontend (useAgentState.ts) on page load and, on a poll, whilestatus === 'needs_login_in_progress'. Includesturn_state,context_window_tokens,qualified_label,available_models,effort,available_efforts,links, and other fields described inline throughout this document. All subsequent state updates arrive via SSE. -
GET /api/dashboard-state— lean snapshot of agent-owned fields fetched once per running agent by the dashboard's container row to get fresh values without relying on hive-c0re's periodic file-reads. Returns{ status_text?, status_set_at?, ctx_tokens?, context_window_tokens, rate_limited, links }. Only called when the container is running; skipped (muted badges) when stopped. Also accessible via the gateway at/agent/<name>/api/dashboard-state. -
GET /api/todos— harness-local todos snapshot (loose-ends v2) consumed by the todos flyout (refreshTodos/buildTodosList). See the todos-flyout paragraph above for the producer/subsystem model and the bulk mark-done row. -
GET /api/stats?window=24h|7d|30d|all— time-bucketed turn analyticsSnapshotconsumed by the/statspage.allranges from the earliest recorded turn with an adaptive bucket width. -
GET /icon— agent's icon asimage/svg+xml. Returns/etc/hyperhive/icon.svg(set viahyperhive.iconinagent.nix) when present, otherwise 404 — there is no server-side default. Consumers (dashboard container row, this page's own header icon) hit/iconoptimistically and fall back client-side on load failure to the frontend-bundled/favicon.svgrather than probing first. -
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 whenhyperhive.gui.enable = truein the agent'sagent.nix; the harness shows a 🖥 screen link in the state row whengui_vnc_portis present. A← agentback-link in the page header returns to the main per-agent page. Controls:⤢ fitCSS-downscales the canvas to the window viarelayoutCanvas()setting explicit pixel dimensions on the canvas — not CSSmax-width/max-height, because a flex item's automatic minimum size (min-width: autoresolves to the canvas's intrinsic framebuffer resolution) silently clampsmax-*back up, making fit mode a no-op that just centred + clipped the oversized canvas. The fit-mode rules pin the canvas withflex: none; min-width: 0; min-height: 0so the JS-set size sticks.⤡ match sizesends an RFBSetDesktopSizerequest so the server (weston) changes its real output resolution to the window dimensions; enabled once the server advertises theExtendedDesktopSizepseudo-encoding (-308rect in the header). Fit-mode state persists inlocalStorage(screen-fit); default is on. Pointer coordinates are rescaled insendPointerso 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 at127.0.0.1:<vnc_port>. Transparent to any RFB variant. VNC port comes from theHIVE_GUI_VNC_PORTenv var (a fixed port set on the harness service whenhyperhive.gui.enable; seeweston-vnc.nix). -
GET|POST /extra/<name>/…— extra web proxies declared viahyperhive.extraWebProxiesinagent.nix(serialised to theHIVE_EXTRA_WEB_PROXIESenv var as a JSON object{"<name>": "<upstream_url>"}). Each entry mounts a transparent reverse-proxy at/extra/<name>/that forwards every request (method, headers, body) to the configured upstream, strips hop-by-hop headers on both sides, and buffers the full response body (MVP — SSE connections will appear as one large response rather than streaming). The/extra/namespace ensures user-declared proxies can never conflict with native agent endpoints. Upstream values are either anhttp(s)://URL (forwarded viareqwest) or a Unix domain socket, spelledunix:<path>(e.g.unix:/run/myapp/http.sock) — dialed directly with a raw HTTP/1.1 client per request, sincereqwesthas no UDS transport. Implemented inweb_ui/proxy.rs.
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_limitedis set when the harness detects a 429 response and cleared when the retry sleep expires.model_changed { model }— drives the model chip.effort_changed { effort }— drives the effort picker chip. Emitted fromBus::set_effortonPOST /api/effort; applies on the next session start.token_usage_changed { ctx: TokenUsage, cost: TokenUsage }— drives the ctx + cost badges. Emitted fromBus::record_turn_usageat turn-end;ctxis the last inference's usage (current context size),costis the cumulative across every inference (theresultevent'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). A ← live back-link in the page
header returns to the main per-agent page. Turn analytics, read-only, from
/state/hyperhive-turn-stats.sqlite. GET /api/stats?window= 24h|7d|30d|all returns a time-bucketed Snapshot; the page renders
it with Chart.js (bundled into stats.js via esbuild — no CDN
dependency). 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), doughnuts for tool / wake-source / result mix, and a
result-trend stacked bar — per-bucket result_counts so
error / rate-limit / compaction outcomes are visible over time
(the doughnut shows only the window total).
A favorite tools doughnut shows the most-run shell commands —
normalised bash_commands heads written per bash task by the
hive-bash-daemon capture: the basename of the first real command,
looking past cd repo && prefixes, env-assignments, and
prefix-runners like sudo / env (so cd /repo && cargo build
records cargo, not cd). Read via bash_breakdown; the card
stays hidden until the agent has run a bash command (a missing
bash_commands table degrades to an empty list), so it never
renders an empty chart.
A summary chip row carries window totals, plus two
token-efficiency chips derived from the bucket sums: cache
hit-rate (cache_read over all input-side tokens) and
tokens/turn. When reminder_stats is present (fetched via
ReminderRollup RPC and merged into the snapshot in
web_ui/stats.rs::api_stats) three more chips appear: reminders
scheduled / delivered / pending for the window. When the
per-session capture has data, a first-turn ctx chip shows the
input tokens of the most recent fresh claude session's first turn —
a proxy for system-prompt + CLAUDE.md sprawl (a fresh session's first
turn pays the full static prefix uncached). It's derived in stats.rs
(first_turn_ctx: the first turn, ORDER BY started_at LIMIT 1, of
the latest sessions row in the window) and is omitted from the JSON
until the sessions / turn_stats.session_id capture has rows — so
the chip stays hidden on older dbs (inert-until-capture).
stats.rs opens the sqlite db read-only and degrades to an
empty snapshot on any error — the page is decorative, never
authoritative.