Shape (shared by both)

Part of Web UI. See also: Dashboard layout · Per-agent page

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:

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:

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.