Watch
0
0
Fork
You've already forked hyperhive
0

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.
This commit is contained in:
iris 2026-10-11 23:55:15 +02:00
commit 103c842890
13 changed files with 639 additions and 464 deletions

View file

@ -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 different agents tell apart. A subagent's tab shows its agent's icon. An agent
with no icon keeps the hyperhive mark. 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 ### Scheduled prompts
`/schedules` has one card per scheduled prompt. The **name** is the card's `/schedules` has one card per scheduled prompt. The **name** is the card's

View file

@ -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) => (
<p>
Declare <strong>{agentName}</strong> offline? The hive brings its
container down on its next reconcile sweep.
</p>
),
},
paused: {
label: "pause agent",
confirmLabel: "pause",
message: (agentName) => (
<p>
Declare <strong>{agentName}</strong> 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.
</p>
),
},
};
/** 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<string>;
actionErrors: ReadonlyMap<string, ProblemDetails>;
/** 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<ReadonlySet<string>>(
new Set(),
);
const [actionErrors, setActionErrors] = useState<
ReadonlyMap<string, ProblemDetails>
>(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<AgentRow | null>(null);
const [confirmTarget, setConfirmTarget] = useState<{
row: AgentRow;
state: "offline" | "paused";
} | null>(null);
const [matrixTarget, setMatrixTarget] = useState<AgentRow | null>(null);
const [forgeTarget, setForgeTarget] = useState<AgentRow | null>(null);
const [githubTarget, setGithubTarget] = useState<AgentRow | null>(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 = (
<>
<Dialog
open={matrixTarget !== null}
onClose={closeMatrix}
label="link a matrix account"
plain
>
{/* `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 ? (
<LinkMatrixAccountForm
hive={matrixTarget.hive}
agent={matrixTarget.name}
onClose={closeMatrix}
/>
) : null}
</Dialog>
<Dialog
open={forgeTarget !== null}
onClose={closeForge}
label="link a forge account"
plain
>
{forgeTarget?.hive ? (
<LinkForgeAccountForm
hive={forgeTarget.hive}
agent={forgeTarget.name}
onClose={closeForge}
/>
) : null}
</Dialog>
<Dialog
open={githubTarget !== null}
onClose={closeGithub}
label="link a github account"
plain
>
{githubTarget?.hive ? (
<LinkGithubAccountForm
hive={githubTarget.hive}
agent={githubTarget.name}
onClose={closeGithub}
/>
) : null}
</Dialog>
<ConfirmDialog
open={confirmTarget !== null}
label={confirmTarget ? CONFIRM_COPY[confirmTarget.state].label : ""}
onCancel={() => setConfirmTarget(null)}
onConfirm={() => void confirmDeclare()}
confirmLabel={
confirmTarget
? CONFIRM_COPY[confirmTarget.state].confirmLabel
: undefined
}
>
{confirmTarget
? CONFIRM_COPY[confirmTarget.state].message(confirmTarget.row.name)
: null}
</ConfirmDialog>
<ConfirmDialog
open={destroyTarget !== null}
label="destroy agent"
onCancel={() => setDestroyTarget(null)}
onConfirm={() => destroyTarget && void destroyAgent(destroyTarget)}
confirmLabel="destroy"
>
{destroyTarget ? (
<p>
Destroy <strong>{destroyTarget.name}</strong>? 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.
</p>
) : null}
</ConfirmDialog>
</>
);
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,
};
}

View file

@ -22,8 +22,7 @@
white-space: normal; white-space: normal;
word-break: break-word; word-break: break-word;
} }
/* Icon beside the name/message, sized and dimmed like the hive /* Icon (`AgentIcon`) beside the name/message. */
dashboard's `.container-icon` (dashboard.css). */
.ui-agent-card-row { .ui-agent-card-row {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@ -36,23 +35,3 @@
flex: 1; flex: 1;
min-width: 0; 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;
}

View file

@ -12,11 +12,11 @@
// `WantedMenu` below keeps its own clicks (mouse or keyboard-synthesized, // `WantedMenu` below keeps its own clicks (mouse or keyboard-synthesized,
// `WantedMenu` hosts real `<button>`s) from also firing `Card`'s // `WantedMenu` hosts real `<button>`s) from also firing `Card`'s
// `onClick` and opening the detail panel. // `onClick` and opening the detail panel.
import { useState } from "preact/hooks";
import { Badge } from "@hive/shared/badge.js"; import { Badge } from "@hive/shared/badge.js";
import type { ProblemDetails } from "@hive/shared/api-error.js"; import type { ProblemDetails } from "@hive/shared/api-error.js";
import { Card } from "../../ui/card/Card.js"; import { Card } from "../../ui/card/Card.js";
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js"; import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
import { AgentIcon } from "./AgentIcon.js";
import { FRESHNESS, type AgentRow } from "./AgentTypes.js"; import { FRESHNESS, type AgentRow } from "./AgentTypes.js";
import { WantedMenu } from "./WantedMenu.js"; import { WantedMenu } from "./WantedMenu.js";
import "./AgentCard.css"; import "./AgentCard.css";
@ -100,31 +100,3 @@ export function AgentCard({
</Card> </Card>
); );
} }
// 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 `<img>`: the body is
// an agent-authored SVG, and only an image load keeps its script from running.
function AgentIcon({ name }: { name: string }) {
const [fallback, setFallback] = useState(false);
return (
<div
class={
fallback
? "ui-agent-card-icon ui-agent-card-icon-none"
: "ui-agent-card-icon"
}
>
<img
alt=""
src={
fallback
? "/favicon.svg"
: `/api/agents/${encodeURIComponent(name)}/icon`
}
onError={() => setFallback(true)}
/>
</div>
);
}

