swarm-ui/agents: per-agent card view + detail panel, split from the roster table

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.
This commit is contained in:
iris 2026-09-11 21:37:17 +02:00
commit 26b0b0d173
2 changed files with 294 additions and 175 deletions

View file

@ -0,0 +1,74 @@
/* <AgentCard> list a vertical stack of per-agent cards replacing the
old table body (mara: "more like card per agent"). Full-width rows,
not a grid: the second line (the agent's free-form status message)
reads better wrapping the card's own width than squeezed into a fixed
grid cell. */
.ui-agent-card-list {
display: flex;
flex-direction: column;
gap: 0.5em;
}
.ui-agent-card-list-empty {
color: var(--muted);
text-align: center;
padding: 1.25em 0.75em;
}
.ui-agent-card {
display: flex;
flex-direction: column;
gap: 0.35em;
padding: 0.75em 1em;
border: 1px solid var(--border);
border-radius: 0.5em;
background: var(--bg-elev);
cursor: pointer;
}
.ui-agent-card:hover,
.ui-agent-card:focus-visible {
border-color: var(--purple);
outline: none;
}
.ui-agent-card-line1 {
display: flex;
align-items: center;
gap: 0.6em;
}
.ui-agent-card-name {
font-weight: 600;
}
/* Pushes the wanted-state control to the row's trailing edge regardless
of how wide the status badge next to it ends up. */
.ui-agent-card-wanted {
display: inline-flex;
align-items: center;
gap: 0.4em;
margin-left: auto;
}
.ui-agent-card-message {
color: var(--muted);
white-space: normal;
word-break: break-word;
}
/* Detail panel (`Dialog` body) the fields `AgentCard`'s main view
doesn't show. */
.ui-agent-detail-name {
margin: 0 0 0.75em;
}
.ui-agent-detail-fields {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.4em 1em;
margin: 0 0 1em;
}
.ui-agent-detail-fields dt {
color: var(--muted);
}
.ui-agent-detail-fields dd {
margin: 0;
}
.ui-agent-detail-actions {
display: flex;
align-items: center;
gap: 0.75em;
}

View file

