diff --git a/docs/web-ui.md b/docs/web-ui.md
index e695fc5a..c05e089f 100644
--- a/docs/web-ui.md
+++ b/docs/web-ui.md
@@ -193,7 +193,7 @@ cross-page link (`โ FL0W โ โ`), not a pane swap.
`services.hyperhive.matrix.gui.enable` is off (defaults to
`matrix.enable`) so operators without the matrix GUI on don't
see a dead link โ tabs.js gates the `hidden` attribute on
- `state.matrix_gui_enabled` from `/api/state` (#607).
+ `state.matrix_gui_enabled` from `/api/state`.
- **Notification controls**: `๐ enable notifications` when
permission ungranted; `๐ mute / ๐ unmute` toggle once granted.
Always visible in the chrome regardless of active tab.
@@ -404,6 +404,23 @@ falls back to the dimmed hyperhive mark (`/favicon.svg`). The card
body sits to the right with three stacked lines
(`assets/tabs.js::renderContainers`).
+**Icon layout + load strategy:** the `
` is absolutely
+positioned (`inset: 0`) inside the `.container-icon` wrapper โ
+the wrapper is the flex child and sizes itself via `width: 5em` +
+`aspect-ratio: 1`, the `
` is out of flow so its load state
+(pending, loaded, broken) can never contribute intrinsic size or
+reflow the row. Without that, the row would briefly grow as the
+image's natural dimensions arrived, then snap back on
+`object-fit: contain`. The load itself is fire-and-forget: the
+dashboard doesn't pre-check whether the agent is reachable, it
+just lets the `
` try and listens for an `error` event. On
+failure the handler swaps the `src` to `/favicon.svg` (served by
+the dashboard itself, always reachable) and adds the
+`icon-unreachable` class for the dimmed look. When the container
+is known stopped up front (`ContainerView.running = false`) the
+fallback fires immediately, skipping the doomed `/icon`
+fetch entirely.
+
- Line 1: agent name (link โ new tab), m1nd/ag3nt chip, an
**icon-only nav strip** populated async from the agent backend
(`๐ stats`, `๐ฅ screen` when GUI is enabled, `โฌก forge profile`,
@@ -440,6 +457,21 @@ body sits to the right with three stacked lines
row tint AND draws a **rotating amber ring** around the agent
icon, so it's obvious at a glance which container is actually
moving.
+ **Pending-state derivation:** the pill is sourced from two
+ separate stores in priority order. (1) The operator-initiated
+ **transient** (`transientsState`) is set on the dashboard the
+ moment the operator clicks start / stop / restart / rebuild /
+ destroy / spawn โ covers the create-and-start window where the
+ container literally isn't up yet, before any backend state event
+ has fired. (2) If no transient is set, the **rebuild-queue
+ entry** for this agent is consulted (`rebuildQueueState`); this
+ covers worker-driven ops โ meta-update cascades, crash-recover
+ rebuilds, approval-driven rebuilds โ that the operator didn't
+ click. `ContainerStateChanged` carries neither signal, so the
+ dashboard reads from the two snapshots directly. The
+ `opRunning` flag (driving the `pending-running` row class +
+ spinner) is true when (1) is set OR (2) is in `running` state;
+ queued entries leave `opRunning` false.
Container name + port, and a `ctx ยท Nk` chip showing the
agent's last-turn context size (from `ContainerView.ctx_tokens`,
read from the turn-stats sqlite on each `build_all` sweep;
diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js
index a3d36c88..cd2a5bee 100644
--- a/frontend/packages/dashboard/src/tabs.js
+++ b/frontend/packages/dashboard/src/tabs.js
@@ -481,12 +481,9 @@ window.marked = marked;
for (const node of tree) {
const c = node.container;
const url = `http://${hostname}:${c.port}/`;
- // Pending state is overlaid from the transient store first
- // (operator-initiated spawn/destroy/rebuild โ covers the
- // create+start window when the container literally isn't up
- // yet), then from the rebuild_queue (covers worker-driven ops
- // even if no transient was set). `ContainerStateChanged`
- // doesn't carry either signal.
+ // Pending-state derivation + queued-vs-running split โ see
+ // docs/web-ui.md::Container row for the transient โ in-flight
+ // queue priority order and the opRunning rationale.
const transientKind = transientsState.get(c.name)?.kind || null;
const op = !transientKind ? inFlight.get(c.name) : null;
const pending = transientKind
@@ -497,12 +494,6 @@ window.marked = marked;
: (op.kind === 'meta_update' ? 'meta-update queued'
: op.kind === 'destroy' ? 'destroy queued'
: 'rebuild queued')));
- // Split the visual into queued vs running (#769): queued ops get
- // no row highlight โ the badge text alone says "wait, queued" โ
- // while running ops keep the amber row tint + add a rotating
- // amber ring on the agent icon. Operator-initiated transients
- // always count as running (the op is already in flight even if
- // the queue worker hasn't picked it up yet).
const opRunning = transientKind != null
|| (op != null && op.state === 'running');
const selected = selectionState.has(c.name);
@@ -522,24 +513,12 @@ window.marked = marked;
const prefix = treePrefixDom(node);
if (prefix) li.prepend(prefix);
- // Full-height square agent icon, left of the card body. The
- // icon is an
absolutely positioned inside a wrapper div:
- // the div is the flex child and sizes itself via aspect-ratio +
- // stretch, the
is out of flow so its load state โ pending,
- // loaded or broken โ can never contribute intrinsic size or
- // reflow the row. (issue #177)
- //
- // The icon points straight at the agent's `/icon`. We don't
- // guess whether the agent is reachable from the container row โ
- // we just let the
try, and if it actually fails to load
- // (agent stopped, restarting, rebuilding โ web server not
- // answering) the error handler falls it back to the dimmed
- // hyperhive mark (`/favicon.svg`, served by the dashboard
- // itself, always reachable). (issues #195, #202)
+ // Agent icon: 5em square wrapper with an absolutely-positioned
+ //
+ fire-and-forget load with /favicon.svg fallback. The
+ // wrapper doubles as the selection toggle (click / keyboard).
+ // See docs/web-ui.md::Container row for the layout + load-strategy
+ // rationale.
const iconImg = el('img', { class: 'container-icon-img', alt: '' });
- // Icon is the selection toggle. Click โ add/remove from
- // `selectionState` โ re-render. role=button + tabindex makes it
- // keyboard-accessible; aria-pressed reflects the toggle state.
const icon = el('div', {
class: 'container-icon',
role: 'button',
@@ -3116,10 +3095,8 @@ window.marked = marked;
// synchronous read (e.g. the compose autocomplete pulls agent
// names from here instead of refetching on every keystroke).
window.__hyperhive_state = s;
- // #607: surface the M4TR1X โ tab strip entry only when the
- // backend's HIVE_MATRIX_GUI_DIR mount is live (otherwise
- // clicking would 404). `state.matrix_gui_enabled` flips when
- // the operator toggles `hyperhive.matrix.gui.enable` and rebuilds.
+ // Gate the M4TR1X โ tab strip entry โ see
+ // docs/web-ui.md::Tab strip for the matrix_gui_enabled rationale.
const matrixTab = $('tab-matrix');
if (matrixTab) matrixTab.hidden = !s.matrix_gui_enabled;
const openDetails = snapshotOpenDetails();
@@ -3201,13 +3178,11 @@ window.marked = marked;
rebuild_queue_changed: applyRebuildQueueChanged,
};
(function bindDashboardStream() {
- // #448: route the EventSource through a SharedWorker so all open
- // hyperhive tabs share ONE backend SSE connection. Survives
- // Firefox's per-tab connection throttling under many-open-tabs
- // pressure (the actual mara symptom). `openStream` returns an
- // EventSource-shaped facade so the rest of this IIFE is unchanged;
- // graceful fallback to direct `new EventSource` when SharedWorker
- // isn't supported.
+ // Route through the SharedWorker so all open hyperhive tabs share
+ // one upstream SSE connection โ see docs/web-ui.md (SSE multiplexing
+ // paragraph) for the design + Firefox throttling motivation.
+ // `openStream` returns an EventSource-shaped facade with a graceful
+ // direct-EventSource fallback when SharedWorker isn't supported.
const es = openStream('/dashboard/stream');
es.onmessage = (e) => {
let ev;