View file

@ -0,0 +1,19 @@
/* <AgentDetail> 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;
}

View file

@ -0,0 +1,125 @@
// <AgentDetail> — 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 { Badge } from "@hive/shared/badge.js";
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
import type { AgentActions } from "./AgentActions.js";
import { AgentInference } from "./AgentInference.js";
import { AgentTermPreview } from "./AgentTermPreview.js";
import { FRESHNESS, type AgentRow } from "./AgentTypes.js";
import { LinkAccountMenu } from "./LinkAccountMenu.js";
import { LinkedAccounts } from "./LinkedAccounts.js";
import { SubagentTerms } from "./SubagentTerms.js";
import { WantedMenu } from "./WantedMenu.js";
import "./AgentDetail.css";
export function AgentDetail({
row,
actions,
withTerminal = false,
}: {
row: AgentRow;
actions: AgentActions;
withTerminal?: boolean;
}) {
const { tone, label } = FRESHNESS[row.freshness];
return (
<div class="ui-agent-detail">
<dl class="ui-agent-detail-fields">
<dt>status</dt>
<dd>
<Badge
tone={tone}
value={
<>
{label}
{row.last_seen_unix !== null ? (
<>
{" "}
(<RelativeTime epochMs={row.last_seen_unix * 1000} />)
</>
) : null}
</>
}
/>
</dd>
<dt>message</dt>
<dd>{row.snapshot?.status_text ?? "—"}</dd>
<dt>wanted</dt>
<dd>
{/* Full menu (destroy included) — mara: "destroy is already
available via wanted state", no separate button needed. */}
<WantedMenu
row={row}
pending={actions.pendingAgents.has(row.name)}
onSelectUp={actions.selectUp}
onSelectOffline={actions.selectOffline}
onSelectPaused={actions.selectPaused}
onDestroy={actions.requestDestroy}
/>
</dd>
<dt>hive</dt>
<dd>{row.hive ?? "—"}</dd>
<dt>config PR</dt>
<dd>
{row.config_pr ? (
<Badge
tone="warning"
value={
row.config_pr.html_url ? (
<a
href={row.config_pr.html_url}
target="_blank"
rel="noreferrer"
>
#{row.config_pr.pr_number}
</a>
) : (
`#${row.config_pr.pr_number}`
)
}
/>
) : (
"—"
)}
</dd>
<dt>inference</dt>
<dd>
<AgentInference key={row.name} agent={row.name} />
</dd>
<dt>accounts</dt>
<dd>
{row.hive ? (
<LinkedAccounts
key={`${row.hive}/${row.name}`}
hive={row.hive}
agent={row.name}
version={actions.linkVersion}
/>
) : (
"—"
)}
</dd>
</dl>
<div class="ui-agent-detail-actions">
<LinkAccountMenu
agent={row.name}
hasHive={row.hive !== null}
onSelect={(kind) => actions.openLink(row, kind)}
/>
</div>
{withTerminal ? (
<>
{/* The agent's live terminal and its input box. */}
<AgentTermPreview agentName={row.name} />
<SubagentTerms key={row.name} agentName={row.name} />
</>
) : null}
</div>
);
}

