tabs.js + docs: migrate icon + pending-state prose to docs (#712 batch 11)

Mara on #774: previous batches were cookie-stripping rather than
prose migration. This batch actually moves substantive comment
prose from tabs.js into docs/web-ui.md.

Moved to docs/web-ui.md:
- Container row → **Icon layout + load strategy** sub-paragraph
  (new): absolute-positioning rationale (so img load state can't
  reflow row), fire-and-forget load pattern (no pre-check
  reachability), favicon fallback chain, icon-unreachable class,
  the immediate-fallback-when-stopped optimisation.
- Container row → **Pending-state derivation** paragraph (new):
  three-source priority order (transient → in-flight queue → none),
  why ContainerStateChanged isn't enough, the opRunning flag's role
  in driving the pending-running class + spinner.

Collapsed in tabs.js:
- Icon block (~14 lines of WHY comments + pointer to docs) →
  4-line pointer + behavioural one-liner. Drops #177 / #195 / #202
  cookies en passant since their substance now lives in docs.
- Pending-state block (~22 lines split across two paragraphs) →
  4-line pointer. Drops #769 self-cookie (the queued vs running
  split lives in docs::Container row now).
- SharedWorker EventSource (~7 lines) → 5-line pointer. Drops
  #448 cookie (the SSE multiplexing paragraph in docs already
  has the design + Firefox throttling rationale; the in-code
  comment was duplicating).
- M4TR1X tab gating (~4 lines) → 2-line pointer. Drops #607 cookie
  in both tabs.js + docs/web-ui.md::Tab strip (the substance was
  already in docs, just had the cookie attached).

tabs.js: 11 → 6 refs (92% reduction from baseline 73). Net effect:
~47 lines of substantive prose moved out of tabs.js into
docs/web-ui.md, where it belongs.
This commit is contained in:
iris 2026-05-31 14:23:49 +02:00
commit ea1c6e4d05
2 changed files with 48 additions and 41 deletions

View file

@ -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 `<img>` 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 `<img>` 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 `<img>` 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 `<url>/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;

View file

@ -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 <img> absolutely positioned inside a wrapper div:
// the div is the flex child and sizes itself via aspect-ratio +
// stretch, the <img> 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 `<url>/icon`. We don't
// guess whether the agent is reachable from the container row —
// we just let the <img> 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
// <img> + 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;