Compare commits
32 changed files with 472 additions and 842 deletions
|
|
@ -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
|
different agents tell apart. A subagent's tab shows its agent's icon. An agent
|
||||||
with no icon keeps the hyperhive mark.
|
with no icon keeps the hyperhive mark.
|
||||||
|
|
||||||
The tab also has an agent badge, with the agent's icon and name, ahead of the
|
|
||||||
connection badge. It opens the agent's details in a 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
|
### Scheduled prompts
|
||||||
|
|
||||||
`/schedules` has one card per scheduled prompt. The **name** is the card's
|
`/schedules` has one card per scheduled prompt. The **name** is the card's
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -30,7 +30,6 @@ import {
|
||||||
import { Button, type ButtonVariant } from "../ui/button/Button.js";
|
import { Button, type ButtonVariant } from "../ui/button/Button.js";
|
||||||
import { Dialog } from "../ui/dialog/Dialog.js";
|
import { Dialog } from "../ui/dialog/Dialog.js";
|
||||||
import { ConfirmDialog } from "../ui/confirm-dialog/ConfirmDialog.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 { Badge, type BadgeTone } from "@hive/shared/badge.js";
|
||||||
import { GearIcon } from "@hive/shared/icons.js";
|
import { GearIcon } from "@hive/shared/icons.js";
|
||||||
import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.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
|
// `plain`: a `Panel`'s own header bar/border is the dialog's
|
||||||
// only card, not a second one nested inside `Dialog`'s own — pair
|
// only card, not a second one nested inside `Dialog`'s own — pair
|
||||||
// `plain` with `Panel`'s `onClose` so the close button still exists,
|
// `plain` with `Panel`'s `onClose` so the close button still exists,
|
||||||
|
|
@ -539,12 +511,6 @@ export function ComponentsPage() {
|
||||||
</Sample>
|
</Sample>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Popover">
|
|
||||||
<Sample label="anchored, no backdrop, content supplies its own card">
|
|
||||||
<PopoverSample />
|
|
||||||
</Sample>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="ConfirmDialog">
|
<Section title="ConfirmDialog">
|
||||||
<Sample label="modal, cancel/confirm button row">
|
<Sample label="modal, cancel/confirm button row">
|
||||||
<ConfirmDialogSample />
|
<ConfirmDialogSample />
|
||||||
|
|
|
||||||
58
frontend/packages/swarm-ui/src/pages/agents/AgentCard.css
Normal file
58
frontend/packages/swarm-ui/src/pages/agents/AgentCard.css
Normal 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;
|
||||||
|
}
|
||||||
130
frontend/packages/swarm-ui/src/pages/agents/AgentCard.tsx
Normal file
130
frontend/packages/swarm-ui/src/pages/agents/AgentCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
301
frontend/packages/swarm-ui/src/pages/agents/AgentInference.tsx
Normal file
301
frontend/packages/swarm-ui/src/pages/agents/AgentInference.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.css
Normal file
117
frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.css
Normal 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;
|
||||||
|
}
|
||||||
282
frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.tsx
Normal file
282
frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -9,16 +9,10 @@
|
||||||
// plain page reload should all re-open the tab instead of landing on a
|
// plain page reload should all re-open the tab instead of landing on a
|
||||||
// page with no way back into the tab row. `useDynamicTabs().open()` is
|
// page with no way back into the tab row. `useDynamicTabs().open()` is
|
||||||
// idempotent (dedupes by href), so this is safe to call every mount.
|
// idempotent (dedupes by href), so this is safe to call every mount.
|
||||||
import { useEffect, useRef, useState } from "preact/hooks";
|
import { useEffect } from "preact/hooks";
|
||||||
import type { RouteComponentProps } from "wouter-preact";
|
import type { RouteComponentProps } from "wouter-preact";
|
||||||
import { Badge } from "@hive/shared/badge.js";
|
import { AgentTermPreview, termPaths } from "./AgentTermPreview.js";
|
||||||
import { AgentDetailPopover } from "../../agent/detail/AgentDetailPopover.js";
|
import { useAgentFavicon } from "../../shell/useAgentFavicon.js";
|
||||||
import { AgentIcon } from "../../agent/AgentIcon.js";
|
|
||||||
import {
|
|
||||||
AgentTermPreview,
|
|
||||||
termPaths,
|
|
||||||
} from "../../agent/terminal/AgentTermPreview.js";
|
|
||||||
import { useAgentFavicon } from "../../agent/terminal/useAgentFavicon.js";
|
|
||||||
import { useDynamicTabs } from "../../shell/useDynamicTabs.js";
|
import { useDynamicTabs } from "../../shell/useDynamicTabs.js";
|
||||||
import "./AgentTerminalPage.css";
|
import "./AgentTerminalPage.css";
|
||||||
|
|
||||||
|
|
@ -48,13 +42,6 @@ export function AgentTerminalPage({
|
||||||
// instead of once per terminal `page`.
|
// instead of once per terminal `page`.
|
||||||
}, [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 (
|
return (
|
||||||
<div class="ui-agent-terminal-page">
|
<div class="ui-agent-terminal-page">
|
||||||
<AgentTermPreview
|
<AgentTermPreview
|
||||||
|
|
@ -62,23 +49,6 @@ export function AgentTerminalPage({
|
||||||
subagent={subagent}
|
subagent={subagent}
|
||||||
fullHeight
|
fullHeight
|
||||||
showHeaderBadges
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
61
frontend/packages/swarm-ui/src/pages/agents/AgentTypes.ts
Normal file
61
frontend/packages/swarm-ui/src/pages/agents/AgentTypes.ts
Normal 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" },
|
||||||
|
};
|
||||||
|
|
@ -36,3 +36,23 @@
|
||||||
.ui-agents-view-toggle button:not(.active):hover {
|
.ui-agents-view-toggle button:not(.active):hover {
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Detail panel body — its own `Panel`, title supplied by the panel's
|
||||||
|
`title` prop (the agent's name), not repeated in here. */
|
||||||
|
.ui-agent-detail-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 0.4em 1em;
|
||||||
|
margin: 0 0 1em;
|
||||||
|
}
|
||||||
|
.ui-agent-detail-fields dt {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.ui-agent-detail-fields dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.ui-agent-detail-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75em;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,24 +18,27 @@
|
||||||
// natural home for the action that populates it. The form itself
|
// natural home for the action that populates it. The form itself
|
||||||
// (`CreateAgentForm`) mounts inside a `Dialog` here, not its own route.
|
// (`CreateAgentForm`) mounts inside a `Dialog` here, not its own route.
|
||||||
//
|
//
|
||||||
// This file owns the page's own state/`columns` only — `AgentRow` and
|
// This file owns the page's own state/actions/`columns` only —
|
||||||
// friends live in `AgentTypes.ts`, the wanted-state control in
|
// `AgentRow` and friends live in `AgentTypes.ts`, the wanted-state
|
||||||
// `WantedMenu.tsx`, the actions on an agent (declare a state, link an
|
// control in `WantedMenu.tsx`, the card content in `AgentCard.tsx`
|
||||||
// account) in `AgentActions.tsx`, the card content in `AgentCard.tsx` and
|
// (mara: "agentspage is now giant and deserves a split"). Filtering +
|
||||||
// the detail panel's body in `AgentDetail.tsx` (mara: "agentspage is now
|
// cards-vs-table rendering is `FilterableView` (`ui/filterable-view/`,
|
||||||
// giant and deserves a split"). Filtering + cards-vs-table rendering is
|
// see its own comment) — `viewMode` here just says which one to show.
|
||||||
// `FilterableView` (`ui/filterable-view/`, see its own comment) — `viewMode`
|
|
||||||
// here just says which one to show.
|
|
||||||
import { useState } from "preact/hooks";
|
import { useState } from "preact/hooks";
|
||||||
|
import type { ComponentChildren } from "preact";
|
||||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||||
import { Badge } from "@hive/shared/badge.js";
|
import { Badge } from "@hive/shared/badge.js";
|
||||||
import { LinkIcon } from "@hive/shared/icons.js";
|
import { LinkIcon } from "@hive/shared/icons.js";
|
||||||
import { useAgentActions } from "../../agent/roster/AgentActions.js";
|
import { AgentCard } from "./AgentCard.js";
|
||||||
import { AgentCard } from "../../agent/roster/AgentCard.js";
|
import { AgentTermPreview } from "./AgentTermPreview.js";
|
||||||
import { AgentDetail } from "../../agent/detail/AgentDetail.js";
|
import { SubagentTerms } from "./SubagentTerms.js";
|
||||||
import { FRESHNESS, type AgentRow } from "../../agent/AgentTypes.js";
|
import { FRESHNESS, type AgentRow } from "./AgentTypes.js";
|
||||||
|
import { AgentInference } from "./AgentInference.js";
|
||||||
|
import { LinkedAccounts } from "./LinkedAccounts.js";
|
||||||
|
import { LinkAccountMenu } from "./LinkAccountMenu.js";
|
||||||
import { Button } from "../../ui/button/Button.js";
|
import { Button } from "../../ui/button/Button.js";
|
||||||
|
import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
|
||||||
import { Dialog } from "../../ui/dialog/Dialog.js";
|
import { Dialog } from "../../ui/dialog/Dialog.js";
|
||||||
import { FilterableView } from "../../ui/filterable-view/FilterableView.js";
|
import { FilterableView } from "../../ui/filterable-view/FilterableView.js";
|
||||||
import { Panel } from "../../ui/panel/Panel.js";
|
import { Panel } from "../../ui/panel/Panel.js";
|
||||||
|
|
@ -48,9 +51,46 @@ import {
|
||||||
import { SplitView } from "../../ui/split-view/SplitView.js";
|
import { SplitView } from "../../ui/split-view/SplitView.js";
|
||||||
import { type TableColumn } from "../../ui/table/Table.js";
|
import { type TableColumn } from "../../ui/table/Table.js";
|
||||||
import { CreateAgentForm } from "../CreateAgentForm.js";
|
import { CreateAgentForm } from "../CreateAgentForm.js";
|
||||||
import { 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";
|
import "./AgentsPage.css";
|
||||||
|
|
||||||
|
// The one thing that differs between the "offline" and "paused" confirm
|
||||||
|
// dialogs `confirmTarget` drives — everything else (button row, open/close
|
||||||
|
// wiring) is the shared `ConfirmDialog`.
|
||||||
|
const CONFIRM_COPY: Record<
|
||||||
|
"offline" | "paused",
|
||||||
|
{
|
||||||
|
label: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
message: (agentName: string) => ComponentChildren;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
offline: {
|
||||||
|
label: "stop agent",
|
||||||
|
confirmLabel: "stop",
|
||||||
|
message: (agentName) => (
|
||||||
|
<p>
|
||||||
|
Declare <strong>{agentName}</strong> offline? The hive brings its
|
||||||
|
container down on its next reconcile sweep.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
paused: {
|
||||||
|
label: "pause agent",
|
||||||
|
confirmLabel: "pause",
|
||||||
|
message: (agentName) => (
|
||||||
|
<p>
|
||||||
|
Declare <strong>{agentName}</strong> paused? Its container stays up —
|
||||||
|
the web UI and MCP daemons keep running — but its turn loop parks on the
|
||||||
|
hive's next reconcile sweep. Select "up" again to resume it.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// Same 30s default + same reasoning as HivesPage: no inputs on this page
|
// Same 30s default + same reasoning as HivesPage: no inputs on this page
|
||||||
// for a refresh to clobber, so the out-of-the-box behaviour should just
|
// for a refresh to clobber, so the out-of-the-box behaviour should just
|
||||||
// solve staleness rather than require an opt-in every visit.
|
// solve staleness rather than require an opt-in every visit.
|
||||||
|
|
@ -63,6 +103,8 @@ const VIEW_MODE_KEY = "swarm-ui:agents:view-mode";
|
||||||
|
|
||||||
export function AgentsPage() {
|
export function AgentsPage() {
|
||||||
const [rows, setRows] = useState<AgentRow[] | null>(null);
|
const [rows, setRows] = useState<AgentRow[] | null>(null);
|
||||||
|
// The row showing the "link a github account" dialog, same shape.
|
||||||
|
const [githubTarget, setGithubTarget] = useState<AgentRow | null>(null);
|
||||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||||
const [intervalMs, setIntervalMs] =
|
const [intervalMs, setIntervalMs] =
|
||||||
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
||||||
|
|
@ -86,24 +128,56 @@ export function AgentsPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
// The PUT response is the hive's whole declaration set (every agent's
|
// Per-agent, not one page-wide flag: one row's declare-in-flight
|
||||||
// `wanted` state) — same shape `GET /api/hives/{hive}/wanted` returns — so
|
// shouldn't disable every other row's button.
|
||||||
// patch just that field into the rows already on screen rather than firing
|
const [pendingAgents, setPendingAgents] = useState<ReadonlySet<string>>(
|
||||||
// a second, full `/api/agents/status` round-trip merely to learn one
|
new Set(),
|
||||||
// hive's `wanted` column changed. Everything else on the grid (freshness,
|
);
|
||||||
// snapshot, config PR) still catches up on the next interval-driven
|
const [actionErrors, setActionErrors] = useState<
|
||||||
// `refresh()`.
|
ReadonlyMap<string, ProblemDetails>
|
||||||
const actions = useAgentActions((row, declared) => {
|
>(new Map());
|
||||||
setRows((prev) => {
|
// The row pending destroy confirmation, `null` when the dialog is
|
||||||
if (!prev) return prev;
|
// closed — not a boolean, so the dialog can name the agent without a
|
||||||
const byAgent = new Map(declared.map((d) => [d.agent, d.state]));
|
// second piece of state to keep in sync with it. Kept apart from
|
||||||
return prev.map((r) =>
|
// `confirmTarget` below rather than folded into it as a third state:
|
||||||
r.hive === row.hive && byAgent.has(r.name)
|
// destroy is categorically different (irreversible, longer warning,
|
||||||
? { ...r, wanted: byAgent.get(r.name) ?? r.wanted }
|
// danger-styled), not one more case of the same "confirm before parking
|
||||||
: r,
|
// a running agent" shape "offline"/"paused" share.
|
||||||
);
|
const [destroyTarget, setDestroyTarget] = useState<AgentRow | null>(null);
|
||||||
});
|
// "offline" and "paused" are the *same* confirm shape — same button
|
||||||
});
|
// treatment, same "disruptive to a running turn loop, unlike up" reason
|
||||||
|
// for needing a confirm at all — so one union state + one `ConfirmDialog`
|
||||||
|
// covers both instead of two near-identical copies (argus, PR review: "a
|
||||||
|
// shared confirm-dialog-body-generator would drop real line count").
|
||||||
|
// `CONFIRM_COPY` below supplies the one thing that differs.
|
||||||
|
const [confirmTarget, setConfirmTarget] = useState<{
|
||||||
|
row: AgentRow;
|
||||||
|
state: "offline" | "paused";
|
||||||
|
} | null>(null);
|
||||||
|
// The row currently showing the "link a matrix account" dialog — same
|
||||||
|
// null-means-closed shape as `destroyTarget`/`stopTarget`, own piece of
|
||||||
|
// state rather than folded into either since this dialog isn't a
|
||||||
|
// confirmation of a `declareState` call, it's an unrelated action with
|
||||||
|
// its own form (`LinkMatrixAccountForm`).
|
||||||
|
const [matrixTarget, setMatrixTarget] = useState<AgentRow | null>(null);
|
||||||
|
// The row showing the "link a forge account" dialog, same shape.
|
||||||
|
const [forgeTarget, setForgeTarget] = useState<AgentRow | null>(null);
|
||||||
|
// Bumped whenever a link dialog closes; the panel's `LinkedAccounts` refetches
|
||||||
|
// on a change, so a newly linked account shows without a reload.
|
||||||
|
const [linkVersion, setLinkVersion] = useState(0);
|
||||||
|
const linkClosed = () => setLinkVersion((v) => v + 1);
|
||||||
|
const closeMatrix = () => {
|
||||||
|
setMatrixTarget(null);
|
||||||
|
linkClosed();
|
||||||
|
};
|
||||||
|
const closeForge = () => {
|
||||||
|
setForgeTarget(null);
|
||||||
|
linkClosed();
|
||||||
|
};
|
||||||
|
const closeGithub = () => {
|
||||||
|
setGithubTarget(null);
|
||||||
|
linkClosed();
|
||||||
|
};
|
||||||
// Which agent the detail panel shows, *by name* — not the `AgentRow`
|
// Which agent the detail panel shows, *by name* — not the `AgentRow`
|
||||||
// object itself. Storing the row would snapshot it at selection time;
|
// object itself. Storing the row would snapshot it at selection time;
|
||||||
// `rows` replaces its whole array on every `refresh()` and every
|
// `rows` replaces its whole array on every `refresh()` and every
|
||||||
|
|
@ -133,6 +207,99 @@ export function AgentsPage() {
|
||||||
refresh().catch((e: unknown) => setError({ detail: String(e) }));
|
refresh().catch((e: unknown) => setError({ detail: String(e) }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The one PUT every `WantedMenu` selection ends at, whether or not it
|
||||||
|
// went through a `ConfirmDialog` first (`confirmStop`/`destroyAgent`
|
||||||
|
// for "offline"/"destroy"; called directly for "up", which needs no
|
||||||
|
// confirmation): "PUT a new `wanted` declaration, track per-agent
|
||||||
|
// pending/error state, patch the response back into `rows`" is the
|
||||||
|
// same shape regardless of target state or how the caller got here.
|
||||||
|
async function declareState(row: AgentRow, target: string) {
|
||||||
|
if (!row.hive) return;
|
||||||
|
setPendingAgents((prev) => new Set(prev).add(row.name));
|
||||||
|
setActionErrors((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(row.name);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/hives/${encodeURIComponent(row.hive)}/agents/${encodeURIComponent(row.name)}/state`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ state: target }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const problem = await readApiError(res);
|
||||||
|
setActionErrors((prev) => new Map(prev).set(row.name, problem));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The PUT response is the hive's whole declaration set (every
|
||||||
|
// agent's `wanted` state) — same shape `GET /api/hives/{hive}/wanted`
|
||||||
|
// returns — so patch just that field into the rows already on
|
||||||
|
// screen rather than firing a second, full `/api/agents/status`
|
||||||
|
// round-trip merely to learn one hive's `wanted` column changed.
|
||||||
|
// Everything else on the grid (freshness, snapshot, config PR)
|
||||||
|
// still catches up on the next interval-driven `refresh()`.
|
||||||
|
const declared = (await res.json()) as { agent: string; state: string }[];
|
||||||
|
setRows((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const byAgent = new Map(declared.map((d) => [d.agent, d.state]));
|
||||||
|
return prev.map((r) =>
|
||||||
|
r.hive === row.hive && byAgent.has(r.name)
|
||||||
|
? { ...r, wanted: byAgent.get(r.name) ?? r.wanted }
|
||||||
|
: r,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setActionErrors((prev) =>
|
||||||
|
new Map(prev).set(row.name, { detail: String(e) }),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setPendingAgents((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(row.name);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fires from the `confirmTarget` confirm `Dialog` (covers both "offline"
|
||||||
|
// and "paused" — see that state's own comment). Used to be a native
|
||||||
|
// `window.confirm` for the "offline" case (stop is reversible, a later
|
||||||
|
// start un-does it, so a lighter-weight prompt than destroy's felt
|
||||||
|
// proportionate) — mara, reviewing the destroy confirm: use the shared
|
||||||
|
// component everywhere we already confirm before acting, not just for
|
||||||
|
// destroy. Once `ConfirmDialog` existed as a one-line-per-caller
|
||||||
|
// component, the "native is lighter" argument no longer bought
|
||||||
|
// consistency anything.
|
||||||
|
async function confirmDeclare() {
|
||||||
|
if (!confirmTarget) return;
|
||||||
|
const { row, state } = confirmTarget;
|
||||||
|
setConfirmTarget(null);
|
||||||
|
await declareState(row, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fires from the `destroyTarget` confirm `Dialog`, never directly off
|
||||||
|
// a row click — see `destroyTarget`/`WantedMenu`'s "destroy" option.
|
||||||
|
// Unlike the "up"/"offline" directions, there's no "current state" to
|
||||||
|
// read: destroy is a one-way declaration, not a flip.
|
||||||
|
async function destroyAgent(row: AgentRow) {
|
||||||
|
setDestroyTarget(null);
|
||||||
|
await declareState(row, "destroyed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The three non-destroy `WantedMenu` selections, factored out once
|
||||||
|
// rather than repeated per call site — the card, the table's "wanted"
|
||||||
|
// column, and the detail panel's own `WantedMenu` (below) all wire the
|
||||||
|
// same three.
|
||||||
|
const selectUp = (row: AgentRow) => void declareState(row, "up");
|
||||||
|
const selectOffline = (row: AgentRow) =>
|
||||||
|
setConfirmTarget({ row, state: "offline" });
|
||||||
|
const selectPaused = (row: AgentRow) =>
|
||||||
|
setConfirmTarget({ row, state: "paused" });
|
||||||
|
|
||||||
// `FilterableView`'s `columns` — the full field set (including
|
// `FilterableView`'s `columns` — the full field set (including
|
||||||
// per-column sort/filter, which drives both the table's own popovers
|
// per-column sort/filter, which drives both the table's own popovers
|
||||||
// and the card view's filter bar now — see that component's own
|
// and the card view's filter bar now — see that component's own
|
||||||
|
|
@ -200,17 +367,17 @@ export function AgentsPage() {
|
||||||
filterValues: (a) => [a.wanted ?? "no declaration"],
|
filterValues: (a) => [a.wanted ?? "no declaration"],
|
||||||
filterMode: "multiselect",
|
filterMode: "multiselect",
|
||||||
render: (a) => {
|
render: (a) => {
|
||||||
const err = actions.actionErrors.get(a.name);
|
const err = actionErrors.get(a.name);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<WantedMenu
|
<WantedMenu
|
||||||
row={a}
|
row={a}
|
||||||
pending={actions.pendingAgents.has(a.name)}
|
pending={pendingAgents.has(a.name)}
|
||||||
portal
|
portal
|
||||||
onSelectUp={actions.selectUp}
|
onSelectUp={selectUp}
|
||||||
onSelectOffline={actions.selectOffline}
|
onSelectOffline={selectOffline}
|
||||||
onSelectPaused={actions.selectPaused}
|
onSelectPaused={selectPaused}
|
||||||
onDestroy={actions.requestDestroy}
|
onDestroy={setDestroyTarget}
|
||||||
/>
|
/>
|
||||||
{err ? (
|
{err ? (
|
||||||
<Badge
|
<Badge
|
||||||
|
|
@ -231,7 +398,7 @@ export function AgentsPage() {
|
||||||
variant="quiet"
|
variant="quiet"
|
||||||
icon={<LinkIcon />}
|
icon={<LinkIcon />}
|
||||||
value="link account"
|
value="link account"
|
||||||
onClick={a.hive ? () => actions.openLink(a, "matrix") : undefined}
|
onClick={a.hive ? () => setMatrixTarget(a) : undefined}
|
||||||
disabled={!a.hive}
|
disabled={!a.hive}
|
||||||
title={
|
title={
|
||||||
a.hive
|
a.hive
|
||||||
|
|
@ -326,12 +493,12 @@ export function AgentsPage() {
|
||||||
renderCard={(a) => (
|
renderCard={(a) => (
|
||||||
<AgentCard
|
<AgentCard
|
||||||
row={a}
|
row={a}
|
||||||
pending={actions.pendingAgents.has(a.name)}
|
pending={pendingAgents.has(a.name)}
|
||||||
error={actions.actionErrors.get(a.name)}
|
error={actionErrors.get(a.name)}
|
||||||
selected={detailTargetName === a.name}
|
selected={detailTargetName === a.name}
|
||||||
onSelectUp={actions.selectUp}
|
onSelectUp={selectUp}
|
||||||
onSelectOffline={actions.selectOffline}
|
onSelectOffline={selectOffline}
|
||||||
onSelectPaused={actions.selectPaused}
|
onSelectPaused={selectPaused}
|
||||||
onOpenDetail={(row) => setDetailTargetName(row.name)}
|
onOpenDetail={(row) => setDetailTargetName(row.name)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -353,7 +520,114 @@ export function AgentsPage() {
|
||||||
icon="🔎"
|
icon="🔎"
|
||||||
>
|
>
|
||||||
{detailTarget ? (
|
{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>
|
<p class="ui-agents-empty">select an agent to see its details</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -368,7 +642,83 @@ export function AgentsPage() {
|
||||||
>
|
>
|
||||||
<CreateAgentForm onClose={() => setCreateOpen(false)} />
|
<CreateAgentForm onClose={() => setCreateOpen(false)} />
|
||||||
</Dialog>
|
</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
Loading…
Reference in a new issue