hyperhive/docs/web-ui/shape.md
atlas 8406a45275 feat(#1997): add prettier markdown formatter to treefmt
- .prettierrc: proseWrap=preserve (no prose reflow)
- .prettierignore: exclude hivectl-cli.md (auto-generated) + 11 docs
  with multi-line list-item continuations prettier would strip to col 0
  (CommonMark limitation in prettier's list handling)
- format 16 markdown files: cosmetic only (*→_, table alignment,
  heading normalisation) — verified no broken continuations, idempotent
2026-07-02 23:33:11 +02:00

17 KiB

Shape (shared by both)

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

  • 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 <a>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 LiveEvents 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 <details> with a <pre> 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.fromHistorytrue 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 <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) — 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 <img> 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 <img> 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 <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.