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.
This commit is contained in:
iris 2026-09-11 22:30:38 +02:00
commit 601068f1eb
9 changed files with 591 additions and 491 deletions

View file

@ -0,0 +1,24 @@
/* <AgentCard> 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;
}

View file

@ -0,0 +1,93 @@
// <AgentCard> — 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 `<button>`s) from also firing `Card`'s
// `onClick` and opening the detail panel.
import { Badge } from "@hive/shared/badge.js";
import type { ProblemDetails } from "@hive/shared/api-error.js";
import { Card } from "../ui/card/Card.js";
import { RelativeTime } from "../ui/relative-time/RelativeTime.js";
import { FRESHNESS, type AgentRow } from "./AgentTypes.js";
import { WantedMenu } from "./WantedMenu.js";
import "./AgentCard.css";
export function AgentCard({
row,
pending,
error,
selected,
onSelectUp,
onSelectOffline,
onSelectPaused,
onOpenDetail,
}: {
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 selection 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;
onOpenDetail: (row: AgentRow) => void;
}) {
const { tone, label } = FRESHNESS[row.freshness];
return (
<Card selected={selected} onClick={() => 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>
</Card>
);
}

View file

@ -0,0 +1,61 @@
// Wire + presentation types shared across AgentsPage and the components
// it composes (AgentCard, WantedMenu) — split out once those moved to
// their own files (mara: "agentspage is now giant and deserves a
// split") so none of them has to import from AgentsPage.tsx itself.
import type { BadgeTone } from "@hive/shared/badge.js";
export interface ConfigPrStatus {
pr_number: number;
html_url: string | null;
}
export type Freshness = "fresh" | "stale" | "never_reported" | "unknown";
export interface AgentStatusSnapshot {
status_text: string | null;
status_set_at: number | null;
running: boolean;
}
// `"up"` / `"offline"` / `"destroyed"`, wire-spelled by
// `swarm_queue_client::wanted::AgentState::as_str` — kept as a bare
// `string | null` rather than a union, same reason `config_pr`'s shape
// isn't re-derived here: this renders whatever the wire sends, it
// doesn't validate the enum client-side.
export type Wanted = string | null;
// Mirrors `agent_status::AgentStatusRow` field-for-field — this *is*
// the row AgentsPage 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.
export interface AgentRow {
name: string;
hive: string | null;
freshness: Freshness;
// `age_seconds` deliberately unused here, same reason as HivesPage:
// `RelativeTime` recomputes age client-side from `last_seen_unix`
// rather than rendering a once-computed-at-fetch value.
last_seen_unix: number | null;
snapshot: AgentStatusSnapshot | null;
config_pr: ConfigPrStatus | null;
wanted: Wanted;
}
// Same tone pairing as HivesPage — one freshness enum shared by both
// endpoints, so the same tone rule applies to both pages — but
// `unknown`'s label diverges deliberately: an agent outside the
// swarm-identity roster has a concrete next step (register it), while
// an unrecognized *hive* on HivesPage doesn't carry the same
// actionable meaning.
export const FRESHNESS: Record<Freshness, { tone: BadgeTone; label: string }> =
{
fresh: { tone: "positive", label: "fresh" },
stale: { tone: "warning", label: "stale" },
never_reported: { tone: "neutral", label: "never reported" },
// `unknown` means the agent reported into the KV bucket but isn't in
// the swarm-identity roster — a real, distinct case ("hive-reported,
// not yet in swarm level"), not just a fallback default, so it gets a
// label that says what to do about it rather than reusing the enum
// name as the label.
unknown: { tone: "negative", label: "not in swarm identity" },
};

View file

@ -1,18 +1,7 @@
/* <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. Each card's own clickable/selected/border styling is
`Card`'s (`ui/card/`) this file only lays out agent-specific
content inside one. */
.ui-agent-card-list {
display: flex;
flex-direction: column;
gap: 0.5em;
}
/* 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). */
/* Detail panel's "select an agent" empty state. `FilterableView`
(`ui/filterable-view/`) has its own equivalent for its two empty
cases colocated there instead of shared with this one, since the
two components have no other reason to depend on each other's CSS. */
.ui-agents-empty {
color: var(--muted);
text-align: center;
@ -47,27 +36,6 @@
.ui-agents-view-toggle button:not(.active):hover {
color: var(--fg);
}
.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 body its own `Panel`, title supplied by the panel's
`title` prop (the agent's name), not repeated in here. */
@ -88,25 +56,3 @@
align-items: center;
gap: 0.75em;
}
/* Card view's filter toolbar — `Table`'s per-column popovers, minus the
table to hang them off of. Each `MultiselectFilter` (`ui/
multiselect-filter/`) owns its own trigger/popover styling; this file
only lays out the row they sit in plus the name-search input, which
has no shared-component equivalent (a plain text input). */
.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;
}

