From 247ff4498db0819ffb6053340d01e071d2959488 Mon Sep 17 00:00:00 2001 From: iris Date: Thu, 3 Sep 2026 00:43:00 +0200 Subject: [PATCH] swarm-ui: sortable + filterable table columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hyperhive#4020, mara. TableColumn gets two new optional extractors, each one the whole signal for its capability (mara: 'why separate selector and flag?' — dropped the sortable/filterable booleans that would've said the same thing twice and could disagree with the extractor's presence): - sortBy: (row) => string | number — column is click-to-sort iff present. Table owns the sort state (one active column, header click cycles none -> ascending -> descending), since a rendered cell often isn't the sortable value itself (e.g. AgentsPage's status column renders a Badge, not a plain string). - filterValue: (row) => string — column is filterable iff present. A text input row under the headers, substring match case-insensitive. Client-side only, no backend change - every consuming page already fetches its full row set. Wired into AgentsPage and HivesPage, the two pages with a real per-row Table. Left IssueReportPage alone (already has its own purpose-built sort + label/hide-blocked filters, predates this and covers its own domain better than a generic per-column text filter would) and JobsPage alone (renders an indented state tree via JobqGraph, no column table at all despite what my original scoping comment assumed). Verified: typecheck + build clean, nix fmt clean, static render of the actual built CSS confirms the new sort-header + filter-row markup doesn't break table layout. --- .../swarm-ui/src/pages/AgentsPage.tsx | 23 ++- .../packages/swarm-ui/src/pages/HivesPage.tsx | 12 +- .../packages/swarm-ui/src/ui/table/Table.css | 46 +++++ .../packages/swarm-ui/src/ui/table/Table.tsx | 157 ++++++++++++++++-- 4 files changed, 225 insertions(+), 13 deletions(-) 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 +