From 103c842890bf8f09f8fc833dd98571908cd38dc8 Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 11 Oct 2026 23:55:15 +0200 Subject: [PATCH] swarm-ui: open an agent's details in a dialog from its terminal tab The terminal page gets an agent badge (icon and name) that opens the agent's detail panel in a dialog over the terminal, without the terminal preview. The panel body and the actions on an agent (declare a wanted state, link an account, with their confirm and form dialogs) move out of the agent roster page into AgentDetail and useAgentActions, so the roster and the dialog share them. AgentIcon moves to its own file for the same reason. --- docs/swarm/ui.md | 7 + .../src/pages/agents/AgentActions.tsx | 266 +++++++++++ .../swarm-ui/src/pages/agents/AgentCard.css | 23 +- .../swarm-ui/src/pages/agents/AgentCard.tsx | 30 +- .../swarm-ui/src/pages/agents/AgentDetail.css | 19 + .../swarm-ui/src/pages/agents/AgentDetail.tsx | 125 +++++ .../src/pages/agents/AgentDetailDialog.tsx | 85 ++++ .../swarm-ui/src/pages/agents/AgentIcon.css | 27 ++ .../swarm-ui/src/pages/agents/AgentIcon.tsx | 38 ++ .../src/pages/agents/AgentTermPreview.tsx | 6 + .../src/pages/agents/AgentTerminalPage.tsx | 23 +- .../swarm-ui/src/pages/agents/AgentsPage.css | 20 - .../swarm-ui/src/pages/agents/AgentsPage.tsx | 434 ++---------------- 13 files changed, 639 insertions(+), 464 deletions(-) create mode 100644 frontend/packages/swarm-ui/src/pages/agents/AgentActions.tsx create mode 100644 frontend/packages/swarm-ui/src/pages/agents/AgentDetail.css create mode 100644 frontend/packages/swarm-ui/src/pages/agents/AgentDetail.tsx create mode 100644 frontend/packages/swarm-ui/src/pages/agents/AgentDetailDialog.tsx create mode 100644 frontend/packages/swarm-ui/src/pages/agents/AgentIcon.css create mode 100644 frontend/packages/swarm-ui/src/pages/agents/AgentIcon.tsx 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 `