# Shape (shared by both) > Part of [Web UI](../web-ui.md). See also: > [Dashboard layout](dashboard.md) · [Per-agent page](agent.md) - `GET /` → `index.html` from the bundled frontend dist (see `frontend/`). Both binaries' routers declare their dynamic endpoints first and then `fallback_service(ServeDir::new(...))` pointed at `HIVE_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 merged `hyperhive.frontend.mergedDist` (default agent dist + per-agent `extraFiles` overlay). - `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` (was `hive-fr0nt`); the dashboard ships four CSS bundles (`common.css` loaded by every page, plus per-page `dashboard.css` / `flow.css` / `logs.css`); `common.css` inlines `base.css` + `terminal.css` via esbuild's `@import` resolution. `terminal.js` exports `{ create, linkify }` as ES module members (no more `window.HiveTerminal` global outside the back-compat shim the IIFE bodies still use). The dashboard's `#msgflow` and the per-agent `#live` log 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. Bare `http(s)://` URLs in row text are turned into clickable new-tab links by `linkify` (text-node based, no `innerHTML` — XSS-safe); markdown bodies get the same treatment via `marked`'s autolink (npm dep, replacing the vendored UMD bundle), with the rendered ``s rewritten to `target="_blank"`. - `GET /api/state` → JSON snapshot the JS app renders into the DOM. Includes a top-level `seq` (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 with `seq <= snapshot.seq`). - `GET /dashboard/stream` (dashboard) / `GET /events/stream` (per-agent) → `text/event-stream` SSE for live updates. The dashboard stream carries broker `Sent` / `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 a `seq`. The matching backfill endpoint is `GET /dashboard/history` (last ~200 broker messages wrapped in `{ seq, events }`) on the dashboard and `GET /events/history` (last 2000 `LiveEvent`s 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 by `kind` on 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 coupling `recv_blocking_batch` (hot path inside the harness turn loop) to presentation concerns. **SSE multiplexing**: the dashboard uses a `SharedWorker` (`stream-worker.js`) to hold one upstream `EventSource` per 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 via `MessagePort`; on bfcache restore the page re-subscribes (gets a synthetic `open` event immediately if the upstream is already connected). Falls back gracefully when `SharedWorker` is 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 fresh `SharedWorker` port (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, icon?)` — single-line row with an inline `linkify` pass over the text; optional `icon` lands in a fixed-width `.row-glyph` cell so glyphs of varying rendered width all align. - `api.details(cls, summary, body, icon?)` — collapsible `
` with a `
` body (used by long tool-results and stack traces).
- `api.detailsDiff(cls, summary, body, icon?)` — same shape, splits the
  body on newlines and tags each line as `diff-add` / `diff-del` /
  `diff-ctx` so the renderer's diff bodies get coloured without
  emitting raw HTML.
- `api.placeholder(text)` — replaces the log with a single muted row,
  cleared automatically when the next real row arrives.
- `api.fromHistory` — `true` while backfill replay is running; renderers
  use it to suppress live-only side effects.

**`create(opts)` options:** `logEl` (log container element), `streamUrl`
(SSE endpoint), `historyUrl?` (optional replay endpoint — skipped if absent
or if the fetch fails), `renderers` (kind→fn map; unknown kinds fall through
to `renderers._default`, itself defaulting to a JSON-dump note row),
`onLiveEvent?(ev)` (live-only side effects), `onAnyEvent?(ev, {fromHistory})`
(runs for both replay and live — use for derived views that need the full
event picture), `onBackfillDone?(count)` (one-shot after replay; `count=0` on
failed/skipped fetch), `onStreamOpen?()` (fires on every EventSource
(re)connect — use to re-sync snapshot-derived state after a reconnect gap),
`pillAnchor?` (parent element for the "↓ N new" pill; defaults to
`logEl.parentElement`).

**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 ``
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 domain module (`assets/swarm.js`, `assets/call.js`, etc.) 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`
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.

**`
` open-state preservation:** any collapsible element tagged with `data-restore-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 `
` 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`) — a `rendered` / `plain` tabbed view: `rendered` (default) is the vendored `marked` bundle (`GET /static/marked.js`), `plain` is the raw source. - **SVG** (`.svg`) — a `rendered` / `source` tabbed view; `rendered` shows the image via an `` `data:` URI (the browser's secure static mode, so an untrusted SVG can't run scripts), `source` shows the raw markup. - **Raster images** (`.png` / `.jpg` / `.gif` / `.webp` / `.bmp` / `.ico` / `.avif`) — render as an `` pointed at `/api/state-file`, which serves them as binary with their real content-type (text files stay UTF-8-lossy `text/plain`). - **Everything else** — raw text in a `
`.

## 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//` bind-mount,
`.bound` marker filtering, `agent-sockets.json` consumer on the
gateway side) live in [`docs/gateway.md::Per-agent unix-socket
upstream`](gateway.md). 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 `` injection at serve time.