Watch
0
0
Fork
You've already forked hyperhive
0

swarm ui: the agents page is separate cards, not one big agents card

The filter bar is a card of its own, holding the + agent button and the
refresh picker; the hives card and each agent card sit beside it.
This commit is contained in:
iris 2026-11-01 12:21:51 +01:00 • committed by mara
commit 6e5dc3dd30
5 changed files with 78 additions and 44 deletions

View file

@ -55,14 +55,16 @@ works too.
### The agents page ### The agents page
`/agents` is the hives card, then one column of cards, one per agent. A card's `/agents` is a column of separate cards: the hives card, a filter card, then one
card per agent (they are not wrapped in one big card). A card's
header shows its icon, name, freshness, the status message, a **terminal** header shows its icon, name, freshness, the status message, a **terminal**
button that opens the agent's terminal tab, the **wanted** menu (up, offline, button that opens the agent's terminal tab, the **wanted** menu (up, offline,
paused, destroy), and a **login needed** badge with a paused, destroy), and a **login needed** badge with a
**log in** button when its claude has no session. Select the header to open the **log in** button when its claude has no session. Select the header to open the
card in place; select it again to close it. Several cards can be open at once, card in place; select it again to close it. Several cards can be open at once,
and a card stays open across the page's refresh, but not across a reload. The and a card stays open across the page's refresh, but not across a reload. The
filter bar above the cards filters by name, hive, status, message, wanted state filter card, which also holds the **+ agent** button and the refresh picker,
filters by name, hive, status, message, wanted state
and config PR. and config PR.
An opened card has four sections, each folded except **overview**, and each An opened card has four sections, each folded except **overview**, and each

View file