View file

@ -0,0 +1,85 @@
// <AgentDetailDialog> — the agent detail panel from `/agents`, in a dialog, for
// a page that is already showing the agent's terminal and should not be left
// 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 dialog 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.
import { useEffect, useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Dialog } from "../../ui/dialog/Dialog.js";
import { Panel } from "../../ui/panel/Panel.js";
import { useAgentActions } from "./AgentActions.js";
import { AgentDetail } from "./AgentDetail.js";
import type { AgentRow } from "./AgentTypes.js";
export function AgentDetailDialog({
agent,
open,
onClose,
}: {
agent: string;
open: boolean;
onClose: () => void;
}) {
// `undefined` until the roster has answered; `null` when it has and the
// agent is not in it.
const [row, setRow] = useState<AgentRow | null | undefined>(undefined);
const [error, setError] = useState<ProblemDetails | null>(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 dialog 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 (
<>
<Dialog open={open} onClose={onClose} label={`${agent} details`} plain>
{open ? (
<Panel title={agent} icon="🔎" onClose={onClose}>
{error ? (
<ApiErrorPanel
context="failed to load the agent's details"
problem={error}
/>
) : row ? (
<AgentDetail row={row} actions={actions} />
) : row === null ? (
<p>{agent} is not in the swarm's agent roster</p>
) : (
<p>loading…</p>
)}
</Panel>
) : null}
</Dialog>
{actions.dialogs}
</>
);
}

View file

@ -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;
}

View file

@ -0,0 +1,38 @@
// <AgentIcon> — 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 `<img>`: 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 (
<div
class={
"ui-agent-icon" +
(small ? " ui-agent-icon-small" : "") +
(fallback ? " ui-agent-icon-none" : "")
}
>
<img
alt=""
src={
fallback
? "/favicon.svg"
: `/api/agents/${encodeURIComponent(name)}/icon`
}
onError={() => setFallback(true)}
/>
</div>
);
}

View file

