Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: extract Card/MultiselectFilter/SplitView per component-first design

Mara: "result looks like the shape i am looking for, but the code does
not. you did not follow component first principle" - the card's
clickable/selectable mechanics, the card-view filter trigger+popover,
and the list+detail split layout were all one-off page-local JSX in
AgentsPage.tsx instead of docs/web-ui/design-guide.md's "Component-first
design" primitives. Three new ui/ components, each with a same-day
/components demo section per that doc's own rule:

- ui/card/Card.tsx - the role=button/keyboard-activation/selected
  mechanics AgentCard now wraps agent-specific content around, instead
  of owning them itself.
- ui/multiselect-filter/MultiselectFilter.tsx - the checkbox-list
  trigger+popover control. This was also a straight duplicate of
  Table's own inline popover content once AgentsPage's filter toolbar
  needed the identical thing; Table now renders the same
  MultiselectFilterOptions piece too (keeping its own th-anchored
  trigger and fixed+portal positioning, which are genuinely
  table-specific), not a second copy.
- ui/split-view/SplitView.tsx - the list+detail flex-wrap layout, no
  opinion on what's inside either pane.

AgentsPage.tsx's own CSS shrinks to just the agent-specific content
inside these primitives (card line/message layout, detail-panel field
grid, the name-search input) - the container/positioning rules moved
to each component's own colocated CSS.

No behavior change for any other Table caller (HivesPage,
IssueReportPage, the components demo's own Table samples) - the
popover's visual output is identical, just sourced from the shared
component instead of inline JSX.

Verified: typecheck/build clean, real screenshots of both AgentsPage
(pixel-identical to before) and the three new /components sections,
plus a live click confirming MultiselectFilter's popover opens
correctly on the demo page too.
This commit is contained in:
iris 2026-09-11 22:14:21 +02:00
commit 7003560569
11 changed files with 646 additions and 400 deletions

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 { useEffect, useRef, useState } from "preact/hooks";
import { 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";
@ -36,8 +36,10 @@ import { Badge, type BadgeTone } from "@hive/shared/badge.js";
import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js";
import { LinkIcon } from "@hive/shared/icons.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 { Panel } from "../ui/panel/Panel.js";
import { RelativeTime } from "../ui/relative-time/RelativeTime.js";
import {
@ -45,6 +47,7 @@ import {
useRefreshInterval,
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 { CreateAgentForm } from "./CreateAgentForm.js";
import { LinkMatrixAccountForm } from "./LinkMatrixAccountForm.js";
@ -279,12 +282,11 @@ function WantedMenu({
// esbuild config can't consume those yet (same gap `Dialog.tsx`'s own
// comment already flags for `hive-dialog`). Revisit once that closes.
//
// A `role="button"` div, not a real `<button>`: the card also hosts the
// real `<button>`s inside `WantedMenu`, and nested buttons are invalid
// HTML. `onOpenDetail` fires for a click/Enter/Space landing on the card
// itself; the `stopPropagation` wrapper around `WantedMenu` keeps its own
// clicks (mouse or keyboard-synthesized) from also opening the panel, and
// the `e.target !== e.currentTarget` guard below does the same for keys.
// 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,
@ -310,21 +312,7 @@ function AgentCard({
}) {
const { tone, label } = FRESHNESS[row.freshness];
return (
<div
class={
selected ? "ui-agent-card ui-agent-card-selected" : "ui-agent-card"
}
role="button"
tabIndex={0}
onClick={() => onOpenDetail(row)}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpenDetail(row);
}
}}
>
<Card selected={selected} onClick={() => onOpenDetail(row)}>
<div class="ui-agent-card-line1">
<span class="ui-agent-card-name">{row.name}</span>
<Badge
@ -367,18 +355,19 @@ function AgentCard({
<div class="ui-agent-card-message">
{row.snapshot?.status_text ?? "—"}
</div>
</div>
</Card>
);
}
// 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.
// 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,
@ -421,7 +410,7 @@ function AgentFilterBar({
}
/>
{hiveCol ? (
<FilterMultiselect
<MultiselectFilter
label="hive"
options={multiselectOptionsFor(hiveCol)}
filter={getFilter("hive")}
@ -430,7 +419,7 @@ function AgentFilterBar({
/>
) : null}
{statusCol ? (
<FilterMultiselect
<MultiselectFilter
label="status"
options={multiselectOptionsFor(statusCol)}
filter={getFilter("status")}
@ -439,7 +428,7 @@ function AgentFilterBar({
/>
) : null}
{wantedCol ? (
<FilterMultiselect
<MultiselectFilter
label="wanted"
options={multiselectOptionsFor(wantedCol)}
filter={getFilter("wanted")}
@ -460,91 +449,6 @@ function AgentFilterBar({
);
}
// 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);
@ -865,209 +769,217 @@ export function AgentsPage() {
return (
<>
<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")}
<SplitView
primary={
<Panel
title="agents"
icon="👥"
actions={
<>
<Button variant="primary" onClick={() => setCreateOpen(true)}>
+ agent
</Button>
<div
class="ui-agents-view-toggle"
role="group"
aria-label="view"
>
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}
<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}
/>
</>
}
>
{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}
{/* Card view's own filter toolbar — table view keeps its
) : 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
{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. `SplitView`'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.
secondary={
<Panel
title={detailTarget ? detailTarget.name : "agent details"}
icon="🔎"
>
{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="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}`
)
}
<WantedMenu
row={detailTarget}
pending={pendingAgents.has(detailTarget.name)}
onSelectUp={selectUp}
onSelectOffline={selectOffline}
onSelectPaused={selectPaused}
onDestroy={setDestroyTarget}
/>
) : (
"—"
)}
</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)
: 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>
) : (
<p class="ui-agents-empty">select an agent to see its details</p>
)}
</Panel>
</div>
) : (
<p class="ui-agents-empty">select an agent to see its details</p>
)}
</Panel>
}
/>
<Dialog
open={createOpen}
onClose={() => setCreateOpen(false)}