Commit graph hyperhive/frontend/packages
Author SHA1 Message Date
iris
a2c5348ca4 dashboard: rename src/app.js → src/tabs.js (closes #406)
#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.
2026-05-26 19:39:27 +02:00
iris
501a86b725 flow: body to its own full-width line under the meta chips (closes #485)
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.
2026-05-26 19:30:28 +02:00
iris
cfef348a7f flow.html: add SCH3DUL3S to cross-page tab strip (closes #489)
mara: "why can this even be an issue?" — fair. The SCH3DUL3S
tab was added to index.html's chrome in #459 but flow.html's
parallel tab strip was never updated to match. The flow page
operator hit a dead end going back to the schedules pane.

One-line fix: copy the same `<a class="tab" href="/#schedules"
role="tab" data-tab="schedules">` link from index.html into
flow.html's chrome, positioned between SYST3M and FL0W to match
the dashboard ordering. Count pill stays hidden (flow.js
doesn't sync `schedulesState` — same reason SW4RM/Y3R C4LL/
SYST3M pills also stay hidden on this page).
2026-05-26 18:09:33 +02:00
iris
befe5c0523 dashboard: edit-form target multi-select (#474 follow-up)
mara confirmed target editing on q #195 ("also let me edit targets").
Damocles wired `targets_add` / `targets_remove` onto PATCH
/api/schedules/{id} in #478. UI side: the schedule edit form's
read-only targets callout becomes a multi-select checkbox box
(same chip styling as the new-schedule form) pre-checked for
currently-active targets.

Submit logic:

- diff new selection against `originalActiveTargets` to populate
  `targets_add` (checked, not originally active) and
  `targets_remove` (originally active, now unchecked)
- only include each key in the PATCH body when non-empty
- guard against zero-target submission with a clear alert
  pointing the operator at `✕ cancel all` as the intended path
- already-active targets that aren't in the live candidate list
  (e.g. a since-destroyed container) still surface so the
  operator can intentionally drop them

Note (re-add semantics): backend's replace-on-conflict drops
the cancelled-target tombstone + history on re-add — per the
design discussion with damocles, operator intent on re-adding
reads as "fresh start, target is active again." UI copy below
the chip row reflects this.

Carry persistence extended to the `targets` array so the
checkbox state survives a state-poll re-render mid-edit;
listens on `change` in addition to `input` for checkbox events.
2026-05-26 17:08:33 +02:00
iris
062e2d3055 renderReminders: re-indent body inside paintAtomic callback
argus 🟡 nit on #480 — the `if (!rows.length)` block sat at +6
inside the paintAtomic callback, but the `const ul` + for loop
underneath stayed at the pre-wrap +4. Code was correct, just
visually inconsistent. Re-indented the whole callback body
uniformly.

renderScheduleNewForm has the same cosmetic mis-indent but its
body is ~100 lines; leaving that for a separate sweep so the
diff stays focused.
2026-05-26 17:07:19 +02:00
iris
d4a53d83d3 dashboard: atomic-swap paint for schedules + reminders (#464)
argus flagged + mara confirmed: operators see a brief "blink" on
every poll cycle when refreshState fires. Root cause for the
async-fetch sections (refreshReminders, refreshSchedules): the
`await resp.json()` yield is a paint opportunity the browser can
take BEFORE the renderer's `root.innerHTML = ''` + `root.append`
land. The "loading…" placeholder (or the previous render's stale
content) may briefly show through.

Fix: render off-DOM into a `DocumentFragment`, then atomically
swap into the live root with `replaceChildren`. The browser only
sees the new content; no intermediate empty state is reachable.

Added `paintAtomic(liveRoot, build)` helper near the top of the
IIFE — minimal-churn pattern where each renderer's existing
`root.append(...)` body carries over unchanged, just wrapped in
a builder callback that receives the fragment as its `root`
parameter.

Applied to the async-fetch sections argus's note + mara's report
specifically called out:

- `refreshReminders` (both http-error + catch paths)
- `renderReminders`
- `refreshSchedules` (both error paths)
- `renderScheduleNewForm` (carry read still happens against the
  live root BEFORE the swap so mid-typing values are preserved)
- `renderSchedulesList`

Kept scope tight to the async paths. The sync renderers
(renderContainers / renderTombstones / etc.) run inside the
same JS turn as refreshState's other sync work, so the browser
can't paint between their clear+populate steps — no flash to fix
there. If mara still sees blink on those sections after this
lands, extending the pattern is a clean follow-up.

docs/web-ui.md no change needed; this is an implementation
detail of the existing managed-section render machinery.
2026-05-26 17:07:19 +02:00
iris
c4be1e9841 schedules fire-now: textContent + DOM nodes instead of innerHTML
argus 🟡 note on #471 — values rendered into the button flash are
all server-side ints/bool today, but textContent + element children
is the safer pattern if a stringy field ever lands in FireNowReport.
Captures original children on enter so error paths can restore
faithfully (the previous innerHTML round-trip would have already
lost any nested element structure).
2026-05-26 17:06:14 +02:00
iris
f35382e57c schedules fire-now: render FireNowReport inline on the button
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)
2026-05-26 17:06:14 +02:00
iris
7e5b496aa8 dashboard: fire-now button on schedule rows (closes #467)
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.
2026-05-26 17:06:14 +02:00
iris
dd92ef07fa dashboard: inline edit form on schedule rows (closes #474)
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`.
2026-05-26 15:58:28 +02:00
iris
caa0c6ce4c dashboard: friendlier interval input for scheduled prompts (closes #466)
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.
2026-05-26 15:55:20 +02:00
iris
4fdc061c9d dashboard: move queued reminders into SCH3DUL3S tab (closes #460)
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.
2026-05-26 13:39:42 +02:00
iris
c233f8a924 dashboard: scheduled prompts tab + creation form (closes #459)
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).
2026-05-26 11:45:57 +02:00
iris
002419ba4d dashboard: drop stale data-spw note in side-panel-drawer comment (#451)
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.
2026-05-26 01:27:42 +02:00
iris
f4635cc256 dashboard: full-height inbox + drag-to-resize side panel (#450 #451)
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.
2026-05-26 01:27:42 +02:00
iris
b4b7ccf88c dashboard: address argus nits — bfcache restore + worker IIFE (#448)
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.
2026-05-26 01:26:56 +02:00
iris
4504f9ede3 dashboard: SharedWorker for SSE multiplexing (closes #448)
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.
2026-05-26 01:26:56 +02:00
iris
086cbc0311 dashboard: drop misleading manager-special bulk-stop confirm (#443)
mirror of the #445 fix on the bulk-action side: mara's dont
2026-05-26 00:47:20 +02:00
iris
a366c00e0e dashboard: agent selection + bulk action bar (#443)
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.
2026-05-26 00:41:02 +02:00
damocles
b42b219f9b dashboard: green chip for approval source (iris nit on #436) 2026-05-26 00:29:42 +02:00
iris
7b4917b256 container_view: clear live-only fields when stopped (#432)
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).
2026-05-25 23:35:03 +02:00
iris
683e59c757 dashboard: hide live-only status when container is stopped (#432)
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.
2026-05-25 23:00:55 +02:00
iris
502e9e0e70 terminal: snappier snap-to-bottom — 140ms rAF animation (#400 follow-up)
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.
2026-05-25 21:04:24 +02:00
iris
8305c0716a terminal: smooth-scroll to bottom instead of instant jump (#400)
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.
2026-05-25 21:04:24 +02:00
iris
c015f62764 dashboard: drop max-width cap, full-width layout (#416)
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.
2026-05-25 20:59:25 +02:00
iris
1819bee43c dashboard: subscribe to /dashboard/stream for live updates (#406 step 3)
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
2026-05-25 20:55:53 +02:00
iris
cf4fc5ce1c agent: cap icon at 5em, fix overflow menu hidden-state (#411)
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.
2026-05-25 02:19:47 +02:00
iris
918dccfedd flow.js: drop dead window.HiveTerminal (#406 step 2 follow-up)
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.
2026-05-25 02:19:19 +02:00
iris
06c23e0bdc dashboard: extract flow.js as separate /flow.html entry (#406 step 2)
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
2026-05-25 02:19:19 +02:00
iris
560360d2e3 dashboard: extract shared helpers into common.js (#406 step 1)
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)
2026-05-25 02:01:49 +02:00
iris
c08218acdc dashboard: slug to page footer, not chrome (#389 follow-up)
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.
2026-05-25 01:59:12 +02:00
iris
4559a56e2e dashboard: guard renderers against missing-root on /flow.html (#399)
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).
2026-05-25 01:30:11 +02:00
iris
92becdd951 agent: drop orphaned button rules in agent.css (#394 follow-up)
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.
2026-05-25 01:29:39 +02:00
iris
69312b8553 agent: redesign terminal header — full-height icon, two-row main, overflow menu (#394)
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)
2026-05-25 01:29:39 +02:00
iris
88bc07fbbe dashboard: surface in-flight rebuild on container card (#398)
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.
2026-05-25 01:16:10 +02:00
iris
7743c07380 dashboard: connect tree-prefix vertical bars across taller rows (#388)
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.
2026-05-25 01:09:03 +02:00
iris
47403595f1 terminal: anchor tail pill outside .terminal-wrap stacking context (#375)
#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.
2026-05-25 00:52:51 +02:00
iris
9975be9c08 terminal: snap to bottom after post-row mutations (#393)
#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.
2026-05-25 00:47:31 +02:00
iris
91a72b5cbb dashboard: tab strip above banner-thin in chrome (#389)
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.
2026-05-25 00:38:12 +02:00
iris
ba669d2d6c flow: keep the dashboard tab strip on the flow page (#383)
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.
2026-05-25 00:12:28 +02:00
iris
3abd0ba711 dashboard: drop redundant C0NTAINERS heading from SW4RM tab (#385)
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.
2026-05-24 14:24:56 +02:00
iris
d4bb3e4aa0 agent: loose-ends flyout body on its own row (#376)
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.
2026-05-24 13:22:41 +02:00
iris
5615da9211 terminal: tail pill above floating chrome + autoscroll fix (#375)
Two bugs on the agent terminal page after the #362 overhaul:

## 1. `↓ N new` pill clipped by the composer

The fixed-overlay composer (z-index 30, agent.css) sits in the
root stacking context. The pill — `position: absolute` inside
`.live.terminal` with no z-index — defaults to its document-order
position in the body's stacking order, which the composer covers.

Fix: bump `.agent-main .tail-pill { z-index: 35 }` so the pill
participates in the root stacking context above the composer.
Scoped to the agent-page overlay layout — the shared `.tail-pill`
rule stays untouched (the dashboard's in-page layout doesn't need
the bump).

## 2. Autoscroll-on-new-message not firing when the operator was
   already at the bottom

`afterAppend()` in terminal.js was calling `isNearBottom()` AFTER
appending the new row. The new row's own height is already in
`scrollHeight` at that point, so for any row taller than the
NEAR_BOTTOM_PX threshold (48px — easily passed by a multi-line
message body, a tool-result summary, a markdown block), the check
returns false and the pill shows + scroll stays put.

Fix: capture `wasNearBottom = isNearBottom()` BEFORE the
`log.appendChild(...)` in each of `row` / `details` /
`detailsDiff`, pass it into `afterAppend(wasNearBottom)`. Now the
auto-scroll triggers whenever the operator was visually at the
bottom an instant before the row landed, regardless of the new
row's height.

Same shared `@hive/shared/terminal.js` is used by the dashboard
+ per-agent UI + the upcoming /flow.html page, so both pages
inherit the fix.

## Validation

`npm run build` clean.

Bundle deltas: shared terminal bundle re-inlined into both consumers
unchanged in size (the wasNearBottom variable is a single bool, no
measurable delta). Agent CSS +0.1kb (z-index property).

Browser smoke test isn't possible from inside iris's container —
worth eyeballing post-deploy:
  - With the operator scrolled to bottom, a tall message lands and
    the view scrolls to keep it visible (instead of pinning the
    pill).
  - The pill appears above the composer when the operator is
    scrolled up and new messages land.

Closes #375.
2026-05-24 13:22:18 +02:00
iris
9666cb8c3f dashboard: tab-bar restructure + extract FL0W to /flow.html (#369)
Operator: 'option A (tabs)' (#369#issuecomment-3434) +
'yes terminal can be a separate page' (#369#issuecomment-3437).

## Tab framework

`index.html` becomes a 3-tab dashboard with a sticky chrome header:

- `◆ SW4RM ◆`    — containers list (the central thing)
- `◆ Y3R C4LL ◆` — pending approvals + operator-targeted questions
- `◆ SYST3M ◆`   — meta inputs + rebuild queue + reminders + tombstones

Hash routing: `#swarm` / `#call` / `#system` (empty → SW4RM).
F5-reloadable + back-button-aware without a router framework.

SSE stays alive across tab switches — count pills on inactive tabs
update live so the operator never loses pulse on what's happening
elsewhere:

  - SW4RM:  containers with needs_update
  - Y3R C4LL: approvals.pending + questions.pending (attn-coloured pill)
  - SYST3M: rebuild_queue entries in Queued|Running

Pills hidden when count is zero. setInterval(1s) polls the existing
state stores (cheap, no per-renderer hookup needed).

## FL0W as its own page

The all-agents chat moves to /flow.html — full-viewport vibec0re
layout mirroring the per-agent live page (#362):

- Fixed-overlay frosted-glass header at top (back link + title +
  notif controls), backdrop-filter blur shows the scrolled chat
  text behind.
- Full-viewport terminal, scroll-padded for the floating chrome so
  first/last rows stay reachable.
- Fixed-overlay frosted composer at the bottom.
- Operator inbox surfaces via a pill (📬 inbox · N) in the upper
  right — click opens the side-panel flyout with the message list.

In the dashboard tab strip, FL0W is the right-most entry but
renders as a `<a class="tab tab-link" href="/flow.html">` — clicking
navigates to the page rather than swapping a pane. Same pattern
back from flow.html via the `← d4shb04rd` link.

## Implementation notes

- New `/flow.html` page rendered by the same bundled `app.js` — the
  flow page just doesn't have the dashboard-chrome DOM, so the
  matching renderers no-op silently (each `if (!el) return`).
  Avoids splitting the bundle for v1; can extract later if size
  becomes a concern.
- `Panel` module gains `openNamed(name, …)` + `refresh(name, …)` —
  the legacy untyped `open(title, content)` calls clear the owner,
  so file-preview / diff / log drill-ins behave unchanged. `refresh`
  is no-op when a different view owns the panel, so live message
  events re-render the inbox flyout only when it's actually open.
- `renderInbox` updates BOTH the dashboard's inline `#inbox-section`
  (now living on the flow page) AND the flow page's pill count +
  side-panel refresh. The dashboard's empty FL0W tab is removed —
  inbox + message flow + compose box only exist in flow.html.
- Banner shrinks to a thin Catppuccin gradient strip at the top of
  the dashboard chrome (dropped the multi-line ASCII art —
  affectionate but pure chrome budget in a tabbed layout).
- `build.mjs` copies both `index.html` + `flow.html` into dist.

## Validation

`npm run build` clean. Dashboard bundle deltas:
  app.js  150kb → 152kb  (tab routing + count pills + named-Panel)
  dashboard.css 33kb → 38kb (tab chrome + flow page layout)
  + dist/flow.html  4.4kb

Browser smoke test isn't possible from inside iris's container
(no JS engine) — drafting as a PR for operator visual review on
next deploy. Worth eyeballing:
  - Tab switching feels right; counts update live across SSE events
  - FL0W page reads like the agent live page (frosted header + composer)
  - Inbox pill opens flyout; live message arrivals refresh it
  - Back link from flow → dashboard returns to last tab via the
    URL hash (browser remembers the hash across page nav)

Closes #369.
2026-05-24 13:03:53 +02:00
iris
8c7bc850f3 dashboard: render agent topology as a tree in the container list
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.
2026-05-24 12:04:19 +02:00
iris
e931c08739 frontend: vibec0re terminal overhaul (#360)
Per operator spec at #360#issuecomment-3333:
- full-screen terminal
- frosted-glass header overlaid on top
- inbox + loose-ends → flyout
- no a/b flag, just ship it

## Layout

`frontend/packages/agent/src/index.html` restructured to a three-zone
fixed-overlay shape:

- `<header.agent-header>` — fixed top, frosted glass via
  `backdrop-filter: blur(12px) saturate(140%)`. Holds icon + title +
  nav links + state-row (badges/buttons) + two new pill buttons that
  surface inbox / loose-ends counts (and open the side panel on click).
- `<main.agent-main>` — fills the viewport. Terminal positioned absolute
  inset:0 with padding-top/-bottom + scroll-padding equal to the
  floating header/composer heights so first/last rows stay reachable
  and `↓ N new` pill anchors land in the visible scroll zone.
- `<footer.agent-composer>` — fixed bottom, mirror-frosted. Owns
  `#term-input`; dropped the in-frame dashed separator (border-top
  + box-shadow on the bar already separate it from the terminal).
- `<div.side-panel>` — singleton drawer (copy of the dashboard
  pattern, candidate for extraction into @hive/shared). Inbox +
  loose-ends details render here instead of expanding inline.

Dropped from the page: the pre-banner ASCII shimmer (`<pre.banner>`)
and the in-page `<details>` collapsibles for inbox + loose-ends. The
banner JS path (`setBannerActive`) is now a no-op (early-returns on
missing element); kept as dead code rather than ripped out to keep
the diff focused.

## JS

`frontend/packages/agent/src/app.js`:

- New `Panel` singleton with `open(name, title, content)` +
  `close()` + `refresh(name, title, content)` (no-op if a different
  view owns the panel — lets live updates re-render an open view
  without grabbing focus from a closed one). Mirror of the
  dashboard's Panel module; the duplication is intentional for now.
- `renderInbox` + `renderLooseEnds` refactored: update the header
  pill counts, hide/show the pills, and `Panel.refresh` if the
  matching view is open. The list-building DOM logic moved into
  `buildInboxList` + `buildLooseEndsList` so the pill click handler
  can call them on the latest snapshot kept in `lastInbox` /
  `lastLooseEnds` module state.
- Pill click handlers `Panel.open(...)` with the freshly built list.
- Auto-expand behavior on first appearance dropped (the pill +
  count badge is the discoverable signal; auto-popping the flyout
  would interrupt whatever the operator is doing).
- `setHeader` no longer touches `#banner` (element removed); title +
  dashboard back-link + rebuild button still get appended to `#title`.

## CSS

`frontend/packages/agent/src/agent.css` major additions, scoped
`body.agent-shell` so the sibling `stats.html` (which doesn't apply
the shell class) keeps its normal-document scroll + `.banner` ASCII
header via a `body:not(.agent-shell)` block.

New CSS custom properties on :root: `--agent-header-h`,
`--agent-composer-h`, `--agent-frost-bg`, `--agent-frost-blur`. The
terminal's padding + scroll-padding derive from these so a single
height tweak ripples consistently.

Added `.header-pill` (inbox/loose-ends triggers) +
`.agent-status-overlay` (centred login card when status != online).
Side-panel rules copied from `dashboard.css` with one delta: width
caps at 640px (vs dashboard's 760px) since per-agent inbox / loose-
ends rows are narrower than approval diffs / file previews.

## Validation

- `npm run build` — succeeds both workspaces.
  - agent: `dist/static/{app.js (115kb), stats.js (435kb), agent.css (21kb)}`
  - dashboard unchanged (no shared sources touched).
- Browser smoke test isn't possible from inside iris's container
  (no JS engine) — op-side check on next deploy.

Closes #360.
2026-05-24 03:49:26 +02:00
iris
2ecf15bb6f frontend: nest asset output under dist/static/
The src/index.html / src/stats.html files reference assets at URLs
like /static/app.js, /static/dashboard.css. The initial Phase 1 build
flattened everything to dist/{app.js, dashboard.css, ...} which would
have forced the Phase 4 Rust ServeDir mount to do URL rewriting just
to make the existing HTML references resolve.

Rework: bundles now write to dist/static/, HTML stays at dist/ top
level. Layout matches the URLs the HTML uses, so the Phase 4 mount
is the simplest possible `fallback_service(ServeDir::new(dist))`.

No source-file changes — just the esbuild outfile/outdir paths.
Rebuilt; verified asset filenames + sizes unchanged.

Refs #273.
2026-05-23 14:51:01 +02:00
iris
8bebd78895 frontend: add npm workspace scaffold under frontend/
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.
2026-05-23 14:51:01 +02:00