Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1176de08bd | ||
|
|
601068f1eb | ||
|
|
3b66f0c8d6 | ||
|
|
7003560569 | ||
|
|
60d7e8ce84 | ||
|
|
8786f24111 | ||
|
|
1e6156d116 | ||
|
|
26b0b0d173 |
19 changed files with 1427 additions and 357 deletions
|
|
@ -2,7 +2,7 @@
|
||||||
// page component under `./pages/`; this file just maps paths to them.
|
// page component under `./pages/`; this file just maps paths to them.
|
||||||
import { Route, Switch } from "wouter-preact";
|
import { Route, Switch } from "wouter-preact";
|
||||||
import { Shell } from "./shell/Shell.js";
|
import { Shell } from "./shell/Shell.js";
|
||||||
import { AgentsPage } from "./pages/AgentsPage.js";
|
import { AgentsPage } from "./pages/agents/AgentsPage.js";
|
||||||
import { ComponentsPage } from "./pages/ComponentsPage.js";
|
import { ComponentsPage } from "./pages/ComponentsPage.js";
|
||||||
import { JobsPage } from "./pages/JobsPage.js";
|
import { JobsPage } from "./pages/JobsPage.js";
|
||||||
import { HivesPage } from "./pages/HivesPage.js";
|
import { HivesPage } from "./pages/HivesPage.js";
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,19 @@
|
||||||
// swarm-controller's API is up or not.
|
// swarm-controller's API is up or not.
|
||||||
import { useRef, useState } from "preact/hooks";
|
import { useRef, useState } from "preact/hooks";
|
||||||
import type { ComponentChildren } from "preact";
|
import type { ComponentChildren } from "preact";
|
||||||
|
import { Card } from "../ui/card/Card.js";
|
||||||
|
import { FilterableView } from "../ui/filterable-view/FilterableView.js";
|
||||||
|
import {
|
||||||
|
MultiselectFilter,
|
||||||
|
type MultiselectFilterState,
|
||||||
|
} from "../ui/multiselect-filter/MultiselectFilter.js";
|
||||||
import { Panel } from "../ui/panel/Panel.js";
|
import { Panel } from "../ui/panel/Panel.js";
|
||||||
import { RelativeTime } from "../ui/relative-time/RelativeTime.js";
|
import { RelativeTime } from "../ui/relative-time/RelativeTime.js";
|
||||||
import {
|
import {
|
||||||
RefreshIntervalPicker,
|
RefreshIntervalPicker,
|
||||||
type RefreshIntervalMs,
|
type RefreshIntervalMs,
|
||||||
} from "../ui/refresh-interval/RefreshInterval.js";
|
} from "../ui/refresh-interval/RefreshInterval.js";
|
||||||
|
import { SplitView } from "../ui/split-view/SplitView.js";
|
||||||
import { Table, type TableColumn } from "../ui/table/Table.js";
|
import { Table, type TableColumn } from "../ui/table/Table.js";
|
||||||
import { TextField } from "../ui/text-field/TextField.js";
|
import { TextField } from "../ui/text-field/TextField.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -212,6 +219,71 @@ function DialogSample() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Own state for the same reason `TextFieldSample` needs it — a
|
||||||
|
// controlled sample has to actually respond to interaction.
|
||||||
|
function MultiselectFilterSample() {
|
||||||
|
const [filter, setFilter] = useState<MultiselectFilterState>({
|
||||||
|
values: [],
|
||||||
|
negate: false,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<MultiselectFilter
|
||||||
|
label="hive"
|
||||||
|
options={["pr1ma", "sec0nd"]}
|
||||||
|
filter={filter}
|
||||||
|
onToggle={(v) =>
|
||||||
|
setFilter((f) => ({
|
||||||
|
...f,
|
||||||
|
values: f.values.includes(v)
|
||||||
|
? f.values.filter((x) => x !== v)
|
||||||
|
: [...f.values, v],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
onNegateChange={(negate) => setFilter((f) => ({ ...f, negate }))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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() {
|
function ConfirmDialogSample() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
return (
|
return (
|
||||||
|
|
@ -269,6 +341,23 @@ export function ComponentsPage() {
|
||||||
</Sample>
|
</Sample>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
<Section title="SplitView">
|
||||||
|
<Sample label="primary + secondary, side by side (wraps to stacked on a narrow viewport)">
|
||||||
|
<SplitView
|
||||||
|
primary={
|
||||||
|
<Panel title="list">
|
||||||
|
primary content — e.g. AgentsPage's roster
|
||||||
|
</Panel>
|
||||||
|
}
|
||||||
|
secondary={
|
||||||
|
<Panel title="detail">
|
||||||
|
secondary content — e.g. the selected row
|
||||||
|
</Panel>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Sample>
|
||||||
|
</Section>
|
||||||
|
|
||||||
<Section title="Table">
|
<Section title="Table">
|
||||||
<Sample label="populated">
|
<Sample label="populated">
|
||||||
<Table
|
<Table
|
||||||
|
|
@ -288,6 +377,41 @@ export function ComponentsPage() {
|
||||||
</Sample>
|
</Sample>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Card">
|
||||||
|
<div class="components-chip-row">
|
||||||
|
<Sample label="static (no onClick)">
|
||||||
|
<Card>
|
||||||
|
<strong>alpha</strong>
|
||||||
|
<div>plain content, not clickable</div>
|
||||||
|
</Card>
|
||||||
|
</Sample>
|
||||||
|
<Sample label="interactive">
|
||||||
|
<Card onClick={() => {}}>
|
||||||
|
<strong>beta</strong>
|
||||||
|
<div>click, or Enter/Space when focused</div>
|
||||||
|
</Card>
|
||||||
|
</Sample>
|
||||||
|
<Sample label="selected (e.g. the row shown in a detail panel)">
|
||||||
|
<Card selected onClick={() => {}}>
|
||||||
|
<strong>gamma</strong>
|
||||||
|
<div>same card, with selected set</div>
|
||||||
|
</Card>
|
||||||
|
</Sample>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="MultiselectFilter">
|
||||||
|
<Sample label="trigger + checkbox popover">
|
||||||
|
<MultiselectFilterSample />
|
||||||
|
</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">
|
<Section title="RelativeTime">
|
||||||
<div class="components-chip-row">
|
<div class="components-chip-row">
|
||||||
<Sample label="5s ago, ticking">
|
<Sample label="5s ago, ticking">
|
||||||
|
|
|
||||||
24
frontend/packages/swarm-ui/src/pages/agents/AgentCard.css
Normal file
24
frontend/packages/swarm-ui/src/pages/agents/AgentCard.css
Normal 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;
|
||||||
|
}
|
||||||
93
frontend/packages/swarm-ui/src/pages/agents/AgentCard.tsx
Normal file
93
frontend/packages/swarm-ui/src/pages/agents/AgentCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
frontend/packages/swarm-ui/src/pages/agents/AgentTypes.ts
Normal file
61
frontend/packages/swarm-ui/src/pages/agents/AgentTypes.ts
Normal 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" },
|
||||||
|
};
|
||||||
58
frontend/packages/swarm-ui/src/pages/agents/AgentsPage.css
Normal file
58
frontend/packages/swarm-ui/src/pages/agents/AgentsPage.css
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
/* 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;
|
||||||
|
padding: 1.25em 0.75em;
|
||||||
|
}
|
||||||
|
/* Two-way view switcher in the Panel's title-row actions — same visual
|
||||||
|
weight as `RefreshIntervalPicker` next to it (quiet until touched),
|
||||||
|
but a segmented pair rather than a select since there's no listbox
|
||||||
|
worth opening for a two-option choice. */
|
||||||
|
.ui-agents-view-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.4em;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.ui-agents-view-toggle button {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 0.45em 0.75em;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ui-agents-view-toggle button + button {
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.ui-agents-view-toggle button.active {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
.ui-agents-view-toggle button:not(.active):hover {
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
gap: 0.4em 1em;
|
||||||
|
margin: 0 0 1em;
|
||||||
|
}
|
||||||
|
.ui-agent-detail-fields dt {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.ui-agent-detail-fields dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.ui-agent-detail-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75em;
|
||||||
|
}
|
||||||
647
frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx
Normal file
647
frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx
Normal file
|
|
@ -0,0 +1,647 @@
|
||||||
|
// <AgentsPage> — the swarm's agent roster, merged with each agent's open
|
||||||
|
// config-PR status, swarm-wide health status, and declared wanted state.
|
||||||
|
// A roster with no per-row detail is thin, and neither a config-PR nor a
|
||||||
|
// status panel has anything to render against without a roster to embed
|
||||||
|
// in — so all three ended up as one page.
|
||||||
|
//
|
||||||
|
// One fetch, on a refresh-interval cadence like HivesPage: `GET
|
||||||
|
// /api/agents/status` — one row per roster agent (identity store is the
|
||||||
|
// roster, so a never-reported agent still gets a row), each already
|
||||||
|
// carrying its freshness/snapshot/config PR/wanted state. Used to be
|
||||||
|
// three separate fetches joined client-side by name; per operator
|
||||||
|
// review feedback ("the view should be filled by a single backend
|
||||||
|
// call") the join moved server-side instead — see
|
||||||
|
// `swarm-controller/src/main.rs`'s `get_agents_status` handler. No more
|
||||||
|
// per-page joining left to do here: the wire row *is* the row this page renders.
|
||||||
|
//
|
||||||
|
// Owns the "+ agent" trigger too — the roster this populates is the
|
||||||
|
// natural home for the action that populates it. The form itself
|
||||||
|
// (`CreateAgentForm`) mounts inside a `Dialog` here, not its own route.
|
||||||
|
//
|
||||||
|
// 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 } 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 { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
|
||||||
|
import { Dialog } from "../../ui/dialog/Dialog.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 {
|
||||||
|
RefreshIntervalPicker,
|
||||||
|
useRefreshInterval,
|
||||||
|
type RefreshIntervalMs,
|
||||||
|
} from "../../ui/refresh-interval/RefreshInterval.js";
|
||||||
|
import { SplitView } from "../../ui/split-view/SplitView.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";
|
||||||
|
|
||||||
|
// 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`.
|
||||||
|
const CONFIRM_COPY: Record<
|
||||||
|
"offline" | "paused",
|
||||||
|
{
|
||||||
|
label: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
message: (agentName: string) => ComponentChildren;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
offline: {
|
||||||
|
label: "stop agent",
|
||||||
|
confirmLabel: "stop",
|
||||||
|
message: (agentName) => (
|
||||||
|
<p>
|
||||||
|
Declare <strong>{agentName}</strong> offline? The hive brings its
|
||||||
|
container down on its next reconcile sweep.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
paused: {
|
||||||
|
label: "pause agent",
|
||||||
|
confirmLabel: "pause",
|
||||||
|
message: (agentName) => (
|
||||||
|
<p>
|
||||||
|
Declare <strong>{agentName}</strong> paused? Its container stays up —
|
||||||
|
the web UI and MCP daemons keep running — but its turn loop parks on the
|
||||||
|
hive's next reconcile sweep. Select "up" again to resume it.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Same 30s default + same reasoning as HivesPage: no inputs on this page
|
||||||
|
// for a refresh to clobber, so the out-of-the-box behaviour should just
|
||||||
|
// solve staleness rather than require an opt-in every visit.
|
||||||
|
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
|
||||||
|
|
||||||
|
type ViewMode = "cards" | "table";
|
||||||
|
// Persisted like `Table`'s own per-column filters (`storageKey` below) —
|
||||||
|
// a chosen view is a standing preference, not a per-visit default.
|
||||||
|
const VIEW_MODE_KEY = "swarm-ui:agents:view-mode";
|
||||||
|
|
||||||
|
export function AgentsPage() {
|
||||||
|
const [rows, setRows] = useState<AgentRow[] | null>(null);
|
||||||
|
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||||
|
const [intervalMs, setIntervalMs] =
|
||||||
|
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
||||||
|
// Lazy initializer (not a mount effect) so the very first render
|
||||||
|
// already reflects the stored choice — no default-then-flip flash.
|
||||||
|
const [viewMode, setViewModeState] = useState<ViewMode>(() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(VIEW_MODE_KEY) === "table"
|
||||||
|
? "table"
|
||||||
|
: "cards";
|
||||||
|
} catch {
|
||||||
|
return "cards";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
function setViewMode(mode: ViewMode) {
|
||||||
|
setViewModeState(mode);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(VIEW_MODE_KEY, mode);
|
||||||
|
} catch {
|
||||||
|
/* localStorage unavailable — choice is session-only */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
// Per-agent, not one page-wide flag: one row's declare-in-flight
|
||||||
|
// shouldn't disable every other row's button.
|
||||||
|
const [pendingAgents, setPendingAgents] = useState<ReadonlySet<string>>(
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
const [actionErrors, setActionErrors] = useState<
|
||||||
|
ReadonlyMap<string, ProblemDetails>
|
||||||
|
>(new Map());
|
||||||
|
// The row pending destroy confirmation, `null` when the dialog is
|
||||||
|
// closed — not a boolean, so the dialog can name the agent without a
|
||||||
|
// second piece of state to keep in sync with it. Kept apart from
|
||||||
|
// `confirmTarget` below rather than folded into it as a third state:
|
||||||
|
// destroy is categorically different (irreversible, longer warning,
|
||||||
|
// danger-styled), not one more case of the same "confirm before parking
|
||||||
|
// a running agent" shape "offline"/"paused" share.
|
||||||
|
const [destroyTarget, setDestroyTarget] = useState<AgentRow | null>(null);
|
||||||
|
// "offline" and "paused" are the *same* confirm shape — same button
|
||||||
|
// treatment, same "disruptive to a running turn loop, unlike up" reason
|
||||||
|
// for needing a confirm at all — so one union state + one `ConfirmDialog`
|
||||||
|
// covers both instead of two near-identical copies (argus, PR review: "a
|
||||||
|
// shared confirm-dialog-body-generator would drop real line count").
|
||||||
|
// `CONFIRM_COPY` below supplies the one thing that differs.
|
||||||
|
const [confirmTarget, setConfirmTarget] = useState<{
|
||||||
|
row: AgentRow;
|
||||||
|
state: "offline" | "paused";
|
||||||
|
} | null>(null);
|
||||||
|
// The row currently showing the "link a matrix account" dialog — same
|
||||||
|
// null-means-closed shape as `destroyTarget`/`stopTarget`, own piece of
|
||||||
|
// state rather than folded into either since this dialog isn't a
|
||||||
|
// confirmation of a `declareState` call, it's an unrelated action with
|
||||||
|
// its own form (`LinkMatrixAccountForm`).
|
||||||
|
const [matrixTarget, setMatrixTarget] = useState<AgentRow | null>(null);
|
||||||
|
// Which agent the detail panel shows, *by name* — not the `AgentRow`
|
||||||
|
// object itself. Storing the row would snapshot it at selection time;
|
||||||
|
// `rows` replaces its whole array on every `refresh()` and every
|
||||||
|
// `declareState` patch, so a captured object goes stale the moment
|
||||||
|
// either fires (argus, PR review: "open a detail panel, wait for the
|
||||||
|
// next refresh or destroy from inside the panel itself — the card
|
||||||
|
// list updates live, the panel next to it doesn't"). `detailTarget`
|
||||||
|
// below re-derives the live row from `rows` every render instead, so
|
||||||
|
// it can't drift from what the list is showing.
|
||||||
|
const [detailTargetName, setDetailTargetName] = useState<string | null>(null);
|
||||||
|
const detailTarget = rows?.find((r) => r.name === detailTargetName) ?? null;
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const res = await fetch("/api/agents/status");
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(await readApiError(res));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRows((await res.json()) as AgentRow[]);
|
||||||
|
// A refresh that succeeds clears a previous failure — otherwise a
|
||||||
|
// transient error would sit on screen forever after the data itself
|
||||||
|
// has recovered.
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
useRefreshInterval(intervalMs, () => {
|
||||||
|
refresh().catch((e: unknown) => setError({ detail: String(e) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// The one PUT every `WantedMenu` selection ends at, whether or not it
|
||||||
|
// went through a `ConfirmDialog` first (`confirmStop`/`destroyAgent`
|
||||||
|
// for "offline"/"destroy"; called directly for "up", which needs no
|
||||||
|
// confirmation): "PUT a new `wanted` declaration, track per-agent
|
||||||
|
// pending/error state, patch the response back into `rows`" is the
|
||||||
|
// same shape regardless of target state or how the caller got here.
|
||||||
|
async function declareState(row: AgentRow, target: string) {
|
||||||
|
if (!row.hive) return;
|
||||||
|
setPendingAgents((prev) => new Set(prev).add(row.name));
|
||||||
|
setActionErrors((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(row.name);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/hives/${encodeURIComponent(row.hive)}/agents/${encodeURIComponent(row.name)}/state`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ state: target }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const problem = await readApiError(res);
|
||||||
|
setActionErrors((prev) => new Map(prev).set(row.name, problem));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The PUT response is the hive's whole declaration set (every
|
||||||
|
// agent's `wanted` state) — same shape `GET /api/hives/{hive}/wanted`
|
||||||
|
// returns — so patch just that field into the rows already on
|
||||||
|
// screen rather than firing a second, full `/api/agents/status`
|
||||||
|
// round-trip merely to learn one hive's `wanted` column changed.
|
||||||
|
// Everything else on the grid (freshness, snapshot, config PR)
|
||||||
|
// still catches up on the next interval-driven `refresh()`.
|
||||||
|
const declared = (await res.json()) as { agent: string; state: string }[];
|
||||||
|
setRows((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const byAgent = new Map(declared.map((d) => [d.agent, d.state]));
|
||||||
|
return prev.map((r) =>
|
||||||
|
r.hive === row.hive && byAgent.has(r.name)
|
||||||
|
? { ...r, wanted: byAgent.get(r.name) ?? r.wanted }
|
||||||
|
: r,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setActionErrors((prev) =>
|
||||||
|
new Map(prev).set(row.name, { detail: String(e) }),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setPendingAgents((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(row.name);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fires from the `confirmTarget` confirm `Dialog` (covers both "offline"
|
||||||
|
// and "paused" — see that state's own comment). Used to be a native
|
||||||
|
// `window.confirm` for the "offline" case (stop is reversible, a later
|
||||||
|
// start un-does it, so a lighter-weight prompt than destroy's felt
|
||||||
|
// proportionate) — mara, reviewing the destroy confirm: use the shared
|
||||||
|
// component everywhere we already confirm before acting, not just for
|
||||||
|
// destroy. Once `ConfirmDialog` existed as a one-line-per-caller
|
||||||
|
// component, the "native is lighter" argument no longer bought
|
||||||
|
// consistency anything.
|
||||||
|
async function confirmDeclare() {
|
||||||
|
if (!confirmTarget) return;
|
||||||
|
const { row, state } = confirmTarget;
|
||||||
|
setConfirmTarget(null);
|
||||||
|
await declareState(row, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fires from the `destroyTarget` confirm `Dialog`, never directly off
|
||||||
|
// a row click — see `destroyTarget`/`WantedMenu`'s "destroy" option.
|
||||||
|
// Unlike the "up"/"offline" directions, there's no "current state" to
|
||||||
|
// read: destroy is a one-way declaration, not a flip.
|
||||||
|
async function destroyAgent(row: AgentRow) {
|
||||||
|
setDestroyTarget(null);
|
||||||
|
await declareState(row, "destroyed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The three non-destroy `WantedMenu` selections, factored out once
|
||||||
|
// rather than repeated per call site — the card, the table's "wanted"
|
||||||
|
// column, and the detail panel's own `WantedMenu` (below) all wire the
|
||||||
|
// same three.
|
||||||
|
const selectUp = (row: AgentRow) => void declareState(row, "up");
|
||||||
|
const selectOffline = (row: AgentRow) =>
|
||||||
|
setConfirmTarget({ row, state: "offline" });
|
||||||
|
const selectPaused = (row: AgentRow) =>
|
||||||
|
setConfirmTarget({ row, state: "paused" });
|
||||||
|
|
||||||
|
// `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>[] = [
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
header: "name",
|
||||||
|
render: (a) => a.name,
|
||||||
|
sortBy: (a) => a.name,
|
||||||
|
filterValue: (a) => a.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "hive",
|
||||||
|
header: "hive",
|
||||||
|
render: (a) => a.hive ?? "—",
|
||||||
|
sortBy: (a) => a.hive ?? "",
|
||||||
|
filterValues: (a) => [a.hive ?? "—"],
|
||||||
|
filterMode: "multiselect",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
header: "status",
|
||||||
|
sortBy: (a) => FRESHNESS[a.freshness].label,
|
||||||
|
filterValues: (a) => [FRESHNESS[a.freshness].label],
|
||||||
|
filterMode: "multiselect",
|
||||||
|
render: (a) => {
|
||||||
|
const { tone, label } = FRESHNESS[a.freshness];
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
tone={tone}
|
||||||
|
title={
|
||||||
|
a.freshness === "unknown"
|
||||||
|
? "reported by its hive but not registered in swarm-level identity — needs migration"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
value={
|
||||||
|
<>
|
||||||
|
{label}
|
||||||
|
{a.last_seen_unix !== null ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
(<RelativeTime epochMs={a.last_seen_unix * 1000} />)
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "message",
|
||||||
|
header: "message",
|
||||||
|
cellClass: "ui-table-prose",
|
||||||
|
render: (a) => a.snapshot?.status_text ?? "—",
|
||||||
|
filterValue: (a) => a.snapshot?.status_text ?? "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "wanted",
|
||||||
|
header: "wanted",
|
||||||
|
sortBy: (a) => a.wanted ?? "",
|
||||||
|
filterValues: (a) => [a.wanted ?? "no declaration"],
|
||||||
|
filterMode: "multiselect",
|
||||||
|
render: (a) => {
|
||||||
|
const err = actionErrors.get(a.name);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<WantedMenu
|
||||||
|
row={a}
|
||||||
|
pending={pendingAgents.has(a.name)}
|
||||||
|
portal
|
||||||
|
onSelectUp={selectUp}
|
||||||
|
onSelectOffline={selectOffline}
|
||||||
|
onSelectPaused={selectPaused}
|
||||||
|
onDestroy={setDestroyTarget}
|
||||||
|
/>
|
||||||
|
{err ? (
|
||||||
|
<Badge
|
||||||
|
tone="negative"
|
||||||
|
value="failed"
|
||||||
|
title={err.detail ?? "the declaration failed"}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "matrix",
|
||||||
|
header: "matrix",
|
||||||
|
render: (a) => (
|
||||||
|
<Badge
|
||||||
|
variant="quiet"
|
||||||
|
icon={<LinkIcon />}
|
||||||
|
value="link account"
|
||||||
|
onClick={a.hive ? () => setMatrixTarget(a) : undefined}
|
||||||
|
disabled={!a.hive}
|
||||||
|
title={
|
||||||
|
a.hive
|
||||||
|
? `link a matrix account to ${a.name}`
|
||||||
|
: "no hive on record for this agent — nothing to link against"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "config-pr",
|
||||||
|
header: "config PR",
|
||||||
|
sortBy: (a) => a.config_pr?.pr_number ?? 0,
|
||||||
|
filterValue: (a) => (a.config_pr ? `#${a.config_pr.pr_number}` : ""),
|
||||||
|
render: (a) =>
|
||||||
|
a.config_pr ? (
|
||||||
|
<Badge
|
||||||
|
tone="warning"
|
||||||
|
value={
|
||||||
|
a.config_pr.html_url ? (
|
||||||
|
<a href={a.config_pr.html_url} target="_blank" rel="noreferrer">
|
||||||
|
#{a.config_pr.pr_number}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
`#${a.config_pr.pr_number}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<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 ? (
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
// 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}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
) : (
|
||||||
|
<p class="ui-agents-empty">select an agent to see its details</p>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Dialog
|
||||||
|
open={createOpen}
|
||||||
|
onClose={() => setCreateOpen(false)}
|
||||||
|
label="create agent"
|
||||||
|
>
|
||||||
|
<CreateAgentForm />
|
||||||
|
</Dialog>
|
||||||
|
<Dialog
|
||||||
|
open={matrixTarget !== null}
|
||||||
|
onClose={() => setMatrixTarget(null)}
|
||||||
|
label="link a matrix account"
|
||||||
|
>
|
||||||
|
{/* `matrixTarget.hive` is non-null here — the trigger badge above
|
||||||
|
is disabled without one, so this can only open with a real
|
||||||
|
hive to PUT against. */}
|
||||||
|
{matrixTarget?.hive ? (
|
||||||
|
<LinkMatrixAccountForm
|
||||||
|
hive={matrixTarget.hive}
|
||||||
|
agent={matrixTarget.name}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Dialog>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmTarget !== null}
|
||||||
|
label={confirmTarget ? CONFIRM_COPY[confirmTarget.state].label : ""}
|
||||||
|
onCancel={() => setConfirmTarget(null)}
|
||||||
|
onConfirm={() => void confirmDeclare()}
|
||||||
|
confirmLabel={
|
||||||
|
confirmTarget
|
||||||
|
? CONFIRM_COPY[confirmTarget.state].confirmLabel
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{confirmTarget
|
||||||
|
? CONFIRM_COPY[confirmTarget.state].message(confirmTarget.row.name)
|
||||||
|
: null}
|
||||||
|
</ConfirmDialog>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={destroyTarget !== null}
|
||||||
|
label="destroy agent"
|
||||||
|
onCancel={() => setDestroyTarget(null)}
|
||||||
|
onConfirm={() => destroyTarget && void destroyAgent(destroyTarget)}
|
||||||
|
confirmLabel="destroy"
|
||||||
|
>
|
||||||
|
{destroyTarget ? (
|
||||||
|
<p>
|
||||||
|
Destroy <strong>{destroyTarget.name}</strong>? The hive tears its
|
||||||
|
container down on its next reconcile sweep. This is not reversible
|
||||||
|
from here — bringing it back means redeploying via "+ agent", which
|
||||||
|
reuses the agent's existing identity, config repo, and forge
|
||||||
|
collaborator access rather than starting over.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</ConfirmDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
frontend/packages/swarm-ui/src/pages/agents/WantedMenu.tsx
Normal file
124
frontend/packages/swarm-ui/src/pages/agents/WantedMenu.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
frontend/packages/swarm-ui/src/ui/card/Card.css
Normal file
26
frontend/packages/swarm-ui/src/ui/card/Card.css
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
.ui-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35em;
|
||||||
|
padding: 0.75em 1em;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.5em;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
}
|
||||||
|
/* Only an interactive `Card` (has `onClick`) gets pointer/hover
|
||||||
|
affordance — a plain static one shouldn't look clickable. */
|
||||||
|
[role="button"].ui-card {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
[role="button"].ui-card:hover,
|
||||||
|
[role="button"].ui-card:focus-visible {
|
||||||
|
border-color: var(--purple);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
/* A solid border (not just the hover tint above, which needs to keep
|
||||||
|
meaning "hovering", not double as "selected") plus a faint fill so a
|
||||||
|
selected card still reads once the pointer moves away. */
|
||||||
|
.ui-card-selected {
|
||||||
|
border-color: var(--purple);
|
||||||
|
background: color-mix(in srgb, var(--purple) 10%, var(--bg-elev));
|
||||||
|
}
|
||||||
64
frontend/packages/swarm-ui/src/ui/card/Card.tsx
Normal file
64
frontend/packages/swarm-ui/src/ui/card/Card.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
// <Card> — the clickable/selectable-container mechanics (role="button",
|
||||||
|
// keyboard activation, hover + selected styling) any card-shaped list
|
||||||
|
// item needs, with zero opinion on its content — same "primitive owns
|
||||||
|
// the mechanics, caller owns the content" split as `Panel`/`Dialog`.
|
||||||
|
// Extracted out of `AgentsPage`'s `AgentCard` (design-guide's
|
||||||
|
// "Component-first design": a page had hand-rolled this rather than
|
||||||
|
// reaching for a `src/ui/` primitive) — `AgentCard` now wraps agent-
|
||||||
|
// specific content (name/badges/message) around this instead of owning
|
||||||
|
// the click/keyboard/selected logic itself.
|
||||||
|
//
|
||||||
|
// A `role="button"` `<div>`, not a real `<button>`: a card's content
|
||||||
|
// commonly hosts its own real `<button>`s/interactive controls (a
|
||||||
|
// status badge that opens a menu, say), and nested buttons are invalid
|
||||||
|
// HTML. `onClick` fires for a click/Enter/Space landing on the card
|
||||||
|
// itself; a caller whose content has its own interactive controls needs
|
||||||
|
// to `stopPropagation` on those (see `AgentsPage`'s `WantedMenu` wrapper
|
||||||
|
// for the pattern) and should guard its own `onKeyDown`, if any, with an
|
||||||
|
// `e.target !== e.currentTarget` check the same way this component does
|
||||||
|
// internally — nothing here can do that guarding on a caller's behalf.
|
||||||
|
import type { ComponentChildren } from "preact";
|
||||||
|
import "./Card.css";
|
||||||
|
|
||||||
|
export function Card({
|
||||||
|
selected,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
class: extraClass,
|
||||||
|
}: {
|
||||||
|
/** Highlight styling — the caller decides what "selected" means (the
|
||||||
|
* row shown in a detail panel, say); this component just renders it. */
|
||||||
|
selected?: boolean;
|
||||||
|
/** Present → a real interactive card (role="button", keyboard-
|
||||||
|
* activatable). Absent → a plain static container, same border/
|
||||||
|
* padding/radius, no click/keyboard wiring at all. */
|
||||||
|
onClick?: () => void;
|
||||||
|
children: ComponentChildren;
|
||||||
|
class?: string;
|
||||||
|
}) {
|
||||||
|
const classes = ["ui-card", selected && "ui-card-selected", extraClass]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
if (!onClick) {
|
||||||
|
return <div class={classes}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={classes}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={onClick}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.target !== e.currentTarget) return;
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
onClick();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
/* Checkbox-list content — same visual treatment as `Table`'s own
|
||||||
|
popover content (`Table.css`'s `.ui-table-filter-multiselect`/
|
||||||
|
`-checkbox`), colocated here instead of reached-across-files since
|
||||||
|
`Table` no longer renders this markup itself (see this component's
|
||||||
|
file-top comment). */
|
||||||
|
.ui-multiselect-filter-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3em;
|
||||||
|
max-height: 12em;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.ui-multiselect-filter-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35em;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.ui-multiselect-filter-negate {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35em;
|
||||||
|
margin-top: 0.5em;
|
||||||
|
padding-top: 0.5em;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The standalone control (trigger + popover) — `position: relative`/
|
||||||
|
`absolute` under the trigger, not `Table`'s fixed+portal recipe:
|
||||||
|
nothing here sits inside a clipping ancestor (see file-top comment),
|
||||||
|
so the simpler positioning is enough. */
|
||||||
|
.ui-multiselect-filter {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.ui-multiselect-filter-popover {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
margin-top: 0.25em;
|
||||||
|
z-index: 10;
|
||||||
|
min-width: 12em;
|
||||||
|
padding: 0.5em;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
// <MultiselectFilter> / <MultiselectFilterOptions> — the checkbox-list
|
||||||
|
// "pick any of these values" control every multiselect-mode filter in
|
||||||
|
// this package needs, split into a content-only piece and a full
|
||||||
|
// standalone control around it.
|
||||||
|
//
|
||||||
|
// Extracted out of `Table`'s own per-column filter popover (design-
|
||||||
|
// guide's "Component-first design") once `AgentsPage`'s card-view filter
|
||||||
|
// toolbar needed the identical checkbox list and nearly duplicated it —
|
||||||
|
// exactly the "next contributor duplicates ad-hoc styling, someone has
|
||||||
|
// to hunt it down and consolidate" scenario that section warns about.
|
||||||
|
// `Table` keeps its own trigger (an icon button inside a `<th>`) and its
|
||||||
|
// own positioning (`position: fixed` + portal, to escape
|
||||||
|
// `.ui-table-scroll`'s clip — see that file's own comment), genuinely
|
||||||
|
// different concerns from a plain toolbar's; only the checkbox-list
|
||||||
|
// *content* moves here, as `MultiselectFilterOptions`. A caller with no
|
||||||
|
// clipping context and no header row to anchor an icon to — a plain
|
||||||
|
// toolbar, `AgentsPage`'s first use — reaches for `MultiselectFilter`
|
||||||
|
// instead: the same content, wrapped in its own `Badge` trigger and a
|
||||||
|
// much simpler `position: absolute` popover.
|
||||||
|
import { useEffect, useRef, useState } from "preact/hooks";
|
||||||
|
import { Badge } from "@hive/shared/badge.js";
|
||||||
|
import "./MultiselectFilter.css";
|
||||||
|
|
||||||
|
export interface MultiselectFilterState {
|
||||||
|
values: string[];
|
||||||
|
negate: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MultiselectFilterOptions({
|
||||||
|
options,
|
||||||
|
filter,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
options: string[];
|
||||||
|
filter: MultiselectFilterState;
|
||||||
|
onToggle: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div class="ui-multiselect-filter-options">
|
||||||
|
{options.map((v) => (
|
||||||
|
<label key={v} class="ui-multiselect-filter-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={filter.values.includes(v)}
|
||||||
|
onChange={() => onToggle(v)}
|
||||||
|
/>
|
||||||
|
{v}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The negate toggle — mara: "you should be able to change if the filter
|
||||||
|
// is negated or not (eg search for this text vs exclude it)", same
|
||||||
|
// reasoning as `Table`'s own copy. Small enough (one checkbox+label)
|
||||||
|
// that keeping it inline here rather than a third exported piece isn't
|
||||||
|
// worth the indirection; `Table` keeps rendering its own copy for text-
|
||||||
|
// mode filters, which don't otherwise touch this file at all.
|
||||||
|
function NegateToggle({
|
||||||
|
negate,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
negate: boolean;
|
||||||
|
onChange: (negate: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label class="ui-multiselect-filter-negate">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={negate}
|
||||||
|
onChange={(e) => onChange((e.target as HTMLInputElement).checked)}
|
||||||
|
/>
|
||||||
|
exclude
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MultiselectFilter({
|
||||||
|
label,
|
||||||
|
options,
|
||||||
|
filter,
|
||||||
|
onToggle,
|
||||||
|
onNegateChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
options: string[];
|
||||||
|
filter: MultiselectFilterState;
|
||||||
|
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-multiselect-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-multiselect-filter-popover"
|
||||||
|
role="dialog"
|
||||||
|
aria-label={`filter by ${label}`}
|
||||||
|
>
|
||||||
|
<MultiselectFilterOptions
|
||||||
|
options={options}
|
||||||
|
filter={filter}
|
||||||
|
onToggle={onToggle}
|
||||||
|
/>
|
||||||
|
<NegateToggle negate={filter.negate} onChange={onNegateChange} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -34,14 +34,19 @@ export function Panel({
|
||||||
icon,
|
icon,
|
||||||
actions,
|
actions,
|
||||||
children,
|
children,
|
||||||
|
class: extraClass,
|
||||||
}: {
|
}: {
|
||||||
title?: string;
|
title?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
actions?: ComponentChildren;
|
actions?: ComponentChildren;
|
||||||
children: ComponentChildren;
|
children: ComponentChildren;
|
||||||
|
/** Extra class on the outer `<section>` — e.g. flex-sizing a panel
|
||||||
|
* that's one of several sharing a row (`AgentsPage`'s list/detail
|
||||||
|
* split). Omit for the plain single-panel case every other caller is. */
|
||||||
|
class?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section class="ui-panel">
|
<section class={extraClass ? `ui-panel ${extraClass}` : "ui-panel"}>
|
||||||
{title || icon || actions ? (
|
{title || icon || actions ? (
|
||||||
<div class="ui-panel-header">
|
<div class="ui-panel-header">
|
||||||
{icon ? (
|
{icon ? (
|
||||||
|
|
|
||||||
14
frontend/packages/swarm-ui/src/ui/split-view/SplitView.css
Normal file
14
frontend/packages/swarm-ui/src/ui/split-view/SplitView.css
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
.ui-split-view {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1em;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.ui-split-view-primary {
|
||||||
|
flex: 2 1 480px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.ui-split-view-secondary {
|
||||||
|
flex: 1 1 320px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
33
frontend/packages/swarm-ui/src/ui/split-view/SplitView.tsx
Normal file
33
frontend/packages/swarm-ui/src/ui/split-view/SplitView.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
// <SplitView> — a primary/secondary pane laid out side by side, wrapping
|
||||||
|
// to stacked on a narrow viewport. Extracted out of `AgentsPage`'s own
|
||||||
|
// list+detail row (design-guide's "Component-first design": a page had
|
||||||
|
// built this inline for its own list/detail split, `AgentsPage`'s
|
||||||
|
// `Panel`s reaching for it is the first real use, per the doc's "build
|
||||||
|
// the primitive before or alongside the first real page that needs it").
|
||||||
|
//
|
||||||
|
// `flex-wrap`, not a `@media` breakpoint — content-driven stacking, same
|
||||||
|
// approach the shell's own nav row already uses (`shell/Shell.css`)
|
||||||
|
// rather than a second, independent magic-number breakpoint. No opinion
|
||||||
|
// on what's inside either pane (a `Panel`, anything else) — same "own
|
||||||
|
// the mechanics, not the content" split as `Card`.
|
||||||
|
import type { ComponentChildren } from "preact";
|
||||||
|
import "./SplitView.css";
|
||||||
|
|
||||||
|
export function SplitView({
|
||||||
|
primary,
|
||||||
|
secondary,
|
||||||
|
}: {
|
||||||
|
/** The wider pane — a list, a table, whatever the page's main content
|
||||||
|
* is. `flex: 2` against `secondary`'s `flex: 1`. */
|
||||||
|
primary: ComponentChildren;
|
||||||
|
/** The narrower pane — a detail view, a summary, anything that reads
|
||||||
|
* as "more about the thing selected in `primary`." */
|
||||||
|
secondary: ComponentChildren;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div class="ui-split-view">
|
||||||
|
<div class="ui-split-view-primary">{primary}</div>
|
||||||
|
<div class="ui-split-view-secondary">{secondary}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -141,25 +141,9 @@
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* `filterMode: "multiselect"` — a scrollable checkbox list rather than
|
/* `filterMode: "multiselect"`'s actual checkbox-list content is
|
||||||
a `<select multiple>`, which needs a modifier key to pick more than
|
`MultiselectFilterOptions` (`ui/multiselect-filter/`) now — its own
|
||||||
one option and reads as unfamiliar to most operators; a plain
|
colocated CSS, not here. */
|
||||||
checkbox list needs neither. `max-height` + scroll keeps a
|
|
||||||
long option set (many labels) from pushing the popover off-screen. */
|
|
||||||
.ui-table-filter-multiselect {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.3em;
|
|
||||||
max-height: 12em;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.ui-table-filter-checkbox {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35em;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The negate toggle sits under every filter mode's own control (mara:
|
/* The negate toggle sits under every filter mode's own control (mara:
|
||||||
"you should be able to change if the filter is negated or not") —
|
"you should be able to change if the filter is negated or not") —
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { createPortal } from "preact/compat";
|
||||||
import type { ComponentChildren } from "preact";
|
import type { ComponentChildren } from "preact";
|
||||||
import { FilterIcon } from "@hive/shared/icons.js";
|
import { FilterIcon } from "@hive/shared/icons.js";
|
||||||
import { useLocalSetting } from "@hive/shared/settings-storage.js";
|
import { useLocalSetting } from "@hive/shared/settings-storage.js";
|
||||||
|
import { MultiselectFilterOptions } from "../multiselect-filter/MultiselectFilter.js";
|
||||||
import "./Table.css";
|
import "./Table.css";
|
||||||
|
|
||||||
export interface TableColumn<T> {
|
export interface TableColumn<T> {
|
||||||
|
|
@ -133,6 +134,101 @@ function compareValues(a: string | number, b: string | number): number {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The filter *state* half of `<Table>`, split out so a second, differently
|
||||||
|
// shaped view over the same rows (AgentsPage's card list, switchable
|
||||||
|
// against its own `Table`) can share one filter state instead of forking
|
||||||
|
// its own — mara: "separating data and filter from view" lets the choice
|
||||||
|
// of view (table vs. card) stop mattering to what's filtered. `Table`
|
||||||
|
// itself calls this internally below; the popover-trigger UI stays here,
|
||||||
|
// table-shaped (a `<th>`-anchored icon) — a caller with a different
|
||||||
|
// layout renders its own trigger against the same `getFilter`/
|
||||||
|
// `updateFilter`/`toggleFilterValue`/`multiselectOptionsFor` this returns.
|
||||||
|
export function useTableFilters<T>(
|
||||||
|
columns: TableColumn<T>[],
|
||||||
|
rows: T[],
|
||||||
|
storageKey: string,
|
||||||
|
) {
|
||||||
|
const [filters, setFilters] = useLocalSetting<
|
||||||
|
Record<string, ColumnFilterState>
|
||||||
|
>(storageKey, {});
|
||||||
|
|
||||||
|
const activeFilters = Object.entries(filters).filter(([, f]) =>
|
||||||
|
isFilterActive(f),
|
||||||
|
);
|
||||||
|
|
||||||
|
function getFilter(key: string): ColumnFilterState {
|
||||||
|
return filters[key] ?? EMPTY_FILTER;
|
||||||
|
}
|
||||||
|
function updateFilter(key: string, patch: Partial<ColumnFilterState>) {
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
[key]: { ...getFilter(key), ...patch },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function toggleFilterValue(key: string, value: string) {
|
||||||
|
const current = getFilter(key).values;
|
||||||
|
updateFilter(key, {
|
||||||
|
values: current.includes(value)
|
||||||
|
? current.filter((v) => v !== value)
|
||||||
|
: [...current, value],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// mara: "have a small reset filters btn" — one button clears every
|
||||||
|
// column's filter at once rather than hunting down each popover
|
||||||
|
// individually.
|
||||||
|
function resetFilters() {
|
||||||
|
setFilters({});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distinct values currently present for a `"multiselect"`-mode column,
|
||||||
|
// so the checkbox list never offers an option that would match zero
|
||||||
|
// rows. Computed off `rows` (pre-filter) — every column's own option
|
||||||
|
// list stays stable while a sibling column's filter narrows
|
||||||
|
// `visibleRows`, matching how a spreadsheet's column filters don't
|
||||||
|
// hide each other's choices. A row contributes every one of its own
|
||||||
|
// values, not just one.
|
||||||
|
function multiselectOptionsFor(c: TableColumn<T>): string[] {
|
||||||
|
if (!c.filterValues) return [];
|
||||||
|
const values = new Set<string>();
|
||||||
|
for (const row of rows) for (const v of c.filterValues(row)) values.add(v);
|
||||||
|
return Array.from(values).sort((a, b) =>
|
||||||
|
a.localeCompare(b, undefined, { sensitivity: "base", numeric: true }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleRows = useMemo(() => {
|
||||||
|
if (activeFilters.length === 0) return rows;
|
||||||
|
return rows.filter((row) =>
|
||||||
|
activeFilters.every(([key, f]) => {
|
||||||
|
const col = columns.find((c) => c.key === key);
|
||||||
|
let matches: boolean;
|
||||||
|
if (col?.filterMode === "multiselect") {
|
||||||
|
const rowValues = col.filterValues?.(row) ?? [];
|
||||||
|
matches = f.values.some((v) => rowValues.includes(v));
|
||||||
|
} else {
|
||||||
|
const value = col?.filterValue?.(row) ?? "";
|
||||||
|
matches = value.toLowerCase().includes(f.value.toLowerCase());
|
||||||
|
}
|
||||||
|
return f.negate ? !matches : matches;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- `columns` is
|
||||||
|
// a fresh array every render (built inline by every caller); keying
|
||||||
|
// off its identity would recompute every render regardless, so this
|
||||||
|
// depends on the values that actually determine the output instead.
|
||||||
|
}, [rows, JSON.stringify(activeFilters)]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
visibleRows,
|
||||||
|
activeFilters,
|
||||||
|
getFilter,
|
||||||
|
updateFilter,
|
||||||
|
toggleFilterValue,
|
||||||
|
resetFilters,
|
||||||
|
multiselectOptionsFor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function Table<T>({
|
export function Table<T>({
|
||||||
columns,
|
columns,
|
||||||
rows,
|
rows,
|
||||||
|
|
@ -163,13 +259,18 @@ export function Table<T>({
|
||||||
// multi-column sort is real complexity (tie-break order, a UI to
|
// multi-column sort is real complexity (tie-break order, a UI to
|
||||||
// express it) nothing here has asked for yet.
|
// express it) nothing here has asked for yet.
|
||||||
const [sort, setSort] = useState<{ key: string; dir: SortDir } | null>(null);
|
const [sort, setSort] = useState<{ key: string; dir: SortDir } | null>(null);
|
||||||
// Filter state per filterable column key, only populated for a column
|
// Filter *state* lives in `useTableFilters` now (see its own comment) —
|
||||||
// the operator has actually touched. Persisted (see `storageKey`
|
// this component only owns the popover-trigger UI below, table-shaped
|
||||||
// above), same `useLocalSetting` plumbing `IssueReportPage` already
|
// (a `<th>`-anchored icon).
|
||||||
// used for its own now-folded-in label filter.
|
const {
|
||||||
const [filters, setFilters] = useLocalSetting<
|
visibleRows: filteredRows,
|
||||||
Record<string, ColumnFilterState>
|
activeFilters,
|
||||||
>(storageKey, {});
|
getFilter,
|
||||||
|
updateFilter,
|
||||||
|
toggleFilterValue,
|
||||||
|
resetFilters,
|
||||||
|
multiselectOptionsFor,
|
||||||
|
} = useTableFilters(columns, rows, storageKey);
|
||||||
// Which column's filter popover is open, if any — at most one at a
|
// Which column's filter popover is open, if any — at most one at a
|
||||||
// time (opening a second closes the first) so the header row never
|
// time (opening a second closes the first) so the header row never
|
||||||
// shows more than one panel at once.
|
// shows more than one panel at once.
|
||||||
|
|
@ -228,34 +329,6 @@ export function Table<T>({
|
||||||
};
|
};
|
||||||
}, [openFilterKey]);
|
}, [openFilterKey]);
|
||||||
|
|
||||||
const activeFilters = Object.entries(filters).filter(([, f]) =>
|
|
||||||
isFilterActive(f),
|
|
||||||
);
|
|
||||||
|
|
||||||
function getFilter(key: string): ColumnFilterState {
|
|
||||||
return filters[key] ?? EMPTY_FILTER;
|
|
||||||
}
|
|
||||||
function updateFilter(key: string, patch: Partial<ColumnFilterState>) {
|
|
||||||
setFilters({
|
|
||||||
...filters,
|
|
||||||
[key]: { ...getFilter(key), ...patch },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function toggleFilterValue(key: string, value: string) {
|
|
||||||
const current = getFilter(key).values;
|
|
||||||
updateFilter(key, {
|
|
||||||
values: current.includes(value)
|
|
||||||
? current.filter((v) => v !== value)
|
|
||||||
: [...current, value],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// mara: "have a small reset filters btn" — one button clears every
|
|
||||||
// column's filter at once rather than hunting down each popover
|
|
||||||
// individually.
|
|
||||||
function resetFilters() {
|
|
||||||
setFilters({});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close on outside click or Escape — same contract `Dropdown` gives
|
// Close on outside click or Escape — same contract `Dropdown` gives
|
||||||
// its own popover (../.../shared/src/dropdown/Dropdown.tsx), but
|
// its own popover (../.../shared/src/dropdown/Dropdown.tsx), but
|
||||||
// checked by CSS class rather than a ref: unlike `Dropdown`, there's
|
// checked by CSS class rather than a ref: unlike `Dropdown`, there's
|
||||||
|
|
@ -282,40 +355,23 @@ export function Table<T>({
|
||||||
};
|
};
|
||||||
}, [openFilterKey]);
|
}, [openFilterKey]);
|
||||||
|
|
||||||
|
// Sort applies on top of `useTableFilters`'s already-filtered rows —
|
||||||
|
// sorting stays table-only (no other view has asked for it), so it's
|
||||||
|
// layered here rather than folded into the shared hook.
|
||||||
const visibleRows = useMemo(() => {
|
const visibleRows = useMemo(() => {
|
||||||
let out = rows;
|
if (!sort) return filteredRows;
|
||||||
if (activeFilters.length > 0) {
|
const col = columns.find((c) => c.key === sort.key);
|
||||||
out = out.filter((row) =>
|
const sortBy = col?.sortBy;
|
||||||
activeFilters.every(([key, f]) => {
|
if (!sortBy) return filteredRows;
|
||||||
const col = columns.find((c) => c.key === key);
|
return [...filteredRows].sort((a, b) => {
|
||||||
let matches: boolean;
|
const cmp = compareValues(sortBy(a), sortBy(b));
|
||||||
if (col?.filterMode === "multiselect") {
|
return sort.dir === "asc" ? cmp : -cmp;
|
||||||
const rowValues = col.filterValues?.(row) ?? [];
|
});
|
||||||
matches = f.values.some((v) => rowValues.includes(v));
|
|
||||||
} else {
|
|
||||||
const value = col?.filterValue?.(row) ?? "";
|
|
||||||
matches = value.toLowerCase().includes(f.value.toLowerCase());
|
|
||||||
}
|
|
||||||
return f.negate ? !matches : matches;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (sort) {
|
|
||||||
const col = columns.find((c) => c.key === sort.key);
|
|
||||||
const sortBy = col?.sortBy;
|
|
||||||
if (sortBy) {
|
|
||||||
out = [...out].sort((a, b) => {
|
|
||||||
const cmp = compareValues(sortBy(a), sortBy(b));
|
|
||||||
return sort.dir === "asc" ? cmp : -cmp;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- `columns` is
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- `columns` is
|
||||||
// a fresh array every render (built inline by every caller); keying
|
// a fresh array every render (built inline by every caller); keying
|
||||||
// off its identity would recompute every render regardless, so this
|
// off its identity would recompute every render regardless, so this
|
||||||
// depends on the values that actually determine the output instead.
|
// depends on the values that actually determine the output instead.
|
||||||
}, [rows, sort, JSON.stringify(activeFilters)]);
|
}, [filteredRows, sort]);
|
||||||
|
|
||||||
function toggleSort(key: string) {
|
function toggleSort(key: string) {
|
||||||
setSort((prev) => {
|
setSort((prev) => {
|
||||||
|
|
@ -333,22 +389,6 @@ export function Table<T>({
|
||||||
return sort.dir === "asc" ? "ascending" : "descending";
|
return sort.dir === "asc" ? "ascending" : "descending";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Distinct values currently present for a `"multiselect"`-mode column,
|
|
||||||
// so the checkbox list never offers an option that would match zero
|
|
||||||
// rows. Computed off `rows` (pre-filter) — every column's own option
|
|
||||||
// list stays stable while a sibling column's filter narrows
|
|
||||||
// `visibleRows`, matching how a spreadsheet's column filters don't
|
|
||||||
// hide each other's choices. A row contributes every one of its own
|
|
||||||
// values, not just one.
|
|
||||||
function multiselectOptionsFor(c: TableColumn<T>): string[] {
|
|
||||||
if (!c.filterValues) return [];
|
|
||||||
const values = new Set<string>();
|
|
||||||
for (const row of rows) for (const v of c.filterValues(row)) values.add(v);
|
|
||||||
return Array.from(values).sort((a, b) =>
|
|
||||||
a.localeCompare(b, undefined, { sensitivity: "base", numeric: true }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only one popover is ever open at a time, so one ref (rather than a
|
// Only one popover is ever open at a time, so one ref (rather than a
|
||||||
// per-column ref map) is enough to focus whichever control just
|
// per-column ref map) is enough to focus whichever control just
|
||||||
// mounted — typing immediately after the click that opened it,
|
// mounted — typing immediately after the click that opened it,
|
||||||
|
|
@ -387,21 +427,16 @@ export function Table<T>({
|
||||||
|
|
||||||
function renderFilterControl(c: TableColumn<T>) {
|
function renderFilterControl(c: TableColumn<T>) {
|
||||||
if (c.filterMode === "multiselect") {
|
if (c.filterMode === "multiselect") {
|
||||||
const f = getFilter(c.key);
|
// Content shared with `AgentsPage`'s card-view filter toolbar —
|
||||||
|
// see `MultiselectFilter.tsx`'s own file-top comment for why this
|
||||||
|
// moved out of here.
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div class="ui-table-filter-multiselect">
|
<MultiselectFilterOptions
|
||||||
{multiselectOptionsFor(c).map((v) => (
|
options={multiselectOptionsFor(c)}
|
||||||
<label key={v} class="ui-table-filter-checkbox">
|
filter={getFilter(c.key)}
|
||||||
<input
|
onToggle={(v) => toggleFilterValue(c.key, v)}
|
||||||
type="checkbox"
|
/>
|
||||||
checked={f.values.includes(v)}
|
|
||||||
onChange={() => toggleFilterValue(c.key, v)}
|
|
||||||
/>
|
|
||||||
{v}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{renderNegateToggle(c)}
|
{renderNegateToggle(c)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -443,7 +478,7 @@ export function Table<T>({
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
{columns.map((c) => {
|
{columns.map((c) => {
|
||||||
const hasFilterValue = isFilterActive(filters[c.key]);
|
const hasFilterValue = isFilterActive(getFilter(c.key));
|
||||||
const filterOpen = openFilterKey === c.key;
|
const filterOpen = openFilterKey === c.key;
|
||||||
const headerContent = c.sortBy ? (
|
const headerContent = c.sortBy ? (
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue