Watch
0
0
Fork
You've already forked hyperhive
0

docs: sort the top-level directories into five sections by what the reader wants

Every docs directory moves under 1-getting-started, 2-handbook,
4-implementation or 5-project; the options reference (section 3) comes from
the website and has no directory here. trust-boundary stays whole under the
handbook for now. web-ui, swarm and tools split between handbook and
implementation, and the command-line references move to
2-handbook/reference. Links, path references in code and nix, and the docs
index follow the moves; no page content changes.
This commit is contained in:
iris 2026-11-06 18:30:12 +01:00 • committed by mara
commit 6739bb903d
3 changed files with 736 additions and 701 deletions

View file

@ -0,0 +1,183 @@
# CSS custom properties (theme variables)
Colour variables live in **two standalone stylesheets**, split so a theme
swap touches only the first:
1. **`colors.css`** — the 16 `--base00`…`--base0F` base16 slots. **This is
the entire theme swap contract.** A generator (for example one fed a stylix
base16 scheme, which is natively base00–base0F) replaces _only this
file_.
2. **`theme.css`** — the semantic layer: `--bg`, `--fg`, `--purple`, …
_derived_ from the base16 slots (`--bg: var(--base00)` etc.). This is
what the app references and it **never changes on a swap**.
Both files live in `frontend/packages/shared/src/`. Per-page stylesheets
(`common.css`, `dashboard.css`, `agent.css`)
reference the **semantic** names and must **not** redeclare them or reach
for the raw `--baseNN` slots directly. (`base.css` holds only the shared
`body` typography — it references the palette but no longer defines it.)
`colors.css` declares each palette's 16 hex values exactly once —
`--mocha-baseNN` (dark, Catppuccin Mocha) and `--latte-baseNN` (light,
Catppuccin-Latte-derived; `base08`-`base0F` run darker than stock
Latte to actually clear WCAG AA against how this app uses them — real
contrast failures found in review, see the comment above those
declarations in `colors.css` for the numbers and why). Everything else
in the file just points the active `--baseNN` slot at one of those two
raw palettes via `var()`, never a second copy of a hex value: an
unconditional default (Mocha), a row gated behind `@media
(prefers-color-scheme: light)` (Latte, the OS/browser-level default),
and two `:root[data-theme='light'|'dark']` blocks a per-user override
sets (swarm-ui's `SettingsMenu`) — an attribute selector always
outranks the plain `:root` inside the media query, so the override wins
regardless of the actual OS preference. Absent an override, the media
query decides; that's what a client with no more specific theme
decision gets (see "Theme swapping" below for how a stylix deployment
supersedes all of this at once).
Both are deliberately standalone, not `@import`ed into the page bundles:
each package re-exports them (`src/{colors,theme}.css` →
`@import "@hive/shared/…"`) so esbuild emits its own
`dist/static/colors.css` + `dist/static/theme.css`, and every page links
them **first** (`colors.css` then `theme.css`) ahead of the page CSS. Note
`theme.css` does **not** `@import` `colors.css` — that would bake the
slots back into it; they're two separate output files so a swap replaces
just `colors.css`. (Load order doesn't actually affect resolution —
custom properties resolve at computed-value time — but each page still
links `colors.css` first for clarity.)
## Semantic palette (`theme.css`)
Each semantic var derives from a base16 slot (or, for the three that have
no clean slot, a `color-mix()` over base16 — pixel-identical under the
default palette). Default hexes shown are Catppuccin Mocha.
| Variable | Derives from | Default hex | Use |
| -------------- | --------------------------- | ----------- | --------------------------------------------------------------- |
| `--bg` | `base00` | `#1e1e2e` | page background |
| `--bg-elev` | `base01` | `#181825` | elevated surfaces: floating dropdowns, popovers |
| `--border` | `base02` | `#313244` | general borders, hover/active backgrounds |
| `--purple-dim` | `base03` | `#45475a` | subtle borders, terminal chrome, badge backgrounds |
| `--fg` | `base05` | `#cdd6f4` | primary text colour |
| `--red` | `base08` | `#f38ba8` | errors, fail state |
| `--amber` | `base09` | `#fab387` | warnings, pending / running state |
| `--yellow` | `base0A` | `#f9e2af` | flash messages, mild warnings |
| `--green` | `base0B` | `#a6e3a1` | success, ok state |
| `--cyan` | `base0C` | `#89dceb` | tool-use events, info accents |
| `--blue` | `base0D` | `#89b4fa` | links, interactive accent (distinct from cyan) |
| `--purple` | `base0E` | `#cba6f7` | accent — active tabs, links, highlights |
| `--pink` | `base0F` | `#f5c2e7` | thinking events |
| `--crust` | `mix(base00 58%, #000000)` | `#11111b` | terminal / code block background (below `--bg`) |
| `--muted` | `mix(base05 55.5%, base00)` | `#7f849c` | secondary / dimmed text |
| `--subtext0` | `mix(base05 77.7%, base00)` | `#a6adc8` | toolbar/status text; dimmer than `--fg`, lighter than `--muted` |
The shares in the `--muted` and `--subtext0` rows are the dark palette's. The light
palette keeps more of the text colour (85% and 93%, set in `colors.css` as
`--muted-share` and `--subtext0-share`), because the dark shares leave its dimmed
text at 2.4:1 against a card, short of the 4.5:1 AA asks for. A palette that sets
neither variable, such as a generated one, keeps the dark shares.
## Common mistakes
The Catppuccin colour names **don't** map 1:1 to the variable names.
Variables to avoid (undefined — they will silently resolve to transparent / inherited):
| Wrong | Correct |
| -------------------------- | --------------------------------------------------- |
| `--text` | `--fg` |
| `--mauve` | `--purple` |
| `--surface0` | `--bg-elev` (float bg) or `--border` (border/hover) |
| `--surface1` | `--border` |
| `--surface2` | `--purple-dim` |
| `--overlay0`, `--overlay1` | `--muted` |
| `--base`, `--mantle` | `--bg`, `--bg-elev` |
## Usage guide
**Floating menus and dropdowns** (for example agent context menu, tabbar overflow):
```css
background: var(--bg-elev);
border: 1px solid var(--purple-dim);
```
**Hover / active state backgrounds**:
```css
background: var(--border);
```
**Active tab text / accent elements**:
```css
color: var(--purple);
```
**Muted / meta text**:
```css
color: var(--muted);
```
**Error / warning / success badges**:
```css
color: var(--red); /* error */
color: var(--amber); /* warning / running */
color: var(--green); /* ok */
```
## Theme swapping — the base16 contract
**The swap interface is `colors.css` — the 16 base16 slots, not the
semantic names.** A theme generator (for example one reading a stylix base16
scheme) overrides only `colors.css`; the semantic layer in `theme.css`
derives everything else, so the whole UI re-themes with nothing else to
template or regenerate. The base16 slot → semantic mapping is _internal_
to `theme.css` (the "Derives from" column above) — a generator never
needs to know the semantic var names, and `theme.css` + the page bundles stay
untouched.
**Two palettes, one contract.** `colors.css` ships both a dark default
(Catppuccin Mocha, unconditional) and a light default (Catppuccin Latte,
behind `@media (prefers-color-scheme: light)`), plus a per-user override
path (`:root[data-theme]`) — a theme generator that overrides the file
wholesale (for example the stylix path, which writes a single unconditional
`:root` block with no media query, no `--mocha-*`/`--latte-*` vars, no
`[data-theme]` blocks) supersedes all of it at once, same as today.
`colors.css` base16 slot defaults (Catppuccin Mocha, dark default):
| Slot | Default | Standard base16 role | Mapped to |
| -------- | --------- | -------------------- | ---------------------------------------------------------- |
| `base00` | `#1e1e2e` | default bg | `--bg`, (darkened) `--crust` |
| `base01` | `#181825` | lighter bg | `--bg-elev` |
| `base02` | `#313244` | selection/surface | `--border` |
| `base03` | `#45475a` | comments/dim surface | `--purple-dim` |
| `base04` | `#585b70` | dark foreground | _(unused; kept for completeness)_ |
| `base05` | `#cdd6f4` | default foreground | `--fg`, (blended) `--muted`/`--subtext0` |
| `base06` | `#f5e0dc` | light foreground | _(unused)_ |
| `base07` | `#b4befe` | lightest | _(unused)_ |
| `base08` | `#f38ba8` | red | `--red` |
| `base09` | `#fab387` | orange | `--amber` |
| `base0A` | `#f9e2af` | yellow | `--yellow` |
| `base0B` | `#a6e3a1` | green | `--green` |
| `base0C` | `#89dceb` | cyan | `--cyan` (named _sky_ here; Catppuccin's `base0C` is teal) |
| `base0D` | `#89b4fa` | blue | `--blue` |
| `base0E` | `#cba6f7` | magenta | `--purple` |
| `base0F` | `#f5c2e7` | extra accent | `--pink` |
Notes for theme authors:
- **`--crust`, `--muted`, `--subtext0` have no own slot** — they're
derived via `color-mix()` over base16 (`--crust` = a darkened `base00`;
`--muted`/`--subtext0` = `base05`↔`base00` blends). They still track a
swap automatically; no generator action needed.
- A standard Catppuccin base16 scheme uses **teal** for `base0C`;
`colors.css` defaults it to **sky** (`#89dceb`) to preserve the historical
accent. A
stylix Catppuccin scheme will shift `--cyan` to teal — that's the
operator's chosen scheme, working as intended.
- `base04`/`base06`/`base07` aren't consumed by a semantic var today;
they're kept at their standard values so the 16-slot contract is
complete (a partial scheme override still works).

View file

@ -0,0 +1,307 @@
# Shape (shared by both)
> Part of [Web UI](../../2-handbook/web-ui/README.md). See also:
> [Dashboard layout](../../2-handbook/web-ui/dashboard.md) · [Per-agent page](../../2-handbook/web-ui/agent.md)
## Shared routes
- `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` — that fallback serves anything not
matched by an API or action route from the dist. Dashboard dist lives at
`${frontend}/dashboard`; per-agent dist is the merged
`services.hyperhive.agent.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
`colors.css` + `theme.css` + `common.css` + `dashboard.css`;
`common.css` inlines `@hive/shared`'s
`base.css` + `terminal.css` + `tabs.css` +
`pill.css` via esbuild's `@import` resolution.
`terminal.js` exports `{ create, linkify }` as ES module
members; a back-compat shim in the IIFE bodies still exposes
a `window.HiveTerminal` global for callers that need it. The
per-agent `#live` log runs on
this terminal — sticky-bottom autoscroll, "↓ 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 go through
`marked` (npm dep, replacing the vendored UMD bundle) and then
`DOMPurify.sanitize()` before hitting `innerHTML` — untrusted row
text (peer-agent / matrix-relayed message bodies, agent-authored
state files) can carry arbitrary HTML/script via markdown, so
`DOMPurify.sanitize()` cleans the markdown path rather than it being
XSS-safe by construction the way the text-node `linkify` path is.
Rendered `<a>`s are 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 server assembled the snapshot);
clients use it to dedupe their buffered SSE traffic against
the snapshot (drop frames with `seq <= snapshot.seq`).
- `GET /api/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 (`transient_set` / `transient_cleared`). Each frame carries a `seq`. The
agent's matching backfill endpoint is `GET /events/history` (last 2000
`LiveEvent`s wrapped in `{ seq, events }`).
**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 (for example 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
The shared terminal factory in `@hive/shared/terminal.js` backs the
per-agent page's scrollable log stream (`#live`). The factory wires up
sticky-bottom autoscroll, 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.fromHistory` — `true` while backfill replay is running; renderers
use it to suppress live-only side effects.
<!-- vale write-good.Passive = NO -->
**`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`), `expandDetails?` (boolean or zero-arg function
returning one, re-read on every `api.details`/`api.detailsDiff` call rather
than captured once — lets a page default otherwise-collapsed panels open
per a live browser-local preference; renderers that force a row open
regardless, for example message-bearing tool_use, are unaffected either way).
<!-- vale write-good.Passive = YES -->
**Sticky-bottom + snap animation.** `stickToBottom` is the
operator's intent: true means "keep snapping to bottom on every
mutation," false means "the operator scrolled up, leave them 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 — the client silently
drops events with `seq <= snapshot.seq` from the SSE stream
(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, then
`DOMPurify.sanitize()` on the resulting HTML before it's assigned to
`innerHTML` (see `mdNode` in `app.js` / `common.js`) —
`DOMPurify.sanitize()` cleans this path rather than it being
text-node-safe like `linkify`, since markdown can carry raw HTML.
Rendered `<a>`s 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 the handler doesn't rely on a
re-render), call `refreshState()`. State shapes live in
`hive-c0re/src/dashboard/state_snapshot.rs::StateSnapshot` and
`web_ui/state.rs::StateSnapshot` — when
adding state fields, plumb through the snapshot struct and the
relevant domain module (`agents.js`, `permissions.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, so the intermediate empty state is never
visible — clearing and re-appending directly into the live root can
leave it empty for a paint (on async builders) or a whole frame (on
large synchronous ones), which reads as a visible flash on every
poll cycle. Builders receive the fragment as their
`root`, so existing renderer code carries over unchanged; early
returns inside the builder still commit whatever it appended
before returning.
**Keyed DOM caching:** for sections whose rows hold interactive state
(textarea drafts, checkboxes, focused inputs) `paintAtomic` isn't
enough — wiping and rebuilding still destroys the state even if
`paintAtomic` hides the flash. The keyed pattern keeps a
`Map<id, {el, fingerprint}>` where the fingerprint is
`JSON.stringify({...visible fields...})`. On each render, the pattern
reuses cache-hit rows verbatim (preserving textarea draft, checkbox
state, and event listeners) and rebuilds only cache-miss rows. Used for: containers (`containerRowCache`),
rebuild-queue entries (`rebuildQueueRowCache`). The spawn-form input+focus
uses a lighter snapshot-then-restore pattern (snapshot
before `replaceChildren`, restore after) since it's a single
value rather than a per-row cache.
<!-- vale write-good.Passive = NO -->
**`<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. The
dashboard's own answered-questions history list was the only
`<details data-restore-key>` consumer and is gone
— `snapshotOpenDetails()` / `restoreOpenDetails()` currently have
nothing to restore, kept in place for the next collapsible section
that needs it rather than ripped out along with their one caller.
<!-- vale write-good.Passive = YES -->
## 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) so an nspawn restart that races
the previous process's socket release resolves itself, but the two
binaries differ on the attempt budget. The dashboard's
`bind_with_retry` (`hive-c0re/src/dashboard/mod.rs`) has **no
attempt cap** — retrying forever is deliberate, since
`lifecycle::{spawn,rebuild}` preflights genuine port collisions
host-side (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. Its 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's `bind_with_retry`
(`hive-agent/src/web_ui/mod.rs`) instead gives up after
`MAX_BIND_ATTEMPTS` (12) and returns the `AddrInUse` error rather
than looping forever.
The per-agent UI optionally binds a `UnixListener` instead of
TCP when the nix module sets `HIVE_WEB_SOCKET` — 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/4-implementation/networking/gateway.md::Per-agent unix-socket
upstream`](../networking/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/main.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 key off the
mount prefix.
The router serves subpages (`stats`, `screen`) without a trailing slash so
the relative-path resolution stays correct: `static/stats.js` from
`/stats` becomes `/static/stats.js` (last segment `stats` gets
replaced), not `/stats/static/stats.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.

View file

@ -0,0 +1,71 @@
# Per-agent terminal: row taxonomy (as built)
The per-agent web UI's live pane renders one row per `TermMsg`
(`hive-agent/src/term_msg.rs`): `{ts, icon?, level: debug|info|warn|error,
summary, body?, body_format?: markdown|diff, coalesce_key?}`. Classification
(icon, summary text, whether a tool call gets an expandable body) happens
server-side, once — `term_msg.rs` + `stream_enrich.rs` — and both
`GET /api/events/history` and `GET /api/events/stream` serve it identically
as `TermEnvelope { seq?, msgs: TermMsg[] }` frames
(`hive-agent/src/web_ui/stream.rs`).
`ts` is ISO 8601 UTC (`2026-09-13T12:35:03Z`) and is the **source event's**
time, stamped at classify time from the event's own stamp — so a row
replayed from sqlite says when it happened, not when a client fetched
it. It
lives on the row rather than on the envelope because the swarm queue
publishes rows bare, with no envelope around them
(`hive-agent/src/swarm_term.rs`); a subscriber there has nowhere else to
read the time from.
The frontend renders a `TermMsg` close to as-is
(`frontend/packages/shared/src/terminal/Row.tsx` — moved out of
`packages/agent` once swarm-ui became a second consumer of it): `level`
picks the
CSS colour (`terminal.css`'s `.live .level-*`), an empty `summary` +
markdown `body` renders as a flat row with just the body (assistant
text), and any other bodied row is an expandable `<details>`, opened by
default according to the operator's expand-tool-output preference
(`getExpandDetailsPref()`) — uniformly, no per-tool override. There's no
separate client-side row model or classification step.
## Layout
Every row is its own 2-column grid — icon column (`.row-glyph`, width
from the `--row-icon-col` custom property) then content column
(`.row-content`, or a markdown body's own `.md` class) — so an icon's
left edge lands in the same place regardless of row kind. A `<details>`
row grids its `<summary>` instead of itself (so the `<pre>` body below
can stack full-width), using the exact same `grid-template-columns` value,
with the disclosure caret leading the summary text rather than the icon.
One shared column definition, not two independently computed offsets
kept in sync by hand — see `terminal.css`'s own comment on the incident
that motivated the change.
## Levels
| Level | Colour | Roughly |
|---|---|---|
| `debug` | muted | thinking, coalesced ticks, ambient harness chatter |
| `info` | default fg | turn start/ok, assistant text, tool calls + results |
| `warn` | amber, left rule | stderr, an unrecognised event shape, API retries |
| `error` | red, left rule | turn failed, a tool result with `is_error: true` |
The settings menu's "hide developer details" toggle (`getHideDebugPref()`,
`@hive/shared/prefs.js`) skips `debug`-level rows entirely rather than
muting them further — client-side only, no reload. The per-agent page and
the swarm UI both have the toggle, as one shared row
(`@hive/shared/hide-debug-setting.js`). Each browser origin keeps its own
setting. A `debug` row follows the toggle as it changes, so rows already
on screen hide or show at once, not only the rows that arrive after it.
Per-tool icon + summary formatting (what a `Read`/`Edit`/`send` call's row
actually says) lives in `stream_enrich.rs`'s `fmt_tool_use()` family —
read that when you need the specifics, this doc doesn't duplicate it.
## Markdown
`frontend/packages/shared/src/terminal/markdown.ts`'s `renderMarkdown()` runs
`marked.parse()` through DOMPurify into a `<div class="md">`. Applied to
any `TermMsg` with `body_format: "markdown"`.