diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 11dc54fe..4b920f34 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -38,6 +38,13 @@ A terminal's own tab takes the agent's icon as its browser-tab icon, so tabs of different agents tell apart. A subagent's tab shows its agent's icon. An agent with no icon keeps the hyperhive mark. +The tab also has an agent badge, with the agent's icon and name, ahead of the +connection badge. It opens the agent's details in a dialog over the terminal: +the same status, wanted state, inference settings and linked accounts as the +panel on `/agents`, without the terminal preview. You can stop or pause the +agent, or link an account, without leaving the terminal. A subagent's tab opens +its agent's details. + ### Scheduled prompts `/schedules` has one card per scheduled prompt. The **name** is the card's diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentActions.tsx b/frontend/packages/swarm-ui/src/pages/agents/AgentActions.tsx new file mode 100644 index 00000000..b783bbe2 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/agents/AgentActions.tsx @@ -0,0 +1,266 @@ +// useAgentActions — what can be done to an agent from the swarm UI: declare +// its wanted state (up / offline / paused / destroyed) and link an account to +// it, with the confirm and form dialogs those need. Shared by the agent +// roster (`AgentsPage`) and the detail dialog on a terminal page +// (`AgentDetailDialog`), so a state change reads the same from either; each +// caller renders the returned `dialogs` once. +// +// "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. Destroy is kept apart: it is irreversible, has a longer warning and +// is danger-styled, not one more case of that shape. +import { useState } from "preact/hooks"; +import type { ComponentChildren } from "preact"; +import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; +import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js"; +import { Dialog } from "../../ui/dialog/Dialog.js"; +import { LinkMatrixAccountForm } from "../LinkMatrixAccountForm.js"; +import { LinkForgeAccountForm } from "../LinkForgeAccountForm.js"; +import { LinkGithubAccountForm } from "../LinkGithubAccountForm.js"; +import type { AgentRow } from "./AgentTypes.js"; +import type { LinkKind } from "./LinkAccountMenu.js"; + +// 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. +

+ ), + }, +}; + +/** A hive's whole declaration set, as the state PUT answers it. */ +export type Declared = { agent: string; state: string }[]; + +export interface AgentActions { + /** Per-agent, not one flag: one row's declare-in-flight must not disable + * every other row's button. */ + pendingAgents: ReadonlySet; + actionErrors: ReadonlyMap; + /** Bumped when a link dialog closes; `LinkedAccounts` refetches on a + * change, so a newly linked account shows without a reload. */ + linkVersion: number; + selectUp: (row: AgentRow) => void; + selectOffline: (row: AgentRow) => void; + selectPaused: (row: AgentRow) => void; + requestDestroy: (row: AgentRow) => void; + openLink: (row: AgentRow, kind: LinkKind) => void; + /** The confirm and link dialogs; render once, anywhere. */ + dialogs: ComponentChildren; +} + +export function useAgentActions( + /** Called with the hive's declaration set after a PUT succeeds, so the + * caller can patch the `wanted` field into rows already on screen. */ + onDeclared: (row: AgentRow, declared: Declared) => void, +): AgentActions { + const [pendingAgents, setPendingAgents] = useState>( + new Set(), + ); + const [actionErrors, setActionErrors] = useState< + ReadonlyMap + >(new Map()); + // null means closed, not a boolean, so a dialog can name the agent without + // a second piece of state to keep in sync with it. + const [destroyTarget, setDestroyTarget] = useState(null); + const [confirmTarget, setConfirmTarget] = useState<{ + row: AgentRow; + state: "offline" | "paused"; + } | null>(null); + const [matrixTarget, setMatrixTarget] = useState(null); + const [forgeTarget, setForgeTarget] = useState(null); + const [githubTarget, setGithubTarget] = useState(null); + 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 one PUT every wanted-state selection ends at, whether or not it went + // through a `ConfirmDialog` first: PUT a new `wanted` declaration, track + // per-agent pending/error state, hand the response back to the caller. + 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; + } + onDeclared(row, (await res.json()) as Declared); + } 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; + }); + } + } + + async function confirmDeclare() { + if (!confirmTarget) return; + const { row, state } = confirmTarget; + setConfirmTarget(null); + await declareState(row, state); + } + + // Fires from the destroy confirm dialog, never directly off a click. Unlike + // the other directions there is 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"); + } + + const dialogs = ( + <> + + {/* `matrixTarget.hive` is non-null here — the link triggers are + 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} +
+ + ); + + return { + pendingAgents, + actionErrors, + linkVersion, + selectUp: (row) => void declareState(row, "up"), + selectOffline: (row) => setConfirmTarget({ row, state: "offline" }), + selectPaused: (row) => setConfirmTarget({ row, state: "paused" }), + requestDestroy: setDestroyTarget, + openLink: (row, kind) => { + if (kind === "matrix") setMatrixTarget(row); + else if (kind === "forge") setForgeTarget(row); + else setGithubTarget(row); + }, + dialogs, + }; +} diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentCard.css b/frontend/packages/swarm-ui/src/pages/agents/AgentCard.css index e548aa9f..dd1a86e2 100644 --- a/frontend/packages/swarm-ui/src/pages/agents/AgentCard.css +++ b/frontend/packages/swarm-ui/src/pages/agents/AgentCard.css @@ -22,8 +22,7 @@ white-space: normal; word-break: break-word; } -/* Icon beside the name/message, sized and dimmed like the hive - dashboard's `.container-icon` (dashboard.css). */ +/* Icon (`AgentIcon`) beside the name/message. */ .ui-agent-card-row { display: flex; align-items: flex-start; @@ -36,23 +35,3 @@ flex: 1; min-width: 0; } -.ui-agent-card-icon { - position: relative; - overflow: hidden; - flex: none; - width: 5em; - aspect-ratio: 1; - border-radius: 6px; - background-color: color-mix(in srgb, var(--crust) 60%, transparent); -} -.ui-agent-card-icon > img { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - object-fit: contain; -} -.ui-agent-card-icon-none { - filter: grayscale(1); - opacity: 0.4; -} diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentCard.tsx b/frontend/packages/swarm-ui/src/pages/agents/AgentCard.tsx index 894b7c44..b046e4be 100644 --- a/frontend/packages/swarm-ui/src/pages/agents/AgentCard.tsx +++ b/frontend/packages/swarm-ui/src/pages/agents/AgentCard.tsx @@ -12,11 +12,11 @@ // `WantedMenu` below keeps its own clicks (mouse or keyboard-synthesized, // `WantedMenu` hosts real `