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

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