From 103c842890bf8f09f8fc833dd98571908cd38dc8 Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 11 Oct 2026 23:55:15 +0200 Subject: [PATCH 1/6] 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 ` + + 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 501647e1..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,10 @@ // 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, useState } from "preact/hooks"; +import { useEffect, useRef, useState } from "preact/hooks"; import type { RouteComponentProps } from "wouter-preact"; import { Badge } from "@hive/shared/badge.js"; -import { AgentDetailDialog } from "../../agent/detail/AgentDetailDialog.js"; +import { AgentDetailPopover } from "../../agent/detail/AgentDetailPopover.js"; import { AgentIcon } from "../../agent/AgentIcon.js"; import { AgentTermPreview, @@ -48,10 +48,12 @@ export function AgentTerminalPage({ // instead of once per terminal `page`. }, [page]); - // The agent's details open in a dialog over the terminal, so reading or - // changing them never means leaving it. A subagent's terminal opens its - // agent's details: a subagent has none of its own. + // 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 (
@@ -61,18 +63,22 @@ export function AgentTerminalPage({ fullHeight showHeaderBadges leading={ - } - value={name} - title={`open ${name}'s details`} - onClick={() => setDetailOpen(true)} - /> + + } + 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/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..3bc5d1e9 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/popover/Popover.tsx @@ -0,0 +1,115 @@ +// — 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; + setPos({ + top: r.bottom + GAP_PX, + left: Math.max(EDGE_PX, Math.min(r.left, maxLeft)), + }); + } + 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, + ); +} From afa9d861ba2ee8c476d9cfbb55db4eb08b9bbe0f Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 12 Oct 2026 00:17:18 +0200 Subject: [PATCH 5/6] docs: swarm UI popover wording without the input-specific verb --- docs/swarm/ui.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 8fc31a83..b7ef3651 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -42,7 +42,7 @@ 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, and a click outside it or Escape closes it. You can stop or pause 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. From 5c155800605243693a1e0a50d6cb0a4e3f89191e Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 12 Oct 2026 00:21:38 +0200 Subject: [PATCH 6/6] swarm-ui: popover keeps its position object when a scroll moved nothing --- frontend/packages/swarm-ui/src/ui/popover/Popover.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/frontend/packages/swarm-ui/src/ui/popover/Popover.tsx b/frontend/packages/swarm-ui/src/ui/popover/Popover.tsx index 3bc5d1e9..1f86b735 100644 --- a/frontend/packages/swarm-ui/src/ui/popover/Popover.tsx +++ b/frontend/packages/swarm-ui/src/ui/popover/Popover.tsx @@ -54,10 +54,13 @@ export function Popover({ const r = anchor.getBoundingClientRect(); const width = ref.current?.offsetWidth ?? 0; const maxLeft = window.innerWidth - width - EDGE_PX; - setPos({ - top: r.bottom + GAP_PX, - left: Math.max(EDGE_PX, Math.min(r.left, maxLeft)), - }); + 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);