Compare commits
8 changed files with 46 additions and 365 deletions
|
|
@ -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/<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,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<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.
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
/* <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);
|
||||
}
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
// <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,
|
||||
onPick,
|
||||
}: {
|
||||
agent: string;
|
||||
sub: Subagent;
|
||||
current: boolean;
|
||||
onPick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<li>
|
||||
<Link
|
||||
href={termPaths(agent, sub.name).page}
|
||||
class="ui-subagents-menu-link"
|
||||
onClick={onPick}
|
||||
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}
|
||||
onPick={() => setOpen(false)}
|
||||
/>
|
||||
))}
|
||||
</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}
|
||||
onPick={() => setOpen(false)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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`;
|
||||
}
|
||||
|
|
@ -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<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,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={
|
||||
<>
|
||||
<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 key={name} agentName={name} current={subagent} />
|
||||
</>
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
<AgentDetailPopover
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
// 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