View file

@ -18,28 +18,24 @@
// natural home for the action that populates it. The form itself
// (`CreateAgentForm`) mounts inside a `Dialog` here, not its own route.
//
// 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.
import { useRef, useState } from "preact/hooks";
// This file owns the page's own state/actions/`columns` only —
// `AgentRow` and friends live in `AgentTypes.ts`, the wanted-state
// control in `WantedMenu.tsx`, the card content in `AgentCard.tsx`
// (mara: "agentspage is now giant and deserves a split"). Filtering +
// cards-vs-table rendering is `FilterableView` (`ui/filterable-view/`,
// see its own comment) — `viewMode` here just says which one to show.
import { 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";
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js";
import { Badge } from "@hive/shared/badge.js";
import { LinkIcon } from "@hive/shared/icons.js";
import { AgentCard } from "./AgentCard.js";
import { FRESHNESS, type AgentRow } from "./AgentTypes.js";
import { Button } from "../ui/button/Button.js";
import { Card } from "../ui/card/Card.js";
import { ConfirmDialog } from "../ui/confirm-dialog/ConfirmDialog.js";
import { Dialog } from "../ui/dialog/Dialog.js";
import { MultiselectFilter } from "../ui/multiselect-filter/MultiselectFilter.js";
import { FilterableView } from "../ui/filterable-view/FilterableView.js";
import { Panel } from "../ui/panel/Panel.js";
import { RelativeTime } from "../ui/relative-time/RelativeTime.js";
import {
@ -48,65 +44,12 @@ import {
type RefreshIntervalMs,
} from "../ui/refresh-interval/RefreshInterval.js";
import { SplitView } from "../ui/split-view/SplitView.js";
import { Table, useTableFilters, type TableColumn } from "../ui/table/Table.js";
import { type TableColumn } from "../ui/table/Table.js";
import { CreateAgentForm } from "./CreateAgentForm.js";
import { LinkMatrixAccountForm } from "./LinkMatrixAccountForm.js";
import { WantedMenu } from "./WantedMenu.js";
import "./AgentsPage.css";
interface ConfigPrStatus {
pr_number: number;
html_url: string | null;
}
type Freshness = "fresh" | "stale" | "never_reported" | "unknown";
interface AgentStatusSnapshot {
status_text: string | null;
status_set_at: number | null;
running: boolean;
}
// `"up"` / `"offline"` / `"destroyed"`, wire-spelled by
// `swarm_queue_client::wanted::AgentState::as_str` — kept as a bare
// `string | null` rather than a union, same reason
// `config_pr`'s shape isn't re-derived here: this page renders whatever
// the wire sends, it doesn't validate the enum client-side.
type Wanted = string | null;
// Mirrors `agent_status::AgentStatusRow` field-for-field — this *is* the
// 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;
freshness: Freshness;
last_seen_unix: number | null;
// `age_seconds` deliberately unused here, same reason as HivesPage:
// `RelativeTime` recomputes age client-side from `last_seen_unix`
// rather than rendering a once-computed-at-fetch value.
snapshot: AgentStatusSnapshot | null;
config_pr: ConfigPrStatus | null;
wanted: Wanted;
}
// Same tone pairing as HivesPage — one freshness enum shared by both
// endpoints, so the same tone rule applies to both pages — but `unknown`'s
// label diverges deliberately: an agent outside the swarm-identity roster
// has a concrete next step (register it), while an unrecognized *hive* on
// HivesPage doesn't carry the same actionable meaning.
const FRESHNESS: Record<Freshness, { tone: BadgeTone; label: string }> = {
fresh: { tone: "positive", label: "fresh" },
stale: { tone: "warning", label: "stale" },
never_reported: { tone: "neutral", label: "never reported" },
// `unknown` means the agent reported into the KV bucket but isn't in
// the swarm-identity roster — a real, distinct case ("hive-reported,
// not yet in swarm level"), not just a fallback default, so it gets a
// label that says what to do about it rather than reusing the enum
// name as the label.
unknown: { tone: "negative", label: "not in swarm identity" },
};
// The one thing that differs between the "offline" and "paused" confirm
// dialogs `confirmTarget` drives — everything else (button row, open/close
// wiring) is the shared `ConfirmDialog`.
@ -151,304 +94,6 @@ type ViewMode = "cards" | "table";
// 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.
// 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 (`declareState`'s
// backend counterpart, `hive-c0re`'s `decide_pause`, 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.
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` below) 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 `<dialog>` (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<HTMLDivElement>(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 (
<div class="ui-dropdown-anchor" ref={anchorRef}>
<Badge
tone={tone}
value={pending ? "…" : (row.wanted ?? "no declaration")}
// `destroyed` blocks opening the menu at all — same UX-only
// guard as before (real enforcement is server-side, see
// `declareState`'s caller), just applied to the trigger instead
// of a `disabled` toggle badge.
onClick={row.hive && !destroyed ? () => 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"
}
/>
<Dropdown
open={open}
portal={portal}
options={options}
activeValue={row.wanted ?? undefined}
label={`declare ${row.name}`}
onSelect={(value) => {
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}
/>
</div>
);
}
// One card per roster agent (mara: "main view: name, status, message,
// 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). 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. 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 closes.
//
// 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 `<button>`s) from also firing `Card`'s
// `onClick` and opening the detail panel.
function AgentCard({
row,
pending,
error,
selected,
onSelectUp,
onSelectOffline,
onSelectPaused,
onOpenDetail,
}: {
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;
onOpenDetail: (row: AgentRow) => void;
}) {
const { tone, label } = FRESHNESS[row.freshness];
return (
<Card selected={selected} onClick={() => 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>
</Card>
);
}
// One `<input>` (the "name" column's plain-text filter) plus one shared
// `MultiselectFilter` (`ui/multiselect-filter/`) 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 ? (
<MultiselectFilter
label="hive"
options={multiselectOptionsFor(hiveCol)}
filter={getFilter("hive")}
onToggle={(v) => toggleFilterValue("hive", v)}
onNegateChange={(negate) => updateFilter("hive", { negate })}
/>
) : null}
{statusCol ? (
<MultiselectFilter
label="status"
options={multiselectOptionsFor(statusCol)}
filter={getFilter("status")}
onToggle={(v) => toggleFilterValue("status", v)}
onNegateChange={(negate) => updateFilter("status", { negate })}
/>
) : null}
{wantedCol ? (
<MultiselectFilter
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>
);
}
export function AgentsPage() {
const [rows, setRows] = useState<AgentRow[] | null>(null);
const [error, setError] = useState<ProblemDetails | null>(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<AgentRow>[] = [
@ -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 (
<>
<SplitView
@ -824,57 +455,26 @@ export function AgentsPage() {
/>
) : 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={detailTargetName === a.name}
onSelectUp={selectUp}
onSelectOffline={selectOffline}
onSelectPaused={selectPaused}
onOpenDetail={(row) => setDetailTargetName(row.name)}
/>
))
)}
</div>
) : null}
{rows && rows.length > 0 && viewMode === "table" ? (
<Table
{rows ? (
<FilterableView
columns={columns}
rows={rows}
rowKey={(a) => 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) => (
<AgentCard
row={a}
pending={pendingAgents.has(a.name)}
error={actionErrors.get(a.name)}
selected={detailTargetName === a.name}
onSelectUp={selectUp}
onSelectOffline={selectOffline}
onSelectPaused={selectPaused}
onOpenDetail={(row) => setDetailTargetName(row.name)}
/>
)}
/>
) : null}
</Panel>

View file

@ -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 (
<div>
<div class="components-chip-row" style={{ marginBottom: "0.5em" }}>
<Button
variant={view === "cards" ? "primary" : "default"}
onClick={() => setView("cards")}
>
cards
</Button>
<Button
variant={view === "table" ? "primary" : "default"}
onClick={() => setView("table")}
>
table
</Button>
</div>
<FilterableView
columns={TABLE_COLUMNS}
rows={TABLE_ROWS}
rowKey={(r) => r.name}
storageKey="swarm-ui:components-demo:filterable-view"
view={view}
emptyMessage="no rows"
renderCard={(r) => (
<Card onClick={() => {}}>
<strong>{r.name}</strong>
<div>{r.detail}</div>
</Card>
)}
/>
</div>
);
}
function ConfirmDialogSample() {
const [open, setOpen] = useState(false);
return (
@ -365,6 +406,12 @@ export function ComponentsPage() {
</Sample>
</Section>
<Section title="FilterableView">
<Sample label="filter bar (built from every filterable column) + cards/table, same filter state either way">
<FilterableViewSample />
</Sample>
</Section>
<Section title="RelativeTime">
<div class="components-chip-row">
<Sample label="5s ago, ticking">

View file

@ -0,0 +1,124 @@
// <WantedMenu> — 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 `<dialog>` (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<HTMLDivElement>(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 (
<div class="ui-dropdown-anchor" ref={anchorRef}>
<Badge
tone={tone}
value={pending ? "…" : (row.wanted ?? "no declaration")}
// `destroyed` blocks opening the menu at all — same UX-only
// guard as before (real enforcement is server-side, see
// `declareState`'s caller), just applied to the trigger instead
// of a `disabled` toggle badge.
onClick={row.hive && !destroyed ? () => 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"
}
/>
<Dropdown
open={open}
portal={portal}
options={options}
activeValue={row.wanted ?? undefined}
label={`declare ${row.name}`}
onSelect={(value) => {
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}
/>
</div>
);
}

View file

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

View file

@ -0,0 +1,179 @@
// <FilterableView> — 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<T>(c: TableColumn<T>): boolean {
return c.filterValue !== undefined || c.filterValues !== undefined;
}
function columnLabel<T>(c: TableColumn<T>): string {
return typeof c.header === "string" ? c.header : c.key;
}
// One control per filterable column — a `MultiselectFilter` for
// `filterMode: "multiselect"`, a plain always-visible `<input>` 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<T>({
columns,
getFilter,
updateFilter,
toggleFilterValue,
resetFilters,
activeFilters,
multiselectOptionsFor,
}: {
columns: TableColumn<T>[];
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<T>) => string[];
}) {
const filterable = columns.filter(isFilterable);
if (filterable.length === 0) return null;
return (
<div class="ui-filterable-view-filter-bar">
{filterable.map((c) => {
const label = columnLabel(c);
if (c.filterMode === "multiselect") {
return (
<MultiselectFilter
key={c.key}
label={label}
options={multiselectOptionsFor(c)}
filter={getFilter(c.key)}
onToggle={(v) => toggleFilterValue(c.key, v)}
onNegateChange={(negate) => updateFilter(c.key, { negate })}
/>
);
}
return (
<input
key={c.key}
type="text"
class="ui-filterable-view-search"
placeholder={`filter by ${label}`}
aria-label={`filter by ${label}`}
value={getFilter(c.key).value}
onInput={(e) =>
updateFilter(c.key, {
value: (e.target as HTMLInputElement).value,
})
}
/>
);
})}
{activeFilters.length > 0 ? (
<button
type="button"
class="ui-table-reset-filters"
onClick={resetFilters}
>
reset filters
</button>
) : null}
</div>
);
}
export function FilterableView<T>({
columns,
rows,
rowKey,
storageKey,
view,
renderCard,
emptyMessage,
}: {
columns: TableColumn<T>[];
rows: T[];
rowKey: (row: T) => string;
// Shared 1:1 with `Table`'s own filter persistence — pass the same
// key `<Table>` 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 (
<Table
columns={columns}
rows={rows}
rowKey={rowKey}
storageKey={storageKey}
emptyMessage={emptyMessage}
/>
);
}
return (
<>
<FilterBar
columns={columns}
getFilter={getFilter}
updateFilter={updateFilter}
toggleFilterValue={toggleFilterValue}
resetFilters={resetFilters}
activeFilters={activeFilters}
multiselectOptionsFor={multiselectOptionsFor}
/>
{rows.length === 0 && emptyMessage ? (
<p class="ui-filterable-view-empty">{emptyMessage}</p>
) : null}
{rows.length > 0 && visibleRows.length === 0 ? (
<p class="ui-filterable-view-empty">no rows match the current filter</p>
) : null}
{visibleRows.length > 0 ? (
<div class="ui-filterable-view-cards">
{visibleRows.map((row) => (
<div key={rowKey(row)}>{renderCard(row)}</div>
))}
</div>
) : null}
</>
);
}