diff --git a/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx b/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx index 454d569f..14010a1a 100644 --- a/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx @@ -207,8 +207,20 @@ export function AgentsPage() { } const columns: TableColumn[] = [ - { key: "name", header: "name", render: (a) => a.name }, - { key: "hive", header: "hive", render: (a) => a.hive ?? "—" }, + { + 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 ?? "", + filterValue: (a) => a.hive ?? "", + }, { key: "status", header: "status", @@ -217,6 +229,8 @@ export function AgentsPage() { // 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, + filterValue: (a) => FRESHNESS[a.freshness].label, render: (a) => { const { tone, label } = FRESHNESS[a.freshness]; return ( @@ -252,6 +266,7 @@ export function AgentsPage() { // `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", @@ -261,6 +276,8 @@ export function AgentsPage() { // control" shape (see its header comment, which names pause/resume // as the exact motivating case), not a separate status chip next // to a separate button. + sortBy: (a) => a.wanted ?? "", + filterValue: (a) => a.wanted ?? "no declaration", render: (a) => { const pending = pendingAgents.has(a.name); const impliedCurrent = @@ -295,6 +312,8 @@ export function AgentsPage() { { 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 ? ( = { }; const COLUMNS: TableColumn[] = [ - { key: "name", header: "name", render: (h) => h.name }, + { + key: "name", + header: "name", + render: (h) => h.name, + sortBy: (h) => h.name, + filterValue: (h) => h.name, + }, { key: "domain", header: "domain", @@ -61,6 +67,8 @@ const COLUMNS: TableColumn[] = [ ) : ( "—" ), + sortBy: (h) => h.domain ?? "", + filterValue: (h) => h.domain ?? "", }, { key: "status", @@ -82,6 +90,8 @@ const COLUMNS: TableColumn[] = [ /> ); }, + sortBy: (h) => FRESHNESS[h.freshness].label, + filterValue: (h) => FRESHNESS[h.freshness].label, }, ]; diff --git a/frontend/packages/swarm-ui/src/ui/table/Table.css b/frontend/packages/swarm-ui/src/ui/table/Table.css index 258cc238..2ec46dae 100644 --- a/frontend/packages/swarm-ui/src/ui/table/Table.css +++ b/frontend/packages/swarm-ui/src/ui/table/Table.css @@ -35,3 +35,49 @@ white-space: normal; word-break: break-word; } + +/* Sortable header — the whole header cell is the click target, not a + separate icon-only button next to it, same "control lives on the + thing it affects" reasoning as `Badge`'s own click-to-toggle shape. */ +.ui-table-sort-button { + display: inline-flex; + align-items: center; + gap: 0.35em; + background: none; + border: none; + padding: 0; + margin: 0; + font: inherit; + font-weight: 600; + color: inherit; + cursor: pointer; +} +.ui-table-sort-button:hover { + color: var(--fg); +} +.ui-table-sort-glyph { + opacity: 0.6; + font-size: 0.85em; +} +.ui-table-sort-button:hover .ui-table-sort-glyph { + opacity: 1; +} + +/* The per-column filter inputs' row — same cell padding as a header row, + no bottom border of its own (the header row above already has one). */ +.ui-table-filter-row th { + padding-top: 0; + padding-bottom: 0.5em; + font-weight: 400; +} +.ui-table-filter-input { + width: 100%; + box-sizing: border-box; + background: var(--bg); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 4px; + padding: 0.25em 0.5em; + font: inherit; + font-size: 0.9em; +} diff --git a/frontend/packages/swarm-ui/src/ui/table/Table.tsx b/frontend/packages/swarm-ui/src/ui/table/Table.tsx index 3655c611..bf982941 100644 --- a/frontend/packages/swarm-ui/src/ui/table/Table.tsx +++ b/frontend/packages/swarm-ui/src/ui/table/Table.tsx @@ -9,6 +9,7 @@ // a `` doesn't shrink below its content's natural width on its // own, so without this the page itself would break, not just look // cramped. +import { useMemo, useState } from "preact/hooks"; import type { ComponentChildren } from "preact"; import "./Table.css"; @@ -29,12 +30,44 @@ export interface TableColumn { * `'descending'` while this is the active sort column, `'none'` while * sortable but not active, omitted entirely for a non-sortable column * (no `aria-sort` attribute at all, the correct value for a column - * that can never be the active sort). A screen reader announces which - * column/direction is active from this attribute; the ▲/▼ glyph a - * sortable header renders is `aria-hidden` and carries no information - * on its own. + * that can never be the active sort). Only consulted for a column with + * no `sortBy` — one with `sortBy` gets its `aria-sort` computed from + * `Table`'s own sort state instead. Kept for the issue-report page's + * pre-existing hand-rolled sortable headers (real `
- {columns.map((c) => ( - - ))} + {columns.map((c) => + c.sortBy ? ( + + ) : ( + + ), + )} + {filterableColumns.length > 0 ? ( + + {columns.map((c) => ( + + ))} + + ) : null} {rows.length === 0 && emptyMessage ? ( @@ -72,8 +200,17 @@ export function Table({ {emptyMessage} + ) : rows.length > 0 && visibleRows.length === 0 ? ( + // Distinct from `emptyMessage` — real rows exist, the active + // filter(s) just matched none of them, not "there's nothing + // here at all". + + + ) : ( - rows.map((row) => ( + visibleRows.map((row) => ( {columns.map((c) => (
- {c.header} - + + + {c.header} +
+ {c.filterValue ? ( + { + const v = (e.target as HTMLInputElement).value; + setFilters((prev) => ({ ...prev, [c.key]: v })); + }} + /> + ) : null} +
+ no rows match the current filter +