diff --git a/frontend/packages/dashboard/build.mjs b/frontend/packages/dashboard/build.mjs index 07097fa..9b7d2a7 100644 --- a/frontend/packages/dashboard/build.mjs +++ b/frontend/packages/dashboard/build.mjs @@ -48,6 +48,8 @@ await build({ logLevel: 'info', }); -copyFileSync(src('index.html'), dist('index.html')); +for (const html of ['index.html', 'flow.html']) { + copyFileSync(src(html), dist(html)); +} console.log('dashboard build ok →', dist('')); diff --git a/frontend/packages/dashboard/src/app.js b/frontend/packages/dashboard/src/app.js index 71453e3..7560e4d 100644 --- a/frontend/packages/dashboard/src/app.js +++ b/frontend/packages/dashboard/src/app.js @@ -74,13 +74,32 @@ window.marked = marked; const root = $('side-panel'); const titleEl = $('side-panel-title'); const bodyEl = $('side-panel-body'); + /** Owner key set by `openNamed` (e.g. 'inbox'). `refresh(name, …)` + * is a no-op when the current owner doesn't match, so live + * updates can re-render an open view without grabbing focus + * from a closed one (or from an unrelated open view like a + * diff drill-in). Untyped calls via `open(title, content)` + * clear the owner — the legacy file-preview/diff/log paths + * don't participate in named-refresh semantics. */ + let owner = null; function open(title, content) { + owner = null; titleEl.textContent = title; bodyEl.replaceChildren(...(content ? [content] : [])); root.classList.add('open'); root.setAttribute('aria-hidden', 'false'); } + function openNamed(name, title, content) { + open(title, content); + owner = name; + } + function refresh(name, title, content) { + if (owner !== name) return; + titleEl.textContent = title; + bodyEl.replaceChildren(...(content ? [content] : [])); + } function close() { + owner = null; root.classList.remove('open'); root.setAttribute('aria-hidden', 'true'); } @@ -91,7 +110,7 @@ window.marked = marked; if (e.key === 'Escape' && root.classList.contains('open')) close(); }); } - return { open, close, bind }; + return { open, openNamed, refresh, close, bind }; })(); // ─── path linkification ───────────────────────────────────────────────── @@ -1326,14 +1345,8 @@ window.marked = marked; if (operatorInbox.length > INBOX_LIMIT) operatorInbox.length = INBOX_LIMIT; return true; } - function renderInbox() { - const root = $('inbox-section'); - if (!root) return; - root.innerHTML = ''; - if (!operatorInbox.length) { - root.append(el('p', { class: 'empty' }, 'no messages')); - return; - } + function buildInboxListNode() { + if (!operatorInbox.length) return el('p', { class: 'empty' }, 'no messages'); const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19); const ul = el('ul', { class: 'inbox' }); for (const m of operatorInbox) { @@ -1348,7 +1361,28 @@ window.marked = marked; ); ul.append(li); } - root.append(ul); + return ul; + } + function renderInbox() { + // Inline section on the dashboard (#inbox-section). Hidden / + // headless on the flow page; the flow page surfaces inbox via + // the pill + side-panel flyout instead. + const root = $('inbox-section'); + if (root && !root.hidden) { + root.innerHTML = ''; + root.append(buildInboxListNode()); + } + // Flow-page pill: visible when there's at least one message, + // count tracks operatorInbox length, click opens the side + // panel. The element only exists on flow.html; on the + // dashboard this no-ops. + const pill = $('inbox-pill'); + const pillCount = $('inbox-pill-count'); + if (pillCount) pillCount.textContent = String(operatorInbox.length); + if (pill) pill.hidden = operatorInbox.length === 0; + // If the side panel is currently showing the inbox view, refresh + // its body in place so live messages land without a re-open. + Panel.refresh('inbox', 'inbox · ' + operatorInbox.length, buildInboxListNode()); } const APPROVAL_TAB_KEY = 'hyperhive:approvals:tab'; @@ -2086,6 +2120,87 @@ window.marked = marked; NOTIF.bind(); Panel.bind(); + // ─── tab routing (#369) ──────────────────────────────────────────────── + // Hash-based: `#swarm` / `#call` / `#system` activate the matching + // pane on the dashboard. Empty hash defaults to SW4RM. FL0W is NOT + // a tab — it's a separate page (`/flow.html`) reached via the + // tab-strip link. Tab routing only applies when the tab DOM is + // present (e.g. not on the flow page itself, where these elements + // don't exist and the loop no-ops). + const TABS = ['swarm', 'call', 'system']; + function activateTab(name) { + const target = TABS.includes(name) ? name : TABS[0]; + for (const t of TABS) { + const tab = $('tab-' + t); + const pane = $('tab-pane-' + t); + if (tab) tab.classList.toggle('active', t === target); + if (pane) pane.classList.toggle('tab-pane-active', t === target); + } + } + function syncTabFromHash() { + const h = (window.location.hash || '#swarm').replace(/^#/, ''); + activateTab(h); + } + window.addEventListener('hashchange', syncTabFromHash); + syncTabFromHash(); + + // Tab count pills — pure derived data from the existing state + // stores so SSE-driven updates flow through without extra plumbing. + // Set `hidden` when the count is zero so the pill doesn't draw + // attention to an empty room. + function setTabCount(tab, n) { + const el_ = $('tab-count-' + tab); + if (!el_) return; + el_.textContent = String(n); + el_.hidden = n <= 0; + } + /** Recompute every tab's count from the current state. Called on + * every renderXxx that's tab-relevant. */ + function refreshTabCounts() { + // SW4RM — flag any container that's stale (needs_update). Empty + // when everyone's current. Container-row pulse signals state + // transitions; the pill catches "deploy-pending" specifically. + let swarm = 0; + for (const c of containersState.values()) { + if (c.needs_update) swarm++; + } + setTabCount('swarm', swarm); + // Y3R C4LL — pending approvals + operator-targeted questions. + const callCount = + (approvalsState?.pending?.length ?? 0) + + (questionsState?.pending?.length ?? 0); + setTabCount('call', callCount); + // SYST3M — queued + running rebuild_queue entries (terminal + // entries are kept for history but aren't 'attention'). + let sysCount = 0; + if (rebuildQueueState) { + for (const e of rebuildQueueState) { + if (e.state === 'Queued' || e.state === 'Running') sysCount++; + } + } + setTabCount('system', sysCount); + // FL0W — operator inbox count. + setTabCount('flow', operatorInbox.length); + } + // Poll the state stores on a 1s tick to keep the pill counts in + // sync. The state stores are mutated synchronously by every SSE + // event + refreshState call, so polling them is correct and cheap + // — no per-renderer hookup needed. + refreshTabCounts(); + setInterval(refreshTabCounts, 1000); + + // Flow page: wire the inbox pill to open the side-panel flyout + // with the operator inbox. Only triggers when the pill exists + // (i.e. we're on flow.html); on the dashboard this no-ops. + (function bindFlowInboxPill() { + const pill = $('inbox-pill'); + if (!pill) return; + pill.addEventListener('click', () => { + Panel.openNamed('inbox', 'inbox · ' + operatorInbox.length, + buildInboxListNode()); + }); + })(); + // ─── message flow: shared terminal pane ──────────────────────────────── // Scroll, pill, backfill + SSE plumbing live in hive-fr0nt::TERMINAL_JS // (window.HiveTerminal). What stays here is the broker-message diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css index 592614a..d940eac 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -3,11 +3,116 @@ @import "@hive/shared/base.css"; @import "@hive/shared/terminal.css"; -body { - max-width: 70em; - margin: 1.5em auto; - padding: 0 1.5em; +/* ─── tabbed dashboard chrome (#369) ──────────────────────────────── + Top-of-page sticky header with banner + tab strip. Tab routing is + hash-based; tab panes are show/hide via the `.tab-pane-active` + class. SSE stays alive across tab switches so count pills update + live on inactive tabs without losing pulse on what's happening + elsewhere. */ + +body.dashboard-shell { + /* Width is generous so the container tree + agent cards aren't + boxed too narrow — agent state pills want room. */ + max-width: 90em; + margin: 0 auto; + padding: 0 1.5em 1.5em; } + +.dashboard-chrome { + position: sticky; + top: 0; + z-index: 25; + background: rgba(30, 30, 46, 0.86); + -webkit-backdrop-filter: blur(8px) saturate(120%); + backdrop-filter: blur(8px) saturate(120%); + border-bottom: 1px solid var(--purple-dim); + padding: 0.4em 0 0; + margin: 0 -1.5em 1em; +} +.banner-thin { + text-align: center; + margin: 0; + padding: 0 1em; + font-size: 0.78em; + color: var(--purple-dim); + letter-spacing: 0.15em; + white-space: pre; + overflow: hidden; + text-overflow: ellipsis; +} + +.tabbar { + display: flex; + align-items: center; + gap: 0.2em; + padding: 0.5em 1em 0; + border-bottom: 1px solid var(--purple-dim); +} +.tabbar .tab { + display: inline-flex; + align-items: center; + gap: 0.5em; + padding: 0.55em 1em 0.45em; + margin-bottom: -1px; /* overlap the tabbar bottom border */ + color: var(--muted); + font-family: inherit; + font-size: 0.92em; + letter-spacing: 0.08em; + text-decoration: none; + border: 1px solid transparent; + border-bottom: 0; + border-radius: 4px 4px 0 0; + cursor: pointer; + transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease; +} +.tabbar .tab:hover { + color: var(--purple); + background: rgba(203, 166, 247, 0.06); +} +.tabbar .tab.active { + color: var(--purple); + border-color: var(--purple-dim); + background: var(--bg); + /* Lift the active tab visually — the bottom border of the tabbar + yields under it via the -1px margin above. */ + box-shadow: 0 -2px 12px -4px rgba(203, 166, 247, 0.4); +} +.tab-label { font-weight: bold; } +.tab-count { + display: inline-block; + background: var(--purple-dim); + color: var(--purple); + border-radius: 999px; + padding: 0 0.55em; + min-width: 1.6em; + text-align: center; + font-size: 0.82em; + font-variant-numeric: tabular-nums; + font-weight: bold; +} +.tab-count-attn { + /* Y3R C4LL — operator-action-required pill colour, harder to miss. */ + background: rgba(243, 139, 168, 0.22); + color: var(--red); +} + +/* Notification controls cohabit with the tabs (always-on chrome). */ +.tabbar #notif-row { + margin-left: auto; + display: flex; + gap: 0.5em; + align-items: center; + padding-right: 0.5em; +} + +/* Tab pane visibility — show only the active one. The .tab-pane-active + class is set by app.js based on the URL hash; default (no hash) + resolves to SW4RM. */ +.tab-pane { display: none; } +.tab-pane.tab-pane-active { display: block; } + +/* FL0W is a separate page (`/flow.html`) — its full-viewport vibec0re + styling lives further down under `body.flow-shell`. */ .banner { text-align: center; margin: 0 0 1em 0; @@ -1174,3 +1279,181 @@ footer a { color: var(--purple); } border: 1px solid var(--border); padding: 0.2em 0.5em; } + +/* ─── /flow.html — full-page chat (#369) ─────────────────────────── + The all-agents chat surface lives on its own page so it can claim + full-viewport vibec0re styling (operator @ #369#issuecomment-3437). + Same shape as the per-agent live page (#362): frosted-glass header + at top, frosted composer docked at bottom, terminal scrolls + behind both. Operator inbox lives behind a header pill that opens + the side-panel flyout — preserves the "inbox + chat in one view" + ergonomics without stealing terminal real estate. */ + +:root { + --flow-header-h: 4.2em; + --flow-composer-h: 3.6em; + --flow-frost-bg: rgba(30, 30, 46, 0.74); + --flow-frost-blur: blur(12px) saturate(140%); +} + +body.flow-shell { + /* Override the dashboard's centred-max-width layout — flow owns + the whole viewport. */ + max-width: none; + margin: 0; + padding: 0; + height: 100vh; + overflow: hidden; + background: + radial-gradient(ellipse 80% 60% at 50% 0%, + rgba(203, 166, 247, 0.06) 0%, + transparent 60%), + var(--bg); +} + +.flow-header { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 30; + min-height: var(--flow-header-h); + display: flex; + align-items: center; + gap: 1em; + padding: 0.55em 1em; + background: var(--flow-frost-bg); + -webkit-backdrop-filter: var(--flow-frost-blur); + backdrop-filter: var(--flow-frost-blur); + border-bottom: 1px solid var(--purple-dim); + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35); + flex-wrap: wrap; +} +.flow-back { + color: var(--cyan); + text-decoration: none; + font-size: 0.85em; + letter-spacing: 0.1em; + padding: 0.25em 0.6em; + border: 1px solid var(--purple-dim); + border-radius: 4px; + flex: 0 0 auto; + transition: border-color 0.15s ease, color 0.15s ease; +} +.flow-back:hover { + border-color: var(--cyan); + text-shadow: 0 0 8px rgba(137, 220, 235, 0.6); +} +.flow-title { + margin: 0; + font-size: 1.2em; + color: var(--purple); + text-transform: uppercase; + letter-spacing: 0.15em; +} +.flow-hint { + margin: 0; + color: var(--muted); + font-size: 0.82em; + flex: 1 1 auto; + min-width: 0; +} +.flow-header .notif-row { + margin-left: auto; + display: flex; + gap: 0.4em; +} + +/* Inbox pill — operator inbox flyout trigger. Sits right under the + header so it stays in the operator's gaze without crowding the + chat. Same shape as the agent page's pills (#362). */ +.flow-pill { + position: fixed; + top: calc(var(--flow-header-h) + 0.8em); + right: 1em; + z-index: 25; + background: var(--bg-elev); + border: 1px solid var(--purple-dim); + color: var(--fg); + font-family: inherit; + font-size: 0.85em; + letter-spacing: 0.04em; + border-radius: 999px; + padding: 0.3em 0.8em; + display: inline-flex; + align-items: center; + gap: 0.5em; + cursor: pointer; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35); + transition: border-color 0.15s ease, box-shadow 0.15s ease, color 0.15s ease; +} +.flow-pill:hover { + border-color: var(--purple); + color: var(--purple); + box-shadow: 0 0 12px -2px var(--purple); +} +.flow-pill-icon { font-size: 1.05em; line-height: 1; } +.flow-pill-label { color: var(--muted); } +.flow-pill-count { + background: rgba(250, 179, 135, 0.18); + color: var(--amber); + border-radius: 999px; + padding: 0 0.5em; + min-width: 1.6em; + text-align: center; + font-weight: bold; + font-variant-numeric: tabular-nums; +} + +.flow-main { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + overflow: hidden; +} +.flow-main .terminal-wrap { + position: absolute; + inset: 0; + border: 0; + background: transparent; + box-shadow: none; + border-radius: 0; + margin: 0; + padding: 0; +} +.flow-main .live { + position: absolute; + inset: 0; + height: auto; + max-height: none; + padding-top: calc(var(--flow-header-h) + 0.8em); + padding-bottom: calc(var(--flow-composer-h) + 0.8em); + scroll-padding-top: calc(var(--flow-header-h) + 0.8em); + scroll-padding-bottom: calc(var(--flow-composer-h) + 0.8em); + overflow: auto; +} +.flow-main .tail-pill { + bottom: calc(var(--flow-composer-h) + 0.6em); +} + +.flow-composer { + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 30; + min-height: var(--flow-composer-h); + background: var(--flow-frost-bg); + -webkit-backdrop-filter: var(--flow-frost-blur); + backdrop-filter: var(--flow-frost-blur); + border-top: 1px solid var(--purple-dim); + box-shadow: 0 -6px 18px rgba(0, 0, 0, 0.35); + padding: 0.4em 1em; +} + +/* Hidden inbox section on the flow page — the renderInbox path + wants `#inbox-section` in the DOM (legacy contract), but we + surface the messages via the pill/flyout instead. */ +.flow-inbox-headless { display: none !important; } diff --git a/frontend/packages/dashboard/src/flow.html b/frontend/packages/dashboard/src/flow.html new file mode 100644 index 0000000..031c1ee --- /dev/null +++ b/frontend/packages/dashboard/src/flow.html @@ -0,0 +1,93 @@ + + + + + hyperhive // FL0W + + + + + + +
+ ← d4shb04rd +

◆ FL0W ◆

+

live broker tail · newest at the top · @name picks a recipient (sticky); tab completes

+ +
+ + + + +
+
+ + + + + +
+
+
connecting…
+
+
+ + + + + + + + + + + + + + diff --git a/frontend/packages/dashboard/src/index.html b/frontend/packages/dashboard/src/index.html index be99833..2acb542 100644 --- a/frontend/packages/dashboard/src/index.html +++ b/frontend/packages/dashboard/src/index.html @@ -6,86 +6,133 @@ - - + -
- - - - -
+ +
+ - -

◆ C0NTAINERS ◆

-
══════════════════════════════════════════════════════════════
-
-

loading…

-
+ +
-

◆ R3BU1LD QU3U3 ◆

-
══════════════════════════════════════════════════════════════
-

pending + running rebuilds, meta-updates, and first-spawns. one runs at a time; meta-update cascades nest under their parent. dedup: re-enqueueing a still-queued op collapses into the existing entry.

-
-

loading…

-
+ +
- -

◆ M1ND H4S QU3STI0NS ◆

-
══════════════════════════════════════════════════════════════
-
-

loading…

-
+ +
+

◆ C0NTAINERS ◆

+
══════════════════════════════════════════════════════════════
+
+

loading…

+
+
-

◆ QU3U3D R3M1ND3RS ◆

-
══════════════════════════════════════════════════════════════
-

reminders agents have queued for themselves but not yet delivered. cancel to drop a stuck or unwanted entry.

-
-

loading…

-
+ +
+

◆ P3NDING APPR0VALS ◆

+
══════════════════════════════════════════════════════════════
+
+

loading…

+
-

◆ P3NDING APPR0VALS ◆

-
══════════════════════════════════════════════════════════════
-
-

loading…

-
+

◆ M1ND H4S QU3STI0NS ◆

+
══════════════════════════════════════════════════════════════
+
+

loading…

+
+
- -

◆ 0PER4T0R 1NB0X ◆

-
══════════════════════════════════════════════════════════════
-
-

loading…

-
+ +
+

◆ M3T4 1NPUTS ◆

+
══════════════════════════════════════════════════════════════
+

select inputs to nix flake update in /meta/. selected agents rebuild in sequence after the lock bump; manager learns each outcome via the usual rebuilt system event.

+
+

loading…

+
-

◆ MESS4GE FL0W ◆

-
══════════════════════════════════════════════════════════════
-

live tail — newest at the top. tap on every send / recv through the broker. compose below: @name picks the recipient (sticky until you @ someone else); tab completes.

-
-
connecting…
-
- @—> - - -
-
+

◆ R3BU1LD QU3U3 ◆

+
══════════════════════════════════════════════════════════════
+

pending + running rebuilds, meta-updates, and first-spawns. one runs at a time; meta-update cascades nest under their parent. dedup: re-enqueueing a still-queued op collapses into the existing entry.

+
+

loading…

+
+ +

◆ QU3U3D R3M1ND3RS ◆

+
══════════════════════════════════════════════════════════════
+

reminders agents have queued for themselves but not yet delivered. cancel to drop a stuck or unwanted entry.

+
+

loading…

+
+ +

◆ K3PT ST4T3 ◆

+
══════════════════════════════════════════════════════════════
+
+

loading…

+
+
+ + + +