dashboard: tab-bar restructure + extract FL0W to /flow.html (#369)

Operator: 'option A (tabs)' (#369#issuecomment-3434) +
'yes terminal can be a separate page' (#369#issuecomment-3437).

## Tab framework

`index.html` becomes a 3-tab dashboard with a sticky chrome header:

- `◆ SW4RM ◆`    — containers list (the central thing)
- `◆ Y3R C4LL ◆` — pending approvals + operator-targeted questions
- `◆ SYST3M ◆`   — meta inputs + rebuild queue + reminders + tombstones

Hash routing: `#swarm` / `#call` / `#system` (empty → SW4RM).
F5-reloadable + back-button-aware without a router framework.

SSE stays alive across tab switches — count pills on inactive tabs
update live so the operator never loses pulse on what's happening
elsewhere:

  - SW4RM:  containers with needs_update
  - Y3R C4LL: approvals.pending + questions.pending (attn-coloured pill)
  - SYST3M: rebuild_queue entries in Queued|Running

Pills hidden when count is zero. setInterval(1s) polls the existing
state stores (cheap, no per-renderer hookup needed).

## FL0W as its own page

The all-agents chat moves to /flow.html — full-viewport vibec0re
layout mirroring the per-agent live page (#362):

- Fixed-overlay frosted-glass header at top (back link + title +
  notif controls), backdrop-filter blur shows the scrolled chat
  text behind.
- Full-viewport terminal, scroll-padded for the floating chrome so
  first/last rows stay reachable.
- Fixed-overlay frosted composer at the bottom.
- Operator inbox surfaces via a pill (📬 inbox · N) in the upper
  right — click opens the side-panel flyout with the message list.

In the dashboard tab strip, FL0W is the right-most entry but
renders as a `<a class="tab tab-link" href="/flow.html">` — clicking
navigates to the page rather than swapping a pane. Same pattern
back from flow.html via the `← d4shb04rd` link.

## Implementation notes

- New `/flow.html` page rendered by the same bundled `app.js` — the
  flow page just doesn't have the dashboard-chrome DOM, so the
  matching renderers no-op silently (each `if (!el) return`).
  Avoids splitting the bundle for v1; can extract later if size
  becomes a concern.
- `Panel` module gains `openNamed(name, …)` + `refresh(name, …)` —
  the legacy untyped `open(title, content)` calls clear the owner,
  so file-preview / diff / log drill-ins behave unchanged. `refresh`
  is no-op when a different view owns the panel, so live message
  events re-render the inbox flyout only when it's actually open.
- `renderInbox` updates BOTH the dashboard's inline `#inbox-section`
  (now living on the flow page) AND the flow page's pill count +
  side-panel refresh. The dashboard's empty FL0W tab is removed —
  inbox + message flow + compose box only exist in flow.html.
- Banner shrinks to a thin Catppuccin gradient strip at the top of
  the dashboard chrome (dropped the multi-line ASCII art —
  affectionate but pure chrome budget in a tabbed layout).
- `build.mjs` copies both `index.html` + `flow.html` into dist.

## Validation

`npm run build` clean. Dashboard bundle deltas:
  app.js  150kb → 152kb  (tab routing + count pills + named-Panel)
  dashboard.css 33kb → 38kb (tab chrome + flow page layout)
  + dist/flow.html  4.4kb

Browser smoke test isn't possible from inside iris's container
(no JS engine) — drafting as a PR for operator visual review on
next deploy. Worth eyeballing:
  - Tab switching feels right; counts update live across SSE events
  - FL0W page reads like the agent live page (frosted header + composer)
  - Inbox pill opens flyout; live message arrivals refresh it
  - Back link from flow → dashboard returns to last tab via the
    URL hash (browser remembers the hash across page nav)

Closes #369.
This commit is contained in:
iris 2026-05-24 12:15:16 +02:00 committed by Mara
commit 9666cb8c3f
5 changed files with 628 additions and 87 deletions

View file

@ -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