@ -37,6 +37,7 @@
// the other one is. Suppressed via `fullHeight` — the full-page mount // the other one is. Suppressed via `fullHeight` — the full-page mount
// already IS the destination, so it has nothing further to expand to. // already IS the destination, so it has nothing further to expand to.
import { useLayoutEffect, useRef, useState } from "preact/hooks"; import { useLayoutEffect, useRef, useState } from "preact/hooks";
import type { ComponentChildren } from "preact";
import { Badge, type BadgeTone } from "@hive/shared/badge.js"; import { Badge, type BadgeTone } from "@hive/shared/badge.js";
import { ExpandIcon } from "@hive/shared/icons.js"; import { ExpandIcon } from "@hive/shared/icons.js";
import { Row } from "@hive/shared/term-row.js"; import { Row } from "@hive/shared/term-row.js";
@ -189,6 +190,7 @@ export function AgentTermPreview({
subagent, subagent,
showHeaderBadges = true, showHeaderBadges = true,
fullHeight = false, fullHeight = false,
leading,
}: { }: {
agentName: string; agentName: string;
// Set: this is one of `agentName`'s subagents, output only like the // Set: this is one of `agentName`'s subagents, output only like the
@ -196,6 +198,9 @@ export function AgentTermPreview({
subagent?: string; subagent?: string;
showHeaderBadges?: boolean; showHeaderBadges?: boolean;
fullHeight?: boolean; fullHeight?: boolean;
// A badge ahead of the connection badge, for the full-tab mount's
// link back to the agent's details.
leading?: ComponentChildren;
}) { }) {
const paths = termPaths(agentName, subagent); const paths = termPaths(agentName, subagent);
const { rows, connection } = useSwarmTermStream(paths.stream); const { rows, connection } = useSwarmTermStream(paths.stream);
@ -236,6 +241,7 @@ export function AgentTermPreview({
it has no expand trigger. */} it has no expand trigger. */}
<div class="ui-agent-term-preview-controls"> <div class="ui-agent-term-preview-controls">
<div class="ui-agent-term-preview-header-badges"> <div class="ui-agent-term-preview-header-badges">
{leading}
<Badge <Badge
tone={CONNECTION_TONE[connection]} tone={CONNECTION_TONE[connection]}
value={CONNECTION_LABEL[connection]} value={CONNECTION_LABEL[connection]}

View file

@ -9,8 +9,11 @@
// plain page reload should all re-open the tab instead of landing on a // 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 // page with no way back into the tab row. `useDynamicTabs().open()` is
// idempotent (dedupes by href), so this is safe to call every mount. // idempotent (dedupes by href), so this is safe to call every mount.
import { useEffect } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import type { RouteComponentProps } from "wouter-preact"; import type { RouteComponentProps } from "wouter-preact";
import { Badge } from "@hive/shared/badge.js";
import { AgentDetailDialog } from "./AgentDetailDialog.js";
import { AgentIcon } from "./AgentIcon.js";
import { AgentTermPreview, termPaths } from "./AgentTermPreview.js"; import { AgentTermPreview, termPaths } from "./AgentTermPreview.js";
import { useAgentFavicon } from "../../shell/useAgentFavicon.js"; import { useAgentFavicon } from "../../shell/useAgentFavicon.js";
import { useDynamicTabs } from "../../shell/useDynamicTabs.js"; import { useDynamicTabs } from "../../shell/useDynamicTabs.js";
@ -42,6 +45,11 @@ export function AgentTerminalPage({
// instead of once per terminal `page`. // instead of once per terminal `page`.
}, [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.
const [detailOpen, setDetailOpen] = useState(false);
return ( return (
<div class="ui-agent-terminal-page"> <div class="ui-agent-terminal-page">
<AgentTermPreview <AgentTermPreview
@ -49,6 +57,19 @@ export function AgentTerminalPage({
subagent={subagent} subagent={subagent}
fullHeight fullHeight
showHeaderBadges showHeaderBadges
leading={
<Badge
icon={<AgentIcon name={name} small />}
value={name}
title={`open ${name}'s details`}
onClick={() => setDetailOpen(true)}
/>
}
/>
<AgentDetailDialog
agent={name}
open={detailOpen}
onClose={() => setDetailOpen(false)}
/> />
</div> </div>
); );

View file

@ -36,23 +36,3 @@
.ui-agents-view-toggle button:not(.active):hover { .ui-agents-view-toggle button:not(.active):hover {
color: var(--fg); 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;
}

View file

@ -18,27 +18,24 @@
// natural home for the action that populates it. The form itself // natural home for the action that populates it. The form itself
// (`CreateAgentForm`) mounts inside a `Dialog` here, not its own route. // (`CreateAgentForm`) mounts inside a `Dialog` here, not its own route.
// //
// This file owns the page's own state/actions/`columns` only — // This file owns the page's own state/`columns` only — `AgentRow` and
// `AgentRow` and friends live in `AgentTypes.ts`, the wanted-state // friends live in `AgentTypes.ts`, the wanted-state control in
// control in `WantedMenu.tsx`, the card content in `AgentCard.tsx` // `WantedMenu.tsx`, the actions on an agent (declare a state, link an
// (mara: "agentspage is now giant and deserves a split"). Filtering + // account) in `AgentActions.tsx`, the card content in `AgentCard.tsx` and
// cards-vs-table rendering is `FilterableView` (`ui/filterable-view/`, // the detail panel's body in `AgentDetail.tsx` (mara: "agentspage is now
// see its own comment) — `viewMode` here just says which one to show. // 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 { useState } from "preact/hooks";
import type { ComponentChildren } from "preact";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Badge } from "@hive/shared/badge.js"; import { Badge } from "@hive/shared/badge.js";
import { LinkIcon } from "@hive/shared/icons.js"; import { LinkIcon } from "@hive/shared/icons.js";
import { useAgentActions } from "./AgentActions.js";
import { AgentCard } from "./AgentCard.js"; import { AgentCard } from "./AgentCard.js";
import { AgentTermPreview } from "./AgentTermPreview.js"; import { AgentDetail } from "./AgentDetail.js";
import { SubagentTerms } from "./SubagentTerms.js";
import { FRESHNESS, type AgentRow } from "./AgentTypes.js"; import { FRESHNESS, type AgentRow } from "./AgentTypes.js";
import { AgentInference } from "./AgentInference.js";
import { LinkedAccounts } from "./LinkedAccounts.js";
import { LinkAccountMenu } from "./LinkAccountMenu.js";
import { Button } from "../../ui/button/Button.js"; import { Button } from "../../ui/button/Button.js";
import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
import { Dialog } from "../../ui/dialog/Dialog.js"; import { Dialog } from "../../ui/dialog/Dialog.js";
import { FilterableView } from "../../ui/filterable-view/FilterableView.js"; import { FilterableView } from "../../ui/filterable-view/FilterableView.js";
import { Panel } from "../../ui/panel/Panel.js"; import { Panel } from "../../ui/panel/Panel.js";
@ -51,46 +48,9 @@ import {
import { SplitView } from "../../ui/split-view/SplitView.js"; import { SplitView } from "../../ui/split-view/SplitView.js";
import { type TableColumn } from "../../ui/table/Table.js"; import { type TableColumn } from "../../ui/table/Table.js";
import { CreateAgentForm } from "../CreateAgentForm.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 "./WantedMenu.js";
import "./AgentsPage.css"; 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) => (
<p>
Declare <strong>{agentName}</strong> offline? The hive brings its
container down on its next reconcile sweep.
</p>
),
},
paused: {
label: "pause agent",
confirmLabel: "pause",
message: (agentName) => (
<p>
Declare <strong>{agentName}</strong> 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.
</p>
),
},
};
// Same 30s default + same reasoning as HivesPage: no inputs on this page // 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 // for a refresh to clobber, so the out-of-the-box behaviour should just
// solve staleness rather than require an opt-in every visit. // 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() { export function AgentsPage() {
const [rows, setRows] = useState<AgentRow[] | null>(null); const [rows, setRows] = useState<AgentRow[] | null>(null);
// The row showing the "link a github account" dialog, same shape.
const [githubTarget, setGithubTarget] = useState<AgentRow | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null); const [error, setError] = useState<ProblemDetails | null>(null);
const [intervalMs, setIntervalMs] = const [intervalMs, setIntervalMs] =
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS); useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
@ -128,56 +86,24 @@ export function AgentsPage() {
} }
} }
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
// Per-agent, not one page-wide flag: one row's declare-in-flight // The PUT response is the hive's whole declaration set (every agent's
// shouldn't disable every other row's button. // `wanted` state) — same shape `GET /api/hives/{hive}/wanted` returns — so
const [pendingAgents, setPendingAgents] = useState<ReadonlySet<string>>( // patch just that field into the rows already on screen rather than firing
new Set(), // 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,
); );
const [actionErrors, setActionErrors] = useState< });
ReadonlyMap<string, ProblemDetails> });
>(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<AgentRow | null>(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<AgentRow | null>(null);
// The row showing the "link a forge account" dialog, same shape.
const [forgeTarget, setForgeTarget] = useState<AgentRow | null>(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();
};
// Which agent the detail panel shows, *by name* — not the `AgentRow` // Which agent the detail panel shows, *by name* — not the `AgentRow`
// object itself. Storing the row would snapshot it at selection time; // object itself. Storing the row would snapshot it at selection time;
// `rows` replaces its whole array on every `refresh()` and every // `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) })); 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 // `FilterableView`'s `columns` — the full field set (including
// per-column sort/filter, which drives both the table's own popovers // 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 // 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"], filterValues: (a) => [a.wanted ?? "no declaration"],
filterMode: "multiselect", filterMode: "multiselect",
render: (a) => { render: (a) => {
const err = actionErrors.get(a.name); const err = actions.actionErrors.get(a.name);
return ( return (
<> <>
<WantedMenu <WantedMenu
row={a} row={a}
pending={pendingAgents.has(a.name)} pending={actions.pendingAgents.has(a.name)}
portal portal
onSelectUp={selectUp} onSelectUp={actions.selectUp}
onSelectOffline={selectOffline} onSelectOffline={actions.selectOffline}
onSelectPaused={selectPaused} onSelectPaused={actions.selectPaused}
onDestroy={setDestroyTarget} onDestroy={actions.requestDestroy}
/> />
{err ? ( {err ? (
<Badge <Badge
@ -398,7 +231,7 @@ export function AgentsPage() {
variant="quiet" variant="quiet"
icon={<LinkIcon />} icon={<LinkIcon />}
value="link account" value="link account"
onClick={a.hive ? () => setMatrixTarget(a) : undefined} onClick={a.hive ? () => actions.openLink(a, "matrix") : undefined}
disabled={!a.hive} disabled={!a.hive}
title={ title={
a.hive a.hive
@ -493,12 +326,12 @@ export function AgentsPage() {
renderCard={(a) => ( renderCard={(a) => (
<AgentCard <AgentCard
row={a} row={a}
pending={pendingAgents.has(a.name)} pending={actions.pendingAgents.has(a.name)}
error={actionErrors.get(a.name)} error={actions.actionErrors.get(a.name)}
selected={detailTargetName === a.name} selected={detailTargetName === a.name}
onSelectUp={selectUp} onSelectUp={actions.selectUp}
onSelectOffline={selectOffline} onSelectOffline={actions.selectOffline}
onSelectPaused={selectPaused} onSelectPaused={actions.selectPaused}
onOpenDetail={(row) => setDetailTargetName(row.name)} onOpenDetail={(row) => setDetailTargetName(row.name)}
/> />
)} )}
@ -520,114 +353,7 @@ export function AgentsPage() {
icon="🔎" icon="🔎"
> >
{detailTarget ? ( {detailTarget ? (
<div class="ui-agent-detail"> <AgentDetail row={detailTarget} actions={actions} withTerminal />
<dl class="ui-agent-detail-fields">
<dt>status</dt>
<dd>
{(() => {
const { tone, label } = FRESHNESS[detailTarget.freshness];
return (
<Badge
tone={tone}
value={
<>
{label}
{detailTarget.last_seen_unix !== null ? (
<>
{" "}
(
<RelativeTime
epochMs={detailTarget.last_seen_unix * 1000}
/>
)
</>
) : null}
</>
}
/>
);
})()}
</dd>
<dt>message</dt>
<dd>{detailTarget.snapshot?.status_text ?? "—"}</dd>
<dt>wanted</dt>
<dd>
{/* Full menu (destroy included) — mara: "destroy is
already available via wanted state", no separate
button needed. */}
<WantedMenu
row={detailTarget}
pending={pendingAgents.has(detailTarget.name)}
onSelectUp={selectUp}
onSelectOffline={selectOffline}
onSelectPaused={selectPaused}
onDestroy={setDestroyTarget}
/>
</dd>
<dt>hive</dt>
<dd>{detailTarget.hive ?? "—"}</dd>
<dt>config PR</dt>
<dd>
{detailTarget.config_pr ? (
<Badge
tone="warning"
value={
detailTarget.config_pr.html_url ? (
<a
href={detailTarget.config_pr.html_url}
target="_blank"
rel="noreferrer"
>
#{detailTarget.config_pr.pr_number}
</a>
) : (
`#${detailTarget.config_pr.pr_number}`
)
}
/>
) : (
"—"
)}
</dd>
<dt>inference</dt>
<dd>
<AgentInference
key={detailTarget.name}
agent={detailTarget.name}
/>
</dd>
<dt>accounts</dt>
<dd>
{detailTarget.hive ? (
<LinkedAccounts
key={`${detailTarget.hive}/${detailTarget.name}`}
hive={detailTarget.hive}
agent={detailTarget.name}
version={linkVersion}
/>
) : (
"—"
)}
</dd>
</dl>
<div class="ui-agent-detail-actions">
<LinkAccountMenu
agent={detailTarget.name}
hasHive={detailTarget.hive !== null}
onSelect={(kind) => {
if (kind === "matrix") setMatrixTarget(detailTarget);
else if (kind === "forge") setForgeTarget(detailTarget);
else setGithubTarget(detailTarget);
}}
/>
</div>
{/* The agent's live terminal and its input box. */}
<AgentTermPreview agentName={detailTarget.name} />
<SubagentTerms
key={detailTarget.name}
agentName={detailTarget.name}
/>
</div>
) : ( ) : (
<p class="ui-agents-empty">select an agent to see its details</p> <p class="ui-agents-empty">select an agent to see its details</p>
)} )}
@ -642,83 +368,7 @@ export function AgentsPage() {
> >
<CreateAgentForm onClose={() => setCreateOpen(false)} /> <CreateAgentForm onClose={() => setCreateOpen(false)} />
</Dialog> </Dialog>
<Dialog {actions.dialogs}
open={matrixTarget !== null}
onClose={closeMatrix}
label="link a matrix account"
plain
>
{/* `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 ? (
<LinkMatrixAccountForm
hive={matrixTarget.hive}
agent={matrixTarget.name}
onClose={closeMatrix}
/>
) : null}
</Dialog>
<Dialog
open={forgeTarget !== null}
onClose={closeForge}
label="link a forge account"
plain
>
{forgeTarget?.hive ? (
<LinkForgeAccountForm
hive={forgeTarget.hive}
agent={forgeTarget.name}
onClose={closeForge}
/>
) : null}
</Dialog>
<Dialog
open={githubTarget !== null}
onClose={closeGithub}
label="link a github account"
plain
>
{githubTarget?.hive ? (
<LinkGithubAccountForm
hive={githubTarget.hive}
agent={githubTarget.name}
onClose={closeGithub}
/>
) : null}
</Dialog>
<ConfirmDialog
open={confirmTarget !== null}
label={confirmTarget ? CONFIRM_COPY[confirmTarget.state].label : ""}
onCancel={() => setConfirmTarget(null)}
onConfirm={() => void confirmDeclare()}
confirmLabel={
confirmTarget
? CONFIRM_COPY[confirmTarget.state].confirmLabel
: undefined
}
>
{confirmTarget
? CONFIRM_COPY[confirmTarget.state].message(confirmTarget.row.name)
: null}
</ConfirmDialog>
<ConfirmDialog
open={destroyTarget !== null}
label="destroy agent"
onCancel={() => setDestroyTarget(null)}
onConfirm={() => destroyTarget && void destroyAgent(destroyTarget)}
confirmLabel="destroy"
>
{destroyTarget ? (
<p>
Destroy <strong>{destroyTarget.name}</strong>? 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.
</p>
) : null}
</ConfirmDialog>
</> </>
); );
} }