The subagent daemon stamps Swarm-Model and Swarm-Effort on each row it publishes. The list endpoint reads them off the newest row it already fetches, so a long-running subagent keeps them past the stream's 24 hour window, and the terminal page's menu and the agent panel's list show them beside the name.
86 lines
3.6 KiB
TypeScript
86 lines
3.6 KiB
TypeScript
// <SubagentTerms> — an agent's subagents, under its own terminal preview in
|
|
// `AgentsPage`'s detail panel. The list is `GET /api/agents/{name}/subagents`:
|
|
// 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
|
|
// 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 { useEffect, useState } from "preact/hooks";
|
|
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
|
import { Badge } from "@hive/shared/badge.js";
|
|
import { AgentTermPreview } from "./AgentTermPreview.js";
|
|
import { STALE_S, runLabel, stillListed } from "./subagent-list.js";
|
|
import { useSubagentList } from "./useSubagentList.js";
|
|
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
|
|
import "./SubagentTerms.css";
|
|
|
|
export function SubagentTerms({ agentName }: { agentName: string }) {
|
|
const { subagents, error } = useSubagentList(agentName);
|
|
const [picked, setPicked] = useState<string | null>(null);
|
|
// The refresh can drop the picked subagent (its stream is gone). Only a
|
|
// name still in the list counts as selected, so the terminal closes with
|
|
// its badge and a later subagent of the same name does not reopen it.
|
|
const selected = stillListed(picked, subagents);
|
|
useEffect(() => {
|
|
if (picked !== null && subagents !== null && selected === null) {
|
|
setPicked(null);
|
|
}
|
|
}, [picked, subagents, selected]);
|
|
|
|
// 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.
|
|
if (!error && (subagents === null || subagents.length === 0)) return null;
|
|
|
|
const nowS = Date.now() / 1000;
|
|
|
|
return (
|
|
<section class="ui-subagent-terms" aria-label={`${agentName}'s subagents`}>
|
|
<div class="ui-subagent-terms-list">
|
|
<span class="ui-subagent-terms-heading">subagents</span>
|
|
{subagents?.map((sub) => {
|
|
const { name, last_row_unix } = sub;
|
|
const runs = runLabel(sub);
|
|
const stale =
|
|
last_row_unix !== null && nowS - last_row_unix > STALE_S;
|
|
return (
|
|
<span
|
|
key={name}
|
|
class={`ui-subagent-terms-item${stale ? " ui-subagent-terms-item-stale" : ""}`}
|
|
>
|
|
<Badge
|
|
variant={name === selected ? "default" : "quiet"}
|
|
value={name}
|
|
expanded={name === selected}
|
|
title={
|
|
name === selected
|
|
? `hide ${name}'s terminal`
|
|
: `show ${name}'s terminal`
|
|
}
|
|
onClick={() => setPicked(name === selected ? null : name)}
|
|
/>
|
|
{runs && <span class="ui-subagent-terms-age">{runs}</span>}
|
|
{last_row_unix !== null && (
|
|
<span class="ui-subagent-terms-age">
|
|
<RelativeTime epochMs={last_row_unix * 1000} />
|
|
</span>
|
|
)}
|
|
</span>
|
|
);
|
|
})}
|
|
</div>
|
|
{error && (
|
|
<ApiErrorPanel
|
|
problem={error}
|
|
context={`failed to list ${agentName}'s subagents`}
|
|
/>
|
|
)}
|
|
{selected && (
|
|
<AgentTermPreview agentName={agentName} subagent={selected} />
|
|
)}
|
|
</section>
|
|
);
|
|
}
|