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
parent 8c7bc850f3
commit 9666cb8c3f
5 changed files with 627 additions and 86 deletions

View file

@ -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(''));

View file

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

View file

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

View 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">@—&gt;</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>

View file

@ -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">@—&gt;</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"