mara: "make clickable badges a proper pill not a roundrect" (#4276).
.ui-badge's base border-radius: 1em is a genuine pill at its own
compact display-only height, but .ui-badge-interactive's min-height:
2.75em (the WCAG 2.5.5 touch-target floor) makes an interactive badge
tall enough that the fixed 1em radius no longer reaches half the box's
height -- the corners round without the sides ever meeting the same
curve, i.e. a rounded rectangle. border-radius: 999px is the standard
stadium-shape trick (the browser clamps it to exactly half the box's
height whenever it exceeds it), same value .hive-pill/.hive-pill-sm
already use. Shared component -- fixes every interactive Badge caller
(swarm-ui's WantedMenu/AgentCard/model-effort pickers, the per-agent
page's StatusChips/MetaNav/HeaderPill) in one place.
Verified with a real headless-chromium screenshot of the /components
Badge section before/after: the interactive "model sonnet"/"pause"
badges were visibly rectangular with rounded corners before this,
matching the non-interactive display badges' pill shape after.
mara, PR review: "make agentspage a subdir now that its split into
sub components". AgentsPage.tsx/.css, AgentCard.tsx/.css,
AgentTypes.ts, and WantedMenu.tsx move as a family into their own
pages/agents/ directory; CreateAgentForm and LinkMatrixAccountForm
stay in pages/ since they aren't part of this split (CreateAgentForm
is still rendered inside AgentsPage's own dialog but is a standalone,
independently-named form, not one of the pieces carved out of the
page itself).
Pure move: relative imports within the new pages/agents/ family are
unchanged (they were always siblings), only the ones reaching back
out to ui/ and the two forms above gained one more '../', plus
App.tsx's route import.
Three more asks from the same review thread:
- "agentspage is now giant and deserves a split" - AgentsPage.tsx was
1047 lines. Split into AgentTypes.ts (AgentRow and friends),
WantedMenu.tsx, AgentCard.tsx (+ its own CSS), leaving AgentsPage.tsx
as state/actions/columns/the render tree - 649 lines, and every piece
it composes is now independently readable.
- "what about the component that represents filtered data ... that the
card view and table can both use?" - extracted FilterableView
(ui/filterable-view/): takes columns/rows/rowKey/storageKey/view/
renderCard, builds its filter bar from *every* filterable column (not
a hand-picked subset - the old AgentFilterBar only showed 4 of the
agent columns' 6 filterable fields, an accidental gap the table's own
popovers didn't have), and renders either the card list or Table.
AgentsPage now just tells it which view to show; the view toggle
itself stays page-side since it's Panel-header chrome, not filtering.
Disclosed side effect: card view's filter bar now also covers
message/config-PR (text filters), matching table view exactly instead
of a narrower subset.
- CSS audit: AgentsPage.css now holds only what's genuinely page-specific
(the view toggle, the detail-panel field grid) - everything else moved
to its owning component's own colocated CSS.
FilterableView gets a /components demo (view toggle + filter bar + both
render modes, same day per the design guide). Verified: AgentsPage
still renders the same (real screenshot), and the demo's own table
toggle produces a real Table with the same rows.
argus, PR review: detailTarget stored the whole AgentRow object at
selection time, so it never picked up a later refresh() or a
declareState patch - the card list updated live, the panel next to it
kept showing whatever was true the moment it was opened. Concrete
repro: open an agent's detail panel, wait for the next refresh or
destroy it from inside the panel itself, watch the panel not update.
Fix: store only the selected agent's name (detailTargetName) and
re-derive the actual row from rows on every render
(rows?.find(r => r.name === detailTargetName)). The panel can't drift
from what the list is showing since it's reading the same array.
Verified with a real refresh cycle against a mock server that returns
different data on the second call: before, both card and panel show
"idle"; after a live refresh, both show the new value in sync.
Mara: "result looks like the shape i am looking for, but the code does
not. you did not follow component first principle" - the card's
clickable/selectable mechanics, the card-view filter trigger+popover,
and the list+detail split layout were all one-off page-local JSX in
AgentsPage.tsx instead of docs/web-ui/design-guide.md's "Component-first
design" primitives. Three new ui/ components, each with a same-day
/components demo section per that doc's own rule:
- ui/card/Card.tsx - the role=button/keyboard-activation/selected
mechanics AgentCard now wraps agent-specific content around, instead
of owning them itself.
- ui/multiselect-filter/MultiselectFilter.tsx - the checkbox-list
trigger+popover control. This was also a straight duplicate of
Table's own inline popover content once AgentsPage's filter toolbar
needed the identical thing; Table now renders the same
MultiselectFilterOptions piece too (keeping its own th-anchored
trigger and fixed+portal positioning, which are genuinely
table-specific), not a second copy.
- ui/split-view/SplitView.tsx - the list+detail flex-wrap layout, no
opinion on what's inside either pane.
AgentsPage.tsx's own CSS shrinks to just the agent-specific content
inside these primitives (card line/message layout, detail-panel field
grid, the name-search input) - the container/positioning rules moved
to each component's own colocated CSS.
No behavior change for any other Table caller (HivesPage,
IssueReportPage, the components demo's own Table samples) - the
popover's visual output is identical, just sourced from the shared
component instead of inline JSX.
Verified: typecheck/build clean, real screenshots of both AgentsPage
(pixel-identical to before) and the three new /components sections,
plus a live click confirming MultiselectFilter's popover opens
correctly on the demo page too.
Two more asks from mara's live review:
- "i want the same filters for the cards tho, thats why i suggested
separating data and filter from view" - extracted Table's filter
*state* (not its popover UI, which stays table-shaped) into a new
exported useTableFilters hook. Table calls it internally, unchanged
behavior for every existing caller. AgentsPage now calls the same hook
with the same storageKey, so card view and table view read/write one
shared filter state instead of each having their own (or cards having
none at all). Card view gets its own toolbar (AgentFilterBar) - a
name search input plus one FilterMultiselect per multiselect column,
same checkbox-list markup Table's own popover uses, driving the same
state. Switching the view toggle no longer loses or hides whatever's
filtered.
- "why no separate panel? i mean a second panel on agent page" - replaced
the modal Dialog with a real second Panel, always mounted (empty state
when nothing's selected, so selecting an agent never shifts the page's
own layout). Panel gained an optional `class` prop so the two panels
can flex-size themselves in a row. List/detail panels sit side by side
in a flex-wrap row that stacks on a narrow viewport - content-driven,
same approach the shell's own nav uses, not a second hardcoded
breakpoint. Selected card gets a highlight so it's clear which one the
detail panel is showing.
Verified with real CDP clicks: split layout with nothing selected,
selecting a card highlights it and populates the detail panel, opening
a card-view filter and checking a value narrows both the card list AND
(after switching the toggle) the table to the identical row set.
Three things from mara's PR review + argus's:
- "the info from main list should be included in the agent view" - detail
panel now repeats status/message/wanted alongside the panel-only fields
(hive, config PR, matrix link), not just the leftovers.
- "destroy is already available via wanted state" - dropped the standalone
"destroy agent" button; the detail panel's wanted field is a real
WantedMenu (default showDestroy) instead, same control as the card/table,
just with the fourth option back. Also resolves argus's stale-doc-comment
finding (the comment described a second WantedMenu call site that didn't
exist yet - now it does).
- Rebased onto main to pick up the just-merged dropdown-portal-clip fix -
this branch was cut before that merged, so it had silently regressed
back to the pre-fix Dropdown the whole time.
That rebase surfaced a real bug of its own, likely "the third screenshot
shows a layout bug": WantedMenu always passed `portal` to Dropdown, and a
portaled (position: fixed, body-appended) dropdown renders *behind* an
open native <dialog> - the dialog is promoted to the browser's top layer,
which composites above ordinary body content regardless of z-index. Only
the sliver of the dropdown extending past the dialog's own edge was
visible. WantedMenu's `portal` is now its own prop, opt-in, true only at
the table's call site (the one with an actual clipping ancestor to escape)
- card and detail-panel call sites render it as a plain child instead,
which is both correct inside the dialog and one fewer moving part where
it isn't needed.
Verified with real CDP clicks: detail panel shows all fields, and its
wanted dropdown now renders in the right place with all four options
visible instead of mostly hidden behind the dialog.
Mara's follow-up on #4257, right after the card-only version went up:
"still want the filters tho, maybe split the data component from the
view" / "if we split data component and view, we can make it switchable
between table and specialized card view."
AgentsPage already had its data/actions (rows, declareState, the
dialog-target state) separated from rendering by this point - the only
missing piece was a second renderer. Restores the original Table +
columns array as the "table" viewMode alongside the new card view,
adds a small two-button toggle in the Panel's actions row, and persists
the choice to localStorage (same pattern as Table's own per-column
filter persistence) so a reload keeps the last choice. Cards stays the
default.
Verified with a real CDP click switching to table view: columns,
sort arrows, and filter icons are all back.
Replaces AgentsPage's Table-rendered roster with one AgentCard per
agent: name/status/wanted on the first line, the free-form status
message on the second (mara, scoping #4257: "main view: name, status,
message, wanted" / "message as second line" / "more like card per
agent").
Everything the old table's other columns carried (hive, matrix
link-account, config-PR link, destroy) moves into a detail panel that
opens on card click, reusing the existing Dialog modal rather than a
new docked/slideover primitive - the shared hive-side-panel drawer is a
shadow-DOM custom element swarm-ui's esbuild config can't consume yet
(same gap Dialog.tsx's own comment already flags for hive-dialog).
WantedMenu gains a showDestroy flag: the card's own menu keeps the
three everyday states, destroy gets its own button in the detail panel
instead of a fourth dropdown entry next to states someone reaches for
often.
Known regression, flagged for follow-up rather than silently dropped:
the old table's per-column sort/filter has no replacement in this view
yet.
Backend list/detail endpoint split (also requested in #4257) is
deliberately left for a follow-up PR - it's an orthogonal optimization,
not required for this interaction to work correctly against the
existing single /api/agents/status response.
A `WantedMenu` badge dropdown on the agents table clips against
`.ui-table-scroll`s overflow the moment its row is the last (or
near-last) one — the popover extends past the table content the
scroll container bounds itself to. `Table.tsx` already solved the
identical clip for its own column-filter popover with a
position:fixed + portal computed from the anchor rect; `Dropdown` now
takes an optional `portal` prop that opts a caller into that same
recipe instead of a second hand-rolled copy of it. Off by default —
every other current caller (StatusChips x2, the components-page demo)
keeps its existing non-portal behavior unchanged.
For embedding companions (e.g. trollshell WebKitGTK) that supply their
own header/input chrome around the per-agent page and only want the
raw terminal feed. Comma-separated so both axes can hide in one param:
?hide=header,input. Zeroes --agent-header-h/--agent-composer-h via body
classes rather than conditionally computing padding, so every dependent
calc() (overlay offset, terminal scroll padding) collapses in one place.
mara (#4170): swarm-ui's wanted-state dropdown could only ever declare
up/offline/destroy, with no way to swarm-declare the existing hive-local
turn-loop pause (`hivectl agent pause|resume`).
`AgentState::Paused` is not a fifth peer of Up/Offline/Destroyed on the
power axis this enum otherwise answers — it's Up plus an orthogonal
turn-loop pause. `hive-c0re`'s `workers::wanted` reconcile loop now
decides the two axes independently (`decide` for power, the new
`decide_pause` for the marker), so a stopped agent declared Paused
converges with both a Start and a Pause in the same pass.
Known, deliberate limitation: a Paused declaration on an agent this
hive has never deployed only reaches Deploy this pass — writing the
pause marker into a harness dir that may not exist yet was judged not
worth the risk, so it converges on the next pass once the agent is
present instead.
swarm-ui's WantedMenu gains a fourth "paused" option (warning-tone
badge). No separate "resume" entry — selecting "up" from a paused row
already clears the marker via the same decide_pause path.
Pause/resume marker writes go through one shared
Coordinator::set_paused_by_name helper, used by both the interactive
dashboard pause/resume handlers and this reconcile loop, instead of
each duplicating the parse-name/write-marker/track-rescan shape.
swarm-ui's "offline" and "paused" confirm dialogs share one
confirmTarget state and one ConfirmDialog instead of two near-identical
copies.
Closes#4170
mara (#4172): the components page was out of date against its own
stated rule ("a new primitive gets a section here the same day it's
added"). Dialog and ConfirmDialog are real standalone exported
primitives (AgentsPage uses both directly) with no demo section.
FormField deliberately stays excluded — its own comment already says
it's not a reachable primitive, just TextField/SelectField's shared
internal wrapper, so it was never meant to get one.
Also gave the Table demo's "detail" column a multiselect filter, so
the checkbox popover and reset-filters button — real, daily-used Table
behavior — are actually shown rather than an unfiltered grid.
Per mara's screenshot report (agent-terminal icons not aligned in the
first column) — and her explicit follow-up steer on the first version of
this fix: 'dont do it by offsets at all, it should be part of the layout
that they align.'
Root cause traced first, not guessed: .row-glyph relied on inheriting
text-indent: -1.4em from .live .row to paint its glyph in the reserved
prefix slot; details.row > summary separately reset text-indent: 0 for
its own hanging-caret needs, which also zeroed the inherited value for
the icon nested inside it (indent inherits by computed value, not by
rule). Two independently-computed offsets that had to agree by hand,
and silently didn't.
Replaced the whole offset scheme with a real structural fix: every row
is a 2-column CSS grid (icon column, width from one shared
--row-icon-col custom property, then content column), and a details
row grids its own <summary> with the exact same grid-template-columns
value instead of griding itself (so its <pre> body still stacks full
width below, not squeezed into column 2). Icon and content are placed
by explicit grid-column, not auto-placement inference, so an icon-less
row's lone child still lands in the content column. .row-content is a
new wrapper class (Row.tsx, terminal.js's row()/mutableRow()/
placeholder()) giving that content an element the grid can target by
class - a DocumentFragment (what linkify() returns) doesn't persist as
a node once appended, so without an explicit wrapper there was nothing
for the grid to place.
Caught and fixed a second real bug while building this for real (not
just reasoning about the CSS): the first draft's details.row { display:
block } had lower specificity than .live .row's display: grid and never
actually applied, squeezing <summary> into the row's own 1.4em icon
column and wrapping its text one character per line. Needed
.live details.row to out-specify it.
Verified with a headless-chromium render of all 6 row shapes (flat
icon / flat icon-less / flat markdown-body / details icon / details
icon-less / a long-wrapping flat row) - all align and wrap correctly.
Also verified via the earlier Range.getBoundingClientRect() measurement
(glyph paint position, not just the element's own box): diff 0.00 for
both this and the previous fix, but only this one is structural rather
than two numbers that happen to still agree today.
Per mara's issue: tables should remember their filters (with a reset
button), the labels filter should be multi-select, and any filter should
support negation (search vs exclude).
All in the shared Table component (ui/table/Table.tsx), used by
AgentsPage/HivesPage/IssueReportPage:
- filters now persist via the same useLocalSetting hook IssueReportPage
already used for its own state, keyed by a new required storageKey
prop (required, not optional, so no caller can forget it and every
table gets persistence for free)
- a small 'reset filters' button clears every column's filter at once,
shown only when at least one is active
- new filterMode: "multiselect" (+ a filterValues extractor, alongside
the existing single-value filterValue) renders a checkbox list and
matches on any overlap - IssueReportPage's own bespoke label-checkbox
sidebar is folded into this instead of staying a second, separate
filter mechanism
- a negate toggle ('exclude') sits under every filter mode's control,
applying uniformly to text/select/multiselect
Verified: tsc --noEmit and the esbuild bundle both clean.
Extends PutMatrixAccountRequest with a mode field (token, the existing
behavior and default; or password). Password mode has swarm-controller
itself perform m.login.password against the caller-given homeserver
(mirrors hive-c0re's own /api/matrix-account-login for the hive-local
case) and stores the resulting token instead of a caller-supplied one
-- the password is used once, over this PUT, and never stored. Also
adds the 'main is reserved' guard hive-c0re's login form already has,
which swarm-controller had no equivalent of before this.
swarm-ui's link-matrix-account form gets a credential-mode toggle
wired to the same contract: token mode is unchanged, password mode
swaps the token field for user-id + password fields and makes
homeserver required (no hive-side fallback to resolve it against, per
PutMatrixAccountRequest::homeserver's own doc).
Per #4122.
The link-matrix-account form's placeholder read 'e.g. primary', which
sounds like it names the hive-provided default account. That account is
actually named main (reserved in hyperhive.matrixAccounts's schema) and
this route can neither create nor touch it. Swapped to an example with
no relationship to the real default, per #4122.
New per-row action on AgentsPage: a quiet-variant icon badge (LinkIcon,
matching the LinksMenu/SettingsMenu chrome-not-chip convention) opens a
dialog with an account/token/homeserver form, PUTting
/api/hives/{hive}/agents/{agent}/matrix-accounts/{account} per the
contract atlas posted on hyperhive#3726 (issuecomment-72355).
Built against the contract before the backend endpoint exists per
atlas's explicit note that it doesn't change when the implementation
lands -- this 404s until that item merges. No linked-accounts list:
no route exposes one, and a credential store shouldn't hand a secret
back out anyway, so this is a blind set/update action, matching
mara's 1:1-for-now ruling on the issue.
Verified: tsc --noEmit and nix fmt clean, esbuild build clean. Real
DOM-interaction screenshots against a throwaway mock server (deleted
before this commit, never tracked) -- table column render + disabled
state on a hiveless row, dialog open, form filled with the token
masked, and the success path end to end (token field clears, success
message shows) against a mocked 200 response.
argus caught this reviewing PR #4110: Badge only applied the disabled
prop inside its onClick-present branch (a real <button disabled>).
When onClick is undefined -- exactly the case every current caller
hits when its gating condition is false, since onClick and disabled
are computed off the same condition -- it fell through to a plain
<span> that never reads disabled and never gets the
.ui-badge-interactive class the dimming CSS depends on. WantedMenu
and the matrix-account trigger both silently lost the disabled
affordance to this.
Fixed at the root: both the button-vs-span branch and the
interactive-styling class now key off disabled || onClick (extracted
to one interactive flag) instead of onClick alone.
Verified: tsc --noEmit clean on both swarm-ui and agent (Badge's two
consumer packages), nix fmt clean. Screenshot against a throwaway
mock roster (deleted before this commit, never tracked) showing a
hiveless row and a destroyed row both visibly dimmed now, next to an
enabled row at full opacity -- previously all three looked identical.
argus's review (reproduced, not speculative): .ui-table-scroll sets
overflow-x: auto with overflow-y left unset. Per the CSS overflow
spec, an axis left visible computes to auto too once the other axis
isn't visible -- so this box silently clips vertically as well as
horizontally. The popover was position: absolute; top: 100% under its
header <th>, itself inside .ui-table-scroll -- on a table shorter than
header-plus-popover (a small hive's roster, or any table narrowed by
an existing filter), the popover got cut off at the scroll box's own
bottom edge, with a stray vertical scrollbar as the visible symptom.
Computes the popover's viewport position from the anchor <th>'s own
getBoundingClientRect() and renders it via a portal onto
document.body, position: fixed -- escapes .ui-table-scroll's clip the
same way any position: fixed element escapes an ancestor's overflow
(neither .ui-table-scroll nor .ui-table establishes a new containing
block). Recomputes on scroll (capture-phase window listener, the
standard technique for detecting scroll on a nested scroll container
without binding to every ancestor by hand) and resize while open, so
the popover stays anchored rather than only positioning once at click
time. createPortal comes from preact/compat, already resolvable
through the existing preact dependency -- no new package.json entry.
Verified against argus's own repro shape: a 1-row table, scripted
click on the filter icon (same real-DOM-event technique as this PR's
first round), screenshot shows the popover rendering fully rather than
clipped, no stray scrollbar.
Replaces the permanent filter-row under Table's headers with a small
filter-icon button in each filterable column's own header cell. The
icon fades in on header hover/focus via a CSS opacity transition, or
stays visible outright once that column actually has a filter set
(mara: "instead of a filter row, add little filter icons on header
hover with fade in out animation ... when a filter is set, the filter
icon does not disappear"). Clicking it opens a small anchored popover
directly under the header holding the exact same filter control
filterMode already provides (text input or select) -- the underlying
filter mechanism from hyperhive#4088 is unchanged, only where the
control lives moved. Close-on-outside-click/Escape mirrors the
contract Dropdown already gives its own popover, adapted to a shared
listener across every column instead of a ref per column since only
one popover is ever open at a time.
New FilterIcon in @hive/shared's icons.tsx (a plain inline SVG funnel,
same Feather/lucide-style shape as the existing GearIcon/LinkIcon) --
found and reused that pattern rather than reaching for an emoji glyph,
matching the documented reason those two exist as SVG in the first
place (mara, on the old emoji icons: inconsistent size/weight across
platforms).
Three columns gain Table's filterValue/filterMode (the mechanism
hyperhive#4088 added): title (text, substring search -- there was no
way to search by title text at all), assignees (text, not select --
a row can carry more than one assignee and Table's select mode
matches one whole string per row exactly, so substring search over
the joined string is the shape that actually fits multi-value data),
and blocked (select, synthesized "blocked"/"not blocked" strings --
distinct from the existing "hide blocked" toggle, which only hides
blocked issues and has no way to show only them).
Deliberately not touched: repo (redundant with the existing repo
SelectField), labels (redundant with the existing label chip
multi-select -- chips are the better UI for a bounded label set
anyway), the three numeric columns (no clean filter shape, already
sortable). The existing hand-rolled sort (SortHeader, useLocalSetting-
persisted) is untouched too -- migrating it onto Table's own sortBy
would drop the localStorage persistence this page specifically wants,
and Table doesn't expose controlled sort state to a caller today. The
two layers compose without conflict: Table's own filter/sort runs
over whatever rows it's handed, which is already this page's own
filtered+sorted array.
Scoped on the issue first, including this exact reasoning, before
writing any code.
Dialog previously forced every caller to a fixed width: 90vw; max-width: 44em
shell regardless of content — fine for the create-agent form's wide
two-panel layout (the only caller until now), but a ConfirmDialog's short
paragraph then wrapped at its own narrower max-width while the shell stayed
the wide default, leaving a dead gutter before the close button (mara filed
a screenshot showing exactly this).
First pass added a narrow prop/second CSS class for ConfirmDialog to opt
into a smaller fixed width. Review pointed at the actual root cause one
level up: width: 90vw is a forced width, not a cap — a native dialog's own
UA default is width: fit-content. Switching .ui-dialog to
width: fit-content; max-width: min(90vw, 44em) lets each caller size to its
own content naturally: the create-agent form still hits the 44em cap (same
rendered width as before, confirmed via screenshot), ConfirmDialog's
paragraph settles at its own intrinsic width with no extra prop, no second
CSS class, and no second hardcoded number to keep in sync with the first.
Table columns whose value only ever comes from a small closed set
(freshness, wanted, hive) get a <select> in their filter-row cell
instead of a free-text input, populated from the distinct values
present in the currently-loaded rows plus an "any" option, matched by
exact equality instead of substring. Free-text columns (name, the
agent's own status message, config PR, hive domain) are unchanged.
mara, hyperhive#4079: "agent wanted state is multiple buttons insteaf
of a badge with dropdown ... same pattern as agent term badges with
dropdowns". The wanted column used to be a toggle badge plus a
separate quiet destroy badge, stacking under each other in the narrow
column. Replaced both with one WantedMenu badge that opens a Dropdown
with the three explicit states (up/offline/destroy) -- the exact
badge-triggers-a-dropdown shape the per-agent terminals StatusChips
already uses (and swarm-uis own ComponentsPage already demos with
sample data), built from the same shared Badge/Dropdown components.
"up" still declares straight away with no confirmation; "offline" and
"destroy" still go through the existing ConfirmDialog modals unchanged
-- only the trigger moved, the confirm behavior for the two directions
that already had one is untouched.
Explicit dropdown options also fix a real bug the old toggle had:
mara also asked "when no state is declared, i want to set it to
online" -- the old toggle inferred a target as the opposite of
snapshot.running for an undeclared row, so a click on an
undeclared-but-running agent silently declared it offline rather than
making its actual state explicit. The dropdown just lets "up" be
picked directly regardless of any inference, which is what she is
asking for -- flagging this reading explicitly in case an actual
one-time migration (auto-declaring every currently-undeclared agent
up) was intended instead, which this does not do.
Added a shared .ui-dropdown-anchor utility class to Dropdown.css --
this is the third near-identical "position: relative wrapper for a
badge that opens a Dropdown" (after agents own StatusChips.css and
swarm-uis ComponentsPage.css), so a new caller should not reinvent a
fourth copy. Left the two existing ones alone rather than migrating
them as a drive-by.
Verified with a local esbuild build + a throwaway mock /api/agents/status
server, screenshotted headlessly: the wanted column now shows exactly
one badge per row instead of stacked badges.
argus, review: the primary wanted-toggle badge still had no
wanted === "destroyed" guard, so it stayed clickable on a destroyed
row and would PUT {state: "up"} on click -- directly contradicting
the destroy confirm dialogs own "not reversible from here" copy.
Missed this in the previous fix-up (which only addressed the
ConfirmDialog-for-stop ask). Added the guard to both the click handler
and disabled, plus a title explaining why on a destroyed row.
mara: "use the new component where we already have confirm dialogs."
The stop/offline direction of the wanted toggle used a native
window.confirm -- the reasoning at the time was that a hand-rolled
Dialog felt heavy for a reversible action, with the real Dialog confirm
reserved for the irreversible destroy direction. Now that
ConfirmDialog is a one-line-per-caller shared component, that argument
no longer buys consistency anything, so both directions confirm the
same way.
mara, reviewing the swarm-ui destroy-trigger PR: "why the extra
styling? shouldnt there be a component that does this already?" There
wasnt one -- Dialog is deliberately content-agnostic (see its own
file-top comment), so the destroy confirm had grown its own
page-scoped .agents-destroy-confirm/-actions CSS for what is really a
generic "message + cancel/confirm button row" shape.
Extracted ui/confirm-dialog/ConfirmDialog.tsx: wraps Dialog, owns the
button row, leaves the message body to the caller via children.
AgentsPage now uses it instead of a bare Dialog + bespoke CSS; deleted
the now-unused AgentsPage.css.
Closes#4067. Backend half (Wanted::Destroyed + reconcile) shipped in
#4065 with no swarm-controller API changes needed -- SetAgentStateRequest
already accepted {"state": "destroyed"}, it just had nothing in swarm-ui
sending it.
Destroy is a separate `quiet`-variant badge next to the existing
start/stop toggle (#3988's "wanted" column), not a third state folded
into that same click target -- one wrong click on a shared toggle
would be irreversible, where a dedicated badge only fires from its own
confirm dialog. That confirm is a real `Dialog`, not the native
`window.confirm` the reversible stop direction uses -- the wanted
column's own comment called this out as the case that would justify
one when it was first written.
Shared the PUT-declare/pending/error/patch-rows logic between the
existing toggleWanted and the new destroyAgent (declareState) rather
than duplicating it -- confirmation and target-state selection are the
only parts that differ between a toggle and a one-way declaration.
Verified: `tsc --noEmit` clean, `nix fmt` reports the expected
formatting-only diff, wire shapes (state string "destroyed",
AgentDeclaration response) checked against swarm-queue-client's
AgentState::as_str and swarm-controller's actual handler rather than
assumed from the issue description.
docs/web-ui.md duplicated the web-ui/ directory name at the top level --
the only such collision in docs/ (every other subsystem has just a
directory, no sibling <dir>.md file). That's exactly why it rendered
outside the directory structure in the docs site nav (mara's report,
hyperhive#4054): the site build walks docs/ generically with no
special-casing, so a loose top-level file next to a same-named
directory shows up as its own flat top-level entry instead of nesting
under that directory's section.
web-ui.md's own first paragraph already said as much -- 'This doc has
been split for readability... start at web-ui/README.md instead.' It
was a leftover pointer from before the split, not a page carrying
unique content on its own merit.
Folded its two sections web-ui/README.md didn't already have (the
swarm-ui design-guide link, and the task-oriented 'reading paths'
quick-lookup list) into web-ui/README.md's existing 'More depth'
section, then deleted the stray file and repointed every real
reference at it: 3 in-tree doc cross-links, 3 doc prose mentions
(retargeted to the more specific dashboard.md/shape.md sub-page each
one was actually about), and ~28 frontend source comments
(dashboard/agent/shared packages) that cited it as
'docs/web-ui.md::<heading>' for implementation context -- retargeted
each to whichever of dashboard.md/shape.md/agent.md actually carries
that heading now, verified against each file's real heading list
rather than guessed.
Verified via scripts/check-doc-refs.sh (the same lint CI runs): 0 dead
pointers, both before write (confirming the tree was clean beforehand)
and after (confirming nothing broke).
hyperhive#4020, mara. TableColumn gets two new optional extractors,
each one the whole signal for its capability (mara: 'why separate
selector and flag?' — dropped the sortable/filterable booleans that
would've said the same thing twice and could disagree with the
extractor's presence):
- sortBy: (row) => string | number — column is click-to-sort iff
present. Table owns the sort state (one active column, header click
cycles none -> ascending -> descending), since a rendered cell often
isn't the sortable value itself (e.g. AgentsPage's status column
renders a Badge, not a plain string).
- filterValue: (row) => string — column is filterable iff present. A
text input row under the headers, substring match case-insensitive.
Client-side only, no backend change - every consuming page already
fetches its full row set. Wired into AgentsPage and HivesPage, the two
pages with a real per-row Table. Left IssueReportPage alone (already
has its own purpose-built sort + label/hide-blocked filters, predates
this and covers its own domain better than a generic per-column text
filter would) and JobsPage alone (renders an indented state tree via
JobqGraph, no column table at all despite what my original scoping
comment assumed).
Verified: typecheck + build clean, nix fmt clean, static render of the
actual built CSS confirms the new sort-header + filter-row markup
doesn't break table layout.
mara: 'separate technical status from agent provided status string' —
the agents page's status column concatenated the freshness label with
the agent's own free-text status_text into one Badge pill, meant for a
short discrete label, not a full sentence. Long status strings blew
the row out and made the table look messy.
Now two columns: 'status' is just the freshness badge (fresh/stale/
never reported/not in swarm identity) plus relative time, unchanged in
meaning; 'message' is the agent's own status string as plain prose in
its own capped-width, wrapping cell (Table's new cellClass, ui-table-
prose), not a badge. A stopped agent (status_text always null per the
wire contract) shows an em dash there instead of a stale message.
Verified against a static render of the built CSS (real dark theme,
real Badge/table classes, the exact long strings from the screenshot
mara attached) — wraps cleanly within the column instead of
overflowing. Typecheck + build clean.
hyperhive#4008, mara: 'add a setting to not display verbose output...
like the grey colored debug stuff.'
Same shape as the existing expand-tool-output preference
(ExpandDetailsSetting.tsx): a new shared/src/prefs.ts key pair
(getHideDebugPref/setHideDebugPref), a new settings-menu-row component
owning its own state (HideDebugSetting.tsx, not a prop threaded through
the shared SettingsMenu component — per mara's earlier review on the
first one, more per-page options as props there is how that component
accumulates cruft), mounted next to ExpandDetailsSetting in Root.tsx.
Row.tsx skips (returns null for, not CSS display:none) any TermMsg
whose level is 'debug' when the pref is set — matches the muted 'debug'
row this issue is about (see docs/web-ui/terminal-rendering.md's Levels
table). Read live per-row, same as expand-details, so toggling applies
to newly streamed rows in an already-open tab without a reload; already
-rendered rows are unaffected either way, same non-retroactive
precedent the existing preference already sets.
An agent whose freshness is "unknown" is a distinct case, not a
fallback: it reported into the KV bucket but isn't in the swarm-identity
roster. The label used to just reuse the enum name ("unknown"), which
doesn't tell an operator what to do about it. Give it a label that
names the actual situation ("not in swarm identity") plus a tooltip
spelling out the fix (register/migrate it), so an operator scanning the
roster during a migration can immediately tell which agents still need
work.
No backend change needed — GET /api/agents/status already emits a row
for every hive-reported agent outside the roster (AgentStatusReader,
unit-tested as an_agent_outside_the_roster_is_surfaced_as_unknown), and
the page already renders every row it gets back.
hyperhive#3896. The backend for start/stop (Up/Offline wanted-state
declarations) already existed and was merged (#3905's writer, the
PUT /api/hives/{hive}/agents/{agent}/state route) — nothing here was
waiting on Paused/Destroyed, which I'd mistakenly conflated with this
issue in an earlier comment (that's #3803, a different feature).
swarm-controller: merges each row's declared wanted state into
GET /api/agents/status, same shape as the config_pr merge (one read
per distinct hive, not per agent, since a declaration is a hive's
whole agent map).
swarm-ui: AgentsPage gets a "wanted" column — clicking the current-
state badge toggles it (Badge's own chip-plus-control shape, same as
its own header comment's pause/resume example), backed by the PUT
route above. A row with no declaration yet reads its implied current
state off the agent's own last-reported running flag. Stop asks for
confirmation (native window.confirm — no confirm-dialog component
exists in swarm-ui yet); start doesn't.
Pure `nix fmt` output from the commit before this one — no hand edits.
203 files: 52 md, 42 tsx, 32 js, 32 css, 21 ts, 13 html, 8 json, 3 mjs.
Reproduce with `nix develop -c nix fmt` on the parent commit; the result
should be byte-identical to this tree.
None of the 13 `.prettierignore` entries appears here — verified by
intersecting the changed-file list against the ignore file, with a
control proving the intersection finds a match when one exists.
Per mara's review call on this PR: "the view should be filled by a single
backend call." AgentsPage.tsx was doing three fetches (/api/agents,
/api/config-prs, /api/agents/status) and joining them client-side by name.
Moves the config-PR join server-side instead: AgentStatusRow gains a
config_pr field, populated by get_agents_status's handler from
AppState::config_prs after agent_status::AgentStatusReader::view() returns
- not inside that module, which has no forge client and stays that way (see
the field's doc comment for why the handler is the right layer for this
merge, not the reader).
AgentsPage.tsx now does exactly one fetch and no client-side joining at all
- the wire row is the table row. Dropped the separate AgentStatusRow TS
interface (folded into AgentRow, which now mirrors the backend type
field-for-field) and the /api/agents + /api/config-prs fetches entirely;
neither is needed once /api/agents/status already returns every roster
agent with its config PR attached.
ConfigPrStatus gained Deserialize (previously Serialize-only) since
AgentStatusRow derives both and a struct's derive requires every field to
support it.
Continues #3341 item 3, unblocked now that #3568/#3569 (items 1/2)
are merged and GET /api/agents/status is live.
Third fetch alongside the existing roster + config-PR ones, joined
client-side by name same as the config-PR merge. Adds a hive column
and a status column (freshness badge + status_text + relative-time,
same rendering AgentsPage's sibling HivesPage already uses for the
hive-level status endpoint).
`docs/` was reorganised into topic subdirectories and the references to it
were not moved with it. Thirteen distinct paths and three relative links no
longer resolved, spread across nix, css, html, js, markdown and
.prettierignore — a stale pointer is not a markdown problem, it is wherever
someone wrote a path down.
Each mapping resolved to exactly one target. `docs/matrix.md` was the sole
ambiguous basename: .prettierignore lists `docs/tools/matrix.md` separately
and that entry still resolves, so the stale one is the integrations doc.
The three relative links were each one `../` too deep — from `<crate>/src/`
two levels reach the repo root. `hive-agent/src/login.rs` already had the
correct form, in the same crate, at the same depth.
.prettierignore is repointed rather than dropped, though nothing in the tree
runs prettier: no treefmt entry, no CI job, no package script. Whether that
config should exist at all is a separate question from whether it names
files that do.
Continues #3901 (dashboard round 1 was #3906, tabs.js/swarm.js).
Scanned the rest of packages/dashboard/src for the same 'moved to X'/
'now lives in Y' pattern: five more hits in schedules.js, common.js,
logs.js (two, one duplicating the other), core.js, and swarm.js.
All trimmed to state the current location as a fact rather than
narrating the move; one inline duplicate (logs.js's tab-default
comment restating the file-header pointer) dropped outright.
`check-doc-refs.sh` resolves the PATH half of a `docs/x.md::Section`
pointer and stops there. The section half rots the same way, and more
quietly: the file still exists, so every path-shaped check stays green
while the pointer names nothing.
Eighteen sites, five distinct pointers, each retargeted at a heading
verified to exist rather than at the nearest plausible one:
docs/web-ui.md::Container row
-> docs/web-ui/dashboard.md::Container row
docs/web-ui.md::Shared terminal pane
-> docs/web-ui/shape.md::Shared terminal pane
Both sections moved out when docs/web-ui.md became a two-heading
index. The path still resolves, which is exactly why nothing
caught them.
approvals.md::Helper events to the manager
-> approvals.md::Helper events to the submitting agent
Renamed with the manager special-casing removal; the pointer kept
the old vocabulary.
approvals.md::Migration from the pre-tag
-> approvals.md::Startup migrations (older hosts)
Same content, including the HIVE_SKIP_META_MIGRATION kill switch
the citing comment names.
agent-hierarchy.md::Current state
-> ::Where the tree lives (topology.rs, container_view.rs)
-> ::Reparenting (topology.rs's set_parent, host-sock)
Split by what each site actually asks for rather than repointed
uniformly: two want the format and the source-of-truth rule, two
want the reparenting validation.
Three known-dead pointers are deliberately left alone:
* `docs/integrations/forge.md::Sources` sits on a line PR #3927
rewrites; fixing it here would conflict for no gain.
* `docs/web-ui/shape.md::One unified channel` names real text that is
bold inline rather than a heading — which of those counts as
resolvable is the open question on #3922.
* `persistence.md::Harness state files` should point at a heading
whose own text contains backticks, and the backticked-pointer form
cannot nest them. That is a limit of the convention, not a typo.
Comments only; no behaviour change. Refs #3922.
Comments cite nix modules, scripts and crate source files constantly,
and nothing evaluates a comment — so when a file moves, the reference
rots silently and `nix flake check` stays green. A reader following one
finds nothing and cannot tell whether the file was renamed, deleted, or
never existed.
Seven such references, each repointed at the file that actually holds
the thing the sentence is about rather than at the directory the old
name became:
hive-c0re/src/agent_config/limits.rs hive-agent/src/mcp.rs
-> hive-agent-mcp/src/mcp/mod.rs
hive-agent-mcp/src/mcp/mod.rs hive-c0re/src/limits.rs
-> hive-c0re/src/agent_config/limits.rs
(and the module path in the doc
comment above it, which was stale
in the same way)
hive-c0re/src/forge/mod.rs hive-c0re/src/knowledge.rs
-> hive-c0re/src/workers/knowledge.rs
nix/host-modules/hive-c0re/options.nix hive-c0re/src/hive_stats.rs
-> hive-c0re/src/stats/hive_stats.rs
nix/packages/default.nix nix/host-modules/hive-c0re.nix
-> .../hive-c0re/options.nix
nix/agent-modules/network.nix nix/host-modules/hive-gateway.nix
-> .../hive-gateway/dnsmasq.nix
frontend/README.md nix/modules/frontend.nix
-> nix/packages/frontend.nix
The two `limits.rs` comments are a matched pair: each names the other's
old path, so the "keep in sync" instruction they exist to carry pointed
both ways at nothing.
Where a flat module became a directory the target is the file that
declares the named thing, not `default.nix` by reflex — the
`preBuildAgentTemplates` option is declared in `options.nix`, and the
DHCP pool that sentence is about lives in `dnsmasq.nix`.
Comments only; no behaviour change. Refs #3923, which is about whether a
gate should cover this class at all — that question is unanswered and
this does not close it.
Continues #3901 (dashboard/#3906, agent+swarm-ui/#3917, this closes out
the packages/shared slice — jobq-graph was already done separately).
Scanned the rest of shared/src for the same 'used to be X, now Y'
pattern: one real hit. The other two matches (hive-btn.js's is=
attribute history, hive-side-panel.js's one-word /* legacy */ comment)
are load-bearing or too trivial to touch.
Continues #3901. Same pattern as tabs.js/swarm.js: cut 'used to be
X, now Y'/'moved to'/'no longer' change-history narration down to
the current design fact, keep every load-bearing rationale intact
(the ResizeObserver feedback-loop note in Header.tsx, the Dropdown-
vs-real-<a> semantics in MetaNav.tsx, etc.).
Two of the three motion gaps mara flagged on the swarm-ui jobs graph
(the third, node status changes as a tree of nesting divs rather than
a node-link diagram, has no edges to animate today — see the issue
thread for that scoping correction).
- New node mount: .jg-node gets a fade+slide-in keyframe. No JS change
needed — Preact only creates a new .jg-node DOM node when its key
(the node id) is genuinely new, so this only plays on first
appearance, not every fetch re-render.
- State change flash: NodeView tracks each node's previous state via
a ref; on a real change it adds .jg-state-flash to the glyph span
(removed on animationend), driving a scale pulse.
Both follow the same three-rule motion-guard shape as Shell.css's
shell-page-enter / LinksMenu.css's links-menu-popover-enter.