diff --git a/frontend/packages/swarm-ui/src/pages/AgentsPage.css b/frontend/packages/swarm-ui/src/pages/AgentsPage.css
index d54367ff..aa59f3c6 100644
--- a/frontend/packages/swarm-ui/src/pages/AgentsPage.css
+++ b/frontend/packages/swarm-ui/src/pages/AgentsPage.css
@@ -60,6 +60,14 @@
border-color: var(--purple);
outline: none;
}
+/* The row currently shown in the detail panel — a solid border (not
+ just the hover tint above, which needs to keep meaning "hovering",
+ not double as "selected") plus a faint fill so it still reads once
+ the pointer moves away. */
+.ui-agent-card-selected {
+ border-color: var(--purple);
+ background: color-mix(in srgb, var(--purple) 10%, var(--bg-elev));
+}
.ui-agent-card-line1 {
display: flex;
align-items: center;
@@ -82,11 +90,8 @@
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;
-}
+/* Detail panel body — its own `Panel`, title supplied by the panel's
+ `title` prop (the agent's name), not repeated in here. */
.ui-agent-detail-fields {
display: grid;
grid-template-columns: auto 1fr;
@@ -104,3 +109,55 @@
align-items: center;
gap: 0.75em;
}
+
+/* List panel + detail panel side by side — `flex-wrap`, not a
+ `@media` breakpoint, so a narrow viewport stacks them the same
+ content-driven way the shell's own nav already wraps (`Shell.css`),
+ rather than picking a second, independent magic-number breakpoint. */
+.ui-agents-layout {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1em;
+ align-items: flex-start;
+}
+.ui-agents-list-panel {
+ flex: 2 1 480px;
+ min-width: 0;
+}
+.ui-agents-detail-panel {
+ flex: 1 1 320px;
+ min-width: 0;
+}
+
+/* Card view's filter toolbar — `Table`'s per-column popovers, minus the
+ table to hang them off of. */
+.ui-agents-filter-bar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5em;
+ margin-bottom: 0.75em;
+}
+.ui-agents-filter-search {
+ background: var(--bg);
+ color: var(--fg);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ padding: 0.35em 0.6em;
+ font: inherit;
+ font-size: 0.9em;
+}
+.ui-agents-filter {
+ position: relative;
+}
+/* `.ui-table-filter-popover` (Table.css) supplies the panel chrome
+ (background/border/shadow) — this just positions it under the
+ trigger, `position: absolute` rather than the fixed/portal recipe
+ `Table`'s own version needs: nothing here clips this popover, so the
+ simpler positioning is enough (see this component's own comment). */
+.ui-agents-filter-popover {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ margin-top: 0.25em;
+}
diff --git a/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx b/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx
index c299c144..c7344a54 100644
--- a/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx
+++ b/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx
@@ -28,7 +28,7 @@
//
// The "wanted" column is one `WantedMenu` badge+dropdown per row — see
// that component's own comment above `AgentsPage` for why.
-import { useRef, useState } from "preact/hooks";
+import { useEffect, useRef, useState } from "preact/hooks";
import type { ComponentChildren } from "preact";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
@@ -45,7 +45,7 @@ import {
useRefreshInterval,
type RefreshIntervalMs,
} from "../ui/refresh-interval/RefreshInterval.js";
-import { Table, type TableColumn } from "../ui/table/Table.js";
+import { Table, useTableFilters, type TableColumn } from "../ui/table/Table.js";
import { CreateAgentForm } from "./CreateAgentForm.js";
import { LinkMatrixAccountForm } from "./LinkMatrixAccountForm.js";
import "./AgentsPage.css";
@@ -289,6 +289,7 @@ function AgentCard({
row,
pending,
error,
+ selected,
onSelectUp,
onSelectOffline,
onSelectPaused,
@@ -297,6 +298,11 @@ function AgentCard({
row: AgentRow;
pending: boolean;
error: ProblemDetails | undefined;
+ /** This row is the one currently shown in the detail panel — a
+ * highlight only (the panel itself is the source of truth), so a
+ * page-refresh landing on a still-valid `detailTarget` reads as
+ * obviously "that one" rather than a silent selection nothing marks. */
+ selected: boolean;
onSelectUp: (row: AgentRow) => void;
onSelectOffline: (row: AgentRow) => void;
onSelectPaused: (row: AgentRow) => void;
@@ -305,7 +311,9 @@ function AgentCard({
const { tone, label } = FRESHNESS[row.freshness];
return (
onOpenDetail(row)}
@@ -363,6 +371,180 @@ function AgentCard({
);
}
+// One `` (the "name" column's plain-text filter) plus one
+// `FilterMultiselect` per multiselect-mode filterable column — the card
+// view's answer to `Table`'s own per-column popover icons, driving the
+// exact same `useTableFilters` state `AgentsPage` passes down (mara:
+// "same filters for the cards tho"). Deliberately not a generic
+// "render every filterable column automatically" loop: the name column's
+// free-text filter needs its own always-visible ``, not a
+// dropdown, so there's no single shape that covers every column anyway.
+function AgentFilterBar({
+ columns,
+ getFilter,
+ updateFilter,
+ toggleFilterValue,
+ resetFilters,
+ activeFilters,
+ multiselectOptionsFor,
+}: {
+ columns: TableColumn[];
+ getFilter: (key: string) => {
+ value: string;
+ values: string[];
+ negate: boolean;
+ };
+ updateFilter: (
+ key: string,
+ patch: Partial<{ value: string; values: string[]; negate: boolean }>,
+ ) => void;
+ toggleFilterValue: (key: string, value: string) => void;
+ resetFilters: () => void;
+ activeFilters: unknown[];
+ multiselectOptionsFor: (c: TableColumn) => string[];
+}) {
+ const hiveCol = columns.find((c) => c.key === "hive");
+ const statusCol = columns.find((c) => c.key === "status");
+ const wantedCol = columns.find((c) => c.key === "wanted");
+ return (
+
+ );
+}
+
+// One multiselect filter's trigger + popover — same checkbox-list markup
+// `Table`'s own popover renders (`.ui-table-filter-checkbox`/
+// `-negate`, reused rather than duplicated), just `position: absolute`
+// under a `Badge` trigger instead of a portal: this never sits inside a
+// clipping scroll container or a `