@ -17,13 +17,14 @@
// `get_agents_status` handler for where `config_pr`/`wanted` get merged
// in and why that's the handler's job rather than
// `agent_status::AgentStatusReader`'s. No more per-page joining left to
// do here: the wire row *is* the table row.
// do here: the wire row *is* the row this page renders.
//
// Owns the "+ agent" trigger too — the roster this populates is the
// natural home for the action that populates it; a separate top-level
// nav entry would be one click of indirection for no benefit. The form
// itself (`CreateAgentForm`) mounts inside a `Dialog` here rather than
// its own route.
// natural home for the action that populates it. The form itself
// (`CreateAgentForm`) mounts inside a `Dialog` here, not its own route.
//
// Renders as one `AgentCard` per row, not a `Table` — see that
// component's own comment below for the card/detail-panel split.
//
// The "wanted" column is one `WantedMenu` badge+dropdown per row — see
// that component's own comment above `AgentsPage` for why.
@ -44,9 +45,9 @@ import {
useRefreshInterval,
type RefreshIntervalMs,
} from "../ui/refresh-interval/RefreshInterval.js";
import { Table, type TableColumn } from "../ui/table/Table.js";
import { CreateAgentForm } from "./CreateAgentForm.js";
import { LinkMatrixAccountForm } from "./LinkMatrixAccountForm.js";
import "./AgentsPage.css";
interface ConfigPrStatus {
pr_number: number;
@ -69,8 +70,9 @@ interface AgentStatusSnapshot {
type Wanted = string | null;
// Mirrors `agent_status::AgentStatusRow` field-for-field — this *is* the
// table row now, not a shape assembled from it, so there's no separate
// join-result type to keep in sync with the wire contract by hand.
// row this page renders, not a shape assembled from it, so there's no
// separate join-result type to keep in sync with the wire contract by
// hand.
interface AgentRow {
name: string;
hive: string | null;
@ -165,13 +167,24 @@ function WantedMenu({
onSelectOffline,
onSelectPaused,
onDestroy,
showDestroy = true,
}: {
row: AgentRow;
pending: boolean;
onSelectUp: (row: AgentRow) => void;
onSelectOffline: (row: AgentRow) => void;
onSelectPaused: (row: AgentRow) => void;
onDestroy: (row: AgentRow) => void;
onDestroy?: (row: AgentRow) => void;
/**
* The card view (see `AgentCard` below) keeps this menu to the
* three everyday states and gives "destroy" the rare, one-way
* one its own button in the detail panel instead, so it can't be
* reached by an extra click off a state that's already open (mara,
* scoping the card/detail split: destroy belongs with the "rarely
* used actions" bucket, not next to up/paused/offline). The
* `WantedMenu` inside that detail panel still gets the full set.
*/
showDestroy?: boolean;
}) {
const [open, setOpen] = useState(false);
const anchorRef = useRef<HTMLDivElement>(null);
@ -188,7 +201,9 @@ function WantedMenu({
{ value: "up", label: "up" },
{ value: "paused", label: "paused" },
{ value: "offline", label: "offline" },
{ value: "destroy", label: "destroy", danger: true },
...(showDestroy
? [{ value: "destroy", label: "destroy", danger: true }]
: []),
];
return (
@ -222,7 +237,7 @@ function WantedMenu({
if (value === "up") onSelectUp(row);
else if (value === "offline") onSelectOffline(row);
else if (value === "paused") onSelectPaused(row);
else onDestroy(row);
else onDestroy?.(row);
}}
onClose={() => setOpen(false)}
anchorRef={anchorRef}
@ -231,6 +246,101 @@ function WantedMenu({
);
}
// One card per roster agent (mara: "main view: name, status, message,
// wanted" / "message as second line" / "more like card per agent",
// replacing the old table row). Everything else the old table's other
// columns carried (hive, matrix link-account, config-PR link, destroy)
// moved to the detail panel `AgentsPage` opens on card click — a plain
// `Dialog`, not the shared `hive-side-panel` slide-in drawer: that's a
// shadow-DOM custom element, and swarm-ui's esbuild config can't consume
// those at all yet (same already-tracked gap `Dialog.tsx`'s own comment
// notes for `hive-dialog`). Revisit once that gap closes. The old
// per-column sort/filter has no replacement yet in this view.
//
// A `role="button"` div, not a real `<button>`: the card also hosts the
// real `<button>`s inside `WantedMenu`, and nested buttons are invalid
// HTML. `onOpenDetail` fires for a click/Enter/Space landing on the card
// itself; the `stopPropagation` wrapper around `WantedMenu` keeps its own
// clicks (mouse or keyboard-synthesized) from also opening the panel, and
// the `e.target !== e.currentTarget` guard below does the same for keys.
function AgentCard({
row,
pending,
error,
onSelectUp,
onSelectOffline,
onSelectPaused,
onOpenDetail,
}: {
row: AgentRow;
pending: boolean;
error: ProblemDetails | undefined;
onSelectUp: (row: AgentRow) => void;
onSelectOffline: (row: AgentRow) => void;
onSelectPaused: (row: AgentRow) => void;
onOpenDetail: (row: AgentRow) => void;
}) {
const { tone, label } = FRESHNESS[row.freshness];
return (
<div
class="ui-agent-card"
role="button"
tabIndex={0}
onClick={() => onOpenDetail(row)}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpenDetail(row);
}
}}
>
<div class="ui-agent-card-line1">
<span class="ui-agent-card-name">{row.name}</span>
<Badge
tone={tone}
title={
row.freshness === "unknown"
? "reported by its hive but not registered in swarm-level identity — needs migration"
: undefined
}
value={
<>
{label}
{row.last_seen_unix !== null ? (
<>
{" "}
(<RelativeTime epochMs={row.last_seen_unix * 1000} />)
</>
) : null}
</>
}
/>
<span class="ui-agent-card-wanted" onClick={(e) => e.stopPropagation()}>
<WantedMenu
row={row}
pending={pending}
showDestroy={false}
onSelectUp={onSelectUp}
onSelectOffline={onSelectOffline}
onSelectPaused={onSelectPaused}
/>
{error ? (
<Badge
tone="negative"
value="failed"
title={error.detail ?? "the declaration failed"}
/>
) : null}
</span>
</div>
<div class="ui-agent-card-message">
{row.snapshot?.status_text ?? "—"}
</div>
</div>
);
}
export function AgentsPage() {
const [rows, setRows] = useState<AgentRow[] | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
@ -269,6 +379,10 @@ export function AgentsPage() {
// confirmation of a `declareState` call, it's an unrelated action with
// its own form (`LinkMatrixAccountForm`).
const [matrixTarget, setMatrixTarget] = useState<AgentRow | null>(null);
// The row showing the detail panel (hive, matrix link-account,
// config-PR link, destroy — everything `AgentCard`'s main view
// doesn't) — same null-means-closed shape as the two above.
const [detailTarget, setDetailTarget] = useState<AgentRow | null>(null);
async function refresh() {
const res = await fetch("/api/agents/status");
@ -370,162 +484,6 @@ export function AgentsPage() {
await declareState(row, "destroyed");
}
const columns: TableColumn<AgentRow>[] = [
{
key: "name",
header: "name",
render: (a) => a.name,
sortBy: (a) => a.name,
filterValue: (a) => a.name,
},
{
key: "hive",
header: "hive",
render: (a) => a.hive ?? "—",
sortBy: (a) => a.hive ?? "",
// "—" (not "") so the missing-hive option in the filter list reads
// the same as the cell itself, rather than showing a blank choice.
// Multiselect, not single-value `"select"` — an operator narrowing
// to a handful of hives at once shouldn't need to filter one at a
// time.
filterValues: (a) => [a.hive ?? "—"],
filterMode: "multiselect",
},
{
key: "status",
header: "status",
// Technical freshness only — the agent's own free-text status
// string used to be concatenated into this same badge, which
// stuffed a full sentence into a pill meant for a short discrete
// label and blew the row out (mara: "looks messy"). That string
// now lives in its own "message" column below.
sortBy: (a) => FRESHNESS[a.freshness].label,
// Multiselect, same reasoning as the hive column above.
filterValues: (a) => [FRESHNESS[a.freshness].label],
filterMode: "multiselect",
render: (a) => {
const { tone, label } = FRESHNESS[a.freshness];
return (
<Badge
tone={tone}
title={
a.freshness === "unknown"
? "reported by its hive but not registered in swarm-level identity — needs migration"
: undefined
}
value={
<>
{label}
{a.last_seen_unix !== null ? (
<>
{" "}
(<RelativeTime epochMs={a.last_seen_unix * 1000} />)
</>
) : null}
</>
}
/>
);
},
},
{
key: "message",
header: "message",
cellClass: "ui-table-prose",
// Plain text, not a Badge — the agent's own free-form status
// sentence, as distinct from the technical freshness state in the
// "status" column. A `running: false` snapshot always carries
// `status_text: null` (the wire contract's own rule), so a stopped
// agent just shows an em dash here rather than a stale message.
render: (a) => a.snapshot?.status_text ?? "—",
filterValue: (a) => a.snapshot?.status_text ?? "",
},
{
key: "wanted",
header: "wanted",
// One `WantedMenu` (badge + dropdown, defined above) per row —
// real per-badge logic lives there, this column just wires its
// callbacks to the page's own state/handlers.
sortBy: (a) => a.wanted ?? "",
// Multiselect, same reasoning as the hive column above.
filterValues: (a) => [a.wanted ?? "no declaration"],
filterMode: "multiselect",
render: (a) => {
const err = actionErrors.get(a.name);
return (
<>
<WantedMenu
row={a}
pending={pendingAgents.has(a.name)}
onSelectUp={(row) => void declareState(row, "up")}
onSelectOffline={(row) =>
setConfirmTarget({ row, state: "offline" })
}
onSelectPaused={(row) =>
setConfirmTarget({ row, state: "paused" })
}
onDestroy={setDestroyTarget}
/>
{err ? (
<Badge
tone="negative"
value="failed"
title={err.detail ?? "the declaration failed"}
/>
) : null}
</>
);
},
},
{
key: "matrix",
header: "matrix",
// Icon-only trigger, quiet variant (no permanent pill fill) — same
// "chrome, not a status chip" reasoning as `LinksMenu`/`SettingsMenu`'s
// own `Badge` triggers (see `@hive/shared/badge/Badge.tsx`'s
// `BadgeVariant` doc). Disabled with no hive on record, same guard
// `WantedMenu` uses — the endpoint is hive-scoped, there's nothing
// to PUT against without one.
render: (a) => (
<Badge
variant="quiet"
icon={<LinkIcon />}
value="link account"
onClick={a.hive ? () => setMatrixTarget(a) : undefined}
disabled={!a.hive}
title={
a.hive
? `link a matrix account to ${a.name}`
: "no hive on record for this agent — nothing to link against"
}
/>
),
},
{
key: "config-pr",
header: "config PR",
sortBy: (a) => a.config_pr?.pr_number ?? 0,
filterValue: (a) => (a.config_pr ? `#${a.config_pr.pr_number}` : ""),
render: (a) =>
a.config_pr ? (
<Badge
tone="warning"
value={
a.config_pr.html_url ? (
<a href={a.config_pr.html_url} target="_blank" rel="noreferrer">
#{a.config_pr.pr_number}
</a>
) : (
`#${a.config_pr.pr_number}`
)
}
/>
) : (
"—"
),
},
];
return (
<Panel
title="agents"
@ -550,15 +508,102 @@ export function AgentsPage() {
/>
) : null}
{!error && rows === null ? <p>loading</p> : null}
{rows ? (
<Table
columns={columns}
rows={rows}
rowKey={(a) => a.name}
emptyMessage="no agents yet — the swarm-wide identity store has no agents registered on any hive"
storageKey="swarm-ui:agents:table-filters"
/>
{rows && rows.length === 0 ? (
<p class="ui-agent-card-list-empty">
no agents yet the swarm-wide identity store has no agents registered
on any hive
</p>
) : null}
{rows && rows.length > 0 ? (
<div class="ui-agent-card-list">
{rows.map((a) => (
<AgentCard
key={a.name}
row={a}
pending={pendingAgents.has(a.name)}
error={actionErrors.get(a.name)}
onSelectUp={(row) => void declareState(row, "up")}
onSelectOffline={(row) =>
setConfirmTarget({ row, state: "offline" })
}
onSelectPaused={(row) =>
setConfirmTarget({ row, state: "paused" })
}
onOpenDetail={setDetailTarget}
/>
))}
</div>
) : null}
<Dialog
open={detailTarget !== null}
onClose={() => setDetailTarget(null)}
label={detailTarget ? `${detailTarget.name} details` : "agent details"}
>
{/* Everything `AgentCard`'s main view doesn't already show see
that component's own comment for the field split. */}
{detailTarget ? (
<div class="ui-agent-detail">
<h2 class="ui-agent-detail-name">{detailTarget.name}</h2>
<dl class="ui-agent-detail-fields">
<dt>hive</dt>
<dd>{detailTarget.hive ?? "—"}</dd>
<dt>config PR</dt>
<dd>
{detailTarget.config_pr ? (
<Badge
tone="warning"
value={
detailTarget.config_pr.html_url ? (
<a
href={detailTarget.config_pr.html_url}
target="_blank"
rel="noreferrer"
>
#{detailTarget.config_pr.pr_number}
</a>
) : (
`#${detailTarget.config_pr.pr_number}`
)
}
/>
) : (
"—"
)}
</dd>
</dl>
<div class="ui-agent-detail-actions">
<Badge
variant="quiet"
icon={<LinkIcon />}
value="link matrix account"
onClick={
detailTarget.hive
? () => {
setMatrixTarget(detailTarget);
setDetailTarget(null);
}
: undefined
}
disabled={!detailTarget.hive}
title={
detailTarget.hive
? `link a matrix account to ${detailTarget.name}`
: "no hive on record for this agent — nothing to link against"
}
/>
<Button
disabled={detailTarget.wanted === "destroyed"}
onClick={() => {
setDestroyTarget(detailTarget);
setDetailTarget(null);
}}
>
destroy agent
</Button>
</div>
</div>
) : null}
</Dialog>
<Dialog
open={createOpen}
onClose={() => setCreateOpen(false)}