diff --git a/frontend/packages/swarm-ui/src/pages/AgentsPage.css b/frontend/packages/swarm-ui/src/pages/AgentsPage.css
index 38e7f78f..d54367ff 100644
--- a/frontend/packages/swarm-ui/src/pages/AgentsPage.css
+++ b/frontend/packages/swarm-ui/src/pages/AgentsPage.css
@@ -8,11 +8,43 @@
flex-direction: column;
gap: 0.5em;
}
-.ui-agent-card-list-empty {
+/* Shared by both views (`Table` has its own `emptyMessage` rendering,
+ but the card view has nothing equivalent — this covers both, and
+ `AgentsPage` only mounts it when `rows` is loaded and empty). */
+.ui-agents-empty {
color: var(--muted);
text-align: center;
padding: 1.25em 0.75em;
}
+/* Two-way view switcher in the Panel's title-row actions — same visual
+ weight as `RefreshIntervalPicker` next to it (quiet until touched),
+ but a segmented pair rather than a select since there's no listbox
+ worth opening for a two-option choice. */
+.ui-agents-view-toggle {
+ display: inline-flex;
+ border: 1px solid var(--border);
+ border-radius: 0.4em;
+ overflow: hidden;
+}
+.ui-agents-view-toggle button {
+ border: none;
+ background: none;
+ color: var(--muted);
+ padding: 0.45em 0.75em;
+ font: inherit;
+ font-size: 0.9em;
+ cursor: pointer;
+}
+.ui-agents-view-toggle button + button {
+ border-left: 1px solid var(--border);
+}
+.ui-agents-view-toggle button.active {
+ background: var(--bg-elev);
+ color: var(--fg);
+}
+.ui-agents-view-toggle button:not(.active):hover {
+ color: var(--fg);
+}
.ui-agent-card {
display: flex;
flex-direction: column;
diff --git a/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx b/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx
index 24309aa3..b4c1d192 100644
--- a/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx
+++ b/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx
@@ -1,30 +1,30 @@
// — the swarm's agent roster, merged with each agent's open
-// config-PR status, swarm-wide health status, and declared wanted state
-// in the same table. Three separate asks (a roster listing, a per-agent
-// config-PR indicator, and per-agent status/liveness) that turned out to
-// be one page: a roster with no per-row detail is thin, and neither a
-// config-PR panel nor a status panel has anything to render against
-// without a roster to embed in.
+// config-PR status, swarm-wide health status, and declared wanted state.
+// A roster with no per-row detail is thin, and neither a config-PR nor a
+// status panel has anything to render against without a roster to embed
+// in — so all three ended up as one page.
//
// One fetch, on a refresh-interval cadence like HivesPage: `GET
// /api/agents/status` — one row per roster agent (identity store is the
// roster, so a never-reported agent still gets a row), each already
-// carrying its freshness/snapshot/config PR/wanted state. That merge
-// used to be three separate fetches (`/api/agents`, `/api/config-prs`,
-// `/api/agents/status`) joined client-side by name; per operator review
-// feedback ("the view should be filled by a single backend call") the
-// join moved server-side instead — see `swarm-controller/src/main.rs`'s
-// `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 row this page renders.
+// carrying its freshness/snapshot/config PR/wanted state. Used to be
+// three separate fetches joined client-side by name; per operator
+// review feedback ("the view should be filled by a single backend
+// call") the join moved server-side instead — see
+// `swarm-controller/src/main.rs`'s `get_agents_status` handler. No more
+// per-page joining left to 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. 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.
+// Two interchangeable views over the same `rows`/actions, picked by
+// `viewMode`: the original `Table` (full columns, per-column sort/
+// filter) and one `AgentCard` per row (see that component's own
+// comment for the card/detail-panel split). Mara, after the card view
+// landed: "still want the filters tho" / "split data component from
+// the view... switchable between table and specialized card view" —
+// so the toggle, not a card-only replacement.
//
// The "wanted" column is one `WantedMenu` badge+dropdown per row — see
// that component's own comment above `AgentsPage` for why.
@@ -45,6 +45,7 @@ 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";
@@ -142,6 +143,11 @@ const CONFIRM_COPY: Record<
// solve staleness rather than require an opt-in every visit.
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
+type ViewMode = "cards" | "table";
+// Persisted like `Table`'s own per-column filters (`storageKey` below) —
+// a chosen view is a standing preference, not a per-visit default.
+const VIEW_MODE_KEY = "swarm-ui:agents:view-mode";
+
// The "wanted" column's control: one badge showing the current
// declaration, opening a `Dropdown` with the four explicit states —
// replaces the old toggle-badge-plus-separate-destroy-badge pair.
@@ -247,15 +253,15 @@ 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.
+// wanted" / "message as second line" / "more like card per agent") —
+// the `viewMode === "cards"` alternative to the original `Table`, not
+// its replacement (see `AgentsPage`'s own comment). Everything the
+// table's other columns carried (hive, matrix link-account, config-PR
+// link, destroy) moves to a detail panel on card click instead — 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 yet (same gap `Dialog.tsx`'s own comment already
+// flags for `hive-dialog`). Revisit once that gap closes.
//
// A `role="button"` div, not a real `
: null}
{rows && rows.length === 0 ? (
-
+
no agents yet — the swarm-wide identity store has no agents registered
on any hive