diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 11dc54fe..b7ef3651 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -38,6 +38,14 @@ 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 popover under the badge: +the same status, wanted state, inference settings and linked accounts as the +panel on `/agents`, without the terminal preview. The popover doesn't dim the +page. Selecting outside it, or Escape, closes it. 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/agent/AgentIcon.css b/frontend/packages/swarm-ui/src/agent/AgentIcon.css new file mode 100644 index 00000000..de4110c5 --- /dev/null +++ b/frontend/packages/swarm-ui/src/agent/AgentIcon.css @@ -0,0 +1,27 @@ +/* The icon square, sized and dimmed like the hive dashboard's + `.container-icon` (dashboard.css). */ +.ui-agent-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-icon > img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; +} +.ui-agent-icon-none { + filter: grayscale(1); + opacity: 0.4; +} +/* Beside a line of text, inside a badge. */ +.ui-agent-icon-small { + width: 1.3em; + border-radius: 3px; +} diff --git a/frontend/packages/swarm-ui/src/agent/AgentIcon.tsx b/frontend/packages/swarm-ui/src/agent/AgentIcon.tsx new file mode 100644 index 00000000..2fbf67ed --- /dev/null +++ b/frontend/packages/swarm-ui/src/agent/AgentIcon.tsx @@ -0,0 +1,38 @@ +// — the agent's icon from swarm-controller's +// `GET /api/agents/{name}/icon`, the same square and fallback as the hive +// dashboard's container row: an agent with no icon (404) or an unreachable +// controller shows the dimmed hyperhive mark instead of a broken image. +// Always an ``: the body is an agent-authored SVG, and only an image load +// keeps its script from running. +import { useState } from "preact/hooks"; +import "./AgentIcon.css"; + +export function AgentIcon({ + name, + small = false, +}: { + name: string; + /** Badge-sized, for beside a line of text, rather than the card's square. */ + small?: boolean; +}) { + const [fallback, setFallback] = useState(false); + return ( +
+ setFallback(true)} + /> +
+ ); +} diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentTypes.ts b/frontend/packages/swarm-ui/src/agent/AgentTypes.ts similarity index 100% rename from frontend/packages/swarm-ui/src/pages/agents/AgentTypes.ts rename to frontend/packages/swarm-ui/src/agent/AgentTypes.ts diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.css b/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.css new file mode 100644 index 00000000..36bf6222 --- /dev/null +++ b/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.css @@ -0,0 +1,19 @@ +/* body — the panel or dialog around it supplies the title (the + agent's name), so it is 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/agent/detail/AgentDetail.tsx b/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx new file mode 100644 index 00000000..3f864178 --- /dev/null +++ b/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx @@ -0,0 +1,135 @@ +// — one agent's details: its status, wanted state, hive, config +// PR, inference settings 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). +// +// 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. +import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; +import { Badge } from "@hive/shared/badge.js"; +import { RelativeTime } from "../../ui/relative-time/RelativeTime.js"; +import type { AgentActions } from "../roster/AgentActions.js"; +import { AgentInference } from "./AgentInference.js"; +import { AgentTermPreview } from "../terminal/AgentTermPreview.js"; +import { FRESHNESS, type AgentRow } from "../AgentTypes.js"; +import { LinkAccountMenu } from "./LinkAccountMenu.js"; +import { LinkedAccounts } from "./LinkedAccounts.js"; +import { SubagentTerms } from "../terminal/SubagentTerms.js"; +import { WantedMenu } from "../roster/WantedMenu.js"; +import "./AgentDetail.css"; + +export function AgentDetail({ + row, + actions, + withTerminal = false, +}: { + row: AgentRow; + actions: AgentActions; + withTerminal?: boolean; +}) { + const { tone, label } = FRESHNESS[row.freshness]; + const declareError = actions.actionErrors.get(row.name); + return ( +
+
+
status
+
+ + {label} + {row.last_seen_unix !== null ? ( + <> + {" "} + () + + ) : null} + + } + /> +
+
message
+
{row.snapshot?.status_text ?? "—"}
+
wanted
+
+ {/* Full menu (destroy included) — mara: "destroy is already + available via wanted state", no separate button needed. */} + + {/* A failed declaration shows here too: on a terminal page's + dialog there is no roster card to carry the "failed" badge. */} + {declareError ? ( + + ) : null} +
+
hive
+
{row.hive ?? "—"}
+
config PR
+
+ {row.config_pr ? ( + + #{row.config_pr.pr_number} + + ) : ( + `#${row.config_pr.pr_number}` + ) + } + /> + ) : ( + "—" + )} +
+
inference
+
+ +
+
accounts
+
+ {row.hive ? ( + + ) : ( + "—" + )} +
+
+
+ actions.openLink(row, kind)} + /> +
+ {withTerminal ? ( + <> + {/* The agent's live terminal and its input box. */} + + + + ) : null} +
+ ); +} diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentDetailPopover.tsx b/frontend/packages/swarm-ui/src/agent/detail/AgentDetailPopover.tsx new file mode 100644 index 00000000..5c282225 --- /dev/null +++ b/frontend/packages/swarm-ui/src/agent/detail/AgentDetailPopover.tsx @@ -0,0 +1,93 @@ +// — the agent detail panel from `/agents`, 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 +// panel, minus the terminal preview the page itself already is. +// +// The row comes from `GET /api/agents/status` when the popover opens, found by +// name: the panel on `/agents` has the whole roster in hand, this page does +// not. A wanted-state change patches the row in place from the PUT's answer. +// The confirm and link dialogs those actions open stay modal: they hold a +// decision, and the popover ignores clicks inside them. +import { useEffect, useState } from "preact/hooks"; +import type { RefObject } from "preact"; +import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; +import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; +import { Panel } from "../../ui/panel/Panel.js"; +import { Popover } from "../../ui/popover/Popover.js"; +import { useAgentActions } from "../roster/AgentActions.js"; +import { AgentDetail } from "./AgentDetail.js"; +import type { AgentRow } from "../AgentTypes.js"; + +export function AgentDetailPopover({ + agent, + open, + onClose, + anchorRef, +}: { + agent: string; + open: boolean; + onClose: () => void; + anchorRef: RefObject; +}) { + // `undefined` until the roster has answered; `null` when it has and the + // agent is not in it. + const [row, setRow] = useState(undefined); + const [error, setError] = useState(null); + const actions = useAgentActions((declaredRow, declared) => { + const state = declared.find((d) => d.agent === declaredRow.name)?.state; + if (state !== undefined) { + setRow((prev) => (prev ? { ...prev, wanted: state } : prev)); + } + }); + + // Fetched on each open, so what the popover shows is current rather than + // what the page last saw. + useEffect(() => { + if (!open) return; + let cancelled = false; + (async () => { + const res = await fetch("/api/agents/status"); + if (!res.ok) { + if (!cancelled) setError(await readApiError(res)); + return; + } + const rows = (await res.json()) as AgentRow[]; + if (cancelled) return; + setRow(rows.find((r) => r.name === agent) ?? null); + setError(null); + })().catch((e: unknown) => { + if (!cancelled) setError({ detail: String(e) }); + }); + return () => { + cancelled = true; + }; + }, [open, agent]); + + return ( + <> + + + {error ? ( + + ) : row ? ( + + ) : row === null ? ( +

