- {/* v0.3 PR-8: approvals are now sourced from the sidebar agent
- rollup (PR-6 SidebarAgentBlock + live /api/agent/approvals
- poll). The topbar bell stays as a launcher for the modal
- view; its badge counter is suppressed until the live hook is
- bridged here in PR-10 (it's already alive in the sidebar). */}
setBellOpen(true)}
onCmdK={() => setPaletteOpen(true)}
onMenu={() => setNavOpen(true)}
menuOpen={navOpen}
- approvals={approvalItems.length}
/>
diff --git a/ui/src/dash/memory-overhaul.css b/ui/src/dash/memory-overhaul.css
index 78f2d5b5..5f2e03fb 100644
--- a/ui/src/dash/memory-overhaul.css
+++ b/ui/src/dash/memory-overhaul.css
@@ -239,6 +239,28 @@
.ag-ptr-ic { width: 52px; height: 52px; border-radius: var(--rad-lg); border: 1px solid var(--accent-line); background: var(--accent-soft); display: inline-flex; align-items: center; justify-content: center; color: var(--accent); }
.ag-ptr-h { font-size: 15px; color: var(--fg); margin-bottom: 6px; }
.ag-ptr-body p { font-size: 12.5px; color: var(--fg-3); line-height: 1.55; margin: 0 0 14px; max-width: 560px; }
+
+@media (max-width: 900px) {
+ .mo-top {
+ grid-template-columns: 1fr;
+ }
+}
+@media (max-width: 520px) {
+ .mo-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .mo-bank-head {
+ gap: 8px;
+ align-items: flex-start;
+ }
+
+ .mo-bank-id {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+}
.ag-ptr-body b { color: var(--fg-2); }
.ag-ptr-actions { display: flex; gap: 8px; }
.ag-ptr-stat { display: flex; flex-direction: column; gap: 8px; padding-left: 20px; border-left: 1px solid var(--line); }
diff --git a/ui/src/dash/overhaul.css b/ui/src/dash/overhaul.css
index 7548ce77..ca3511e9 100644
--- a/ui/src/dash/overhaul.css
+++ b/ui/src/dash/overhaul.css
@@ -144,6 +144,12 @@
gap: 0 8px;
padding: 0 14px;
min-height: 0;
+ min-width: 0;
+}
+
+.sh > *,
+.sr > * {
+ min-width: 0;
}
/* ── Header row ────────────────────────────────────────────────────────────── */
@@ -224,6 +230,11 @@
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
+ min-width: 0;
+}
+
+.sl-detail-row {
+ display: contents;
}
/* ── Device chips ──────────────────────────────────────────────────────────── */
@@ -295,6 +306,7 @@
align-items: flex-end;
gap: 1px;
font-family: var(--jbm);
+ min-width: 0;
}
.sl-ml {
@@ -421,17 +433,12 @@
box-sizing: border-box;
}
-/* ─── Hero strip ─────────────────────────────────────────────────────────────── */
+/* ─── Dashboard toolbar ─────────────────────────────────────────────────────── */
.dash-hero {
display: flex;
align-items: center;
gap: 12px;
- padding: 4px 0 8px;
-}
-.dash-hero-greeting {
- font-size: 13px;
- color: var(--fg-2);
- font-family: var(--geist, sans-serif);
+ padding: 0 0 4px;
}
.dash-hero-spacer { flex: 1; }
.dash-hero-meta {
@@ -439,6 +446,7 @@
font-size: 11px;
color: var(--fg-4);
letter-spacing: 0.02em;
+ min-width: 0;
}
/* ─── Customize / Done button ────────────────────────────────────────────────── */
@@ -503,6 +511,75 @@
}
}
+@media (max-width: 760px) {
+ .sl-table .sh {
+ display: none;
+ }
+
+ .sr {
+ grid-template-columns: 10px minmax(0, 1fr) 26px;
+ grid-template-rows: auto auto auto;
+ gap: 3px 8px;
+ height: auto;
+ min-height: 70px;
+ padding: 8px 10px;
+ align-items: center;
+ }
+
+ .sr .sl-dot-cell {
+ grid-column: 1;
+ grid-row: 1 / 4;
+ align-self: start;
+ padding-top: 4px;
+ }
+
+ .sr .snm {
+ grid-column: 2;
+ grid-row: 1;
+ max-width: 100%;
+ }
+
+ .sr .smodel {
+ grid-column: 2;
+ grid-row: 2;
+ max-width: min(100%, 15ch);
+ line-height: 1.2;
+ }
+
+ .sr .sl-detail-row {
+ grid-column: 2;
+ grid-row: 3;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 4px 10px;
+ min-width: 0;
+ }
+
+ .sr .sl-metric {
+ flex: 0 1 auto;
+ flex-direction: row;
+ align-items: baseline;
+ gap: 3px;
+ }
+
+ .sr .sl-ml {
+ font-size: 7.5px;
+ }
+
+ .sr .sl-mv,
+ .sr .sl-mv.sl-ctx {
+ font-size: 10px;
+ }
+
+ .sr .pinbtn {
+ grid-column: 3;
+ grid-row: 1 / 4;
+ opacity: 1;
+ align-self: center;
+ }
+}
+
/* ─── Grid cell ──────────────────────────────────────────────────────────────── */
.dash-cell {
position: relative;
diff --git a/ui/src/dash/slot-list.jsx b/ui/src/dash/slot-list.jsx
index 9ffe12fb..eb62df90 100644
--- a/ui/src/dash/slot-list.jsx
+++ b/ui/src/dash/slot-list.jsx
@@ -143,33 +143,35 @@ function SlotRow({ s, pinned, onTogglePin }) {
{s.model || '—'}
- {/* col 4: device chip (auto) */}
-
-
- {/* col 5: mem (58px) */}
-
- mem
- {mem || '—'}
-
+
+ {/* col 4: device chip (auto) */}
+
+
+ {/* col 5: mem (58px) */}
+
+ mem
+ {mem || '—'}
+
- {/* col 6: tok/s (52px) */}
-
- tok/s
-
- {toks || '—'}
+ {/* col 6: tok/s (52px) */}
+
+ tok/s
+
+ {toks || '—'}
+
-
- {/* col 7: ttft (56px) */}
-
- ttft
- {ttft || '—'}
-
+ {/* col 7: ttft (56px) */}
+
+ ttft
+ {ttft || '—'}
+
- {/* col 8: ctx (74px) */}
-
- ctx
- {ctx || '—'}
+ {/* col 8: ctx (74px) */}
+
+ ctx
+ {ctx || '—'}
+
{/* col 9: pin (26px) */}
diff --git a/ui/src/dashboard.css b/ui/src/dashboard.css
index 91501df9..9df449b3 100644
--- a/ui/src/dashboard.css
+++ b/ui/src/dashboard.css
@@ -212,64 +212,21 @@ a { color: inherit; text-decoration: none; }
.tb-eyebrow .sep { color: var(--fg-5); }
.tb-eyebrow .now { color: var(--fg); font-weight: 500; }
.tb-spacer { flex: 1; }
-.tb-host {
- display: inline-flex;
- align-items: center;
- gap: 8px;
- padding: 5px 10px;
- border: 1px solid var(--line);
- border-radius: var(--rad);
- font-family: var(--jbm);
- font-size: 11px;
- color: var(--fg-2);
- cursor: pointer;
-}
-.tb-host:hover { border-color: var(--line-strong); }
-.tb-host .host-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 8px var(--ok); }
-.tb-host b { color: var(--fg); font-weight: 500; }
-.tb-host .ut { color: var(--fg-4); }
-.tb-bell {
- width: 30px; height: 30px;
- display: inline-flex; align-items: center; justify-content: center;
- border: 1px solid var(--line);
- border-radius: var(--rad);
- background: transparent;
- cursor: pointer;
- color: var(--fg-2);
- position: relative;
-}
-.tb-bell:hover { color: var(--fg); border-color: var(--line-strong); }
-.tb-bell .badge {
- position: absolute;
- top: -4px;
- right: -4px;
- background: var(--accent);
- color: #0a0a0a;
- font-family: var(--jbm);
- font-size: 9px;
- font-weight: 600;
- min-width: 16px;
- height: 16px;
- border-radius: 999px;
- padding: 0 4px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- border: 2px solid var(--bg);
-}
.tb-cmdk {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 5px 9px;
- border: 1px solid var(--line);
+ border: 1px solid var(--accent-line);
border-radius: var(--rad);
font-family: var(--jbm);
font-size: 11px;
- color: var(--fg-3);
+ color: var(--fg-2);
+ background: color-mix(in srgb, var(--bg-2) 78%, var(--accent-soft));
cursor: pointer;
}
-.tb-cmdk:hover { color: var(--fg); border-color: var(--line-strong); }
+.tb-cmdk:hover { color: var(--fg); border-color: var(--accent-line); background: var(--accent-soft); }
+.tb-cmdk svg { color: var(--accent); }
.tb-cmdk kbd {
font-family: var(--jbm);
font-size: 10px;
@@ -394,33 +351,6 @@ a { color: inherit; text-decoration: none; }
layout but tightens vertical margin so the sidebar can host both
blocks without scrolling on a 720-tall viewport. */
-/* Runtime widget (2026-06-05) — consolidated hermes/hal0/runtime/openwebui
- rollup. Inherits the base .sb-status card chrome; these rules add the
- section header, deep-link row keys, and the indented hal0 model sub-row. */
-.sb-runtime .sb-runtime-h {
- color: var(--fg-4);
- font-size: 9.5px;
- letter-spacing: 0.08em;
- text-transform: uppercase;
- margin-bottom: 6px;
-}
-.sb-runtime .row { padding: 2.5px 0; }
-.sb-runtime .row.rt-sub { padding-top: 0; }
-.sb-runtime .row.rt-sub .k { padding-left: 10px; opacity: 0.8; }
-.sb-runtime .rt-extra { color: var(--fg-4); font-size: 0.85em; margin-left: 4px; }
-.sb-runtime a.rt-link {
- color: var(--fg-4);
- text-decoration: none;
- cursor: pointer;
- transition: color 0.12s ease;
-}
-.sb-runtime a.rt-link:hover { color: var(--accent); }
-.sb-runtime a.rt-link:focus-visible {
- outline: 1px solid var(--accent);
- outline-offset: 2px;
- border-radius: 2px;
-}
-
/* ─── Footer ────────────────────────────────────────────────────── */
.footer {
grid-area: footer;
@@ -856,12 +786,14 @@ a { color: inherit; text-decoration: none; }
/* Slots-page tabs — underline style, matching the Agent page's tab bar
(Inference · Image Gen · Endpoints · Profiles). Borderless buttons with an
accent bottom-border when active, over a single hairline rule. */
-.slot-tabs { display: flex; gap: 0; border-bottom: 1px solid var(--line); margin-bottom: 18px; }
+.slot-tabs { display: flex; gap: 0; border-bottom: 1px solid var(--line); margin-bottom: 18px; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; }
+.slot-tabs::-webkit-scrollbar { display: none; }
.slot-tab {
display: inline-flex; align-items: center; gap: 8px; padding: 10px 16px;
background: transparent; border: none; border-bottom: 2px solid transparent;
font-family: var(--jbm); font-size: 12.5px; font-weight: 500; color: var(--fg-3);
cursor: pointer; transition: color 0.12s ease, border-color 0.12s ease;
+ flex: 0 0 auto;
}
.slot-tab:hover { color: var(--fg); }
.slot-tab.on,
@@ -874,6 +806,52 @@ a { color: inherit; text-decoration: none; }
.slot-tab .slot-tab-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--fg-5); }
.slot-tab .slot-tab-dot.live { background: var(--comfy); box-shadow: 0 0 8px var(--comfy); }
+/* Logs page rows */
+.log-row {
+ padding: 2px 16px;
+ display: grid;
+ grid-template-columns: 100px 78px 60px 80px minmax(0, 1fr);
+ gap: 12px;
+ border-left: 2px solid transparent;
+}
+.log-row.log-warn { border-left-color: var(--warn); }
+.log-row.log-error { border-left-color: var(--err); }
+.log-ts { color: var(--fg-5); }
+.log-source { color: var(--accent); }
+.log-level { color: var(--fg-3); }
+.log-ok .log-level { color: var(--ok); }
+.log-warn .log-level { color: var(--warn); }
+.log-error .log-level { color: var(--err); }
+.log-slot { color: var(--fg-2); }
+.log-slot.empty { color: var(--fg-5); }
+.log-msg {
+ color: var(--fg-2);
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+.log-group-row { cursor: pointer; }
+.log-group-row.open { background: rgba(232,185,78,0.05); }
+.log-group-child {
+ padding-left: 32px;
+ color: var(--fg-3);
+ border-left-color: rgba(232,185,78,0.4);
+ background: rgba(232,185,78,0.03);
+}
+.log-group-msg {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.log-group-msg b {
+ color: var(--fg);
+ font-weight: 500;
+}
+.log-group-meta {
+ color: var(--fg-4);
+ font-size: 10px;
+ margin-left: 4px;
+}
+
/* Main 50/50 row — Memory map | Throughput (home-redesign 2026-06-05).
Both halves are .side-card so they read as a matched pair. */
.dash-5050 {
@@ -1873,13 +1851,28 @@ select.npu-sel {
.models-layout { grid-template-columns: 1fr; }
.mdl-filters { position: static; }
}
+@media (max-width: 900px) {
+ .vh {
+ flex-wrap: wrap;
+ align-items: flex-start;
+ gap: 10px;
+ }
+ .vh .vh-spacer {
+ display: none;
+ }
+ .vh h1 {
+ margin-right: auto;
+ }
+ .vh .btn {
+ flex: 0 1 auto;
+ }
+}
@media (max-width: 720px) {
:root { --sidebar-w: 0px; }
.sidebar { display: none; }
.view { padding: 16px 16px 80px; }
.view-banners { padding: 14px 16px 0; }
.tb-brand { width: auto; }
- .tb-host .ut { display: none; }
.tb-cmdk { display: none; } /* command palette folded into the nav drawer on mobile */
.topbar .tb-menu { display: flex; }
.tb-eyebrow { display: none; }
@@ -1904,6 +1897,33 @@ select.npu-sel {
.topbar { padding: 0 12px; gap: 10px; }
.footer { display: none; }
.app { grid-template-columns: 1fr; grid-template-rows: var(--topbar-h) 1fr; grid-template-areas: "topbar" "main"; }
+ .log-row {
+ grid-template-columns: 74px 52px 44px minmax(0, 1fr);
+ grid-template-rows: auto auto;
+ gap: 1px 8px;
+ padding: 5px 10px;
+ }
+ .log-slot {
+ grid-column: 4;
+ grid-row: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ .log-msg {
+ grid-column: 1 / -1;
+ grid-row: 2;
+ }
+ .log-group-child {
+ padding-left: 20px;
+ }
+ .log-group-msg {
+ display: block;
+ }
+ .log-group-meta {
+ display: block;
+ margin: 2px 0 0;
+ }
}
/* Container image-pull indeterminate progress bar (issue #659).
@@ -2070,10 +2090,6 @@ select.npu-sel {
border-radius: 4px;
padding: 1px 5px;
}
-/* The 1080px breakpoint hides .sb-status globally (desktop sidebar rail
- collapse); re-show it inside the drawer where the rail is full-width. */
-.nav-drawer .sb-status { display: block; }
-
/* ─── Toast ────────────────────────────────────────────────────── */
.hal0-toast {
position: fixed;
diff --git a/ui/src/main.tsx b/ui/src/main.tsx
index 11b6a362..2c540ccc 100644
--- a/ui/src/main.tsx
+++ b/ui/src/main.tsx
@@ -37,9 +37,6 @@ import './dash/overhaul.css'
import './dash/data.jsx'
import './dash/tweaks-panel.jsx'
-// 2026-06-05: the standalone SidebarAgentBlock (+ its window hook bridge) is
-// retired — its agent health folded into the consolidated Runtime widget in
-// chrome.jsx, which imports useSidebarAgentRollup directly via ES modules.
import './dash/chrome.jsx'
import './dash/primitives.jsx'
import './dash/cards-shell.jsx'
diff --git a/ui/tests/e2e/specs/dashboard-v3.spec.ts b/ui/tests/e2e/specs/dashboard-v3.spec.ts
index bfb8cbee..a83ae8df 100644
--- a/ui/tests/e2e/specs/dashboard-v3.spec.ts
+++ b/ui/tests/e2e/specs/dashboard-v3.spec.ts
@@ -28,10 +28,12 @@ test.describe('Dashboard v3 (/)', () => {
await expect(page.locator('.sb-row.active .lbl')).toHaveText('Overview')
})
- test('topbar exposes brand + command-palette button + bell', async ({ page }) => {
+ test('topbar exposes brand + quick actions without stale host/bell chrome', async ({ page }) => {
await page.goto('/')
await expect(page.locator('.tb-brand')).toBeVisible()
await expect(page.locator('.tb-cmdk')).toBeVisible()
- await expect(page.locator('.tb-bell')).toBeVisible()
+ await expect(page.locator('.tb-cmdk')).toContainText('Quick actions')
+ await expect(page.locator('.tb-host')).toHaveCount(0)
+ await expect(page.locator('.tb-bell')).toHaveCount(0)
})
})
diff --git a/ui/tests/e2e/specs/memory-gate-v3.spec.ts b/ui/tests/e2e/specs/memory-gate-v3.spec.ts
index 8f25e9a4..a43d007e 100644
--- a/ui/tests/e2e/specs/memory-gate-v3.spec.ts
+++ b/ui/tests/e2e/specs/memory-gate-v3.spec.ts
@@ -15,12 +15,10 @@
* (nav-memory absent); the MCP sub-link stays
* - the Agent page renders an MCP-only tab bar (the Memory tab is hidden),
* and a deep link to #memory falls back to the MCP tab
- * - the sidebar Runtime widget carries no dead-end "Memory →" link
- * (the widget consolidated the old SidebarAgentBlock; its agent row now
- * deep-links to the Hermes dashboard, never the gated Memory route)
+ * - the removed sidebar Runtime widget does not reappear under the gate
*
* The ON state is covered by the default mock (memory_enabled: true) across
- * agent-view-v3 / memory-graph-v3 / sidebar-runtime-widget.
+ * agent-view-v3 / memory-graph-v3.
*/
import { test, expect, json } from '../fixtures/apiMock'
@@ -62,12 +60,10 @@ test.describe('memory gate OFF (HAL0_MEMORY_ENABLED unset)', () => {
await expect(page.locator('[data-testid="mem-engine-card"]')).toHaveCount(0)
})
- test('Runtime widget renders with no dead-end Memory link', async ({ page }) => {
+ test('removed Runtime widget does not reappear', async ({ page }) => {
await page.goto('/#dashboard')
await expect(page.locator('.sb-list')).toBeVisible({ timeout: FIVE_S })
- // The consolidated Runtime widget still renders under the memory gate...
- await expect(page.locator('[data-testid="sidebar-runtime-widget"]')).toBeVisible()
- // ...and never offers the old dead-end "Memory →" CTA into the gated route.
+ await expect(page.locator('[data-testid="sidebar-runtime-widget"]')).toHaveCount(0)
await expect(page.locator('[data-testid="sidebar-agent-open-memory"]')).toHaveCount(0)
})
})
diff --git a/ui/tests/e2e/specs/sidebar-runtime-widget.spec.ts b/ui/tests/e2e/specs/sidebar-runtime-widget.spec.ts
deleted file mode 100644
index 93e51de8..00000000
--- a/ui/tests/e2e/specs/sidebar-runtime-widget.spec.ts
+++ /dev/null
@@ -1,212 +0,0 @@
-/**
- * sidebar-runtime-widget — consolidated runtime rollup (2026-06-05).
- *
- * The three former stacked sidebar blocks (SidebarAgentBlock /
- * SidebarEndpointBlock / SidebarStatusBlock) are merged into ONE card so
- * hermes, hal0, runtime and openwebui read as a single runtime rollup:
- *
- * - hermes — bundled agent health (/api/agents). Row key deep-links to
- * the Hermes dashboard ONLY when /api/config/urls advertises
- * one (hermes_enabled); otherwise it's plain text (the dash
- * binds loopback-only, so there's no host:port fallback).
- * - hal0 — the composite /v1 endpoint (synthetic /api/slots entry,
- * served from HAL0_DATA in forced-mock) + model count.
- * - openwebui — external chat UI link derived from /api/config/urls.
- *
- * Health indicators live in the footer runtime chip: slot readiness from
- * useRuntimeRollup plus service dots from /api/services/health.
- *
- * Mock seams: /api/agents and /api/config/urls are NOT in the forced-mock
- * allowlist, so page.route drives them. /api/slots IS allowlisted, so the
- * hal0 + runtime rows render from HAL0_DATA (data.jsx).
- */
-import { test, expect, json } from '../fixtures/apiMock'
-
-const FIVE_S = 5_500
-
-const HERMES_URL = 'https://hermes.example.com'
-const OPENWEBUI_URL = 'http://hal0.local:3001'
-
-const AGENTS_RUNNING = {
- agents: [{ name: 'hermes', installed_at: '2026-05-25T12:00:00Z', status: 'installed' }],
- count: 1,
-}
-const AGENTS_EMPTY = { agents: [], count: 0 }
-
-// Both services advertised + reachable (LAN-direct / public-URL deploy).
-const URLS_ALL = {
- api: 'http://hal0.local:8080',
- openwebui: OPENWEBUI_URL,
- openwebui_enabled: true,
- hermes: HERMES_URL,
- hermes_enabled: true,
-}
-// Neither service reachably linkable (stock install: OWUI down, no hermes URL).
-const URLS_NONE = {
- api: 'http://hal0.local:8080',
- openwebui: '',
- openwebui_enabled: false,
- hermes: '',
- hermes_enabled: false,
-}
-const SERVICES_HEALTH_UP = {
- services: [
- { id: 'comfyui', name: 'ComfyUI', up: false, detail: 'unreachable', url: null, stat: null },
- { id: 'hermes', name: 'Hermes', up: true, detail: 'systemd unit active', url: null, stat: null },
- {
- id: 'openwebui',
- name: 'OpenWebUI',
- up: true,
- detail: 'reachable — /health ok',
- url: null,
- stat: null,
- },
- { id: 'n8n', name: 'n8n', up: false, detail: 'unmonitored', url: null, stat: null },
- ],
-}
-const SERVICES_HEALTH_DOWN = {
- services: [
- { id: 'comfyui', name: 'ComfyUI', up: false, detail: 'unreachable', url: null, stat: null },
- {
- id: 'hermes',
- name: 'Hermes',
- up: false,
- detail: 'systemd unit inactive or absent',
- url: null,
- stat: null,
- },
- {
- id: 'openwebui',
- name: 'OpenWebUI',
- up: false,
- detail: 'unreachable (ConnectError)',
- url: null,
- stat: null,
- },
- { id: 'n8n', name: 'n8n', up: false, detail: 'unmonitored', url: null, stat: null },
- ],
-}
-
-test.describe('Sidebar Runtime widget — populated', () => {
- test.beforeEach(async ({ page }) => {
- await page.route('**/api/agents', (route) => json(route, AGENTS_RUNNING))
- await page.route('**/api/config/urls', (route) => json(route, URLS_ALL))
- await page.route('**/api/services/health', (route) => json(route, SERVICES_HEALTH_UP))
- })
-
- test('renders one widget with hermes / hal0 / runtime / openwebui rows', async ({ page }) => {
- await page.goto('/')
- const widget = page.locator('[data-testid="sidebar-runtime-widget"]')
- await expect(widget).toBeVisible({ timeout: FIVE_S })
- await expect(widget.locator('.sb-runtime-h')).toHaveText('Runtime')
- await expect(page.locator('[data-testid="runtime-row-hermes"]')).toBeVisible()
- await expect(page.locator('[data-testid="runtime-row-hal0"]')).toBeVisible()
- await expect(page.locator('[data-testid="runtime-row-openwebui"]')).toBeVisible()
- // The old standalone block is gone.
- await expect(page.locator('[data-testid="sidebar-agent-block"]')).toHaveCount(0)
- })
-
- test('hermes row deep-links to the backend-advertised dashboard', async ({
- page,
- }) => {
- await page.goto('/')
- const row = page.locator('[data-testid="runtime-row-hermes"]')
- await expect(row).toBeVisible({ timeout: FIVE_S })
- const link = row.locator('a.rt-link')
- await expect(link).toContainText('hermes')
- await expect(link).toHaveAttribute('href', HERMES_URL)
- await expect(link).toHaveAttribute('target', '_blank')
- await expect(row.locator('.v')).toContainText('agent')
- await expect(row.locator('.v .dot')).toHaveCount(0)
- })
-
- test('openwebui row deep-links to the backend-advertised URL', async ({
- page,
- }) => {
- await page.goto('/')
- const row = page.locator('[data-testid="runtime-row-openwebui"]')
- await expect(row).toBeVisible({ timeout: FIVE_S })
- const link = row.locator('a.rt-link')
- await expect(link).toContainText('openwebui')
- await expect(link).toHaveAttribute('href', OPENWEBUI_URL)
- await expect(link).toHaveAttribute('target', '_blank')
- await expect(row.locator('.v')).toContainText('chat')
- await expect(row.locator('.v .dot')).toHaveCount(0)
- })
-
- test('hal0 row shows the advertised model count', async ({ page }) => {
- await page.goto('/')
- const row = page.locator('[data-testid="runtime-row-hal0"]')
- await expect(row).toBeVisible({ timeout: FIVE_S })
- // model count reflects HAL0_DATA's synthetic endpoint (2 chat).
- await expect(row.locator('.v b')).toHaveText('2')
- })
-
- test('footer ribbon shows runtimes + services health groups', async ({ page }) => {
- await page.goto('/')
- // HAL0_DATA seeds 8 enabled slots (legacy is disabled); all are ready.
- const runtimes = page.locator('[data-testid="foot-health-runtimes"]')
- await expect(runtimes.locator('.lbl .v')).toContainText('8 / 8 ready', { timeout: FIVE_S })
- await expect(runtimes.locator('.pip.ok')).toHaveCount(8)
- // services group — one LED pip per backing service, all up here.
- const services = page.locator('[data-testid="foot-health-services"]')
- await expect(services.locator('.lbl .v')).toContainText('3 / 3 ready')
- await expect(services.locator('.pip.ok')).toHaveCount(3)
- })
-})
-
-test.describe('Sidebar Runtime widget — no advertised service links', () => {
- test.beforeEach(async ({ page }) => {
- await page.route('**/api/agents', (route) => json(route, AGENTS_RUNNING))
- await page.route('**/api/config/urls', (route) => json(route, URLS_NONE))
- await page.route('**/api/services/health', (route) => json(route, SERVICES_HEALTH_UP))
- })
-
- test('hermes row stays plain text when no dashboard URL is advertised', async ({ page }) => {
- await page.goto('/')
- const row = page.locator('[data-testid="runtime-row-hermes"]')
- await expect(row).toBeVisible({ timeout: FIVE_S })
- // No anchor — just the bare key — while health still renders.
- await expect(row.locator('a.rt-link')).toHaveCount(0)
- await expect(row.locator('.k')).toHaveText('hermes')
- await expect(row.locator('.v')).toContainText('agent')
- })
-
- test('openwebui health can be up even when no link is advertised', async ({ page }) => {
- await page.goto('/')
- const row = page.locator('[data-testid="runtime-row-openwebui"]')
- await expect(row.locator('.v')).toContainText('chat', { timeout: FIVE_S })
- await expect(row.locator('a.rt-link')).toHaveCount(0)
- await expect(row.locator('.k')).toHaveText('openwebui')
- // footer services pip still reads the openwebui health as up.
- const services = page.locator('[data-testid="foot-health-services"]')
- await expect(services.locator('.pip[aria-label="openwebui: up"]')).toHaveCount(1)
- })
-})
-
-test.describe('Sidebar Runtime widget — hermes tone mapping', () => {
- test('service health renders a down (red) hermes dot', async ({ page }) => {
- await page.route('**/api/agents', (route) =>
- json(route, {
- agents: [{ name: 'hermes', installed_at: '2026-05-25T12:00:00Z', status: 'broken' }],
- count: 1,
- }),
- )
- await page.route('**/api/config/urls', (route) => json(route, URLS_ALL))
- await page.route('**/api/services/health', (route) => json(route, SERVICES_HEALTH_DOWN))
- await page.goto('/')
- const pip = page.locator('[data-testid="foot-health-services"] .pip[aria-label^="hermes:"]')
- await expect(pip).toHaveClass(/\berr\b/, { timeout: FIVE_S })
- })
-
- test('no agent installed renders sidebar copy without a health dot', async ({ page }) => {
- await page.route('**/api/agents', (route) => json(route, AGENTS_EMPTY))
- await page.route('**/api/config/urls', (route) => json(route, URLS_ALL))
- await page.route('**/api/services/health', (route) => json(route, SERVICES_HEALTH_UP))
- await page.goto('/')
- const v = page.locator('[data-testid="runtime-row-hermes"] .v')
- await expect(v).toContainText('not installed', { timeout: FIVE_S })
- // The widget itself still renders (hermes never hides the whole card).
- await expect(page.locator('[data-testid="sidebar-runtime-widget"]')).toBeVisible()
- })
-})
diff --git a/ui/tests/e2e/specs/system-card-v3.spec.ts b/ui/tests/e2e/specs/system-card-v3.spec.ts
index cb54f894..dbaa8945 100644
--- a/ui/tests/e2e/specs/system-card-v3.spec.ts
+++ b/ui/tests/e2e/specs/system-card-v3.spec.ts
@@ -82,7 +82,7 @@ test.describe.skip('System card (dashboard sidebar)', () => {
test('runtime health row is no longer present in the System card', async ({ page }) => {
await page.goto('/#dashboard')
const card = page.locator('.sys-card')
- // Removed 2026-06-05 — folded into the sidebar Runtime widget instead.
+ // Runtime health lives in the footer, not the System card.
await expect(card.locator('.sys-row .k', { hasText: 'runtime' })).toHaveCount(0)
await expect(card.locator('.sys-health')).toHaveCount(0)
})
diff --git a/ui/tests/e2e/specs/ui-sweep-a-v3.spec.ts b/ui/tests/e2e/specs/ui-sweep-a-v3.spec.ts
index c059dc35..bfb83045 100644
--- a/ui/tests/e2e/specs/ui-sweep-a-v3.spec.ts
+++ b/ui/tests/e2e/specs/ui-sweep-a-v3.spec.ts
@@ -26,6 +26,12 @@ import { test, expect, json } from '../fixtures/apiMock'
const FIVE_S = 5_500
+async function openApprovals(page: any) {
+ await page.evaluate(() => {
+ window.dispatchEvent(new CustomEvent('hal0:open-approvals'))
+ })
+}
+
// ─── 1. AddSecretModal: real save ──────────────────────────────────────────
test.describe('AddSecretModal — real save', () => {
@@ -146,10 +152,7 @@ test.describe('ApprovalModal live wiring', () => {
)
await page.goto('/#dashboard')
- // Open the bell modal
- const bell = page.locator('[aria-label="Agent approvals"]')
- await bell.waitFor({ state: 'visible', timeout: FIVE_S })
- await bell.click()
+ await openApprovals(page)
await expect(page.locator('.approval-card')).toHaveCount(1, { timeout: FIVE_S })
await expect(page.locator('.approval-card')).toContainText('fs_write')
@@ -161,9 +164,7 @@ test.describe('ApprovalModal live wiring', () => {
json(route, { approvals: [] })
)
await page.goto('/#dashboard')
- const bell = page.locator('[aria-label="Agent approvals"]')
- await bell.waitFor({ state: 'visible', timeout: FIVE_S })
- await bell.click()
+ await openApprovals(page)
await expect(page.locator('[data-testid="approvals-empty"]')).toBeVisible({ timeout: FIVE_S })
})
@@ -178,9 +179,7 @@ test.describe('ApprovalModal live wiring', () => {
})
await page.goto('/#dashboard')
- const bell = page.locator('[aria-label="Agent approvals"]')
- await bell.waitFor({ state: 'visible', timeout: FIVE_S })
- await bell.click()
+ await openApprovals(page)
await expect(page.locator('.approval-card')).toBeVisible({ timeout: FIVE_S })
await page.locator('.approval-card button:has-text("Approve")').click()
@@ -199,9 +198,7 @@ test.describe('ApprovalModal live wiring', () => {
})
await page.goto('/#dashboard')
- const bell = page.locator('[aria-label="Agent approvals"]')
- await bell.waitFor({ state: 'visible', timeout: FIVE_S })
- await bell.click()
+ await openApprovals(page)
await expect(page.locator('.approval-card')).toBeVisible({ timeout: FIVE_S })
await page.locator('.approval-card .btn.danger').first().click()
@@ -215,19 +212,15 @@ test.describe('ApprovalModal live wiring', () => {
// pointer card (design §7). Those tests were deleted; the memory
// surface is covered by memory-view/-tools/-graph specs instead.
-// ─── 7. Dashboard: no hardcoded "halo" username ─────────────────────────
+// ─── 7. Dashboard: compact operational toolbar ───────────────────────────
test.describe('Dashboard hero strip', () => {
- // dashboard-overhaul (feat/dashboard-overhaul): the handoff hero copy is
- // explicitly "Welcome back, halo. system steady on " — the design
- // mandates the greeting the old anti-hardcoded-username guard removed. The
- // design handoff is the law for this surface, so the assertion is inverted:
- // the greeting + the live host phrasing must both be present.
- test('renders the handoff hero greeting + live host phrasing', async ({ page }) => {
+ test('renders status metadata without the old greeting copy', async ({ page }) => {
await page.goto('/#dashboard')
await expect(page.locator('.hero-strip')).toBeVisible({ timeout: FIVE_S })
- await expect(page.locator('.hero-strip')).toContainText('Welcome back, halo')
- await expect(page.locator('.hero-strip')).toContainText('system steady on')
+ await expect(page.locator('.hero-strip')).not.toContainText('Welcome back, halo')
+ await expect(page.locator('.hero-strip')).not.toContainText('system steady on')
+ await expect(page.getByRole('button', { name: /customize/i })).toBeVisible()
})
})