From 601068f1ebee2e97542fdf6162183cb7dbde9dd9 Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 11 Sep 2026 22:30:38 +0200 Subject: [PATCH] swarm-ui/agents: split the page, extract FilterableView per mara's follow-up 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. --- .../packages/swarm-ui/src/pages/AgentCard.css | 24 + .../packages/swarm-ui/src/pages/AgentCard.tsx | 93 ++++ .../packages/swarm-ui/src/pages/AgentTypes.ts | 61 +++ .../swarm-ui/src/pages/AgentsPage.css | 62 +-- .../swarm-ui/src/pages/AgentsPage.tsx | 466 ++---------------- .../swarm-ui/src/pages/ComponentsPage.tsx | 47 ++ .../swarm-ui/src/pages/WantedMenu.tsx | 124 +++++ .../src/ui/filterable-view/FilterableView.css | 26 + .../src/ui/filterable-view/FilterableView.tsx | 179 +++++++ 9 files changed, 591 insertions(+), 491 deletions(-) create mode 100644 frontend/packages/swarm-ui/src/pages/AgentCard.css create mode 100644 frontend/packages/swarm-ui/src/pages/AgentCard.tsx create mode 100644 frontend/packages/swarm-ui/src/pages/AgentTypes.ts create mode 100644 frontend/packages/swarm-ui/src/pages/WantedMenu.tsx create mode 100644 frontend/packages/swarm-ui/src/ui/filterable-view/FilterableView.css create mode 100644 frontend/packages/swarm-ui/src/ui/filterable-view/FilterableView.tsx diff --git a/frontend/packages/swarm-ui/src/pages/AgentCard.css b/frontend/packages/swarm-ui/src/pages/AgentCard.css new file mode 100644 index 00000000..90a81e33 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/AgentCard.css @@ -0,0 +1,24 @@ +/* content layout — the clickable/selectable container + itself is `Card`'s (`ui/card/`); this is just the name/badges/ + message arrangement inside one. */ +.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; +} diff --git a/frontend/packages/swarm-ui/src/pages/AgentCard.tsx b/frontend/packages/swarm-ui/src/pages/AgentCard.tsx new file mode 100644 index 00000000..fcf04a0c --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/AgentCard.tsx @@ -0,0 +1,93 @@ +// — one card per roster agent (mara: "main view: name, +// status, message, wanted" / "message as second line" / "more like +// card per agent") — the FilterableView `view === "cards"` alternative +// to the original `Table`, not its replacement (see `AgentsPage`'s own +// comment). Clicking a card opens a detail panel repeating this same +// info plus what doesn't fit here (hive, config-PR link, matrix +// link-account) — mara: "the info from main list should be included in +// the agent view" too, not just the leftovers. +// +// Built on the shared `Card` primitive (`ui/card/`) for the clickable/ +// selectable-container mechanics — the `stopPropagation` wrapper around +// `WantedMenu` below keeps its own clicks (mouse or keyboard-synthesized, +// `WantedMenu` hosts real ` - ) : null} - - ); -} - export function AgentsPage() { const [rows, setRows] = useState(null); const [error, setError] = useState(null); @@ -628,9 +273,10 @@ export function AgentsPage() { const selectPaused = (row: AgentRow) => setConfirmTarget({ row, state: "paused" }); - // `viewMode === "table"`'s columns — the full field set (including - // per-column sort/filter, which the card view doesn't have) mara asked - // to keep. Table's own `WantedMenu` keeps the default `showDestroy` + // `FilterableView`'s `columns` — the full field set (including + // per-column sort/filter, which drives both the table's own popovers + // and the card view's filter bar now — see that component's own + // comment). Table's own `WantedMenu` keeps the default `showDestroy` // (the table's cells have room for the fourth option the card view // moved out to its detail panel). const columns: TableColumn[] = [ @@ -760,21 +406,6 @@ 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 ( <> ) : null} {!error && rows === null ?

loading…

