Shape (shared by both)
Part of Web UI. See also: Dashboard layout · Per-agent page
GET /→index.htmlfrom the bundled frontend dist (seefrontend/). Both binaries' routers declare their dynamic endpoints first and thenfallback_service(ServeDir::new(...))pointed atHIVE_STATIC_DIR— anything not matched by an API or action route is served from the dist. Dashboard dist lives at${frontend}/dashboard; per-agent dist is the mergedhyperhive.frontend.mergedDist(default agent dist + per-agentextraFilesoverlay).GET /static/*→ bundled CSS + JS produced by esbuild (frontend/packages/{dashboard,agent}/build.mjs). Both pages pull the shared terminal pane + Catppuccin palette + typography from@hive/shared(washive-fr0nt); the dashboard ships four CSS bundles (common.cssloaded by every page, plus per-pagedashboard.css/flow.css/logs.css);common.cssinlinesbase.css+terminal.cssvia esbuild's@importresolution.terminal.jsexports{ create, linkify }as ES module members (no morewindow.HiveTerminalglobal outside the back-compat shim the IIFE bodies still use). The dashboard's#msgflowand the per-agent#livelog are both backed by this terminal — sticky-bottom auto-scroll, "↓ N new" pill, history backfill, SSE plumbing all live there. Each page registers a kind→renderer map; unknown kinds fall through to a JSON-dump note row. Barehttp(s)://URLs in row text are turned into clickable new-tab links bylinkify(text-node based, noinnerHTML— XSS-safe); markdown bodies get the same treatment viamarked's autolink (npm dep, replacing the vendored UMD bundle), with the rendered<a>s rewritten totarget="_blank".GET /api/state→ JSON snapshot the JS app renders into the DOM. Includes a top-levelseq(the dashboard event channel's high-water mark at the moment the snapshot was assembled); clients use it to dedupe their buffered SSE traffic against the snapshot (drop frames withseq <= snapshot.seq).GET /dashboard/stream(dashboard) /GET /events/stream(per-agent) →text/event-streamSSE for live updates. The dashboard stream carries brokerSent/Delivered(mirrored by a forwarder task from the broker's intra-process channel) plus mutation events (approval_added/approval_resolved,question_added/question_resolved,transient_set/transient_cleared). Each frame carries aseq. The matching backfill endpoint isGET /dashboard/history(last ~200 broker messages wrapped in{ seq, events }) on the dashboard andGET /events/history(last 2000LiveEvents also wrapped in{ seq, events }) on the agent. 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 bykindon the client is a one-liner. Per-domain splits are reserved for high-volume sub-streams most consumers skip (none exist yet). The broker's intra-process channel stays separate from the dashboard channel to avoid couplingrecv_blocking_batch(hot path inside the harness turn loop) to presentation concerns. SSE multiplexing: the dashboard uses aSharedWorker(stream-worker.js) to hold one upstreamEventSourceper URL. All same-origin tabs share this worker — a second dashboard tab joins the existing connection rather than opening a duplicate. The worker fans SSE events out to each subscribed tab viaMessagePort; on bfcache restore the page re-subscribes (gets a syntheticopenevent immediately if the upstream is already connected). Falls back gracefully whenSharedWorkeris unavailable (e.g. some private-mode browsers). Worker-death self-heal: Firefox kills "idle" SharedWorkers under memory pressure with no client-side signal — the port silently goes no-op. The worker now pings every connected port every 30s; the client bumps a last-activity timestamp on every message (incl. pings, which carry no URL — bumped before the URL filter). A visibility-gated watchdog polls every 15s and, if the page is visible AND has active subs AND hasn't heard from the worker in >90s (three missed pings), presumes the worker dead and re-subscribes on a freshSharedWorkerport (same code path bfcache-restore uses). Recovery is per-tab; pings are invisible on the healthy path.
Shared terminal pane
Both surfaces' scrollable log streams (#msgflow on the dashboard,
#live on the per-agent page) are backed by the shared terminal
factory in @hive/shared/terminal.js. The factory wires up
sticky-bottom auto-scroll, 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
shapes the renderers call:
api.row(cls, text)— single-line row with an inlinelinkifypass over the text.api.details(cls, summary, body)— collapsible<details>with a<pre>body (used by long tool-results and stack traces).api.detailsDiff(cls, summary, body)— same shape, splits the body on newlines and tags each line asdiff-add/diff-del/diff-ctxso the renderer's diff bodies get coloured without emitting raw HTML.
Sticky-bottom + snap animation. stickToBottom is the
operator's intent: true means "keep snapping to bottom on every
mutation", false means "I scrolled up, leave me alone". The flag
flips when a scroll event lands further than NEAR_BOTTOM_PX = 48
from the bottom. New rows then either snap to bottom (when sticky)
or bump the unseen-count and surface the "↓ N new" pill. The snap
is a brief 140ms ease-out (SCROLL_ANIM_MS) — the browser's
default behavior: 'smooth' ~500ms reads as "still smooth, but
visibly slow"; 140ms feels snap-y while still reading as motion
rather than a jump. Distances under SCROLL_SNAP_PX = 24
short-circuit to instant — animating a 12px nudge would just be
jitter. Each new snap cancels the previous requestAnimationFrame
so a burst of mutations coalesces into one ride to the latest
bottom; the per-frame step re-reads scrollHeight - clientHeight
so mutations landing mid-animation extend the destination smoothly
rather than land short.
Mid-animation scroll-event guard. The scroll handler's
isNearBottom check would flip stickToBottom false mid-snap as
the smooth animation eases through positions that are technically
"not near bottom yet", which would strand the operator partway. A
smoothScrollingUntil timestamp gates the scroll handler — set to
the animation end + ~80ms headroom, re-armed on each fresh snap.
Programmatic scrollTop writes (the animation's per-frame update)
fire scroll events that the gate swallows. One caveat: if live events
arrive faster than the animation window (< 220ms apart), the
MutationObserver keeps re-firing snapToBottom() which perpetually
re-arms the gate — trapping the operator at the bottom with no way
to scroll up or trigger loadMore(). Safety valve: even inside the
guard the handler checks isNearBottom(); if the operator has
scrolled away from the bottom, stickToBottom is immediately forced
false. The MO's if (stickToBottom) check then stops re-arming
snaps, and the gate expires within ≤220ms.
Post-append MutationObserver. Renderers commonly call
api.row(cls, text) to create the row shell then append more
children (badges, multi-line bodies, tool result panes) after the
factory returned. The initial sticky-snap fires off the row's
empty shape; the renderer's later appends grow the row past the
visible bottom. A MutationObserver on the log subtree fires once
per microtask after each batch of synchronous mutations and snaps
again when stickToBottom is true. Programmatic scrollTop
writes don't re-trigger the observer (scroll isn't a DOM
mutation), so no feedback loop. The pre-append
nearBottomBeforeAppend snapshot is still useful — it keeps the
initial visual lag to one frame instead of one microtask + frame.
Backfill + SSE. Cold load fetches historyUrl (replay), then
subscribes to streamUrl (live tail). Both endpoints return
{ seq, events } so the client can dedupe — events with
seq <= snapshot.seq from the SSE stream are dropped silently
(the snapshot already covers them). History rows render with a
.no-anim class so they don't stagger in like live events. The
optional streamFactory(url) callback lets the dashboard hand
the factory a SharedWorker-backed EventSource facade (so
multiple tabs share one upstream connection — see SSE
multiplexing above); when omitted, the factory falls back to a
plain new EventSource(url).
linkify (text-node based). Bare http(s):// URLs in row
text get wrapped in <a target="_blank" rel="noopener noreferrer">
inside a fresh text node, so the autolinker never touches
innerHTML and untrusted row content can't smuggle markup. The
trailing-punctuation strip keeps .,;: outside the link surface.
Markdown bodies go through marked separately and get the same
target rewrite.
The JS app handles all form[data-async] submissions via a delegated
listener: read data-confirm, swap the button to a spinner, POST
application/x-www-form-urlencoded, re-enable the button on success
(refreshState may keep the form mounted, so we don't rely on a
re-render), call refreshState(). State shapes live in
dashboard.rs::StateSnapshot and web_ui.rs::StateSnapshot — when
adding state fields, plumb through the snapshot struct and the
relevant assets/tabs.js render function.
Focus preservation: refreshState checks whether
document.activeElement sits inside one of the managed sections
and, if so, skips the refresh (defers 2s). The operator never has
the form yanked out from under them mid-type; the update lands as
soon as they blur.
Atomic section repaint: every managed-section renderer goes
through paintAtomic(liveRoot, build): the builder appends into
a fresh DocumentFragment (off-DOM) and the commit is one
replaceChildren call. The naive root.innerHTML = ''; root.append(...)
shape was visibly flashing empty on every poll cycle — on async
paths the await yield gave the browser a paint opportunity between
the clear and the re-append, and on complex builds (many el()
allocations) layout could escape the per-task budget even on the
synchronous path. The fragment approach keeps the intermediate
empty state invisible. Builders receive the fragment as their
root, so existing renderer code carries over unchanged; early
returns inside the builder still commit whatever was appended
before they returned.
Keyed DOM caching: for sections whose rows hold interactive state
(textarea drafts, checkboxes, focused inputs) paintAtomic is not
enough — wiping and rebuilding still destroys the state even if the
flash is hidden. The keyed pattern keeps a Map<id, {el, fingerprint}>
where the fingerprint is JSON.stringify({...visible fields...}).
On each render: cache-hit rows are reused verbatim (preserving
textarea draft, checkbox state, and event listeners); only cache-miss
rows are rebuilt and inserted. Used for: containers (containerRowCache),
rebuild-queue entries (rebuildQueueRowCache), and question rows
(questionRowCache). The spawn-form input+focus and meta-input
checkboxes use a lighter snapshot-then-restore pattern (snapshot
before replaceChildren, restore after) since they are single
values rather than per-row caches.
<details> open-state preservation: any collapsible element
tagged with data-restore-key="<stable-key>" survives the
refresh. snapshotOpenDetails() walks managed sections before
render, restoreOpenDetails() re-applies after. Long-content
drill-ins (file previews, diffs, journald logs) now open in the
side panel (see below) rather than expanding inline, so the
only restore-keyed <details> left is the answered-questions
history list.
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, a backdrop click, or Escape. Panel.open(title, node) swaps the body; the JS builders for file previews,
approval diffs, and journald logs all render 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. Width is clamped to CSS min-width: 320px
/ max-width: 96vw; the viewport-resize handler re-clamps
persisted values after a window shrink. File
previews are type-aware:
- Markdown (
.md/.markdown) — arendered/plaintabbed view:rendered(default) is the vendoredmarkedbundle (GET /static/marked.js),plainis the raw source. - SVG (
.svg) — arendered/sourcetabbed view;renderedshows the image via an<img>data:URI (the browser's secure static mode, so an untrusted SVG can't run scripts),sourceshows the raw markup. - Raster images (
.png/.jpg/.gif/.webp/.bmp/.ico/.avif) — render as an<img>pointed at/api/state-file, which serves them as binary with their real content-type (text files stay UTF-8-lossytext/plain). - Everything else — raw text in a
<pre>.
Listener bind
Both bind their TCP listener with SO_REUSEADDR via
tokio::net::TcpSocket plus a retry loop on AddrInUse
(exponential backoff capped at 2s, no attempt cap) so an nspawn
restart that races the previous process's socket release resolves
itself. The retry is uncapped on purpose: a capped budget once
left the harness silently UI-less for the rest of its lifetime
when a back-to-back restart held the port longer than the cap
allowed. Genuine port collisions are preflighted host-side
(lifecycle::{spawn,rebuild} refuses with a clear error,
surfaced on the dashboard as a banner), so at this layer a
persistent AddrInUse always reflects a recoverable stale
socket — retrying forever is the safe choice. The first 12
attempts log at WARN; after that the level drops to INFO so a
long-held stale socket doesn't flood the journal.
The per-agent UI optionally binds a UnixListener instead of
TCP when HIVE_WEB_SOCKET is set — the unix-socket transition
mechanics (per-agent /run/hive-agent/<name>/ bind-mount,
.bound marker filtering, agent-sockets.json consumer on the
gateway side) live in docs/gateway.md::Per-agent unix-socket upstream. The env var is opt-in per agent so the
two modes coexist while sub-agents transition.
Per-agent relative paths
The per-agent UI uses document-relative paths everywhere for
assets, API calls, form actions, and the screen WebSocket. Bare
references like static/app.js, api/state, events/stream,
screen/ws resolve against document.baseURI — the page's URL
without its last path segment.
That makes the page work under any prefix the agent ends up mounted at without rebuilding the dist. The cases that matter:
| served at | api/state resolves to |
|---|---|
/ (own port, today's shape) |
/api/state |
/agent/iris/ (gateway-prefixed) |
/agent/iris/api/state |
/agent/iris/stats (subpage, no trailing slash) |
/agent/iris/api/state |
The gateway upstream config strips the prefix before forwarding to
the per-agent server, so the agent's Rust routes (api/state,
events/stream, screen/ws, login/start, …) keep their absolute
paths server-side. Only the browser-facing URLs are gated on the
mount prefix.
Subpages (stats, screen) are served without a trailing slash so
the relative-path resolution stays correct: static/app.js from
/stats becomes /static/app.js (last segment stats gets
replaced), not /stats/static/app.js. Adding a trailing slash to
those routes would break the resolution; either keep them
slash-less or use <base href> injection at serve time.