diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 62882145..5b2372d4 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -39,15 +39,6 @@ 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 a053d2bf..7ba1d68b 100644 --- a/frontend/packages/swarm-ui/src/agent/terminal/SubagentTerms.tsx +++ b/frontend/packages/swarm-ui/src/agent/terminal/SubagentTerms.tsx @@ -3,24 +3,57 @@ // 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 `useSubagentList` refetches it. Picking one shows its +// 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 { STALE_S } from "./subagent-list.js"; -import { useSubagentList } from "./useSubagentList.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, error } = useSubagentList(agentName); + const [subagents, setSubagents] = useState(null); + const [error, setError] = useState(null); 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 deleted file mode 100644 index 413a055c..00000000 --- a/frontend/packages/swarm-ui/src/agent/terminal/SubagentsMenu.css +++ /dev/null @@ -1,47 +0,0 @@ -/* : 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 deleted file mode 100644 index 3fc2778d..00000000 --- a/frontend/packages/swarm-ui/src/agent/terminal/SubagentsMenu.tsx +++ /dev/null @@ -1,145 +0,0 @@ -// — "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 deleted file mode 100644 index 9de9262c..00000000 --- a/frontend/packages/swarm-ui/src/agent/terminal/subagent-list.ts +++ /dev/null @@ -1,50 +0,0 @@ -// 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 deleted file mode 100644 index 6810f3f7..00000000 --- a/frontend/packages/swarm-ui/src/agent/terminal/useSubagentList.ts +++ /dev/null @@ -1,36 +0,0 @@ -// 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 5d909dda..248a907e 100644 --- a/frontend/packages/swarm-ui/src/pages/agents/AgentTerminalPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/agents/AgentTerminalPage.tsx @@ -14,7 +14,6 @@ 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, @@ -64,18 +63,15 @@ 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"); -});