Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
32 changed files with 472 additions and 842 deletions

View file

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

View file

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

View file

@ -1,38 +0,0 @@
// <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

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

@ -1,135 +0,0 @@
// <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 { 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 (
<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}
/>
{/* A failed declaration shows here too: on a terminal page's
dialog there is no roster card to carry the "failed" badge. */}
{declareError ? (
<ApiErrorPanel
context="the declaration failed"
problem={declareError}
/>
) : null}
</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

@ -1,93 +0,0 @@
// <AgentDetailPopover> — 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<HTMLElement>;
}) {
// `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 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 (
<>
<Popover
open={open}
onClose={onClose}
anchorRef={anchorRef}
label={`${agent} details`}
>
<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>
</Popover>
{actions.dialogs}
</>
);
}

View file

@ -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) => (
<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

@ -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<HTMLSpanElement>(null);
return (
<>
<span ref={anchorRef}>
<Button onClick={() => setOpen((o) => !o)}>toggle popover</Button>
</span>
<Popover
open={open}
onClose={() => setOpen(false)}
anchorRef={anchorRef}
label="sample popover"
>
<Panel title="a popover" icon="🔗" onClose={() => setOpen(false)}>
<p>
Floats under its trigger and leaves the page behind it clickable.
Outside click or Escape closes it.
</p>
</Panel>
</Popover>
</>
);
}
// `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() {
</Sample>
</Section>
<Section title="Popover">
<Sample label="anchored, no backdrop, content supplies its own card">
<PopoverSample />
</Sample>
</Section>
<Section title="ConfirmDialog">
<Sample label="modal, cancel/confirm button row">
<ConfirmDialogSample />

View file

