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.
mara wanted one place for everything that fires at a future time.
The QU3U3D R3M1ND3RS section moves out of the SYST3M tab and
lands at the bottom of the SCH3DUL3S tab, below QU3U3D SCH3DUL3S.
Pure layout change — refreshReminders() and /api/reminders
unchanged; the section just lives under a different parent now.
Reminders don't contribute to the SCH3DUL3S pill count (kept as
"active schedules" only — reminders are self-scheduled and high
cardinality; adding them would make the pill noisy).
docs/web-ui.md picks up a SCH3DUL3S tab section (the previous
docs lumped the schedules subsection under SYST3M, but the tab
has been separate since #459); reminders subsection follows
schedules in the same tab.
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.
mara on #453: address argus's two yellow nits.
bfcache restore gap
previously openStream registered a pagehide unsubscribe but never
re-subscribed on pageshow, so a bfcache restore left the consumer's
onmessage bound but no events flowing.
fix: maintain a registry of live subscriptions (Map<url, target,
route>). bind page lifecycle hooks once:
- pagehide: unsubscribe every URL, drop route listeners, invalidate
the cached SharedWorker port (it may be collected if all other
tabs closed while this page was frozen).
- pageshow { persisted: true }: get a fresh port via getSharedPort
(creates a new SharedWorker if needed), re-attach every route
listener, re-subscribe to every URL. target.readyState resets to
CONNECTING so the worker's synthetic open after subscribe fires
the consumer's onStreamOpen and triggers a refresh.
worker bundle format
stream-worker.js was bundled as format: 'esm' but loaded as
classic via new SharedWorker(url, name). today's worker has no
imports/exports so the ESM bundle is syntactically valid as a
classic script; argus's concern was that a future contributor
adding an import would silently break things.
fix: switched build.mjs to format: 'iife'. esbuild now wraps the
worker output in (() => { ... })(); any future import statement
would surface as a build error rather than ship broken code.
verified output starts with the IIFE wrapper.
other
- target.close() now reads _sharedPort lazily so close-after-bfcache
(port may have been recreated) doesn't try to postMessage on a
stale reference.
- _activeSubs.delete on close keeps the registry honest if a
consumer ever explicitly closes a stream (none do today, but the
shape stays correct).
validation: npm run build clean. stream-worker.js: 1.8 kb → 1.9 kb
(IIFE wrapper). common.js bfcache logic adds ~30 LOC inside the
existing module — bundle deltas negligible.
mara on #448: "firefox disconnects bc of too many tabs. needs bg
service worker". picked SharedWorker over full Service Worker:
smaller change, addresses the actual problem (shared connection
across tabs), no offline-cache scope creep.
architecture
per-tab `new EventSource('/dashboard/stream')` replaced with a
SharedWorker-backed facade. one SharedWorker instance per origin
holds ONE upstream EventSource and fans every server-sent event
out to every connected tab via MessagePort. N hyperhive tabs now
share ONE backend connection, immune to Firefox's per-tab SSE
throttling under many-open-tabs pressure.
wire protocol (port.postMessage):
tab → worker
{ kind: 'subscribe', url: '/dashboard/stream' }
{ kind: 'unsubscribe', url: '/dashboard/stream' }
worker → tab
{ kind: 'open', url }
{ kind: 'message', url, data: '<raw SSE data>' }
{ kind: 'error', url }
subscription tracking is per (port, url). a late subscriber that
joins after the upstream is already OPEN gets a synthetic 'open'
event so its onStreamOpen handler still runs (triggers the
snapshot re-sync that recovers events lost during the join gap).
unsubscribing the last port for a URL closes the upstream
EventSource so we don't leak idle streams.
files
- frontend/packages/dashboard/src/stream-worker.js: new — the
worker. multi-URL multiplexing via Map<url, {es, ports}>.
- frontend/packages/dashboard/src/common.js: new exported helper
openStream(url) — returns an EventSource-shaped facade backed
by the SharedWorker. graceful fallback to direct EventSource
when SharedWorker is unavailable.
- frontend/packages/dashboard/src/app.js: replaces the inline
new EventSource('/dashboard/stream') with openStream.
- frontend/packages/dashboard/src/flow.js: passes
streamFactory: openStream to termCreate so the broker
terminal's SSE goes through the worker too.
- frontend/packages/shared/src/terminal.js: accepts an optional
streamFactory(url) option. default unchanged — non-dashboard
consumers (per-agent UI) keep using direct EventSource.
- frontend/packages/dashboard/build.mjs: new esbuild entry for
stream-worker.js → dist/static/stream-worker.js (separate
bundle because SharedWorker scripts run in a different global
scope and can't be inlined into app.js).
scope kept tight
- per-agent UI's /events/stream stays on direct EventSource. the
agent UI's tab count per agent is typically 1; SharedWorker
helps when you have N tabs hitting the SAME stream and the
per-agent stream URLs differ. if mara wants the agent UI to
share its workers too it's a separate small PR.
- no offline-cache, no push notifications — those need full
Service Worker; explicit non-goal here per the design Q.
validation
- npm run build --workspace=@hive/dashboard clean.
- stream-worker.js bundle: 1.8 kb.
- app.js: 154 kb → 158 kb. flow.js: 29.9 kb → 32 kb.
- browser smoke test isn't possible from inside iris's container;
the EventSource-shaped facade preserves the exact onmessage /
onopen / onerror surface the existing IIFE consumers use.
#446 (agent selection + bulk actions) already merged, which
removed the per-card R3ST4RT/ST0P/etc buttons entirely. So the
frontend half of the original #445 (lift the c.is_manager gate
around the per-card stop button) is now redundant — there ARE
no per-card buttons left, and the selection bar's ST0P button is
already manager-aware.
What remains and what this commit ships: the host-side guard in
`dashboard.rs::post_kill` that rejects stop on the manager with
"kill: refusing to stop the manager". Removed.
Rationale: hive-c0re owns the dashboard server, so stopping the
manager has no dashboard disruption. Per-agent approvals submitted
by other sub-agents still process through the host-side approval
queue without the manager up, and operator-driven meta-input
updates work from the dashboard either way. The MCP-surface
self-kill guard in `manager_server.rs::ManagerRequest::Kill` stays
in place: a manager calling Kill on its own container is
self-suicide mid-call, not a legitimate operator action; the
guard prevents that footgun.
Destroy / purge guards (`actions.rs:578` — "refusing to destroy
the manager") stay in place; mara's ask was specifically about
stop, and destroying the manager is a much bigger blast radius.
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.