Watch
0
0
Fork
You've already forked hyperhive
0

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:
iris 2026-10-18 01:33:06 +02:00 • committed by mara
commit 664409c87f
8 changed files with 360 additions and 46 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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);
}

View 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>
</>
);
}

View file

@ -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`;
}

View file

@ -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 };
}

View file

@ -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

View 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");
});