diff --git a/frontend/packages/dashboard/src/app.js b/frontend/packages/dashboard/src/app.js index 24d22d83..96275567 100644 --- a/frontend/packages/dashboard/src/app.js +++ b/frontend/packages/dashboard/src/app.js @@ -499,14 +499,23 @@ window.marked = marked; // answering) the error handler falls it back to the dimmed // hyperhive mark (`/favicon.svg`, served by the dashboard // itself, always reachable). (issues #195, #202) - const iconImg = el('img', { class: 'container-icon-img', src: `${url}icon`, alt: '' }); + const iconImg = el('img', { class: 'container-icon-img', alt: '' }); const icon = el('div', { class: 'container-icon' }, iconImg); - iconImg.addEventListener('error', () => { - if (iconImg.dataset.fallback) return; // guard: don't loop if the favicon itself 404s - iconImg.dataset.fallback = '1'; + 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 (#432) — 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' }); @@ -527,30 +536,43 @@ window.marked = marked; head.append(navStrip); const forgeBase = `http://${hostname}:3000`; const containerBase = `http://${hostname}:${c.port}`; - fetch(`/api/agent/${encodeURIComponent(c.name)}/links`) - .then((r) => (r.ok ? r.json() : [])) - .then((links) => { - if (!Array.isArray(links)) return; - for (const lnk of 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); - } - }) - .catch(() => { /* graceful: agent down → no strip */ }); + if (c.running) { + fetch(`/api/agent/${encodeURIComponent(c.name)}/links`) + .then((r) => (r.ok ? r.json() : [])) + .then((links) => { + if (!Array.isArray(links)) return; + for (const lnk of 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); + } + }) + .catch(() => { /* graceful: agent down → no strip */ }); + } + // Status / runtime badges (#432). Pending transients always win + // (start / stop / restart / rebuild is in progress). Otherwise: + // when the container is stopped, surface a single `■ stopped` + // badge and skip everything that depends on a live harness + // (alive / rate-limited / needs-login / ctx / status text); + // those badges go stale the moment the harness goes away and + // just confuse the operator if we keep showing them. 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.rate_limited) { head.append(el('span', { class: 'badge badge-rate-limited', title: 'API rate-limited — harness is parked, will retry automatically' }, @@ -581,7 +603,7 @@ window.marked = marked; }, `⏰ ${c.pending_reminders}`)); } - if (c.ctx_tokens != null) { + if (c.running && c.ctx_tokens != null) { const k = Math.round(c.ctx_tokens / 1000); // Thresholds track the model's real context window when the // backend supplies it; otherwise fall back to fixed constants. @@ -602,7 +624,11 @@ window.marked = marked; body.append(head); // ── agent status text ───────────────────────────────────────── - if (c.status_text) { + // Self-reported status (via set_status MCP tool) — only fresh + // while the harness is up. Skip on stopped containers (#432); + // the text is from the last time the harness was running and + // just misleads now. + if (c.running && c.status_text) { const nowUnix = Math.floor(Date.now() / 1000); const ageStr = c.status_set_at != null ? ` (set ${fmtAgeSecs(nowUnix - c.status_set_at)} ago)` : '';