@ -0,0 +1,58 @@
/* <AgentCard> content layout — the clickable/selectable container
itself is `Card`'s (`ui/card/`); this is just the name/badges/
message arrangement inside one. */
.ui-agent-card-line1 {
display: flex;
align-items: center;
gap: 0.6em;
}
.ui-agent-card-name {
font-weight: 600;
}
/* Pushes the wanted-state control to the row's trailing edge regardless
of how wide the status badge next to it ends up. */
.ui-agent-card-wanted {
display: inline-flex;
align-items: center;
gap: 0.4em;
margin-left: auto;
}
.ui-agent-card-message {
color: var(--muted);
white-space: normal;
word-break: break-word;
}
/* 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;
gap: 0.7em;
}
.ui-agent-card-body {
display: flex;
flex-direction: column;
gap: 0.35em;
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;
}

View file

@ -0,0 +1,130 @@
// <AgentCard> — one card per roster agent (mara: "main view: name,
// status, message, wanted" / "message as second line" / "more like
// card per agent") — the FilterableView `view === "cards"` alternative
// to the original `Table`, not its replacement (see `AgentsPage`'s own
// comment). Clicking a card opens a detail panel repeating this same
// info plus what doesn't fit here (hive, config-PR link, matrix
// link-account) — mara: "the info from main list should be included in
// the agent view" too, not just the leftovers.
//
// Built on the shared `Card` primitive (`ui/card/`) for the clickable/
// selectable-container mechanics — the `stopPropagation` wrapper around
// `WantedMenu` below keeps its own clicks (mouse or keyboard-synthesized,
// `WantedMenu` hosts real `<button>`s) from also firing `Card`'s
// `onClick` and opening the detail panel.
import { useState } from "preact/hooks";
import { Badge } from "@hive/shared/badge.js";
import type { ProblemDetails } from "@hive/shared/api-error.js";
import { Card } from "../../ui/card/Card.js";
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
import { FRESHNESS, type AgentRow } from "./AgentTypes.js";
import { WantedMenu } from "./WantedMenu.js";
import "./AgentCard.css";
export function AgentCard({
row,
pending,
error,
selected,
onSelectUp,
onSelectOffline,
onSelectPaused,
onOpenDetail,
}: {
row: AgentRow;
pending: boolean;
error: ProblemDetails | undefined;
/** This row is the one currently shown in the detail panel — a
* highlight only (the panel itself is the source of truth), so a
* page-refresh landing on a still-valid selection reads as obviously
* "that one" rather than a silent selection nothing marks. */
selected: boolean;
onSelectUp: (row: AgentRow) => void;
onSelectOffline: (row: AgentRow) => void;
onSelectPaused: (row: AgentRow) => void;
onOpenDetail: (row: AgentRow) => void;
}) {
const { tone, label } = FRESHNESS[row.freshness];
return (
<Card selected={selected} onClick={() => onOpenDetail(row)}>
<div class="ui-agent-card-row">
<AgentIcon name={row.name} />
<div class="ui-agent-card-body">
<div class="ui-agent-card-line1">
<span class="ui-agent-card-name">{row.name}</span>
<Badge
tone={tone}
title={
row.freshness === "unknown"
? "reported by its hive but not registered in swarm-level identity — needs migration"
: undefined
}
value={
<>
{label}
{row.last_seen_unix !== null ? (
<>
{" "}
(<RelativeTime epochMs={row.last_seen_unix * 1000} />)
</>
) : null}
</>
}
/>
<span
class="ui-agent-card-wanted"
onClick={(e) => e.stopPropagation()}
>
<WantedMenu
row={row}
pending={pending}
showDestroy={false}
onSelectUp={onSelectUp}
onSelectOffline={onSelectOffline}
onSelectPaused={onSelectPaused}
/>
{error ? (
<Badge
tone="negative"
value="failed"
title={error.detail ?? "the declaration failed"}
/>
) : null}
</span>
</div>
<div class="ui-agent-card-message">
{row.snapshot?.status_text ?? "—"}
</div>
</div>
</div>
</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,40 @@
/* <AgentInference> — one setting per line: badge, then where it comes from. */
.ui-agent-inference {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.3em;
}
.ui-agent-inference-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.3em;
}
.ui-agent-inference-list li {
display: flex;
align-items: center;
gap: 0.5em;
flex-wrap: wrap;
}
.ui-agent-inference-source,
.ui-agent-inference-muted {
color: var(--muted);
}
.ui-agent-inference-form {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.75em;
margin-top: 1em;
}
.ui-agent-inference-problem {
margin: 0;
color: var(--red);
}
.ui-agent-inference-actions {
display: flex;
align-items: center;
gap: 0.75em;
}

View file

@ -0,0 +1,301 @@
// <AgentInference> — one agent's inference settings on the detail panel: the
// model and effort it runs on now, with where each came from, and an editor
// for the value the swarm sets.
//
// Reads `GET /api/agents/{name}/inference`, which answers
// `{ configured, effective }`: `configured` is the swarm's stored value (null
// when none), `effective` what the agent reported running on (null until its
// first report reaches the controller). The editor PUTs the whole value and
// the clear action DELETEs it; a cleared field puts the agent back on its own
// value, which is why an empty field is left out of the body rather than sent.
//
// Its state belongs to one agent: callers key it by agent name, so switching
// agents remounts it instead of showing the previous agent's settings.
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 { Badge } from "@hive/shared/badge.js";
import { Dialog } from "../../ui/dialog/Dialog.js";
import { Panel } from "../../ui/panel/Panel.js";
import { SelectField } from "../../ui/select-field/SelectField.js";
import { TextField } from "../../ui/text-field/TextField.js";
import { Button } from "../../ui/button/Button.js";
import "./AgentInference.css";
type Source = "swarm" | "agent_nix" | "local_file" | "default";
// Mirrors the swarm's `InferenceSettings`; every field is optional.
interface Settings {
model?: string;
effort?: string;
subagent_models?: string[];
}
// Mirrors `EffectiveInference`.
interface Effective {
model: string;
effort: string;
model_source: Source;
effort_source: Source;
}
interface View {
configured: Settings | null;
effective: Effective | null;
}
// The values the swarm accepts for `effort`.
const EFFORTS = ["low", "medium", "high", "xhigh", "max"];
const EFFORT_OPTIONS = [
{ value: "", label: "the agent's own" },
...EFFORTS.map((e) => ({ value: e, label: e })),
];
const SOURCE_LABEL: Record<Source, string> = {
swarm: "swarm",
agent_nix: "agent.nix",
local_file: "its own pick",
default: "default",
};
// The same checks the swarm makes on a model name, so an obviously bad one
// is refused before the round trip; the swarm stays the real gate.
function modelNameProblem(name: string): string | null {
if (/\s/.test(name)) return "a model name has no whitespace";
if (name.startsWith("-")) return "a model name does not start with -";
return null;
}
// Subagent models are typed as one list, split on commas and whitespace.
function parseModels(text: string): string[] {
return text.split(/[\s,]+/).filter((m) => m !== "");
}
function inferenceUrl(agent: string): string {
return `/api/agents/${encodeURIComponent(agent)}/inference`;
}
export function AgentInference({ agent }: { agent: string }) {
const [view, setView] = useState<View | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
// Bumped after a save or a clear, so the settings are fetched again.
const [reload, setReload] = useState(0);
const [editing, setEditing] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
const r = await fetch(inferenceUrl(agent));
if (!r.ok) {
if (!cancelled) setError(await readApiError(r));
return;
}
const body = (await r.json()) as Partial<View>;
if (cancelled) return;
setView({
configured: body.configured ?? null,
effective: body.effective ?? null,
});
setError(null);
})().catch((e: unknown) => {
if (!cancelled) setError({ detail: String(e) });
});
return () => {
cancelled = true;
};
}, [agent, reload]);
if (error) {
return (
<Badge
tone="negative"
value="inference unavailable"
title={error.detail ?? "reading the inference settings failed"}
/>
);
}
if (view === null) return <span class="ui-agent-inference-muted">…</span>;
const { configured, effective } = view;
const swarmModels = configured?.subagent_models ?? [];
return (
<div class="ui-agent-inference">
<ul class="ui-agent-inference-list">
<li>
{effective ? (
<>
<Badge label="model" value={effective.model} />
<span class="ui-agent-inference-source">
{SOURCE_LABEL[effective.model_source]}
</span>
</>
) : (
<Badge
label="model"
value={configured?.model ?? "its own"}
title="the agent has not reported what it runs on yet"
/>
)}
</li>
<li>
{effective ? (
<>
<Badge label="effort" value={effective.effort} />
<span class="ui-agent-inference-source">
{SOURCE_LABEL[effective.effort_source]}
</span>
</>
) : (
<Badge
label="effort"
value={configured?.effort ?? "its own"}
title="the agent has not reported what it runs on yet"
/>
)}
</li>
<li>
<Badge
label="subagent models"
value={swarmModels.length > 0 ? swarmModels.join(", ") : "its own"}
/>
</li>
</ul>
<Badge
variant="quiet"
value="edit"
title={`set the swarm's model, effort and subagent models for ${agent}`}
onClick={() => setEditing(true)}
/>
<Dialog
open={editing}
onClose={() => setEditing(false)}
label="edit inference settings"
plain
>
{/* Remounted per open, so a cancelled edit leaves nothing behind. */}
{editing ? (
<InferenceForm
agent={agent}
configured={configured}
onClose={() => setEditing(false)}
onSaved={() => {
setEditing(false);
setReload((n) => n + 1);
}}
/>
) : null}
</Dialog>
</div>
);
}
function InferenceForm({
agent,
configured,
onClose,
onSaved,
}: {
agent: string;
configured: Settings | null;
onClose: () => void;
onSaved: () => void;
}) {
const [model, setModel] = useState(configured?.model ?? "");
const [effort, setEffort] = useState(configured?.effort ?? "");
const [subagents, setSubagents] = useState(
(configured?.subagent_models ?? []).join(", "),
);
const [busy, setBusy] = useState(false);
const [problem, setProblem] = useState<ProblemDetails | null>(null);
const subagentList = parseModels(subagents);
const modelProblem =
modelNameProblem(model.trim()) ??
subagentList.map(modelNameProblem).find((p) => p !== null) ??
null;
const empty =
model.trim() === "" && effort === "" && subagentList.length === 0;
async function send(init: RequestInit) {
setBusy(true);
try {
const r = await fetch(inferenceUrl(agent), init);
if (!r.ok) {
setProblem(await readApiError(r));
return;
}
onSaved();
} catch (e: unknown) {
setProblem({ detail: String(e) });
} finally {
setBusy(false);
}
}
function save(e: Event) {
e.preventDefault();
const body: Settings = {};
if (model.trim() !== "") body.model = model.trim();
if (effort !== "") body.effort = effort;
if (subagentList.length > 0) body.subagent_models = subagentList;
void send({
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
return (
<Panel title={`inference — ${agent}`} icon="🧠" onClose={onClose}>
<p>
Sets what the swarm makes {agent} run on. A field left empty stays the
agent's own value. The agent picks a change up within seconds; a turn
already running finishes on what it started with.
</p>
<form class="ui-agent-inference-form" onSubmit={save}>
<TextField
id="inference-model"
label="model"
value={model}
placeholder="e.g. opus"
onInput={setModel}
/>
<SelectField
id="inference-effort"
label="effort"
value={effort}
options={EFFORT_OPTIONS}
onChange={setEffort}
/>
<TextField
id="inference-subagent-models"
label="subagent models"
value={subagents}
placeholder="e.g. haiku, sonnet"
onInput={setSubagents}
/>
{modelProblem ? (
<p class="ui-agent-inference-problem">{modelProblem}</p>
) : null}
{problem ? (
<ApiErrorPanel context="saving failed" problem={problem} />
) : null}
<div class="ui-agent-inference-actions">
<Button
variant="primary"
type="submit"
disabled={busy || empty || modelProblem !== null}
>
{busy ? "saving…" : "save"}
</Button>
<Button
disabled={busy || configured === null}
onClick={() => void send({ method: "DELETE" })}
>
clear the swarm's setting
</Button>
</div>
</form>
</Panel>
);
}

View file

@ -0,0 +1,17 @@
/* <AgentInputForm> — one row: the text field takes the width, the send
button sits at its baseline. */
.ui-agent-input {
margin-block-start: 0.5rem;
}
.ui-agent-input-form {
display: flex;
align-items: flex-end;
gap: 0.5em;
}
.ui-agent-input-form > :first-child {
flex: 1;
}
.ui-agent-input-sent {
margin-block-start: 0.25rem;
color: var(--green);
}

View file

@ -0,0 +1,70 @@
// <AgentInputForm> — text for one agent, under its swarm terminal.
// POSTs `/api/agents/{name}/input`: 202 once the agent's harness holds the
// message, 503 when it is not listening, problem+json otherwise. The agent
// sees it as an inbox turn from the logged-in user (the controller takes the
// sender from the gateway, not from this request).
import { useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { TextField } from "../../ui/text-field/TextField.js";
import { Button } from "../../ui/button/Button.js";
import "./AgentInputForm.css";
type SubmitState =
| { status: "idle" }
| { status: "submitting" }
| { status: "sent" }
| { status: "error"; problem: ProblemDetails };
export function AgentInputForm({ agentName }: { agentName: string }) {
const [body, setBody] = useState("");
const [result, setResult] = useState<SubmitState>({ status: "idle" });
async function submit(e: Event) {
e.preventDefault();
setResult({ status: "submitting" });
try {
const r = await fetch(
`/api/agents/${encodeURIComponent(agentName)}/input`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ body }),
},
);
if (!r.ok) {
setResult({ status: "error", problem: await readApiError(r) });
return;
}
setResult({ status: "sent" });
setBody("");
} catch (err) {
setResult({ status: "error", problem: { detail: String(err) } });
}
}
return (
<div class="ui-agent-input">
<form class="ui-agent-input-form" onSubmit={submit}>
<TextField
id={`agent-input-${agentName}`}
label={`message ${agentName}`}
value={body}
required
onInput={setBody}
/>
<Button
variant="primary"
type="submit"
disabled={result.status === "submitting"}
>
{result.status === "submitting" ? "sending…" : "send"}
</Button>
</form>
{result.status === "sent" && <p class="ui-agent-input-sent">sent</p>}
{result.status === "error" && (
<ApiErrorPanel context="failed to send" problem={result.problem} />
)}
</div>
);
}

View file

@ -0,0 +1,117 @@
/* <AgentTermPreview> — a *preview*, not the full terminal: a much
shorter cap than @hive/shared/terminal.css's own 32em default so it
reads as "peek at what's happening" inside the detail panel rather
than competing with the panel's own fields for vertical space. */
@import "@hive/shared/terminal.css";
.ui-agent-term-preview {
margin-block-start: 1rem;
}
.ui-agent-term-preview-wrap.terminal-wrap {
max-height: 18em;
}
/* `AgentTerminalPage`'s full-tab mount (`fullHeight` prop → the
`-full` modifier on the OUTER `.ui-agent-term-preview` root, not the
wrap) — self-contained flex chain, not dependent on the page around
it doing anything beyond giving this root a real height to fill (see
AgentTerminalPage.css). Column flex so the input row keeps its natural
height and the terminal-wrap above it takes the rest; `flex: 1` +
`min-height: 0` on the wrap (not `height: 100%`) is deliberate — a
flex item's `height` percentage doesn't account for a sibling
(the input row) already consuming space the way `flex: 1` does, and
`min-height: 0` is what actually lets the item shrink below its
content's natural height so `.live.terminal`'s own `overflow-y: auto`
(terminal.css) scrolls instead of the whole column growing past its
container. `!important`-free — the `max-height: none` overrides
below already carry one more class in the selector than the
un-modified rules above (`.ui-agent-term-preview-full` prefixing
each), so they're strictly more specific and win regardless of
source order. */
.ui-agent-term-preview-full {
display: flex;
flex-direction: column;
margin-block-start: 0;
}
.ui-agent-term-preview-full .ui-agent-term-preview-wrap.terminal-wrap {
max-height: none;
flex: 1;
min-height: 0;
}
.ui-agent-term-preview-full .ui-agent-term-preview-wrap .live.terminal {
max-height: none;
height: 100%;
/* One badge row: the full tab is wide enough that the badges never wrap. */
padding-block-start: 3.4em;
}
/* Flush to the header bar and the viewport sides; the input row below
gets its own padding so it doesn't touch the screen edge. */
.ui-agent-term-preview-full .ui-agent-term-preview-wrap.terminal-wrap {
margin: 0;
}
.ui-agent-term-preview-full .ui-agent-input {
padding: 0.4em 0.75em 0.6em;
}
/* `box-sizing: border-box`, not the browser's default `content-box` —
`.live.terminal`'s own `padding: 0.8em 1em 0.4em` (terminal.css) is
otherwise added ON TOP of this `max-height`, so the box renders
~1.2em taller than intended and spills past `.terminal-wrap`'s own
`max-height` above (which has no padding of its own to inflate it)
since `.terminal-wrap` has no `overflow: hidden` to catch the excess
— mara: "the terminal text bleeds out at the bottom of the terminal".
Confirmed via measured `clientHeight` (194px vs the wrap's 177px, a
~17px gap matching 1.2em at this font size) before this fix, not
just reasoning about the CSS. */
.ui-agent-term-preview-wrap .live.terminal {
max-height: 18em;
box-sizing: border-box;
/* Extra top padding (base `.live.terminal` rule: `0.8em 1em 0.4em`) so
the floating badge cluster below has room to sit above the first
row's text instead of overlapping it — found by actually rendering
this: with the base padding, "thinking"/"model sonnet" sat directly
on top of the terminal's first line whenever content started right
at the top (a running preview scrolled to the newest rows almost
always does). Sized for two badge rows, since the badges wrap in the
narrow details panel; each keeps the 2.75em touch-target floor. */
padding-block-start: 7.4em;
}
/* The floating row over the terminal's top edge: state badges (connection
first, then turn_state/agent_state/model/ctx/cost) on the left, the
expand trigger on the right. Positioned by `.terminal-wrap`'s own
`position: relative`, same anchor `@hive/shared/terminal.css`'s
`.tail-pill` uses, which anchors bottom-right and so never competes for
this edge. The row itself takes no pointer events, so the terminal
under its empty middle still scrolls and selects; only the badges and
the trigger do. `flex-wrap` on the badges rather than a fixed width —
at small viewport widths wrapping beats clipping. */
.ui-agent-term-preview-controls {
position: absolute;
top: 0.4em;
left: 0.4em;
right: 0.4em;
z-index: 1;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5em;
pointer-events: none;
}
.ui-agent-term-preview-controls > * {
pointer-events: auto;
}
.ui-agent-term-preview-header-badges {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25em;
min-width: 0;
}
/* Every badge in the row the same height as the expand trigger, which
keeps an interactive badge's 2.75em touch-target floor. */
.ui-agent-term-preview-controls .ui-badge {
min-height: 2.75em;
}

View file

@ -0,0 +1,282 @@
// <AgentTermPreview> — the MVP mara's own ruling asked for: not a separate
// page, a small preview embedded below `AgentsPage`'s detail panel fields, with
// an `AgentInputForm` under an agent's terminal (a subagent's has none: it
// takes no input of its own). Consumes swarm-controller's
// `GET /api/agents/{name}/term/stream`, or for a subagent
// `GET /api/agents/{name}/subagents/{subagent}/term/stream`, via
// `useSwarmTermStream`, rendering through the same `@hive/shared` `Row`
// component the per-hive local terminal uses — "share components between hive and swarm level
// term as much as possible" (mara).
//
// **Floating header badges** — turn state / model / ctx / cost parity
// with the per-agent page's own `StatusChips` — mara: "dont make it a
// header though - make it floating elements on top of the terminal", so
// this is a small `Badge` cluster pinned to the terminal box's own
// corner (`.terminal-wrap`'s `position: relative`, same anchor
// `@hive/shared/terminal.css`'s `.tail-pill` already uses), not a
// separate layout row. Read-only, no click targets: pause/resume already
// has its own control surface (`WantedMenu` on `AgentsPage`), so nothing
// here needs to be interactive. Sourced from `useSwarmAgentStateStream`,
// not from `AgentRow` — the roster fetch `AgentsPage` already did has no
// notion of turn_state/model/ctx/cost, only `wanted`/`snapshot.running`.
// `showHeaderBadges` (default true) exists for a caller that already
// shows this info elsewhere and wants this cluster suppressed — checked
// against the original plan for this page, which proposed exactly that
// for `AgentsPage`'s embedded usage; that page's detail panel turned out
// NOT to duplicate any of this (`AgentRow`/`AgentTypes.ts` carry none of
// turn_state/model/ctx/cost), so `AgentsPage` leaves this at its default
// rather than hiding the only place swarm-ui shows it — see the commit
// message for the full reasoning. The prop still exists because
// suppressing it is a real, reusable need in principle, just not here.
// **Expand-to-fullscreen trigger** — opens/navigates to
// `/agents/:name/terminal` (`AgentTerminalPage`) via `useDynamicTabs`,
// same header-tab mechanism `Shell`'s own nav row now shares. Lives here
// (not on `AgentsPage`) so both the embedded preview and the future full
// page mount the identical trigger without either needing to know where
// the other one is. Suppressed via `fullHeight` — the full-page mount
// already IS the destination, so it has nothing further to expand to.
import { useLayoutEffect, useRef, useState } from "preact/hooks";
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
import { ExpandIcon } from "@hive/shared/icons.js";
import { Row } from "@hive/shared/term-row.js";
import { useDynamicTabs } from "../../shell/useDynamicTabs.js";
import {
useSwarmTermStream,
type ConnectionState,
} from "./useSwarmTermStream.js";
import {
useSwarmAgentStateStream,
type TokenUsage,
} from "./useSwarmAgentStateStream.js";
import { AgentInputForm } from "./AgentInputForm.js";
import "./AgentTermPreview.css";
const NEAR_BOTTOM_PX = 48;
const CONNECTION_LABEL: Record<ConnectionState, string> = {
connecting: "connecting…",
open: "live",
reconnecting: "reconnecting…",
closed: "disconnected",
};
const CONNECTION_TONE: Record<ConnectionState, BadgeTone> = {
connecting: "neutral",
open: "positive",
reconnecting: "warning",
closed: "negative",
};
// Ported, not imported, same call `useSwarmTermStream`'s own `appendRow`
// already made for this file's sibling hook: `@hive/agent`'s equivalents
// (`STATE_LABELS`/`tokenTotal`/`fmtTokens` in `Root.tsx`/`lib/format.ts`)
// live in a different package this one doesn't otherwise depend on, and
// a cross-package hop isn't worth it for three small, stable helpers.
// Vocabulary (glyphless — the per-agent page's own glyphs are part of
// its bigger consolidated status badge, not this smaller read-only one)
// kept in exact parity with `Root.tsx`'s `STATE_LABELS` so "idle" here
// means the same thing it means there.
const TURN_STATE_TONE: Record<string, BadgeTone> = {
idle: "neutral",
thinking: "accent",
compacting: "warning",
};
function tokenTotal(u: TokenUsage | null): number | null {
if (!u) return null;
// Matches `Root.tsx`'s own `tokenTotal`: input + both cache buckets,
// `output_tokens` deliberately excluded (shown in a title breakdown
// there, not carried at all here — this badge has no breakdown to put
// it in).
return (
(u.input_tokens ?? 0) +
(u.cache_read_input_tokens ?? 0) +
(u.cache_creation_input_tokens ?? 0)
);
}
function fmtTokens(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
if (n >= 1_000) return Math.round(n / 1000) + "k";
return String(n);
}
// The paths one terminal is reached at. A subagent's terminal sits under its
// agent's, and has no turn-state header: the swarm relays none for it.
interface TermPaths {
label: string;
page: string;
stream: string;
state: string | null;
}
export function termPaths(agentName: string, subagent?: string): TermPaths {
const agent = `/agents/${encodeURIComponent(agentName)}`;
if (subagent === undefined) {
return {
label: agentName,
page: `${agent}/terminal`,
stream: `/api${agent}/term/stream`,
state: `/api${agent}/state/stream`,
};
}
const sub = `${agent}/subagents/${encodeURIComponent(subagent)}`;
return {
label: `${agentName}/${subagent}`,
page: `${sub}/terminal`,
stream: `/api${sub}/term/stream`,
state: null,
};
}
// Turn state, agent state, model, ctx and cost, as the badges beside the
// connection one. Siblings only, no wrapper: they sit in the caller's row.
function StateBadges({
header,
ctx,
cost,
}: {
header: NonNullable<ReturnType<typeof useSwarmAgentStateStream>["header"]>;
ctx: number | null;
cost: number | null;
}) {
return (
<>
<Badge
tone={TURN_STATE_TONE[header.turn_state] ?? "neutral"}
value={header.turn_state}
/>
{/* Only the non-boring case gets a badge — a healthy running
agent (`agent_state: "up"`) doesn't need one for the
obvious default. In practice this only ever fires for
"paused": `hive-agent` can only honestly publish "up" or
"paused" on this subject (see `AgentStateMsg::agent_state`'s
own doc), never "offline"/"destroyed" — a stopped or
destroyed agent publishes nothing at all. */}
{header.agent_state !== "up" && (
<Badge tone="warning" value={header.agent_state} />
)}
<Badge
label="model"
value={header.model}
title={
header.resolved_model
? `resolved to ${header.resolved_model}`
: undefined
}
/>
{ctx !== null && (
<Badge
label="ctx"
value={fmtTokens(ctx)}
title="tokens used in the current context window"
/>
)}
{cost !== null && (
<Badge
label="cost"
value={fmtTokens(cost)}
title="cumulative tokens billed across the last turn"
/>
)}
</>
);
}
export function AgentTermPreview({
agentName,
subagent,
showHeaderBadges = true,
fullHeight = false,
}: {
agentName: string;
// Set: this is one of `agentName`'s subagents, output only like the
// agent's own.
subagent?: string;
showHeaderBadges?: boolean;
fullHeight?: boolean;
}) {
const paths = termPaths(agentName, subagent);
const { rows, connection } = useSwarmTermStream(paths.stream);
const { header } = useSwarmAgentStateStream(paths.state);
const { open: openTab } = useDynamicTabs();
const logRef = useRef<HTMLDivElement>(null);
const [stickToBottom, setStickToBottom] = useState(true);
const ctx = header ? tokenTotal(header.ctx_usage) : null;
const cost = header ? tokenTotal(header.cost_usage) : null;
function handleScroll() {
const el = logRef.current;
if (!el) return;
setStickToBottom(
el.scrollHeight - el.scrollTop - el.clientHeight <= NEAR_BOTTOM_PX,
);
}
// Runs after Preact has committed new rows, so `scrollHeight` is
// already current — same ordering `@hive/agent`'s `LiveStream` uses.
useLayoutEffect(() => {
const el = logRef.current;
if (el && stickToBottom) el.scrollTop = el.scrollHeight - el.clientHeight;
}, [rows, stickToBottom]);
return (
<div
class={
"ui-agent-term-preview" +
(fullHeight ? " ui-agent-term-preview-full" : "")
}
>
<div class="terminal-wrap ui-agent-term-preview-wrap">
{/* One floating row over the terminal's top edge: the state
badges (connection first) on the left, the expand trigger on
the right. The full-tab mount is already the destination, so
it has no expand trigger. */}
<div class="ui-agent-term-preview-controls">
<div class="ui-agent-term-preview-header-badges">
<Badge
tone={CONNECTION_TONE[connection]}
value={CONNECTION_LABEL[connection]}
/>
{header && showHeaderBadges && (
<StateBadges header={header} ctx={ctx} cost={cost} />
)}
</div>
{!fullHeight && (
<Badge
icon={<ExpandIcon />}
value="expand"
title={`open ${paths.label}'s terminal in its own tab`}
onClick={() => openTab({ href: paths.page, label: paths.label })}
/>
)}
</div>
<div class="live terminal" ref={logRef} onScroll={handleScroll}>
{rows.length === 0 ? (
<div class="row note">
{/* Explicit `.row-content` wrapper, not a bare text child —
`.live .row`'s CSS grid (terminal.css) has a narrow
first column reserved for a row icon; a row's lone
child with no explicit `grid-column` auto-places into
that column instead of the content one, wrapping the
text one or two characters per line — mara reported
exactly this on the live "connecting…" placeholder.
`Row.tsx`'s own icon-less flat row already documents
and avoids this same trap. */}
<span class="row-content">
{connection === "open"
? "(connected — waiting for events)"
: CONNECTION_LABEL[connection]}
</span>
</div>
) : (
rows.map((row) => <Row key={row.key} row={row} />)
)}
</div>
</div>
{subagent === undefined && <AgentInputForm agentName={agentName} />}
</div>
);
}

View file

@ -9,16 +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, useRef, useState } from "preact/hooks";
import { useEffect } from "preact/hooks";
import type { RouteComponentProps } from "wouter-preact";
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 { AgentTermPreview, termPaths } from "./AgentTermPreview.js";
import { useAgentFavicon } from "../../shell/useAgentFavicon.js";
import { useDynamicTabs } from "../../shell/useDynamicTabs.js";
import "./AgentTerminalPage.css";
@ -48,13 +42,6 @@ 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<HTMLSpanElement>(null);
return (
<div class="ui-agent-terminal-page">
<AgentTermPreview
@ -62,23 +49,6 @@ export function AgentTerminalPage({
subagent={subagent}
fullHeight
showHeaderBadges
leading={
<span ref={badgeRef}>
<Badge
icon={<AgentIcon name={name} small />}
value={name}
title={`${detailOpen ? "close" : "open"} ${name}'s details`}
expanded={detailOpen}
onClick={() => setDetailOpen((o) => !o)}
/>
</span>
}
/>
<AgentDetailPopover
agent={name}
open={detailOpen}
onClose={() => setDetailOpen(false)}
anchorRef={badgeRef}
/>
</div>
);

View file

@ -0,0 +1,61 @@
// Wire + presentation types shared across AgentsPage and the components
// it composes (AgentCard, WantedMenu) — split out once those moved to
// their own files (mara: "agentspage is now giant and deserves a
// split") so none of them has to import from AgentsPage.tsx itself.
import type { BadgeTone } from "@hive/shared/badge.js";
export interface ConfigPrStatus {
pr_number: number;
html_url: string | null;
}
export type Freshness = "fresh" | "stale" | "never_reported" | "unknown";
export interface AgentStatusSnapshot {
status_text: string | null;
status_set_at: number | null;
running: boolean;
}
// `"up"` / `"offline"` / `"destroyed"`, wire-spelled by
// `swarm_queue_client::wanted::AgentState::as_str` — kept as a bare
// `string | null` rather than a union, same reason `config_pr`'s shape
// isn't re-derived here: this renders whatever the wire sends, it
// doesn't validate the enum client-side.
export type Wanted = string | null;
// Mirrors `agent_status::AgentStatusRow` field-for-field — this *is*
// the row AgentsPage renders, not a shape assembled from it, so
// there's no separate join-result type to keep in sync with the wire
// contract by hand.
export interface AgentRow {
name: string;
hive: string | null;
freshness: Freshness;
// `age_seconds` deliberately unused here, same reason as HivesPage:
// `RelativeTime` recomputes age client-side from `last_seen_unix`
// rather than rendering a once-computed-at-fetch value.
last_seen_unix: number | null;
snapshot: AgentStatusSnapshot | null;
config_pr: ConfigPrStatus | null;
wanted: Wanted;
}
// Same tone pairing as HivesPage — one freshness enum shared by both
// endpoints, so the same tone rule applies to both pages — but
// `unknown`'s label diverges deliberately: an agent outside the
// swarm-identity roster has a concrete next step (register it), while
// an unrecognized *hive* on HivesPage doesn't carry the same
// actionable meaning.
export const FRESHNESS: Record<Freshness, { tone: BadgeTone; label: string }> =
{
fresh: { tone: "positive", label: "fresh" },
stale: { tone: "warning", label: "stale" },
never_reported: { tone: "neutral", label: "never reported" },
// `unknown` means the agent reported into the KV bucket but isn't in
// the swarm-identity roster — a real, distinct case ("hive-reported,
// not yet in swarm level"), not just a fallback default, so it gets a
// label that says what to do about it rather than reusing the enum
// name as the label.
unknown: { tone: "negative", label: "not in swarm identity" },
};

View file

@ -36,3 +36,23 @@
.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;
}

View file

@ -18,24 +18,27 @@
// 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/`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.
// 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.
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 { 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 { 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 { 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";
@ -48,9 +51,46 @@ import {
import { SplitView } from "../../ui/split-view/SplitView.js";
import { type TableColumn } from "../../ui/table/Table.js";
import { CreateAgentForm } from "../CreateAgentForm.js";
import { WantedMenu } from "../../agent/roster/WantedMenu.js";
import { LinkMatrixAccountForm } from "../LinkMatrixAccountForm.js";
import { LinkForgeAccountForm } from "../LinkForgeAccountForm.js";
import { LinkGithubAccountForm } from "../LinkGithubAccountForm.js";
import { WantedMenu } from "./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) => (
<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
// for a refresh to clobber, so the out-of-the-box behaviour should just
// solve staleness rather than require an opt-in every visit.
@ -63,6 +103,8 @@ const VIEW_MODE_KEY = "swarm-ui:agents:view-mode";
export function AgentsPage() {
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 [intervalMs, setIntervalMs] =
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
@ -86,24 +128,56 @@ export function AgentsPage() {
}
}
const [createOpen, setCreateOpen] = useState(false);
// 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,
// 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<ReadonlySet<string>>(
new Set(),
);
});
});
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`
// object itself. Storing the row would snapshot it at selection time;
// `rows` replaces its whole array on every `refresh()` and every
@ -133,6 +207,99 @@ 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
@ -200,17 +367,17 @@ export function AgentsPage() {
filterValues: (a) => [a.wanted ?? "no declaration"],
filterMode: "multiselect",
render: (a) => {
const err = actions.actionErrors.get(a.name);
const err = actionErrors.get(a.name);
return (
<>
<WantedMenu
row={a}
pending={actions.pendingAgents.has(a.name)}
pending={pendingAgents.has(a.name)}
portal
onSelectUp={actions.selectUp}
onSelectOffline={actions.selectOffline}
onSelectPaused={actions.selectPaused}
onDestroy={actions.requestDestroy}
onSelectUp={selectUp}
onSelectOffline={selectOffline}
onSelectPaused={selectPaused}
onDestroy={setDestroyTarget}
/>
{err ? (
<Badge
@ -231,7 +398,7 @@ export function AgentsPage() {
variant="quiet"
icon={<LinkIcon />}
value="link account"
onClick={a.hive ? () => actions.openLink(a, "matrix") : undefined}
onClick={a.hive ? () => setMatrixTarget(a) : undefined}
disabled={!a.hive}
title={
a.hive
@ -326,12 +493,12 @@ export function AgentsPage() {
renderCard={(a) => (
<AgentCard
row={a}
pending={actions.pendingAgents.has(a.name)}
error={actions.actionErrors.get(a.name)}
pending={pendingAgents.has(a.name)}
error={actionErrors.get(a.name)}
selected={detailTargetName === a.name}
onSelectUp={actions.selectUp}
onSelectOffline={actions.selectOffline}
onSelectPaused={actions.selectPaused}
onSelectUp={selectUp}
onSelectOffline={selectOffline}
onSelectPaused={selectPaused}
onOpenDetail={(row) => setDetailTargetName(row.name)}
/>
)}
@ -353,7 +520,114 @@ export function AgentsPage() {
icon="🔎"
>
{detailTarget ? (
<AgentDetail row={detailTarget} actions={actions} withTerminal />
<div class="ui-agent-detail">
<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>
)}
@ -368,7 +642,83 @@ export function AgentsPage() {
>
<CreateAgentForm onClose={() => setCreateOpen(false)} />
</Dialog>
{actions.dialogs}
<Dialog
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>
</>
);
}

Some files were not shown because too many files have changed in this diff Show more