diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index c9ef2aa6..eb949387 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -156,6 +156,11 @@ window.marked = marked; // forms' POST → 200 → matching event flips the row without a // snapshot refetch. const containersState = new Map(); + // Keyed container row cache. Maps agent name → { el:
  • , fingerprint }. + // Allows renderContainers to skip rebuilding rows whose displayed state + // hasn't changed — prevents full-wipe flicker + avoids redundant async + // dashboard-state fetches on every SSE event. + const containerRowCache = new Map(); function syncContainersFromSnapshot(s) { containersState.clear(); for (const c of s.containers || []) containersState.set(c.name, c); @@ -563,6 +568,248 @@ window.marked = marked; return prefix; } + // Serialise the visible state of a container row into a stable string + // for change-detection. Includes everything that affects what the row + // renders — container fields, derived pending/selection state, tree + // position, and link-base context. The async dashboard-state (nav + // strip, ctx badge, status text) is intentionally excluded: it + // populates in-place and is preserved when a row is reused. + function containerRowFingerprint(c, node, pending, opRunning, selected, + askerCount, targetCount, gatewayLinks, hostname) { + return JSON.stringify({ + running: c.running, + needs_login: c.needs_login, + needs_update: c.needs_update, + pending_reminders: c.pending_reminders, + port: c.port, + pending, + opRunning, + selected, + askerCount, + targetCount, + depth: node.depth, + isLast: node.isLast, + ancestorIsLast: node.ancestorIsLast, + gatewayLinks, + hostname, + }); + } + + // Build a single container-row
  • from scratch. Extracted so + // renderContainers can call this only for rows whose fingerprint + // changed (keyed cache), skipping the build + async dashboard-state + // fetch for stable rows. + function buildContainerLi(c, node, opts) { + const { + pending, opRunning, selected, + askerCount, targetCount, agentQCount, + url, containerBase, forgeBase, s, + } = opts; + const li = el('li', { + class: 'container-row' + + (pending ? ' pending' : '') + + (opRunning ? ' pending-running' : '') + + (selected ? ' selected' : ''), + }); + // Topology: depth contributes left-padding; the glyph string in + // the .tree-prefix span draws the ├─ / └─ joint + continuation + // lines (`│ `) for ancestors whose subtree extends below this + // row. Both are CSS-driven from the data attributes so the + // legacy flat layout (every container at depth 0) is bit- + // identical to today's render — no glyph, no indent. + if (node.depth > 0) li.dataset.depth = String(node.depth); + const prefix = treePrefixDom(node); + if (prefix) li.prepend(prefix); + + // 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: '' }); + const icon = el('div', { + class: 'container-icon', + role: 'button', + tabindex: '0', + 'aria-pressed': selected ? 'true' : 'false', + title: selected + ? `deselect ${c.name} (or press Esc to clear all)` + : `select ${c.name} for bulk actions`, + }, iconImg); + icon.addEventListener('click', (e) => { + e.preventDefault(); + toggleSelection(c.name); + }); + icon.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleSelection(c.name); + } + }); + if (c.running) { + iconImg.src = `${url}icon`; + iconImg.addEventListener('error', () => { + if (iconImg.dataset.fallback) return; // guard: don't loop if the favicon itself 404s + iconImg.dataset.fallback = '1'; + icon.classList.add('icon-unreachable'); + iconImg.src = '/favicon.svg'; + }); + } else { + // Container stopped — skip the doomed `${url}icon` fetch and + // go straight to the dimmed hyperhive mark. Avoids a noisy + // failed request in the console + the brief broken-image flash. + icon.classList.add('icon-unreachable'); + iconImg.src = '/favicon.svg'; + } + // Card body: the three stacked content lines, right of the icon. + const body = el('div', { class: 'card-body' }); + + // ── identity ───────────────────────────────────────────────── + const head = el('div', { class: 'head' }); + head.append( + el('a', { class: 'name', href: url, target: '_blank', rel: 'noopener' }, c.name), + ); + // Icon-only nav strip — populated async from the agent's own + // `GET /api/dashboard-state` (via gateway when enabled, direct + // TCP otherwise). The agent is the single source of truth for its + // link list: stats / screen (GUI agents only — c0re's disk-based + // fallback cannot detect this) / forge profile / agent-configs / + // extras. DOM-built — link strings come from the agent's process + // and must never reach the HTML parser. + const navStrip = el('span', { class: 'nav-strip' }); + head.append(navStrip); + if (c.running) { + // Fetch the lean dashboard-state snapshot from the agent directly. + // Populates: nav strip links (including the screen link that + // c0re's disk-based build cannot detect), rate_limited badge, + // ctx-window badge, and self-reported status text. + // Fails gracefully when the agent is starting up or the gateway + // is not yet routing to it — badges simply don't appear. + // Only runs when the row is first built (fingerprint changed) — + // reused rows keep their previously-fetched nav strip + badges. + fetch(`${containerBase}/api/dashboard-state`) + .then((r) => (r.ok ? r.json() : null)) + .then((ds) => { + if (!ds) return; + // ── nav strip ─────────────────────────────────────────── + if (Array.isArray(ds.links)) { + for (const lnk of ds.links) { + const href = lnk.kind === 'forge' ? forgeBase + (lnk.url || '') + : lnk.kind === 'external' ? (lnk.url || '') + : /* container */ containerBase + '/' + (lnk.url || ''); + const a = el('a', { + class: 'nav-link', + href, + target: '_blank', + rel: 'noopener', + title: lnk.label || '', + }); + // Plain text — agent-controlled strings stay out of innerHTML. + a.textContent = lnk.icon || lnk.label || ''; + navStrip.append(a); + } + } + // ── agent-owned status badges ──────────────────────────── + // rate_limited: only show when no other critical badge is + // already shown (pending / not-running already handled sync). + if (ds.rate_limited) { + head.append(el('span', + { class: 'badge badge-rate-limited', title: 'API rate-limited — harness is parked, will retry automatically' }, + '⊘ rate limited')); + } + // ctx-window badge + if (ds.ctx_tokens != null) { + const k = Math.round(ds.ctx_tokens / 1000); + const win = ds.context_window_tokens; + const warn = win != null ? win * CTX_WARN_FRACTION : CTX_WARN_TOKENS; + const caution = win != null ? win * CTX_CAUTION_FRACTION : CTX_CAUTION_TOKENS; + const ctxClass = ds.ctx_tokens >= warn ? 'badge-ctx-warn' + : ds.ctx_tokens >= caution ? 'badge-ctx-caution' + : 'badge-ctx-ok'; + const title = win != null + ? `last turn context: ${ds.ctx_tokens.toLocaleString()} / ${win.toLocaleString()} ` + + `tokens (${Math.round((ds.ctx_tokens / win) * 100)}% of the window)` + : `last turn context size: ${ds.ctx_tokens.toLocaleString()} tokens`; + head.append(el('span', { class: `badge ${ctxClass}`, title }, `ctx·${k}k`)); + } + // ── agent status text (self-reported via set_status) ───── + if (ds.status_text) { + const nowUnix = Math.floor(Date.now() / 1000); + const ageStr = ds.status_set_at != null + ? ` (set ${fmtAgeSecs(nowUnix - ds.status_set_at)} ago)` : ''; + body.append(el('div', { + class: 'agent-status', + title: `agent self-reported status${ageStr}`, + }, + el('span', { class: 'status-icon' }, '◈ '), + ds.status_text, + el('span', { class: 'status-age' }, ageStr), + )); + } + }) + .catch(() => { /* graceful: agent starting / gateway miss → no data */ }); + } + // Status / runtime badges. Pending transients always win + // (start / stop / restart / rebuild is in progress). Otherwise, + // when the container is stopped, surface a single `■ not running` + // badge. `needs_login` is still c0re-owned (reads auth sentinel + // files on the host). rate_limited / ctx / status_text are + // agent-owned and rendered by the async dashboard-state fetch above. + if (pending) { + head.append(el('span', { class: 'pending-state' }, + el('span', { class: 'spinner' }, '◐'), ' ', pending + '…')); + } else if (!c.running) { + head.append(el('span', + { class: 'badge badge-muted', title: 'container is shut down — start it to bring the harness back up' }, + '■ not running')); + } else if (c.needs_login) { + head.append(el('a', + { class: 'badge badge-warn', href: url, target: '_blank', rel: 'noopener' }, + 'needs login →')); + } + if (c.needs_update) { + head.append(form( + '/rebuild/' + c.name, 'badge badge-warn btn-inline', 'needs update ↻', + 'rebuild ' + c.name + '? hot-reloads the container.', + {}, { noRefresh: true }, + )); + } + + if (c.pending_reminders && c.pending_reminders > 0) { + head.append(el('span', + { + class: 'badge badge-reminder', + title: 'pending reminders queued for this agent — see the reminders section to view / cancel', + }, + `⏰ ${c.pending_reminders}`)); + } + // Pending questions where this agent is the asker (awaiting an + // answer) or the target (owes a reply). Derived live from + // questionsState so the badge updates instantly on QuestionAdded / + // QuestionResolved without a separate backend field. + if (agentQCount > 0) { + const parts = []; + if (askerCount > 0) parts.push(`${askerCount} asked`); + if (targetCount > 0) parts.push(`${targetCount} to answer`); + head.append(el('span', + { + class: 'badge badge-loose-ends', + title: `pending questions: ${parts.join(', ')} — see the Q33R1ES tab`, + }, + `❓ ${agentQCount}`)); + } + body.append(head); + + // Per-card action buttons (R3ST4RT / ST0P / ST4RT / R3BU1LD / + // DESTR0Y / PURG3) moved to the selection bar — see + // docs/web-ui.md::Selection bar. The contextual `needs update ↻` + // chip in the head row stays — it's a state-hint, not an + // action button. + + li.append(icon, body, buildAgentMenu(c, forgeBase)); + return li; + } + function renderContainers(s) { const root = $('containers-section'); // #containers-section only exists on /index.html. tabs.js is the @@ -572,7 +819,6 @@ window.marked = marked; // no-op-when-target-absent convention the other renderers // (renderTombstones, etc.) follow. if (!root) return; - root.replaceChildren(); // Containers come from the derived map (event-driven) rather than // `s.containers`; `s` still supplies hostname (for the web-ui @@ -582,6 +828,13 @@ window.marked = marked; const portConflicts = derivePortConflicts(containers); const anyStale = containers.some((c) => c.needs_update); + // Preserve the keyed container list across the header-section + // rebuild. We wipe the banners/buttons above the list on every + // render (simple), but recycle
  • elements for unchanged rows + // (keyed) to avoid full-DOM thrash and redundant async fetches. + const existingUl = root.querySelector('ul.containers'); + root.replaceChildren(); + // Port-hash collisions: rename one of the listed agents and // rebuild. The banner sits above the agent list so it's the // first thing the operator sees when something's wedged. @@ -637,18 +890,30 @@ window.marked = marked; // `agent-sockets.json`). See // `docs/web-ui.md::Container row` + `docs/gateway.md::Vhost map`. const gatewayLinks = !!(s && s.gateway_enabled); - const ul = el('ul', { class: 'containers' }); + // Forge public URL: prefer state.forge_public_url (set by the NixOS + // module when forge.behindGateway=true), fall back to + // ":3000" for gateway-off / local-dev deploys. + const forgeBase = (s && s.forge_public_url) || `http://${hostname}:3000`; + const ul = existingUl ?? el('ul', { class: 'containers' }); const tree = buildAgentTree(containers); // In-flight rebuild / meta-update / destroy ops per agent name — // see docs/web-ui.md::Container row for the building… badge // rationale (covers the SYST3M-shows-rebuild-but-SW4RM-shows-stopped // gap when no operator transient is set). const inFlight = inFlightOpsByAgent(); + + // Build the ordered list of
  • elements, reusing cached rows + // whose displayed state hasn't changed. + const orderedLis = []; for (const node of tree) { const c = node.container; const url = gatewayLinks ? `/agent/${encodeURIComponent(c.name)}/` : `http://${hostname}:${c.port}/`; + // Container nav-strip base: gateway prefix or direct TCP. + const containerBase = gatewayLinks + ? `/agent/${encodeURIComponent(c.name)}` + : `http://${hostname}:${c.port}`; // 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. @@ -667,222 +932,57 @@ window.marked = marked; const opRunning = transientKind != null || (op != null && op.state === 'running'); const selected = selectionState.has(c.name); - const li = el('li', { - class: 'container-row' - + (pending ? ' pending' : '') - + (opRunning ? ' pending-running' : '') - + (selected ? ' selected' : ''), - }); - // Topology: depth contributes left-padding; the glyph string in - // the .tree-prefix span draws the ├─ / └─ joint + continuation - // lines (`│ `) for ancestors whose subtree extends below this - // row. Both are CSS-driven from the data attributes so the - // legacy flat layout (every container at depth 0) is bit- - // identical to today's render — no glyph, no indent. - if (node.depth > 0) li.dataset.depth = String(node.depth); - const prefix = treePrefixDom(node); - if (prefix) li.prepend(prefix); - - // 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: '' }); - const icon = el('div', { - class: 'container-icon', - role: 'button', - tabindex: '0', - 'aria-pressed': selected ? 'true' : 'false', - title: selected - ? `deselect ${c.name} (or press Esc to clear all)` - : `select ${c.name} for bulk actions`, - }, iconImg); - icon.addEventListener('click', (e) => { - e.preventDefault(); - toggleSelection(c.name); - }); - icon.addEventListener('keydown', (e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - toggleSelection(c.name); - } - }); - if (c.running) { - iconImg.src = `${url}icon`; - iconImg.addEventListener('error', () => { - if (iconImg.dataset.fallback) return; // guard: don't loop if the favicon itself 404s - iconImg.dataset.fallback = '1'; - icon.classList.add('icon-unreachable'); - iconImg.src = '/favicon.svg'; - }); - } else { - // Container stopped — skip the doomed `${url}icon` fetch and - // go straight to the dimmed hyperhive mark. Avoids a noisy - // failed request in the console + the brief broken-image flash. - icon.classList.add('icon-unreachable'); - iconImg.src = '/favicon.svg'; - } - // Card body: the three stacked content lines, right of the icon. - const body = el('div', { class: 'card-body' }); - - // ── identity ───────────────────────────────────────────────── - const head = el('div', { class: 'head' }); - head.append( - el('a', { class: 'name', href: url, target: '_blank', rel: 'noopener' }, c.name), - ); - // Icon-only nav strip — populated async from the agent's own - // `GET /api/dashboard-state` (via gateway when enabled, direct - // TCP otherwise). The agent is the single source of truth for its - // link list: stats / screen (GUI agents only — c0re's disk-based - // fallback cannot detect this) / forge profile / agent-configs / - // extras. DOM-built — link strings come from the agent's process - // and must never reach the HTML parser. - const navStrip = el('span', { class: 'nav-strip' }); - head.append(navStrip); - // Forge public URL: prefer state.forge_public_url (set by the NixOS - // module when forge.behindGateway=true, e.g. - // "https://forge.pr1ma.darkest.space"), fall back to - // ":3000" for gateway-off / local-dev deploys. - const forgeBase = (s && s.forge_public_url) || `http://${hostname}:3000`; - // Container nav-strip base: gateway prefix or direct TCP. - const containerBase = gatewayLinks - ? `/agent/${encodeURIComponent(c.name)}` - : `http://${hostname}:${c.port}`; - if (c.running) { - // Fetch the lean dashboard-state snapshot from the agent directly. - // Populates: nav strip links (including the screen link that - // c0re's disk-based build cannot detect), rate_limited badge, - // ctx-window badge, and self-reported status text. - // Fails gracefully when the agent is starting up or the gateway - // is not yet routing to it — badges simply don't appear. - fetch(`${containerBase}/api/dashboard-state`) - .then((r) => (r.ok ? r.json() : null)) - .then((ds) => { - if (!ds) return; - // ── nav strip ─────────────────────────────────────────── - if (Array.isArray(ds.links)) { - for (const lnk of ds.links) { - const href = lnk.kind === 'forge' ? forgeBase + (lnk.url || '') - : lnk.kind === 'external' ? (lnk.url || '') - : /* container */ containerBase + '/' + (lnk.url || ''); - const a = el('a', { - class: 'nav-link', - href, - target: '_blank', - rel: 'noopener', - title: lnk.label || '', - }); - // Plain text — agent-controlled strings stay out of innerHTML. - a.textContent = lnk.icon || lnk.label || ''; - navStrip.append(a); - } - } - // ── agent-owned status badges ──────────────────────────── - // rate_limited: only show when no other critical badge is - // already shown (pending / not-running already handled sync). - if (ds.rate_limited) { - head.append(el('span', - { class: 'badge badge-rate-limited', title: 'API rate-limited — harness is parked, will retry automatically' }, - '⊘ rate limited')); - } - // ctx-window badge - if (ds.ctx_tokens != null) { - const k = Math.round(ds.ctx_tokens / 1000); - const win = ds.context_window_tokens; - const warn = win != null ? win * CTX_WARN_FRACTION : CTX_WARN_TOKENS; - const caution = win != null ? win * CTX_CAUTION_FRACTION : CTX_CAUTION_TOKENS; - const ctxClass = ds.ctx_tokens >= warn ? 'badge-ctx-warn' - : ds.ctx_tokens >= caution ? 'badge-ctx-caution' - : 'badge-ctx-ok'; - const title = win != null - ? `last turn context: ${ds.ctx_tokens.toLocaleString()} / ${win.toLocaleString()} ` - + `tokens (${Math.round((ds.ctx_tokens / win) * 100)}% of the window)` - : `last turn context size: ${ds.ctx_tokens.toLocaleString()} tokens`; - head.append(el('span', { class: `badge ${ctxClass}`, title }, `ctx·${k}k`)); - } - // ── agent status text (self-reported via set_status) ───── - if (ds.status_text) { - const nowUnix = Math.floor(Date.now() / 1000); - const ageStr = ds.status_set_at != null - ? ` (set ${fmtAgeSecs(nowUnix - ds.status_set_at)} ago)` : ''; - body.append(el('div', { - class: 'agent-status', - title: `agent self-reported status${ageStr}`, - }, - el('span', { class: 'status-icon' }, '◈ '), - ds.status_text, - el('span', { class: 'status-age' }, ageStr), - )); - } - }) - .catch(() => { /* graceful: agent starting / gateway miss → no data */ }); - } - // Status / runtime badges. Pending transients always win - // (start / stop / restart / rebuild is in progress). Otherwise, - // when the container is stopped, surface a single `■ not running` - // badge. `needs_login` is still c0re-owned (reads auth sentinel - // files on the host). rate_limited / ctx / status_text are - // agent-owned and rendered by the async dashboard-state fetch above. - if (pending) { - head.append(el('span', { class: 'pending-state' }, - el('span', { class: 'spinner' }, '◐'), ' ', pending + '…')); - } else if (!c.running) { - head.append(el('span', - { class: 'badge badge-muted', title: 'container is shut down — start it to bring the harness back up' }, - '■ not running')); - } else if (c.needs_login) { - head.append(el('a', - { class: 'badge badge-warn', href: url, target: '_blank', rel: 'noopener' }, - 'needs login →')); - } - if (c.needs_update) { - head.append(form( - '/rebuild/' + c.name, 'badge badge-warn btn-inline', 'needs update ↻', - 'rebuild ' + c.name + '? hot-reloads the container.', - {}, { noRefresh: true }, - )); - } - - if (c.pending_reminders && c.pending_reminders > 0) { - head.append(el('span', - { - class: 'badge badge-reminder', - title: 'pending reminders queued for this agent — see the reminders section to view / cancel', - }, - `⏰ ${c.pending_reminders}`)); - } // Pending questions where this agent is the asker (awaiting an // answer) or the target (owes a reply). Derived live from // questionsState so the badge updates instantly on QuestionAdded / // QuestionResolved without a separate backend field. + const askerCount = questionsState.pending.filter((q) => q.asker === c.name).length; + const targetCount = questionsState.pending.filter((q) => q.target === c.name).length; const agentQCount = questionsState.pending.filter( (q) => q.asker === c.name || q.target === c.name, ).length; - if (agentQCount > 0) { - const askerCount = questionsState.pending.filter((q) => q.asker === c.name).length; - const targetCount = questionsState.pending.filter((q) => q.target === c.name).length; - const parts = []; - if (askerCount > 0) parts.push(`${askerCount} asked`); - if (targetCount > 0) parts.push(`${targetCount} to answer`); - head.append(el('span', - { - class: 'badge badge-loose-ends', - title: `pending questions: ${parts.join(', ')} — see the Q33R1ES tab`, - }, - `❓ ${agentQCount}`)); + + const fp = containerRowFingerprint(c, node, pending, opRunning, selected, + askerCount, targetCount, gatewayLinks, hostname); + const cached = containerRowCache.get(c.name); + + let li; + if (cached && cached.fingerprint === fp) { + // Row unchanged — reuse the existing DOM node. The async + // dashboard-state (nav strip, ctx badge, status text) stays + // intact from the previous build, avoiding a redundant fetch. + li = cached.el; + } else { + li = buildContainerLi(c, node, { + pending, opRunning, selected, + askerCount, targetCount, agentQCount, + url, containerBase, forgeBase, s, + }); + containerRowCache.set(c.name, { el: li, fingerprint: fp }); } - body.append(head); - - // Per-card action buttons (R3ST4RT / ST0P / ST4RT / R3BU1LD / - // DESTR0Y / PURG3) moved to the selection bar — see - // docs/web-ui.md::Selection bar. The contextual `needs update ↻` - // chip in the head row stays — it's a state-hint, not an - // action button. - - li.append(icon, body, buildAgentMenu(c, forgeBase)); - ul.append(li); + orderedLis.push(li); } + + // Remove cache entries for agents that no longer exist. + for (const [name, entry] of containerRowCache) { + if (!liveNames.has(name)) { + entry.el.remove(); + containerRowCache.delete(name); + } + } + + // Apply correct DOM order without a full wipe. insertBefore is a + // no-op when the node is already at position i, so stable sections + // of the list cause zero layout work. + for (let i = 0; i < orderedLis.length; i++) { + if (ul.children[i] !== orderedLis[i]) { + ul.insertBefore(orderedLis[i], ul.children[i] ?? null); + } + } + // Trim any excess children (defensive — shouldn't happen after the + // cache-removal pass above, but keeps the ul length exact). + while (ul.children.length > orderedLis.length) ul.lastChild.remove(); + root.append(ul); renderSelectionBar(containers); }