The N3W SCH3DUL3 vertical form is gone. The schedules table now
carries an always-visible inline create row at the bottom — fill the
cells, click + to POST /api/schedules, the new schedule appears
above on the next refresh.
Per-column inputs match the display semantics:
- `next` → `<input type="datetime-local">` (defaults to now+5min)
- `every` → 4 mini d/h/m/s number inputs (blank/all-zero = one-shot)
- `body` → `<textarea rows=1>` expanding to 4em on focus-within
so multi-line prompts still fit (closes mara's "prompt may still be
multi-line" requirement)
- per-agent columns → checkbox (whole cell clickable via padded label)
- actions → + submit + ⌫ reset (clears the carry without POSTing)
`newScheduleCarry` module-scope object survives the paintAtomic
re-render so mid-typed values stick across schedule-list refreshes
(same pattern as `scheduleEditCarry` from #474).
H2 collapses from "N3W SCH3DUL3" + "QU3U3D SCH3DUL3S" down to a
single "SCH3DUL3S" since the section now does both. Empty-state
branch on `renderSchedulesList` is gone — the table always renders
because the create row is always there.
`#schedule-new-section` div + `renderScheduleNewForm` /
`readScheduleFormCarry` / `submitNewSchedule` and the
`.schedule-new-form` CSS selector all removed. The edit-form path
(#474 `renderScheduleEditForm` colspan'd row on `✎` toggle) is
unchanged — "edit mode is inline in the table" already shipped
there, this PR just adds the same mental model for create.
Validations match the old form: empty targets / empty body /
invalid datetime / non-integer interval each surface an `alert()`.
The + button shows a spinner while the POST is in flight, then
clears the carry + triggers `refreshSchedules()`.
Backend route POST /api/rebuild-queue/{id}/cancel already exists
(refuses Running / terminal entries with {cancelled: false}). This
adds the operator-facing affordance:
- small circular X button on the right edge of each row whose
state === 'queued'. Running / done / failed rows don't render
it, so the operator never clicks a button that the backend
would refuse.
- uses the same data-async + data-confirm pattern as the
reminder cancel form — global submit handler does POST +
spinner + error toast for free.
- successful cancel flips the row queued -> cancelled via the
live RebuildQueueChanged snapshot, so the button disappears
on the next paint without an explicit refresh.
CSS keeps it quiet by default (muted border, transparent
background) and lights red on hover / focus, matching the
.btn-deny family without claiming a full button-width slot
that would push the row layout around.
The journald viewer opens in the side panel, but the <pre> only takes
the height of its content — so a short log fetch leaves the bottom 80%
of the panel empty, and a long one pushes the controls past the
viewport. Wrap the body in a column-flex container that fills the
side-panel-body, keep controls fixed at the top, and let the <pre>
flex-grow into the rest of the panel with its own overflow:
.journal-body { display: flex; flex-direction: column; height: 100%; }
.journal-controls { flex: 0 0 auto; }
.journal-output { flex: 1 1 0; min-height: 0; overflow: auto; }
`min-height: 0` is the canonical "let me actually flex-shrink for
overflow" escape hatch on flex children. Also moved the auto-scroll
target from side-panel-body onto the <pre> itself — the panel-body no
longer overflows now that journal-body fills it; the <pre> is the new
scroll surface.
Pure CSS + one JS line; no DOM shape change.
Replaces the per-schedule card layout with one table:
| # | src | next | every | owner | body | …agents… | actions |
|---|-----|------|-------|-------|------|----------|---------|
| 5 | op | 5m | 10m | mara | "…" | ✓ ✓ . ✓ | ↯ ✎ ✕ |
Each schedule is one <tr> in the tbody. Agent columns are dynamic —
operator + manager + live containers + any "extra" name that appears
as a target on some schedule but isn't a current container (same
membership rule buildTargetChips uses, so the table + new/edit forms
agree on what's addressable). Per-agent cells:
- active target → <button>✓</button> that cancels just that target
on click (replaces the per-row ✕ from the old targets sub-table)
- cancelled target → muted ✕ glyph (no button; re-add flows through
the edit form's targets multi-select)
- not a target → empty cell
Agent column headers tilt -45° via CSS so each column reads as 28px
of horizontal real estate instead of the full word width. Standard
rotated-header pattern: 95px-tall <th> with position:relative, inner
<div> positioned absolute at bottom-left, transform rotates about
left-bottom.
Body cell truncates with ellipsis + full-text title. Description used
to be a separate visible block on the card layout; the table folds
it into the body cell's title to keep row height tight. If mara wants
description visible in-table it's a small follow-up — easier to
iterate on a rejection.
Edit form expands inline into a colspan'd row underneath the schedule
row it edits (instead of inside the card). Wrapper drops the form's
background so it reads as a row extension.
No backend changes; everything renders from existing schedulesState
+ containersState.
argus 🟡 on #504 — `font-variant-numeric: tabular-nums` has no
effect on `.rqe-step` since step strings are text-only
("nixos-container update" / "meta prepare_deploy" / etc.),
no digit columns to align. Harmless but cleaner without it.
Frontend half of #437 — paired with damocles's #501 backend which
adds `step: Option<String>` to `QueueEntry` and annotates 9
worker phases across ApplyCommit / Spawn / MetaUpdate / Rebuild
pipelines.
When `entry.step` is present, render a cyan `↳ <step>` sub-line
below the main row in `renderQueueEntry`. Wraps to its own flex
line via `flex-basis: 100%`, indented 1.8em to align under the
state glyph + kind. Terminal transitions clear `step` on the
backend, so the sub-line just disappears on Done / Failed rows —
no client-side staleness handling needed.
CSS: `.rqe-step` keys off cyan to visually group with the
running-spinner color cue (vs the muted `.rqe-when` /
`.rqe-reason` chips). `tabular-nums` keeps the indented arrow
column stable as step strings change length mid-build.
docs/web-ui.md updated to describe the new sub-line behaviour
in the R3BU1LD QU3U3 section.
#406 was the JS-split refactor: pull shared helpers into
common.js (step 1), pull the flow-only IIFEs into flow.js
(step 2), then rename the legacy combined entry from app.js
to tabs.js (step 3 — this commit) to reflect that the bundle
now owns the dashboard tabs surface only.
What moved:
- `frontend/packages/dashboard/src/app.js` → `tabs.js`
- `build.mjs` entry: `src('app.js')` → `src('tabs.js')`;
output is now `static/tabs.js`
- `index.html` `<script>` src: `/static/app.js` →
`/static/tabs.js`; the FL0W-section comment retouched
- `flow.html` reference from `/static/app.js`-as-tabs to
`/static/tabs.js`; notif + offscreen-inbox comments rewired
to point at the correct owners (common.js for NOTIF,
flow.js for renderInbox)
- `common.js`, `flow.js`, `tabs.js` headers: scrub stale
"app.js" references; document that #406 steps 2 + 3 are
done and both pages import directly from common.js
- `dashboard.css` comments: every "by app.js" → "by tabs.js"
- `docs/web-ui.md`, `docs/conventions.md`: legacy
`assets/app.js` → `assets/tabs.js` (the path prefix is
itself stale from a much-earlier rename, but consistent
with the rest of those docs)
- `CLAUDE.md` file map: refresh the dashboard package's
src/ and dist/ layouts to reflect the post-split shape
(tabs.js, flow.js, common.js, stream-worker.js)
What did NOT move:
- `frontend/packages/agent/src/app.js` (per-agent UI entry)
stays named `app.js` — it's a separate package, has only
one entry point, no split happened there
- The two "no-op when target absent" guards in renderContainers
and renderQuestions are kept as belt-and-suspenders for any
future page that adds tabs.js without the corresponding
sections; comments updated to note this rather than the
pre-split flow.html reason
Functional behaviour identical; this is a pure rename + comment
sweep.
mara: "the 'timestamp from -> to' part is long enough to warrant
its own line. then the actual messages can be rendered (nearly)
full width."
Confirmed in the layout — previously `.live .msgrow .msg-body`
sat inline with `flex: 1 1 0`, eating whatever the chips left.
With a 14:23:42 timestamp + agent names + arrows that was ~30ch
of prefix; long bodies wrapped awkwardly.
One-line CSS fix: `flex: 1 1 100%` on `.msg-body` forces it to
wrap to its own flex line in the existing `flex-wrap: wrap`
container. Metadata chips stay on the row above; body takes the
full width down to the row's content edge.
`min-width: 0` retained so `word-break: break-word` keeps
working. Reply rows keep their `padding-left: 1.2em` border-left
indent — the body lands within that frame, matching the visual
"this is a reply" nesting.
Out of scope but worth knowing:
- consecutive-message grouping (one header per agent run) was
the second design option I floated; happy to land it as a
follow-up if reading still feels chatty.
- timestamp-on-hover was the third; skipping unless asked.
damocles's #472 returns `{ ok, failed, missing, one_shot_consumed }`
from the fire-now endpoint. Parse the response and flash the
per-target outcome on the button itself for ~1.5s before
refreshSchedules() repaints — operator sees the result
immediately without a modal alert or an `/api/schedules`
re-fetch round-trip.
Button label transitions:
↯ fire now → ◐ firing… → ↯ fired: 3 ok, 1 missing — consumed
(green flash, then row refresh)
Operator wants to trigger a scheduled prompt immediately
instead of waiting for the next interval. Adds a `↯ fire now`
button on every active schedule row, next to `✕ cancel all`.
Semantics (per design discussion with damocles):
- recurring schedules → out-of-band pulse, `next_fire_at_unix`
untouched; the regular cadence keeps firing on the original
schedule. Operator gets an extra fan-out, not a phase shift.
- one-shots → consumed after the manual fire. Operator's
intent reads as "the scheduled time was wrong, send NOW";
leaving the original time would be surprising.
Confirm dialog spells out the recurring-vs-one-shot
difference up front so the operator knows what they're about
to do. Button is disabled when every target is already
cancelled (nothing to fire).
Talks to `POST /api/schedules/{id}/fire-now` (damocles is
wiring the backend in parallel). Mauve styling distinguishes
it from cancel (red) and submit (amber); fits the existing
btn pattern.
docs/web-ui.md updated.
Per damocles's PATCH /api/schedules/{id} backend (#475), each
non-cancelled schedule row gets a `✎ edit` button that toggles
an inline form pre-filled with current values. Editable:
- body (textarea)
- description (blank to clear)
- next-fire (datetime-local)
- interval (shared composer from #466 — all-zero flips to one-shot)
Targets stay immutable per the design call with damocles:
per-target last-result history is keyed on them; cancel + new
schedule is the documented retarget workaround. The form
surfaces the active target list as a read-only note explaining
this.
Submit semantics — the form computes a PATCH diff against the
original schedule and only includes keys for fields that
actually changed. Blank description → `null` (clear), all-zero
interval on a recurring schedule → `null` (flip to one-shot).
No-op submit (no fields changed) just closes the edit form.
Refactored the interval composer (#466) into a shared
`buildIntervalComposer({ label, namePrefix, initialSeconds })`
helper so the new-schedule and edit-schedule forms use the same
chip/d/h/m/s/preview widget. New-schedule form behavior
unchanged; edit-form input names are prefixed `edit_interval_`
to avoid FormData collisions when both happen to mount.
State survives state-poll re-renders via `editingSchedules`
(Set of ids being edited) + `scheduleEditCarry` (per-id
mid-edit values) — same pattern the new-schedule form uses
with `readScheduleFormCarry`.
Replaces the single raw-seconds field in the SCH3DUL3D PR0MPTS
creation form with a structured composer:
- Preset chip row (1m / 5m / 15m / 30m / 1h / 6h / 12h / 1d / 7d
/ one-shot) that fills the parts inputs in one click
- Four small d/h/m/s number inputs combined into total seconds
on submit (all-zero = one-shot, preserving the backend null
semantic)
- Live "↻ every …" preview using the existing fmtDuration helper
so the operator sees what they're about to queue
Carry semantics extended to round-trip the split fields across
state-poll re-renders; legacy `interval` carry still honoured if
an older bundle is in the page.
Docs (web-ui.md) updated to describe the new composer.
frontend for the #444 scheduled-prompts feature. backend is
already merged (PR #454 + sibling commits): GET /api/schedules
(snapshot), POST /api/schedules (operator-direct submit), POST
/api/schedules/{id}/cancel (whole or per-target).
new chrome
- SCH3DUL3S tab in the dashboard tab strip, between SYST3M and
the FL0W link. count pill shows the number of schedules with
at least one still-active target.
- pane has two sections: N3W SCH3DUL3 (creation form) +
QU3U3D SCH3DUL3S (list of cards).
creation form
- targets multi-select rendered as chip-style checkboxes;
candidates pulled from containersState plus the special
`operator` and `manager` recipients.
- prompt body textarea (required, non-empty trim).
- first-fire datetime-local input, defaulted to "5 minutes from
now" so the form has a sensible pre-filled future timestamp.
- optional interval (seconds) input — blank = one-shot.
- optional description (one-liner shown on the schedule card).
- mid-typing carry: re-rendering the form preserves field values
+ checkbox state. the operator never loses what they were
typing when the schedule list refreshes underneath them.
- POSTs SchedulePromptPayload JSON to /api/schedules; on success
re-fetches the list to surface the new row.
schedules list
- one card per schedule, active rows first (sorted by
next_fire_at_unix), cancelled tail dimmed.
- header: id, source chip (`operator` or `approval` — reuses
the rebuild-queue rqe-source styling for visual consistency),
next-fire countdown / overdue label, recurring vs one-shot
badge, owner.
- body: prompt text in a styled <pre>-ish block with linkified
path references.
- per-target table: target name, last fire age, last result,
per-row cancel button.
- whole-schedule "✕ cancel all" button.
- per-target cancel posts { targets: ["name"] }; cancel-all
posts no body (== cancel whole row).
no-SSE refresh
- the backend doesn't emit SchedulesChanged dashboard events
yet (damocles flagged this as a follow-up PR C). list
re-fetches on:
- tab activation (so switching to SCH3DUL3S never lands stale)
- cold load via refreshState
- after every submit + cancel POST
the operator's typical interactions all force a refresh; the
remaining gap (worker fires while you're staring at the tab)
is the natural argument for PR C.
files
- frontend/packages/dashboard/src/index.html — new tab + pane
with the two sections.
- frontend/packages/dashboard/src/app.js — schedulesState
cache, refreshSchedules, render*ScheduleNewForm, submit +
cancel helpers, tab routing extended for `schedules`, count
pill wired into refreshTabCounts.
- frontend/packages/dashboard/src/dashboard.css — schedule
form + card styling + chip checkboxes + targets table. small
.btn-inline-small helper for the per-target cancel.
validation
- npm run build --workspace=@hive/dashboard clean. app.js
158 kb → 161 kb. CSS 41.3 kb → 43.9 kb.
- browser smoke test isn't possible from inside iris's container;
endpoints are wire-compatible (backend types unchanged) and
the form serialisation matches SchedulePromptPayload's JSON
shape exactly (targets / body / first_fire_at_unix /
interval_seconds / description).
argus nit on #456: the CSS comment claimed "The body adds
data-spw=<px> once persisted" — that's leftover from a prior
design and no such attribute is ever set. The actual flow is
localStorage → JS reads on Panel.bind → sets --side-panel-w via
inline style. Replaced the misleading line with an accurate
description of the persistence path.
two related flyout improvements — both small, single PR.
#450 — full-height inbox
dropped the .inbox max-height: 24em cap. the side-panel-body
already provides overflow: auto, so the cap was just clamping the
inbox list short of the available panel height on tall viewports.
the inbox now fills as much of the panel as it needs and the
panel itself scrolls.
#451 — drag-to-resize side panel
added a 6px hit-strip on the drawer's left edge. mousedown/move/
up handlers track the drag and update --side-panel-w on the
drawer; the CSS variable defaults to min(760px, 94vw) (preserving
pre-#451 behaviour) and is clamped to [320px, 96vw] so a bad
stored value can never wedge the drawer off-screen.
ergonomics
- handle is invisible at rest, mauve glow on hover + during drag
so the affordance is discoverable but doesn't compete with the
2px mauve border-left for the visual boundary.
- body.side-panel-resizing class forces ew-resize cursor + kills
user-select page-wide so the cursor doesn't flip back to
default the moment it leaves the 6px band during a fast drag.
- final width persists to localStorage (key
hyperhive:side-panel-width) so it survives reload. window
resize re-clamps so a stored width that exceeds the new 96vw
shrinks accordingly.
- handle is a separator role with aria-orientation: vertical +
aria-label for screen readers.
files
- frontend/packages/dashboard/src/dashboard.css
- .inbox: dropped max-height (#450).
- .side-panel-drawer: width = var(--side-panel-w, min(760px,
94vw)) + min/max clamp (#451).
- new .side-panel-resize + body.side-panel-resizing rules.
- frontend/packages/dashboard/src/common.js
- Panel.bind now also calls applyStoredWidth + bindResize.
- resize handle is prepended to the drawer at bind time so
every flyout (inbox, file preview, diff, journal) gets it.
validation
- npm run build --workspace=@hive/dashboard clean. CSS 40.9kb →
41.3kb. app.js + flow.js grew ~0.5kb each (resize handler).
- browser smoke test isn't possible from inside iris's container;
the resize math is straightforward (width = innerWidth -
clientX, clamped) and the CSS variable + localStorage
persistence are standard patterns.
per mara on #443: "dont show all the restart buttons etc., just
show state and links. instead, clicking an agent icon selects that
agent. you can select as many as you like. then you can run an
action on all of them."
selection model
- module-level selectionState = Set<string> of agent logical
names. clicking a container-row icon toggles membership; Esc
clears the whole set (ignored when an editable element has
focus so typing in compose / answer / journal-search isn't
intercepted).
- icon now has role="button" + tabindex="0" so it's
keyboard-accessible; aria-pressed reflects the toggle state.
hover + focus-visible get a mauve ring.
- selected rows get a .selected class — mauve outline +
faint mauve wash on the row, brighter ring on the icon.
- on every renderContainers pass, stale selections (agents
destroyed while selected) are pruned defensively.
sticky action bar
- new #selection-bar in index.html — fixed bottom strip with
count chip, name list, action buttons, clear button. hidden
when selection empty. styled to match the flow composer's
frosted-mauve chrome (vibecore family).
- per mara's option B answer: every action button is visible;
buttons that don't apply to the whole selection are disabled
with a tooltip explaining WHY ("iris is already stopped" etc).
.btn:disabled styling added.
- actions: R3ST4RT / ST0P / ST4RT / R3BU1LD / DESTR0Y / PURG3.
per-action confirm prompt lists the names being acted on.
when the selection includes the manager the ST0P prompt calls
out the consequence (approvals + meta operations pause until
manager is restarted). DESTR0Y/PURG3 stay sub-agent-only;
including the manager disables them with a clear tooltip.
- actions POST per agent in a loop to the existing endpoints
(/restart/{name}, /kill/{name}, etc.); no new backend wire
surface. event-driven derived stores (containersState,
rebuildQueueState) already update live via SSE — no manual
refetch.
- failures from any individual POST surface in a single alert
at the end rather than spamming dialogs mid-loop.
per-card actions removed
- the in-card R3ST4RT / ST0P / ST4RT / R3BU1LD / DESTR0Y / PURG3
block is gone. cards now show identity / state / nav-strip /
status text / drill-ins only — "state and links" per mara.
the contextual needs-update chip in the head row stays.
manager stop
- the "also make manager stoppable" half of #443 ships as PR
#445 (separate small backend change). this PR depends on #445
for the bulk-stop button to actually work on selections
containing the manager; until #445 merges, ST0P on a
manager-included selection will fail individually with a 500
for the manager (other selected agents are still stopped;
failures bucket into the end-of-loop alert).
build clean: npm run build --workspace=@hive/dashboard. no
backend changes here.
Mara: remove the 90em max-width so the dashboard fills wide
screens instead of boxing content into a centred column with
empty margins. `padding: 0 1.5em 1.5em` stays as the small
edge-of-viewport gutter; `.dashboard-chrome { margin: 0 -1.5em
... }` continues to pull the sticky chrome bar edge-to-edge
through that gutter.
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.
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.
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.
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.
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.