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:
parent
9761ffa24a
commit
103c842890
13 changed files with 639 additions and 464 deletions
266
frontend/packages/swarm-ui/src/pages/agents/AgentActions.tsx
Normal file
266
frontend/packages/swarm-ui/src/pages/agents/AgentActions.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -22,8 +22,7 @@
|
|||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
/* Icon beside the name/message, sized and dimmed like the hive
|
||||
dashboard's `.container-icon` (dashboard.css). */
|
||||
/* Icon (`AgentIcon`) beside the name/message. */
|
||||
.ui-agent-card-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
|
@ -36,23 +35,3 @@
|
|||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.ui-agent-card-icon {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
flex: none;
|
||||
width: 5em;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 6px;
|
||||
background-color: color-mix(in srgb, var(--crust) 60%, transparent);
|
||||
}
|
||||
.ui-agent-card-icon > img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.ui-agent-card-icon-none {
|
||||
filter: grayscale(1);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,11 +12,11 @@
|
|||
// `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 { AgentIcon } from "./AgentIcon.js";
|
||||
import { FRESHNESS, type AgentRow } from "./AgentTypes.js";
|
||||
import { WantedMenu } from "./WantedMenu.js";
|
||||
import "./AgentCard.css";
|
||||
|
|
@ -100,31 +100,3 @@ export function AgentCard({
|
|||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
19
frontend/packages/swarm-ui/src/pages/agents/AgentDetail.css
Normal file
19
frontend/packages/swarm-ui/src/pages/agents/AgentDetail.css
Normal 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;
|
||||
}
|
||||
125
frontend/packages/swarm-ui/src/pages/agents/AgentDetail.tsx
Normal file
125
frontend/packages/swarm-ui/src/pages/agents/AgentDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
27
frontend/packages/swarm-ui/src/pages/agents/AgentIcon.css
Normal file
27
frontend/packages/swarm-ui/src/pages/agents/AgentIcon.css
Normal 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;
|
||||
}
|
||||
38
frontend/packages/swarm-ui/src/pages/agents/AgentIcon.tsx
Normal file
38
frontend/packages/swarm-ui/src/pages/agents/AgentIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -37,6 +37,7 @@
|
|||
// 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 type { ComponentChildren } from "preact";
|
||||
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
|
||||
import { ExpandIcon } from "@hive/shared/icons.js";
|
||||
import { Row } from "@hive/shared/term-row.js";
|
||||
|
|
@ -189,6 +190,7 @@ export function AgentTermPreview({
|
|||
subagent,
|
||||
showHeaderBadges = true,
|
||||
fullHeight = false,
|
||||
leading,
|
||||
}: {
|
||||
agentName: string;
|
||||
// Set: this is one of `agentName`'s subagents, output only like the
|
||||
|
|
@ -196,6 +198,9 @@ export function AgentTermPreview({
|
|||
subagent?: string;
|
||||
showHeaderBadges?: 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 { rows, connection } = useSwarmTermStream(paths.stream);
|
||||
|
|
@ -236,6 +241,7 @@ export function AgentTermPreview({
|
|||
it has no expand trigger. */}
|
||||
<div class="ui-agent-term-preview-controls">
|
||||
<div class="ui-agent-term-preview-header-badges">
|
||||
{leading}
|
||||
<Badge
|
||||
tone={CONNECTION_TONE[connection]}
|
||||
value={CONNECTION_LABEL[connection]}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,11 @@
|
|||
// plain page reload should all re-open the tab instead of landing on a
|
||||
// page with no way back into the tab row. `useDynamicTabs().open()` is
|
||||
// idempotent (dedupes by href), so this is safe to call every mount.
|
||||
import { useEffect } from "preact/hooks";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
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 { useAgentFavicon } from "../../shell/useAgentFavicon.js";
|
||||
import { useDynamicTabs } from "../../shell/useDynamicTabs.js";
|
||||
|
|
@ -42,6 +45,11 @@ export function AgentTerminalPage({
|
|||
// instead of once per terminal `page`.
|
||||
}, [page]);
|
||||
|
||||
// The agent's details open in a dialog over the terminal, so reading or
|
||||
// changing them never means leaving it. A subagent's terminal opens its
|
||||
// agent's details: a subagent has none of its own.
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div class="ui-agent-terminal-page">
|
||||
<AgentTermPreview
|
||||
|
|
@ -49,6 +57,19 @@ export function AgentTerminalPage({
|
|||
subagent={subagent}
|
||||
fullHeight
|
||||
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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -36,23 +36,3 @@
|
|||
.ui-agents-view-toggle button:not(.active):hover {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
/* Detail panel body — its own `Panel`, title supplied by the panel's
|
||||
`title` prop (the agent's name), not repeated in here. */
|
||||
.ui-agent-detail-fields {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.4em 1em;
|
||||
margin: 0 0 1em;
|
||||
}
|
||||
.ui-agent-detail-fields dt {
|
||||
color: var(--muted);
|
||||
}
|
||||
.ui-agent-detail-fields dd {
|
||||
margin: 0;
|
||||
}
|
||||
.ui-agent-detail-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75em;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,27 +18,24 @@
|
|||
// natural home for the action that populates it. The form itself
|
||||
// (`CreateAgentForm`) mounts inside a `Dialog` here, not its own route.
|
||||
//
|
||||
// This file owns the page's own state/actions/`columns` only —
|
||||
// `AgentRow` and friends live in `AgentTypes.ts`, the wanted-state
|
||||
// control in `WantedMenu.tsx`, the card content in `AgentCard.tsx`
|
||||
// (mara: "agentspage is now giant and deserves a split"). Filtering +
|
||||
// cards-vs-table rendering is `FilterableView` (`ui/filterable-view/`,
|
||||
// see its own comment) — `viewMode` here just says which one to show.
|
||||
// This file owns the page's own state/`columns` only — `AgentRow` and
|
||||
// friends live in `AgentTypes.ts`, the wanted-state control in
|
||||
// `WantedMenu.tsx`, the actions on an agent (declare a state, link an
|
||||
// account) in `AgentActions.tsx`, the card content in `AgentCard.tsx` and
|
||||
// the detail panel's body in `AgentDetail.tsx` (mara: "agentspage is now
|
||||
// giant and deserves a split"). Filtering + cards-vs-table rendering is
|
||||
// `FilterableView` (`ui/filterable-view/`, see its own comment) — `viewMode`
|
||||
// here just says which one to show.
|
||||
import { useState } from "preact/hooks";
|
||||
import type { ComponentChildren } from "preact";
|
||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||
import { Badge } from "@hive/shared/badge.js";
|
||||
import { LinkIcon } from "@hive/shared/icons.js";
|
||||
import { useAgentActions } from "./AgentActions.js";
|
||||
import { AgentCard } from "./AgentCard.js";
|
||||
import { AgentTermPreview } from "./AgentTermPreview.js";
|
||||
import { SubagentTerms } from "./SubagentTerms.js";
|
||||
import { AgentDetail } from "./AgentDetail.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";
|
||||
|
|
@ -51,46 +48,9 @@ import {
|
|||
import { SplitView } from "../../ui/split-view/SplitView.js";
|
||||
import { type TableColumn } from "../../ui/table/Table.js";
|
||||
import { CreateAgentForm } from "../CreateAgentForm.js";
|
||||
import { LinkMatrixAccountForm } from "../LinkMatrixAccountForm.js";
|
||||
import { LinkForgeAccountForm } from "../LinkForgeAccountForm.js";
|
||||
import { LinkGithubAccountForm } from "../LinkGithubAccountForm.js";
|
||||
import { WantedMenu } from "./WantedMenu.js";
|
||||
import "./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.
|
||||
|
|
@ -103,8 +63,6 @@ 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);
|
||||
|
|
@ -128,56 +86,24 @@ export function AgentsPage() {
|
|||
}
|
||||
}
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
// Per-agent, not one page-wide flag: one row's declare-in-flight
|
||||
// shouldn't disable every other row's button.
|
||||
const [pendingAgents, setPendingAgents] = useState<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();
|
||||
};
|
||||
// The PUT response is the hive's whole declaration set (every agent's
|
||||
// `wanted` state) — same shape `GET /api/hives/{hive}/wanted` returns — so
|
||||
// patch just that field into the rows already on screen rather than firing
|
||||
// a second, full `/api/agents/status` round-trip merely to learn one
|
||||
// hive's `wanted` column changed. Everything else on the grid (freshness,
|
||||
// snapshot, config PR) still catches up on the next interval-driven
|
||||
// `refresh()`.
|
||||
const actions = useAgentActions((row, declared) => {
|
||||
setRows((prev) => {
|
||||
if (!prev) return prev;
|
||||
const byAgent = new Map(declared.map((d) => [d.agent, d.state]));
|
||||
return prev.map((r) =>
|
||||
r.hive === row.hive && byAgent.has(r.name)
|
||||
? { ...r, wanted: byAgent.get(r.name) ?? r.wanted }
|
||||
: r,
|
||||
);
|
||||
});
|
||||
});
|
||||
// Which agent the detail panel shows, *by name* — not the `AgentRow`
|
||||
// object itself. Storing the row would snapshot it at selection time;
|
||||
// `rows` replaces its whole array on every `refresh()` and every
|
||||
|
|
@ -207,99 +133,6 @@ export function AgentsPage() {
|
|||
refresh().catch((e: unknown) => setError({ detail: String(e) }));
|
||||
});
|
||||
|
||||
// The one PUT every `WantedMenu` selection ends at, whether or not it
|
||||
// went through a `ConfirmDialog` first (`confirmStop`/`destroyAgent`
|
||||
// for "offline"/"destroy"; called directly for "up", which needs no
|
||||
// confirmation): "PUT a new `wanted` declaration, track per-agent
|
||||
// pending/error state, patch the response back into `rows`" is the
|
||||
// same shape regardless of target state or how the caller got here.
|
||||
async function declareState(row: AgentRow, target: string) {
|
||||
if (!row.hive) return;
|
||||
setPendingAgents((prev) => new Set(prev).add(row.name));
|
||||
setActionErrors((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(row.name);
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/hives/${encodeURIComponent(row.hive)}/agents/${encodeURIComponent(row.name)}/state`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ state: target }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const problem = await readApiError(res);
|
||||
setActionErrors((prev) => new Map(prev).set(row.name, problem));
|
||||
return;
|
||||
}
|
||||
// The PUT response is the hive's whole declaration set (every
|
||||
// agent's `wanted` state) — same shape `GET /api/hives/{hive}/wanted`
|
||||
// returns — so patch just that field into the rows already on
|
||||
// screen rather than firing a second, full `/api/agents/status`
|
||||
// round-trip merely to learn one hive's `wanted` column changed.
|
||||
// Everything else on the grid (freshness, snapshot, config PR)
|
||||
// still catches up on the next interval-driven `refresh()`.
|
||||
const declared = (await res.json()) as { agent: string; state: string }[];
|
||||
setRows((prev) => {
|
||||
if (!prev) return prev;
|
||||
const byAgent = new Map(declared.map((d) => [d.agent, d.state]));
|
||||
return prev.map((r) =>
|
||||
r.hive === row.hive && byAgent.has(r.name)
|
||||
? { ...r, wanted: byAgent.get(r.name) ?? r.wanted }
|
||||
: r,
|
||||
);
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
setActionErrors((prev) =>
|
||||
new Map(prev).set(row.name, { detail: String(e) }),
|
||||
);
|
||||
} finally {
|
||||
setPendingAgents((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(row.name);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fires from the `confirmTarget` confirm `Dialog` (covers both "offline"
|
||||
// and "paused" — see that state's own comment). Used to be a native
|
||||
// `window.confirm` for the "offline" case (stop is reversible, a later
|
||||
// start un-does it, so a lighter-weight prompt than destroy's felt
|
||||
// proportionate) — mara, reviewing the destroy confirm: use the shared
|
||||
// component everywhere we already confirm before acting, not just for
|
||||
// destroy. Once `ConfirmDialog` existed as a one-line-per-caller
|
||||
// component, the "native is lighter" argument no longer bought
|
||||
// consistency anything.
|
||||
async function confirmDeclare() {
|
||||
if (!confirmTarget) return;
|
||||
const { row, state } = confirmTarget;
|
||||
setConfirmTarget(null);
|
||||
await declareState(row, state);
|
||||
}
|
||||
|
||||
// Fires from the `destroyTarget` confirm `Dialog`, never directly off
|
||||
// a row click — see `destroyTarget`/`WantedMenu`'s "destroy" option.
|
||||
// Unlike the "up"/"offline" directions, there's no "current state" to
|
||||
// read: destroy is a one-way declaration, not a flip.
|
||||
async function destroyAgent(row: AgentRow) {
|
||||
setDestroyTarget(null);
|
||||
await declareState(row, "destroyed");
|
||||
}
|
||||
|
||||
// The three non-destroy `WantedMenu` selections, factored out once
|
||||
// rather than repeated per call site — the card, the table's "wanted"
|
||||
// column, and the detail panel's own `WantedMenu` (below) all wire the
|
||||
// same three.
|
||||
const selectUp = (row: AgentRow) => void declareState(row, "up");
|
||||
const selectOffline = (row: AgentRow) =>
|
||||
setConfirmTarget({ row, state: "offline" });
|
||||
const selectPaused = (row: AgentRow) =>
|
||||
setConfirmTarget({ row, state: "paused" });
|
||||
|
||||
// `FilterableView`'s `columns` — the full field set (including
|
||||
// per-column sort/filter, which drives both the table's own popovers
|
||||
// and the card view's filter bar now — see that component's own
|
||||
|
|
@ -367,17 +200,17 @@ export function AgentsPage() {
|
|||
filterValues: (a) => [a.wanted ?? "no declaration"],
|
||||
filterMode: "multiselect",
|
||||
render: (a) => {
|
||||
const err = actionErrors.get(a.name);
|
||||
const err = actions.actionErrors.get(a.name);
|
||||
return (
|
||||
<>
|
||||
<WantedMenu
|
||||
row={a}
|
||||
pending={pendingAgents.has(a.name)}
|
||||
pending={actions.pendingAgents.has(a.name)}
|
||||
portal
|
||||
onSelectUp={selectUp}
|
||||
onSelectOffline={selectOffline}
|
||||
onSelectPaused={selectPaused}
|
||||
onDestroy={setDestroyTarget}
|
||||
onSelectUp={actions.selectUp}
|
||||
onSelectOffline={actions.selectOffline}
|
||||
onSelectPaused={actions.selectPaused}
|
||||
onDestroy={actions.requestDestroy}
|
||||
/>
|
||||
{err ? (
|
||||
<Badge
|
||||
|
|
@ -398,7 +231,7 @@ export function AgentsPage() {
|
|||
variant="quiet"
|
||||
icon={<LinkIcon />}
|
||||
value="link account"
|
||||
onClick={a.hive ? () => setMatrixTarget(a) : undefined}
|
||||
onClick={a.hive ? () => actions.openLink(a, "matrix") : undefined}
|
||||
disabled={!a.hive}
|
||||
title={
|
||||
a.hive
|
||||
|
|
@ -493,12 +326,12 @@ export function AgentsPage() {
|
|||
renderCard={(a) => (
|
||||
<AgentCard
|
||||
row={a}
|
||||
pending={pendingAgents.has(a.name)}
|
||||
error={actionErrors.get(a.name)}
|
||||
pending={actions.pendingAgents.has(a.name)}
|
||||
error={actions.actionErrors.get(a.name)}
|
||||
selected={detailTargetName === a.name}
|
||||
onSelectUp={selectUp}
|
||||
onSelectOffline={selectOffline}
|
||||
onSelectPaused={selectPaused}
|
||||
onSelectUp={actions.selectUp}
|
||||
onSelectOffline={actions.selectOffline}
|
||||
onSelectPaused={actions.selectPaused}
|
||||
onOpenDetail={(row) => setDetailTargetName(row.name)}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -520,114 +353,7 @@ export function AgentsPage() {
|
|||
icon="🔎"
|
||||
>
|
||||
{detailTarget ? (
|
||||
<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>
|
||||
<AgentDetail row={detailTarget} actions={actions} withTerminal />
|
||||
) : (
|
||||
<p class="ui-agents-empty">select an agent to see its details</p>
|
||||
)}
|
||||
|
|
@ -642,83 +368,7 @@ export function AgentsPage() {
|
|||
>
|
||||
<CreateAgentForm onClose={() => setCreateOpen(false)} />
|
||||
</Dialog>
|
||||
<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>
|
||||
{actions.dialogs}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue