From ea1c6e4d057fe2e65ed37ff79c0c09fe023bc79a Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 31 May 2026 14:23:49 +0200 Subject: [PATCH] tabs.js + docs: migrate icon + pending-state prose to docs (#712 batch 11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/web-ui.md | 34 ++++++++++++++- frontend/packages/dashboard/src/tabs.js | 55 +++++++------------------ 2 files changed, 48 insertions(+), 41 deletions(-) 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;