diff --git a/frontend/packages/swarm-ui/src/pages/AgentsPage.css b/frontend/packages/swarm-ui/src/pages/AgentsPage.css index d54367ff..aa59f3c6 100644 --- a/frontend/packages/swarm-ui/src/pages/AgentsPage.css +++ b/frontend/packages/swarm-ui/src/pages/AgentsPage.css @@ -60,6 +60,14 @@ border-color: var(--purple); outline: none; } +/* The row currently shown in the detail panel — a solid border (not + just the hover tint above, which needs to keep meaning "hovering", + not double as "selected") plus a faint fill so it still reads once + the pointer moves away. */ +.ui-agent-card-selected { + border-color: var(--purple); + background: color-mix(in srgb, var(--purple) 10%, var(--bg-elev)); +} .ui-agent-card-line1 { display: flex; align-items: center; @@ -82,11 +90,8 @@ word-break: break-word; } -/* Detail panel (`Dialog` body) — the fields `AgentCard`'s main view - doesn't show. */ -.ui-agent-detail-name { - margin: 0 0 0.75em; -} +/* 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; @@ -104,3 +109,55 @@ align-items: center; gap: 0.75em; } + +/* List panel + detail panel side by side — `flex-wrap`, not a + `@media` breakpoint, so a narrow viewport stacks them the same + content-driven way the shell's own nav already wraps (`Shell.css`), + rather than picking a second, independent magic-number breakpoint. */ +.ui-agents-layout { + display: flex; + flex-wrap: wrap; + gap: 1em; + align-items: flex-start; +} +.ui-agents-list-panel { + flex: 2 1 480px; + min-width: 0; +} +.ui-agents-detail-panel { + flex: 1 1 320px; + min-width: 0; +} + +/* Card view's filter toolbar — `Table`'s per-column popovers, minus the + table to hang them off of. */ +.ui-agents-filter-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5em; + margin-bottom: 0.75em; +} +.ui-agents-filter-search { + background: var(--bg); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 4px; + padding: 0.35em 0.6em; + font: inherit; + font-size: 0.9em; +} +.ui-agents-filter { + position: relative; +} +/* `.ui-table-filter-popover` (Table.css) supplies the panel chrome + (background/border/shadow) — this just positions it under the + trigger, `position: absolute` rather than the fixed/portal recipe + `Table`'s own version needs: nothing here clips this popover, so the + simpler positioning is enough (see this component's own comment). */ +.ui-agents-filter-popover { + position: absolute; + top: 100%; + left: 0; + margin-top: 0.25em; +} diff --git a/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx b/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx index c299c144..c7344a54 100644 --- a/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx @@ -28,7 +28,7 @@ // // The "wanted" column is one `WantedMenu` badge+dropdown per row — see // that component's own comment above `AgentsPage` for why. -import { useRef, useState } from "preact/hooks"; +import { useEffect, 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"; @@ -45,7 +45,7 @@ import { useRefreshInterval, type RefreshIntervalMs, } from "../ui/refresh-interval/RefreshInterval.js"; -import { Table, type TableColumn } from "../ui/table/Table.js"; +import { Table, useTableFilters, type TableColumn } from "../ui/table/Table.js"; import { CreateAgentForm } from "./CreateAgentForm.js"; import { LinkMatrixAccountForm } from "./LinkMatrixAccountForm.js"; import "./AgentsPage.css"; @@ -289,6 +289,7 @@ function AgentCard({ row, pending, error, + selected, onSelectUp, onSelectOffline, onSelectPaused, @@ -297,6 +298,11 @@ function AgentCard({ row: AgentRow; pending: boolean; error: ProblemDetails | undefined; + /** This row is the one currently shown in the detail panel — a + * highlight only (the panel itself is the source of truth), so a + * page-refresh landing on a still-valid `detailTarget` reads as + * obviously "that one" rather than a silent selection nothing marks. */ + selected: boolean; onSelectUp: (row: AgentRow) => void; onSelectOffline: (row: AgentRow) => void; onSelectPaused: (row: AgentRow) => void; @@ -305,7 +311,9 @@ function AgentCard({ const { tone, label } = FRESHNESS[row.freshness]; return (
onOpenDetail(row)} @@ -363,6 +371,180 @@ function AgentCard({ ); } +// One `` (the "name" column's plain-text filter) plus one +// `FilterMultiselect` per multiselect-mode filterable column — the card +// view's answer to `Table`'s own per-column popover icons, driving the +// exact same `useTableFilters` state `AgentsPage` passes down (mara: +// "same filters for the cards tho"). Deliberately not a generic +// "render every filterable column automatically" loop: the name column's +// free-text filter needs its own always-visible ``, not a +// dropdown, so there's no single shape that covers every column anyway. +function AgentFilterBar({ + columns, + getFilter, + updateFilter, + toggleFilterValue, + resetFilters, + activeFilters, + multiselectOptionsFor, +}: { + columns: TableColumn[]; + getFilter: (key: string) => { + value: string; + values: string[]; + negate: boolean; + }; + updateFilter: ( + key: string, + patch: Partial<{ value: string; values: string[]; negate: boolean }>, + ) => void; + toggleFilterValue: (key: string, value: string) => void; + resetFilters: () => void; + activeFilters: unknown[]; + multiselectOptionsFor: (c: TableColumn) => string[]; +}) { + const hiveCol = columns.find((c) => c.key === "hive"); + const statusCol = columns.find((c) => c.key === "status"); + const wantedCol = columns.find((c) => c.key === "wanted"); + return ( +
+ + updateFilter("name", { + value: (e.target as HTMLInputElement).value, + }) + } + /> + {hiveCol ? ( + toggleFilterValue("hive", v)} + onNegateChange={(negate) => updateFilter("hive", { negate })} + /> + ) : null} + {statusCol ? ( + toggleFilterValue("status", v)} + onNegateChange={(negate) => updateFilter("status", { negate })} + /> + ) : null} + {wantedCol ? ( + toggleFilterValue("wanted", v)} + onNegateChange={(negate) => updateFilter("wanted", { negate })} + /> + ) : null} + {activeFilters.length > 0 ? ( + + ) : null} +
+ ); +} + +// One multiselect filter's trigger + popover — same checkbox-list markup +// `Table`'s own popover renders (`.ui-table-filter-checkbox`/ +// `-negate`, reused rather than duplicated), just `position: absolute` +// under a `Badge` trigger instead of a portal: this never sits inside a +// clipping scroll container or a `` the way `WantedMenu`'s +// portal mode has to route around, so the simpler positioning is enough. +function FilterMultiselect({ + label, + options, + filter, + onToggle, + onNegateChange, +}: { + label: string; + options: string[]; + filter: { values: string[]; negate: boolean }; + onToggle: (value: string) => void; + onNegateChange: (negate: boolean) => void; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(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} +
+ ); +} + export function AgentsPage() { const [rows, setRows] = useState(null); const [error, setError] = useState(null); @@ -666,186 +848,226 @@ export function AgentsPage() { }, ]; + // Same hook `Table` itself now uses internally, called again here with + // the identical `columns` + `storageKey` — one filter state (in + // localStorage), two independent readers. `rows ?? []`: the hook wants + // a real array, and there's nothing to filter before the first + // `refresh()` resolves anyway. + const { + visibleRows, + activeFilters, + getFilter, + updateFilter, + toggleFilterValue, + resetFilters, + multiselectOptionsFor, + } = useTableFilters(columns, rows ?? [], "swarm-ui:agents:table-filters"); + return ( - - -
- - -
- - - } - > - {error ? ( - - ) : null} - {!error && rows === null ?

loading…

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

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

- ) : null} - {rows && rows.length > 0 && viewMode === "cards" ? ( -
- {rows.map((a) => ( - +
+ + +
+ + +
+ + + } + > + {error ? ( + - ))} -
- ) : null} - {rows && rows.length > 0 && viewMode === "table" ? ( - a.name} - storageKey="swarm-ui:agents:table-filters" - /> - ) : null} - setDetailTarget(null)} - label={detailTarget ? `${detailTarget.name} details` : "agent details"} - > - {/* Mara: "the info from main list should be included in the - agent view" — so this repeats status/message/wanted rather - than showing only what `AgentCard`'s main view doesn't, plus - hive/config-PR/matrix-link, which really are panel-only. */} - {detailTarget ? ( -
-

{detailTarget.name}

-
-
status
-
- {(() => { - const { tone, label } = FRESHNESS[detailTarget.freshness]; - return ( + ) : null} + {!error && rows === null ?

loading…

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

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

+ ) : null} + {/* Card view's own filter toolbar — table view keeps its + existing per-column popovers instead (see `columns` above); + both read/write the same `useTableFilters` state (same + `storageKey`), so switching the view toggle doesn't reset or + hide whatever's filtered. */} + {rows && rows.length > 0 && viewMode === "cards" ? ( + + ) : null} + {rows && rows.length > 0 && viewMode === "cards" ? ( +
+ {visibleRows.length === 0 ? ( +

no rows match the current filter

+ ) : ( + visibleRows.map((a) => ( + + )) + )} +
+ ) : null} + {rows && rows.length > 0 && viewMode === "table" ? ( +
a.name} + storageKey="swarm-ui:agents:table-filters" + /> + ) : 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. `.ui-agents-layout`'s `flex-wrap` + stacks this below the list on a narrow viewport, same + content-driven-not-a-fixed-breakpoint approach the shell's own + nav already uses (`Shell.css`), rather than a new media query. */} + + {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 ? ( - {label} - {detailTarget.last_seen_unix !== null ? ( - <> - {" "} - ( - - ) - - ) : null} - + detailTarget.config_pr.html_url ? ( + + #{detailTarget.config_pr.pr_number} + + ) : ( + `#${detailTarget.config_pr.pr_number}` + ) } /> - ); - })()} -
-
message
-
{detailTarget.snapshot?.status_text ?? "—"}
-
wanted
-
- {/* Full menu (destroy included) — mara: "destroy is - already available via wanted state", no separate - button needed. */} - { - setDestroyTarget(row); - setDetailTarget(null); - }} + ) : ( + "—" + )} +
+
+
+ } + 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" + } /> - -
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); - setDetailTarget(null); - } - : 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" - } - /> +
-
- ) : null} - + ) : ( +

select an agent to see its details

+ )} +
+ setCreateOpen(false)} @@ -900,6 +1122,6 @@ export function AgentsPage() {

) : null} - + ); } diff --git a/frontend/packages/swarm-ui/src/ui/panel/Panel.tsx b/frontend/packages/swarm-ui/src/ui/panel/Panel.tsx index f90e838a..56afdd7b 100644 --- a/frontend/packages/swarm-ui/src/ui/panel/Panel.tsx +++ b/frontend/packages/swarm-ui/src/ui/panel/Panel.tsx @@ -34,14 +34,19 @@ 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/table/Table.tsx b/frontend/packages/swarm-ui/src/ui/table/Table.tsx index 7683a05b..33a3d39e 100644 --- a/frontend/packages/swarm-ui/src/ui/table/Table.tsx +++ b/frontend/packages/swarm-ui/src/ui/table/Table.tsx @@ -133,6 +133,101 @@ function compareValues(a: string | number, b: string | number): number { }); } +// The filter *state* half of `
`, 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(filters[c.key]); + const hasFilterValue = isFilterActive(getFilter(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, @@ -163,13 +258,18 @@ 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 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, {}); + // 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); // 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. @@ -228,34 +328,6 @@ 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 @@ -282,40 +354,23 @@ 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(() => { - 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; + 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; + }); // 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, sort, JSON.stringify(activeFilters)]); + }, [filteredRows, sort]); function toggleSort(key: string) { setSort((prev) => { @@ -333,22 +388,6 @@ 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, @@ -443,7 +482,7 @@ export function Table({