diff --git a/docs/web-ui/shape.md b/docs/web-ui/shape.md index 56e42eb9..a38b9328 100644 --- a/docs/web-ui/shape.md +++ b/docs/web-ui/shape.md @@ -88,14 +88,31 @@ 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 inline `linkify` - pass over the text. -- `api.details(cls, summary, body)` — collapsible `
` with +- `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)` — same shape, splits the
+- `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
diff --git a/frontend/packages/dashboard/src/matrix-accounts.js b/frontend/packages/dashboard/src/matrix-accounts.js
index b0d5fed6..1985967b 100644
--- a/frontend/packages/dashboard/src/matrix-accounts.js
+++ b/frontend/packages/dashboard/src/matrix-accounts.js
@@ -4,39 +4,26 @@
 // account and store its access token, without editing the agent's config
 // repo. Companion to the multi-account harness support.
 //
-// Backend contract (v2 — BE-4 adds live/user_id/as_of from the daemon snapshot):
+// Backend contract:
 //   GET  /api/matrix-accounts?agent=
-//        -> { accounts: [ { name, homeserver: string|null, token_present: bool,
-//                           live: bool, user_id: string|null } ],
+//        -> { accounts: [{name, homeserver, token_present, live, user_id}],
 //             as_of_unix: int|null }
-//   POST /api/matrix-account-login    (x-www-form-urlencoded, operator-auth)
+//   POST /api/matrix-account-login (x-www-form-urlencoded)
 //        fields: agent, account, homeserver, mode=password|token,
 //                user_id?, password?, token?
-//        -> 200 JSON { ok: true, user_id }   on success
-//        -> error body in transition: today a bare plain-text body
-//           (hive-c0re's error_response), migrating to RFC 9457
-//           application/problem+json { type, title, detail, … }. The error
-//           path reads shape-agnostically (text first, then JSON `detail` if
-//           it parses) so both shapes work regardless of BE/FE merge order.
-//   The token is NEVER echoed back in any response, and this page never
-//   re-renders a submitted secret.
+//        -> 200 { ok: true, user_id }. Error body shape-agnostic: plain text
+//           today, migrating to RFC 9457 problem+json { detail, … }.
+//   Token is never echoed; page never re-renders a submitted secret.
 //
-// Live status dot (coordinated with the BE snapshot + heartbeat): the daemon
-// force-rewrites its host-visible snapshot every ~30s (heartbeat, BE), so
-// `as_of_unix` advances while the daemon is alive — a stalled `as_of` is now an
-// honest "daemon stopped publishing" signal, not just "snapshot is old". We
-// render —
-//   green        (live + running + fresh)     = online
-//   dim green    (live but as_of stale > ~90s) = heartbeat stopped, likely dead
-//   amber        (live + container DOWN)        = stale (container down ⟹ down)
-//   amber        (token_present + !live)        = provisioned but offline
-//   grey         (no token)                     = not provisioned
-// The dim-green age case is meaningful only because of the heartbeat: 3 missed
-// ~30s beats (>90s) without the container being explicitly down means the
-// daemon is up-but-dead or wedged. The container cross-ref still takes
-// precedence (a stopped container is definitively stale regardless of age).
-// `as_of_unix` is tooltipped throughout so freshness is always legible. When
-// `live` is absent (v1 backend) the dot falls back to token-present rendering.
+// Live status dot — the daemon heartbeats every ~30s (advances as_of_unix),
+// so a stalled as_of = daemon dead, not just stale snapshot:
+//   green      live + running + fresh          = online
+//   dim green  live but as_of stale > ~90s     = heartbeat stopped
+//   amber      live + container DOWN           = definitively stale
+//   amber      token_present + !live           = provisioned but offline
+//   grey       no token                        = not provisioned
+// Container state takes precedence; as_of_unix is tooltipped for freshness.
+// v1 backend (no `live` field) falls back to token-present rendering.
 
 import { $, el, esc, fmtAgeSecs, renderServerWarnings } from './common.js';
 
diff --git a/frontend/packages/dashboard/src/permissions.js b/frontend/packages/dashboard/src/permissions.js
index f1d7bef8..edca22ea 100644
--- a/frontend/packages/dashboard/src/permissions.js
+++ b/frontend/packages/dashboard/src/permissions.js
@@ -14,21 +14,12 @@
 // it reads — to union live containers with agents already named in the
 // assignments map.
 //
-// Editing model (the save-all issue): the operator toggles any number of
-// checkboxes across BOTH matrices, then clicks the single "save all (N)"
-// button. We diff each checkbox against the baseline captured at render
-// time (stored on `data-baseline`) and POST only the perm-types that
-// actually changed per agent to `POST /api/permissions`:
-//
-//   { changes: [ { agent, tool_groups?, capabilities? } ] }
-//
-// Omitted field = leave that perm-type untouched (no commit, no diff);
-// an included array fully replaces that perm-type for that agent. The
-// backend coalesces caps+groups for one agent into ONE combined queue
-// entry → one rebuild per agent (no more double-rebuilds). The batch is
-// atomic: validated whole, applied whole, or rejected whole with
-// `{error}` — so the saved→rebuilding transition only fires on a clean
-// 200.
+// Editing model: checkboxes are diffed against a `data-baseline` captured at
+// render time; only changed perm-types per agent are POSTed to
+// `POST /api/permissions` as `{ changes: [{agent, tool_groups?, capabilities?}] }`.
+// Omitted field = leave untouched; included array = full replace. The backend
+// coalesces caps+groups per agent into ONE queue entry (one rebuild, no
+// double-rebuild). Batch is atomic — saved→rebuilding only fires on a clean 200.
 
 import { $, el } from './common.js';
 import { containersState } from './state.js';
diff --git a/frontend/packages/dashboard/src/stream-worker.js b/frontend/packages/dashboard/src/stream-worker.js
index 06ae6b90..263d0a25 100644
--- a/frontend/packages/dashboard/src/stream-worker.js
+++ b/frontend/packages/dashboard/src/stream-worker.js
@@ -9,32 +9,19 @@
 //     { kind: 'unsubscribe', url: '/api/dashboard/stream' }
 //
 //   worker → tab
-//     { kind: 'open',    url: '...' }   relayed from EventSource.onopen,
-//                                       plus a synthetic open fired to
-//                                       a brand-new subscriber when the
-//                                       upstream is already OPEN — so
-//                                       the page's onStreamOpen still
-//                                       runs and triggers a snapshot
-//                                       re-sync after a reconnect gap.
+//     { kind: 'open',    url: '...' }   relayed from EventSource.onopen; also
+//                                       fired synthetically to new subscribers
+//                                       when upstream is already OPEN (so
+//                                       onStreamOpen runs + re-syncs state).
 //     { kind: 'message', url: '...', data: '' }
 //     { kind: 'error',   url: '...' }   relayed from EventSource.onerror.
-//     { kind: 'ping'     }             heartbeat — fired every
-//                                       PING_INTERVAL_MS to every
-//                                       connected port. The client's
-//                                       watchdog uses these as
-//                                       proof-of-life; silence past
-//                                       ~3× the interval triggers a
-//                                       re-subscribe on a fresh port
-//                                       (recovers from Firefox killing
-//                                       the SharedWorker out from
-//                                       under us, which it does under
-//                                       memory pressure with no native
-//                                       signal to the client).
+//     { kind: 'ping'     }             heartbeat every PING_INTERVAL_MS.
+//                                       Silence past ~3× triggers a re-subscribe
+//                                       (guards Firefox silently GCing the
+//                                       SharedWorker under memory pressure).
 //
-// Subscriptions are tracked per (port, url): a single port can
-// subscribe to multiple URLs (only one is in use today).
-// Unsubscribing the last port for a URL closes the EventSource so we
-// don't keep idle streams open.
+// Per (port, url) subscriptions; unsubscribing the last port for a URL closes
+// the EventSource. One URL in use today; design allows multiple.
 
 const streams = new Map();
 // All currently-connected ports. Used by the heartbeat tick to fan
diff --git a/frontend/packages/shared/src/terminal.js b/frontend/packages/shared/src/terminal.js
index 6d5f353d..ddc63c60 100644
--- a/frontend/packages/shared/src/terminal.js
+++ b/frontend/packages/shared/src/terminal.js
@@ -3,54 +3,9 @@
 // owns scroll behaviour, animation suppression on backfill, and the
 // EventSource lifecycle.
 //
-// Usage:
-//
-//   import { create, linkify } from '@hive/shared/terminal.js';
-//
-//   create({
-//     logEl: document.getElementById('msgflow'),
-//     historyUrl: '/messages/history?limit=200',  // optional
-//     streamUrl:  '/messages/stream',
-//     renderers: {
-//       sent:      (ev, api) => api.row('msgrow sent', ...),
-//       delivered: (ev, api) => api.row('msgrow delivered', ...),
-//       _default:  (ev, api) => api.row('note', JSON.stringify(ev)),
-//     },
-//     onLiveEvent: (ev) => { /* live-only side effects (notif, state pokes) */ },
-//     onAnyEvent:  (ev, { fromHistory }) => { /* runs for every event in
-//       both backfill replay and live — use for derived views that need
-//       the full picture (e.g. a per-recipient inbox built from broker
-//       events) */ },
-//     onBackfillDone: (count) => { /* one-shot after history replay */ },
-//     onStreamOpen: () => { /* fires on every EventSource (re)connect —
-//       use to re-sync snapshot-derived state after a reconnect gap */ },
-//     pillAnchor: document.getElementById('msgflow').parentElement,
-//   });
-//
-// Renderers receive (ev, api) where api exposes:
-//
-//   api.row(cls, text, icon?)           → appends a flat 
; -// optional `icon` lands in a -// fixed-width `.row-glyph` cell -// api.details(cls, summary, body, icon?) → appends
-// with a ; `icon` (if -// given) shares the `.row-glyph` column -// api.detailsDiff(cls, summary, body, icon?) → ditto but body is -// line-coloured by leading "+ "/"- " -// api.placeholder(text) → replaces log content with a single -// muted "(placeholder)" row, cleared -// on the next real row -// api.fromHistory → true while backfill is replaying -// -// Each kind is dispatched to `renderers[ev.kind]`; unknown kinds fall -// through to `renderers._default` (which itself defaults to a JSON-dump -// note row). The convention is that the SSE/history endpoints emit -// objects with a `kind` field. -// -// Backfill is best-effort: if `historyUrl` is unset or the fetch fails, -// we skip straight to SSE. The optional `onBackfillDone(count)` hook -// fires after replay finishes (or after a failed/skipped fetch with -// count=0); pages use it to set state flags from the replayed history. +// create(opts) — full options list + renderer api (api.row, api.details, +// api.detailsDiff, api.placeholder, api.fromHistory) + behavioral notes: +// docs/web-ui/shape.md §Shared terminal pane. const NEAR_BOTTOM_PX = 48; // Scroll distance from the top of the log that triggers an automatic