diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 5b2372d4..62882145 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -39,6 +39,15 @@ terminal in the same preview, and **expand** opens it in its own tab, as it does for the agent. The list refreshes every 10 seconds, so a subagent the agent spawns shows up once it writes output. +On a terminal's own page (`/agents//terminal`, or a subagent's) the +header has an **N running subagents** badge. It opens a list of the agent's +subagents, each a link to its terminal; on a subagent's page the current one +gets a marker and the list starts with a link back to the agent's own terminal. A +subagent counts as running when it published a row in the last 15 minutes. The +swarm doesn't report more than when the newest row came, so a subagent that +thinks quietly for longer drops out of the count. The list shows the others below, +dimmed, as **not running**. + An agent's terminal opens with its last 100 rows from the past 24 hours, then follows new ones; a reconnect picks up after the last row it showed. A subagent terminal shows rows published while it's open, and has no turn-state badges. diff --git a/frontend/packages/swarm-ui/src/agent/terminal/SubagentTerms.tsx b/frontend/packages/swarm-ui/src/agent/terminal/SubagentTerms.tsx index 7ba1d68b..a053d2bf 100644 --- a/frontend/packages/swarm-ui/src/agent/terminal/SubagentTerms.tsx +++ b/frontend/packages/swarm-ui/src/agent/terminal/SubagentTerms.tsx @@ -3,57 +3,24 @@ // 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 +// demand, so `useSubagentList` refetches it. 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 { STALE_S } from "./subagent-list.js"; +import { useSubagentList } from "./useSubagentList.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(null); - const [error, setError] = useState(null); + const { subagents, error } = useSubagentList(agentName); const [selected, setSelected] = useState(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. diff --git a/frontend/packages/swarm-ui/src/agent/terminal/SubagentsMenu.css b/frontend/packages/swarm-ui/src/agent/terminal/SubagentsMenu.css new file mode 100644 index 00000000..413a055c --- /dev/null +++ b/frontend/packages/swarm-ui/src/agent/terminal/SubagentsMenu.css @@ -0,0 +1,47 @@ +/* : the open list under the header badge. */ +.ui-subagents-menu { + display: flex; + flex-direction: column; + gap: 0.25em; +} +.ui-subagents-menu-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; +} +.ui-subagents-menu-link { + display: flex; + justify-content: space-between; + gap: 1em; + padding: 0.4em 0.5em; + border-radius: 0.35em; + color: inherit; + text-decoration: none; +} +.ui-subagents-menu-link:hover, +.ui-subagents-menu-link:focus-visible { + background: var(--bg); +} +.ui-subagents-menu-link[aria-current="page"] { + border-left: 3px solid var(--purple); + font-weight: 600; +} +.ui-subagents-menu-name { + overflow-wrap: anywhere; +} +.ui-subagents-menu-age, +.ui-subagents-menu-heading { + color: var(--muted); + font-size: 0.85em; +} +.ui-subagents-menu-heading { + margin: 0.4em 0.5em 0; +} +.ui-subagents-menu-quiet { + opacity: 0.7; +} +.ui-subagents-menu-back { + color: var(--blue); +} diff --git a/frontend/packages/swarm-ui/src/agent/terminal/SubagentsMenu.tsx b/frontend/packages/swarm-ui/src/agent/terminal/SubagentsMenu.tsx new file mode 100644 index 00000000..3fc2778d --- /dev/null +++ b/frontend/packages/swarm-ui/src/agent/terminal/SubagentsMenu.tsx @@ -0,0 +1,145 @@ +// — "N running subagents" in the full-screen terminal page's +// header: a badge that opens a list of the agent's subagents, each a link to +// its own terminal page. On a subagent's page the current one is marked and the +// list starts with a way back to the agent's own terminal. The list is the +// same `GET /api/agents/{name}/subagents` as the one under the preview, and +// "running" means the same too: a row published within `STALE_S` +// (`subagent-list.ts`). Quiet subagents are listed below, dimmed, so a finished +// one can still be opened. +// +// Nothing shows for an agent with no subagents, and nothing before the first +// answer; a failed fetch shows its reason in the open list. +import { useRef, useState } from "preact/hooks"; +import { Link } from "wouter-preact"; +import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; +import { Badge } from "@hive/shared/badge.js"; +import { Panel } from "../../ui/panel/Panel.js"; +import { Popover } from "../../ui/popover/Popover.js"; +import { RelativeTime } from "../../ui/relative-time/RelativeTime.js"; +import { termPaths } from "./AgentTermPreview.js"; +import { runningLabel, splitRunning, type Subagent } from "./subagent-list.js"; +import { useSubagentList } from "./useSubagentList.js"; +import "./SubagentsMenu.css"; + +function Entry({ + agent, + sub, + current, + onPick, +}: { + agent: string; + sub: Subagent; + current: boolean; + onPick: () => void; +}) { + return ( +
  • + + {sub.name} + {sub.last_row_unix !== null && ( + + + + )} + +
  • + ); +} + +export function SubagentsMenu({ + agentName, + current, +}: { + agentName: string; + /** The subagent whose page this is, when it is a subagent's. */ + current?: string; +}) { + const { subagents, error } = useSubagentList(agentName); + const [open, setOpen] = useState(false); + const anchorRef = useRef(null); + + if (!error && (subagents === null || subagents.length === 0)) return null; + + const { running, quiet } = splitRunning(subagents ?? [], Date.now() / 1000); + + return ( + <> + + 0 ? "positive" : "neutral"} + title={`${open ? "close" : "open"} ${agentName}'s subagents`} + expanded={open} + onClick={() => setOpen((o) => !o)} + /> + + setOpen(false)} + anchorRef={anchorRef} + label={`${agentName}'s subagents`} + > + setOpen(false)} + > +
    + {error && ( + + )} + {current !== undefined && ( + setOpen(false)} + > + ← {agentName}'s own terminal + + )} + {running.length > 0 && ( +
      + {running.map((s) => ( + setOpen(false)} + /> + ))} +
    + )} + {quiet.length > 0 && ( + <> +

    not running

    +
      + {quiet.map((s) => ( + setOpen(false)} + /> + ))} +
    + + )} +
    +
    +
    + + ); +} diff --git a/frontend/packages/swarm-ui/src/agent/terminal/subagent-list.ts b/frontend/packages/swarm-ui/src/agent/terminal/subagent-list.ts new file mode 100644 index 00000000..9de9262c --- /dev/null +++ b/frontend/packages/swarm-ui/src/agent/terminal/subagent-list.ts @@ -0,0 +1,50 @@ +// What the subagent list means, without a DOM: the wire row, which subagents +// count as running, and the words for the count. Shared by the list under an +// agent's terminal preview and the menu on the full-screen terminal page, so +// the two agree on what "running" is. + +/** One row of `GET /api/agents/{name}/subagents`. */ +export interface Subagent { + name: string; + /** Unix seconds of its newest terminal row, `null` when not readable. */ + last_row_unix: number | null; +} + +/** A subagent quiet for longer than this is no longer counted as running. */ +export const STALE_S = 15 * 60; + +/** + * Whether a subagent counts as running: it published a row within + * [`STALE_S`]. The controller reports only when the newest row was published, + * so this is a guess. A subagent that is thinking quietly drops out, and one + * that finished stays in until the window passes. + */ +export function isRunning(sub: Subagent, nowS: number): boolean { + return sub.last_row_unix !== null && nowS - sub.last_row_unix <= STALE_S; +} + +/** Newest row first; a subagent whose time could not be read sorts last. */ +export 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); +} + +/** The list split into the running subagents and the quiet ones, each newest + * first. */ +export function splitRunning( + subs: Subagent[], + nowS: number, +): { running: Subagent[]; quiet: Subagent[] } { + const sorted = [...subs].sort(byRecency); + return { + running: sorted.filter((s) => isRunning(s, nowS)), + quiet: sorted.filter((s) => !isRunning(s, nowS)), + }; +} + +/** The badge's text: `1 running subagent`, `3 running subagents`. */ +export function runningLabel(count: number): string { + return count === 1 ? "1 running subagent" : `${count} running subagents`; +} diff --git a/frontend/packages/swarm-ui/src/agent/terminal/useSubagentList.ts b/frontend/packages/swarm-ui/src/agent/terminal/useSubagentList.ts new file mode 100644 index 00000000..6810f3f7 --- /dev/null +++ b/frontend/packages/swarm-ui/src/agent/terminal/useSubagentList.ts @@ -0,0 +1,36 @@ +// An agent's subagents, refetched on a timer: `GET /api/agents/{name}/subagents`. +// Subagents are spawned on demand, so a one-off fetch would go stale. `null` +// until the first answer; a failed fetch sets `error` and keeps the last list. +// Mount per agent name: the timer is armed once per mount. +import { useState } from "preact/hooks"; +import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; +import { useRefreshInterval } from "../../ui/refresh-interval/RefreshInterval.js"; +import { byRecency, type Subagent } from "./subagent-list.js"; + +const REFRESH_MS = 10_000; + +export function useSubagentList(agentName: string): { + subagents: Subagent[] | null; + error: ProblemDetails | null; +} { + const [subagents, setSubagents] = useState(null); + const [error, setError] = useState(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) })); + }); + + return { subagents, error }; +} diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentTerminalPage.tsx b/frontend/packages/swarm-ui/src/pages/agents/AgentTerminalPage.tsx index 248a907e..5d909dda 100644 --- a/frontend/packages/swarm-ui/src/pages/agents/AgentTerminalPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/agents/AgentTerminalPage.tsx @@ -14,6 +14,7 @@ import type { RouteComponentProps } from "wouter-preact"; import { Badge } from "@hive/shared/badge.js"; import { AgentDetailPopover } from "../../agent/detail/AgentDetailPopover.js"; import { AgentIcon } from "../../agent/AgentIcon.js"; +import { SubagentsMenu } from "../../agent/terminal/SubagentsMenu.js"; import { AgentTermPreview, termPaths, @@ -63,15 +64,18 @@ export function AgentTerminalPage({ fullHeight showHeaderBadges leading={ - - } - value={name} - title={`${detailOpen ? "close" : "open"} ${name}'s details`} - expanded={detailOpen} - onClick={() => setDetailOpen((o) => !o)} - /> - + <> + + } + value={name} + title={`${detailOpen ? "close" : "open"} ${name}'s details`} + expanded={detailOpen} + onClick={() => setDetailOpen((o) => !o)} + /> + + + } /> ({ + name, + last_row_unix: ago === null ? null : NOW - ago, +}); + +test("a subagent with a recent row is running, a quiet one is not", () => { + assert.equal(isRunning(sub("a", 5), NOW), true); + assert.equal(isRunning(sub("a", STALE_S), NOW), true); + assert.equal(isRunning(sub("a", STALE_S + 1), NOW), false); +}); + +test("a subagent whose time could not be read is not counted as running", () => { + assert.equal(isRunning(sub("a", null), NOW), false); +}); + +test("newest first, unreadable last, ties by name", () => { + const list = [sub("c", 50), sub("z", null), sub("b", 10), sub("a", 10)]; + assert.deepEqual( + [...list].sort(byRecency).map((s) => s.name), + ["a", "b", "c", "z"], + ); +}); + +test("the list splits into running and quiet, each newest first", () => { + const { running, quiet } = splitRunning( + [ + sub("old", STALE_S + 100), + sub("new", 1), + sub("mid", 60), + sub("none", null), + ], + NOW, + ); + assert.deepEqual( + running.map((s) => s.name), + ["new", "mid"], + ); + assert.deepEqual( + quiet.map((s) => s.name), + ["old", "none"], + ); +}); + +test("the badge counts in words", () => { + assert.equal(runningLabel(1), "1 running subagent"); + assert.equal(runningLabel(4), "4 running subagents"); +});