: null} - {rows && rows.length === 0 ? ( -

- no agents yet — the swarm-wide identity store has no agents - registered on any hive -

- ) : 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" ? ( - - ) : null} - {rows && rows.length > 0 && viewMode === "cards" ? ( -
- {visibleRows.length === 0 ? ( -

- no rows match the current filter -

- ) : ( - visibleRows.map((a) => ( - setDetailTargetName(row.name)} - /> - )) - )} -
- ) : null} - {rows && rows.length > 0 && viewMode === "table" ? ( - a.name} storageKey="swarm-ui:agents:table-filters" + view={viewMode} + emptyMessage="no agents yet — the swarm-wide identity store has no agents registered on any hive" + renderCard={(a) => ( + setDetailTargetName(row.name)} + /> + )} /> ) : null} diff --git a/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx b/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx index 98280748..0e5536cb 100644 --- a/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx @@ -8,6 +8,7 @@ import { useRef, useState } from "preact/hooks"; import type { ComponentChildren } from "preact"; import { Card } from "../ui/card/Card.js"; +import { FilterableView } from "../ui/filterable-view/FilterableView.js"; import { MultiselectFilter, type MultiselectFilterState, @@ -243,6 +244,46 @@ function MultiselectFilterSample() { ); } +// A `view` toggle here is caller-owned (same contract `AgentsPage` uses) +// — `FilterableView` itself only renders whichever one this sample +// says to. Reuses `TABLE_COLUMNS`/`TABLE_ROWS` from the `Table` section +// above so the filter behavior is directly comparable between the two. +function FilterableViewSample() { + const [view, setView] = useState<"cards" | "table">("cards"); + return ( +
+
+ + +
+ r.name} + storageKey="swarm-ui:components-demo:filterable-view" + view={view} + emptyMessage="no rows" + renderCard={(r) => ( + {}}> + {r.name} +
{r.detail}
+
+ )} + /> +
+ ); +} + function ConfirmDialogSample() { const [open, setOpen] = useState(false); return ( @@ -365,6 +406,12 @@ export function ComponentsPage() { +
+ + + +
+
diff --git a/frontend/packages/swarm-ui/src/pages/WantedMenu.tsx b/frontend/packages/swarm-ui/src/pages/WantedMenu.tsx new file mode 100644 index 00000000..e45468e0 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/WantedMenu.tsx @@ -0,0 +1,124 @@ +// — the "wanted" state 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. Explicit options also fix a real bug the toggle had: for an +// undeclared row, the toggle inferred a target as "the opposite of +// `snapshot.running`", so a click on an undeclared-but-running agent +// silently declared it *offline* rather than making its real state +// explicit — a dropdown just lets "up" be picked directly regardless +// of what's inferred, no flip-to-the-opposite-of-a-guess involved +// (mara: "when no state is declared, i want to set it to online"). +// Presentational only, same split as `StatusChips`'s `Picker`/ +// `StatusMenu`: the caller owns what each selection actually does. +// +// "paused" has no separate resume option here — selecting "up" from a +// paused row is the resume, same PUT either way (`AgentsPage`'s +// `declareState`, `hive-c0re`'s `decide_pause` backend counterpart, +// treats a declared `Up` with the marker still set as "clear it"). A +// dedicated "resume" entry would just be a second spelling of the +// option already above it. +import { useRef, useState } from "preact/hooks"; +import { Badge, type BadgeTone } from "@hive/shared/badge.js"; +import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js"; +import type { AgentRow } from "./AgentTypes.js"; + +export function WantedMenu({ + row, + pending, + onSelectUp, + onSelectOffline, + onSelectPaused, + onDestroy, + showDestroy = true, + portal = false, +}: { + row: AgentRow; + pending: boolean; + onSelectUp: (row: AgentRow) => void; + onSelectOffline: (row: AgentRow) => void; + onSelectPaused: (row: AgentRow) => void; + onDestroy?: (row: AgentRow) => void; + /** + * The card view (see `AgentCard`) passes `false` here — its + * quick-access menu keeps to the three everyday states, so "destroy" + * can't be reached by an extra click off a state that's already open. + * `AgentsPage`'s detail-panel `WantedMenu` keeps the default (`true`) + * — mara: "destroy is already available via wanted state", i.e. the + * full menu there, not a separate button. + */ + showDestroy?: boolean; + /** + * Forwarded to `Dropdown`'s own `portal` (see its file-top comment) — + * only the table's "wanted" column needs it, to escape + * `.ui-table-scroll`'s clip on the table's last row (the bug `Dropdown`'s + * own `portal` prop exists to fix in the first place). + * Neither the card list nor the detail panel has a clipping ancestor, + * and inside the detail panel specifically `portal` is actively wrong: + * a `position: fixed` element appended to `document.body` renders + * *behind* an open native `` (the dialog is promoted to the + * browser's top layer, which composites above ordinary body content + * regardless of z-index) — found while screenshotting this exact + * dropdown open inside the detail panel, only the portion extending + * past the dialog's own edge was visible. + */ + portal?: boolean; +}) { + const [open, setOpen] = useState(false); + const anchorRef = useRef(null); + const destroyed = row.wanted === "destroyed"; + const tone: BadgeTone = + row.wanted === "up" + ? "positive" + : row.wanted === "paused" + ? "warning" + : destroyed + ? "negative" + : "neutral"; + const options: DropdownOption[] = [ + { value: "up", label: "up" }, + { value: "paused", label: "paused" }, + { value: "offline", label: "offline" }, + ...(showDestroy + ? [{ value: "destroy", label: "destroy", danger: true }] + : []), + ]; + + return ( +
+ setOpen((o) => !o) : undefined} + expanded={open} + disabled={pending || !row.hive || destroyed} + title={ + destroyed + ? `${row.name} is destroyed — redeploy via "+ agent" to bring it back` + : row.hive + ? `declare a new state for ${row.name}` + : "no hive on record for this agent — nothing to declare against" + } + /> + { + setOpen(false); + if (value === "up") onSelectUp(row); + else if (value === "offline") onSelectOffline(row); + else if (value === "paused") onSelectPaused(row); + else onDestroy?.(row); + }} + onClose={() => setOpen(false)} + anchorRef={anchorRef} + /> +
+ ); +} diff --git a/frontend/packages/swarm-ui/src/ui/filterable-view/FilterableView.css b/frontend/packages/swarm-ui/src/ui/filterable-view/FilterableView.css new file mode 100644 index 00000000..eb2cdf42 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/filterable-view/FilterableView.css @@ -0,0 +1,26 @@ +.ui-filterable-view-filter-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5em; + margin-bottom: 0.75em; +} +.ui-filterable-view-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-filterable-view-empty { + color: var(--muted); + text-align: center; + padding: 1.25em 0.75em; +} +.ui-filterable-view-cards { + display: flex; + flex-direction: column; + gap: 0.5em; +} diff --git a/frontend/packages/swarm-ui/src/ui/filterable-view/FilterableView.tsx b/frontend/packages/swarm-ui/src/ui/filterable-view/FilterableView.tsx new file mode 100644 index 00000000..9c619d6b --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/filterable-view/FilterableView.tsx @@ -0,0 +1,179 @@ +// — a filterable list of rows, switchable between the +// original `Table` and a caller-supplied card renderer, both reading +// the same filter state. Extracted out of `AgentsPage` (design-guide's +// "Component-first design" — mara: "what about the component that +// represents filtered data ... that the card view and table can both +// use?") once that page had its own hand-wired card-view filter bar +// (only 4 of the agent columns' 6 filterable fields) alongside `Table`'s +// own per-column popovers (all 6) — two filter surfaces, one accidentally +// narrower than the other, both driving `useTableFilters` under the hood +// already. This component is the one place that wiring lives now: build +// the filter bar from *every* filterable column, not a hand-picked +// subset, so a caller can't have the card view and table view disagree +// on what's filterable by construction. +// +// `view` is a controlled prop, not owned here — the toggle between +// "cards" and "table" is chrome that typically lives in a `Panel`'s +// title-row actions (`AgentsPage`'s own), not this component's body, so +// the caller keeps that state and just says which one to render. +import type { ComponentChildren } from "preact"; +import { + MultiselectFilter, + type MultiselectFilterState, +} from "../multiselect-filter/MultiselectFilter.js"; +import { Table, useTableFilters, type TableColumn } from "../table/Table.js"; +import "./FilterableView.css"; + +function isFilterable(c: TableColumn): boolean { + return c.filterValue !== undefined || c.filterValues !== undefined; +} + +function columnLabel(c: TableColumn): string { + return typeof c.header === "string" ? c.header : c.key; +} + +// One control per filterable column — a `MultiselectFilter` for +// `filterMode: "multiselect"`, a plain always-visible `` for the +// `"text"` default. No popover-vs-inline choice to make per column the +// way `Table`'s own icon-triggered popovers do: there's no header row +// to anchor an icon to here, every control is just always on. +function FilterBar({ + columns, + getFilter, + updateFilter, + toggleFilterValue, + resetFilters, + activeFilters, + multiselectOptionsFor, +}: { + columns: TableColumn[]; + getFilter: (key: string) => MultiselectFilterState & { value: string }; + 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 filterable = columns.filter(isFilterable); + if (filterable.length === 0) return null; + return ( +
+ {filterable.map((c) => { + const label = columnLabel(c); + if (c.filterMode === "multiselect") { + return ( + toggleFilterValue(c.key, v)} + onNegateChange={(negate) => updateFilter(c.key, { negate })} + /> + ); + } + return ( + + updateFilter(c.key, { + value: (e.target as HTMLInputElement).value, + }) + } + /> + ); + })} + {activeFilters.length > 0 ? ( + + ) : null} +
+ ); +} + +export function FilterableView({ + columns, + rows, + rowKey, + storageKey, + view, + renderCard, + emptyMessage, +}: { + columns: TableColumn[]; + rows: T[]; + rowKey: (row: T) => string; + // Shared 1:1 with `Table`'s own filter persistence — pass the same + // key `
` gets when it's the other `view`, so cards↔table never + // lose or hide an active filter (see this file's own comment). + storageKey: string; + view: "cards" | "table"; + renderCard: (row: T) => ComponentChildren; + // Shown when `rows` itself is empty. A filter narrowing a non-empty + // `rows` to zero visible rows gets its own fixed message instead + // (same as `Table`'s own "no rows match the current filter") — the + // two cases mean different things, same as `Table`'s own split. + emptyMessage?: ComponentChildren; +}) { + const { + visibleRows, + activeFilters, + getFilter, + updateFilter, + toggleFilterValue, + resetFilters, + multiselectOptionsFor, + } = useTableFilters(columns, rows, storageKey); + + if (view === "table") { + return ( +
+ ); + } + + return ( + <> + + {rows.length === 0 && emptyMessage ? ( +

{emptyMessage}

+ ) : null} + {rows.length > 0 && visibleRows.length === 0 ? ( +

no rows match the current filter

+ ) : null} + {visibleRows.length > 0 ? ( +
+ {visibleRows.map((row) => ( +
{renderCard(row)}
+ ))} +
+ ) : null} + + ); +}