Maintain a module-level containerRowCache (Map<name, {el, fingerprint}>)
that preserves <li> elements across renderContainers calls. Each row's
fingerprint encodes everything that affects its rendered output:
container running/login/update/reminder state, derived pending/opRunning
labels, tree position (depth, isLast, ancestorIsLast), selection, agent
question counts, and link-base context.
When the fingerprint is unchanged the existing DOM node is reused:
- no replaceChildren wipe for stable rows
- the async dashboard-state fetch (nav strip, ctx badge, status text)
is skipped — previously-fetched data stays in place
- DOM order is reconciled via insertBefore with zero layout work for
in-place nodes
Before this change every SSE event (container_changed, rebuild_queue_
changed, transient_set/cleared, question_added/resolved) caused a full
wipe + rebuild of the entire container list, triggering N concurrent
/api/dashboard-state fetches where N is the number of running agents.
After this change only the rows whose state actually changed are
rebuilt; the rest survive intact across re-renders.
If the operator had an agent:foo filter active and all of foo's questions
were resolved, foo's chip disappears from the filter row on the next
render — but the stored filter value is still agent:foo. The section
then shows "no questions match this filter" with no active chip visible,
leaving the operator confused.
Fix: compute the set of valid filter values (all, operator, peer, plus
one agent:<name> per current participant) before rendering. If the stored
value is not in the set, silently reset it to 'all'. Write directly to
localStorage rather than via setQuestionsFilter() to avoid a re-entrant
renderQuestions() call.
When a question_added or question_resolved SSE event fires, the handler
calls renderQuestions() directly — bypassing the snapshotOpenDetails /
restoreOpenDetails pair that refreshState wraps around it. This caused
the answered-history <details> (data-restore-key="q-history") to
collapse every time a question arrived or was resolved while the
operator had it open.
Fix: call snapshotOpenDetails() at the start of renderQuestions() and
restoreOpenDetails() at the end. The double-restore in the refreshState
path is harmless — re-opening an already-open <details> is idempotent,
and restoreOpenDetails is a no-op when the snapshot set is empty.
Two small fixes in tabs.js:
1. renderApprovalHistory showed 'spawn' for init_config approvals.
The pending-approvals path already handles the three kinds correctly
(apply / init / spawn); history used a two-branch ternary that fell
through to 'spawn' for init_config. Fixed to match the pending path.
2. The 1s interval that ticks rebuild-queue elapsed badges contained a
second loop polling '.build-logs-runtime[data-bl-elapsed]'. No code
in tabs.js ever sets that attribute — the logs page manages its own
elapsed timers inside logs.js. Removed the dead loop.
- Add 'capabilities-section' and 'tool-groups-section' to
MANAGED_SECTION_IDS so operatorIsTyping() covers them too
- applyCapabilitiesChanged and applyToolGroupsChanged skip re-render
when the operator has focus inside the section, preventing the
table from being torn down under an in-progress checkbox edit.
Tab-activation re-fetch is the recovery path for any missed event.
refreshTabCounts() compared entry.state against 'Queued' / 'Running'
(PascalCase) but QueueState serialises as snake_case per
#[serde(rename_all = "snake_case")] — wire values are 'queued' /
'running'. Tab badge was always 0 regardless of rebuild-queue depth.
Add CapabilitiesChanged and ToolGroupsChanged DashboardEvent variants
so the P3RM1SS10NS tab reflects perm changes without the operator
navigating away and back.
Backend:
- DashboardEvent::CapabilitiesChanged { seq, caps, descriptions,
assignments } — same payload shape as GET /api/capabilities
- DashboardEvent::ToolGroupsChanged { seq, groups, descriptions,
assignments } — same payload shape as GET /api/tool-groups
- Coordinator::emit_capabilities_snapshot() and
emit_tool_groups_snapshot() — read from the JSON files and broadcast
- rebuild_queue.rs PermChange worker: emit after each successful
commit_capabilities / commit_tool_groups call
Frontend:
- applyCapabilitiesChanged(ev): calls renderCapabilities(root, ev)
- applyToolGroupsChanged(ev): calls renderToolGroups(root, ev)
- Both registered in MUTATION_HANDLERS
- activateTab comment updated (SSE now covers perm changes)
Docs: dashboard.md and CLAUDE.md updated.
This completes SSE coverage for all dashboard sections: SW4RM,
Y3R C4LL, SYST3M, SCH3DUL3S/reminders, and P3RM1SS10NS all
derive live updates from /dashboard/stream.
- activateTab('schedules') now calls both refreshSchedules() and
refreshReminders() since both sections live on the same tab.
(The previous SYST3M/system target was wrong.)
- Update index.html comment to reflect schedules_changed SSE coverage
- Update index.html reminders comment to mention reminders_changed SSE
- Update tabs.js reminders section comment to reflect SSE coverage
Add RemindersChanged SSE event so the pending-reminders list in the
SYST3M tab updates live without polling.
Backend emission sites (every path that mutates the reminders table):
- agent_server: store_remind (remind MCP call)
- dashboard.rs: post_cancel_reminder, post_retry_reminder
- questions.rs: cancel_loose_end Reminder kind
- reminder_scheduler: after each delivery batch (any_delivered)
Coordinator gets emit_reminders_snapshot() mirroring the existing
emit_schedules_snapshot() pattern: lists PendingReminder rows from the
broker and emits DashboardEvent::RemindersChanged.
Frontend: applyRemindersChanged(ev) calls renderReminders(ev.reminders)
and is registered as reminders_changed in MUTATION_HANDLERS.
Docs: dashboard.md reminders_changed entry; CLAUDE.md file map updated.
Add `SchedulesChanged` to the dashboard event channel so the
operator's schedule list updates in real time without requiring a
tab-activation or form-submit refresh.
Backend:
- `dashboard_events.rs`: new `SchedulesChanged { seq, schedules }`
variant carrying a full `Vec<WireSchedule>` snapshot (same
snapshot-over-diff rationale as `RebuildQueueChanged`).
- `coordinator.rs`: `emit_schedules_snapshot()` helper — queries the
scheduled_prompts list, converts to wire shape, broadcasts the event.
- `dashboard.rs`: call `emit_schedules_snapshot()` at the end of each
operator API handler that mutates a schedule:
`post_schedule_new`, `post_schedule_fire_now`,
`patch_schedule`, `post_schedule_cancel`.
- `scheduled_prompts_worker.rs`: call `emit_schedules_snapshot()`
after each tick that fires schedules, so `last_fired_at_unix`,
`next_fire_at_unix`, and reaped one-shots surface live.
Frontend:
- `tabs.js`: add `applySchedulesChanged(ev)` — replaces
`schedulesState` from the snapshot and calls `renderSchedulesList()`.
Registered in `MUTATION_HANDLERS` as `schedules_changed`.
Tab-activation re-fetch kept as safety net for approval-path
inserts and disconnect windows; comment updated to reflect this.
Docs:
- `docs/web-ui/dashboard.md`: document `schedules_changed` event.
- `CLAUDE.md`: add `SchedulesChanged` to the file-map entry.
The old measurement used tabbar.clientWidth which includes horizontal
padding (~2em / ~30px total), causing an over-allocation of the same
amount. Flex gap between tabs (0.2em) was also not counted in the
accumulated offsetWidth, adding another ~20px of error. Together these
caused the algorithm to leave too many tabs visible, overflowing the
bar and pushing the ⋮ button off the right edge of the screen.
Replace the clientWidth + cumulative-offsetWidth approach with
getBoundingClientRect: the cutoff is the tabbar's actual right edge
minus its right padding, minus a reserve for the overflow wrapper.
Individual tabs are overflowed once their right edge exceeds the
cutoff; subsequent tabs are overflowed unconditionally to keep the
visible set contiguous and left-anchored.
Now that the dashboard fetches agent-owned state directly from
GET /api/dashboard-state (via gateway), hive-c0re no longer needs
to read those fields from disk on the agent's behalf.
Removed from ContainerView:
ctx_tokens, context_window_tokens, rate_limited,
extra_links, status_text, status_set_at
Removed from container_view.rs:
DashboardLink struct, build_nav_links, read_dashboard_links,
read_status, is_rate_limited, read_last_turn, resolve_ctx_window
(and the resolve_ctx_window unit tests)
Removed from dashboard.rs:
GET /api/agent/{name}/links route + get_agent_links handler
dashboard JS (tabs.js):
Merged rate_limited, ctx-window badge, and status-text rendering
into the async dashboard-state fetch block. c0re still provides
needs_login (auth sentinel on host), needs_update, pending_reminders,
running, deployed_sha, parent — all genuinely host-side fields.
hive-ag3nt now exposes a lean DashboardState endpoint that returns the
agent-owned fields the dashboard card needs:
- status_text / status_set_at (from hyperhive-status on disk)
- ctx_tokens / context_window_tokens (from Bus)
- rate_limited (from Bus)
- links (from agent_links — includes screen link for GUI agents,
which c0re's disk-based fallback cannot determine)
The dashboard fetches `${containerBase}/api/dashboard-state` instead of
the previous c0re proxy `/api/agent/{name}/links`. For gateway deployments
this is a same-origin call to the agent via the gateway's unix-socket
upstream; for direct TCP it hits the agent port directly. Both paths fail
gracefully (empty strip) when the agent is starting up.
The main behaviour fix: GUI agents now show the screen link in the dashboard
nav strip. c0re's build_nav_links reads /etc/hyperhive/gui.json from outside
the container (not possible), so it always omitted the screen link.
Add a '❓ N' badge to each container row showing the count of pending
questions where the agent is the asker (awaiting an answer) or the
target (owes a reply). Derived live from questionsState — no backend
field needed.
Details:
- New .badge-loose-ends CSS class (purple, matching Q33R1ES section colour)
- Badge tooltip breaks down the count: 'N asked, N to answer'
- renderContainersFromState() called on QuestionAdded/QuestionResolved
so the badge updates instantly when question state changes
- Existing ⏰ reminders badge kept separate (different signal)
The pending-label derivation had no case for op.kind === 'restart', so
it fell through to the rebuild/rebuild-queued else branch — showing a
misleading 'rebuild pending' badge when the operator issued a restart.
Add explicit 'restarting' / 'restart queued' cases alongside the
existing meta_update and destroy cases.
Also add restart / startup_sweep / perm_change to QUEUE_KIND_GLYPH so
they render with a meaningful glyph (↺ / ⚡ / 🔑) instead of '?' in
the rebuild-queue panel.
Remove the deployed sha plain-text badge from the card head.
Add a forge-linked menu item to the three-dot menu instead:
'deployed:abc123def012 ↗' opens agent-configs/<name>/commit/<sha>
on the hive forge in a new tab.
Only shown when both c.deployed_sha and forgeBase are present.
buildAgentMenu now takes forgeBase as second arg; call site passes
the same forgeBase computed for nav-strip links.
CSS: add text-decoration: none + box-sizing to .agent-menu-item
so <a> link items render identically to the <button> items.
- Wrap table in .schedules-table-wrap (overflow-x: auto) so it
never exceeds the page width on narrow viewports
- Add .schedules-table-next-col (width: 8em) and
.schedules-table-every-col (width: 7em) with white-space: nowrap
to stop those columns from stealing space from the body column
- Align visual style with the perm page tables: unified border on
all cells (was: border-top row separator only), bg-elev thead,
font-size 0.82em (was 0.9em), min-width: 100% on the table itself
Drop the 'h-sock :8421' meta span from the agent card head.
ContainerView.container is now #[serde(skip)] since no frontend
code reads it; the field stays in Rust for internal use.
c.port remains serialized — used for fallback agent UI URLs.
- hive-sh4re: ToolGroup::description() and Capability::description() return
short human-readable strings for each variant
- hive-c0re: ToolGroupsSnapshot and CapabilitiesSnapshot now include a
`descriptions` map (name → description); get_capabilities now iterates
Capability::ALL instead of hardcoding the list
- tabs.js: renderToolGroups + renderCapabilities use descriptions[name] as
the column header title attribute (native browser tooltip on hover)
- rename cap-cap-col → cap-col; add CSS rules for .cap-col and .cap-save-col
- drop zero-width-space replace in capability header cells (nowrap makes it a no-op)
- add Capability::ALL to hive-sh4re; validate incoming cap strings in post_capabilities
Add a new P3RM1SS10NS tab to the dashboard that consolidates all
per-agent permission configuration:
Backend:
- GET /api/capabilities returns { caps: [...], assignments: {...} }
driven by Capability::ALL variants (manage_root_agent,
read_host_journal, query_agent_state)
- POST /api/capabilities/{agent} writes capabilities.json and queues
a rebuild so HIVE_CAPABILITIES takes effect
Frontend:
- New 'permissions' entry in TABS, placed after 'system'
- P3RM1SS10NS tab pane with two sections:
C4P4B1L1T13S — agents × capabilities checkbox matrix (.cap-*)
T00L GR0UPS — agents × tool-groups checkbox matrix (.tg-*) moved
from SYST3M tab
- activateTab('permissions') fetches both tables; neither has an SSE
channel so they re-fetch on each activation to stay fresh
- CSS for .cap-* mirrors the .tg-* layout (scrollable, Catppuccin)
- common.js fetchStateFile: 'HTTP' -> 'http' — last uppercase instance
in the frontend (tabs.js, app.js, logs.js already fixed in prior PRs)
- tabs.js: two redundant arrow wrappers in setTimeout dropped —
setTimeout(() => f(), N) -> setTimeout(f, N) where f takes no args
- Replace innerHTML = '' with replaceChildren() throughout tabs.js and
app.js (12 + 7 sites). paintAtomic already used replaceChildren; now
the direct-clear sites are consistent with it.
- Add missing .rqe-source-startup_sweep CSS rule (startup_sweep is a
valid QueueSource variant but had no badge style, falling through to
the base muted appearance with no explicit intent).
- Lowercase the one uppercase 'HTTP ' in the fetchAndRenderToolGroups
error path to match every other fetch error in the file.
overflowBtn/overflowDrop/overflowWrap were declared after syncTabFromHash()
was invoked. activateTab() calls updateTabbarOverflow() which closes over
these consts — hitting them in the TDZ threw ReferenceError on every page
load, also preventing fetchAndRenderToolGroups() from running (tool-groups
section stuck at loading).
Selecting any option in the M0V3 dropdown now fires confirm+POST
immediately; no separate button needed. On cancel or after the
request completes, the select resets to the placeholder. Removes
the now-redundant btn-move button and its CSS rule.
Remove the separate '⇡ M0V3 → ROOT' button from the selection bar.
Add '(no parent)' as the first real option in the existing M0V3
dropdown — selecting it submits an empty new_parent, which the backend
already treats as 'promote to root'.
The select placeholder label changes to '⇢ M0V3 →' so the combined
control reads naturally without the old standalone button. The submit
button enables as soon as any option past the placeholder is chosen
(selectedIndex > 0), which correctly covers both '(no parent)' and
named-parent selections.
Section heading, element id, CSS classes, and JS functions all renamed
from 'capabilities'/'cap-*' to 'tool-groups'/'tg-*' to accurately
describe what the UI manages (tool-group permissions, not a capabilities
system).
Backend (hive-c0re/src/dashboard.rs):
GET /api/tool-groups — returns { groups: [...], assignments: {...} };
groups list comes from ToolGroup::ALL so the UI needs no change when
a new group is added (satisfies the 'no extend ui' requirement)
POST /api/tool-groups/{agent} — accepts { groups: [...] }, calls
set_groups() then enqueues a rebuild so the new HIVE_TOOL_GROUPS
env var takes effect immediately
hive-sh4re/src/lib.rs:
Added ToolGroup::ALL const (ordered slice of every group)
Added ToolGroup::as_str() — snake_case wire name, matches serde
Frontend:
SYST3M tab: new C4P4B1L1T13S section above K3PT ST4T3 with
#capabilities-section placeholder
tabs.js: fetchAndRenderCapabilities() + renderCapabilities() —
columns are built from the groups array returned by the API;
each row has one checkbox per group and a save button that POSTs
and re-fetches after 800ms; agents without explicit assignments
show a (default) label; triggered on each SYST3M tab activation
dashboard.css: .cap-table-wrap/.cap-table/.cap-row/.cap-agent-*
styles for the scrollable matrix table
dashboard.css used var(--surface0/1/2) and var(--text) throughout the
agent context menu, tabbar overflow dropdown, and logs toolbar — none
of which are declared in base.css (the palette only defines --bg-elev,
--border, --purple-dim, --fg etc). This caused all three dropdowns to
render with a transparent background (issue #996).
Replacements:
--surface0 → --bg-elev (dropdown background)
--surface1 → --border (hover / active state)
--surface2 → --purple-dim (border / separator)
--text → --fg (label colour)
Also addresses two argus yellows from the PR #997 review:
- Skip updateTabbarOverflow() early when the dropdown is open; avoids
DOM flicker from the 1s badge-update tick while the menu is visible.
- Fall back to 32px when overflowBtn.offsetWidth === 0 (hidden on the
first render) so the initial width measurement is not off.
Add a ⋮ overflow button at the right end of the dashboard tab strip.
SETTINGS and LOGS always live in the overflow dropdown (marked
data-overflow="default" in index.html — never rendered in the main bar).
Dynamic overflow: when the bar is too narrow to fit all remaining tabs,
rightmost tabs spill into the dropdown right-to-left. Implemented via
JS measurement + ResizeObserver; no CSS-only relayout trick needed.
The ⋮ button:
- hidden when the dropdown is empty (wide screens with only the default
tabs overflowed, which are always there anyway → button always shown)
- gets .tabbar-overflow-active when the current hash tab is inside
- dropdown items clone the tab label + count badge from the original
- closes on outside click / Escape
MutationObserver on the tabbar catches P33RS/M4TR1X hidden-attribute
changes so the overflow recalculates when those tabs are shown/hidden.
Add /logs.html as a standalone page (same back-link pattern as flow.html):
- BUILD tab: all-agents build log history via new GET /api/build-logs endpoint
- AGENT tab: per-container journald viewer with agent selector + unit filter
- SYSTEM tab: host-side hive-c0re.service logs via new GET /api/journal-host endpoint
Remove inline log drill-ins from SW4RM container rows (buildJournalTrigger
and buildBuildLogsTrigger) — log viewing now lives on the dedicated page.
flow.html: strip the full dashboard tabbar, replace with a simple back link
matching the new logs page chrome.
index.html: add L0GS tab link to /logs.html in the tab strip.
Backend additions:
- build_logs::list_recent_all — cross-agent query (newest first, cap 100)
- GET /api/build-logs — all-agents variant backed by list_recent_all
- GET /api/journal-host — host journald (no -M container flag), restricted
to allow-listed units (hive-c0re.service)
Per mara feedback: don't show disabled actions at all.
- restart/stop only rendered when agent is running
- start only rendered when agent is stopped
- removed .agent-menu-item:disabled CSS rule (no longer needed)
Also switch Escape handler to stopImmediatePropagation so the
selection-clear keydown listener doesn't co-fire when closing a menu.
Each agent card in the SW4RM tab now has a ⋮ button that reveals a
dropdown with single-agent lifecycle actions: R3ST4RT, ST0P, ST4RT,
R3BU1LD, DESTR0Y, PURG3. Items are state-aware (disabled when action
doesn't apply). The button is CSS-invisible until the row is hovered
or the menu is open — quiet rows stay clean. DESTR0Y and PURG3 are
hidden for the manager container.
Click-outside and Escape close the menu. A tree-rebuild (on any SSE
state update) also closes it to avoid stale DOM references.
Three post-rename artifacts:
1. journalUnit: was 'hive-m1nd.service' for the manager branch.
Both roles use 'hive-ag3nt.service' since the harness unification —
always use 'hive-ag3nt.service'.
2. buildTargetChips: hardcoded ['operator', 'manager'] candidates +
filter by n !== 'manager'. After the rename the manager's c.name
is 'root', so it was excluded from the filter but not from the
hardcoded initial list — the manager could appear twice or be listed
as 'manager' when its real broker name is 'root'. Fix: derive
the manager's name from c.is_manager on containersState; filter
sub-agents by !c.is_manager rather than by hardcoded string.
3. schedulesTableAgentSet: same hardcoded 'manager' + filter pattern
as buildTargetChips. Same fix applied.
Part of the docs-migration chore (issue #708). Remove GitHub issue
numbers from inline comments, option descriptions, and rustdoc —
these are contextless noise for anyone reading the code without
access to the original discussions. Replace with prose that captures
the same rationale directly.
No functional change. Build still clean (cargo check passes).
Container link URLs (/screen.html, /stats.html) were root-relative,
which works when the agent is accessed directly at :port/ but resolves
to the gateway root when accessed via /agent/<name>/.
Fix at the source: harness emits relative URLs (screen.html, stats.html)
and the dashboard joins containerBase + '/' + url. The agent page uses
the URL directly and relative resolution is now correct in both paths.
Adds the P33RS tab to the dashboard. The tab is hidden by default and
only shown when at least one peer hive is configured in
`services.hyperhive.peers`. When visible, it renders a card per peer
with a link to that hive's dashboard.
Backend (HYPERHIVE_PEERS env var, PeerHiveView in StateSnapshot) is
wired by a separate PR. This commit is frontend only:
- index.html: P33RS tab strip entry + tab-pane-peers section
- tabs.js: renderPeerHives(), gate logic, 'peers' added to TABS array
Dashboard links to the forge were hardcoded as http://<hostname>:3000,
which breaks when the operator accesses the dashboard through
hive-gateway (forge is served at forge.<domain> with no port).
- nix/modules/hive-c0re.nix: inject HIVE_FORGE_PUBLIC_URL when
forge.behindGateway = true (e.g. http://forge.pr1ma.darkest.space)
- hive-c0re/src/dashboard.rs: expose forge_url: Option<String> in
StateSnapshot, reading from HIVE_FORGE_PUBLIC_URL
- frontend/packages/dashboard/src/tabs.js: use state.forge_url when
present; fall back to http://<hostname>:3000 for gateway-off /
local-dev deploys
Distinguish RecvError::Lagged from Closed in the build-log stream
loop: Lagged continues (next recv delivers the full accumulated delta
via saved cursors), Closed returns (shutdown path).
Clear pre.textContent and reset cursor lengths in es.onerror when
readyState is CONNECTING so auto-reconnect doesn't double-append the
replay from cursor=0.
Backend (hive-c0re):
- build_logs.rs: add tokio::sync::broadcast::Sender<i64> to BuildLogs;
append() and finish() notify subscribers after each sqlite write.
Add BuildLogProgress struct and get_progress(id, stdout_cursor,
stderr_cursor) for incremental delta reads.
- dashboard.rs: two new endpoints —
GET /api/build-logs/id/{id}/stream SSE; streams BuildLogFrame
{stdout_append, stderr_append, status?, done} deltas until the
build finishes or the browser disconnects. Backed by an mpsc
channel task that watches the per-build broadcast notifications.
GET /api/build-logs/id/{id}/raw text/plain download with
Content-Disposition: attachment; filename build-log-{agent}-{id}.txt
Frontend (dashboard):
- tabs.js: running builds (status === null) connect an EventSource to
/stream and append lines live; "live" badge pulses amber while active,
flips to ok/fail on done. Finished builds still use the JSON fetch path.
Collapsing a running panel closes the EventSource; re-expanding
reconnects. Adds a "⬇ raw" download link to every expanded row.
- dashboard.css: .build-logs-dl inline download link; .build-logs-live
live pulse @keyframes animation.
Docs: web-ui.md updated for all three new endpoints + behaviour.