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.
Sibling to ensure_core_avatar (#320). Same one-shot marker-guarded
upload pattern, this time aimed at the Forgejo per-org avatar
endpoint (`POST /api/v1/orgs/{org}/avatar`).
## SVG-to-PNG at build time
Mara: *don't check in the png. instead generate png on the fly or
in build.*
`hive-c0re/build.rs` renders `branding/agent-configs.svg` →
`$OUT_DIR/agent-configs.png` via `rsvg-convert` (librsvg) on every
compile; `forge.rs` then `include_bytes!`s the OUT_DIR PNG. The
raster never gets checked into git — SVG stays source of truth,
the PNG is a build artifact.
- `hive-c0re/Cargo.toml`: declares `build = "build.rs"`
- `flake.nix`: adds `librsvg` to `naersk-lib.buildPackage`
`nativeBuildInputs` (covers both the runtime package and the
clippy check derivation) and to the dev shell so local
`cargo build` finds `rsvg-convert` on PATH.
- For dev builds outside Nix, install librsvg (Debian:
`librsvg2-bin`, macOS: `brew install librsvg`).
## Icon design
Sibling visual to the main hyperhive mark — same dark base + outer
ring + corner-bracket frame so the family reads at a glance. Centre
swaps the hexagonal hive for a stacked-config-files motif: three
offset sheets, folded-corner affordance, curly-brace `{ }` glyph
telegraphing "config file."
Brace font-size dropped 78→56 + letter-spacing -3 (#424 mara:
"braces cross the boundaries of the page") so the glyphs sit
comfortably inside the 120-wide front sheet with clear breathing
room on the left/right edges. The stack is shifted so the front
sheet centres on canvas-(150, 150); brace text anchors there with
`dominant-baseline=central` for true vertical centring.
## Validation
- `cargo check` clean (only pre-existing warnings).
- One-shot marker honoured: re-runs of `ensure_all` skip after the
first success; `rm /var/lib/hyperhive/forge-agent-configs-avatar-set`
forces re-upload (useful for icon revisions).
- Behaviour mirrors the existing `ensure_core_avatar` pattern.
Browser smoke test isn't possible from inside iris's container.
Worth eyeballing post-deploy: `http://localhost:3000/agent-configs`
should show the new avatar where the default identicon used to be.
per mara's review on #433, move the gating from the dashboard into
the host so a stopped container's stale on-disk state (rate_limited
sentinel, hyperhive-needs-login, last-turn-stats row, status blob)
never reaches the wire in the first place. when build_all sees
is_running == false:
- needs_login → false
- ctx_tokens / context_window_tokens → None
- rate_limited → false
- status_text / status_set_at → None
static / declared fields (extra_links, deployed_sha,
pending_reminders, needs_update, parent) stay populated regardless
of run state.
extend AgentMeta (both AgentResponse + ManagerResponse) with a
`running: bool` field so get_agent_meta callers can tell whether
the target is up — answers the second half of #432 ("agent meta
should probably show the info that it is not running as well").
read_agent_status_live wraps the existing read_agent_status with
the same is_running gate so the manager/agent socket handlers don't
have to know about sentinel semantics.
format_agent_meta now prints a `running: yes|no` line so claude
sees the run state in plain text alongside hyperhive_rev.
frontend follow-up in the same commit: drop the redundant
`c.running &&` guards on ctx_tokens / status_text in
renderContainers — the backend now guarantees those fields are
absent when the container is stopped, so the existing
truthy-check is sufficient. the `■ not running` badge + icon /
links fetch short-circuits stay (those are pure presentation /
network-noise wins the backend can't address).
Mara: *don't show agent status if container is shut down. agent
meta should probably show the info that it is not running as well*.
When `c.running` is false the harness isn't there to report state,
but the dashboard kept rendering everything that depends on it —
alive-badge, rate-limited / needs-login chips, ctx token chip,
self-reported status text, the nav-strip fetched from
`/api/agent/{name}/links`. All of it goes stale the moment the
container shuts down; the operator sees data that hasn't been true
for hours.
Two changes in `renderContainers`:
1. Replace the live-only badge chain with a single
`■ not running` muted badge when stopped. Pending transients
still win (a start / stop in flight gets the spinner). Static
info — needs-update, `container :port` meta, `deployed:sha`,
pending-reminder count — stays visible regardless of running
state.
2. Skip the nav-strip fetch + the self-reported status text on
stopped containers. The fetch would fail anyway (container
web server is down); the status text was last set when the
harness was alive and isn't current.
Also short-circuit the agent-icon img: skip the doomed `${url}icon`
request and go straight to the dimmed `/favicon.svg` fallback.
Avoids a noisy failed request in the console + the brief
broken-image flash.
No CSS changes — reuses the existing `.badge-muted` style.
Mara: *can we make this really fast to not be a problem? like its
okay if it is pretty fast*.
`scrollTo({ behavior: 'smooth' })` runs ~500ms in Chromium/Firefox
— "still smooth, but visibly slow." Swap it for a custom rAF loop
with an ease-out cubic over 140ms. Distances under 24px short-
circuit to instant — animating a 12px nudge is just jitter.
Each call cancels the previous rAF before starting a new one, so a
burst of mutations coalesces into one ride to the latest bottom
instead of two animations fighting over scrollTop.
Re-reads `scrollHeight - clientHeight` each frame so a renderer
mutation landing mid-animation (the common case — `api.row`
returned, then renderer appended badge + body) extends the
destination smoothly rather than landing short.
`smoothScrollingUntil` gate shrinks accordingly (140 + 80 = 220ms)
— still protects the scroll handler from flipping stickToBottom
on the intermediate scroll events the rAF fires.
Mara: "New scroll down behavior works, but jumps instead of scroll."
The autoscroll path in `afterAppend` + the MutationObserver re-snap
+ the tail-pill click handler all set `log.scrollTop = log.scrollHeight`
— instant jump. Reads as jerky on long mutations where the row
height grew a lot between the initial append and the body fill.
Switch to `log.scrollTo({ top: log.scrollHeight, behavior: 'smooth' })`
via a new `snapToBottom()` helper. All three call sites (afterAppend,
MO callback, pill click) route through it.
## Smooth-scroll vs `stickToBottom` flicker
`behavior: 'smooth'` fires a stream of scroll events as the position
eases toward the target. Without guarding, the scroll handler reads
the intermediate position, fails `isNearBottom()`, flips
`stickToBottom` to false — the next MO callback then skips the snap
and strands the operator mid-scroll.
Add a `smoothScrollingUntil` timestamp gate: every `snapToBottom()`
call (without `immediate`) re-arms it to `now + 800ms` (~Chromium /
Firefox smooth-scroll animation duration + headroom). The scroll
handler ignores events while the gate is active. Bursts of writes
coalesce into one smooth ride to the latest bottom rather than a
sequence of half-cancelled animations.
## Backfill replay
`currentNoAnim` is true during history backfill — the operator never
sees the intermediate positions there, so smooth scroll is just
wasted animation. `snapToBottom(immediate=true)` (and the noAnim
branch in afterAppend) falls back to instant scroll for that path.
Same shared `@hive/shared/terminal.js` is used by the dashboard +
per-agent terminal + flow page; all three inherit the change.
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.
Pre-step-2, the SSE subscription on /index.html was conditionally
created by the broker-terminal IIFE that only ran on /flow.html
(via the `if (!flow || !window.HiveTerminal) return;` guard) — so
the dashboard only updated on cold load + after async-form submits.
The split in step 2 made the gap more obvious; close it.
Bare `EventSource` (the dashboard doesn't render broker rows, so
none of the HiveTerminal infrastructure is wanted here). Each
event's `kind` looks up against a `MUTATION_HANDLERS` table that
fans out to the existing `applyXxx` handlers. Unknown kinds (broker
`sent` / `delivered`, anything the backend adds later) silently
no-op. On (re)connect we kick `refreshState()` to recover events
lost during the disconnect window — same pattern as flow.js's
`onStreamOpen`. EventSource handles auto-reconnect.
#408 will give /index.html its own stream that omits broker
traffic; for now both pages subscribe to `/dashboard/stream` and
filter client-side.
Closes the #406 architectural roadmap:
- step 1 (#410): common.js extraction ✓
- step 2 (#412): split into app.js + flow.js entry points ✓
- step 3 (this PR): /index.html re-acquires its SSE subscription
Two regressions from #394 that landed once mara deployed:
1. **Icon way too big** — `.agent-icon { height: 100%; aspect-ratio: 1 }`
sized off the parent's height. With `align-items: stretch` on the
header and `min-height: 6em`, the `<img>`'s intrinsic dimensions
fed back into the flex container's height calculation and pushed
the header (and the icon stretched to fill it) far past 6em — the
icon ended up dominating the page.
Switch to explicit `width: 5em; height: 5em` (header content area
= 6em min-h - 2 × 0.5em padding). `align-self: flex-start` so a
state-row wrap doesn't drag the icon down with it.
2. **Overflow menu always visible** — `.overflow-menu { display: flex }`
in author CSS overrode the `[hidden]` UA rule (`[hidden]
{ display: none }` has the same specificity). With nothing hiding
it, the menu rendered at viewport 0,0 by default (no top/left set
on the popover until JS opens it), so the operator saw `↑ dashboard
/ ↻ rebuild container / ↻ new claude session` stacked at the
top-left of every page load.
Split the rule: `.overflow-menu` keeps the chrome (frosted bg,
position fixed, z-index, border, padding); `.overflow-menu:not([hidden])`
carries `display: flex`. Now `[hidden]` wins when set, no menu
shown until the trigger opens it.
agent.css 22.4kb → 22.5kb.
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).
After moving `↑ DASHB04RD` / `↻ R3BU1LD` / `↻ new session` into
the overflow `⋯` menu in #394, the per-button rules in agent.css
have no live consumer:
- `.btn-dashlink` (cyan back-link chip)
- `.btn-rebuild` (amber rebuild chip — note: the dashboard has
its own `.btn-rebuild` for the per-row R3BU1LD form on the
SW4RM tab, that stays)
- `.btn-new-session` (amber round-pill button)
- `.btn-send` (green send-button variant — orphaned since an
earlier compose-form retirement, swept here)
Plus a comment-only `#state-row` block (layout fully provided by
the renamed `.agent-state-row` / `.agent-header-row` classes).
Each removed block is replaced with a one-line "moved to overflow"
comment so future readers don't reintroduce the old chrome by
mistake.
agent.css 23.6kb → 22.4kb.
Operator brief (#394): the header had thirteen distinct visual
elements in one flex row with three different border-radius
languages, four colour treatments, three label styles. Mara's
direction:
- agent icon bigger (full header height) as the identity anchor
- title glow stays
- nav links lose their default-anchor underline
- overflow `⋯` absorbs `↻ R3BU1LD` + `↻ new session` (rare,
destructive — both worth one extra click; rebuild is normally
done from the dashboard)
- accent stacking in the state strip stays — that's the vibe
## Layout shape
Three flex columns in `.agent-header`:
[icon · full height] [main column · 2 rows] [pills + ⋯]
The main column carries row 1 (`◆ AGENT ◆` title + meta-nav) on
top and row 2 (alive · state · model · ctx · cost · last-turn ·
cancel-turn) below.
## Changes
### `index.html`
- Wrap title + nav in `.agent-header-row .agent-header-title-row`;
wrap state-row siblings in `.agent-header-row .agent-state-row`;
both go inside a new `.agent-header-main` column.
- Right cluster `.agent-header-pills` contains the inbox + loose
pills + a new `<button id="overflow-btn">⋯</button>` trigger.
- Drop static `#new-session-btn` from `#state-row` — moved into the
overflow menu, populated dynamically.
- Add `<div id="overflow-menu" role="menu" hidden>` as a sibling
of `<header>` (lives outside the header so its `position: fixed`
popover isn't trapped by any header stacking context).
### `agent.css`
- `--agent-header-h: 4.6em → 6em` so the icon can be square + full
height without crowding the two-row main column. Terminal
padding-top + status overlay top + tail-pill all derive from
this variable, so they follow automatically.
- `.agent-header { align-items: stretch }` lets the icon stretch
to full height; `.agent-icon { height: 100%; aspect-ratio: 1 }`
sizes it as a square off the stretched height.
- `.agent-nav-link` rule added — `text-decoration: none`, cyan +
soft glow, hover lights brighter (mara's spec).
- `.overflow-btn` (round trigger) + `.overflow-menu` (frosted
popover, fixed-position) + `.overflow-item` (rows with an icon
column + label, hover ink matches per-action accent — cyan for
dashboard, amber for rebuild/new-session).
- Remove the old `#state-row` selector (layout now provided by
`.agent-state-row` + `.agent-header-row`).
### `app.js`
- `setHeader` no longer appends DASHB04RD / R3BU1LD chips into the
title — title is just the identity glyph now. Both actions get
rendered into the overflow menu by `populateOverflowMenu()`.
- `populateOverflowMenu(label, dashUrl)` builds three rows:
`↑ dashboard` (anchor), `↻ rebuild container` (button — same
POST-form action as before), `↻ new claude session` (button —
same `/api/new-session` call as the legacy header button).
- Overflow toggle / outside-click / Escape dismissal — same
pattern as the side-panel flyout (`Panel`).
- Drop the static `new-session-btn` IIFE binder; the dynamically-
rendered menu item owns its handler now.
- Drop the per-nav-link inline `marginLeft` (layout gap comes from
the new `.agent-nav { gap }` rule).
## Validation
- `npm run build` clean.
- Build deltas: agent.css 21.0kb → 23.6kb (overflow + nav rules
+ comments), app.js 117.4kb → 118.9kb (menu builder + toggle).
- Browser smoke test isn't possible from inside iris's container.
Worth eyeballing post-deploy:
- Icon fills the full header height as a square
- Title glow + uppercase styling preserved
- Nav links render without underline; hover lights brighter
- `⋯` opens a frosted popover with `↑ dashboard`, `↻ rebuild
container`, `↻ new claude session`
- Rebuild confirm + POST works the same as the legacy chip
- New-session confirm + POST works the same as the legacy button
- State strip still wraps when crowded (model/ctx/cost
multi-line on narrow viewports)
- Cancel-turn button still appears while thinking and clears
on turn end
- Terminal padding-top adjusts to the new 6em header height
(no row hidden under the chrome)
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.
#375 captured `wasNearBottom` BEFORE the row's initial append so
the autoscroll wouldn't be tricked by the new row's own height.
That's still right, but it leaves a second gap: renderers commonly
call `api.row(cls, text)` to build the shell, then mutate the
returned element by appending more children (badges, multi-line
turn bodies, tool-result panes). The initial scrollTop assignment
in afterAppend only sees the row's INITIAL height — once the
renderer adds the body, the row's grown past the visible bottom
and the operator's stranded scrolled to the row's TOP, breaking
stick-to-bottom for every subsequent event.
Mara's symptom: two lines of text appear, the view scrolls only
one row, the terminal isn't at the bottom anymore, and the next
event doesn't auto-scroll because `isNearBottom()` now returns
false.
Fix: add a `stickToBottom` boolean (updated synchronously from the
scroll event handler) + a MutationObserver on the log subtree
(`childList`, `subtree`, `characterData`). On any mutation, if
stickToBottom is true, re-snap to bottom. MutationObserver
batches mutations into one microtask callback per synchronous
block, so it runs once per renderer call regardless of how many
children the renderer appends after `api.row` returned.
Programmatic `scrollTop = scrollHeight` doesn't trigger MO (the
scroll isn't a DOM mutation), so no feedback loop. Operator
scrolling up still flips stickToBottom to false via the existing
scroll handler — MO becomes a no-op until they scroll back.
Same shared `@hive/shared/terminal.js` powers the dashboard's
flow page + per-agent terminal, so both pages inherit the fix.
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: the per-entry header (from/sep/ts) + body were on the same
grid row, with the body squeezed into a 1fr column. In the side-
panel flyout's ~640px width, after the auto header columns ate
their share, the body wrapped over many narrow lines.
Fix: `.agent-inbox li` switches from `display: grid` to
`display: block`. The body element (currently a `<div>` for
loose-ends and a `<span>` for inbox messages) gets explicit
`display: block` so it always breaks to a new line under the
header. Light `padding-left + border-left` indent on the body
gives a visual relationship to the header row above without
needing a column structure.
`inbox-ts` + `inbox-sep` pick up small left margins to preserve
the inline spacing the grid's `gap: 0.5em` used to provide.
`.answer-form` drops its no-longer-applicable `grid-column: 1 / -1`
(replaced with a regular block layout + matching padding-left so
the form aligns with the question body it answers).
## Validation
`npm run build` clean. Bundle deltas: agent.css unchanged in
bundled size (rules swapped 1:1).
Browser smoke test isn't possible from inside iris's container.
Worth eyeballing post-deploy:
- Loose-ends entries with long question/description bodies wrap
to the panel width rather than getting clipped into a narrow
fourth column.
- The header line stays compact and readable.
- Answer-form for a question loose-end still aligns visually
under the question body it answers.
Closes#376.