swarm-ui: a running-subagents menu in the full-screen terminal header
An N running subagents badge opens a list of the agent's subagents, each a link to its terminal, so you can switch without going back to the agents page. A subagent counts as running when it published a row in the last 15 minutes; the quiet ones are listed below it, dimmed. The list under the preview and the menu now share one fetch hook and one definition of running.
This commit is contained in:
parent
0e77d61a9f
commit
664409c87f
8 changed files with 360 additions and 46 deletions
|
|
@ -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/<name>/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.
|
||||
|
|
|
|||
|
|
@ -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<Subagent[] | null>(null);
|
||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||
const { subagents, error } = useSubagentList(agentName);
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
/* <SubagentsMenu>: 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);
|
||||
}
|
||||
140
frontend/packages/swarm-ui/src/agent/terminal/SubagentsMenu.tsx
Normal file
140
frontend/packages/swarm-ui/src/agent/terminal/SubagentsMenu.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
// <SubagentsMenu> — "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,
|
||||
}: {
|
||||
agent: string;
|
||||
sub: Subagent;
|
||||
current: boolean;
|
||||
}) {
|
||||
return (
|
||||
<li>
|
||||
<Link
|
||||
href={termPaths(agent, sub.name).page}
|
||||
class="ui-subagents-menu-link"
|
||||
aria-current={current ? "page" : undefined}
|
||||
>
|
||||
<span class="ui-subagents-menu-name">{sub.name}</span>
|
||||
{sub.last_row_unix !== null && (
|
||||
<span class="ui-subagents-menu-age">
|
||||
<RelativeTime epochMs={sub.last_row_unix * 1000} />
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
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<HTMLSpanElement>(null);
|
||||
|
||||
if (!error && (subagents === null || subagents.length === 0)) return null;
|
||||
|
||||
const { running, quiet } = splitRunning(subagents ?? [], Date.now() / 1000);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span ref={anchorRef}>
|
||||
<Badge
|
||||
value={runningLabel(running.length)}
|
||||
tone={running.length > 0 ? "positive" : "neutral"}
|
||||
title={`${open ? "close" : "open"} ${agentName}'s subagents`}
|
||||
expanded={open}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
/>
|
||||
</span>
|
||||
<Popover
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
anchorRef={anchorRef}
|
||||
label={`${agentName}'s subagents`}
|
||||
>
|
||||
<Panel
|
||||
title={`${agentName}'s subagents`}
|
||||
icon="🧩"
|
||||
onClose={() => setOpen(false)}
|
||||
>
|
||||
<div class="ui-subagents-menu">
|
||||
{error && (
|
||||
<ApiErrorPanel
|
||||
context={`failed to list ${agentName}'s subagents`}
|
||||
problem={error}
|
||||
/>
|
||||
)}
|
||||
{current !== undefined && (
|
||||
<Link
|
||||
href={termPaths(agentName).page}
|
||||
class="ui-subagents-menu-link ui-subagents-menu-back"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
← {agentName}'s own terminal
|
||||
</Link>
|
||||
)}
|
||||
{running.length > 0 && (
|
||||
<ul class="ui-subagents-menu-list" aria-label="running">
|
||||
{running.map((s) => (
|
||||
<Entry
|
||||
key={s.name}
|
||||
agent={agentName}
|
||||
sub={s}
|
||||
current={s.name === current}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{quiet.length > 0 && (
|
||||
<>
|
||||
<p class="ui-subagents-menu-heading">not running</p>
|
||||
<ul
|
||||
class="ui-subagents-menu-list ui-subagents-menu-quiet"
|
||||
aria-label="not running"
|
||||
>
|
||||
{quiet.map((s) => (
|
||||
<Entry
|
||||
key={s.name}
|
||||
agent={agentName}
|
||||
sub={s}
|
||||
current={s.name === current}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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`;
|
||||
}
|
||||
|
|
@ -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<Subagent[] | null>(null);
|
||||
const [error, setError] = useState<ProblemDetails | 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) }));
|
||||
});
|
||||
|
||||
return { subagents, error };
|
||||
}
|
||||
|
|
@ -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={
|
||||
<span ref={badgeRef}>
|
||||
<Badge
|
||||
icon={<AgentIcon name={name} small />}
|
||||
value={name}
|
||||
title={`${detailOpen ? "close" : "open"} ${name}'s details`}
|
||||
expanded={detailOpen}
|
||||
onClick={() => setDetailOpen((o) => !o)}
|
||||
/>
|
||||
</span>
|
||||
<>
|
||||
<span ref={badgeRef}>
|
||||
<Badge
|
||||
icon={<AgentIcon name={name} small />}
|
||||
value={name}
|
||||
title={`${detailOpen ? "close" : "open"} ${name}'s details`}
|
||||
expanded={detailOpen}
|
||||
onClick={() => setDetailOpen((o) => !o)}
|
||||
/>
|
||||
</span>
|
||||
<SubagentsMenu agentName={name} current={subagent} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<AgentDetailPopover
|
||||
|
|
|
|||
61
frontend/packages/swarm-ui/test/subagent-list.test.mjs
Normal file
61
frontend/packages/swarm-ui/test/subagent-list.test.mjs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// What counts as a running subagent, and the menu badge's wording. Run by
|
||||
// `npm test`, which Node runs with its built-in test runner and type
|
||||
// stripping, so the `.ts` module is imported as it is.
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
STALE_S,
|
||||
byRecency,
|
||||
isRunning,
|
||||
runningLabel,
|
||||
splitRunning,
|
||||
} from "../src/agent/terminal/subagent-list.ts";
|
||||
|
||||
const NOW = 1_000_000;
|
||||
const sub = (name, ago) => ({
|
||||
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");
|
||||
});
|
||||
Loading…
Reference in a new issue