diff --git a/frontend/packages/swarm-ui/src/App.tsx b/frontend/packages/swarm-ui/src/App.tsx index 99380be6..8255a61c 100644 --- a/frontend/packages/swarm-ui/src/App.tsx +++ b/frontend/packages/swarm-ui/src/App.tsx @@ -2,7 +2,7 @@ // page component under `./pages/`; this file just maps paths to them. import { Route, Switch } from "wouter-preact"; import { Shell } from "./shell/Shell.js"; -import { AgentsPage } from "./pages/agents/AgentsPage.js"; +import { AgentsPage } from "./pages/AgentsPage.js"; import { ComponentsPage } from "./pages/ComponentsPage.js"; import { JobsPage } from "./pages/JobsPage.js"; import { HivesPage } from "./pages/HivesPage.js"; diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx b/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx similarity index 53% rename from frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx rename to frontend/packages/swarm-ui/src/pages/AgentsPage.tsx index 6ed2fb62..5d42ada8 100644 --- a/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx @@ -1,54 +1,105 @@ // — 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. +// config-PR status, swarm-wide health status, and declared wanted state +// in the same table. Three separate asks (a roster listing, a per-agent +// config-PR indicator, and per-agent status/liveness) that turned out to +// be one page: a roster with no per-row detail is thin, and neither a +// config-PR panel nor a status panel has anything to render against +// without a roster to embed in. // // 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. +// carrying its freshness/snapshot/config PR/wanted state. That merge +// used to be three separate fetches (`/api/agents`, `/api/config-prs`, +// `/api/agents/status`) 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 for where `config_pr`/`wanted` get merged +// in and why that's the handler's job rather than +// `agent_status::AgentStatusReader`'s. No more per-page joining left to +// do here: the wire row *is* the table row. // // 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. +// natural home for the action that populates it; a separate top-level +// nav entry would be one click of indirection for no benefit. The form +// itself (`CreateAgentForm`) mounts inside a `Dialog` here rather than +// 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"; +// The "wanted" column is one `WantedMenu` badge+dropdown per row — see +// that component's own comment above `AgentsPage` for why. +import { useRef, useState } from "preact/hooks"; import 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 { 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 { 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 { Button } from "../ui/button/Button.js"; +import { ConfirmDialog } from "../ui/confirm-dialog/ConfirmDialog.js"; +import { Dialog } from "../ui/dialog/Dialog.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"; +} from "../ui/refresh-interval/RefreshInterval.js"; +import { Table, type TableColumn } from "../ui/table/Table.js"; +import { CreateAgentForm } from "./CreateAgentForm.js"; +import { LinkMatrixAccountForm } from "./LinkMatrixAccountForm.js"; + +interface ConfigPrStatus { + pr_number: number; + html_url: string | null; +} + +type Freshness = "fresh" | "stale" | "never_reported" | "unknown"; + +interface AgentStatusSnapshot { + status_text: string | null; + status_set_at: number | null; + running: boolean; +} + +// `"up"` / `"offline"` / `"destroyed"`, wire-spelled by +// `swarm_queue_client::wanted::AgentState::as_str` — kept as a bare +// `string | null` rather than a union, same reason +// `config_pr`'s shape isn't re-derived here: this page renders whatever +// the wire sends, it doesn't validate the enum client-side. +type Wanted = string | null; + +// Mirrors `agent_status::AgentStatusRow` field-for-field — this *is* the +// table row now, not a shape assembled from it, so there's no separate +// join-result type to keep in sync with the wire contract by hand. +interface AgentRow { + name: string; + hive: string | null; + freshness: Freshness; + last_seen_unix: number | null; + // `age_seconds` deliberately unused here, same reason as HivesPage: + // `RelativeTime` recomputes age client-side from `last_seen_unix` + // rather than rendering a once-computed-at-fetch value. + snapshot: AgentStatusSnapshot | null; + config_pr: ConfigPrStatus | null; + wanted: Wanted; +} + +// Same tone pairing as HivesPage — one freshness enum shared by both +// endpoints, so the same tone rule applies to both pages — but `unknown`'s +// label diverges deliberately: an agent outside the swarm-identity roster +// has a concrete next step (register it), while an unrecognized *hive* on +// HivesPage doesn't carry the same actionable meaning. +const FRESHNESS: Record = { + fresh: { tone: "positive", label: "fresh" }, + stale: { tone: "warning", label: "stale" }, + never_reported: { tone: "neutral", label: "never reported" }, + // `unknown` means the agent reported into the KV bucket but isn't in + // the swarm-identity roster — a real, distinct case ("hive-reported, + // not yet in swarm level"), not just a fallback default, so it gets a + // label that says what to do about it rather than reusing the enum + // name as the label. + unknown: { tone: "negative", label: "not in swarm identity" }, +}; // The one thing that differs between the "offline" and "paused" confirm // dialogs `confirmTarget` drives — everything else (button row, open/close @@ -89,35 +140,102 @@ const CONFIRM_COPY: Record< // 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"; +// The "wanted" column's control: one badge showing the current +// declaration, opening a `Dropdown` with the four explicit states — +// replaces the old toggle-badge-plus-separate-destroy-badge pair. +// Explicit options also fix a real bug the toggle had: for an +// undeclared row, the toggle inferred a target as "the opposite of +// `snapshot.running`", so a click on an undeclared-but-running agent +// silently declared it *offline* rather than making its real state +// explicit — a dropdown just lets "up" be picked directly regardless +// of what's inferred, no flip-to-the-opposite-of-a-guess involved +// (mara: "when no state is declared, i want to set it to online"). +// Presentational only, same split as `StatusChips`'s `Picker`/ +// `StatusMenu`: the caller owns what each selection actually does. +// +// "paused" has no separate resume option here — selecting "up" from a +// paused row is the resume, same PUT either way (`declareState`'s +// backend counterpart, `hive-c0re`'s `decide_pause`, treats a declared +// `Up` with the marker still set as "clear it"). A dedicated "resume" +// entry would just be a second spelling of the option already above it. +function WantedMenu({ + row, + pending, + onSelectUp, + onSelectOffline, + onSelectPaused, + onDestroy, +}: { + row: AgentRow; + pending: boolean; + onSelectUp: (row: AgentRow) => void; + onSelectOffline: (row: AgentRow) => void; + onSelectPaused: (row: AgentRow) => void; + onDestroy: (row: AgentRow) => void; +}) { + const [open, setOpen] = useState(false); + const anchorRef = useRef(null); + const destroyed = row.wanted === "destroyed"; + const tone: BadgeTone = + row.wanted === "up" + ? "positive" + : row.wanted === "paused" + ? "warning" + : destroyed + ? "negative" + : "neutral"; + const options: DropdownOption[] = [ + { value: "up", label: "up" }, + { value: "paused", label: "paused" }, + { value: "offline", label: "offline" }, + { value: "destroy", label: "destroy", danger: true }, + ]; + + return ( +
+ setOpen((o) => !o) : undefined} + expanded={open} + disabled={pending || !row.hive || destroyed} + title={ + destroyed + ? `${row.name} is destroyed — redeploy via "+ agent" to bring it back` + : row.hive + ? `declare a new state for ${row.name}` + : "no hive on record for this agent — nothing to declare against" + } + /> + { + setOpen(false); + if (value === "up") onSelectUp(row); + else if (value === "offline") onSelectOffline(row); + else if (value === "paused") onSelectPaused(row); + else onDestroy(row); + }} + onClose={() => setOpen(false)} + anchorRef={anchorRef} + /> +
+ ); +} export function AgentsPage() { const [rows, setRows] = useState(null); const [error, setError] = useState(null); const [intervalMs, setIntervalMs] = useState(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(() => { - 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. @@ -151,17 +269,6 @@ export function AgentsPage() { // confirmation of a `declareState` call, it's an unrelated action with // its own form (`LinkMatrixAccountForm`). const [matrixTarget, setMatrixTarget] = useState(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(null); - const detailTarget = rows?.find((r) => r.name === detailTargetName) ?? null; async function refresh() { const res = await fetch("/api/agents/status"); @@ -263,22 +370,6 @@ export function AgentsPage() { 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[] = [ { key: "name", @@ -292,13 +383,24 @@ export function AgentsPage() { header: "hive", render: (a) => a.hive ?? "—", sortBy: (a) => a.hive ?? "", + // "—" (not "") so the missing-hive option in the filter list reads + // the same as the cell itself, rather than showing a blank choice. + // Multiselect, not single-value `"select"` — an operator narrowing + // to a handful of hives at once shouldn't need to filter one at a + // time. filterValues: (a) => [a.hive ?? "—"], filterMode: "multiselect", }, { key: "status", header: "status", + // Technical freshness only — the agent's own free-text status + // string used to be concatenated into this same badge, which + // stuffed a full sentence into a pill meant for a short discrete + // label and blew the row out (mara: "looks messy"). That string + // now lives in its own "message" column below. sortBy: (a) => FRESHNESS[a.freshness].label, + // Multiselect, same reasoning as the hive column above. filterValues: (a) => [FRESHNESS[a.freshness].label], filterMode: "multiselect", render: (a) => { @@ -330,13 +432,22 @@ export function AgentsPage() { key: "message", header: "message", cellClass: "ui-table-prose", + // Plain text, not a Badge — the agent's own free-form status + // sentence, as distinct from the technical freshness state in the + // "status" column. A `running: false` snapshot always carries + // `status_text: null` (the wire contract's own rule), so a stopped + // agent just shows an em dash here rather than a stale message. render: (a) => a.snapshot?.status_text ?? "—", filterValue: (a) => a.snapshot?.status_text ?? "", }, { key: "wanted", header: "wanted", + // One `WantedMenu` (badge + dropdown, defined above) per row — + // real per-badge logic lives there, this column just wires its + // callbacks to the page's own state/handlers. sortBy: (a) => a.wanted ?? "", + // Multiselect, same reasoning as the hive column above. filterValues: (a) => [a.wanted ?? "no declaration"], filterMode: "multiselect", render: (a) => { @@ -346,10 +457,13 @@ export function AgentsPage() { void declareState(row, "up")} + onSelectOffline={(row) => + setConfirmTarget({ row, state: "offline" }) + } + onSelectPaused={(row) => + setConfirmTarget({ row, state: "paused" }) + } onDestroy={setDestroyTarget} /> {err ? ( @@ -366,6 +480,12 @@ export function AgentsPage() { { key: "matrix", header: "matrix", + // Icon-only trigger, quiet variant (no permanent pill fill) — same + // "chrome, not a status chip" reasoning as `LinksMenu`/`SettingsMenu`'s + // own `Badge` triggers (see `@hive/shared/badge/Badge.tsx`'s + // `BadgeVariant` doc). Disabled with no hive on record, same guard + // `WantedMenu` uses — the endpoint is hive-scoped, there's nothing + // to PUT against without one. render: (a) => ( - - -
- - -
- - - } - > - {error ? ( - - ) : null} - {!error && rows === null ?

loading…

: null} - {rows ? ( - a.name} - storageKey="swarm-ui:agents:table-filters" - view={viewMode} - emptyMessage="no agents yet — the swarm-wide identity store has no agents registered on any hive" - renderCard={(a) => ( - setDetailTargetName(row.name)} - /> - )} - /> - ) : null} - - } - // 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={ - - {detailTarget ? ( -
-
-
status
-
- {(() => { - const { tone, label } = FRESHNESS[detailTarget.freshness]; - return ( - - {label} - {detailTarget.last_seen_unix !== null ? ( - <> - {" "} - ( - - ) - - ) : null} - - } - /> - ); - })()} -
-
message
-
{detailTarget.snapshot?.status_text ?? "—"}
-
wanted
-
- {/* Full menu (destroy included) — mara: "destroy is - already available via wanted state", no separate - button needed. */} - -
-
hive
-
{detailTarget.hive ?? "—"}
-
config PR
-
- {detailTarget.config_pr ? ( - - #{detailTarget.config_pr.pr_number} - - ) : ( - `#${detailTarget.config_pr.pr_number}` - ) - } - /> - ) : ( - "—" - )} -
-
-
- } - 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" - } - /> -
-
- ) : ( -

select an agent to see its details

- )} -
- } - /> + + + + + } + > + {error ? ( + + ) : null} + {!error && rows === null ?

loading…

: null} + {rows ? ( + a.name} + emptyMessage="no agents yet — the swarm-wide identity store has no agents registered on any hive" + storageKey="swarm-ui:agents:table-filters" + /> + ) : null} setCreateOpen(false)} @@ -642,6 +613,6 @@ export function AgentsPage() {

) : null} - + ); } diff --git a/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx b/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx index 0e5536cb..bb4f3fcf 100644 --- a/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx @@ -7,19 +7,12 @@ // swarm-controller's API is up or not. import { useRef, useState } from "preact/hooks"; import type { ComponentChildren } from "preact"; -import { Card } from "../ui/card/Card.js"; -import { FilterableView } from "../ui/filterable-view/FilterableView.js"; -import { - MultiselectFilter, - type MultiselectFilterState, -} from "../ui/multiselect-filter/MultiselectFilter.js"; import { Panel } from "../ui/panel/Panel.js"; import { RelativeTime } from "../ui/relative-time/RelativeTime.js"; import { RefreshIntervalPicker, type RefreshIntervalMs, } from "../ui/refresh-interval/RefreshInterval.js"; -import { SplitView } from "../ui/split-view/SplitView.js"; import { Table, type TableColumn } from "../ui/table/Table.js"; import { TextField } from "../ui/text-field/TextField.js"; import { @@ -219,71 +212,6 @@ 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({ - values: [], - negate: false, - }); - return ( - - 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 ( -
-
- - -
- r.name} - storageKey="swarm-ui:components-demo:filterable-view" - view={view} - emptyMessage="no rows" - renderCard={(r) => ( - {}}> - {r.name} -
{r.detail}
-
- )} - /> -
- ); -} - function ConfirmDialogSample() { const [open, setOpen] = useState(false); return ( @@ -341,23 +269,6 @@ export function ComponentsPage() { -
- - - primary content — e.g. AgentsPage's roster - - } - secondary={ - - secondary content — e.g. the selected row - - } - /> - -
-
-
-
- - - alpha -
plain content, not clickable
-
-
- - {}}> - beta -
click, or Enter/Space when focused
-
-
- - {}}> - gamma -
same card, with selected set
-
-
-
-
- -
- - - -
- -
- - - -
-
diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentCard.css b/frontend/packages/swarm-ui/src/pages/agents/AgentCard.css deleted file mode 100644 index 90a81e33..00000000 --- a/frontend/packages/swarm-ui/src/pages/agents/AgentCard.css +++ /dev/null @@ -1,24 +0,0 @@ -/* content layout — the clickable/selectable container - itself is `Card`'s (`ui/card/`); this is just the name/badges/ - message arrangement inside one. */ -.ui-agent-card-line1 { - display: flex; - align-items: center; - gap: 0.6em; -} -.ui-agent-card-name { - font-weight: 600; -} -/* Pushes the wanted-state control to the row's trailing edge regardless - of how wide the status badge next to it ends up. */ -.ui-agent-card-wanted { - display: inline-flex; - align-items: center; - gap: 0.4em; - margin-left: auto; -} -.ui-agent-card-message { - color: var(--muted); - white-space: normal; - word-break: break-word; -} diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentCard.tsx b/frontend/packages/swarm-ui/src/pages/agents/AgentCard.tsx deleted file mode 100644 index e1ea8803..00000000 --- a/frontend/packages/swarm-ui/src/pages/agents/AgentCard.tsx +++ /dev/null @@ -1,93 +0,0 @@ -// — one card per roster agent (mara: "main view: name, -// status, message, wanted" / "message as second line" / "more like -// card per agent") — the FilterableView `view === "cards"` alternative -// to the original `Table`, not its replacement (see `AgentsPage`'s own -// comment). Clicking a card opens a detail panel repeating this same -// info plus what doesn't fit here (hive, config-PR link, matrix -// link-account) — mara: "the info from main list should be included in -// the agent view" too, not just the leftovers. -// -// Built on the shared `Card` primitive (`ui/card/`) for the clickable/ -// selectable-container mechanics — the `stopPropagation` wrapper around -// `WantedMenu` below keeps its own clicks (mouse or keyboard-synthesized, -// `WantedMenu` hosts real ` - ) : null} -
- ); -} - -export function FilterableView({ - columns, - rows, - rowKey, - storageKey, - view, - renderCard, - emptyMessage, -}: { - columns: TableColumn[]; - rows: T[]; - rowKey: (row: T) => string; - // Shared 1:1 with `Table`'s own filter persistence — pass the same - // key `
` gets when it's the other `view`, so cards↔table never - // lose or hide an active filter (see this file's own comment). - storageKey: string; - view: "cards" | "table"; - renderCard: (row: T) => ComponentChildren; - // Shown when `rows` itself is empty. A filter narrowing a non-empty - // `rows` to zero visible rows gets its own fixed message instead - // (same as `Table`'s own "no rows match the current filter") — the - // two cases mean different things, same as `Table`'s own split. - emptyMessage?: ComponentChildren; -}) { - const { - visibleRows, - activeFilters, - getFilter, - updateFilter, - toggleFilterValue, - resetFilters, - multiselectOptionsFor, - } = useTableFilters(columns, rows, storageKey); - - if (view === "table") { - return ( -
- ); - } - - return ( - <> - - {rows.length === 0 && emptyMessage ? ( -

{emptyMessage}

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

no rows match the current filter

- ) : null} - {visibleRows.length > 0 ? ( -
- {visibleRows.map((row) => ( -
{renderCard(row)}
- ))} -
- ) : null} - - ); -} diff --git a/frontend/packages/swarm-ui/src/ui/multiselect-filter/MultiselectFilter.css b/frontend/packages/swarm-ui/src/ui/multiselect-filter/MultiselectFilter.css deleted file mode 100644 index 698c77e0..00000000 --- a/frontend/packages/swarm-ui/src/ui/multiselect-filter/MultiselectFilter.css +++ /dev/null @@ -1,51 +0,0 @@ -/* 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); -} diff --git a/frontend/packages/swarm-ui/src/ui/multiselect-filter/MultiselectFilter.tsx b/frontend/packages/swarm-ui/src/ui/multiselect-filter/MultiselectFilter.tsx deleted file mode 100644 index a104ce99..00000000 --- a/frontend/packages/swarm-ui/src/ui/multiselect-filter/MultiselectFilter.tsx +++ /dev/null @@ -1,140 +0,0 @@ -// / — 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 `
`) 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 ( -
- {options.map((v) => ( - - ))} -
- ); -} - -// 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 ( - - ); -} - -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(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 ( -
- 0 - ? `${label} (${filter.values.length})` - : label - } - onClick={() => setOpen((o) => !o)} - expanded={open} - /> - {open ? ( - - ) : null} -
- ); -} diff --git a/frontend/packages/swarm-ui/src/ui/panel/Panel.tsx b/frontend/packages/swarm-ui/src/ui/panel/Panel.tsx index 56afdd7b..f90e838a 100644 --- a/frontend/packages/swarm-ui/src/ui/panel/Panel.tsx +++ b/frontend/packages/swarm-ui/src/ui/panel/Panel.tsx @@ -34,19 +34,14 @@ export function Panel({ icon, actions, children, - class: extraClass, }: { title?: string; icon?: string; actions?: ComponentChildren; children: ComponentChildren; - /** Extra class on the outer `
` — 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 ( -
+
{title || icon || actions ? (
{icon ? ( diff --git a/frontend/packages/swarm-ui/src/ui/split-view/SplitView.css b/frontend/packages/swarm-ui/src/ui/split-view/SplitView.css deleted file mode 100644 index 6e70efd4..00000000 --- a/frontend/packages/swarm-ui/src/ui/split-view/SplitView.css +++ /dev/null @@ -1,14 +0,0 @@ -.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; -} diff --git a/frontend/packages/swarm-ui/src/ui/split-view/SplitView.tsx b/frontend/packages/swarm-ui/src/ui/split-view/SplitView.tsx deleted file mode 100644 index 09931966..00000000 --- a/frontend/packages/swarm-ui/src/ui/split-view/SplitView.tsx +++ /dev/null @@ -1,33 +0,0 @@ -// — 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 ( -
-
{primary}
-
{secondary}
-
- ); -} diff --git a/frontend/packages/swarm-ui/src/ui/table/Table.css b/frontend/packages/swarm-ui/src/ui/table/Table.css index 06277b49..6be32e7a 100644 --- a/frontend/packages/swarm-ui/src/ui/table/Table.css +++ b/frontend/packages/swarm-ui/src/ui/table/Table.css @@ -141,9 +141,25 @@ font-size: 0.9em; } -/* `filterMode: "multiselect"`'s actual checkbox-list content is - `MultiselectFilterOptions` (`ui/multiselect-filter/`) now — its own - colocated CSS, not here. */ +/* `filterMode: "multiselect"` — a scrollable checkbox list rather than + a ``, 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 ` {columns.map((c) => { - const hasFilterValue = isFilterActive(getFilter(c.key)); + const hasFilterValue = isFilterActive(filters[c.key]); const filterOpen = openFilterKey === c.key; const headerContent = c.sortBy ? (
`-anchored icon) — a caller with a different -// layout renders its own trigger against the same `getFilter`/ -// `updateFilter`/`toggleFilterValue`/`multiselectOptionsFor` this returns. -export function useTableFilters( - columns: TableColumn[], - rows: T[], - storageKey: string, -) { - const [filters, setFilters] = useLocalSetting< - Record - >(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) { - 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): string[] { - if (!c.filterValues) return []; - const values = new Set(); - 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({ columns, rows, @@ -259,18 +163,13 @@ export function Table({ // multi-column sort is real complexity (tie-break order, a UI to // express it) nothing here has asked for yet. const [sort, setSort] = useState<{ key: string; dir: SortDir } | null>(null); - // Filter *state* lives in `useTableFilters` now (see its own comment) — - // this component only owns the popover-trigger UI below, table-shaped - // (a ``-anchored icon). - const { - visibleRows: filteredRows, - activeFilters, - getFilter, - updateFilter, - toggleFilterValue, - resetFilters, - multiselectOptionsFor, - } = useTableFilters(columns, rows, storageKey); + // Filter state per filterable column key, only populated for a column + // the operator has actually touched. Persisted (see `storageKey` + // above), same `useLocalSetting` plumbing `IssueReportPage` already + // used for its own now-folded-in label filter. + const [filters, setFilters] = useLocalSetting< + Record + >(storageKey, {}); // 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 // shows more than one panel at once. @@ -329,6 +228,34 @@ export function Table({ }; }, [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) { + 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 // its own popover (../.../shared/src/dropdown/Dropdown.tsx), but // checked by CSS class rather than a ref: unlike `Dropdown`, there's @@ -355,23 +282,40 @@ export function Table({ }; }, [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(() => { - if (!sort) return filteredRows; - const col = columns.find((c) => c.key === sort.key); - const sortBy = col?.sortBy; - if (!sortBy) return filteredRows; - return [...filteredRows].sort((a, b) => { - const cmp = compareValues(sortBy(a), sortBy(b)); - return sort.dir === "asc" ? cmp : -cmp; - }); + let out = rows; + if (activeFilters.length > 0) { + out = out.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; + }), + ); + } + 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 // 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. - }, [filteredRows, sort]); + }, [rows, sort, JSON.stringify(activeFilters)]); function toggleSort(key: string) { setSort((prev) => { @@ -389,6 +333,22 @@ export function Table({ 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): string[] { + if (!c.filterValues) return []; + const values = new Set(); + 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 // per-column ref map) is enough to focus whichever control just // mounted — typing immediately after the click that opened it, @@ -427,16 +387,21 @@ export function Table({ function renderFilterControl(c: TableColumn) { if (c.filterMode === "multiselect") { - // Content shared with `AgentsPage`'s card-view filter toolbar — - // see `MultiselectFilter.tsx`'s own file-top comment for why this - // moved out of here. + const f = getFilter(c.key); return ( <> - toggleFilterValue(c.key, v)} - /> +
+ {multiselectOptionsFor(c).map((v) => ( + + ))} +
{renderNegateToggle(c)} ); @@ -478,7 +443,7 @@ export function Table({