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 627 additions and 86 deletions
|
|
@ -48,6 +48,8 @@ await build({
|
||||||
logLevel: 'info',
|
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(''));
|
console.log('dashboard build ok →', dist(''));
|
||||||
|
|
|
||||||
|
|
@ -74,13 +74,32 @@ window.marked = marked;
|
||||||
const root = $('side-panel');
|
const root = $('side-panel');
|
||||||
const titleEl = $('side-panel-title');
|
const titleEl = $('side-panel-title');
|
||||||
const bodyEl = $('side-panel-body');
|
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) {
|
function open(title, content) {
|
||||||
|
owner = null;
|
||||||
titleEl.textContent = title;
|
titleEl.textContent = title;
|
||||||
bodyEl.replaceChildren(...(content ? [content] : []));
|
bodyEl.replaceChildren(...(content ? [content] : []));
|
||||||
root.classList.add('open');
|
root.classList.add('open');
|
||||||
root.setAttribute('aria-hidden', 'false');
|
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() {
|
function close() {
|
||||||
|
owner = null;
|
||||||
root.classList.remove('open');
|
root.classList.remove('open');
|
||||||
root.setAttribute('aria-hidden', 'true');
|
root.setAttribute('aria-hidden', 'true');
|
||||||
}
|
}
|
||||||
|
|
@ -91,7 +110,7 @@ window.marked = marked;
|
||||||
if (e.key === 'Escape' && root.classList.contains('open')) close();
|
if (e.key === 'Escape' && root.classList.contains('open')) close();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { open, close, bind };
|
return { open, openNamed, refresh, close, bind };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// ─── path linkification ─────────────────────────────────────────────────
|
// ─── path linkification ─────────────────────────────────────────────────
|
||||||
|
|
@ -1326,14 +1345,8 @@ window.marked = marked;
|
||||||
if (operatorInbox.length > INBOX_LIMIT) operatorInbox.length = INBOX_LIMIT;
|
if (operatorInbox.length > INBOX_LIMIT) operatorInbox.length = INBOX_LIMIT;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
function renderInbox() {
|
function buildInboxListNode() {
|
||||||
const root = $('inbox-section');
|
if (!operatorInbox.length) return el('p', { class: 'empty' }, 'no messages');
|
||||||
if (!root) return;
|
|
||||||
root.innerHTML = '';
|
|
||||||
if (!operatorInbox.length) {
|
|
||||||
root.append(el('p', { class: 'empty' }, 'no messages'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
|
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
|
||||||
const ul = el('ul', { class: 'inbox' });
|
const ul = el('ul', { class: 'inbox' });
|
||||||
for (const m of operatorInbox) {
|
for (const m of operatorInbox) {
|
||||||
|
|
@ -1348,7 +1361,28 @@ window.marked = marked;
|
||||||
);
|
);
|
||||||
ul.append(li);
|
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';
|
const APPROVAL_TAB_KEY = 'hyperhive:approvals:tab';
|
||||||
|
|
@ -2086,6 +2120,87 @@ window.marked = marked;
|
||||||
NOTIF.bind();
|
NOTIF.bind();
|
||||||
Panel.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 ────────────────────────────────
|
// ─── message flow: shared terminal pane ────────────────────────────────
|
||||||
// Scroll, pill, backfill + SSE plumbing live in hive-fr0nt::TERMINAL_JS
|
// Scroll, pill, backfill + SSE plumbing live in hive-fr0nt::TERMINAL_JS
|
||||||
// (window.HiveTerminal). What stays here is the broker-message
|
// (window.HiveTerminal). What stays here is the broker-message
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,116 @@
|
||||||
@import "@hive/shared/base.css";
|
@import "@hive/shared/base.css";
|
||||||
@import "@hive/shared/terminal.css";
|
@import "@hive/shared/terminal.css";
|
||||||
|
|
||||||
body {
|
/* ─── tabbed dashboard chrome (#369) ────────────────────────────────
|
||||||
max-width: 70em;
|
Top-of-page sticky header with banner + tab strip. Tab routing is
|
||||||
margin: 1.5em auto;
|
hash-based; tab panes are show/hide via the `.tab-pane-active`
|
||||||
padding: 0 1.5em;
|
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 {
|
.banner {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 0 0 1em 0;
|
margin: 0 0 1em 0;
|
||||||
|
|
@ -1174,3 +1279,181 @@ footer a { color: var(--purple); }
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
padding: 0.2em 0.5em;
|
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; }
|
||||||
|
|
|
||||||
93
frontend/packages/dashboard/src/flow.html
Normal file
93
frontend/packages/dashboard/src/flow.html
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>hyperhive // FL0W</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
|
<link rel="stylesheet" href="/static/dashboard.css">
|
||||||
|
</head>
|
||||||
|
<body class="flow-shell">
|
||||||
|
|
||||||
|
<!-- Fixed-overlay header. Frosted glass over the message flow —
|
||||||
|
backdrop-filter blur shows the scrolled chat text behind. Mirrors
|
||||||
|
the agent live page's overlay layout (#362). -->
|
||||||
|
<header class="flow-header" id="flow-header">
|
||||||
|
<a href="/" class="flow-back" title="back to dashboard">← d4shb04rd</a>
|
||||||
|
<h2 class="flow-title">◆ FL0W ◆</h2>
|
||||||
|
<p class="flow-hint">live broker tail · newest at the top · <code>@name</code> picks a recipient (sticky); <code>tab</code> completes</p>
|
||||||
|
<!-- Notif controls cohabit here (no other chrome on this page).
|
||||||
|
Same IDs as on the dashboard so app.js's NOTIF binding picks
|
||||||
|
them up unchanged. -->
|
||||||
|
<div id="notif-row" class="notif-row">
|
||||||
|
<button type="button" id="notif-enable" class="btn btn-notif" hidden>🔔 enable notifications</button>
|
||||||
|
<button type="button" id="notif-mute" class="btn btn-notif" hidden>🔕 mute</button>
|
||||||
|
<button type="button" id="notif-unmute" class="btn btn-notif" hidden>🔔 unmute</button>
|
||||||
|
<span id="notif-status" class="meta" hidden></span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Operator inbox flyout trigger — count + click → side panel
|
||||||
|
(singleton, declared below). Hidden until the inbox is non-
|
||||||
|
empty. Mirrors the agent page's pill pattern (#362). -->
|
||||||
|
<button type="button" id="inbox-pill" class="flow-pill" hidden
|
||||||
|
title="open operator inbox">
|
||||||
|
<span class="flow-pill-icon" aria-hidden="true">📬</span>
|
||||||
|
<span class="flow-pill-label">inbox</span>
|
||||||
|
<span class="flow-pill-count" id="inbox-pill-count">0</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Main content: the full-viewport terminal. Padded for the
|
||||||
|
overlay header + composer so the first/last rows stay
|
||||||
|
reachable. -->
|
||||||
|
<main class="flow-main">
|
||||||
|
<div class="terminal-wrap">
|
||||||
|
<div id="msgflow" class="live terminal"><div class="meta">connecting…</div></div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Fixed-overlay composer at the bottom. Same frosted treatment
|
||||||
|
as the header — symmetric framing, terminal goes edge-to-edge
|
||||||
|
between them. -->
|
||||||
|
<footer class="flow-composer">
|
||||||
|
<div id="op-compose" class="op-compose">
|
||||||
|
<span id="op-compose-prompt" class="op-compose-prompt">@—></span>
|
||||||
|
<textarea id="op-compose-input" class="op-compose-input"
|
||||||
|
placeholder="@agent message… (enter sends, shift+enter newline, tab completes @-mention)"
|
||||||
|
rows="1" autocomplete="off"></textarea>
|
||||||
|
<div id="op-compose-suggest" class="op-compose-suggest" hidden></div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<!-- Inbox rendered offscreen — kept in the DOM so app.js's
|
||||||
|
renderInbox keeps working unchanged. The pill click handler
|
||||||
|
opens the side panel which displays a clone of the list. The
|
||||||
|
legacy section heading would otherwise be visible; hidden
|
||||||
|
here. -->
|
||||||
|
<div id="inbox-section" class="flow-inbox-headless" hidden>
|
||||||
|
<p class="meta">loading…</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Slide-in side panel. Singleton — JS swaps the title + body
|
||||||
|
and toggles `.open`. On this page only used to surface the
|
||||||
|
operator inbox flyout; the dashboard's other panel uses
|
||||||
|
(approval diffs, file previews, logs) don't apply here. -->
|
||||||
|
<div id="side-panel" class="side-panel" aria-hidden="true">
|
||||||
|
<div class="side-panel-backdrop" id="side-panel-backdrop"></div>
|
||||||
|
<aside class="side-panel-drawer" role="dialog" aria-modal="true"
|
||||||
|
aria-labelledby="side-panel-title">
|
||||||
|
<header class="side-panel-head">
|
||||||
|
<span class="side-panel-title" id="side-panel-title"></span>
|
||||||
|
<button type="button" class="side-panel-close" id="side-panel-close"
|
||||||
|
title="close (esc)">✕</button>
|
||||||
|
</header>
|
||||||
|
<div class="side-panel-body" id="side-panel-body"></div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Same bundled entry as the dashboard. Renderers whose target
|
||||||
|
DOM doesn't exist on this page no-op silently. SSE +
|
||||||
|
/api/state fetching still run; only the chat-relevant chunks
|
||||||
|
have anywhere to render to. -->
|
||||||
|
<script type="module" src="/static/app.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -6,32 +6,99 @@
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<link rel="stylesheet" href="/static/dashboard.css">
|
<link rel="stylesheet" href="/static/dashboard.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="dashboard-shell">
|
||||||
<pre class="banner">
|
|
||||||
░▒▓█▓▒░ HYPERHIVE ░▒▓█▓▒░ HIVE-C0RE ░▒▓█▓▒░ WE ARE THE WIRED ░▒▓█▓▒░
|
|
||||||
</pre>
|
|
||||||
|
|
||||||
|
<!-- Sticky chrome — banner + tab strip. Tabs route via the URL
|
||||||
|
hash so F5 / back-button / shared links keep you on the same
|
||||||
|
view. JS owns the actual show/hide; this is just the menu. -->
|
||||||
|
<header class="dashboard-chrome">
|
||||||
|
<pre class="banner banner-thin">░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░</pre>
|
||||||
|
|
||||||
|
<nav class="tabbar" id="tabbar" role="tablist">
|
||||||
|
<a class="tab" id="tab-swarm" href="#swarm" role="tab"
|
||||||
|
aria-controls="tab-pane-swarm"
|
||||||
|
data-tab="swarm">
|
||||||
|
<span class="tab-label">◆ SW4RM ◆</span>
|
||||||
|
<span class="tab-count" id="tab-count-swarm" hidden></span>
|
||||||
|
</a>
|
||||||
|
<a class="tab" id="tab-call" href="#call" role="tab"
|
||||||
|
aria-controls="tab-pane-call"
|
||||||
|
data-tab="call">
|
||||||
|
<span class="tab-label">◆ Y3R C4LL ◆</span>
|
||||||
|
<span class="tab-count tab-count-attn" id="tab-count-call" hidden></span>
|
||||||
|
</a>
|
||||||
|
<a class="tab" id="tab-system" href="#system" role="tab"
|
||||||
|
aria-controls="tab-pane-system"
|
||||||
|
data-tab="system">
|
||||||
|
<span class="tab-label">◆ SYST3M ◆</span>
|
||||||
|
<span class="tab-count" id="tab-count-system" hidden></span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- FL0W is its own page (`/flow.html`), not a tab — per
|
||||||
|
operator @ #369#issuecomment-3437 ("yes terminal can be a
|
||||||
|
separate page"). The link lives in the tab strip so it
|
||||||
|
reads as a peer surface; clicking navigates rather than
|
||||||
|
swapping panes in place. Count pill mirrors the dashboard's
|
||||||
|
operator-inbox length and is hidden when zero. -->
|
||||||
|
<a class="tab tab-link" id="tab-flow" href="/flow.html"
|
||||||
|
title="open the all-agents chat in a dedicated full-page terminal">
|
||||||
|
<span class="tab-label">◆ FL0W ◆ →</span>
|
||||||
|
<span class="tab-count" id="tab-count-flow" hidden></span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Notification controls live in the chrome (always-on
|
||||||
|
ergonomics; not tab-specific). -->
|
||||||
<div id="notif-row" class="notif-row">
|
<div id="notif-row" class="notif-row">
|
||||||
<button type="button" id="notif-enable" class="btn btn-notif" hidden>🔔 enable notifications</button>
|
<button type="button" id="notif-enable" class="btn btn-notif" hidden>🔔 enable notifications</button>
|
||||||
<button type="button" id="notif-mute" class="btn btn-notif" hidden>🔕 mute</button>
|
<button type="button" id="notif-mute" class="btn btn-notif" hidden>🔕 mute</button>
|
||||||
<button type="button" id="notif-unmute" class="btn btn-notif" hidden>🔔 unmute</button>
|
<button type="button" id="notif-unmute" class="btn btn-notif" hidden>🔔 unmute</button>
|
||||||
<span id="notif-status" class="meta" hidden></span>
|
<span id="notif-status" class="meta" hidden></span>
|
||||||
</div>
|
</div>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
<!-- swarm: live containers, dormant state, meta input bumps that
|
<!-- Tab panes. Exactly one is `.tab-pane-active` at a time;
|
||||||
affect the whole swarm. -->
|
JS toggles based on the URL hash + `hashchange` events. -->
|
||||||
|
<main class="dashboard-main">
|
||||||
|
|
||||||
|
<!-- SW4RM: the swarm itself. Container cards (the central thing
|
||||||
|
the operator looks at) and rebuild queue / cascade visualisation
|
||||||
|
that drives them. -->
|
||||||
|
<section class="tab-pane" id="tab-pane-swarm"
|
||||||
|
role="tabpanel" aria-labelledby="tab-swarm">
|
||||||
<h2>◆ C0NTAINERS ◆</h2>
|
<h2>◆ C0NTAINERS ◆</h2>
|
||||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||||
<div id="containers-section">
|
<div id="containers-section">
|
||||||
<p class="meta">loading…</p>
|
<p class="meta">loading…</p>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<h2>◆ K3PT ST4T3 ◆</h2>
|
<!-- Y3R C4LL: things blocked on operator decision. Approvals +
|
||||||
|
questions read as the same concept ("something is waiting on
|
||||||
|
you"); both surface their full bodies inline so the operator
|
||||||
|
can decide without leaving the pane. -->
|
||||||
|
<section class="tab-pane" id="tab-pane-call"
|
||||||
|
role="tabpanel" aria-labelledby="tab-call">
|
||||||
|
<h2>◆ P3NDING APPR0VALS ◆</h2>
|
||||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||||
<div id="tombstones-section">
|
<div id="approvals-section">
|
||||||
<p class="meta">loading…</p>
|
<p class="meta">loading…</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h2>◆ M1ND H4S QU3STI0NS ◆</h2>
|
||||||
|
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||||
|
<div id="questions-section">
|
||||||
|
<p class="meta">loading…</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- SYST3M: passive / rare-interaction state. Meta inputs (lock
|
||||||
|
bumps), rebuild queue (watch only), queued reminders, kept
|
||||||
|
state from previous tombstoned agents. Headings stay; the
|
||||||
|
per-section content auto-compresses to a one-line summary
|
||||||
|
when empty (separate JS toggle). -->
|
||||||
|
<section class="tab-pane" id="tab-pane-system"
|
||||||
|
role="tabpanel" aria-labelledby="tab-system">
|
||||||
<h2>◆ M3T4 1NPUTS ◆</h2>
|
<h2>◆ M3T4 1NPUTS ◆</h2>
|
||||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||||
<p class="meta">select inputs to <code>nix flake update</code> in <code>/meta/</code>. selected agents rebuild in sequence after the lock bump; manager learns each outcome via the usual <code>rebuilt</code> system event.</p>
|
<p class="meta">select inputs to <code>nix flake update</code> in <code>/meta/</code>. selected agents rebuild in sequence after the lock bump; manager learns each outcome via the usual <code>rebuilt</code> system event.</p>
|
||||||
|
|
@ -46,13 +113,6 @@
|
||||||
<p class="meta">loading…</p>
|
<p class="meta">loading…</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- operator decisions: things waiting on you. -->
|
|
||||||
<h2>◆ M1ND H4S QU3STI0NS ◆</h2>
|
|
||||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
|
||||||
<div id="questions-section">
|
|
||||||
<p class="meta">loading…</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>◆ QU3U3D R3M1ND3RS ◆</h2>
|
<h2>◆ QU3U3D R3M1ND3RS ◆</h2>
|
||||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||||
<p class="meta">reminders agents have queued for themselves but not yet delivered. cancel to drop a stuck or unwanted entry.</p>
|
<p class="meta">reminders agents have queued for themselves but not yet delivered. cancel to drop a stuck or unwanted entry.</p>
|
||||||
|
|
@ -60,32 +120,19 @@
|
||||||
<p class="meta">loading…</p>
|
<p class="meta">loading…</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>◆ P3NDING APPR0VALS ◆</h2>
|
<h2>◆ K3PT ST4T3 ◆</h2>
|
||||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||||
<div id="approvals-section">
|
<div id="tombstones-section">
|
||||||
<p class="meta">loading…</p>
|
<p class="meta">loading…</p>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- messages: broker traffic + the compose box that produces it. -->
|
<!-- FL0W: lives on its own page now (`/flow.html`). The
|
||||||
<h2>◆ 0PER4T0R 1NB0X ◆</h2>
|
message-flow + inbox + compose DOM only exists there — when
|
||||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
app.js boots on this page the corresponding renderers
|
||||||
<div id="inbox-section">
|
no-op silently (each guard is `if (!el) return`). -->
|
||||||
<p class="meta">loading…</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>◆ MESS4GE FL0W ◆</h2>
|
</main>
|
||||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
|
||||||
<p class="meta">live tail — newest at the top. tap on every <code>send</code> / <code>recv</code> through the broker. compose below: <code>@name</code> picks the recipient (sticky until you @ someone else); <code>tab</code> completes.</p>
|
|
||||||
<div class="terminal-wrap">
|
|
||||||
<div id="msgflow" class="live"><div class="meta">connecting…</div></div>
|
|
||||||
<div id="op-compose" class="op-compose">
|
|
||||||
<span id="op-compose-prompt" class="op-compose-prompt">@—></span>
|
|
||||||
<textarea id="op-compose-input" class="op-compose-input"
|
|
||||||
placeholder="@agent message… (enter sends, shift+enter newline, tab completes @-mention)"
|
|
||||||
rows="1" autocomplete="off"></textarea>
|
|
||||||
<div id="op-compose-suggest" class="op-compose-suggest" hidden></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||||
|
|
@ -95,7 +142,8 @@
|
||||||
<!-- Slide-in detail panel. Long content (clicked file previews,
|
<!-- Slide-in detail panel. Long content (clicked file previews,
|
||||||
approval diffs, journald logs, applied config) opens here
|
approval diffs, journald logs, applied config) opens here
|
||||||
instead of expanding inline. Singleton — JS swaps the title +
|
instead of expanding inline. Singleton — JS swaps the title +
|
||||||
body and toggles `.open`. -->
|
body and toggles `.open`. Lives outside the tab panes so it
|
||||||
|
overlays any active tab. -->
|
||||||
<div id="side-panel" class="side-panel" aria-hidden="true">
|
<div id="side-panel" class="side-panel" aria-hidden="true">
|
||||||
<div class="side-panel-backdrop" id="side-panel-backdrop"></div>
|
<div class="side-panel-backdrop" id="side-panel-backdrop"></div>
|
||||||
<aside class="side-panel-drawer" role="dialog" aria-modal="true"
|
<aside class="side-panel-drawer" role="dialog" aria-modal="true"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue