argus 🟡 on #504 — `font-variant-numeric: tabular-nums` has no
effect on `.rqe-step` since step strings are text-only
("nixos-container update" / "meta prepare_deploy" / etc.),
no digit columns to align. Harmless but cleaner without it.
Frontend half of #437 — paired with damocles's #501 backend which
adds `step: Option<String>` to `QueueEntry` and annotates 9
worker phases across ApplyCommit / Spawn / MetaUpdate / Rebuild
pipelines.
When `entry.step` is present, render a cyan `↳ <step>` sub-line
below the main row in `renderQueueEntry`. Wraps to its own flex
line via `flex-basis: 100%`, indented 1.8em to align under the
state glyph + kind. Terminal transitions clear `step` on the
backend, so the sub-line just disappears on Done / Failed rows —
no client-side staleness handling needed.
CSS: `.rqe-step` keys off cyan to visually group with the
running-spinner color cue (vs the muted `.rqe-when` /
`.rqe-reason` chips). `tabular-nums` keeps the indented arrow
column stable as step strings change length mid-build.
docs/web-ui.md updated to describe the new sub-line behaviour
in the R3BU1LD QU3U3 section.
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.
#406 was the JS-split refactor: pull shared helpers into
common.js (step 1), pull the flow-only IIFEs into flow.js
(step 2), then rename the legacy combined entry from app.js
to tabs.js (step 3 — this commit) to reflect that the bundle
now owns the dashboard tabs surface only.
What moved:
- `frontend/packages/dashboard/src/app.js` → `tabs.js`
- `build.mjs` entry: `src('app.js')` → `src('tabs.js')`;
output is now `static/tabs.js`
- `index.html` `<script>` src: `/static/app.js` →
`/static/tabs.js`; the FL0W-section comment retouched
- `flow.html` reference from `/static/app.js`-as-tabs to
`/static/tabs.js`; notif + offscreen-inbox comments rewired
to point at the correct owners (common.js for NOTIF,
flow.js for renderInbox)
- `common.js`, `flow.js`, `tabs.js` headers: scrub stale
"app.js" references; document that #406 steps 2 + 3 are
done and both pages import directly from common.js
- `dashboard.css` comments: every "by app.js" → "by tabs.js"
- `docs/web-ui.md`, `docs/conventions.md`: legacy
`assets/app.js` → `assets/tabs.js` (the path prefix is
itself stale from a much-earlier rename, but consistent
with the rest of those docs)
- `CLAUDE.md` file map: refresh the dashboard package's
src/ and dist/ layouts to reflect the post-split shape
(tabs.js, flow.js, common.js, stream-worker.js)
What did NOT move:
- `frontend/packages/agent/src/app.js` (per-agent UI entry)
stays named `app.js` — it's a separate package, has only
one entry point, no split happened there
- The two "no-op when target absent" guards in renderContainers
and renderQuestions are kept as belt-and-suspenders for any
future page that adds tabs.js without the corresponding
sections; comments updated to note this rather than the
pre-split flow.html reason
Functional behaviour identical; this is a pure rename + comment
sweep.
mara: "the 'timestamp from -> to' part is long enough to warrant
its own line. then the actual messages can be rendered (nearly)
full width."
Confirmed in the layout — previously `.live .msgrow .msg-body`
sat inline with `flex: 1 1 0`, eating whatever the chips left.
With a 14:23:42 timestamp + agent names + arrows that was ~30ch
of prefix; long bodies wrapped awkwardly.
One-line CSS fix: `flex: 1 1 100%` on `.msg-body` forces it to
wrap to its own flex line in the existing `flex-wrap: wrap`
container. Metadata chips stay on the row above; body takes the
full width down to the row's content edge.
`min-width: 0` retained so `word-break: break-word` keeps
working. Reply rows keep their `padding-left: 1.2em` border-left
indent — the body lands within that frame, matching the visual
"this is a reply" nesting.
Out of scope but worth knowing:
- consecutive-message grouping (one header per agent run) was
the second design option I floated; happy to land it as a
follow-up if reading still feels chatty.
- timestamp-on-hover was the third; skipping unless asked.
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).
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.
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.
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.
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).
damocles's #472 returns `{ ok, failed, missing, one_shot_consumed }`
from the fire-now endpoint. Parse the response and flash the
per-target outcome on the button itself for ~1.5s before
refreshSchedules() repaints — operator sees the result
immediately without a modal alert or an `/api/schedules`
re-fetch round-trip.
Button label transitions:
↯ fire now → ◐ firing… → ↯ fired: 3 ok, 1 missing — consumed
(green flash, then row refresh)
Operator wants to trigger a scheduled prompt immediately
instead of waiting for the next interval. Adds a `↯ fire now`
button on every active schedule row, next to `✕ cancel all`.
Semantics (per design discussion with damocles):
- recurring schedules → out-of-band pulse, `next_fire_at_unix`
untouched; the regular cadence keeps firing on the original
schedule. Operator gets an extra fan-out, not a phase shift.
- one-shots → consumed after the manual fire. Operator's
intent reads as "the scheduled time was wrong, send NOW";
leaving the original time would be surprising.
Confirm dialog spells out the recurring-vs-one-shot
difference up front so the operator knows what they're about
to do. Button is disabled when every target is already
cancelled (nothing to fire).
Talks to `POST /api/schedules/{id}/fire-now` (damocles is
wiring the backend in parallel). Mauve styling distinguishes
it from cancel (red) and submit (amber); fits the existing
btn pattern.
docs/web-ui.md updated.
Per damocles's PATCH /api/schedules/{id} backend (#475), each
non-cancelled schedule row gets a `✎ edit` button that toggles
an inline form pre-filled with current values. Editable:
- body (textarea)
- description (blank to clear)
- next-fire (datetime-local)
- interval (shared composer from #466 — all-zero flips to one-shot)
Targets stay immutable per the design call with damocles:
per-target last-result history is keyed on them; cancel + new
schedule is the documented retarget workaround. The form
surfaces the active target list as a read-only note explaining
this.
Submit semantics — the form computes a PATCH diff against the
original schedule and only includes keys for fields that
actually changed. Blank description → `null` (clear), all-zero
interval on a recurring schedule → `null` (flip to one-shot).
No-op submit (no fields changed) just closes the edit form.
Refactored the interval composer (#466) into a shared
`buildIntervalComposer({ label, namePrefix, initialSeconds })`
helper so the new-schedule and edit-schedule forms use the same
chip/d/h/m/s/preview widget. New-schedule form behavior
unchanged; edit-form input names are prefixed `edit_interval_`
to avoid FormData collisions when both happen to mount.
State survives state-poll re-renders via `editingSchedules`
(Set of ids being edited) + `scheduleEditCarry` (per-id
mid-edit values) — same pattern the new-schedule form uses
with `readScheduleFormCarry`.
Replaces the single raw-seconds field in the SCH3DUL3D PR0MPTS
creation form with a structured composer:
- Preset chip row (1m / 5m / 15m / 30m / 1h / 6h / 12h / 1d / 7d
/ one-shot) that fills the parts inputs in one click
- Four small d/h/m/s number inputs combined into total seconds
on submit (all-zero = one-shot, preserving the backend null
semantic)
- Live "↻ every …" preview using the existing fmtDuration helper
so the operator sees what they're about to queue
Carry semantics extended to round-trip the split fields across
state-poll re-renders; legacy `interval` carry still honoured if
an older bundle is in the page.
Docs (web-ui.md) updated to describe the new composer.
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.