@ -1,6 +1,6 @@
/* The folded hives card over the agents. */ /* The folded hives card over the agents. */
.ui-agents-hives { .ui-agents-hives {
margin-block-end: 1em; margin-block-end: 0.5em;
} }
/* The roster's refresh picker, at the top right of the open card. */ /* The roster's refresh picker, at the top right of the open card. */
.ui-hives-refresh { .ui-hives-refresh {

View file

@ -18,8 +18,10 @@
// natural home for the action that populates it. The form itself // natural home for the action that populates it. The form itself
// (`CreateAgentForm`) mounts inside a `Dialog` here, not its own route. // (`CreateAgentForm`) mounts inside a `Dialog` here, not its own route.
// //
// The roster is one column of cards, each opening in place into its details // The page is a column of separate cards: the hives card, a filter card (with
// (mara: "one column of expandable cards"). This file owns the page's own // the "+ agent" button and the refresh picker), then one card per agent, each
// opening in place into its details (mara: "one column of expandable cards";
// the agents are not wrapped in one big card). This file owns the page's own
// state and the filter fields only — `AgentRow` and friends live in // state and the filter fields only — `AgentRow` and friends live in
// `AgentTypes.ts`, the wanted-state control in `WantedMenu.tsx`, the actions // `AgentTypes.ts`, the wanted-state control in `WantedMenu.tsx`, the actions
// on an agent (declare a state, link an account) in `AgentActions.tsx`, the // on an agent (declare a state, link an account) in `AgentActions.tsx`, the
@ -34,7 +36,6 @@ import { FRESHNESS, type AgentRow } from "../../agent/AgentTypes.js";
import { Button } from "../../ui/button/Button.js"; import { Button } from "../../ui/button/Button.js";
import { Dialog } from "../../ui/dialog/Dialog.js"; import { Dialog } from "../../ui/dialog/Dialog.js";
import { FilterableView } from "../../ui/filterable-view/FilterableView.js"; import { FilterableView } from "../../ui/filterable-view/FilterableView.js";
import { Panel } from "../../ui/panel/Panel.js";
import { HivesCard } from "./HivesCard.js"; import { HivesCard } from "./HivesCard.js";
import { import {
RefreshIntervalPicker, RefreshIntervalPicker,
@ -128,43 +129,39 @@ export function AgentsPage() {
<div class="ui-agents-hives"> <div class="ui-agents-hives">
<HivesCard /> <HivesCard />
</div> </div>
<Panel {error ? (
title="agents" <ApiErrorPanel
icon="👥" context="failed to load the agent roster"
actions={ problem={error}
<> />
<Button variant="primary" onClick={() => setCreateOpen(true)}> ) : null}
+ agent {!error && rows === null ? <p>loading…</p> : null}
</Button> {rows ? (
<RefreshIntervalPicker <FilterableView
id="agents-refresh" view="cards"
value={intervalMs} standalone
onChange={setIntervalMs} barActions={
/> <>
</> <Button variant="primary" onClick={() => setCreateOpen(true)}>
} + agent
> </Button>
{error ? ( <RefreshIntervalPicker
<ApiErrorPanel id="agents-refresh"
context="failed to load the agent roster" value={intervalMs}
problem={error} onChange={setIntervalMs}
/> />
) : null} </>
{!error && rows === null ? <p>loading…</p> : null} }
{rows ? ( columns={FIELDS}
<FilterableView rows={rows}
view="cards" rowKey={(a) => a.name}
columns={FIELDS} storageKey="swarm-ui:agents:table-filters"
rows={rows} emptyMessage="no agents yet — the swarm-wide identity store has no agents registered on any hive"
rowKey={(a) => a.name} // Each card gets its live row from `rows` on every render, so an
storageKey="swarm-ui:agents:table-filters" // opened card follows a refresh or a state change made inside it.
emptyMessage="no agents yet — the swarm-wide identity store has no agents registered on any hive" renderCard={(a) => <AgentCard row={a} actions={actions} />}
// 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. ) : null}
renderCard={(a) => <AgentCard row={a} actions={actions} />}
/>
) : null}
</Panel>
<Dialog <Dialog
open={createOpen} open={createOpen}
onClose={() => setCreateOpen(false)} onClose={() => setCreateOpen(false)}

View file

@ -24,3 +24,19 @@
flex-direction: column; flex-direction: column;
gap: 0.5em; gap: 0.5em;
} }
/* A filter bar that is a card of its own (`standalone`), the same border and
fill as a row card. */
.ui-filterable-view-filter-card {
padding: 0.75em 1em;
border: 1px solid var(--border);
border-radius: 0.5em;
background: var(--bg-elev);
margin-bottom: 0.5em;
}
/* The caller's buttons, at the bar's trailing edge. */
.ui-filterable-view-actions {
display: flex;
align-items: center;
gap: 0.5em;
margin-left: auto;
}

View file

@ -50,7 +50,11 @@ function FilterBar<T>({
resetFilters, resetFilters,
activeFilters, activeFilters,
multiselectOptionsFor, multiselectOptionsFor,
actions,
standalone,
}: { }: {
actions?: ComponentChildren;
standalone?: boolean;
columns: FilterColumn<T>[]; columns: FilterColumn<T>[];
getFilter: (key: string) => MultiselectFilterState & { value: string }; getFilter: (key: string) => MultiselectFilterState & { value: string };
updateFilter: ( updateFilter: (
@ -65,7 +69,13 @@ function FilterBar<T>({
const filterable = columns.filter(isFilterable); const filterable = columns.filter(isFilterable);
if (filterable.length === 0) return null; if (filterable.length === 0) return null;
return ( return (
<div class="ui-filterable-view-filter-bar"> <div
class={
standalone
? "ui-filterable-view-filter-bar ui-filterable-view-filter-card"
: "ui-filterable-view-filter-bar"
}
>
{filterable.map((c) => { {filterable.map((c) => {
const label = columnLabel(c); const label = columnLabel(c);
if (c.filterMode === "multiselect") { if (c.filterMode === "multiselect") {
@ -105,6 +115,7 @@ function FilterBar<T>({
reset filters reset filters
</button> </button>
) : null} ) : null}
{actions ? <div class="ui-filterable-view-actions">{actions}</div> : null}
</div> </div>
); );
} }
@ -130,9 +141,15 @@ export function FilterableView<T>(
// (same as `Table`'s own "no rows match the current filter") — the // (same as `Table`'s own "no rows match the current filter") — the
// two cases mean different things, same as `Table`'s own split. // two cases mean different things, same as `Table`'s own split.
emptyMessage?: ComponentChildren; emptyMessage?: ComponentChildren;
// Cards view only. The filter bar becomes a card of its own, so the caller
// can lay it out beside the row cards instead of inside a panel, and
// `barActions` (buttons, a refresh picker) sit at its trailing edge.
standalone?: boolean;
barActions?: ComponentChildren;
}, },
) { ) {
const { columns, rows, rowKey, storageKey, renderCard, emptyMessage } = props; const { columns, rows, rowKey, storageKey, renderCard, emptyMessage } = props;
const { standalone, barActions } = props;
const { const {
visibleRows, visibleRows,
activeFilters, activeFilters,
@ -165,6 +182,8 @@ export function FilterableView<T>(
resetFilters={resetFilters} resetFilters={resetFilters}
activeFilters={activeFilters} activeFilters={activeFilters}
multiselectOptionsFor={multiselectOptionsFor} multiselectOptionsFor={multiselectOptionsFor}
actions={barActions}
standalone={standalone}
/> />
{rows.length === 0 && emptyMessage ? ( {rows.length === 0 && emptyMessage ? (
<p class="ui-filterable-view-empty">{emptyMessage}</p> <p class="ui-filterable-view-empty">{emptyMessage}</p>