Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

...
4 changed files with 41 additions and 5 deletions

View file

@ -39,7 +39,9 @@ terminal, and below it the agent's **subagents**: every subagent the swarm
queue holds terminal rows for from the last 24 hours. Picking one shows its
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.
agent spawns shows up once it writes output. A subagent that drops out of the
list closes its terminal, and a later subagent with the same name starts
unpicked.
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

View file

@ -8,18 +8,27 @@
// 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 { 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 } from "./subagent-list.js";
import { STALE_S, 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 [selected, setSelected] = useState<string | null>(null);
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
@ -49,7 +58,7 @@ export function SubagentTerms({ agentName }: { agentName: string }) {
? `hide ${name}'s terminal`
: `show ${name}'s terminal`
}
onClick={() => setSelected(name === selected ? null : name)}
onClick={() => setPicked(name === selected ? null : name)}
/>
{last_row_unix !== null && (
<span class="ui-subagent-terms-age">

View file

@ -48,3 +48,18 @@ export function splitRunning(
export function runningLabel(count: number): string {
return count === 1 ? "1 running subagent" : `${count} running subagents`;
}
/**
* The picked subagent while the list still holds it, else `null`. The list
* refreshes on a timer and can drop a subagent whose stream is gone, so a
* pick outlives its badge unless it is checked against the list. `null`
* list (not fetched yet) holds no pick either.
*/
export function stillListed(
picked: string | null,
subs: Subagent[] | null,
): string | null {
return picked !== null && subs?.some((s) => s.name === picked)
? picked
: null;
}

View file

@ -9,6 +9,7 @@ import {
isRunning,
runningLabel,
splitRunning,
stillListed,
} from "../src/agent/terminal/subagent-list.ts";
const NOW = 1_000_000;
@ -59,3 +60,12 @@ test("the badge counts in words", () => {
assert.equal(runningLabel(1), "1 running subagent");
assert.equal(runningLabel(4), "4 running subagents");
});
test("a pick holds only while the refreshed list still has it", () => {
const subs = [sub("a", 10), sub("b", 20)];
assert.equal(stillListed("a", subs), "a");
assert.equal(stillListed("gone", subs), null);
assert.equal(stillListed("a", []), null);
assert.equal(stillListed("a", null), null);
assert.equal(stillListed(null, subs), null);
});