diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index de0372aa..17a21fbc 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -55,14 +55,16 @@ works too. ### 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** button that opens the agent's terminal tab, the **wanted** menu (up, offline, paused, destroy), and a **login needed** badge with a **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, 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. An opened card has four sections, each folded except **overview**, and each diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.css b/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.css index 2c13fb49..6097ce72 100644 --- a/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.css +++ b/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.css @@ -1,6 +1,6 @@ /* The folded hives card over the agents. */ .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. */ .ui-hives-refresh { diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx b/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx index df33be1f..989911c7 100644 --- a/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx @@ -18,8 +18,10 @@ // natural home for the action that populates it. The form itself // (`CreateAgentForm`) mounts inside a `Dialog` here, not its own route. // -// 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 +// The page is a column of separate cards: the hives card, a filter card (with +// 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 // `AgentTypes.ts`, the wanted-state control in `WantedMenu.tsx`, the actions // 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 { 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 { RefreshIntervalPicker, @@ -128,43 +129,39 @@ export function AgentsPage() {
- - - - - } - > - {error ? ( - - ) : null} - {!error && rows === null ?

loading…

: null} - {rows ? ( - 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) => } - /> - ) : null} -
+ {error ? ( + + ) : null} + {!error && rows === null ?

loading…

: null} + {rows ? ( + + + + + } + 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) => } + /> + ) : null} setCreateOpen(false)} diff --git a/frontend/packages/swarm-ui/src/ui/filterable-view/FilterableView.css b/frontend/packages/swarm-ui/src/ui/filterable-view/FilterableView.css index eb2cdf42..13f31fa7 100644 --- a/frontend/packages/swarm-ui/src/ui/filterable-view/FilterableView.css +++ b/frontend/packages/swarm-ui/src/ui/filterable-view/FilterableView.css @@ -24,3 +24,19 @@ flex-direction: column; 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; +} diff --git a/frontend/packages/swarm-ui/src/ui/filterable-view/FilterableView.tsx b/frontend/packages/swarm-ui/src/ui/filterable-view/FilterableView.tsx index ec9754c3..3111f30d 100644 --- a/frontend/packages/swarm-ui/src/ui/filterable-view/FilterableView.tsx +++ b/frontend/packages/swarm-ui/src/ui/filterable-view/FilterableView.tsx @@ -50,7 +50,11 @@ function FilterBar({ resetFilters, activeFilters, multiselectOptionsFor, + actions, + standalone, }: { + actions?: ComponentChildren; + standalone?: boolean; columns: FilterColumn[]; getFilter: (key: string) => MultiselectFilterState & { value: string }; updateFilter: ( @@ -65,7 +69,13 @@ function FilterBar({ const filterable = columns.filter(isFilterable); if (filterable.length === 0) return null; return ( -
+
{filterable.map((c) => { const label = columnLabel(c); if (c.filterMode === "multiselect") { @@ -105,6 +115,7 @@ function FilterBar({ reset filters ) : null} + {actions ?
{actions}
: null}
); } @@ -130,9 +141,15 @@ export function FilterableView( // (same as `Table`'s own "no rows match the current filter") — the // two cases mean different things, same as `Table`'s own split. 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 { standalone, barActions } = props; const { visibleRows, activeFilters, @@ -165,6 +182,8 @@ export function FilterableView( resetFilters={resetFilters} activeFilters={activeFilters} multiselectOptionsFor={multiselectOptionsFor} + actions={barActions} + standalone={standalone} /> {rows.length === 0 && emptyMessage ? (

{emptyMessage}