swarm-ui/agents: shared filters across views, real second panel for detail

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.
This commit is contained in:
iris 2026-09-11 22:03:04 +02:00
commit 60d7e8ce84
4 changed files with 586 additions and 263 deletions

View file

@ -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;
}

View file

@ -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 (
<div
class="ui-agent-card"
class={
selected ? "ui-agent-card ui-agent-card-selected" : "ui-agent-card"
}
role="button"
tabIndex={0}
onClick={() => onOpenDetail(row)}
@ -363,6 +371,180 @@ function AgentCard({
);
}
// One `<input>` (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 `<input>`, 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<AgentRow>[];
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<AgentRow>) => 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 (
<div class="ui-agents-filter-bar">
<input
type="text"
class="ui-agents-filter-search"
placeholder="filter by name…"
aria-label="filter by name"
value={getFilter("name").value}
onInput={(e) =>
updateFilter("name", {
value: (e.target as HTMLInputElement).value,
})
}
/>
{hiveCol ? (
<FilterMultiselect
label="hive"
options={multiselectOptionsFor(hiveCol)}
filter={getFilter("hive")}
onToggle={(v) => toggleFilterValue("hive", v)}
onNegateChange={(negate) => updateFilter("hive", { negate })}
/>
) : null}
{statusCol ? (
<FilterMultiselect
label="status"
options={multiselectOptionsFor(statusCol)}
filter={getFilter("status")}
onToggle={(v) => toggleFilterValue("status", v)}
onNegateChange={(negate) => updateFilter("status", { negate })}
/>
) : null}
{wantedCol ? (
<FilterMultiselect
label="wanted"
options={multiselectOptionsFor(wantedCol)}
filter={getFilter("wanted")}
onToggle={(v) => toggleFilterValue("wanted", v)}
onNegateChange={(negate) => updateFilter("wanted", { negate })}
/>
) : null}
{activeFilters.length > 0 ? (
<button
type="button"
class="ui-table-reset-filters"
onClick={resetFilters}
>
reset filters
</button>
) : null}
</div>
);
}
// 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 `<dialog>` the way `WantedMenu`'s
// portal mode has to route around, so the simpler positioning is enough.
function FilterMultiselect({
label,
options,
filter,
onToggle,
onNegateChange,
}: {
label: string;
options: string[];
filter: { values: string[]; negate: boolean };
onToggle: (value: string) => void;
onNegateChange: (negate: boolean) => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handlePointerDown(e: PointerEvent) {
if (e.target instanceof Node && ref.current?.contains(e.target)) return;
setOpen(false);
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("pointerdown", handlePointerDown, true);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown, true);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
return (
<div class="ui-agents-filter" ref={ref}>
<Badge
variant="quiet"
value={
filter.values.length > 0
? `${label} (${filter.values.length})`
: label
}
onClick={() => setOpen((o) => !o)}
expanded={open}
/>
{open ? (
<div
class="ui-agents-filter-popover ui-table-filter-popover"
role="dialog"
aria-label={`filter by ${label}`}
>
<div class="ui-table-filter-multiselect">
{options.map((v) => (
<label key={v} class="ui-table-filter-checkbox">
<input
type="checkbox"
checked={filter.values.includes(v)}
onChange={() => onToggle(v)}
/>
{v}
</label>
))}
</div>
<label class="ui-table-filter-negate">
<input
type="checkbox"
checked={filter.negate}
onChange={(e) =>
onNegateChange((e.target as HTMLInputElement).checked)
}
/>
exclude
</label>
</div>
) : null}
</div>
);
}
export function AgentsPage() {
const [rows, setRows] = useState<AgentRow[] | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
@ -666,186 +848,226 @@ export function AgentsPage() {
},
];
// Same hook `Table` itself now uses internally, called again here with
// the identical `columns` + `storageKey` — one filter state (in
// localStorage), two independent readers. `rows ?? []`: the hook wants
// a real array, and there's nothing to filter before the first
// `refresh()` resolves anyway.
const {
visibleRows,
activeFilters,
getFilter,
updateFilter,
toggleFilterValue,
resetFilters,
multiselectOptionsFor,
} = useTableFilters(columns, rows ?? [], "swarm-ui:agents:table-filters");
return (
<Panel
title="agents"
icon="👥"
actions={
<>
<Button variant="primary" onClick={() => setCreateOpen(true)}>
+ agent
</Button>
<div class="ui-agents-view-toggle" role="group" aria-label="view">
<button
type="button"
aria-pressed={viewMode === "cards"}
class={viewMode === "cards" ? "active" : undefined}
onClick={() => setViewMode("cards")}
>
cards
</button>
<button
type="button"
aria-pressed={viewMode === "table"}
class={viewMode === "table" ? "active" : undefined}
onClick={() => setViewMode("table")}
>
table
</button>
</div>
<RefreshIntervalPicker
id="agents-refresh"
value={intervalMs}
onChange={setIntervalMs}
/>
</>
}
>
{error ? (
<ApiErrorPanel
context="failed to load the agent roster"
problem={error}
/>
) : null}
{!error && rows === null ? <p>loading</p> : null}
{rows && rows.length === 0 ? (
<p class="ui-agents-empty">
no agents yet the swarm-wide identity store has no agents registered
on any hive
</p>
) : null}
{rows && rows.length > 0 && viewMode === "cards" ? (
<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={selectUp}
onSelectOffline={selectOffline}
onSelectPaused={selectPaused}
onOpenDetail={setDetailTarget}
<>
<div class="ui-agents-layout">
<Panel
title="agents"
icon="👥"
class="ui-agents-list-panel"
actions={
<>
<Button variant="primary" onClick={() => setCreateOpen(true)}>
+ agent
</Button>
<div class="ui-agents-view-toggle" role="group" aria-label="view">
<button
type="button"
aria-pressed={viewMode === "cards"}
class={viewMode === "cards" ? "active" : undefined}
onClick={() => setViewMode("cards")}
>
cards
</button>
<button
type="button"
aria-pressed={viewMode === "table"}
class={viewMode === "table" ? "active" : undefined}
onClick={() => setViewMode("table")}
>
table
</button>
</div>
<RefreshIntervalPicker
id="agents-refresh"
value={intervalMs}
onChange={setIntervalMs}
/>
</>
}
>
{error ? (
<ApiErrorPanel
context="failed to load the agent roster"
problem={error}
/>
))}
</div>
) : null}
{rows && rows.length > 0 && viewMode === "table" ? (
<Table
columns={columns}
rows={rows}
rowKey={(a) => a.name}
storageKey="swarm-ui:agents:table-filters"
/>
) : null}
<Dialog
open={detailTarget !== null}
onClose={() => setDetailTarget(null)}
label={detailTarget ? `${detailTarget.name} details` : "agent details"}
>
{/* Mara: "the info from main list should be included in the
agent view" so this repeats status/message/wanted rather
than showing only what `AgentCard`'s main view doesn't, plus
hive/config-PR/matrix-link, which really are panel-only. */}
{detailTarget ? (
<div class="ui-agent-detail">
<h2 class="ui-agent-detail-name">{detailTarget.name}</h2>
<dl class="ui-agent-detail-fields">
<dt>status</dt>
<dd>
{(() => {
const { tone, label } = FRESHNESS[detailTarget.freshness];
return (
) : null}
{!error && rows === null ? <p>loading</p> : null}
{rows && rows.length === 0 ? (
<p class="ui-agents-empty">
no agents yet the swarm-wide identity store has no agents
registered on any hive
</p>
) : null}
{/* Card view's own filter toolbar table view keeps its
existing per-column popovers instead (see `columns` above);
both read/write the same `useTableFilters` state (same
`storageKey`), so switching the view toggle doesn't reset or
hide whatever's filtered. */}
{rows && rows.length > 0 && viewMode === "cards" ? (
<AgentFilterBar
columns={columns}
getFilter={getFilter}
updateFilter={updateFilter}
toggleFilterValue={toggleFilterValue}
resetFilters={resetFilters}
activeFilters={activeFilters}
multiselectOptionsFor={multiselectOptionsFor}
/>
) : null}
{rows && rows.length > 0 && viewMode === "cards" ? (
<div class="ui-agent-card-list">
{visibleRows.length === 0 ? (
<p class="ui-agents-empty">no rows match the current filter</p>
) : (
visibleRows.map((a) => (
<AgentCard
key={a.name}
row={a}
pending={pendingAgents.has(a.name)}
error={actionErrors.get(a.name)}
selected={detailTarget?.name === a.name}
onSelectUp={selectUp}
onSelectOffline={selectOffline}
onSelectPaused={selectPaused}
onOpenDetail={setDetailTarget}
/>
))
)}
</div>
) : null}
{rows && rows.length > 0 && viewMode === "table" ? (
<Table
columns={columns}
rows={rows}
rowKey={(a) => a.name}
storageKey="swarm-ui:agents:table-filters"
/>
) : null}
</Panel>
{/* A second on-page `Panel`, not a modal mara: "why no separate
panel? ... i mean a second panel on agent page." Always
mounted (an empty state when nothing's selected) rather than
conditionally rendered, so picking an agent never causes the
page's own layout to jump. `.ui-agents-layout`'s `flex-wrap`
stacks this below the list on a narrow viewport, same
content-driven-not-a-fixed-breakpoint approach the shell's own
nav already uses (`Shell.css`), rather than a new media query. */}
<Panel
title={detailTarget ? detailTarget.name : "agent details"}
icon="🔎"
class="ui-agents-detail-panel"
>
{detailTarget ? (
<div class="ui-agent-detail">
<dl class="ui-agent-detail-fields">
<dt>status</dt>
<dd>
{(() => {
const { tone, label } = FRESHNESS[detailTarget.freshness];
return (
<Badge
tone={tone}
value={
<>
{label}
{detailTarget.last_seen_unix !== null ? (
<>
{" "}
(
<RelativeTime
epochMs={detailTarget.last_seen_unix * 1000}
/>
)
</>
) : null}
</>
}
/>
);
})()}
</dd>
<dt>message</dt>
<dd>{detailTarget.snapshot?.status_text ?? "—"}</dd>
<dt>wanted</dt>
<dd>
{/* Full menu (destroy included) mara: "destroy is
already available via wanted state", no separate
button needed. */}
<WantedMenu
row={detailTarget}
pending={pendingAgents.has(detailTarget.name)}
onSelectUp={selectUp}
onSelectOffline={selectOffline}
onSelectPaused={selectPaused}
onDestroy={setDestroyTarget}
/>
</dd>
<dt>hive</dt>
<dd>{detailTarget.hive ?? "—"}</dd>
<dt>config PR</dt>
<dd>
{detailTarget.config_pr ? (
<Badge
tone={tone}
tone="warning"
value={
<>
{label}
{detailTarget.last_seen_unix !== null ? (
<>
{" "}
(
<RelativeTime
epochMs={detailTarget.last_seen_unix * 1000}
/>
)
</>
) : null}
</>
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>
<dt>message</dt>
<dd>{detailTarget.snapshot?.status_text ?? "—"}</dd>
<dt>wanted</dt>
<dd>
{/* Full menu (destroy included) mara: "destroy is
already available via wanted state", no separate
button needed. */}
<WantedMenu
row={detailTarget}
pending={pendingAgents.has(detailTarget.name)}
onSelectUp={selectUp}
onSelectOffline={selectOffline}
onSelectPaused={selectPaused}
onDestroy={(row) => {
setDestroyTarget(row);
setDetailTarget(null);
}}
) : (
"—"
)}
</dd>
</dl>
<div class="ui-agent-detail-actions">
<Badge
variant="quiet"
icon={<LinkIcon />}
value="link matrix account"
onClick={
detailTarget.hive
? () => setMatrixTarget(detailTarget)
: 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"
}
/>
</dd>
<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"
}
/>
</div>
</div>
</div>
) : null}
</Dialog>
) : (
<p class="ui-agents-empty">select an agent to see its details</p>
)}
</Panel>
</div>
<Dialog
open={createOpen}
onClose={() => setCreateOpen(false)}
@ -900,6 +1122,6 @@ export function AgentsPage() {
</p>
) : null}
</ConfirmDialog>
</Panel>
</>
);
}