{agent} is not in the swarm's agent roster

+ ) : ( +

loading…

+ )} +
+
+ {actions.dialogs} + + ); +} diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentInference.css b/frontend/packages/swarm-ui/src/agent/detail/AgentInference.css similarity index 100% rename from frontend/packages/swarm-ui/src/pages/agents/AgentInference.css rename to frontend/packages/swarm-ui/src/agent/detail/AgentInference.css diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentInference.tsx b/frontend/packages/swarm-ui/src/agent/detail/AgentInference.tsx similarity index 100% rename from frontend/packages/swarm-ui/src/pages/agents/AgentInference.tsx rename to frontend/packages/swarm-ui/src/agent/detail/AgentInference.tsx diff --git a/frontend/packages/swarm-ui/src/pages/agents/LinkAccountMenu.tsx b/frontend/packages/swarm-ui/src/agent/detail/LinkAccountMenu.tsx similarity index 100% rename from frontend/packages/swarm-ui/src/pages/agents/LinkAccountMenu.tsx rename to frontend/packages/swarm-ui/src/agent/detail/LinkAccountMenu.tsx diff --git a/frontend/packages/swarm-ui/src/pages/agents/LinkedAccounts.css b/frontend/packages/swarm-ui/src/agent/detail/LinkedAccounts.css similarity index 100% rename from frontend/packages/swarm-ui/src/pages/agents/LinkedAccounts.css rename to frontend/packages/swarm-ui/src/agent/detail/LinkedAccounts.css diff --git a/frontend/packages/swarm-ui/src/pages/agents/LinkedAccounts.tsx b/frontend/packages/swarm-ui/src/agent/detail/LinkedAccounts.tsx similarity index 100% rename from frontend/packages/swarm-ui/src/pages/agents/LinkedAccounts.tsx rename to frontend/packages/swarm-ui/src/agent/detail/LinkedAccounts.tsx diff --git a/frontend/packages/swarm-ui/src/agent/roster/AgentActions.tsx b/frontend/packages/swarm-ui/src/agent/roster/AgentActions.tsx new file mode 100644 index 00000000..229b644d --- /dev/null +++ b/frontend/packages/swarm-ui/src/agent/roster/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 +// (`AgentDetailPopover`), 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 "../../pages/LinkMatrixAccountForm.js"; +import { LinkForgeAccountForm } from "../../pages/LinkForgeAccountForm.js"; +import { LinkGithubAccountForm } from "../../pages/LinkGithubAccountForm.js"; +import type { AgentRow } from "../AgentTypes.js"; +import type { LinkKind } from "../detail/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/agent/roster/AgentCard.css similarity index 61% rename from frontend/packages/swarm-ui/src/pages/agents/AgentCard.css rename to frontend/packages/swarm-ui/src/agent/roster/AgentCard.css index e548aa9f..dd1a86e2 100644 --- a/frontend/packages/swarm-ui/src/pages/agents/AgentCard.css +++ b/frontend/packages/swarm-ui/src/agent/roster/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/agent/roster/AgentCard.tsx similarity index 79% rename from frontend/packages/swarm-ui/src/pages/agents/AgentCard.tsx rename to frontend/packages/swarm-ui/src/agent/roster/AgentCard.tsx index 894b7c44..c66d244b 100644 --- a/frontend/packages/swarm-ui/src/pages/agents/AgentCard.tsx +++ b/frontend/packages/swarm-ui/src/agent/roster/AgentCard.tsx @@ -12,12 +12,12 @@ // `WantedMenu` below keeps its own clicks (mouse or keyboard-synthesized, // `WantedMenu` hosts real ` + + setOpen(false)} + anchorRef={anchorRef} + label="sample popover" + > + setOpen(false)}> +

