Commit graph hyperhive/frontend/packages/dashboard/src
Author SHA1 Message Date
iris
9c72fd369a dashboard.css + docs: migrate topology indent + icon/pending prose (#712 batch 12)
dashboard.css carried many CSS WHY-blocks duplicating prose
already in docs/web-ui.md (after PRs #722/#724/#728/#776).
Substantive new migration: topology indent geometry. Remaining
cookies collapse to docs pointers.

Moved to docs/web-ui.md::Topology tree:
- Indent + lane geometry paragraph (~18 lines new prose):
  per-depth 1.8em ladder rationale + hardcoded six levels +
  CSS Values 5 attr() partial-support caveat (Chromium-only as
  of 2026) + lane center continuation bars + bottom: -0.4em
  prefix extension to merge sibling vertical lines + horizontal
  stub at icon midline.

Collapsed in dashboard.css:
- Topology indent block (#363/#361) → 3-line pointer
- Tree prefix lanes block (#388) → 3-line pointer
- Tree prefix depth-step ladder (~6 lines) → 4-line tighter
  paraphrase (lane-meets-icon rationale stays inline as it's
  immediate context for the values below)
- Live cards icon-left split (#177/#344) → 6-line tighter
  paraphrase + docs pointer
- Icon selection-toggle hover/focus (#443) → docs pointer
- Icon img absolute positioning (#177) → docs pointer
- Icon favicon fallback (#195/#202) → docs pointer
- Pending state queued vs running (#769) → docs pointer
- Icon-only nav strip (#262/#333) → 7-line tighter + docs pointer
- Approval requested-at chip (#272) → 2-line pointer
- In-flight step indicator (#437) → 4-line pointer
- M0V3 affordance (#486) → 3-line pointer
- Cancel-X queued (#575) → 5-line pointer

dashboard.css: 51 → 36 cookies (counting hex-color false positives;
~15 actual issue refs scrubbed). Net ~50 lines of CSS WHY-prose
collapsed to brief docs pointers; ~18 lines of new substantive
prose migrated to docs/web-ui.md::Topology tree.
2026-05-31 14:48:46 +02:00
iris
ea1c6e4d05 tabs.js + docs: migrate icon + pending-state prose to docs (#712 batch 11)
Mara on #774: previous batches were cookie-stripping rather than
prose migration. This batch actually moves substantive comment
prose from tabs.js into docs/web-ui.md.

Moved to docs/web-ui.md:
- Container row → **Icon layout + load strategy** sub-paragraph
  (new): absolute-positioning rationale (so img load state can't
  reflow row), fire-and-forget load pattern (no pre-check
  reachability), favicon fallback chain, icon-unreachable class,
  the immediate-fallback-when-stopped optimisation.
- Container row → **Pending-state derivation** paragraph (new):
  three-source priority order (transient → in-flight queue → none),
  why ContainerStateChanged isn't enough, the opRunning flag's role
  in driving the pending-running class + spinner.

Collapsed in tabs.js:
- Icon block (~14 lines of WHY comments + pointer to docs) →
  4-line pointer + behavioural one-liner. Drops #177 / #195 / #202
  cookies en passant since their substance now lives in docs.
- Pending-state block (~22 lines split across two paragraphs) →
  4-line pointer. Drops #769 self-cookie (the queued vs running
  split lives in docs::Container row now).
- SharedWorker EventSource (~7 lines) → 5-line pointer. Drops
  #448 cookie (the SSE multiplexing paragraph in docs already
  has the design + Firefox throttling rationale; the in-code
  comment was duplicating).
- M4TR1X tab gating (~4 lines) → 2-line pointer. Drops #607 cookie
  in both tabs.js + docs/web-ui.md::Tab strip (the substance was
  already in docs, just had the cookie attached).

tabs.js: 11 → 6 refs (92% reduction from baseline 73). Net effect:
~47 lines of substantive prose moved out of tabs.js into
docs/web-ui.md, where it belongs.
2026-05-31 14:23:49 +02:00
iris
68c2b3284e tabs.js + docs: drop rebuild-queue cookies (#712 batch 10)
#437 (multi-step progress on rebuild_queue entries — closed) ×2:
in tabs.js current-step comment + in docs/web-ui.md prose
referencing the phase annotation.

#501 (PR — merged for #437) ×1: paired with #437 in the same
tabs.js current-step comment.

#575 (X button to cancel pending builds — closed) ×2: in tabs.js
cancel-form comment + in docs/web-ui.md cancel-button prose.

#436 (route approval execution through rebuild_queue — closed)
×1 in docs/web-ui.md: scrubbed the parenthetical "; #436" next
to the approval source-chip description (small freebie since I
was editing that paragraph anyway). The approval-as-source-chip
is the implementation of #436; the cookie was just history.

Pre-scanned docs for matching cookies; caught all 3 doc cookies
in the same PR.
2026-05-31 14:16:30 +02:00
iris
c527f739ec tabs.js: drop scheduled-prompts cookies (#712 batch 9)
#459 (dashboard tab + creation form for scheduled prompts —
closed) ×2 (section header + tab-activation re-fetch comment) +
#444 (make it possible to schedule prompts — closed) ×1 (Backend
endpoint enumeration preamble) + #535 (schedules-as-table —
closed) ×1 (renderSchedulesList layout rationale). The
section-header comment now reads as a pure mile-marker; the
backend-endpoints preamble enumerates what the API surface is
today; the table-layout comment describes the rendered shape.
No docs/web-ui.md matches for any of #444 / #459 / #535.
2026-05-31 14:12:44 +02:00
iris
272d8af620 dashboard: split queued vs running rebuild visual (#769)
Container row's pending highlight fired for both queued + running
rebuild-queue entries, so a long queue painted half the SW4RM tab
amber. Mara on #769: don't highlight while queued, show running as
spinner on agent icon.

- tabs.js: derive opRunning (transient OR op.state === 'running')
  separately from pending; add a pending-running row class.
  Operator-initiated transients still count as running so the
  rebuild-now-clicked → backend-picks-it-up window stays visually
  consistent.
- dashboard.css: move the amber border + tint from .pending to
  .pending-running (queued rows now keep their normal look, with
  the badge text still saying "rebuild queued"). Add a 1s linear
  rotating amber arc as .pending-running > .container-icon::after,
  with overflow:visible so the ring sits just outside the icon and
  composes with the mauve selected ring.
- docs/web-ui.md::Container row: describe the queued vs running
  split in prose. Drop the orphaned (#398) cookie while I'm at it
  — small #712 freebie.
2026-05-31 14:10:37 +02:00
iris
4ab4d99340 tabs.js + docs: drop #272 + #275 cookies (#712 batch 8)
#272 (show approval requested-at — closed) ×2 in tabs.js (live
ApprovalAdded fallback note + amber stale chip comment) + ×1 in
docs/web-ui.md (Approval card identity header). #275 (select all
for meta inputs — closed) ×1 in tabs.js (bulk-select rationale)
+ ×1 in docs/web-ui.md (M3T4 1NPUTS tree control note). The
amber-stale chip line in tabs.js now points at
docs/web-ui.md::Approval card for the rendered spec instead of
the closed issue.
2026-05-31 14:01:57 +02:00
iris
3c2fffabdf tabs.js: drop #406 module-split + #408 forward-looking cookies (#712 batch 7)
#406 (split app.js → tabs/flow/common) closed; the migration is
done — the 'moved to ./flow.js' / 'pre-step-2 this wiring lived
in the broker-terminal IIFE' commentary is git-history, not
present-state docs. #408 (split flow messages from main sse) also
closed; the 'will give /index.html its own stream' line was
forward-looking design that didn't ship that way. Comments
tightened to describe what the code does today; the section
headers, behaviour notes, and reconnect-rationale all stay
intact.
2026-05-31 13:35:18 +02:00
iris
8134b955e2 tabs.js: scrub #262/#486/#163 cookies from agent links + topology move + SSE resync (#712 batch 6)
Three small clusters of cookies removed:

- #262 (×2): agent-declared dashboard links via /api/agent/<name>/links.
  Both comment blocks describe the same architectural decision
  (agent backend = source of truth, DOM-built so link strings can't
  reach the HTML parser). The cookies just attributed the decision
  to a PR; the prose stands without them.
- #486 (×2): M0V3 topology affordances + addBulkButton perAgentBodyFor
  hook. Substantive prose moved to docs/web-ui.md::Selection bar in
  #695 already; in-code comments now reference the docs.
- #163 (×3): snapshot re-sync + SSE catchup idempotence. The cookies
  flagged 'this is why the guard exists'; replaced with the present-
  tense 'post-disconnect SSE catchup can carry duplicate rows' which
  reads as the actual reason without needing the issue context.

tabs.js: 31 → 24 #NNN refs (-7). Net +1 line (the rewrites are
sometimes slightly longer when 'issue #N' is replaced with the
substantive description; net is still ahead). 67% reduction since
milestone start.

refs #712
2026-05-31 13:13:07 +02:00
iris
9d9cac2f61 tabs.js: drop schedule + flow-split + TTL-ticker cookies (#712 batch 5)
Four clusters of cookies in tabs.js, all pointing at prose that's
either already in docs/web-ui.md::SCH3DUL3S tab or self-evident
from the code:

- #564 (×4): inline-create row + carry-state for schedules. Layout
  rationale is in the docs; the in-code comments describe what the
  state does, which stands on its own.
- #474 (×3): inline edit form + targets multi-select for
  schedules. Same pattern — docs cover the UX, code comments
  describe the implementation pieces.
- #399 (×4): null-guard for tabs.js sections that may not exist on
  /flow.html. The 'pre-split this code lived in flow.js' historical
  paragraph dropped entirely; replaced with the present-tense
  no-op-when-target-absent convention statement.
- #335 (×3): question-TTL chip ticker. Cookies dropped; substantive
  text stays.

Plus #466 (interval composer shared use) and some adjacent cleanup
from the same passes — small additional drops folded in.

tabs.js: 48 → 31 #NNN refs (down from 73 at start of milestone).
Net -8 lines. Functional code unchanged; build clean.

refs #712
2026-05-31 13:03:48 +02:00
iris
4c30b52e48 tabs.js: scrub #398/#432 cookies from container-card render path (#712 batch 4)
Seven cookies removed across the container-card rendering code (4
#398, 3 #432). All point at existing docs/web-ui.md::Container row
prose (rebuild_queue building badge, stopped-container state
clearing). Each comment shrinks to either a short docs ref or just
drops the cookie where the substantive content already explains
itself.

Net ±0 lines in tabs.js (rewrites are similar length); the win is
each comment now reads as 'what this code does' rather than 'why
PR #N decided this'. tabs.js is now at 48 #NNN refs (down from
55 at start of this batch, 73 at start of the milestone).

refs #712, builds on #722 + #724 + #728
2026-05-31 12:35:08 +02:00
iris
10097bb9b6 tabs.js: collapse stale dangling sentence in per-card actions comment (argus #728)
When I rewrote the original 'Per-card action buttons … moved into
the sticky #selection-bar (see renderSelectionBar) which appears
when the operator has at least one agent selected via the icon
click' comment to use the docs ref, I shrunk part of it but left
the second sentence as a dangling fragment glued onto the new
short ref. Collapse cleanly: docs ref + the still-useful
`needs update ↻` chip note.
2026-05-31 12:16:27 +02:00
iris
69debe6b7d tabs.js + web-ui.md: drop 'no special-case manager' framing (mara on #728)
mara's review nit: explaining the absence of a manager special-case
implicitly endorses the idea that special cases would be normal.
Default is no special case; the doc shouldn't dwell on it.

- ST0P comment block removed entirely (the bulk-button line above
  is self-explanatory; the substantive 'c0re survives manager-down'
  rationale lives in the host docs, not here).
- M0V3 helper comment reframed: trim the 'no special-case' framing
  but keep the substantive note that the backend refuses moves it
  can't satisfy and the failure surfaces in the bulk roll-up.
- docs/web-ui.md::Selection bar bullet list: drop the 'manager
  included; no special-case' qualifiers; just describe the action.
  M0V3→ROOT keeps the rationale that backend refusals surface in
  the roll-up but stops calling out manager specifically.
2026-05-31 12:16:27 +02:00
iris
a9422518e5 docs: scrub selection-bar #443/#596 cookies in tabs.js, add interaction-model preface (#712 batch 3)
selection + selection-bar in tabs.js carried six #443 cookies + two
#596 cookies — most marking the bulk-bar architectural decision
('actions live in bar, not on per-card buttons; manager not
special-cased') or the SW4RM-tab gate. All scrubbed; the canonical
docs/web-ui.md::Selection bar section already covered the bulk
actions in detail but was missing a preface for the **interaction
model** itself (how selection enters/exits, why per-card buttons
moved). Added that as the opening paragraph of the section so the
in-code refs have something to point at.

Net in this batch:
- tabs.js: -8 #NNN refs (#443 ×6, #596 ×2)
- docs/web-ui.md: +9 lines (interaction-model preface)
- functional code unchanged; build clean

refs #712
2026-05-31 12:16:27 +02:00
iris
711d42031c docs: scrub module-split cookies from tabs.js + migrate paintAtomic rationale (#712 batch 2)
Two threads cleaned up in tabs.js:

1. Top-of-file '#406 step 1/2/3' historical narrative deleted —
   it documented past module splits (moves to common.js / flow.js,
   file rename app.js → tabs.js) which are git-history concerns,
   not current-behaviour docs. Replaced with a two-sentence
   contract description that references docs/web-ui.md and the
   present-tense module split.

2. Five 'moved to ./common.js (#406)' stubs deleted — same
   reasoning. The imports at the top of the file already document
   what's in common.js; standalone 'this function lives elsewhere'
   pointers are noise once you stop tracking the move event.

paintAtomic's substantive rationale migrated to a new
'Atomic section repaint' subsection in docs/web-ui.md (under the
existing focus-preservation note); in-code comment shrinks to a
two-line reference. Same pattern as the topology-tree batch.

Net: 36 fewer lines in tabs.js, 14 new in docs/web-ui.md.
Functional code unchanged; build clean.

refs #712
2026-05-31 11:56:15 +02:00
iris
26d8565365 docs: migrate tabs.js topology-tree prose to docs/web-ui.md (#712 pattern check)
First slice of #712 (dashboard SPA frontend → docs/). Shrinks the
buildAgentTree + treePrefixDom comment blocks in tabs.js to one-line
docs/ references; adds a new 'Topology tree' subsection to
docs/web-ui.md under '### Container row' carrying the substantive
prose.

Net effect on this region:
- 6 #NNN tracking cookies dropped (#363, #361, #388)
- 30 lines of substantive design comments collapsed to 6 lines of
  references in tabs.js
- 35 new lines in docs/web-ui.md (the migrated prose, restructured
  into a single coherent subsection rather than two adjacent comment
  blocks)
- functional code unchanged; build clean

Deliberately small pattern-check (one cohesive subsystem) before
expanding to the rest of #712 (~140 more refs across tabs.js +
dashboard.css + index.html + common.js + flow.js). Reviewers can
sanity-check the shape (where prose lands, how short the in-code
ref becomes, whether section nesting reads well) before I scale.

refs #712
2026-05-31 11:49:02 +02:00
iris
1656b265ed dashboard #486: drop manager special-case + enable M0V3 picker for multi-select (mara on #695)
Two changes from mara's review:

1. drop the manager special-case. Both M0V3 affordances now apply
   regardless of whether the manager is in the selection; backend
   topology::set_parent refuses the manager move and the failure
   surfaces in the bulk-action error roll-up. Matches the #443 ST0P
   policy of 'don't pre-gate manager actions, let the backend speak'.

2. enable the M0V3 → <pick> picker for multi-select. Was single-agent
   only in v1. Picker now omits every selected agent itself plus the
   union of every selected agent's descendants (cycle-safe across the
   whole batch); on submit POSTs once per selected agent sequentially,
   same shape as the existing bulk-button loop. Confirm message +
   error roll-up adapt to selection size.

docs/web-ui.md updated to match.
2026-05-31 11:32:13 +02:00
iris
2950a7f9ee dashboard: selection-bar M0V3 affordances for re-parenting agents (#486)
Backend POST /api/topology/set-parent already shipped; the dashboard
was missing the operator surface to drive it. Adds two affordances
to the SW4RM tab's selection bar (alongside the existing R3ST4RT /
ST0P / ST4RT / R3BU1LD / DESTR0Y / PURG3 actions):

- '⇡ M0V3 → ROOT' (bulk): promote selected agents to top-level
  (parent=null). Disabled when all selected are already at root or
  the selection includes the manager (backend refuses anyway).

- '⇢ M0V3 → [pick]' (single-agent only): inline <select> dropdown
  + button pair. Dropdown lists every container that isn't the
  target nor a descendant of it (client-side BFS via the existing
  c.parent map). On submit POSTs form-encoded
  'child=<name>&new_parent=<target>' to /api/topology/set-parent;
  the backend re-checks the cycle invariant and re-emits a
  container snapshot so the tree repaints without a reload.

Both POSTs hit a single URL, so addBulkButton grew an optional
'perAgentBodyFor(name)' hook to handle the body-driven endpoint
shape (vs the URL-suffix /start/<name> shape every other action
uses). Lifecycle endpoints unchanged.

Mauve chrome (var(--mauve)) reads as 'structural change' rather
than the destructive red / amber of destroy / rebuild.

closes #486
2026-05-31 11:32:13 +02:00
iris
20b5e03ee0 dashboard: footer link → forge.darkest.space mirror (closes #639)
Footer was still pointing at the old canonical at
`git.berlin.ccc.de/vinzenz/hyperhive` — that's the upstream URL
from before #524 moved the public mirror to `forge.darkest.space`.
README + docs already reference the new URL; this was the only
stale frontend pointer.
2026-05-30 16:42:37 +02:00
iris
40b9a7f2cd dashboard: M4TR1X → tab strip entry for matrix GUI (#607)
Frontend half of #607 v0 — adds the `◆ M4TR1X ◆ →` page-link entry
to the dashboard tab strip between SCH3DUL3S and FL0W →. Same
`tab-link` class as FL0W → since /matrix/ is its own SPA, not an
in-place pane swap.

Hidden via the `hidden` attribute by default; tabs.js's `refreshState`
flips it based on `s.matrix_gui_enabled` (added in #610). When the
operator hasn't set `hyperhive.matrix.gui.enable = true` the snapshot
returns false and the tab strip doesn't surface a dead link.

docs/web-ui.md updated:
- chrome-header tab-strip list now includes M4TR1X → with the
  hidden-when-disabled note
- new "M4TR1X page" section under FL0W explaining the same-origin
  serve via hive-c0re ServeDir + the manual homeserver URL pick on
  first login (with #609 tracking the post-#15 nginx-front re-root +
  .well-known/matrix/client auto-discovery)
2026-05-29 22:06:28 +02:00
iris
0deca50352 dashboard: fold schedule creation into the table view (closes #564)
The N3W SCH3DUL3 vertical form is gone. The schedules table now
carries an always-visible inline create row at the bottom — fill the
cells, click + to POST /api/schedules, the new schedule appears
above on the next refresh.

Per-column inputs match the display semantics:
- `next` → `<input type="datetime-local">` (defaults to now+5min)
- `every` → 4 mini d/h/m/s number inputs (blank/all-zero = one-shot)
- `body` → `<textarea rows=1>` expanding to 4em on focus-within
  so multi-line prompts still fit (closes mara's "prompt may still be
  multi-line" requirement)
- per-agent columns → checkbox (whole cell clickable via padded label)
- actions → + submit + ⌫ reset (clears the carry without POSTing)

`newScheduleCarry` module-scope object survives the paintAtomic
re-render so mid-typed values stick across schedule-list refreshes
(same pattern as `scheduleEditCarry` from #474).

H2 collapses from "N3W SCH3DUL3" + "QU3U3D SCH3DUL3S" down to a
single "SCH3DUL3S" since the section now does both. Empty-state
branch on `renderSchedulesList` is gone — the table always renders
because the create row is always there.

`#schedule-new-section` div + `renderScheduleNewForm` /
`readScheduleFormCarry` / `submitNewSchedule` and the
`.schedule-new-form` CSS selector all removed. The edit-form path
(#474 `renderScheduleEditForm` colspan'd row on `✎` toggle) is
unchanged — "edit mode is inline in the table" already shipped
there, this PR just adds the same mental model for create.

Validations match the old form: empty targets / empty body /
invalid datetime / non-integer interval each surface an `alert()`.
The + button shows a spinner while the POST is in flight, then
clears the carry + triggers `refreshSchedules()`.
2026-05-29 19:57:14 +02:00
iris
1ae939499c dashboard: hide selection bar outside SW4RM tab (closes #596)
The sticky bulk-action bar (#443) was visible on every tab whenever the
selection was non-empty. On Y3R C4LL / SYST3M / SCH3DUL3S the operator
sees a floating bar without the agent cards next to it for cross-
reference — which is what mara called out in #596.

Fix: gate the bar on `document.body.dataset.activeTab === 'swarm'` in
addition to the existing non-empty-selection check. Selection state
itself stays in memory, so the bar reappears on return to SW4RM if any
agents are still ticked. The `activateTab` hook now sets the data
attribute and re-runs `renderSelectionBar` so the toggle takes effect
on hashchange without waiting for the next SSE update.
2026-05-29 19:35:30 +02:00
iris
036bf6c3c1 dashboard: cancel-X on queued rebuild_queue rows (closes #575)
Backend route POST /api/rebuild-queue/{id}/cancel already exists
(refuses Running / terminal entries with {cancelled: false}). This
adds the operator-facing affordance:

- small circular X button on the right edge of each row whose
  state === 'queued'. Running / done / failed rows don't render
  it, so the operator never clicks a button that the backend
  would refuse.
- uses the same data-async + data-confirm pattern as the
  reminder cancel form — global submit handler does POST +
  spinner + error toast for free.
- successful cancel flips the row queued -> cancelled via the
  live RebuildQueueChanged snapshot, so the button disappears
  on the next paint without an explicit refresh.

CSS keeps it quiet by default (muted border, transparent
background) and lights red on hover / focus, matching the
.btn-deny family without claiming a full button-width slot
that would push the row layout around.
2026-05-29 16:43:37 +02:00
iris
8bbb3e4905 tabs: logs flyout fills the side-panel height (closes #541)
The journald viewer opens in the side panel, but the <pre> only takes
the height of its content — so a short log fetch leaves the bottom 80%
of the panel empty, and a long one pushes the controls past the
viewport. Wrap the body in a column-flex container that fills the
side-panel-body, keep controls fixed at the top, and let the <pre>
flex-grow into the rest of the panel with its own overflow:

  .journal-body { display: flex; flex-direction: column; height: 100%; }
  .journal-controls { flex: 0 0 auto; }
  .journal-output { flex: 1 1 0; min-height: 0; overflow: auto; }

`min-height: 0` is the canonical "let me actually flex-shrink for
overflow" escape hatch on flex children. Also moved the auto-scroll
target from side-panel-body onto the <pre> itself — the panel-body no
longer overflows now that journal-body fills it; the <pre> is the new
scroll surface.

Pure CSS + one JS line; no DOM shape change.
2026-05-28 18:54:02 +02:00
iris
42a2503834 tabs: render schedules as a single table with tilted agent columns (closes #535)
Replaces the per-schedule card layout with one table:

  | # | src | next | every | owner | body | …agents… | actions |
  |---|-----|------|-------|-------|------|----------|---------|
  | 5 | op  | 5m   | 10m   | mara  | "…"  | ✓ ✓ . ✓ |  ↯ ✎ ✕  |

Each schedule is one <tr> in the tbody. Agent columns are dynamic —
operator + manager + live containers + any "extra" name that appears
as a target on some schedule but isn't a current container (same
membership rule buildTargetChips uses, so the table + new/edit forms
agree on what's addressable). Per-agent cells:

  - active target  → <button>✓</button> that cancels just that target
    on click (replaces the per-row ✕ from the old targets sub-table)
  - cancelled target → muted ✕ glyph (no button; re-add flows through
    the edit form's targets multi-select)
  - not a target → empty cell

Agent column headers tilt -45° via CSS so each column reads as 28px
of horizontal real estate instead of the full word width. Standard
rotated-header pattern: 95px-tall <th> with position:relative, inner
<div> positioned absolute at bottom-left, transform rotates about
left-bottom.

Body cell truncates with ellipsis + full-text title. Description used
to be a separate visible block on the card layout; the table folds
it into the body cell's title to keep row height tight. If mara wants
description visible in-table it's a small follow-up — easier to
iterate on a rejection.

Edit form expands inline into a colspan'd row underneath the schedule
row it edits (instead of inside the card). Wrapper drops the form's
background so it reads as a row extension.

No backend changes; everything renders from existing schedulesState
+ containersState.
2026-05-28 14:22:39 +02:00
iris
bc1c910044 tabs: extract scheduleField() helper — collapse 8 label/caption sites
Every field on the new- and edit-schedule forms (plus buildIntervalComposer
and buildTargetChips) builds the same wrapper shape:

    el('label', { class: 'schedule-field' },
      el('span', { class: 'schedule-field-label' }, '…'))

then appends an input. Eight identical-modulo-text sites. Extracting one
small helper:

    function scheduleField(labelText, ...children) {
      return el('label', { class: 'schedule-field' },
        el('span', { class: 'schedule-field-label' }, labelText),
        ...children);
    }

drops the per-site cost to a single `form_.append(scheduleField('caption',
input));` line. -10 LOC net; no behaviour change.
2026-05-28 14:13:35 +02:00
iris
88476976b7 stream-worker: honest comment on heartbeat catch (argus nit on #529)
argus pointed out the inline catch comment claimed dead ports get cleaned up on
next subscribe, but nothing actually prunes the allPorts Set on subscribe — the
honest answer is the one already at the bottom of onconnect: dead entries are
left in the Set, the bound cost is acceptable, and postMessage's throw is the
ambient signal we use. Point at that comment instead of repeating a wrong
description.
2026-05-27 23:40:38 +02:00
iris
b9df538940 frontend/dashboard: heartbeat + watchdog so a dead SharedWorker self-heals (#515)
Firefox kills "idle" SharedWorkers under memory pressure with no native
signal to the client. The page's port silently becomes a no-op and
events stop flowing — observable symptom: mara's "dashboard never
refreshes; F5 fixes it" (because F5 creates a fresh page that creates
a fresh worker).

The worker now pings every connected port every 30s. The client tracks
last-activity-from-worker on every message arrival (incl. pings, since
those carry no URL — bumped before the URL filter in the route handler).
A visibility-gated watchdog polls every 15s; if the page is visible AND
has active subs AND hasn't heard from the worker in >90s, it presumes
the worker dead, logs a console warning, and re-subscribes on a fresh
port. Three pings missed before we act, so a normal tab-throttle blip
doesn't false-positive.

The fresh-port re-subscribe re-uses the bfcache-restore code path
(same shape: drop stale listeners, getSharedPort → new SharedWorker,
re-attach each route + repost subscribe). Recovery is per-tab — when
one tab's watchdog fires and brings up a new worker, other tabs that
share the named worker pick it up on their own watchdog cycle.

Falls back gracefully on environments without SharedWorker (the
existing direct-EventSource path is untouched) and is invisible on the
healthy path — pings are 30s apart, no UI surface.
2026-05-27 23:37:21 +02:00
iris
e8c86bef3b tabs: dedup target-chips + interval-parse between new/edit schedule forms
mara: \"look through the code for dedups, structural improvements
and so on\". Two near-identical blocks across the new-schedule and
edit-schedule forms folded into shared helpers.

## `buildTargetChips({ idPrefix, fieldName, checked, extraNames })`

Was inlined twice in 18-line blocks that built the same
`<label class="schedule-field">` + `<div class="schedule-targets">`
+ candidate-list logic (containers + operator + manager). Now one
function, two callers; `extraNames` lets the edit form keep
showing already-active targets that have vanished from the live
container list so the operator can still uncheck them
intentionally.

## `intervalSecondsFromFormData(fd, namePrefix)`

Both submit handlers had the same ~13-line d/h/m/s → total-seconds
parser (with `NaN` propagation on bad input). Pulled into one
helper next to `buildIntervalComposer`; call sites become 3 lines:

  const intervalTotal = intervalSecondsFromFormData(fd, 'interval_');
  if (Number.isNaN(intervalTotal)) { alert(...); return; }
  const interval_seconds = intervalTotal > 0 ? intervalTotal : null;

Net -13 lines, but the bigger win is shape — when (not if) a new
schedule field surfaces, there's one chip-render path + one
interval-parser to thread it through instead of two.

Zero behaviour change. Built clean.
2026-05-27 19:22:59 +02:00
damocles
c1f27e3b7b approvals: manager can withdraw pending approvals (closes #250) 2026-05-27 12:48:30 +02:00
iris
1dfcc8953c rqe-step: drop tabular-nums (no digits in step names)
argus 🟡 on #504 — `font-variant-numeric: tabular-nums` has no
effect on `.rqe-step` since step strings are text-only
("nixos-container update" / "meta prepare_deploy" / etc.),
no digit columns to align. Harmless but cleaner without it.
2026-05-27 02:00:42 +02:00
iris
50986b5a05 dashboard: render step sub-line on rebuild queue entries (closes #437)
Frontend half of #437 — paired with damocles's #501 backend which
adds `step: Option<String>` to `QueueEntry` and annotates 9
worker phases across ApplyCommit / Spawn / MetaUpdate / Rebuild
pipelines.

When `entry.step` is present, render a cyan `↳ <step>` sub-line
below the main row in `renderQueueEntry`. Wraps to its own flex
line via `flex-basis: 100%`, indented 1.8em to align under the
state glyph + kind. Terminal transitions clear `step` on the
backend, so the sub-line just disappears on Done / Failed rows —
no client-side staleness handling needed.

CSS: `.rqe-step` keys off cyan to visually group with the
running-spinner color cue (vs the muted `.rqe-when` /
`.rqe-reason` chips). `tabular-nums` keeps the indented arrow
column stable as step strings change length mid-build.

docs/web-ui.md updated to describe the new sub-line behaviour
in the R3BU1LD QU3U3 section.
2026-05-27 02:00:42 +02:00
iris
ac4c8456e1 flow.js: subscribe to /dashboard/stream with ?kinds= filter (closes #408)
Frontend half of #408 — wires flow.js's terminal subscribe URL to
the kinds-filter damocles shipped in #499. Only the four kinds
flow.js actually handles get sent on the wire:

- sent / delivered → broker terminal renderer
- container_state_changed / container_removed → local autocomplete
  cache (flowContainers Map used by the @-mention composer)

Everything else (approval_*, question_*, transient_*, tombstones_*,
meta_*, rebuild_queue_*) is now dropped server-side before the
JSON-serialise, instead of being deserialised + dispatched to
flow.js's no-op `_default: () => {}` per frame.

Tabs.js keeps the unfiltered `/dashboard/stream` subscribe since
it routes every mutation kind into its derived stores.

## SharedWorker note

`openStream` keys subscriptions by full URL (#448, #453). The
filtered URL is therefore its own upstream connection to
hive-c0re — when both /index.html and /flow.html are open at the
same time the worker holds two upstreams (one filtered, one full)
instead of today's single shared upstream. The trade is small
per-frame wire-byte + serialise savings vs +1 backend connection
in the both-open case. The flow-page-only case is a pure win.
Acceptable per #408's intent ("split flow messages from main
endpoint").

Round-trip note: if the backend's `kind_tag` strings ever drift
from what's listed here, the affected kind silently never matches
and flow loses that event class. damocles's #499 added a serde
round-trip test against `kind_tag` to guard the backend side;
the frontend mirrors the spelling exactly from the PR body.
2026-05-26 23:02:52 +02:00
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