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
24 changed files with 555 additions and 541 deletions

View file

@ -17,3 +17,11 @@
align-items: center;
gap: 0.75em;
}
/* The folded sections of an opened card sit one under another, each with the
shared disclosure look; the fields keep the grid above. */
.ui-agent-detail-sections {
display: flex;
flex-direction: column;
gap: 0.75em;
}

View file

@ -1,8 +1,11 @@
// <AgentDetail> — one agent's details: its status, wanted state, hive, config
// PR, inference settings, resource caps and linked accounts, plus the actions on them. The
// body of the detail panel on `/agents` (where `withTerminal` adds the live
// terminal and subagents below it) and of the detail dialog on a terminal
// page (which is already beside the terminal, so omits it).
// PR, inference settings, resource caps and linked accounts, plus the actions on
// them. Two layouts. In an opened card on `/agents` (`inCard`) the card header
// already shows status, message and wanted state, so the body is four folded
// sections (`sections.ts`): overview, settings, accounts, and the terminal with
// its subagents, each built when first opened. In the detail dialog on a terminal
// page it is one flat list with those three rows, and no terminal, since the
// page is already beside one.
//
// Takes the agent's *live* row and the shared `AgentActions`, so a state
// change or a link made here shows wherever else the same row is drawn.
@ -20,140 +23,262 @@ import { AgentTermPreview } from "../terminal/AgentTermPreview.js";
import { FRESHNESS, type AgentRow } from "../AgentTypes.js";
import { LinkAccountMenu } from "./LinkAccountMenu.js";
import { LinkedAccounts } from "./LinkedAccounts.js";
import { LazyDisclosure } from "../../ui/disclosure/LazyDisclosure.js";
import {
SECTION_TITLE,
sectionsFor,
startsOpen,
type DetailSection,
} from "./sections.js";
import { SubagentTerms } from "../terminal/SubagentTerms.js";
import { WantedMenu } from "../roster/WantedMenu.js";
import "./AgentDetail.css";
export function AgentDetail({
/** The rows the card header already shows: status, message and wanted. */
function HeaderRows({
row,
actions,
withTerminal = false,
}: {
row: AgentRow;
actions: AgentActions;
withTerminal?: boolean;
}) {
const { tone, label } = FRESHNESS[row.freshness];
const declareError = actions.actionErrors.get(row.name);
return (
<div class="ui-agent-detail">
<dl class="ui-agent-detail-fields">
<dt>status</dt>
<dd>
<>
<dt>status</dt>
<dd>
<Badge
tone={tone}
value={
<>
{label}
{row.last_seen_unix !== null ? (
<>
{" "}
(<RelativeTime epochMs={row.last_seen_unix * 1000} />)
</>
) : null}
</>
}
/>
</dd>
<dt>message</dt>
<dd>{row.snapshot?.status_text ?? "—"}</dd>
<dt>wanted</dt>
<dd>
{/* Full menu (destroy included) — mara: "destroy is already
available via wanted state", no separate button needed. */}
<WantedMenu
row={row}
pending={actions.pendingAgents.has(row.name)}
onSelectUp={actions.selectUp}
onSelectOffline={actions.selectOffline}
onSelectPaused={actions.selectPaused}
onDestroy={actions.requestDestroy}
/>
{/* A failed declaration shows here too: on a terminal page's
dialog there is no roster card to carry the "failed" badge. */}
{declareError ? (
<ApiErrorPanel
context="the declaration failed"
problem={declareError}
/>
) : null}
</dd>
</>
);
}
function OverviewRows({ row }: { row: AgentRow }) {
return (
<>
<dt>state</dt>
<dd>
<AgentStateRow key={row.name} agent={row.name} />
</dd>
<dt>hive</dt>
<dd>{row.hive ?? "—"}</dd>
<dt>config PR</dt>
<dd>
{row.config_pr ? (
<Badge
tone={tone}
tone="warning"
value={
<>
{label}
{row.last_seen_unix !== null ? (
<>
{" "}
(<RelativeTime epochMs={row.last_seen_unix * 1000} />)
</>
) : null}
</>
row.config_pr.html_url ? (
<a
href={row.config_pr.html_url}
target="_blank"
rel="noreferrer"
>
#{row.config_pr.pr_number}
</a>
) : (
`#${row.config_pr.pr_number}`
)
}
/>
</dd>
<dt>message</dt>
<dd>{row.snapshot?.status_text ?? "—"}</dd>
<dt>wanted</dt>
<dd>
{/* Full menu (destroy included) — mara: "destroy is already
available via wanted state", no separate button needed. */}
<WantedMenu
row={row}
pending={actions.pendingAgents.has(row.name)}
onSelectUp={actions.selectUp}
onSelectOffline={actions.selectOffline}
onSelectPaused={actions.selectPaused}
onDestroy={actions.requestDestroy}
) : (
"—"
)}
</dd>
</>
);
}
function SettingsRows({ agent }: { agent: string }) {
return (
<>
<dt>inference</dt>
<dd>
<AgentInference key={agent} agent={agent} />
</dd>
<dt>instructions</dt>
<dd>
<AgentInstructions key={agent} agent={agent} />
</dd>
<dt>resource caps</dt>
<dd>
<AgentLimits key={agent} agent={agent} />
</dd>
<dt>permissions</dt>
<dd>
<AgentPermissions key={agent} agent={agent} />
</dd>
<dt>allowed domains</dt>
<dd>
<AllowedDomains key={agent} agent={agent} />
</dd>
</>
);
}
function AccountsRows({
row,
actions,
}: {
row: AgentRow;
actions: AgentActions;
}) {
return (
<>
<dt>accounts</dt>
<dd>
{row.hive ? (
<LinkedAccounts
key={`${row.hive}/${row.name}`}
hive={row.hive}
agent={row.name}
version={actions.linkVersion}
/>
{/* A failed declaration shows here too: on a terminal page's
dialog there is no roster card to carry the "failed" badge. */}
{declareError ? (
<ApiErrorPanel
context="the declaration failed"
problem={declareError}
/>
) : null}
</dd>
<dt>state</dt>
<dd>
<AgentStateRow key={row.name} agent={row.name} />
</dd>
<dt>hive</dt>
<dd>{row.hive ?? "—"}</dd>
<dt>config PR</dt>
<dd>
{row.config_pr ? (
<Badge
tone="warning"
value={
row.config_pr.html_url ? (
<a
href={row.config_pr.html_url}
target="_blank"
rel="noreferrer"
>
#{row.config_pr.pr_number}
</a>
) : (
`#${row.config_pr.pr_number}`
)
}
/>
) : (
"—"
)}
</dd>
<dt>inference</dt>
<dd>
<AgentInference key={row.name} agent={row.name} />
</dd>
<dt>instructions</dt>
<dd>
<AgentInstructions key={row.name} agent={row.name} />
</dd>
<dt>resource caps</dt>
<dd>
<AgentLimits key={row.name} agent={row.name} />
</dd>
<dt>permissions</dt>
<dd>
<AgentPermissions key={row.name} agent={row.name} />
</dd>
<dt>allowed domains</dt>
<dd>
<AllowedDomains key={row.name} agent={row.name} />
</dd>
<dt>accounts</dt>
<dd>
{row.hive ? (
<LinkedAccounts
key={`${row.hive}/${row.name}`}
hive={row.hive}
agent={row.name}
version={actions.linkVersion}
/>
) : (
"—"
)}
</dd>
</dl>
<div class="ui-agent-detail-actions">
<LinkAccountMenu
agent={row.name}
hasHive={row.hive !== null}
onSelect={(kind) => actions.openLink(row, kind)}
/>
</div>
{withTerminal ? (
) : (
"—"
)}
</dd>
</>
);
}
function LinkAccountAction({
row,
actions,
}: {
row: AgentRow;
actions: AgentActions;
}) {
return (
<div class="ui-agent-detail-actions">
<LinkAccountMenu
agent={row.name}
hasHive={row.hive !== null}
onSelect={(kind) => actions.openLink(row, kind)}
/>
</div>
);
}
/** The body of one folded section of an opened card. */
function SectionBody({
section,
row,
actions,
}: {
section: DetailSection;
row: AgentRow;
actions: AgentActions;
}) {
switch (section) {
case "overview":
return (
<dl class="ui-agent-detail-fields">
<OverviewRows row={row} />
</dl>
);
case "settings":
return (
<dl class="ui-agent-detail-fields">
<SettingsRows agent={row.name} />
</dl>
);
case "accounts":
return (
<>
<dl class="ui-agent-detail-fields">
<AccountsRows row={row} actions={actions} />
</dl>
<LinkAccountAction row={row} actions={actions} />
</>
);
case "terminal":
return (
<>
{/* The agent's live terminal and its input box. */}
<AgentTermPreview agentName={row.name} />
<SubagentTerms key={row.name} agentName={row.name} />
</>
);
}
}
export function AgentDetail({
row,
actions,
withTerminal = false,
inCard = false,
}: {
row: AgentRow;
actions: AgentActions;
/** Add the live terminal and subagents: for the flat layout, below the rows. */
withTerminal?: boolean;
/** The folded sections of an opened card, without the rows its header shows. */
inCard?: boolean;
}) {
if (inCard) {
return (
<div class="ui-agent-detail ui-agent-detail-sections">
{sectionsFor(withTerminal).map((section) => (
<LazyDisclosure
key={section}
title={SECTION_TITLE[section]}
startOpen={startsOpen(section)}
>
<SectionBody section={section} row={row} actions={actions} />
</LazyDisclosure>
))}
</div>
);
}
return (
<div class="ui-agent-detail">
<dl class="ui-agent-detail-fields">
<HeaderRows row={row} actions={actions} />
<OverviewRows row={row} />
<SettingsRows agent={row.name} />
<AccountsRows row={row} actions={actions} />
</dl>
<LinkAccountAction row={row} actions={actions} />
{withTerminal ? (
<SectionBody section="terminal" row={row} actions={actions} />
) : null}
</div>
);

View file

@ -1,4 +1,4 @@
// <AgentDetailPopover> — the agent detail panel from `/agents`, in a popover
// <AgentDetailPopover> — an agent's details from `/agents`, as in an opened card, in a popover
// under its anchor, for a page that is already showing the agent's terminal
// and should not be left, or dimmed, to read or change its details. It carries
// the same body (`AgentDetail`) and the same actions (`useAgentActions`) as the

View file

@ -1,4 +1,4 @@
// <AgentInference> — one agent's inference settings on the detail panel: the
// <AgentInference> — one agent's inference settings in an opened agent card: the
// model and effort it runs on now, with where each came from, and an editor
// for the value the swarm sets.
//

View file

@ -1,5 +1,5 @@
// <AgentInstructions> — an editor for an agent's AGENTS.md, in a dialog opened
// from the agent's detail panel.
// from the agent's opened card.
//
// It loads the copy the agent's harness last published
// (`GET /api/agents/{name}/agents-md`, so a stopped agent shows the text it

View file

@ -1,4 +1,4 @@
// <AgentLimits> — one agent's resource caps on the detail panel: the CPU and
// <AgentLimits> — one agent's resource caps in an opened agent card: the CPU and
// memory caps the swarm sets for it, and an editor for them.
//
// Reads `GET /api/agents/{name}/limits`, which answers the stored caps as

View file

@ -1,4 +1,4 @@
// <AgentPermissions> — one agent's permissions on the detail panel: what the
// <AgentPermissions> — one agent's permissions in an opened agent card: what the
// swarm grants it, and an editor for that.
//
// Reads `GET /api/agents/{name}/permissions`, which answers the stored list as

View file

@ -1,4 +1,4 @@
// The detail panel's allowed-domains editor: which entries one agent's panel
// The allowed-domains editor of an opened agent card: which entries it
// shows and the bodies it sends. Kept out of the component so `npm test` can
// run it.

View file

@ -1,4 +1,4 @@
// The detail panel's permissions editor: what it starts on and how a granted
// An opened agent card's permissions editor: what it starts on and how a granted
// set reads. Kept out of the component so `npm test` can run it.
/**

View file

@ -0,0 +1,28 @@
// The sections an agent card opens into, without a DOM: which there are, in
// what order, and which start open. The card's header already shows the
// agent's status, message and wanted state, so the sections hold the rest.
/** One folded block of an opened agent card. */
export type DetailSection = "overview" | "settings" | "accounts" | "terminal";
/** What each section's header says. */
export const SECTION_TITLE: Record<DetailSection, string> = {
overview: "overview",
settings: "settings",
accounts: "accounts",
terminal: "terminal",
};
/**
* The sections a card shows, top to bottom. The terminal is left out where
* the page is already beside one (the terminal page's details dialog).
*/
export function sectionsFor(withTerminal: boolean): DetailSection[] {
const sections: DetailSection[] = ["overview", "settings", "accounts"];
return withTerminal ? [...sections, "terminal"] : sections;
}
/** Only the overview is open when a card opens; the rest build on demand. */
export function startsOpen(section: DetailSection): boolean {
return section === "overview";
}

View file

@ -42,3 +42,14 @@
flex: 1;
min-width: 0;
}
/* The header is what opens the card: it reads as a control without a box. */
.ui-agent-card-row[role="button"] {
cursor: pointer;
}
/* The opened body, set off from the header by a rule. */
.ui-agent-card-detail {
margin-block-start: 0.75em;
padding-block-start: 0.75em;
border-block-start: 1px solid var(--border);
}

View file

@ -1,56 +1,55 @@
// <AgentCard> — one card per roster agent (mara: "main view: name,
// status, message, wanted" / "message as second line" / "more like
// card per agent") — the FilterableView `view === "cards"` alternative
// to the original `Table`, not its replacement (see `AgentsPage`'s own
// comment). Clicking a card opens a detail panel repeating this same
// info plus what doesn't fit here (hive, config-PR link, matrix
// link-account) — mara: "the info from main list should be included in
// the agent view" too, not just the leftovers.
// card per agent"). The header is those four things; selecting it opens the
// card in place into the agent's details (mara: "one column of expandable
// cards"), so there is no second panel to select into. The header already
// shows the agent's status, message and wanted state, so the opened body is the
// rest: `AgentDetail`'s folded sections (`inCard`).
//
// Built on the shared `Card` primitive (`ui/card/`) for the clickable/
// selectable-container mechanics — the `stopPropagation` wrapper around
// `WantedMenu` below keeps its own clicks (mouse or keyboard-synthesized,
// `WantedMenu` hosts real `<button>`s) from also firing `Card`'s
// `onClick` and opening the detail panel.
// The `stopPropagation` wrappers around the login and `WantedMenu` controls
// keep their own clicks (mouse or keyboard-synthesized, they host real
// `<button>`s) from also toggling the card. The wanted menu carries the destroy
// option here, since no detail row repeats it any more.
import { useState } from "preact/hooks";
import { Badge } from "@hive/shared/badge.js";
import type { ProblemDetails } from "@hive/shared/api-error.js";
import { Card } from "../../ui/card/Card.js";
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
import { AgentIcon } from "../AgentIcon.js";
import { ClaudeLoginDialog } from "../claude-login/ClaudeLoginDialog.js";
import { AgentDetail } from "../detail/AgentDetail.js";
import { FRESHNESS, showsLoginNeeded, type AgentRow } from "../AgentTypes.js";
import type { AgentActions } from "./AgentActions.js";
import { WantedMenu } from "./WantedMenu.js";
import "./AgentCard.css";
export function AgentCard({
row,
pending,
error,
selected,
onSelectUp,
onSelectOffline,
onSelectPaused,
onOpenDetail,
actions,
}: {
row: AgentRow;
pending: boolean;
error: ProblemDetails | undefined;
/** This row is the one currently shown in the detail panel — a
* highlight only (the panel itself is the source of truth), so a
* page-refresh landing on a still-valid selection reads as obviously
* "that one" rather than a silent selection nothing marks. */
selected: boolean;
onSelectUp: (row: AgentRow) => void;
onSelectOffline: (row: AgentRow) => void;
onSelectPaused: (row: AgentRow) => void;
onOpenDetail: (row: AgentRow) => void;
actions: AgentActions;
}) {
const { tone, label } = FRESHNESS[row.freshness];
const [loggingIn, setLoggingIn] = useState(false);
const [open, setOpen] = useState(false);
const error = actions.actionErrors.get(row.name);
const toggle = () => setOpen((was) => !was);
return (
<Card selected={selected} onClick={() => onOpenDetail(row)}>
<div class="ui-agent-card-row">
<Card selected={open}>
<div
class="ui-agent-card-row"
role="button"
tabIndex={0}
aria-expanded={open}
onClick={toggle}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggle();
}
}}
>
<AgentIcon name={row.name} />
<div class="ui-agent-card-body">
<div class="ui-agent-card-line1">
@ -104,11 +103,11 @@ export function AgentCard({
>
<WantedMenu
row={row}
pending={pending}
showDestroy={false}
onSelectUp={onSelectUp}
onSelectOffline={onSelectOffline}
onSelectPaused={onSelectPaused}
pending={actions.pendingAgents.has(row.name)}
onSelectUp={actions.selectUp}
onSelectOffline={actions.selectOffline}
onSelectPaused={actions.selectPaused}
onDestroy={actions.requestDestroy}
/>
{error ? (
<Badge
@ -124,6 +123,11 @@ export function AgentCard({
</div>
</div>
</div>
{open ? (
<div class="ui-agent-card-detail">
<AgentDetail row={row} actions={actions} withTerminal inCard />
</div>
) : null}
</Card>
);
}

View file

@ -47,13 +47,13 @@ export function WantedMenu({
* only the table's "wanted" column needs it, to escape
* `.ui-table-scroll`'s clip on the table's last row (the bug `Dropdown`'s
* own `portal` prop exists to fix in the first place).
* Neither the card list nor the detail panel has a clipping ancestor,
* and inside the detail panel specifically `portal` is actively wrong:
* Neither the card list nor the details dialog has a clipping ancestor,
* and inside the details dialog specifically `portal` is actively wrong:
* a `position: fixed` element appended to `document.body` renders
* *behind* an open native `<dialog>` (the dialog is promoted to the
* browser's top layer, which composites above ordinary body content
* regardless of z-index) — found while screenshotting this exact
* dropdown open inside the detail panel, only the portion extending
* dropdown open inside the details dialog, only the portion extending
* past the dialog's own edge was visible.
*/
portal?: boolean;

View file

@ -1,7 +1,7 @@
/* <AgentTermPreview> — a *preview*, not the full terminal: a much
shorter cap than @hive/shared/terminal.css's own 32em default so it
reads as "peek at what's happening" inside the detail panel rather
than competing with the panel's own fields for vertical space. */
reads as "peek at what's happening" inside an opened agent card rather
than competing with the card's own fields for vertical space. */
@import "@hive/shared/terminal.css";
.ui-agent-term-preview {

View file

@ -1,5 +1,5 @@
// <AgentTermPreview> — the MVP mara's own ruling asked for: not a separate
// page, a small preview embedded below `AgentsPage`'s detail panel fields, with
// page, a small preview in an opened agent card on `AgentsPage`, with
// an `AgentInputForm` under an agent's terminal (a subagent's has none: it
// takes no input of its own). Consumes swarm-controller's
// `GET /api/agents/{name}/term/stream`, or for a subagent
@ -20,9 +20,9 @@
// not from `AgentRow` — the roster fetch `AgentsPage` already did has no
// notion of turn_state/model/ctx/cost, only `wanted`/`snapshot.running`.
// Only the full-tab mount (`fullHeight`) shows the state badges: the small
// preview in the detail panel stays free of them (mara: "the preview is small
// preview in an opened card stays free of them (mara: "the preview is small
// and should not be encroached further with badges on top, but for the full
// screen term i want it like that"), and the panel's details table lists the
// screen term i want it like that"), and the card's overview lists the
// same state in a row of its own (`AgentStateRow`).
// **Expand-to-fullscreen trigger** — opens/navigates to

View file

@ -1,5 +1,5 @@
// <SubagentTerms> — an agent's subagents, under its own terminal preview in
// `AgentsPage`'s detail panel. The list is `GET /api/agents/{name}/subagents`:
// an opened agent card on `AgentsPage`. The list is `GET /api/agents/{name}/subagents`:
// the subagents the swarm queue holds terminal rows for, each with the
// publish time of its newest row. Newest first, with a ticking age beside
// the name; one quiet for `STALE_S` is dimmed. Subagents are spawned on

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)}

Some files were not shown because too many files have changed in this diff Show more