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 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 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 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 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 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. // agent's own: subagents take no input.
// //
// Mount it keyed by agent name: the refresh timer is armed once per mount. // 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 { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { Badge } from "@hive/shared/badge.js"; import { Badge } from "@hive/shared/badge.js";
import { AgentTermPreview } from "./AgentTermPreview.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 { useSubagentList } from "./useSubagentList.js";
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js"; import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
import "./SubagentTerms.css"; import "./SubagentTerms.css";
export function SubagentTerms({ agentName }: { agentName: string }) { export function SubagentTerms({ agentName }: { agentName: string }) {
const { subagents, error } = useSubagentList(agentName); 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 // 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 // "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` ? `hide ${name}'s terminal`
: `show ${name}'s terminal` : `show ${name}'s terminal`
} }
onClick={() => setSelected(name === selected ? null : name)} onClick={() => setPicked(name === selected ? null : name)}
/> />
{last_row_unix !== null && ( {last_row_unix !== null && (
<span class="ui-subagent-terms-age"> <span class="ui-subagent-terms-age">

View file

@ -48,3 +48,18 @@ export function splitRunning(
export function runningLabel(count: number): string { export function runningLabel(count: number): string {
return count === 1 ? "1 running subagent" : `${count} running subagents`; 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, isRunning,
runningLabel, runningLabel,
splitRunning, splitRunning,
stillListed,
} from "../src/agent/terminal/subagent-list.ts"; } from "../src/agent/terminal/subagent-list.ts";
const NOW = 1_000_000; 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(1), "1 running subagent");
assert.equal(runningLabel(4), "4 running subagents"); 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);
});