diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index b7ef3651..11dc54fe 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -38,14 +38,6 @@ 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 deleted file mode 100644 index de4110c5..00000000 --- a/frontend/packages/swarm-ui/src/agent/AgentIcon.css +++ /dev/null @@ -1,27 +0,0 @@ -/* 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 deleted file mode 100644 index 2fbf67ed..00000000 --- a/frontend/packages/swarm-ui/src/agent/AgentIcon.tsx +++ /dev/null @@ -1,38 +0,0 @@ -// — 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/agent/detail/AgentDetail.css b/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.css deleted file mode 100644 index 36bf6222..00000000 --- a/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.css +++ /dev/null @@ -1,19 +0,0 @@ -/* 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 deleted file mode 100644 index 3f864178..00000000 --- a/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx +++ /dev/null @@ -1,135 +0,0 @@ -// — 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 deleted file mode 100644 index 5c282225..00000000 --- a/frontend/packages/swarm-ui/src/agent/detail/AgentDetailPopover.tsx +++ /dev/null @@ -1,93 +0,0 @@ -// — 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/agent/roster/AgentActions.tsx b/frontend/packages/swarm-ui/src/agent/roster/AgentActions.tsx deleted file mode 100644 index 229b644d..00000000 --- a/frontend/packages/swarm-ui/src/agent/roster/AgentActions.tsx +++ /dev/null @@ -1,266 +0,0 @@ -// 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/ComponentsPage.tsx b/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx index 984d193e..2d691653 100644 --- a/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx @@ -30,7 +30,6 @@ import { import { Button, type ButtonVariant } from "../ui/button/Button.js"; import { Dialog } from "../ui/dialog/Dialog.js"; import { ConfirmDialog } from "../ui/confirm-dialog/ConfirmDialog.js"; -import { Popover } from "../ui/popover/Popover.js"; import { Badge, type BadgeTone } from "@hive/shared/badge.js"; import { GearIcon } from "@hive/shared/icons.js"; import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js"; @@ -232,33 +231,6 @@ function DialogSample() { ); } -// `Popover` anchors to the trigger it is given and toggles from it, so the -// sample needs the trigger's ref and its own `open` state. -function PopoverSample() { - const [open, setOpen] = useState(false); - const anchorRef = useRef(null); - return ( - <> - - - - 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, @@ -539,12 +511,6 @@ export function ComponentsPage() { -
- - - -
-
diff --git a/frontend/packages/swarm-ui/src/agent/roster/AgentCard.css b/frontend/packages/swarm-ui/src/pages/agents/AgentCard.css similarity index 61% rename from frontend/packages/swarm-ui/src/agent/roster/AgentCard.css rename to frontend/packages/swarm-ui/src/pages/agents/AgentCard.css index dd1a86e2..e548aa9f 100644 --- a/frontend/packages/swarm-ui/src/agent/roster/AgentCard.css +++ b/frontend/packages/swarm-ui/src/pages/agents/AgentCard.css @@ -22,7 +22,8 @@ white-space: normal; word-break: break-word; } -/* Icon (`AgentIcon`) beside the name/message. */ +/* Icon beside the name/message, sized and dimmed like the hive + dashboard's `.container-icon` (dashboard.css). */ .ui-agent-card-row { display: flex; align-items: flex-start; @@ -35,3 +36,23 @@ 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/agent/roster/AgentCard.tsx b/frontend/packages/swarm-ui/src/pages/agents/AgentCard.tsx similarity index 79% rename from frontend/packages/swarm-ui/src/agent/roster/AgentCard.tsx rename to frontend/packages/swarm-ui/src/pages/agents/AgentCard.tsx index c66d244b..894b7c44 100644 --- a/frontend/packages/swarm-ui/src/agent/roster/AgentCard.tsx +++ b/frontend/packages/swarm-ui/src/pages/agents/AgentCard.tsx @@ -12,12 +12,12 @@ // `WantedMenu` below keeps its own clicks (mouse or keyboard-synthesized, // `WantedMenu` hosts real `