Per @argus on PR #412: after the common.js step (#410)
`appendText` uses the direct `termLinkify` import and `termCreate`
is called directly in flow.js — nothing reads `window.HiveTerminal`
anywhere. Drop the line + the stale comment alongside.
Splits the single bundle into two: `app.js` (entry for /index.html —
tab renderers + tab routing + refreshState) and a new `flow.js`
(entry for /flow.html — operator inbox derived store + inbox pill
flyout + broker terminal + @-mention composer). Both bundles inline
`./common.js` (DOM helpers, Panel, NOTIF, path linkification).
## What `flow.js` owns
- Operator inbox derived store (`operatorInbox`, `INBOX_LIMIT`,
`inboxAppendFromEvent`, `buildInboxListNode`, `renderInbox`) +
inbox-pill click wiring
- Broker terminal init (`HiveTerminal.create({ logEl: msgflow, ... })`)
with `renderMsg`, `pulseBanner`, `msgRowMap` reply-thread indicator,
and the renderers map for `sent` / `delivered` broker rows
- @-mention composer (`#op-compose-input` IIFE — sticky recipient,
autocomplete, parseAddressed, /op-send POST)
- A small local `flowContainers` cache for the composer's
autocomplete, refreshed on cold load + on every SSE reconnect via
`onStreamOpen`, and live-updated by `container_state_changed` /
`container_removed` SSE events (the dashboard's `containersState`
lives in `app.js` and isn't available here)
## What `app.js` no longer does
- Drops the inbox derived store, the bindFlowInboxPill IIFE, the
broker-terminal IIFE, and the composer IIFE — all moved
- Drops the `renderInbox()` call in `refreshState` (dashboard has
no #inbox-section element)
- Drops `setTabCount('flow', operatorInbox.length)` — the FL0W tab
count lives in flow.js now (cross-page count broadcasting is a
future follow-up; the slot currently stays hidden on /index.html)
- Drops the `window.HiveTerminal` global — the bare-import pattern
in common.js / flow.js made it unused on the dashboard
## What changes for /flow.html
- `<script src>` switches from `/static/app.js` → `/static/flow.js`
- Mutation events on the dashboard stream (`approval_added`,
`container_state_changed`, etc.) are silently ignored on /flow.html
via a `_default: () => {}` renderer (the dashboard tabs aren't on
this page; firing the legacy applyXxx handlers from here just
mutated dead stores). #408 follow-up filters this at the SSE level
## Validation
- `npm run build` clean.
- Bundle deltas:
- `app.js`: 154kb → 135kb (dropped ~19kb of flow code)
- `flow.js`: NEW 29kb (was bundled into the old 154kb app.js)
- `flow.html` page total: 154kb → 29kb (flow.js + inlined common,
no tab renderers shipped)
- Source: `app.js` 2287 → 1907 lines (-380); `flow.js` 423 lines (new)
- No HTML / CSS changes besides the `<script src>` swap on /flow.html.
## Known limitations (out of scope; tracked separately)
- /index.html still has no live SSE subscription — the dashboard
updates only on cold load + after async-form submits. Pre-existing
behaviour; the SSE wiring also lived in the flow IIFE before.
Step 3 of #406 (or its own bug fix) re-wires it.
- /flow.html's `_default: noop` drops mutation events; #408 fixes
the duplicate-traffic by splitting the SSE endpoint server-side.
- The FL0W tab-strip count pill on /index.html stays hidden — the
count source is now in flow.js. Broadcast via localStorage /
BroadcastChannel is a small follow-up if both pages are open.
Browser smoke test isn't possible from inside iris's container.
Worth eyeballing post-deploy:
- /flow.html: terminal renders broker rows; inbox pill shows count
+ opens flyout; composer autocomplete suggests known agents
+ sends successfully
- /index.html: tab renderers all work; notification toggle still
binds; side panel still opens for diffs / file previews / logs
First slice of the app.js split (#406). Pure utility / infrastructure
code that both /index.html and /flow.html use lifts out of the IIFE
into a sibling ES module:
- DOM helpers: `$`, `el`, `esc`, `form`, `fmtAgeSecs`
- Side-panel singleton (`Panel.open` / `openNamed` / `refresh` /
`close` / `bind`). The `ensure()` lazy-init makes it tolerate
being imported before the DOM element exists — `bind()` still
needs to be called once the host page is ready.
- Path linkification + file-preview side panel:
`appendLinkified`, `appendText`, `makePathLink`, plus the
internal `openFilePanel` + `fetchStateFile` + `mdNode` /
`svgImage` / `buildTabbedPreview` it depends on.
- Browser-notification module `NOTIF` (`bind`, `show`,
`renderControls`).
`app.js` now imports these from `./common.js` and the duplicated
definitions are gone. Each removal is replaced by a one-line
breadcrumb comment so a reader chasing a name from the bundled
output can find where it landed.
`truncate`, `fmtAgo`, `fmtElapsed`, `fmtDuration` stay in app.js for
now — each has caller-specific phrasing ("X running", "X ago") that
doesn't generalise cleanly. Lift them when a second consumer needs
the same shape.
## Next steps (separate PRs)
- Step 2: split app.js into `tabs.js` (entry for /index.html — tab
renderers + tab routing + refreshState) and `flow.js` (entry for
/flow.html — broker terminal + inbox derived store + compose),
both importing from common.js. Updates `build.mjs` for multiple
entry points and switches each HTML file's `<script src>`.
- Step 3 (#408 follow-up): backend-side stream split so /flow.html
doesn't have to subscribe to the dashboard's mutation events at all.
## Validation
- `npm run build` clean.
- Build deltas: `app.js` 154.3kb (was 153.6kb) — bundle size bumped
slightly due to per-module overhead; same code under the hood.
Source: app.js 2603 → 2287 lines (-316); common.js 367 lines (new).
- No HTML / CSS changes. Both pages still load `/static/app.js` as
before.
Browser smoke test isn't possible from inside iris's container.
Worth eyeballing post-deploy:
- Notification toggle + send still works (NOTIF.bind, NOTIF.show)
- Side panel still opens for diff / file preview / logs (Panel)
- Path tokens in messages still render as clickable anchors that
open the file in the side panel (appendLinkified → makePathLink
→ openFilePanel)
PR #392 moved the slug below the tab strip but kept it INSIDE the
sticky chrome — Mara's report ("still at old position everywhere
except flow tab") makes the original intent clearer: the slug
should be at the page BOTTOM, with the chrome reduced to pure
navigation.
## index.html
- Remove the banner-thin from `.dashboard-chrome` entirely.
- Move it into the existing `<footer>` element (above the divider
+ project-link line). Sits at the bottom of every tab pane after
the main content scrolls past.
## flow.html
- Remove the banner-thin from the chrome. The flow page is a
full-viewport terminal with `body { overflow: hidden }` and
no normal-flow footer position — the slug simply doesn't appear
here. The frosted chrome is purely the tab strip now.
## dashboard.css
- `--flow-header-h: 4.7em → 3.6em` — chrome is shorter without
the banner; terminal padding-top + tail-pill offset + inbox-pill
top all derive from this variable, so they follow automatically.
- `footer .banner-thin { margin-bottom: 0.8em }` so the slug
doesn't crash into the divider + link line below it.
No JS changes. Build clean.
Mara hit:
TypeError: can't access property "innerHTML", root is null
renderContainers app.js:666
applyContainerStateChanged app.js:484
container_state_changed app.js:2306
…on /flow.html. The same bundled `app.js` runs on both /index.html
(dashboard, has the tab panes) and /flow.html (flow page, has only
the broker terminal). SSE events arrive on every page —
`container_state_changed` / `tombstones_changed` / `approval_*` /
`question_*` route through their corresponding renderers, which
then `root.innerHTML = ''` on `$('section-id')` and crash when the
section isn't in the DOM.
The convention is already "no-op when the target DOM doesn't
exist" — `renderMetaInputs`, `renderRebuildQueue`, `renderReminders`
all guard with `if (!root) return;` at the top. Bring the rest in
line:
- `renderContainers`
- `renderTombstones`
- `renderQuestions`
- `renderApprovals`
`renderInbox` already handles the absent case via its
`if (root && !root.hidden)` branch — no change.
No behaviour change on /index.html. On /flow.html the failing
events silently no-op as intended (terminal renderers still
re-render the broker tail normally).
The SW4RM tab's container card was reading container state
straight from the snapshot — when a rebuild was in flight and the
container was momentarily stopped between teardown and bring-up,
the card showed "stopped" while the SYST3M tab's rebuild queue
showed the operation running. The two surfaces disagreed.
Mara: *should show as building in container page as well*.
Cross-reference: build a `inFlightOpsByAgent()` map from
`rebuildQueueState` (kinds: rebuild / meta_update / destroy;
states: queued / running — skip `spawn` since transients already
drive that case). When rendering each container row, prefer the
operator-initiated transient if set; otherwise fall back to the
in-flight queue entry as a synthetic pending kind:
`rebuilding` / `meta-updating` / `destroying` (or `… queued`
when still waiting on the worker). The existing `pending-state`
spinner badge surfaces it visually — no new CSS rule needed.
Also wire `applyRebuildQueueChanged` to re-render containers so
the badge lights up the moment a rebuild lands in the queue and
clears the moment it finishes — no manual refresh.
The container-row tree-prefix used text box-drawing glyphs (├ └ │)
positioned with `top: 0.6em` — a single text-line tall. Once rows
grew past one line (5em square icon + multi-line body), the `│`
columns of consecutive siblings no longer touched, leaving visible
breaks in the tree.
Replace the text-glyph string with structured DOM: one `.tree-lane`
per depth column. Continuation lanes (`.lane-line`) paint a 1px
border-left spanning the full row height + the `.containers` gap
below, so adjacent siblings' bars visually merge into one unbroken
vertical. The row's own joint lane is `├` (branch — bar continues
below) or `└` (last — bar stops at icon midline), with a horizontal
stub at 3.1em (row padding-top + icon half-height) reaching to the
icon edge.
Joint y / stub width are derived from the 5em icon + 0.6em row
padding-top + 0.8em row padding-left so they meet the icon cleanly.
#375 set `z-index: 35` on the agent page's tail pill so it'd float
above the fixed composer (z-index 30). The fix only landed because
nothing else in `.agent-main` created a stacking context. But the
pill is anchored inside `.terminal-wrap`, which carries
`backdrop-filter: blur(...) saturate(...)` for the frost effect —
and `backdrop-filter` CREATES A STACKING CONTEXT. The pill's
z-index 35 was trapped inside that context and never got to compete
with the composer's z-index 30 in the root stacking context, so the
operator still saw the badge clipped under the input box.
Same root cause on the flow page — `.flow-main .terminal-wrap`
inherits the same backdrop-filter rule.
Fix: anchor the pill in `.agent-main` / `.flow-main` instead of
`log.parentElement` (= `.terminal-wrap`). Both ancestors are
`position: absolute` with `overflow: hidden` but NO backdrop-filter
or other stacking-context creators, so the pill's z-index reaches
the root and properly floats above the composer.
Geometry unchanged — `.agent-main` / `.flow-main` and the
`.terminal-wrap` they contain both `inset: 0` the same area, so the
pill's `bottom: calc(--composer-h + 0.6em)` lands at the same y.
Also added `.flow-main .tail-pill { z-index: 35 }` (the flow page
was missing the per-page z-index bump that the agent page already
had).
`pillAnchor` is an existing opt in @hive/shared/terminal.js (the
default is `log.parentElement`); both consumers now set it explicitly.
Swap the order of `.tabbar` and `.banner` inside `.dashboard-chrome`
so the operator's navigation surface sits at the very top of the
sticky/fixed header — the "WE ARE THE WIRED" slug becomes decoration
below the tabs rather than chrome above them.
Applied to both `index.html` (sticky chrome) and `flow.html`
(fixed-overlay chrome).
No CSS changes — `.tabbar { border-bottom }` still divides tabs from
the area below (now the banner), and active-tab `margin-bottom: -1px`
still merges into that boundary cleanly.
Operator wanted the tab header visible on /flow.html so switching
tabs doesn't require navigating back to / first.
The flow page now reuses the same `<header class="dashboard-chrome">`
markup the dashboard renders, with a few tweaks:
- The SW4RM / Y3R C4LL / SYST3M tabs are cross-page links
(`href="/#swarm"` etc.) — clicking lands on the dashboard with the
destination tab pre-active via the hash router.
- The FL0W tab is rendered `.active.tab-link` + `aria-current="page"`
so it reads as the current view (no clickable arrow / "go here"
affordance — you're already here).
- Banner-thin echoes the dashboard for visual continuity.
- Notif controls cohabit with the tabs (same IDs the dashboard uses,
so app.js's NOTIF binding picks them up unchanged).
Layout glue:
- `body.flow-shell .dashboard-chrome.flow-chrome` overrides the
dashboard's `position: sticky` with `position: fixed` so the
chrome stays put under flow-shell's `overflow: hidden` body
layout, keeping the terminal full-viewport behind/beneath.
- New rule for the active FL0W tab — the `.tab-link` styling on the
dashboard otherwise reads as a passive cross-page link; here we
need it lit-up like a regular active tab.
- `--flow-header-h` bumped from 4.2em → 4.7em to match the natural
height of the tab strip + banner combo. Terminal padding +
inbox-pill top offset both derive from this variable, so they
follow automatically.
Removed:
- Legacy `.flow-title`, `.flow-hint`, `.flow-back` CSS rules (their
HTML counterparts are gone — the tab strip carries the
identity now).
- The `<a class="flow-back">← d4shb04rd</a>` link and the
`<h2 class="flow-title">` from flow.html.
## Validation
`npm run build` clean.
dashboard.css: 38kb → 37kb (legacy rules removed, new shared-
chrome rules are smaller)
flow.html: 4.4kb → 4.7kb (tab strip replaces title bar)
app.js: unchanged (no JS changes — the tab navigation is
pure HTML href + cross-page hash)
Closes#383.
The SW4RM tab label already announces the section — an inline
`<h2>◆ C0NTAINERS ◆</h2>` + divider underneath was redundant
ink. The tab is single-section so there's no other content to
disambiguate from.
`#containers-section` div stays put (app.js targets it by id);
just the heading + divider go.
`npm run build` clean.
Closes#385.
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.
Closes#363 (frontend half of milestone #361). Consumes the
`ContainerView.parent: Option<String>` field landing in damocles'
backend slice — when present, the container list renders depth-first
with sibling-position tree glyphs (├─ / └─ joints + │ continuation
columns). When absent (pre-#361 state — every container's `parent` is
None) the tree collapses to a flat list with no glyphs and no indent
— bit-identical to the legacy render.
## Tree shape
- Roots: containers whose `parent` is None OR whose parent name isn't
in the current container set (orphan tolerance).
- Sibling ordering: alphabetical by name within each level (matches
damocles' wire spec at #363#issuecomment-3356).
- Cycle safety: any container not reached via the root-walk gets
emitted as a root at the end — no agent ever silently disappears
from the list when the topology is malformed.
- Tree glyphs: ancestor at depth d contributes a │ continuation column
when that ancestor has more siblings below; otherwise a 3-space gap.
The joint is ├─ for non-last siblings, └─ for the last child.
- Depth-0 ancestor column is suppressed: roots already separate
visually as top-level rows, no need for a column 0 vertical line.
## DOM / CSS
- New `buildAgentTree(containers)` + `treePrefix(node)` helpers in
app.js. The render loop walks the tree-ordered list instead of the
legacy alphabetical containers array.
- Each container row gets `data-depth=N` (only when N > 0) and a
`<span class="tree-prefix">…</span>` prepended (absolute-positioned
into the row's left margin so the existing flex icon/body layout
isn't disrupted).
- CSS: per-depth `margin-left` step rules for depths 1-6 (hardcoded
rather than typed-attr() because CSS Values 5 is Chromium-only as
of 2026). 6 depths cover any reasonable hive topology with
headroom; deeper agents render at depth 6 indent without further
step — visually clamps gracefully.
- `.tree-prefix` rendered with `var(--purple-dim)` so the structural
lines read as supporting chrome, not as content.
## Validation
- `npm run build` clean. Bundle deltas: dashboard app.js
+1.8kb (tree builder + treePrefix + render-loop tweak),
dashboard.css +0.4kb (tree-prefix + per-depth indent rules).
- The render is a no-op until `ContainerView.parent` is populated —
validation in production deferred to once damocles' meta-topology
field lands. The pre-#361 path (every parent=None) is exercised
by every existing dashboard load.
- Forward-compatible with damocles' design pivot at #364 (topology
source moved from agent.nix to meta/topology.json). The wire shape
on ContainerView is unchanged from the frontend's perspective — the
field is just sourced from a different backend store.
The src/index.html / src/stats.html files reference assets at URLs
like /static/app.js, /static/dashboard.css. The initial Phase 1 build
flattened everything to dist/{app.js, dashboard.css, ...} which would
have forced the Phase 4 Rust ServeDir mount to do URL rewriting just
to make the existing HTML references resolve.
Rework: bundles now write to dist/static/, HTML stays at dist/ top
level. Layout matches the URLs the HTML uses, so the Phase 4 mount
is the simplest possible `fallback_service(ServeDir::new(dist))`.
No source-file changes — just the esbuild outfile/outdir paths.
Rebuilt; verified asset filenames + sizes unchanged.
Refs #273.
Phase 1 of the backend/frontend code split (#273). Additive — no
existing code is touched; the legacy hive-c0re/assets, hive-ag3nt/
assets and hive-fr0nt/assets trees stay in place until the Rust
cutover later in this branch.
Layout:
frontend/package.json npm workspaces root
frontend/packages/shared/ @hive/shared
src/{base,terminal}.css + terminal.js (ES module)
src/index.js re-exports terminal.js
frontend/packages/dashboard/ @hive/dashboard
src/{index.html, app.js, dashboard.css} ported from hive-c0re/assets
build.mjs esbuild config → dist/
frontend/packages/agent/ @hive/agent
src/{index,stats,screen}.html + agent.css
+ {app,stats}.js ported from hive-ag3nt/assets
build.mjs esbuild config → dist/
Changes vs the existing assets:
- terminal.js is an ES module exporting { create, linkify } instead
of assigning to window.HiveTerminal. The dashboard / agent app.js
files re-expose them on window so the IIFE bodies keep working
unchanged through Phase 1; the global aliases can be dropped in a
follow-up once the IIFEs are unwrapped.
- marked is imported from the marked@4.3.0 npm package (replacing
the vendored hive-fr0nt/assets/marked.umd.js bundle).
- chart.js is imported from chart.js@4.4.4 (replacing the jsDelivr
CDN script tag on the per-agent stats page — page now works
offline / on operator machines without internet egress).
- dashboard.css and agent.css both gain @import lines at the top
that pull base.css + terminal.css from @hive/shared, replacing
the runtime string concatenation in serve_css.
- index.html / stats.html collapse from three / two script tags to
one type="module" tag pointing at the bundled output.
package-lock.json is intentionally omitted from this commit — npm
isn't available in the iris container yet (approval pending) and the
lockfile will land in the next commit on this branch once the
toolchain is in place. The PR will not be opened until it's there.
Phase 2 (nix derivations), Phase 3 (container plumbing + the
hyperhive.frontend.extraFiles option for per-agent layering), and
Phase 4 (Rust cutover to tower_http::ServeDir, delete hive-fr0nt
+ legacy assets dirs) land as follow-up commits on this same
branch.
Refs #273.