+ Floats under its trigger and leaves the page behind it clickable. + Outside click or Escape closes it. +

+
+
+ + ); +} + // `plain`: a `Panel`'s own header bar/border is the dialog's // only card, not a second one nested inside `Dialog`'s own — pair // `plain` with `Panel`'s `onClose` so the close button still exists, @@ -511,6 +539,12 @@ export function ComponentsPage() { +
+ + + +
+
diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentTerminalPage.tsx b/frontend/packages/swarm-ui/src/pages/agents/AgentTerminalPage.tsx index 8fe9989d..248a907e 100644 --- a/frontend/packages/swarm-ui/src/pages/agents/AgentTerminalPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/agents/AgentTerminalPage.tsx @@ -9,10 +9,16 @@ // plain page reload should all re-open the tab instead of landing on a // page with no way back into the tab row. `useDynamicTabs().open()` is // idempotent (dedupes by href), so this is safe to call every mount. -import { useEffect } from "preact/hooks"; +import { useEffect, useRef, useState } from "preact/hooks"; import type { RouteComponentProps } from "wouter-preact"; -import { AgentTermPreview, termPaths } from "./AgentTermPreview.js"; -import { useAgentFavicon } from "../../shell/useAgentFavicon.js"; +import { Badge } from "@hive/shared/badge.js"; +import { AgentDetailPopover } from "../../agent/detail/AgentDetailPopover.js"; +import { AgentIcon } from "../../agent/AgentIcon.js"; +import { + AgentTermPreview, + termPaths, +} from "../../agent/terminal/AgentTermPreview.js"; +import { useAgentFavicon } from "../../agent/terminal/useAgentFavicon.js"; import { useDynamicTabs } from "../../shell/useDynamicTabs.js"; import "./AgentTerminalPage.css"; @@ -42,6 +48,13 @@ export function AgentTerminalPage({ // instead of once per terminal `page`. }, [page]); + // The agent's details open in a popover under its badge, so reading or + // changing them never means leaving the terminal, or dimming it. A + // subagent's terminal opens its agent's details: a subagent has none of its + // own. + const [detailOpen, setDetailOpen] = useState(false); + const badgeRef = useRef(null); + return (
+ } + value={name} + title={`${detailOpen ? "close" : "open"} ${name}'s details`} + expanded={detailOpen} + onClick={() => setDetailOpen((o) => !o)} + /> + + } + /> + setDetailOpen(false)} + anchorRef={badgeRef} />
); 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..c082db11 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 { AgentCard } from "./AgentCard.js"; -import { AgentTermPreview } from "./AgentTermPreview.js"; -import { SubagentTerms } from "./SubagentTerms.js"; -import { FRESHNESS, type AgentRow } from "./AgentTypes.js"; -import { AgentInference } from "./AgentInference.js"; -import { LinkedAccounts } from "./LinkedAccounts.js"; -import { LinkAccountMenu } from "./LinkAccountMenu.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 { 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 { WantedMenu } from "../../agent/roster/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)} /> - - {/* `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} -
+ {actions.dialogs} ); } diff --git a/frontend/packages/swarm-ui/src/pages/schedules/SchedulesPage.tsx b/frontend/packages/swarm-ui/src/pages/schedules/SchedulesPage.tsx index 2fbae2d2..247a53c1 100644 --- a/frontend/packages/swarm-ui/src/pages/schedules/SchedulesPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/schedules/SchedulesPage.tsx @@ -22,7 +22,7 @@ import { type RefreshIntervalMs, } from "../../ui/refresh-interval/RefreshInterval.js"; import type { TableColumn } from "../../ui/table/Table.js"; -import type { AgentRow } from "../agents/AgentTypes.js"; +import type { AgentRow } from "../../agent/AgentTypes.js"; import { ScheduleCard } from "./ScheduleCard.js"; import { ScheduleForm } from "./ScheduleForm.js"; import { scheduleState, type Schedule } from "./ScheduleTypes.js"; diff --git a/frontend/packages/swarm-ui/src/ui/popover/Popover.css b/frontend/packages/swarm-ui/src/ui/popover/Popover.css new file mode 100644 index 00000000..e0397413 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/popover/Popover.css @@ -0,0 +1,15 @@ +/* — floating box under its anchor. Placement (`top`/`left`) is set + inline from the anchor's rect; this is the rest. The content brings its own + card (a `Panel`), so the box itself only floats, casts a shadow and caps its + size to the viewport. */ +.ui-popover { + position: fixed; + z-index: 30; + box-sizing: border-box; + width: 28em; + max-width: calc(100vw - 1em); + max-height: calc(100vh - 4em); + overflow: auto; + border-radius: 0.5em; + box-shadow: 0 0.4em 1.2em rgba(0, 0, 0, 0.45); +} diff --git a/frontend/packages/swarm-ui/src/ui/popover/Popover.tsx b/frontend/packages/swarm-ui/src/ui/popover/Popover.tsx new file mode 100644 index 00000000..1f86b735 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/popover/Popover.tsx @@ -0,0 +1,118 @@ +// — arbitrary content in a floating box anchored under the element +// that opened it, with no backdrop: the page behind stays visible and +// clickable, unlike `Dialog`'s modal overlay. For content that belongs to the +// thing you clicked (an agent's details from its badge), not a task that +// should hold the whole page. +// +// Not a native ``: `showModal()` always dims and blocks the page, and a +// non-modal `show()` gets no outside-click or Escape handling for free, so +// this hand-rolls that contract the way `Dropdown` does. Rendered in a portal +// at `position: fixed`, placed from the anchor's `getBoundingClientRect()`, so +// a clipping ancestor (the terminal's scrolling box) can't cut it off. +// +// A modal `` opened from inside it (a confirm, a form) is not +// "outside": a click in an open dialog, or Escape while one is open, goes to +// that dialog and leaves the popover alone. +import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks"; +import { createPortal } from "preact/compat"; +import type { ComponentChildren, RefObject } from "preact"; +import "./Popover.css"; + +// Space kept between the popover and the viewport's edges. +const EDGE_PX = 8; +const GAP_PX = 4; + +export function Popover({ + open, + onClose, + anchorRef, + label, + children, +}: { + open: boolean; + onClose: () => void; + /** The element the popover hangs under. A click on it is not an outside + * click, so its own toggle handler stays in charge of closing. */ + anchorRef: RefObject; + /** `aria-label` for the floating box. */ + label: string; + children: ComponentChildren; +}) { + const ref = useRef(null); + const [pos, setPos] = useState<{ top: number; left: number } | null>(null); + + // Placed from the anchor on open, and again on scroll and resize, so it + // follows the anchor instead of floating where it first appeared. + useLayoutEffect(() => { + if (!open) { + setPos(null); + return; + } + function place() { + const anchor = anchorRef.current; + if (!anchor) return; + const r = anchor.getBoundingClientRect(); + const width = ref.current?.offsetWidth ?? 0; + const maxLeft = window.innerWidth - width - EDGE_PX; + const top = r.bottom + GAP_PX; + const left = Math.max(EDGE_PX, Math.min(r.left, maxLeft)); + // Scrolling anywhere (the streaming terminal included) calls this; keep + // the old object when nothing moved so the subtree does not re-render. + setPos((prev) => + prev && prev.top === top && prev.left === left ? prev : { top, left }, + ); + } + place(); + window.addEventListener("scroll", place, true); + window.addEventListener("resize", place); + return () => { + window.removeEventListener("scroll", place, true); + window.removeEventListener("resize", place); + }; + }, [open, anchorRef]); + + useEffect(() => { + if (!open) return; + function inside(target: EventTarget | null, el: Element | null) { + return target instanceof Node && el !== null && el.contains(target); + } + function onPointerDown(e: PointerEvent) { + if ( + inside(e.target, ref.current) || + inside(e.target, anchorRef.current) || + inside(e.target, document.querySelector("dialog[open]")) + ) { + return; + } + onClose(); + } + function onKeyDown(e: KeyboardEvent) { + if (e.key !== "Escape" || document.querySelector("dialog[open]")) return; + onClose(); + } + document.addEventListener("pointerdown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("pointerdown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [open, onClose, anchorRef]); + + if (!open) return null; + return createPortal( + , + document.body, + ); +}