// — the swarm's agent roster, merged with each agent's open // config-PR status, swarm-wide health status, and declared wanted state. // A roster with no per-row detail is thin, and neither a config-PR nor a // status panel has anything to render against without a roster to embed // in — so all three ended up as one page. // // One fetch, on a refresh-interval cadence like HivesPage: `GET // /api/agents/status` — one row per roster agent (identity store is the // roster, so a never-reported agent still gets a row), each already // carrying its freshness/snapshot/config PR/wanted state. Used to be // three separate fetches joined client-side by name; per operator // review feedback ("the view should be filled by a single backend // call") the join moved server-side instead — see // `swarm-controller/src/main.rs`'s `get_agents_status` handler. No more // per-page joining left to do here: the wire row *is* the row this page renders. // // Owns the "+ agent" trigger too — the roster this populates is the // 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/actions/`columns` only — // `AgentRow` and friends live in `AgentTypes.ts`, the wanted-state // control in `WantedMenu.tsx`, the card content in `AgentCard.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. import { useState } from "preact/hooks"; import type { ComponentChildren } from "preact"; 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 { LinkIcon } from "@hive/shared/icons.js"; import { AgentCard } from "./AgentCard.js"; import { AgentTermPreview } from "./AgentTermPreview.js"; import { FRESHNESS, type AgentRow } from "./AgentTypes.js"; import { LinkedAccounts } from "./LinkedAccounts.js"; import { Button } from "../../ui/button/Button.js"; import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js"; import { Dialog } from "../../ui/dialog/Dialog.js"; import { FilterableView } from "../../ui/filterable-view/FilterableView.js"; import { Panel } from "../../ui/panel/Panel.js"; import { RelativeTime } from "../../ui/relative-time/RelativeTime.js"; import { RefreshIntervalPicker, useRefreshInterval, type RefreshIntervalMs, } from "../../ui/refresh-interval/RefreshInterval.js"; import { SplitView } from "../../ui/split-view/SplitView.js"; import { type TableColumn } from "../../ui/table/Table.js"; import { CreateAgentForm } from "../CreateAgentForm.js"; import { LinkMatrixAccountForm } from "../LinkMatrixAccountForm.js"; import { LinkForgeAccountForm } from "../LinkForgeAccountForm.js"; import { LinkGithubAccountForm } from "../LinkGithubAccountForm.js"; import { WantedMenu } from "./WantedMenu.js"; import "./AgentsPage.css"; // The one thing that differs between the "offline" and "paused" confirm // dialogs `confirmTarget` drives — everything else (button row, open/close // wiring) is the shared `ConfirmDialog`. const CONFIRM_COPY: Record< "offline" | "paused", { label: string; confirmLabel: string; message: (agentName: string) => ComponentChildren; } > = { offline: { label: "stop agent", confirmLabel: "stop", message: (agentName) => (

Declare {agentName} offline? The hive brings its container down on its next reconcile sweep.

), }, paused: { label: "pause agent", confirmLabel: "pause", message: (agentName) => (

Declare {agentName} paused? Its container stays up — the web UI and MCP daemons keep running — but its turn loop parks on the hive's next reconcile sweep. Select "up" again to resume it.

), }, }; // Same 30s default + same reasoning as HivesPage: no inputs on this page // for a refresh to clobber, so the out-of-the-box behaviour should just // 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"; export function AgentsPage() { const [rows, setRows] = useState(null); // The row showing the "link a github account" dialog, same shape. const [githubTarget, setGithubTarget] = useState(null); const [error, setError] = useState(null); const [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL_MS); // 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(() => { 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); // Per-agent, not one page-wide flag: one row's declare-in-flight // shouldn't disable every other row's button. const [pendingAgents, setPendingAgents] = useState>( new Set(), ); const [actionErrors, setActionErrors] = useState< ReadonlyMap >(new Map()); // The row pending destroy confirmation, `null` when the dialog is // closed — not a boolean, so the dialog can name the agent without a // second piece of state to keep in sync with it. Kept apart from // `confirmTarget` below rather than folded into it as a third state: // destroy is categorically different (irreversible, longer warning, // danger-styled), not one more case of the same "confirm before parking // a running agent" shape "offline"/"paused" share. const [destroyTarget, setDestroyTarget] = useState(null); // "offline" and "paused" are the *same* confirm shape — same button // treatment, same "disruptive to a running turn loop, unlike up" reason // for needing a confirm at all — so one union state + one `ConfirmDialog` // covers both instead of two near-identical copies (argus, PR review: "a // shared confirm-dialog-body-generator would drop real line count"). // `CONFIRM_COPY` below supplies the one thing that differs. const [confirmTarget, setConfirmTarget] = useState<{ row: AgentRow; state: "offline" | "paused"; } | null>(null); // The row currently showing the "link a matrix account" dialog — same // null-means-closed shape as `destroyTarget`/`stopTarget`, own piece of // state rather than folded into either since this dialog isn't a // confirmation of a `declareState` call, it's an unrelated action with // its own form (`LinkMatrixAccountForm`). const [matrixTarget, setMatrixTarget] = useState(null); // The row showing the "link a forge account" dialog, same shape. const [forgeTarget, setForgeTarget] = useState(null); // Bumped whenever a link dialog closes; every `LinkedAccounts` refetches // on a change, so a newly linked account shows without a reload. const [linkVersion, setLinkVersion] = useState(0); const linkClosed = () => setLinkVersion((v) => v + 1); const closeMatrix = () => { setMatrixTarget(null); linkClosed(); }; const closeForge = () => { setForgeTarget(null); linkClosed(); }; const closeGithub = () => { setGithubTarget(null); linkClosed(); }; // 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(null); const detailTarget = rows?.find((r) => r.name === detailTargetName) ?? null; async function refresh() { const res = await fetch("/api/agents/status"); if (!res.ok) { setError(await readApiError(res)); return; } setRows((await res.json()) as AgentRow[]); // A refresh that succeeds clears a previous failure — otherwise a // transient error would sit on screen forever after the data itself // has recovered. setError(null); } useRefreshInterval(intervalMs, () => { refresh().catch((e: unknown) => setError({ detail: String(e) })); }); // The one PUT every `WantedMenu` selection ends at, whether or not it // went through a `ConfirmDialog` first (`confirmStop`/`destroyAgent` // for "offline"/"destroy"; called directly for "up", which needs no // confirmation): "PUT a new `wanted` declaration, track per-agent // pending/error state, patch the response back into `rows`" is the // same shape regardless of target state or how the caller got here. async function declareState(row: AgentRow, target: string) { if (!row.hive) return; setPendingAgents((prev) => new Set(prev).add(row.name)); setActionErrors((prev) => { const next = new Map(prev); next.delete(row.name); return next; }); try { const res = await fetch( `/api/hives/${encodeURIComponent(row.hive)}/agents/${encodeURIComponent(row.name)}/state`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ state: target }), }, ); if (!res.ok) { const problem = await readApiError(res); setActionErrors((prev) => new Map(prev).set(row.name, problem)); return; } // The PUT response is the hive's whole declaration set (every // agent's `wanted` state) — same shape `GET /api/hives/{hive}/wanted` // returns — so patch just that field into the rows already on // screen rather than firing a second, full `/api/agents/status` // round-trip merely to learn one hive's `wanted` column changed. // Everything else on the grid (freshness, snapshot, config PR) // still catches up on the next interval-driven `refresh()`. const declared = (await res.json()) as { agent: string; state: string }[]; setRows((prev) => { if (!prev) return prev; const byAgent = new Map(declared.map((d) => [d.agent, d.state])); return prev.map((r) => r.hive === row.hive && byAgent.has(r.name) ? { ...r, wanted: byAgent.get(r.name) ?? r.wanted } : r, ); }); } catch (e: unknown) { setActionErrors((prev) => new Map(prev).set(row.name, { detail: String(e) }), ); } finally { setPendingAgents((prev) => { const next = new Set(prev); next.delete(row.name); return next; }); } } // Fires from the `confirmTarget` confirm `Dialog` (covers both "offline" // and "paused" — see that state's own comment). Used to be a native // `window.confirm` for the "offline" case (stop is reversible, a later // start un-does it, so a lighter-weight prompt than destroy's felt // proportionate) — mara, reviewing the destroy confirm: use the shared // component everywhere we already confirm before acting, not just for // destroy. Once `ConfirmDialog` existed as a one-line-per-caller // component, the "native is lighter" argument no longer bought // consistency anything. async function confirmDeclare() { if (!confirmTarget) return; const { row, state } = confirmTarget; setConfirmTarget(null); await declareState(row, state); } // Fires from the `destroyTarget` confirm `Dialog`, never directly off // a row click — see `destroyTarget`/`WantedMenu`'s "destroy" option. // Unlike the "up"/"offline" directions, there's no "current state" to // read: destroy is a one-way declaration, not a flip. async function destroyAgent(row: AgentRow) { setDestroyTarget(null); await declareState(row, "destroyed"); } // The three non-destroy `WantedMenu` selections, factored out once // rather than repeated per call site — the card, the table's "wanted" // column, and the detail panel's own `WantedMenu` (below) all wire the // same three. const selectUp = (row: AgentRow) => void declareState(row, "up"); const selectOffline = (row: AgentRow) => setConfirmTarget({ row, state: "offline" }); const selectPaused = (row: AgentRow) => setConfirmTarget({ row, state: "paused" }); // `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[] = [ { 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 ( {label} {a.last_seen_unix !== null ? ( <> {" "} () ) : 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 = actionErrors.get(a.name); return ( <> {err ? ( ) : null} ); }, }, { key: "matrix", header: "matrix", render: (a) => ( <> {a.hive ? ( ) : null} } value="link account" onClick={a.hive ? () => setMatrixTarget(a) : 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 ? ( #{a.config_pr.pr_number} ) : ( `#${a.config_pr.pr_number}` ) } /> ) : ( "—" ), }, ]; return ( <>
} > {error ? ( ) : null} {!error && rows === null ?

loading…

: null} {rows ? ( 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) => ( setDetailTargetName(row.name)} /> )} /> ) : null} } // 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={ {detailTarget ? (
status
{(() => { const { tone, label } = FRESHNESS[detailTarget.freshness]; return ( {label} {detailTarget.last_seen_unix !== null ? ( <> {" "} ( ) ) : null} } /> ); })()}
message
{detailTarget.snapshot?.status_text ?? "—"}
wanted
{/* Full menu (destroy included) — mara: "destroy is already available via wanted state", no separate button needed. */}
hive
{detailTarget.hive ?? "—"}
config PR
{detailTarget.config_pr ? ( #{detailTarget.config_pr.pr_number} ) : ( `#${detailTarget.config_pr.pr_number}` ) } /> ) : ( "—" )}
accounts
{detailTarget.hive ? ( ) : ( "—" )}
} value="link matrix account" onClick={ detailTarget.hive ? () => setMatrixTarget(detailTarget) : undefined } disabled={!detailTarget.hive} title={ detailTarget.hive ? `link a matrix account to ${detailTarget.name}` : "no hive on record for this agent — nothing to link against" } /> } value="link forge account" onClick={ detailTarget.hive ? () => setForgeTarget(detailTarget) : undefined } disabled={!detailTarget.hive} title={ detailTarget.hive ? `link an external forge account to ${detailTarget.name}` : "no hive on record for this agent — nothing to link against" } /> } value="link github account" onClick={ detailTarget.hive ? () => setGithubTarget(detailTarget) : undefined } disabled={!detailTarget.hive} title={ detailTarget.hive ? `link a github account to ${detailTarget.name}` : "no hive on record for this agent — nothing to link against" } />
{/* MVP scope per mara's own ruling: a small read-only preview, no header/no input — the full terminal (+ sending input back) is explicit follow-up scope. */}
) : (

select an agent to see its details

)}
} /> setCreateOpen(false)} label="create agent" plain > setCreateOpen(false)} /> {/* `matrixTarget.hive` is non-null here — the trigger badge above is disabled without one, so this can only open with a real hive to PUT against. */} {matrixTarget?.hive ? ( ) : null} {forgeTarget?.hive ? ( ) : null} {githubTarget?.hive ? ( ) : null} setConfirmTarget(null)} onConfirm={() => void confirmDeclare()} confirmLabel={ confirmTarget ? CONFIRM_COPY[confirmTarget.state].confirmLabel : undefined } > {confirmTarget ? CONFIRM_COPY[confirmTarget.state].message(confirmTarget.row.name) : null} setDestroyTarget(null)} onConfirm={() => destroyTarget && void destroyAgent(destroyTarget)} confirmLabel="destroy" > {destroyTarget ? (

Destroy {destroyTarget.name}? The hive tears its container down on its next reconcile sweep. This is not reversible from here — bringing it back means redeploying via "+ agent", which reuses the agent's existing identity, config repo, and forge collaborator access rather than starting over.

) : null}
); }