swarm-ui: split pages/agents into two pages and the agent components
pages/agents held the roster page, the terminal page and every component under them. It keeps the two pages; the components move to src/agent, by concern: roster (card, wanted menu, actions), detail (panel body, dialog, inference, linked accounts, link menu) and terminal (preview, input, subagents, stream hooks, favicon), with the shared row type and icon at the top. Moves and import rewrites only.
This commit is contained in:
parent
fd6953ed7c
commit
74e588415b
27 changed files with 28 additions and 25 deletions
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>
|
||||
);
|
||||
}
|
||||
61
frontend/packages/swarm-ui/src/agent/AgentTypes.ts
Normal file
61
frontend/packages/swarm-ui/src/agent/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" },
|
||||
};
|
||||
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,85 @@
|
|||
// <AgentDetailDialog> — the agent detail panel from `/agents`, in a dialog, for
|
||||
// a page that is already showing the agent's terminal and should not be left
|
||||
// to read or change its details. It carries the same body (`AgentDetail`) and
|
||||
// the same actions (`useAgentActions`) as the panel, minus the terminal
|
||||
// preview the page itself already is.
|
||||
//
|
||||
// The row comes from `GET /api/agents/status` when the dialog opens, found by
|
||||
// name: the panel on `/agents` has the whole roster in hand, this page does
|
||||
// not. A wanted-state change patches the row in place from the PUT's answer.
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||
import { Dialog } from "../../ui/dialog/Dialog.js";
|
||||
import { Panel } from "../../ui/panel/Panel.js";
|
||||
import { useAgentActions } from "../roster/AgentActions.js";
|
||||
import { AgentDetail } from "./AgentDetail.js";
|
||||
import type { AgentRow } from "../AgentTypes.js";
|
||||
|
||||
export function AgentDetailDialog({
|
||||
agent,
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
agent: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
// `undefined` until the roster has answered; `null` when it has and the
|
||||
// agent is not in it.
|
||||
const [row, setRow] = useState<AgentRow | null | undefined>(undefined);
|
||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||
const actions = useAgentActions((declaredRow, declared) => {
|
||||
const state = declared.find((d) => d.agent === declaredRow.name)?.state;
|
||||
if (state !== undefined) {
|
||||
setRow((prev) => (prev ? { ...prev, wanted: state } : prev));
|
||||
}
|
||||
});
|
||||
|
||||
// Fetched on each open, so what the dialog shows is current rather than
|
||||
// what the page last saw.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const res = await fetch("/api/agents/status");
|
||||
if (!res.ok) {
|
||||
if (!cancelled) setError(await readApiError(res));
|
||||
return;
|
||||
}
|
||||
const rows = (await res.json()) as AgentRow[];
|
||||
if (cancelled) return;
|
||||
setRow(rows.find((r) => r.name === agent) ?? null);
|
||||
setError(null);
|
||||
})().catch((e: unknown) => {
|
||||
if (!cancelled) setError({ detail: String(e) });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, agent]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onClose={onClose} label={`${agent} details`} plain>
|
||||
{open ? (
|
||||
<Panel title={agent} icon="🔎" onClose={onClose}>
|
||||
{error ? (
|
||||
<ApiErrorPanel
|
||||
context="failed to load the agent's details"
|
||||
problem={error}
|
||||
/>
|
||||
) : row ? (
|
||||
<AgentDetail row={row} actions={actions} />
|
||||
) : row === null ? (
|
||||
<p>{agent} is not in the swarm's agent roster</p>
|
||||
) : (
|
||||
<p>loading…</p>
|
||||
)}
|
||||
</Panel>
|
||||
) : null}
|
||||
</Dialog>
|
||||
{actions.dialogs}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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/agent/detail/AgentInference.tsx
Normal file
301
frontend/packages/swarm-ui/src/agent/detail/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,60 @@
|
|||
// <LinkAccountMenu> — one "link account" badge that opens a `Dropdown` with
|
||||
// the three kinds of account an agent can be linked to, in place of one
|
||||
// badge per kind: three side-by-side badges were wider than the detail
|
||||
// panel and spilled out of it. Presentational only, same split as
|
||||
// `WantedMenu`: the caller owns what each selection opens.
|
||||
import { useRef, useState } from "preact/hooks";
|
||||
import { Badge } from "@hive/shared/badge.js";
|
||||
import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js";
|
||||
import { LinkIcon } from "@hive/shared/icons.js";
|
||||
|
||||
export type LinkKind = "matrix" | "forge" | "github";
|
||||
|
||||
const OPTIONS: DropdownOption[] = [
|
||||
{ value: "matrix", label: "matrix account" },
|
||||
{ value: "forge", label: "forge account" },
|
||||
{ value: "github", label: "github account" },
|
||||
];
|
||||
|
||||
export function LinkAccountMenu({
|
||||
agent,
|
||||
hasHive,
|
||||
onSelect,
|
||||
}: {
|
||||
agent: string;
|
||||
/** Linking writes against the agent's hive, so without one there is
|
||||
* nothing to link against and the menu stays shut. */
|
||||
hasHive: boolean;
|
||||
onSelect: (kind: LinkKind) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
<div class="ui-dropdown-anchor" ref={anchorRef}>
|
||||
<Badge
|
||||
variant="quiet"
|
||||
icon={<LinkIcon />}
|
||||
value="link account"
|
||||
onClick={hasHive ? () => setOpen((o) => !o) : undefined}
|
||||
expanded={open}
|
||||
disabled={!hasHive}
|
||||
title={
|
||||
hasHive
|
||||
? `link a matrix, forge or github account to ${agent}`
|
||||
: "no hive on record for this agent — nothing to link against"
|
||||
}
|
||||
/>
|
||||
<Dropdown
|
||||
open={open}
|
||||
options={OPTIONS}
|
||||
label={`link an account to ${agent}`}
|
||||
onSelect={(value) => {
|
||||
setOpen(false);
|
||||
onSelect(value as LinkKind);
|
||||
}}
|
||||
onClose={() => setOpen(false)}
|
||||
anchorRef={anchorRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
/* <LinkedAccounts> — one account per line: kind/name badge, then host. */
|
||||
.ui-linked-accounts {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.3em;
|
||||
}
|
||||
.ui-linked-accounts li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ui-linked-accounts-host,
|
||||
.ui-linked-accounts-muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
197
frontend/packages/swarm-ui/src/agent/detail/LinkedAccounts.tsx
Normal file
197
frontend/packages/swarm-ui/src/agent/detail/LinkedAccounts.tsx
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
// <LinkedAccounts> — the accounts linked to one agent, one row each: kind,
|
||||
// name, host, and a delete action on every row but the agent's own account.
|
||||
// Reads `GET /api/hives/{hive}/agents/{agent}/linked-accounts`, which carries
|
||||
// names and hosts only, never a credential.
|
||||
//
|
||||
// Fetched on mount, after a delete, and whenever `version` changes;
|
||||
// `AgentsPage` bumps it when a link dialog closes, so an account linked there
|
||||
// shows up without waiting for a reload.
|
||||
//
|
||||
// Its state belongs to one agent: callers key it by hive and agent, so
|
||||
// switching agents remounts it instead of showing the previous agent's rows.
|
||||
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 { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
|
||||
import "./LinkedAccounts.css";
|
||||
|
||||
type AccountKind = "matrix" | "forgejo" | "github";
|
||||
|
||||
// Mirrors `linked_accounts::LinkedAccount`.
|
||||
interface LinkedAccount {
|
||||
kind: AccountKind;
|
||||
name: string;
|
||||
host: string | null;
|
||||
reserved: boolean;
|
||||
}
|
||||
|
||||
// The DELETE route for one account, beside the PUT that links it.
|
||||
function accountUrl(hive: string, agent: string, a: LinkedAccount): string {
|
||||
const base = `/api/hives/${encodeURIComponent(hive)}/agents/${encodeURIComponent(agent)}`;
|
||||
switch (a.kind) {
|
||||
case "matrix":
|
||||
return `${base}/matrix-accounts/${encodeURIComponent(a.name)}`;
|
||||
case "forgejo":
|
||||
return `${base}/forge-accounts/${encodeURIComponent(a.name)}`;
|
||||
case "github":
|
||||
return `${base}/github-account`;
|
||||
}
|
||||
}
|
||||
|
||||
export function LinkedAccounts({
|
||||
hive,
|
||||
agent,
|
||||
version,
|
||||
}: {
|
||||
hive: string;
|
||||
agent: string;
|
||||
version: number;
|
||||
}) {
|
||||
const [accounts, setAccounts] = useState<LinkedAccount[] | null>(null);
|
||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||
// Bumped after a delete, so the list is fetched again.
|
||||
const [reload, setReload] = useState(0);
|
||||
// The row whose delete confirmation is open; null means closed.
|
||||
const [deleteTarget, setDeleteTarget] = useState<LinkedAccount | null>(null);
|
||||
// Matrix only: log the token out at its homeserver before deleting.
|
||||
const [revoke, setRevoke] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<ProblemDetails | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const r = await fetch(
|
||||
`/api/hives/${encodeURIComponent(hive)}/agents/${encodeURIComponent(agent)}/linked-accounts`,
|
||||
);
|
||||
if (!r.ok) {
|
||||
if (!cancelled) setError(await readApiError(r));
|
||||
return;
|
||||
}
|
||||
const data = (await r.json()) as LinkedAccount[];
|
||||
if (cancelled) return;
|
||||
setAccounts(data);
|
||||
setError(null);
|
||||
})().catch((e: unknown) => {
|
||||
if (!cancelled) setError({ detail: String(e) });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [hive, agent, version, reload]);
|
||||
|
||||
function openDelete(a: LinkedAccount) {
|
||||
setDeleteTarget(a);
|
||||
setRevoke(false);
|
||||
setDeleteError(null);
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
const url = accountUrl(hive, agent, deleteTarget);
|
||||
const r = await fetch(
|
||||
deleteTarget.kind === "matrix" && revoke ? `${url}?revoke=true` : url,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
// A 404 means the account the dialog is about is already gone — the
|
||||
// end state the delete wanted — so it closes the same as a success.
|
||||
if (!r.ok && r.status !== 404) {
|
||||
setDeleteError(await readApiError(r));
|
||||
return;
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
setReload((n) => n + 1);
|
||||
} catch (e: unknown) {
|
||||
setDeleteError({ detail: String(e) });
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const dialog = (
|
||||
<ConfirmDialog
|
||||
open={deleteTarget !== null}
|
||||
label="delete linked account"
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={() => void confirmDelete()}
|
||||
confirmLabel="delete"
|
||||
confirmDisabled={deleting}
|
||||
>
|
||||
{deleteTarget ? (
|
||||
<>
|
||||
<p>
|
||||
Delete the {deleteTarget.kind} account{" "}
|
||||
<strong>{deleteTarget.name}</strong>
|
||||
{deleteTarget.host ? (
|
||||
<>
|
||||
{" "}
|
||||
on <strong>{deleteTarget.host}</strong>
|
||||
</>
|
||||
) : null}{" "}
|
||||
from <strong>{agent}</strong>? Every stored version of its token is
|
||||
removed from the swarm secret store. The agent is not told.
|
||||
</p>
|
||||
{deleteTarget.kind === "matrix" ? (
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={revoke}
|
||||
onChange={(e) =>
|
||||
setRevoke((e.target as HTMLInputElement).checked)
|
||||
}
|
||||
/>{" "}
|
||||
also log the token out at the homeserver; if that fails, the
|
||||
account is kept
|
||||
</label>
|
||||
) : null}
|
||||
{deleteError ? (
|
||||
<ApiErrorPanel context="delete failed" problem={deleteError} />
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</ConfirmDialog>
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Badge
|
||||
tone="negative"
|
||||
value="accounts unavailable"
|
||||
title={error.detail ?? "listing linked accounts failed"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (accounts === null) return <span class="ui-linked-accounts-muted">…</span>;
|
||||
if (accounts.length === 0) {
|
||||
return <span class="ui-linked-accounts-muted">none linked</span>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<ul class="ui-linked-accounts">
|
||||
{accounts.map((a) => (
|
||||
<li key={`${a.kind}/${a.name}`}>
|
||||
<Badge label={a.kind} value={a.name} />
|
||||
<span class="ui-linked-accounts-host">{a.host ?? "—"}</span>
|
||||
{a.reserved ? (
|
||||
<Badge
|
||||
value="own account"
|
||||
title="the agent's own account, which the swarm mints"
|
||||
/>
|
||||
) : (
|
||||
<Badge
|
||||
variant="quiet"
|
||||
value="delete"
|
||||
title={`delete the ${a.kind} account ${a.name}`}
|
||||
onClick={() => openDelete(a)}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{dialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
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
|
||||
// (`AgentDetailDialog`), so a state change reads the same from either; each
|
||||
// caller renders the returned `dialogs` once.
|
||||
//
|
||||
// "offline" and "paused" are the *same* confirm shape — same button
|
||||
// treatment, same "disruptive to a running turn loop, unlike up" reason for
|
||||
// needing a confirm at all — so one union state + one `ConfirmDialog` covers
|
||||
// both. Destroy is kept apart: it is irreversible, has a longer warning and
|
||||
// is danger-styled, not one more case of that shape.
|
||||
import { useState } from "preact/hooks";
|
||||
import type { ComponentChildren } from "preact";
|
||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||
import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
|
||||
import { Dialog } from "../../ui/dialog/Dialog.js";
|
||||
import { LinkMatrixAccountForm } from "../../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,
|
||||
};
|
||||
}
|
||||
37
frontend/packages/swarm-ui/src/agent/roster/AgentCard.css
Normal file
37
frontend/packages/swarm-ui/src/agent/roster/AgentCard.css
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/* <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 (`AgentIcon`) beside the name/message. */
|
||||
.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;
|
||||
}
|
||||
102
frontend/packages/swarm-ui/src/agent/roster/AgentCard.tsx
Normal file
102
frontend/packages/swarm-ui/src/agent/roster/AgentCard.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
// <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 { Badge } from "@hive/shared/badge.js";
|
||||
import type { ProblemDetails } from "@hive/shared/api-error.js";
|
||||
import { Card } from "../../ui/card/Card.js";
|
||||
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
|
||||
import { AgentIcon } from "../AgentIcon.js";
|
||||
import { FRESHNESS, type AgentRow } from "../AgentTypes.js";
|
||||
import { WantedMenu } from "./WantedMenu.js";
|
||||
import "./AgentCard.css";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
124
frontend/packages/swarm-ui/src/agent/roster/WantedMenu.tsx
Normal file
124
frontend/packages/swarm-ui/src/agent/roster/WantedMenu.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
// <WantedMenu> — the "wanted" state control: one badge showing the
|
||||
// current declaration, opening a `Dropdown` with the four explicit
|
||||
// states — replaces the old toggle-badge-plus-separate-destroy-badge
|
||||
// pair. Explicit options also fix a real bug the toggle had: for an
|
||||
// undeclared row, the toggle inferred a target as "the opposite of
|
||||
// `snapshot.running`", so a click on an undeclared-but-running agent
|
||||
// silently declared it *offline* rather than making its real state
|
||||
// explicit — a dropdown just lets "up" be picked directly regardless
|
||||
// of what's inferred, no flip-to-the-opposite-of-a-guess involved
|
||||
// (mara: "when no state is declared, i want to set it to online").
|
||||
// Presentational only, same split as `StatusChips`'s
|
||||
// `StatusMenu`: the caller owns what each selection actually does.
|
||||
//
|
||||
// "paused" has no separate resume option here — selecting "up" from a
|
||||
// paused row is the resume, same PUT either way (`AgentsPage`'s
|
||||
// `declareState`, `hive-c0re`'s `decide_pause` backend counterpart,
|
||||
// treats a declared `Up` with the marker still set as "clear it"). A
|
||||
// dedicated "resume" entry would just be a second spelling of the
|
||||
// option already above it.
|
||||
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";
|
||||
|
||||
export function WantedMenu({
|
||||
row,
|
||||
pending,
|
||||
onSelectUp,
|
||||
onSelectOffline,
|
||||
onSelectPaused,
|
||||
onDestroy,
|
||||
showDestroy = true,
|
||||
portal = false,
|
||||
}: {
|
||||
row: AgentRow;
|
||||
pending: boolean;
|
||||
onSelectUp: (row: AgentRow) => void;
|
||||
onSelectOffline: (row: AgentRow) => void;
|
||||
onSelectPaused: (row: AgentRow) => void;
|
||||
onDestroy?: (row: AgentRow) => void;
|
||||
/**
|
||||
* The card view (see `AgentCard`) passes `false` here — its
|
||||
* quick-access menu keeps to the three everyday states, so "destroy"
|
||||
* can't be reached by an extra click off a state that's already open.
|
||||
* `AgentsPage`'s detail-panel `WantedMenu` keeps the default (`true`)
|
||||
* — mara: "destroy is already available via wanted state", i.e. the
|
||||
* full menu there, not a separate button.
|
||||
*/
|
||||
showDestroy?: boolean;
|
||||
/**
|
||||
* Forwarded to `Dropdown`'s own `portal` (see its file-top comment) —
|
||||
* only the table's "wanted" column needs it, to escape
|
||||
* `.ui-table-scroll`'s clip on the table's last row (the bug `Dropdown`'s
|
||||
* own `portal` prop exists to fix in the first place).
|
||||
* Neither the card list nor the detail panel has a clipping ancestor,
|
||||
* and inside the detail panel specifically `portal` is actively wrong:
|
||||
* a `position: fixed` element appended to `document.body` renders
|
||||
* *behind* an open native `<dialog>` (the dialog is promoted to the
|
||||
* browser's top layer, which composites above ordinary body content
|
||||
* regardless of z-index) — found while screenshotting this exact
|
||||
* dropdown open inside the detail panel, only the portion extending
|
||||
* past the dialog's own edge was visible.
|
||||
*/
|
||||
portal?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
const destroyed = row.wanted === "destroyed";
|
||||
const tone: BadgeTone =
|
||||
row.wanted === "up"
|
||||
? "positive"
|
||||
: row.wanted === "paused"
|
||||
? "warning"
|
||||
: destroyed
|
||||
? "negative"
|
||||
: "neutral";
|
||||
const options: DropdownOption[] = [
|
||||
{ value: "up", label: "up" },
|
||||
{ value: "paused", label: "paused" },
|
||||
{ value: "offline", label: "offline" },
|
||||
...(showDestroy
|
||||
? [{ value: "destroy", label: "destroy", danger: true }]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div class="ui-dropdown-anchor" ref={anchorRef}>
|
||||
<Badge
|
||||
tone={tone}
|
||||
value={pending ? "…" : (row.wanted ?? "no declaration")}
|
||||
// `destroyed` blocks opening the menu at all — same UX-only
|
||||
// guard as before (real enforcement is server-side, see
|
||||
// `declareState`'s caller), just applied to the trigger instead
|
||||
// of a `disabled` toggle badge.
|
||||
onClick={row.hive && !destroyed ? () => setOpen((o) => !o) : undefined}
|
||||
expanded={open}
|
||||
disabled={pending || !row.hive || destroyed}
|
||||
title={
|
||||
destroyed
|
||||
? `${row.name} is destroyed — redeploy via "+ agent" to bring it back`
|
||||
: row.hive
|
||||
? `declare a new state for ${row.name}`
|
||||
: "no hive on record for this agent — nothing to declare against"
|
||||
}
|
||||
/>
|
||||
<Dropdown
|
||||
open={open}
|
||||
portal={portal}
|
||||
options={options}
|
||||
activeValue={row.wanted ?? undefined}
|
||||
label={`declare ${row.name}`}
|
||||
onSelect={(value) => {
|
||||
setOpen(false);
|
||||
if (value === "up") onSelectUp(row);
|
||||
else if (value === "offline") onSelectOffline(row);
|
||||
else if (value === "paused") onSelectPaused(row);
|
||||
else onDestroy?.(row);
|
||||
}}
|
||||
onClose={() => setOpen(false)}
|
||||
anchorRef={anchorRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,288 @@
|
|||
// <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 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";
|
||||
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,
|
||||
leading,
|
||||
}: {
|
||||
agentName: string;
|
||||
// Set: this is one of `agentName`'s subagents, output only like the
|
||||
// agent's own.
|
||||
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);
|
||||
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">
|
||||
{leading}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
/* <SubagentTerms> — one row of subagent names under the agent's own
|
||||
terminal preview; the picked subagent's preview sits below it. */
|
||||
.ui-subagent-terms {
|
||||
margin-block-start: 1rem;
|
||||
}
|
||||
|
||||
.ui-subagent-terms-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.ui-subagent-terms-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35em;
|
||||
}
|
||||
|
||||
.ui-subagent-terms-item-stale {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.ui-subagent-terms-age {
|
||||
color: var(--muted);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.ui-subagent-terms-heading {
|
||||
color: var(--muted);
|
||||
}
|
||||
107
frontend/packages/swarm-ui/src/agent/terminal/SubagentTerms.tsx
Normal file
107
frontend/packages/swarm-ui/src/agent/terminal/SubagentTerms.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
// <SubagentTerms> — an agent's subagents, under its own terminal preview in
|
||||
// `AgentsPage`'s detail panel. The list is `GET /api/agents/{name}/subagents`:
|
||||
// the subagents the swarm queue holds terminal rows for, each with the
|
||||
// publish time of its newest row. Newest first, with a ticking age beside
|
||||
// the name; one quiet for `STALE_S` is dimmed. Subagents are spawned on
|
||||
// demand, so it is refetched every `REFRESH_MS`. Picking one shows its
|
||||
// terminal in `AgentTermPreview`, expand-to-tab included. Read-only like the
|
||||
// agent's own: subagents take no input.
|
||||
//
|
||||
// Mount it keyed by agent name: the refresh timer is armed once per mount.
|
||||
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 { Badge } from "@hive/shared/badge.js";
|
||||
import { AgentTermPreview } from "./AgentTermPreview.js";
|
||||
import { useRefreshInterval } from "../../ui/refresh-interval/RefreshInterval.js";
|
||||
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
|
||||
import "./SubagentTerms.css";
|
||||
|
||||
const REFRESH_MS = 10_000;
|
||||
const STALE_S = 15 * 60;
|
||||
|
||||
interface Subagent {
|
||||
name: string;
|
||||
last_row_unix: number | null;
|
||||
}
|
||||
|
||||
// Newest row first; a subagent whose time could not be read sorts last.
|
||||
function byRecency(a: Subagent, b: Subagent): number {
|
||||
const ta = a.last_row_unix ?? -Infinity;
|
||||
const tb = b.last_row_unix ?? -Infinity;
|
||||
if (ta !== tb) return tb > ta ? 1 : -1;
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
export function SubagentTerms({ agentName }: { agentName: string }) {
|
||||
const [subagents, setSubagents] = useState<Subagent[] | null>(null);
|
||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
|
||||
async function refresh() {
|
||||
const res = await fetch(
|
||||
`/api/agents/${encodeURIComponent(agentName)}/subagents`,
|
||||
);
|
||||
if (!res.ok) {
|
||||
setError(await readApiError(res));
|
||||
return;
|
||||
}
|
||||
setSubagents(((await res.json()) as Subagent[]).sort(byRecency));
|
||||
setError(null);
|
||||
}
|
||||
|
||||
useRefreshInterval(REFRESH_MS, () => {
|
||||
refresh().catch((e: unknown) => setError({ detail: String(e) }));
|
||||
});
|
||||
|
||||
// An agent with no subagents shows nothing here — no heading, no
|
||||
// "none" line — and so does the first fetch while it is in flight. A
|
||||
// failed fetch still shows, so the error is not swallowed.
|
||||
if (!error && (subagents === null || subagents.length === 0)) return null;
|
||||
|
||||
const nowS = Date.now() / 1000;
|
||||
|
||||
return (
|
||||
<section class="ui-subagent-terms" aria-label={`${agentName}'s subagents`}>
|
||||
<div class="ui-subagent-terms-list">
|
||||
<span class="ui-subagent-terms-heading">subagents</span>
|
||||
{subagents?.map(({ name, last_row_unix }) => {
|
||||
const stale =
|
||||
last_row_unix !== null && nowS - last_row_unix > STALE_S;
|
||||
return (
|
||||
<span
|
||||
key={name}
|
||||
class={`ui-subagent-terms-item${stale ? " ui-subagent-terms-item-stale" : ""}`}
|
||||
>
|
||||
<Badge
|
||||
variant={name === selected ? "default" : "quiet"}
|
||||
value={name}
|
||||
expanded={name === selected}
|
||||
title={
|
||||
name === selected
|
||||
? `hide ${name}'s terminal`
|
||||
: `show ${name}'s terminal`
|
||||
}
|
||||
onClick={() => setSelected(name === selected ? null : name)}
|
||||
/>
|
||||
{last_row_unix !== null && (
|
||||
<span class="ui-subagent-terms-age">
|
||||
<RelativeTime epochMs={last_row_unix * 1000} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{error && (
|
||||
<ApiErrorPanel
|
||||
problem={error}
|
||||
context={`failed to list ${agentName}'s subagents`}
|
||||
/>
|
||||
)}
|
||||
{selected && (
|
||||
<AgentTermPreview agentName={agentName} subagent={selected} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
// useAgentFavicon — while the calling page is mounted, the browser tab's icon
|
||||
// is this agent's icon, so tabs of different agents tell apart at a glance.
|
||||
//
|
||||
// The icon comes from swarm-controller's `GET /api/agents/{name}/icon`, an
|
||||
// SVG that is a 404 for an agent with none. A `<link>` can't report a failed
|
||||
// load, so an off-screen image loads it first and the tab icon switches only
|
||||
// once that succeeds: an agent with no icon, or an unreachable controller,
|
||||
// keeps the hyperhive mark. Leaving the page puts the page's own icon back.
|
||||
import { useEffect } from "preact/hooks";
|
||||
|
||||
export function useAgentFavicon(agent: string): void {
|
||||
useEffect(() => {
|
||||
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
||||
if (link === null) return;
|
||||
const original = {
|
||||
href: link.getAttribute("href"),
|
||||
type: link.getAttribute("type"),
|
||||
};
|
||||
const url = `/api/agents/${encodeURIComponent(agent)}/icon`;
|
||||
let cancelled = false;
|
||||
|
||||
const probe = new Image();
|
||||
probe.onload = () => {
|
||||
if (cancelled) return;
|
||||
link.setAttribute("type", "image/svg+xml");
|
||||
link.setAttribute("href", url);
|
||||
};
|
||||
probe.src = url;
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
for (const [attr, value] of Object.entries(original)) {
|
||||
if (value === null) link.removeAttribute(attr);
|
||||
else link.setAttribute(attr, value);
|
||||
}
|
||||
};
|
||||
}, [agent]);
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
// Live SSE consumer for the swarm-relayed agent turn-state header
|
||||
// (`GET /api/agents/{name}/state/stream`, swarm-controller's
|
||||
// `agent_state_stream.rs`). Same transport shape as `useSwarmTermStream`
|
||||
// — one core-NATS subject relayed as bare SSE `data`, no envelope, no
|
||||
// replay — but the payload here is a full header that *replaces* the
|
||||
// last one rather than an appendable row, so there is nothing to
|
||||
// coalesce: each event is just the latest value.
|
||||
//
|
||||
// **No seed on attach.** `agent_state_stream.rs`'s own module doc names
|
||||
// this limitation: the subject is published on transition and re-sent
|
||||
// once a minute, so an agent that has sat idle since before this hook
|
||||
// mounted announces nothing for up to a minute. `header` stays `null`
|
||||
// until the first event lands — callers should render no badges in that
|
||||
// gap rather than a stale or guessed one (there is no swarm-level
|
||||
// endpoint today that can seed turn_state/model/ctx/cost synchronously —
|
||||
// `GET /api/agents/status`'s snapshot carries only
|
||||
// `status_text`/`running`).
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
export type ConnectionState = "connecting" | "open" | "reconnecting" | "closed";
|
||||
|
||||
export interface TokenUsage {
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
cache_read_input_tokens?: number;
|
||||
cache_creation_input_tokens?: number;
|
||||
}
|
||||
|
||||
// `swarm_queue_client::agent_inference::Source`.
|
||||
export type InferenceSource = "swarm" | "agent_nix" | "local_file" | "default";
|
||||
|
||||
// Mirrors `hive_agent::swarm_agent_state::AgentStateMsg` field-for-field
|
||||
// — see that struct's own doc comment for why `turn_state_since` (an
|
||||
// ISO 8601 string here, unlike the per-agent page's unix-seconds
|
||||
// `/api/state`) and `agent_state` (the swarm `AgentState` enum, not a
|
||||
// bare `paused: bool`) diverge from the per-agent page's own shape.
|
||||
export interface AgentStateHeader {
|
||||
turn_state: "idle" | "thinking" | "compacting";
|
||||
turn_state_since: string;
|
||||
agent_state: "up" | "offline" | "paused" | "destroyed";
|
||||
model: string;
|
||||
effort: string;
|
||||
model_source: InferenceSource;
|
||||
effort_source: InferenceSource;
|
||||
resolved_model: string | null;
|
||||
context_window_tokens: number;
|
||||
ctx_usage: TokenUsage | null;
|
||||
cost_usage: TokenUsage | null;
|
||||
}
|
||||
|
||||
export interface UseSwarmAgentStateStreamResult {
|
||||
header: AgentStateHeader | null;
|
||||
connection: ConnectionState;
|
||||
}
|
||||
|
||||
export function useSwarmAgentStateStream(
|
||||
streamUrl: string | null,
|
||||
): UseSwarmAgentStateStreamResult {
|
||||
const [header, setHeader] = useState<AgentStateHeader | null>(null);
|
||||
const [connection, setConnection] = useState<ConnectionState>("connecting");
|
||||
|
||||
useEffect(() => {
|
||||
setHeader(null);
|
||||
setConnection("connecting");
|
||||
// `null`: a terminal with no header to follow (a subagent's).
|
||||
if (streamUrl === null) return;
|
||||
const es = new EventSource(streamUrl);
|
||||
es.onopen = () => setConnection("open");
|
||||
es.onmessage = (e) => {
|
||||
try {
|
||||
setHeader(JSON.parse(e.data) as AgentStateHeader);
|
||||
} catch {
|
||||
// Malformed payload: drop it silently. Unlike the terminal
|
||||
// preview there is no row list to append a "[parse err]" line
|
||||
// to here, and one bad header isn't worth degrading the whole
|
||||
// badge cluster over — the next valid header replaces it.
|
||||
}
|
||||
};
|
||||
es.onerror = () => {
|
||||
setConnection(
|
||||
es.readyState === 0 /* CONNECTING */ ? "reconnecting" : "closed",
|
||||
);
|
||||
};
|
||||
return () => es.close();
|
||||
}, [streamUrl]);
|
||||
|
||||
return { header, connection };
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
// Live SSE consumer for the swarm-relayed agent terminal
|
||||
// (`GET /api/agents/{name}/term/stream`, swarm-controller's
|
||||
// `term_stream.rs`). Deliberately NOT `@hive/agent`'s `useLiveStream` —
|
||||
// that hook's wire shape is `TermEnvelope` (`{seq, msgs: []}`) plus a
|
||||
// history/backfill dance; the swarm relay forwards one bare `TermMsg`
|
||||
// per SSE event, its replayed rows first and in order on the same
|
||||
// stream, so there's nothing to buffer/dedupe/backfill against. The
|
||||
// browser's own `Last-Event-ID` resumes a reconnect, so rows are kept
|
||||
// across one. Same row-coalescing
|
||||
// rule as `useLiveStream`'s `appendRow` (ported, not imported — one
|
||||
// small helper isn't worth a cross-package hop for).
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import type { TermMsg, TermRow } from "@hive/shared/term-msg.js";
|
||||
|
||||
export type ConnectionState = "connecting" | "open" | "reconnecting" | "closed";
|
||||
|
||||
function appendRow(rows: TermRow[], row: TermRow): TermRow[] {
|
||||
const last = rows[rows.length - 1];
|
||||
if (row.coalesce_key && last && last.coalesce_key === row.coalesce_key) {
|
||||
return [...rows.slice(0, -1), { ...row, key: last.key }];
|
||||
}
|
||||
return [...rows, row];
|
||||
}
|
||||
|
||||
export interface UseSwarmTermStreamResult {
|
||||
rows: TermRow[];
|
||||
connection: ConnectionState;
|
||||
}
|
||||
|
||||
export function useSwarmTermStream(
|
||||
streamUrl: string,
|
||||
): UseSwarmTermStreamResult {
|
||||
const [rows, setRows] = useState<TermRow[]>([]);
|
||||
const [connection, setConnection] = useState<ConnectionState>("connecting");
|
||||
const keySeqRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
setRows([]);
|
||||
setConnection("connecting");
|
||||
keySeqRef.current = 0;
|
||||
const es = new EventSource(streamUrl);
|
||||
es.onopen = () => setConnection("open");
|
||||
es.onmessage = (e) => {
|
||||
keySeqRef.current += 1;
|
||||
const key = "r" + keySeqRef.current;
|
||||
let msg: TermMsg;
|
||||
try {
|
||||
msg = JSON.parse(e.data);
|
||||
} catch {
|
||||
setRows((prev) =>
|
||||
appendRow(prev, {
|
||||
key,
|
||||
level: "warn",
|
||||
fromHistory: false,
|
||||
summary: "[parse err] " + e.data,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setRows((prev) => appendRow(prev, { ...msg, key, fromHistory: false }));
|
||||
};
|
||||
es.onerror = () => {
|
||||
setConnection(
|
||||
es.readyState === 0 /* CONNECTING */ ? "reconnecting" : "closed",
|
||||
);
|
||||
};
|
||||
return () => es.close();
|
||||
}, [streamUrl]);
|
||||
|
||||
return { rows, connection };
|
||||
}
|
||||
Loading…
Reference in a new issue