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:
parent
8c7bc850f3
commit
9666cb8c3f
5 changed files with 628 additions and 87 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue