);
diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.css b/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.css
index c8290d30..dddb1fe9 100644
--- a/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.css
+++ b/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.css
@@ -36,23 +36,3 @@
.ui-agents-view-toggle button:not(.active):hover {
color: var(--fg);
}
-
-/* Detail panel body — its own `Panel`, title supplied by the panel's
- `title` prop (the agent's name), not repeated in here. */
-.ui-agent-detail-fields {
- display: grid;
- grid-template-columns: auto 1fr;
- gap: 0.4em 1em;
- margin: 0 0 1em;
-}
-.ui-agent-detail-fields dt {
- color: var(--muted);
-}
-.ui-agent-detail-fields dd {
- margin: 0;
-}
-.ui-agent-detail-actions {
- display: flex;
- align-items: center;
- gap: 0.75em;
-}
diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx b/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx
index 3328cebd..f43264b1 100644
--- a/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx
+++ b/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx
@@ -18,27 +18,24 @@
// 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.
+// 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.
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 { useAgentActions } from "./AgentActions.js";
import { AgentCard } from "./AgentCard.js";
-import { AgentTermPreview } from "./AgentTermPreview.js";
-import { SubagentTerms } from "./SubagentTerms.js";
+import { AgentDetail } from "./AgentDetail.js";
import { FRESHNESS, type AgentRow } from "./AgentTypes.js";
-import { AgentInference } from "./AgentInference.js";
-import { LinkedAccounts } from "./LinkedAccounts.js";
-import { LinkAccountMenu } from "./LinkAccountMenu.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";
@@ -51,46 +48,9 @@ import {
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.
@@ -103,8 +63,6 @@ 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);
@@ -128,56 +86,24 @@ export function AgentsPage() {
}
}
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; the panel's `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();
- };
+ // 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 actions = useAgentActions((row, declared) => {
+ 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,
+ );
+ });
+ });
// 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
@@ -207,99 +133,6 @@ export function AgentsPage() {
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
@@ -367,17 +200,17 @@ export function AgentsPage() {
filterValues: (a) => [a.wanted ?? "no declaration"],
filterMode: "multiselect",
render: (a) => {
- const err = actionErrors.get(a.name);
+ const err = actions.actionErrors.get(a.name);
return (
<>
{err ? (
}
value="link account"
- onClick={a.hive ? () => setMatrixTarget(a) : undefined}
+ onClick={a.hive ? () => actions.openLink(a, "matrix") : undefined}
disabled={!a.hive}
title={
a.hive
@@ -493,12 +326,12 @@ export function AgentsPage() {
renderCard={(a) => (
setDetailTargetName(row.name)}
/>
)}
@@ -520,114 +353,7 @@ export function AgentsPage() {
icon="🔎"
>
{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}`
- )
- }
- />
- ) : (
- "—"
- )}
-
- - inference
- -
-
-
- - accounts
- -
- {detailTarget.hive ? (
-
- ) : (
- "—"
- )}
-
-
-
- {
- if (kind === "matrix") setMatrixTarget(detailTarget);
- else if (kind === "forge") setForgeTarget(detailTarget);
- else setGithubTarget(detailTarget);
- }}
- />
-
- {/* The agent's live terminal and its input box. */}
-
-
-
+
) : (
select an agent to see its details
)}
@@ -642,83 +368,7 @@ export function AgentsPage() {
>
setCreateOpen(false)} />
-
-
-
- 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}
-
+ {actions.dialogs}
>
);
}