Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c15580060 | ||
|
|
afa9d861ba | ||
|
|
f957e19d0d | ||
|
|
74e588415b | ||
|
|
fd6953ed7c | ||
|
|
103c842890 |
32 changed files with 842 additions and 472 deletions
|
|
@ -38,6 +38,14 @@ A terminal's own tab takes the agent's icon as its browser-tab icon, so tabs of
|
|||
different agents tell apart. A subagent's tab shows its agent's icon. An agent
|
||||
with no icon keeps the hyperhive mark.
|
||||
|
||||
The tab also has an agent badge, with the agent's icon and name, ahead of the
|
||||
connection badge. It opens the agent's details in a popover under the badge:
|
||||
the same status, wanted state, inference settings and linked accounts as the
|
||||
panel on `/agents`, without the terminal preview. The popover doesn't dim the
|
||||
page. Selecting outside it, or Escape, closes it. You can stop or pause the
|
||||
agent, or link an account, without leaving the terminal. A subagent's tab opens
|
||||
its agent's details.
|
||||
|
||||
### Scheduled prompts
|
||||
|
||||
`/schedules` has one card per scheduled prompt. The **name** is the card's
|
||||
|
|
|
|||
27
frontend/packages/swarm-ui/src/agent/AgentIcon.css
Normal file
27
frontend/packages/swarm-ui/src/agent/AgentIcon.css
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/* The icon square, sized and dimmed like the hive dashboard's
|
||||
`.container-icon` (dashboard.css). */
|
||||
.ui-agent-icon {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
flex: none;
|
||||
width: 5em;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 6px;
|
||||
background-color: color-mix(in srgb, var(--crust) 60%, transparent);
|
||||
}
|
||||
.ui-agent-icon > img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.ui-agent-icon-none {
|
||||
filter: grayscale(1);
|
||||
opacity: 0.4;
|
||||
}
|
||||
/* Beside a line of text, inside a badge. */
|
||||
.ui-agent-icon-small {
|
||||
width: 1.3em;
|
||||
border-radius: 3px;
|
||||
}
|
||||
38
frontend/packages/swarm-ui/src/agent/AgentIcon.tsx
Normal file
38
frontend/packages/swarm-ui/src/agent/AgentIcon.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
// <AgentIcon> — the agent's icon from swarm-controller's
|
||||
// `GET /api/agents/{name}/icon`, the same square and fallback as the hive
|
||||
// dashboard's container row: an agent with no icon (404) or an unreachable
|
||||
// controller shows the dimmed hyperhive mark instead of a broken image.
|
||||
// Always an `<img>`: the body is an agent-authored SVG, and only an image load
|
||||
// keeps its script from running.
|
||||
import { useState } from "preact/hooks";
|
||||
import "./AgentIcon.css";
|
||||
|
||||
export function AgentIcon({
|
||||
name,
|
||||
small = false,
|
||||
}: {
|
||||
name: string;
|
||||
/** Badge-sized, for beside a line of text, rather than the card's square. */
|
||||
small?: boolean;
|
||||
}) {
|
||||
const [fallback, setFallback] = useState(false);
|
||||
return (
|
||||
<div
|
||||
class={
|
||||
"ui-agent-icon" +
|
||||
(small ? " ui-agent-icon-small" : "") +
|
||||
(fallback ? " ui-agent-icon-none" : "")
|
||||
}
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
src={
|
||||
fallback
|
||||
? "/favicon.svg"
|
||||
: `/api/agents/${encodeURIComponent(name)}/icon`
|
||||
}
|
||||
onError={() => setFallback(true)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
frontend/packages/swarm-ui/src/agent/detail/AgentDetail.css
Normal file
19
frontend/packages/swarm-ui/src/agent/detail/AgentDetail.css
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/* <AgentDetail> body — the panel or dialog around it supplies the title (the
|
||||
agent's name), so it is not repeated in here. */
|
||||
.ui-agent-detail-fields {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.4em 1em;
|
||||
margin: 0 0 1em;
|
||||
}
|
||||
.ui-agent-detail-fields dt {
|
||||
color: var(--muted);
|
||||
}
|
||||
.ui-agent-detail-fields dd {
|
||||
margin: 0;
|
||||
}
|
||||
.ui-agent-detail-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75em;
|
||||
}
|
||||
135
frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx
Normal file
135
frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
// <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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
// <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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
266
frontend/packages/swarm-ui/src/agent/roster/AgentActions.tsx
Normal file
266
frontend/packages/swarm-ui/src/agent/roster/AgentActions.tsx
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
// useAgentActions — what can be done to an agent from the swarm UI: declare
|
||||
// its wanted state (up / offline / paused / destroyed) and link an account to
|
||||
// it, with the confirm and form dialogs those need. Shared by the agent
|
||||
// roster (`AgentsPage`) and the detail dialog on a terminal page
|
||||
// (`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,
|
||||
};
|
||||
}
|
||||
|
|
@ -22,8 +22,7 @@
|
|||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
/* Icon beside the name/message, sized and dimmed like the hive
|
||||
dashboard's `.container-icon` (dashboard.css). */
|
||||
/* Icon (`AgentIcon`) beside the name/message. */
|
||||
.ui-agent-card-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
|
@ -36,23 +35,3 @@
|
|||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.ui-agent-card-icon {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
flex: none;
|
||||
width: 5em;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 6px;
|
||||
background-color: color-mix(in srgb, var(--crust) 60%, transparent);
|
||||
}
|
||||
.ui-agent-card-icon > img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.ui-agent-card-icon-none {
|
||||
filter: grayscale(1);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
|
@ -12,12 +12,12 @@
|
|||
// `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 { AgentIcon } from "../AgentIcon.js";
|
||||
import { FRESHNESS, type AgentRow } from "../AgentTypes.js";
|
||||
import { WantedMenu } from "./WantedMenu.js";
|
||||
import "./AgentCard.css";
|
||||
|
||||
|
|
@ -100,31 +100,3 @@ export function AgentCard({
|
|||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// The agent's icon from swarm-controller's `GET /api/agents/{name}/icon`,
|
||||
// the same square and fallback as the hive dashboard's container row: an
|
||||
// agent with no icon (404) or an unreachable controller shows the dimmed
|
||||
// hyperhive mark instead of a broken image. Always an `<img>`: the body is
|
||||
// an agent-authored SVG, and only an image load keeps its script from running.
|
||||
function AgentIcon({ name }: { name: string }) {
|
||||
const [fallback, setFallback] = useState(false);
|
||||
return (
|
||||
<div
|
||||
class={
|
||||
fallback
|
||||
? "ui-agent-card-icon ui-agent-card-icon-none"
|
||||
: "ui-agent-card-icon"
|
||||
}
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
src={
|
||||
fallback
|
||||
? "/favicon.svg"
|
||||
: `/api/agents/${encodeURIComponent(name)}/icon`
|
||||
}
|
||||
onError={() => setFallback(true)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
import { useRef, useState } from "preact/hooks";
|
||||
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
|
||||
import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js";
|
||||
import type { AgentRow } from "./AgentTypes.js";
|
||||
import type { AgentRow } from "../AgentTypes.js";
|
||||
|
||||
export function WantedMenu({
|
||||
row,
|
||||
|
|
@ -37,6 +37,7 @@
|
|||
// the other one is. Suppressed via `fullHeight` — the full-page mount
|
||||
// already IS the destination, so it has nothing further to expand to.
|
||||
import { useLayoutEffect, useRef, useState } from "preact/hooks";
|
||||
import type { ComponentChildren } from "preact";
|
||||
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
|
||||
import { ExpandIcon } from "@hive/shared/icons.js";
|
||||
import { Row } from "@hive/shared/term-row.js";
|
||||
|
|
@ -189,6 +190,7 @@ export function AgentTermPreview({
|
|||
subagent,
|
||||
showHeaderBadges = true,
|
||||
fullHeight = false,
|
||||
leading,
|
||||
}: {
|
||||
agentName: string;
|
||||
// Set: this is one of `agentName`'s subagents, output only like the
|
||||
|
|
@ -196,6 +198,9 @@ export function AgentTermPreview({
|
|||
subagent?: string;
|
||||
showHeaderBadges?: boolean;
|
||||
fullHeight?: boolean;
|
||||
// A badge ahead of the connection badge, for the full-tab mount's
|
||||
// link back to the agent's details.
|
||||
leading?: ComponentChildren;
|
||||
}) {
|
||||
const paths = termPaths(agentName, subagent);
|
||||
const { rows, connection } = useSwarmTermStream(paths.stream);
|
||||
|
|
@ -236,6 +241,7 @@ export function AgentTermPreview({
|
|||
it has no expand trigger. */}
|
||||
<div class="ui-agent-term-preview-controls">
|
||||
<div class="ui-agent-term-preview-header-badges">
|
||||
{leading}
|
||||
<Badge
|
||||
tone={CONNECTION_TONE[connection]}
|
||||
value={CONNECTION_LABEL[connection]}
|
||||
|
|
@ -30,6 +30,7 @@ import {
|
|||
import { Button, type ButtonVariant } from "../ui/button/Button.js";
|
||||
import { Dialog } from "../ui/dialog/Dialog.js";
|
||||
import { ConfirmDialog } from "../ui/confirm-dialog/ConfirmDialog.js";
|
||||
import { Popover } from "../ui/popover/Popover.js";
|
||||
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
|
||||
import { GearIcon } from "@hive/shared/icons.js";
|
||||
import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js";
|
||||
|
|
@ -231,6 +232,33 @@ function DialogSample() {
|
|||
);
|
||||
}
|
||||
|
||||
// `Popover` anchors to the trigger it is given and toggles from it, so the
|
||||
// sample needs the trigger's ref and its own `open` state.
|
||||
function PopoverSample() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const anchorRef = useRef<HTMLSpanElement>(null);
|
||||
return (
|
||||
<>
|
||||
<span ref={anchorRef}>
|
||||
<Button onClick={() => setOpen((o) => !o)}>toggle popover</Button>
|
||||
</span>
|
||||
<Popover
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
anchorRef={anchorRef}
|
||||
label="sample popover"
|
||||
>
|
||||
<Panel title="a popover" icon="🔗" onClose={() => setOpen(false)}>
|
||||
<p>
|
||||
Floats under its trigger and leaves the page behind it clickable.
|
||||
Outside click or Escape closes it.
|
||||
</p>
|
||||
</Panel>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// `plain`: a `Panel`'s own header bar/border is the dialog's
|
||||
// only card, not a second one nested inside `Dialog`'s own — pair
|
||||
// `plain` with `Panel`'s `onClose` so the close button still exists,
|
||||
|
|
@ -511,6 +539,12 @@ export function ComponentsPage() {
|
|||
</Sample>
|
||||
</Section>
|
||||
|
||||
<Section title="Popover">
|
||||
<Sample label="anchored, no backdrop, content supplies its own card">
|
||||
<PopoverSample />
|
||||
</Sample>
|
||||
</Section>
|
||||
|
||||
<Section title="ConfirmDialog">
|
||||
<Sample label="modal, cancel/confirm button row">
|
||||
<ConfirmDialogSample />
|
||||
|
|
|
|||
|
|
@ -9,10 +9,16 @@
|
|||
// plain page reload should all re-open the tab instead of landing on a
|
||||
// page with no way back into the tab row. `useDynamicTabs().open()` is
|
||||
// idempotent (dedupes by href), so this is safe to call every mount.
|
||||
import { useEffect } from "preact/hooks";
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import type { RouteComponentProps } from "wouter-preact";
|
||||
import { AgentTermPreview, termPaths } from "./AgentTermPreview.js";
|
||||
import { useAgentFavicon } from "../../shell/useAgentFavicon.js";
|
||||
import { Badge } from "@hive/shared/badge.js";
|
||||
import { AgentDetailPopover } from "../../agent/detail/AgentDetailPopover.js";
|
||||
import { AgentIcon } from "../../agent/AgentIcon.js";
|
||||
import {
|
||||
AgentTermPreview,
|
||||
termPaths,
|
||||
} from "../../agent/terminal/AgentTermPreview.js";
|
||||
import { useAgentFavicon } from "../../agent/terminal/useAgentFavicon.js";
|
||||
import { useDynamicTabs } from "../../shell/useDynamicTabs.js";
|
||||
import "./AgentTerminalPage.css";
|
||||
|
||||
|
|
@ -42,6 +48,13 @@ export function AgentTerminalPage({
|
|||
// instead of once per terminal `page`.
|
||||
}, [page]);
|
||||
|
||||
// The agent's details open in a popover under its badge, so reading or
|
||||
// changing them never means leaving the terminal, or dimming it. A
|
||||
// subagent's terminal opens its agent's details: a subagent has none of its
|
||||
// own.
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const badgeRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
return (
|
||||
<div class="ui-agent-terminal-page">
|
||||
<AgentTermPreview
|
||||
|
|
@ -49,6 +62,23 @@ export function AgentTerminalPage({
|
|||
subagent={subagent}
|
||||
fullHeight
|
||||
showHeaderBadges
|
||||
leading={
|
||||
<span ref={badgeRef}>
|
||||
<Badge
|
||||
icon={<AgentIcon name={name} small />}
|
||||
value={name}
|
||||
title={`${detailOpen ? "close" : "open"} ${name}'s details`}
|
||||
expanded={detailOpen}
|
||||
onClick={() => setDetailOpen((o) => !o)}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<AgentDetailPopover
|
||||
agent={name}
|
||||
open={detailOpen}
|
||||
onClose={() => setDetailOpen(false)}
|
||||
anchorRef={badgeRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -36,23 +36,3 @@
|
|||
.ui-agents-view-toggle button:not(.active):hover {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
/* Detail panel body — its own `Panel`, title supplied by the panel's
|
||||
`title` prop (the agent's name), not repeated in here. */
|
||||
.ui-agent-detail-fields {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.4em 1em;
|
||||
margin: 0 0 1em;
|
||||
}
|
||||
.ui-agent-detail-fields dt {
|
||||
color: var(--muted);
|
||||
}
|
||||
.ui-agent-detail-fields dd {
|
||||
margin: 0;
|
||||
}
|
||||
.ui-agent-detail-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75em;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,27 +18,24 @@
|
|||
// natural home for the action that populates it. The form itself
|
||||
// (`CreateAgentForm`) mounts inside a `Dialog` here, not its own route.
|
||||
//
|
||||
// This file owns the page's own state/actions/`columns` only —
|
||||
// `AgentRow` and friends live in `AgentTypes.ts`, the wanted-state
|
||||
// control in `WantedMenu.tsx`, the card content in `AgentCard.tsx`
|
||||
// (mara: "agentspage is now giant and deserves a split"). Filtering +
|
||||
// cards-vs-table rendering is `FilterableView` (`ui/filterable-view/`,
|
||||
// see its own comment) — `viewMode` here just says which one to show.
|
||||
// This file owns the page's own state/`columns` only — `AgentRow` and
|
||||
// friends live in `AgentTypes.ts`, the wanted-state control in
|
||||
// `WantedMenu.tsx`, the actions on an agent (declare a state, link an
|
||||
// account) in `AgentActions.tsx`, the card content in `AgentCard.tsx` and
|
||||
// the detail panel's body in `AgentDetail.tsx` (mara: "agentspage is now
|
||||
// giant and deserves a split"). Filtering + cards-vs-table rendering is
|
||||
// `FilterableView` (`ui/filterable-view/`, see its own comment) — `viewMode`
|
||||
// here just says which one to show.
|
||||
import { useState } from "preact/hooks";
|
||||
import type { ComponentChildren } from "preact";
|
||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||
import { Badge } from "@hive/shared/badge.js";
|
||||
import { LinkIcon } from "@hive/shared/icons.js";
|
||||
import { AgentCard } from "./AgentCard.js";
|
||||
import { AgentTermPreview } from "./AgentTermPreview.js";
|
||||
import { SubagentTerms } from "./SubagentTerms.js";
|
||||
import { FRESHNESS, type AgentRow } from "./AgentTypes.js";
|
||||
import { AgentInference } from "./AgentInference.js";
|
||||
import { LinkedAccounts } from "./LinkedAccounts.js";
|
||||
import { LinkAccountMenu } from "./LinkAccountMenu.js";
|
||||
import { useAgentActions } from "../../agent/roster/AgentActions.js";
|
||||
import { AgentCard } from "../../agent/roster/AgentCard.js";
|
||||
import { AgentDetail } from "../../agent/detail/AgentDetail.js";
|
||||
import { FRESHNESS, type AgentRow } from "../../agent/AgentTypes.js";
|
||||
import { Button } from "../../ui/button/Button.js";
|
||||
import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
|
||||
import { Dialog } from "../../ui/dialog/Dialog.js";
|
||||
import { FilterableView } from "../../ui/filterable-view/FilterableView.js";
|
||||
import { Panel } from "../../ui/panel/Panel.js";
|
||||
|
|
@ -51,46 +48,9 @@ import {
|
|||
import { SplitView } from "../../ui/split-view/SplitView.js";
|
||||
import { type TableColumn } from "../../ui/table/Table.js";
|
||||
import { CreateAgentForm } from "../CreateAgentForm.js";
|
||||
import { LinkMatrixAccountForm } from "../LinkMatrixAccountForm.js";
|
||||
import { LinkForgeAccountForm } from "../LinkForgeAccountForm.js";
|
||||
import { LinkGithubAccountForm } from "../LinkGithubAccountForm.js";
|
||||
import { WantedMenu } from "./WantedMenu.js";
|
||||
import { WantedMenu } from "../../agent/roster/WantedMenu.js";
|
||||
import "./AgentsPage.css";
|
||||
|
||||
// The one thing that differs between the "offline" and "paused" confirm
|
||||
// dialogs `confirmTarget` drives — everything else (button row, open/close
|
||||
// wiring) is the shared `ConfirmDialog`.
|
||||
const CONFIRM_COPY: Record<
|
||||
"offline" | "paused",
|
||||
{
|
||||
label: string;
|
||||
confirmLabel: string;
|
||||
message: (agentName: string) => ComponentChildren;
|
||||
}
|
||||
> = {
|
||||
offline: {
|
||||
label: "stop agent",
|
||||
confirmLabel: "stop",
|
||||
message: (agentName) => (
|
||||
<p>
|
||||
Declare <strong>{agentName}</strong> offline? The hive brings its
|
||||
container down on its next reconcile sweep.
|
||||
</p>
|
||||
),
|
||||
},
|
||||
paused: {
|
||||
label: "pause agent",
|
||||
confirmLabel: "pause",
|
||||
message: (agentName) => (
|
||||
<p>
|
||||
Declare <strong>{agentName}</strong> paused? Its container stays up —
|
||||
the web UI and MCP daemons keep running — but its turn loop parks on the
|
||||
hive's next reconcile sweep. Select "up" again to resume it.
|
||||
</p>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
// Same 30s default + same reasoning as HivesPage: no inputs on this page
|
||||
// for a refresh to clobber, so the out-of-the-box behaviour should just
|
||||
// solve staleness rather than require an opt-in every visit.
|
||||
|
|
@ -103,8 +63,6 @@ const VIEW_MODE_KEY = "swarm-ui:agents:view-mode";
|
|||
|
||||
export function AgentsPage() {
|
||||
const [rows, setRows] = useState<AgentRow[] | null>(null);
|
||||
// The row showing the "link a github account" dialog, same shape.
|
||||
const [githubTarget, setGithubTarget] = useState<AgentRow | null>(null);
|
||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||
const [intervalMs, setIntervalMs] =
|
||||
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
||||
|
|
@ -128,56 +86,24 @@ export function AgentsPage() {
|
|||
}
|
||||
}
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
// Per-agent, not one page-wide flag: one row's declare-in-flight
|
||||
// shouldn't disable every other row's button.
|
||||
const [pendingAgents, setPendingAgents] = useState<ReadonlySet<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [actionErrors, setActionErrors] = useState<
|
||||
ReadonlyMap<string, ProblemDetails>
|
||||
>(new Map());
|
||||
// The row pending destroy confirmation, `null` when the dialog is
|
||||
// closed — not a boolean, so the dialog can name the agent without a
|
||||
// second piece of state to keep in sync with it. Kept apart from
|
||||
// `confirmTarget` below rather than folded into it as a third state:
|
||||
// destroy is categorically different (irreversible, longer warning,
|
||||
// danger-styled), not one more case of the same "confirm before parking
|
||||
// a running agent" shape "offline"/"paused" share.
|
||||
const [destroyTarget, setDestroyTarget] = useState<AgentRow | null>(null);
|
||||
// "offline" and "paused" are the *same* confirm shape — same button
|
||||
// treatment, same "disruptive to a running turn loop, unlike up" reason
|
||||
// for needing a confirm at all — so one union state + one `ConfirmDialog`
|
||||
// covers both instead of two near-identical copies (argus, PR review: "a
|
||||
// shared confirm-dialog-body-generator would drop real line count").
|
||||
// `CONFIRM_COPY` below supplies the one thing that differs.
|
||||
const [confirmTarget, setConfirmTarget] = useState<{
|
||||
row: AgentRow;
|
||||
state: "offline" | "paused";
|
||||
} | null>(null);
|
||||
// The row currently showing the "link a matrix account" dialog — same
|
||||
// null-means-closed shape as `destroyTarget`/`stopTarget`, own piece of
|
||||
// state rather than folded into either since this dialog isn't a
|
||||
// confirmation of a `declareState` call, it's an unrelated action with
|
||||
// its own form (`LinkMatrixAccountForm`).
|
||||
const [matrixTarget, setMatrixTarget] = useState<AgentRow | null>(null);
|
||||
// The row showing the "link a forge account" dialog, same shape.
|
||||
const [forgeTarget, setForgeTarget] = useState<AgentRow | null>(null);
|
||||
// Bumped whenever a link dialog closes; the panel's `LinkedAccounts` refetches
|
||||
// on a change, so a newly linked account shows without a reload.
|
||||
const [linkVersion, setLinkVersion] = useState(0);
|
||||
const linkClosed = () => setLinkVersion((v) => v + 1);
|
||||
const closeMatrix = () => {
|
||||
setMatrixTarget(null);
|
||||
linkClosed();
|
||||
};
|
||||
const closeForge = () => {
|
||||
setForgeTarget(null);
|
||||
linkClosed();
|
||||
};
|
||||
const closeGithub = () => {
|
||||
setGithubTarget(null);
|
||||
linkClosed();
|
||||
};
|
||||
// The PUT response is the hive's whole declaration set (every agent's
|
||||
// `wanted` state) — same shape `GET /api/hives/{hive}/wanted` returns — so
|
||||
// patch just that field into the rows already on screen rather than firing
|
||||
// a second, full `/api/agents/status` round-trip merely to learn one
|
||||
// hive's `wanted` column changed. Everything else on the grid (freshness,
|
||||
// snapshot, config PR) still catches up on the next interval-driven
|
||||
// `refresh()`.
|
||||
const actions = useAgentActions((row, declared) => {
|
||||
setRows((prev) => {
|
||||
if (!prev) return prev;
|
||||
const byAgent = new Map(declared.map((d) => [d.agent, d.state]));
|
||||
return prev.map((r) =>
|
||||
r.hive === row.hive && byAgent.has(r.name)
|
||||
? { ...r, wanted: byAgent.get(r.name) ?? r.wanted }
|
||||
: r,
|
||||
);
|
||||
});
|
||||
});
|
||||
// Which agent the detail panel shows, *by name* — not the `AgentRow`
|
||||
// object itself. Storing the row would snapshot it at selection time;
|
||||
// `rows` replaces its whole array on every `refresh()` and every
|
||||
|
|
@ -207,99 +133,6 @@ export function AgentsPage() {
|
|||
refresh().catch((e: unknown) => setError({ detail: String(e) }));
|
||||
});
|
||||
|
||||
// The one PUT every `WantedMenu` selection ends at, whether or not it
|
||||
// went through a `ConfirmDialog` first (`confirmStop`/`destroyAgent`
|
||||
// for "offline"/"destroy"; called directly for "up", which needs no
|
||||
// confirmation): "PUT a new `wanted` declaration, track per-agent
|
||||
// pending/error state, patch the response back into `rows`" is the
|
||||
// same shape regardless of target state or how the caller got here.
|
||||
async function declareState(row: AgentRow, target: string) {
|
||||
if (!row.hive) return;
|
||||
setPendingAgents((prev) => new Set(prev).add(row.name));
|
||||
setActionErrors((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(row.name);
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/hives/${encodeURIComponent(row.hive)}/agents/${encodeURIComponent(row.name)}/state`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ state: target }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const problem = await readApiError(res);
|
||||
setActionErrors((prev) => new Map(prev).set(row.name, problem));
|
||||
return;
|
||||
}
|
||||
// The PUT response is the hive's whole declaration set (every
|
||||
// agent's `wanted` state) — same shape `GET /api/hives/{hive}/wanted`
|
||||
// returns — so patch just that field into the rows already on
|
||||
// screen rather than firing a second, full `/api/agents/status`
|
||||
// round-trip merely to learn one hive's `wanted` column changed.
|
||||
// Everything else on the grid (freshness, snapshot, config PR)
|
||||
// still catches up on the next interval-driven `refresh()`.
|
||||
const declared = (await res.json()) as { agent: string; state: string }[];
|
||||
setRows((prev) => {
|
||||
if (!prev) return prev;
|
||||
const byAgent = new Map(declared.map((d) => [d.agent, d.state]));
|
||||
return prev.map((r) =>
|
||||
r.hive === row.hive && byAgent.has(r.name)
|
||||
? { ...r, wanted: byAgent.get(r.name) ?? r.wanted }
|
||||
: r,
|
||||
);
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
setActionErrors((prev) =>
|
||||
new Map(prev).set(row.name, { detail: String(e) }),
|
||||
);
|
||||
} finally {
|
||||
setPendingAgents((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(row.name);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fires from the `confirmTarget` confirm `Dialog` (covers both "offline"
|
||||
// and "paused" — see that state's own comment). Used to be a native
|
||||
// `window.confirm` for the "offline" case (stop is reversible, a later
|
||||
// start un-does it, so a lighter-weight prompt than destroy's felt
|
||||
// proportionate) — mara, reviewing the destroy confirm: use the shared
|
||||
// component everywhere we already confirm before acting, not just for
|
||||
// destroy. Once `ConfirmDialog` existed as a one-line-per-caller
|
||||
// component, the "native is lighter" argument no longer bought
|
||||
// consistency anything.
|
||||
async function confirmDeclare() {
|
||||
if (!confirmTarget) return;
|
||||
const { row, state } = confirmTarget;
|
||||
setConfirmTarget(null);
|
||||
await declareState(row, state);
|
||||
}
|
||||
|
||||
// Fires from the `destroyTarget` confirm `Dialog`, never directly off
|
||||
// a row click — see `destroyTarget`/`WantedMenu`'s "destroy" option.
|
||||
// Unlike the "up"/"offline" directions, there's no "current state" to
|
||||
// read: destroy is a one-way declaration, not a flip.
|
||||
async function destroyAgent(row: AgentRow) {
|
||||
setDestroyTarget(null);
|
||||
await declareState(row, "destroyed");
|
||||
}
|
||||
|
||||
// The three non-destroy `WantedMenu` selections, factored out once
|
||||
// rather than repeated per call site — the card, the table's "wanted"
|
||||
// column, and the detail panel's own `WantedMenu` (below) all wire the
|
||||
// same three.
|
||||
const selectUp = (row: AgentRow) => void declareState(row, "up");
|
||||
const selectOffline = (row: AgentRow) =>
|
||||
setConfirmTarget({ row, state: "offline" });
|
||||
const selectPaused = (row: AgentRow) =>
|
||||
setConfirmTarget({ row, state: "paused" });
|
||||
|
||||
// `FilterableView`'s `columns` — the full field set (including
|
||||
// per-column sort/filter, which drives both the table's own popovers
|
||||
// and the card view's filter bar now — see that component's own
|
||||
|
|
@ -367,17 +200,17 @@ export function AgentsPage() {
|
|||
filterValues: (a) => [a.wanted ?? "no declaration"],
|
||||
filterMode: "multiselect",
|
||||
render: (a) => {
|
||||
const err = actionErrors.get(a.name);
|
||||
const err = actions.actionErrors.get(a.name);
|
||||
return (
|
||||
<>
|
||||
<WantedMenu
|
||||
row={a}
|
||||
pending={pendingAgents.has(a.name)}
|
||||
pending={actions.pendingAgents.has(a.name)}
|
||||
portal
|
||||
onSelectUp={selectUp}
|
||||
onSelectOffline={selectOffline}
|
||||
onSelectPaused={selectPaused}
|
||||
onDestroy={setDestroyTarget}
|
||||
onSelectUp={actions.selectUp}
|
||||
onSelectOffline={actions.selectOffline}
|
||||
onSelectPaused={actions.selectPaused}
|
||||
onDestroy={actions.requestDestroy}
|
||||
/>
|
||||
{err ? (
|
||||
<Badge
|
||||
|
|
@ -398,7 +231,7 @@ export function AgentsPage() {
|
|||
variant="quiet"
|
||||
icon={<LinkIcon />}
|
||||
value="link account"
|
||||
onClick={a.hive ? () => setMatrixTarget(a) : undefined}
|
||||
onClick={a.hive ? () => actions.openLink(a, "matrix") : undefined}
|
||||
disabled={!a.hive}
|
||||
title={
|
||||
a.hive
|
||||
|
|
@ -493,12 +326,12 @@ export function AgentsPage() {
|
|||
renderCard={(a) => (
|
||||
<AgentCard
|
||||
row={a}
|
||||
pending={pendingAgents.has(a.name)}
|
||||
error={actionErrors.get(a.name)}
|
||||
pending={actions.pendingAgents.has(a.name)}
|
||||
error={actions.actionErrors.get(a.name)}
|
||||
selected={detailTargetName === a.name}
|
||||
onSelectUp={selectUp}
|
||||
onSelectOffline={selectOffline}
|
||||
onSelectPaused={selectPaused}
|
||||
onSelectUp={actions.selectUp}
|
||||
onSelectOffline={actions.selectOffline}
|
||||
onSelectPaused={actions.selectPaused}
|
||||
onOpenDetail={(row) => setDetailTargetName(row.name)}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -520,114 +353,7 @@ export function AgentsPage() {
|
|||
icon="🔎"
|
||||
>
|
||||
{detailTarget ? (
|
||||
<div class="ui-agent-detail">
|
||||
<dl class="ui-agent-detail-fields">
|
||||
<dt>status</dt>
|
||||
<dd>
|
||||
{(() => {
|
||||
const { tone, label } = FRESHNESS[detailTarget.freshness];
|
||||
return (
|
||||
<Badge
|
||||
tone={tone}
|
||||
value={
|
||||
<>
|
||||
{label}
|
||||
{detailTarget.last_seen_unix !== null ? (
|
||||
<>
|
||||
{" "}
|
||||
(
|
||||
<RelativeTime
|
||||
epochMs={detailTarget.last_seen_unix * 1000}
|
||||
/>
|
||||
)
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</dd>
|
||||
<dt>message</dt>
|
||||
<dd>{detailTarget.snapshot?.status_text ?? "—"}</dd>
|
||||
<dt>wanted</dt>
|
||||
<dd>
|
||||
{/* Full menu (destroy included) — mara: "destroy is
|
||||
already available via wanted state", no separate
|
||||
button needed. */}
|
||||
<WantedMenu
|
||||
row={detailTarget}
|
||||
pending={pendingAgents.has(detailTarget.name)}
|
||||
onSelectUp={selectUp}
|
||||
onSelectOffline={selectOffline}
|
||||
onSelectPaused={selectPaused}
|
||||
onDestroy={setDestroyTarget}
|
||||
/>
|
||||
</dd>
|
||||
<dt>hive</dt>
|
||||
<dd>{detailTarget.hive ?? "—"}</dd>
|
||||
<dt>config PR</dt>
|
||||
<dd>
|
||||
{detailTarget.config_pr ? (
|
||||
<Badge
|
||||
tone="warning"
|
||||
value={
|
||||
detailTarget.config_pr.html_url ? (
|
||||
<a
|
||||
href={detailTarget.config_pr.html_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
#{detailTarget.config_pr.pr_number}
|
||||
</a>
|
||||
) : (
|
||||
`#${detailTarget.config_pr.pr_number}`
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</dd>
|
||||
<dt>inference</dt>
|
||||
<dd>
|
||||
<AgentInference
|
||||
key={detailTarget.name}
|
||||
agent={detailTarget.name}
|
||||
/>
|
||||
</dd>
|
||||
<dt>accounts</dt>
|
||||
<dd>
|
||||
{detailTarget.hive ? (
|
||||
<LinkedAccounts
|
||||
key={`${detailTarget.hive}/${detailTarget.name}`}
|
||||
hive={detailTarget.hive}
|
||||
agent={detailTarget.name}
|
||||
version={linkVersion}
|
||||
/>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</dd>
|
||||
</dl>
|
||||
<div class="ui-agent-detail-actions">
|
||||
<LinkAccountMenu
|
||||
agent={detailTarget.name}
|
||||
hasHive={detailTarget.hive !== null}
|
||||
onSelect={(kind) => {
|
||||
if (kind === "matrix") setMatrixTarget(detailTarget);
|
||||
else if (kind === "forge") setForgeTarget(detailTarget);
|
||||
else setGithubTarget(detailTarget);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* The agent's live terminal and its input box. */}
|
||||
<AgentTermPreview agentName={detailTarget.name} />
|
||||
<SubagentTerms
|
||||
key={detailTarget.name}
|
||||
agentName={detailTarget.name}
|
||||
/>
|
||||
</div>
|
||||
<AgentDetail row={detailTarget} actions={actions} withTerminal />
|
||||
) : (
|
||||
<p class="ui-agents-empty">select an agent to see its details</p>
|
||||
)}
|
||||
|
|
@ -642,83 +368,7 @@ export function AgentsPage() {
|
|||
>
|
||||
<CreateAgentForm onClose={() => setCreateOpen(false)} />
|
||||
</Dialog>
|
||||
<Dialog
|
||||
open={matrixTarget !== null}
|
||||
onClose={closeMatrix}
|
||||
label="link a matrix account"
|
||||
plain
|
||||
>
|
||||
{/* `matrixTarget.hive` is non-null here — the trigger badge above
|
||||
is disabled without one, so this can only open with a real
|
||||
hive to PUT against. */}
|
||||
{matrixTarget?.hive ? (
|
||||
<LinkMatrixAccountForm
|
||||
hive={matrixTarget.hive}
|
||||
agent={matrixTarget.name}
|
||||
onClose={closeMatrix}
|
||||
/>
|
||||
) : null}
|
||||
</Dialog>
|
||||
<Dialog
|
||||
open={forgeTarget !== null}
|
||||
onClose={closeForge}
|
||||
label="link a forge account"
|
||||
plain
|
||||
>
|
||||
{forgeTarget?.hive ? (
|
||||
<LinkForgeAccountForm
|
||||
hive={forgeTarget.hive}
|
||||
agent={forgeTarget.name}
|
||||
onClose={closeForge}
|
||||
/>
|
||||
) : null}
|
||||
</Dialog>
|
||||
<Dialog
|
||||
open={githubTarget !== null}
|
||||
onClose={closeGithub}
|
||||
label="link a github account"
|
||||
plain
|
||||
>
|
||||
{githubTarget?.hive ? (
|
||||
<LinkGithubAccountForm
|
||||
hive={githubTarget.hive}
|
||||
agent={githubTarget.name}
|
||||
onClose={closeGithub}
|
||||
/>
|
||||
) : null}
|
||||
</Dialog>
|
||||
<ConfirmDialog
|
||||
open={confirmTarget !== null}
|
||||
label={confirmTarget ? CONFIRM_COPY[confirmTarget.state].label : ""}
|
||||
onCancel={() => setConfirmTarget(null)}
|
||||
onConfirm={() => void confirmDeclare()}
|
||||
confirmLabel={
|
||||
confirmTarget
|
||||
? CONFIRM_COPY[confirmTarget.state].confirmLabel
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{confirmTarget
|
||||
? CONFIRM_COPY[confirmTarget.state].message(confirmTarget.row.name)
|
||||
: null}
|
||||
</ConfirmDialog>
|
||||
<ConfirmDialog
|
||||
open={destroyTarget !== null}
|
||||
label="destroy agent"
|
||||
onCancel={() => setDestroyTarget(null)}
|
||||
onConfirm={() => destroyTarget && void destroyAgent(destroyTarget)}
|
||||
confirmLabel="destroy"
|
||||
>
|
||||
{destroyTarget ? (
|
||||
<p>
|
||||
Destroy <strong>{destroyTarget.name}</strong>? The hive tears its
|
||||
container down on its next reconcile sweep. This is not reversible
|
||||
from here — bringing it back means redeploying via "+ agent", which
|
||||
reuses the agent's existing identity, config repo, and forge
|
||||
collaborator access rather than starting over.
|
||||
</p>
|
||||
) : null}
|
||||
</ConfirmDialog>
|
||||
{actions.dialogs}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import {
|
|||
type RefreshIntervalMs,
|
||||
} from "../../ui/refresh-interval/RefreshInterval.js";
|
||||
import type { TableColumn } from "../../ui/table/Table.js";
|
||||
import type { AgentRow } from "../agents/AgentTypes.js";
|
||||
import type { AgentRow } from "../../agent/AgentTypes.js";
|
||||
import { ScheduleCard } from "./ScheduleCard.js";
|
||||
import { ScheduleForm } from "./ScheduleForm.js";
|
||||
import { scheduleState, type Schedule } from "./ScheduleTypes.js";
|
||||
|
|
|
|||
15
frontend/packages/swarm-ui/src/ui/popover/Popover.css
Normal file
15
frontend/packages/swarm-ui/src/ui/popover/Popover.css
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/* <Popover> — floating box under its anchor. Placement (`top`/`left`) is set
|
||||
inline from the anchor's rect; this is the rest. The content brings its own
|
||||
card (a `Panel`), so the box itself only floats, casts a shadow and caps its
|
||||
size to the viewport. */
|
||||
.ui-popover {
|
||||
position: fixed;
|
||||
z-index: 30;
|
||||
box-sizing: border-box;
|
||||
width: 28em;
|
||||
max-width: calc(100vw - 1em);
|
||||
max-height: calc(100vh - 4em);
|
||||
overflow: auto;
|
||||
border-radius: 0.5em;
|
||||
box-shadow: 0 0.4em 1.2em rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
118
frontend/packages/swarm-ui/src/ui/popover/Popover.tsx
Normal file
118
frontend/packages/swarm-ui/src/ui/popover/Popover.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
// <Popover> — arbitrary content in a floating box anchored under the element
|
||||
// that opened it, with no backdrop: the page behind stays visible and
|
||||
// clickable, unlike `Dialog`'s modal overlay. For content that belongs to the
|
||||
// thing you clicked (an agent's details from its badge), not a task that
|
||||
// should hold the whole page.
|
||||
//
|
||||
// Not a native `<dialog>`: `showModal()` always dims and blocks the page, and a
|
||||
// non-modal `show()` gets no outside-click or Escape handling for free, so
|
||||
// this hand-rolls that contract the way `Dropdown` does. Rendered in a portal
|
||||
// at `position: fixed`, placed from the anchor's `getBoundingClientRect()`, so
|
||||
// a clipping ancestor (the terminal's scrolling box) can't cut it off.
|
||||
//
|
||||
// A modal `<dialog>` opened from inside it (a confirm, a form) is not
|
||||
// "outside": a click in an open dialog, or Escape while one is open, goes to
|
||||
// that dialog and leaves the popover alone.
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks";
|
||||
import { createPortal } from "preact/compat";
|
||||
import type { ComponentChildren, RefObject } from "preact";
|
||||
import "./Popover.css";
|
||||
|
||||
// Space kept between the popover and the viewport's edges.
|
||||
const EDGE_PX = 8;
|
||||
const GAP_PX = 4;
|
||||
|
||||
export function Popover({
|
||||
open,
|
||||
onClose,
|
||||
anchorRef,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** The element the popover hangs under. A click on it is not an outside
|
||||
* click, so its own toggle handler stays in charge of closing. */
|
||||
anchorRef: RefObject<HTMLElement>;
|
||||
/** `aria-label` for the floating box. */
|
||||
label: string;
|
||||
children: ComponentChildren;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
|
||||
// Placed from the anchor on open, and again on scroll and resize, so it
|
||||
// follows the anchor instead of floating where it first appeared.
|
||||
useLayoutEffect(() => {
|
||||
if (!open) {
|
||||
setPos(null);
|
||||
return;
|
||||
}
|
||||
function place() {
|
||||
const anchor = anchorRef.current;
|
||||
if (!anchor) return;
|
||||
const r = anchor.getBoundingClientRect();
|
||||
const width = ref.current?.offsetWidth ?? 0;
|
||||
const maxLeft = window.innerWidth - width - EDGE_PX;
|
||||
const top = r.bottom + GAP_PX;
|
||||
const left = Math.max(EDGE_PX, Math.min(r.left, maxLeft));
|
||||
// Scrolling anywhere (the streaming terminal included) calls this; keep
|
||||
// the old object when nothing moved so the subtree does not re-render.
|
||||
setPos((prev) =>
|
||||
prev && prev.top === top && prev.left === left ? prev : { top, left },
|
||||
);
|
||||
}
|
||||
place();
|
||||
window.addEventListener("scroll", place, true);
|
||||
window.addEventListener("resize", place);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", place, true);
|
||||
window.removeEventListener("resize", place);
|
||||
};
|
||||
}, [open, anchorRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function inside(target: EventTarget | null, el: Element | null) {
|
||||
return target instanceof Node && el !== null && el.contains(target);
|
||||
}
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (
|
||||
inside(e.target, ref.current) ||
|
||||
inside(e.target, anchorRef.current) ||
|
||||
inside(e.target, document.querySelector("dialog[open]"))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key !== "Escape" || document.querySelector("dialog[open]")) return;
|
||||
onClose();
|
||||
}
|
||||
document.addEventListener("pointerdown", onPointerDown);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", onPointerDown);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [open, onClose, anchorRef]);
|
||||
|
||||
if (!open) return null;
|
||||
return createPortal(
|
||||
<div
|
||||
class="ui-popover"
|
||||
role="dialog"
|
||||
aria-label={label}
|
||||
ref={ref}
|
||||
style={
|
||||
pos
|
||||
? { top: `${pos.top}px`, left: `${pos.left}px` }
|
||||
: { visibility: "hidden" }
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue