From e815c7cb5cc8e9d0421065fc09221cb93a855911 Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 31 Aug 2026 18:26:06 +0200 Subject: [PATCH 1/3] swarm-ui: add the custom issue-report page New /issues route: a sortable, filterable table over open issues across every repo that has one -- repo picker (default: no filter, every repo combined), hide-blocked toggle, and a label multi-select, consuming swarm-controller's GET /api/repos + GET /api/issue-report / GET /api/repos/{org}/{repo}/issue-report (see hyperhive#3831 for the row shape). blocked and depended_on_by_count arrive pre-resolved per row -- this page does no dependency-graph walking of its own, just sort/filter over what it's given. Default sort is depended_on_by_count descending, matching mara's framing of the report's headline ordering. Widened Table's TableColumn.header from string to ComponentChildren so a column can carry a real clickable sort-toggle button instead of forking a second table primitive for this one page. --- frontend/packages/swarm-ui/src/App.tsx | 2 + .../swarm-ui/src/pages/IssueReportPage.css | 43 +++ .../swarm-ui/src/pages/IssueReportPage.tsx | 287 ++++++++++++++++++ .../packages/swarm-ui/src/shell/Shell.tsx | 1 + .../packages/swarm-ui/src/ui/table/Table.tsx | 6 +- 5 files changed, 338 insertions(+), 1 deletion(-) create mode 100644 frontend/packages/swarm-ui/src/pages/IssueReportPage.css create mode 100644 frontend/packages/swarm-ui/src/pages/IssueReportPage.tsx diff --git a/frontend/packages/swarm-ui/src/App.tsx b/frontend/packages/swarm-ui/src/App.tsx index 65d47afe..854f5169 100644 --- a/frontend/packages/swarm-ui/src/App.tsx +++ b/frontend/packages/swarm-ui/src/App.tsx @@ -6,6 +6,7 @@ import { AgentsPage } from './pages/AgentsPage.js'; import { ComponentsPage } from './pages/ComponentsPage.js'; import { JobsPage } from './pages/JobsPage.js'; import { HivesPage } from './pages/HivesPage.js'; +import { IssueReportPage } from './pages/IssueReportPage.js'; import { Panel } from './ui/panel/Panel.js'; function NotFound() { @@ -23,6 +24,7 @@ export function App() { + diff --git a/frontend/packages/swarm-ui/src/pages/IssueReportPage.css b/frontend/packages/swarm-ui/src/pages/IssueReportPage.css new file mode 100644 index 00000000..1419727d --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/IssueReportPage.css @@ -0,0 +1,43 @@ +/* — the repo-picker + hide-blocked toggle sit in one + row (mirrors CreateAgentForm's row/wrap pattern: flex-wrap so narrow + viewports stack instead of overflowing), the label chips get their + own row below since the set is open-ended and shouldn't fight the + controls row for width. Sort buttons live inside the table's own + `` cells (Table.tsx's `header` now accepts real markup for + exactly this) so they inherit the table's header styling for free — + only the pointer cursor + no-underline reset is this page's to own. */ +.issue-report-controls { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 1.25em; + margin-bottom: 1em; +} +.issue-report-toggle { + display: flex; + align-items: center; + gap: 0.4em; + cursor: pointer; +} +.issue-report-labels { + display: flex; + flex-wrap: wrap; + gap: 0.6em; + margin-bottom: 1em; +} +.issue-report-label-chip { + display: flex; + align-items: center; + gap: 0.35em; + cursor: pointer; + color: var(--muted); +} +.issue-report-sort-btn { + background: none; + border: none; + padding: 0; + margin: 0; + font: inherit; + color: inherit; + cursor: pointer; +} diff --git a/frontend/packages/swarm-ui/src/pages/IssueReportPage.tsx b/frontend/packages/swarm-ui/src/pages/IssueReportPage.tsx new file mode 100644 index 00000000..62171995 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/IssueReportPage.tsx @@ -0,0 +1,287 @@ +// — mara's custom issue report: browse open issues +// across every repo that has one, sortable/filterable her own way, with +// "hide blocked (open dependency)" as the headline filter and a rank +// column for how many other open issues depend on each one. Backed by +// swarm-controller's `GET /api/repos` (dropdown source, only repos with +// an open issue), `GET /api/issue-report` (default — every repo +// combined) and `GET /api/repos/{org}/{repo}/issue-report` (once a +// specific repo is picked) — `blocked` and `depended_on_by_count` both +// arrive pre-resolved per row, so nothing here does its own +// dependency-graph walk. +// +// Sorting and the label/hide-blocked filters are client-side over +// whatever's currently loaded — only the repo selection re-fetches. +// Default sort is `depended_on_by_count` descending: mara's own framing +// ("i would also like to rank them by how many issues depend on them") +// reads as the report's headline ordering, not just one more column. +// +// No `RefreshIntervalPicker`/auto-poll like the roster pages — this +// reads as a "generate a report" action rather than a live status view +// (mara: a "generating report" spinner sub-5s is fine, doesn't need to +// be instant), so it fetches once per repo-selection change instead of +// polling. +import { useEffect, useMemo, useState } from 'preact/hooks'; +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 { Panel } from '../ui/panel/Panel.js'; +import { SelectField } from '../ui/select-field/SelectField.js'; +import { Table, type TableColumn } from '../ui/table/Table.js'; +import './IssueReportPage.css'; + +interface IssueReportRow { + repo: string; + number: number; + title: string; + labels: string[]; + assignee: string | null; + html_url: string | null; + blocked: boolean; + depended_on_by_count: number; +} + +type SortKey = 'repo' | 'number' | 'title' | 'assignee' | 'blocked' | 'depended_on_by_count'; +type SortDir = 'asc' | 'desc'; + +// Sentinel ` setHideBlocked((e.target as HTMLInputElement).checked)} + /> + hide blocked (open dependency) + + + {allLabels.length ? ( +
+ {allLabels.map((l) => ( + + ))} +
+ ) : null} + {error ? : null} + {!error && loading ?

generating report…

: null} + {!loading && rows ? ( + `${r.repo}#${r.number}`} + emptyMessage="no issues match the current filters" + /> + ) : null} + + ); +} diff --git a/frontend/packages/swarm-ui/src/shell/Shell.tsx b/frontend/packages/swarm-ui/src/shell/Shell.tsx index 8eb9d6cd..aeef0055 100644 --- a/frontend/packages/swarm-ui/src/shell/Shell.tsx +++ b/frontend/packages/swarm-ui/src/shell/Shell.tsx @@ -48,6 +48,7 @@ const NAV_ITEMS: { href: string; label: string; accent: string }[] = [ { href: '/', label: 'hives', accent: 'var(--purple)' }, { href: '/agents', label: 'agents', accent: 'var(--green)' }, { href: '/jobs', label: 'jobs', accent: 'var(--pink)' }, + { href: '/issues', label: 'issues', accent: 'var(--yellow)' }, { href: '/components', label: 'components', accent: 'var(--blue)' }, ]; diff --git a/frontend/packages/swarm-ui/src/ui/table/Table.tsx b/frontend/packages/swarm-ui/src/ui/table/Table.tsx index 23c1739a..5814edbd 100644 --- a/frontend/packages/swarm-ui/src/ui/table/Table.tsx +++ b/frontend/packages/swarm-ui/src/ui/table/Table.tsx @@ -14,7 +14,11 @@ import './Table.css'; export interface TableColumn { key: string; - header: string; + // `ComponentChildren`, not `string` — a plain string still satisfies + // this and renders identically, but a caller that wants a clickable + // sortable header (the issue-report page's own column-header buttons) + // can pass real markup instead of forking a second table primitive. + header: ComponentChildren; render: (row: T) => ComponentChildren; } From f4b61db7539d59ec0515db634f01c7d067e97df3 Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 31 Aug 2026 18:28:53 +0200 Subject: [PATCH 2/3] IssueReportPage: match the corrected row shape, assignees is a list damocles caught it on the swarm-controller PR: forge's assignee field is legacy single-value, assignees is the real multi-assignee list, and this repo actually uses multiple. Widen the frontend's row type + column to match -- rendered comma-joined the same way labels already are. --- .../swarm-ui/src/pages/IssueReportPage.tsx | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/frontend/packages/swarm-ui/src/pages/IssueReportPage.tsx b/frontend/packages/swarm-ui/src/pages/IssueReportPage.tsx index 62171995..a20b7351 100644 --- a/frontend/packages/swarm-ui/src/pages/IssueReportPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/IssueReportPage.tsx @@ -34,13 +34,17 @@ interface IssueReportRow { number: number; title: string; labels: string[]; - assignee: string | null; + // `string[]`, not a single `assignee` — forge's own `assignee` field is + // the legacy single-value one; `assignees` is the real multi-assignee + // list, and this repo actually uses multiple (caught against an + // earlier draft of this row shape that had it singular). + assignees: string[]; html_url: string | null; blocked: boolean; depended_on_by_count: number; } -type SortKey = 'repo' | 'number' | 'title' | 'assignee' | 'blocked' | 'depended_on_by_count'; +type SortKey = 'repo' | 'number' | 'title' | 'assignees' | 'blocked' | 'depended_on_by_count'; type SortDir = 'asc' | 'desc'; // Sentinel ` {columns.map((c) => ( - + ))}
's aria-sort attribute; the issue-report page's sortable columns now report ascending/descending/none so a screen reader can announce which column and direction is active, not just the sighted ▲/▼ glyph. --- .../swarm-ui/src/pages/IssueReportPage.tsx | 33 ++++++++++++++++--- .../packages/swarm-ui/src/ui/table/Table.tsx | 15 ++++++++- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/frontend/packages/swarm-ui/src/pages/IssueReportPage.tsx b/frontend/packages/swarm-ui/src/pages/IssueReportPage.tsx index a20b7351..dc7a352c 100644 --- a/frontend/packages/swarm-ui/src/pages/IssueReportPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/IssueReportPage.tsx @@ -109,16 +109,26 @@ export function IssueReportPage() { // Repo dropdown source, fetched once — this page has no refresh // cadence, and a repo gaining/losing its first/last open issue between - // visits is rare enough that a manual reload covers it. + // visits is rare enough that a manual reload covers it. Same + // `cancelled` guard as the repo-filter effect below, so an unmount + // mid-flight (navigating away before this resolves) doesn't call + // `setRepos`/`setError` on a component that's already gone. useEffect(() => { + let cancelled = false; (async () => { const r = await fetch('/api/repos'); if (!r.ok) { - setError(await readApiError(r)); + if (!cancelled) setError(await readApiError(r)); return; } - setRepos((await r.json()) as string[]); - })().catch((e: unknown) => setError({ detail: String(e) })); + const data = (await r.json()) as string[]; + if (!cancelled) setRepos(data); + })().catch((e: unknown) => { + if (!cancelled) setError({ detail: String(e) }); + }); + return () => { + cancelled = true; + }; }, []); // The one fetch this page re-runs on state change — everything else @@ -188,6 +198,15 @@ export function IssueReportPage() { }); } + // `aria-sort` for a sortable column's `` — 'none' while sortable + // but not the active column, the real direction while it is. Screen + // readers announce this; the ▲/▼ glyph in `SortHeader` is `aria-hidden` + // and carries no information without it. + function ariaSortFor(key: SortKey): 'ascending' | 'descending' | 'none' { + if (sortKey !== key) return 'none'; + return sortDir === 'asc' ? 'ascending' : 'descending'; + } + const columns: TableColumn[] = [ // Shown even with one repo selected (every row shares the value) — // a fixed column set means the table's shape doesn't shift under @@ -195,11 +214,13 @@ export function IssueReportPage() { { key: 'repo', header: , + ariaSort: ariaSortFor('repo'), render: (r) => r.repo, }, { key: 'number', header: , + ariaSort: ariaSortFor('number'), render: (r) => r.html_url ? ( @@ -212,6 +233,7 @@ export function IssueReportPage() { { key: 'title', header: , + ariaSort: ariaSortFor('title'), render: (r) => r.title, }, { @@ -224,11 +246,13 @@ export function IssueReportPage() { header: ( ), + ariaSort: ariaSortFor('assignees'), render: (r) => (r.assignees.length ? r.assignees.join(', ') : '—'), }, { key: 'blocked', header: , + ariaSort: ariaSortFor('blocked'), render: (r) => (r.blocked ? : '—'), }, { @@ -242,6 +266,7 @@ export function IssueReportPage() { onSort={onSort} /> ), + ariaSort: ariaSortFor('depended_on_by_count'), render: (r) => r.depended_on_by_count, }, ]; diff --git a/frontend/packages/swarm-ui/src/ui/table/Table.tsx b/frontend/packages/swarm-ui/src/ui/table/Table.tsx index 5814edbd..8c14b423 100644 --- a/frontend/packages/swarm-ui/src/ui/table/Table.tsx +++ b/frontend/packages/swarm-ui/src/ui/table/Table.tsx @@ -20,6 +20,17 @@ export interface TableColumn { // can pass real markup instead of forking a second table primitive. header: ComponentChildren; render: (row: T) => ComponentChildren; + /** + * ARIA sort state for this column's `` — `'ascending'` / + * `'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. + */ + ariaSort?: 'ascending' | 'descending' | 'none'; } export function Table({ @@ -44,7 +55,9 @@ export function Table({
{c.header} + {c.header} +