Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: the agents page is one column of cards that open in place

The roster loses its second column and its table view. Selecting a card opens
its details under it as four folded sections: overview, settings, accounts and
the terminal. The status, message and wanted rows, which the card header
already shows, are gone from the details. The terminal page's details dialog
keeps the flat list.
This commit is contained in:
iris 2026-11-01 01:21:43 +01:00
commit 2eaa02e892
4 changed files with 555 additions and 541 deletions

View file

@ -474,7 +474,7 @@ export function ComponentsPage() {
<div>click, or Enter/Space when focused</div>
</Card>
</Sample>
<Sample label="selected (e.g. the row shown in a detail panel)">
<Sample label="selected (e.g. an opened row)">
<Card selected onClick={() => {}}>
<strong>gamma</strong>
<div>same card, with selected set</div>

View file

@ -1,8 +1,8 @@
// <AgentTerminalPage> — the swarm terminal tab's full page, mounted at
// `/agents/:name/terminal`. The MVP `AgentTermPreview` (below `AgentsPage`'s
// detail panel) is height-capped; this is that same
// `/agents/:name/terminal`. The MVP `AgentTermPreview` (in an opened
// agent card on `AgentsPage`) is height-capped; this is that same
// component's `fullHeight` mount — no 12em cap, floating header badges
// shown (there's no detail-panel `<dl>` here to duplicate them).
// shown (there's no card header here to duplicate them).
//
// Registers itself in the dynamic-tabs row on mount (not just when opened
// via the preview's expand trigger) — a bookmark, a shared link, or a

View file

@ -1,43 +1,4 @@
/* Detail panel's "select an agent" empty state. `FilterableView`
(`ui/filterable-view/`) has its own equivalent for its two empty
cases — colocated there instead of shared with this one, since the
two components have no other reason to depend on each other's CSS. */
.ui-agents-empty {
color: var(--muted);
text-align: center;
padding: 1.25em 0.75em;
}
/* Two-way view switcher in the Panel's title-row actions — same visual
weight as `RefreshIntervalPicker` next to it (quiet until touched),
but a segmented pair rather than a select since there's no listbox
worth opening for a two-option choice. */
.ui-agents-view-toggle {
display: inline-flex;
border: 1px solid var(--border);
border-radius: 0.4em;
overflow: hidden;
}
.ui-agents-view-toggle button {
border: none;
background: none;
color: var(--muted);
padding: 0.45em 0.75em;
font: inherit;
font-size: 0.9em;
cursor: pointer;
}
.ui-agents-view-toggle button + button {
border-left: 1px solid var(--border);
}
.ui-agents-view-toggle button.active {
background: var(--bg-elev);
color: var(--fg);
}
.ui-agents-view-toggle button:not(.active):hover {
color: var(--fg);
}
/* The folded hives card over the agents and details panels. */
/* The folded hives card over the agents. */
.ui-agents-hives {
margin-block-end: 1em;
}

View file

@ -18,37 +18,31 @@
// natural home for the action that populates it. The form itself
// (`CreateAgentForm`) mounts inside a `Dialog` here, not its own route.
//
// This file owns the page's own state/`columns` only — `AgentRow` and
// friends live in `AgentTypes.ts`, the wanted-state control in
// `WantedMenu.tsx`, the actions on an agent (declare a state, link an
// account) in `AgentActions.tsx`, the card content in `AgentCard.tsx` and
// the detail panel's body in `AgentDetail.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.
// The roster is one column of cards, each opening in place into its details
// (mara: "one column of expandable cards"). This file owns the page's own
// state and the filter fields only — `AgentRow` and friends live in
// `AgentTypes.ts`, the wanted-state control in `WantedMenu.tsx`, the actions
// on an agent (declare a state, link an account) in `AgentActions.tsx`, the
// card in `AgentCard.tsx` and its opened body in `AgentDetail.tsx`
// (mara: "agentspage is now giant and deserves a split"). The filter bar is
// `FilterableView` (`ui/filterable-view/`, see its own comment).
import { useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { Badge } from "@hive/shared/badge.js";
import { LinkIcon } from "@hive/shared/icons.js";
import { useAgentActions } from "../../agent/roster/AgentActions.js";
import { AgentCard } from "../../agent/roster/AgentCard.js";
import { AgentDetail } from "../../agent/detail/AgentDetail.js";
import { FRESHNESS, type AgentRow } from "../../agent/AgentTypes.js";
import { Button } from "../../ui/button/Button.js";
import { Dialog } from "../../ui/dialog/Dialog.js";
import { FilterableView } from "../../ui/filterable-view/FilterableView.js";
import { Panel } from "../../ui/panel/Panel.js";
import { HivesCard } from "./HivesCard.js";
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
import {
RefreshIntervalPicker,
type RefreshIntervalMs,
} from "../../ui/refresh-interval/RefreshInterval.js";
import { fetchJson, usePolled } from "../../ui/polled/usePolled.js";
import { SplitView } from "../../ui/split-view/SplitView.js";
import { type TableColumn } from "../../ui/table/Table.js";
import { type FilterColumn } from "../../ui/table/Table.js";
import { CreateAgentForm } from "../CreateAgentForm.js";
import { WantedMenu } from "../../agent/roster/WantedMenu.js";
import "./AgentsPage.css";
// Same 30s default + same reasoning as HivesCard: no inputs on this page
@ -56,10 +50,48 @@ import "./AgentsPage.css";
// 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";
// What the filter bar filters on, one control per field. The card shows the
// values itself, so a field only says how to read one off a row.
const FIELDS: FilterColumn<AgentRow>[] = [
{
key: "name",
header: "name",
sortBy: (a) => a.name,
filterValue: (a) => a.name,
},
{
key: "hive",
header: "hive",
sortBy: (a) => a.hive ?? "",
filterValues: (a) => [a.hive ?? "—"],
filterMode: "multiselect",
},
{
key: "status",
header: "status",
sortBy: (a) => FRESHNESS[a.freshness].label,
filterValues: (a) => [FRESHNESS[a.freshness].label],
filterMode: "multiselect",
},
{
key: "message",
header: "message",
filterValue: (a) => a.snapshot?.status_text ?? "",
},
{
key: "wanted",
header: "wanted",
sortBy: (a) => a.wanted ?? "",
filterValues: (a) => [a.wanted ?? "no declaration"],
filterMode: "multiselect",
},
{
key: "config-pr",
header: "config PR",
sortBy: (a) => a.config_pr?.pr_number ?? 0,
filterValue: (a) => (a.config_pr ? `#${a.config_pr.pr_number}` : ""),
},
];
export function AgentsPage() {
const [intervalMs, setIntervalMs] =
@ -71,25 +103,6 @@ export function AgentsPage() {
} = usePolled(intervalMs, (signal) =>
fetchJson<AgentRow[]>("/api/agents/status", signal),
);
// 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<ViewMode>(() => {
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);
// The PUT response is the hive's whole declaration set (every agent's
// `wanted` state) — same shape `GET /api/hives/{hive}/wanted` returns — so
@ -109,248 +122,49 @@ export function AgentsPage() {
);
});
});
// 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<string | null>(null);
const detailTarget = rows?.find((r) => r.name === detailTargetName) ?? null;
// `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<AgentRow>[] = [
{
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 ?? "",
filterValues: (a) => [a.hive ?? "—"],
filterMode: "multiselect",
},
{
key: "status",
header: "status",
sortBy: (a) => FRESHNESS[a.freshness].label,
filterValues: (a) => [FRESHNESS[a.freshness].label],
filterMode: "multiselect",
render: (a) => {
const { tone, label } = FRESHNESS[a.freshness];
return (
<Badge
tone={tone}
title={
a.freshness === "unknown"
? "reported by its hive but not registered in swarm-level identity — needs migration"
: undefined
}
value={
<>
{label}
{a.last_seen_unix !== null ? (
<>
{" "}
(<RelativeTime epochMs={a.last_seen_unix * 1000} />)
</>
) : null}
</>
}
/>
);
},
},
{
key: "message",
header: "message",
cellClass: "ui-table-prose",
render: (a) => a.snapshot?.status_text ?? "—",
filterValue: (a) => a.snapshot?.status_text ?? "",
},
{
key: "wanted",
header: "wanted",
sortBy: (a) => a.wanted ?? "",
filterValues: (a) => [a.wanted ?? "no declaration"],
filterMode: "multiselect",
render: (a) => {
const err = actions.actionErrors.get(a.name);
return (
<>
<WantedMenu
row={a}
pending={actions.pendingAgents.has(a.name)}
portal
onSelectUp={actions.selectUp}
onSelectOffline={actions.selectOffline}
onSelectPaused={actions.selectPaused}
onDestroy={actions.requestDestroy}
/>
{err ? (
<Badge
tone="negative"
value="failed"
title={err.detail ?? "the declaration failed"}
/>
) : null}
</>
);
},
},
{
key: "matrix",
header: "matrix",
render: (a) => (
<Badge
variant="quiet"
icon={<LinkIcon />}
value="link account"
onClick={a.hive ? () => actions.openLink(a, "matrix") : undefined}
disabled={!a.hive}
title={
a.hive
? `link a matrix account to ${a.name}`
: "no hive on record for this agent — nothing to link against"
}
/>
),
},
{
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 ? (
<Badge
tone="warning"
value={
a.config_pr.html_url ? (
<a href={a.config_pr.html_url} target="_blank" rel="noreferrer">
#{a.config_pr.pr_number}
</a>
) : (
`#${a.config_pr.pr_number}`
)
}
/>
) : (
"—"
),
},
];
return (
<>
<div class="ui-agents-hives">
<HivesCard />
</div>
<SplitView
primary={
<Panel
title="agents"
icon="👥"
actions={
<>
<Button variant="primary" onClick={() => setCreateOpen(true)}>
+ agent
</Button>
<div
class="ui-agents-view-toggle"
role="group"
aria-label="view"
>
<button
type="button"
aria-pressed={viewMode === "cards"}
class={viewMode === "cards" ? "active" : undefined}
onClick={() => setViewMode("cards")}
>
cards
</button>
<button
type="button"
aria-pressed={viewMode === "table"}
class={viewMode === "table" ? "active" : undefined}
onClick={() => setViewMode("table")}
>
table
</button>
</div>
<RefreshIntervalPicker
id="agents-refresh"
value={intervalMs}
onChange={setIntervalMs}
/>
</>
}
>
{error ? (
<ApiErrorPanel
context="failed to load the agent roster"
problem={error}
/>
) : null}
{!error && rows === null ? <p>loading…</p> : null}
{rows ? (
<FilterableView
columns={columns}
rows={rows}
rowKey={(a) => 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) => (
<AgentCard
row={a}
pending={actions.pendingAgents.has(a.name)}
error={actions.actionErrors.get(a.name)}
selected={detailTargetName === a.name}
onSelectUp={actions.selectUp}
onSelectOffline={actions.selectOffline}
onSelectPaused={actions.selectPaused}
onOpenDetail={(row) => setDetailTargetName(row.name)}
/>
)}
/>
) : null}
</Panel>
<Panel
title="agents"
icon="👥"
actions={
<>
<Button variant="primary" onClick={() => setCreateOpen(true)}>
+ agent
</Button>
<RefreshIntervalPicker
id="agents-refresh"
value={intervalMs}
onChange={setIntervalMs}
/>
</>
}
// 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={
<Panel
title={detailTarget ? detailTarget.name : "agent details"}
icon="🔎"
>
{detailTarget ? (
<AgentDetail row={detailTarget} actions={actions} withTerminal />
) : (
<p class="ui-agents-empty">select an agent to see its details</p>
)}
</Panel>
}
/>
>
{error ? (
<ApiErrorPanel
context="failed to load the agent roster"
problem={error}
/>
) : null}
{!error && rows === null ? <p>loading…</p> : null}
{rows ? (
<FilterableView
view="cards"
columns={FIELDS}
rows={rows}
rowKey={(a) => a.name}
storageKey="swarm-ui:agents:table-filters"
emptyMessage="no agents yet — the swarm-wide identity store has no agents registered on any hive"
// Each card gets its live row from `rows` on every render, so an
// opened card follows a refresh or a state change made inside it.
renderCard={(a) => <AgentCard row={a} actions={actions} />}
/>
) : null}
</Panel>
<Dialog
open={createOpen}
onClose={() => setCreateOpen(false)}