From 393213054dd362011482f5a4ce7fe52b0b02af6a Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 21 Oct 2026 20:58:54 +0200 Subject: [PATCH] swarm-ui: close a subagent terminal when its subagent leaves the list --- .../src/agent/terminal/SubagentTerms.tsx | 17 +++++++++++++---- .../src/agent/terminal/subagent-list.ts | 15 +++++++++++++++ .../swarm-ui/test/subagent-list.test.mjs | 10 ++++++++++ 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/frontend/packages/swarm-ui/src/agent/terminal/SubagentTerms.tsx b/frontend/packages/swarm-ui/src/agent/terminal/SubagentTerms.tsx index a053d2bf..718a005a 100644 --- a/frontend/packages/swarm-ui/src/agent/terminal/SubagentTerms.tsx +++ b/frontend/packages/swarm-ui/src/agent/terminal/SubagentTerms.tsx @@ -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(null); + const [picked, setPicked] = useState(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 && ( diff --git a/frontend/packages/swarm-ui/src/agent/terminal/subagent-list.ts b/frontend/packages/swarm-ui/src/agent/terminal/subagent-list.ts index 9de9262c..6904b51c 100644 --- a/frontend/packages/swarm-ui/src/agent/terminal/subagent-list.ts +++ b/frontend/packages/swarm-ui/src/agent/terminal/subagent-list.ts @@ -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; +} diff --git a/frontend/packages/swarm-ui/test/subagent-list.test.mjs b/frontend/packages/swarm-ui/test/subagent-list.test.mjs index 79ad2fb6..dcf1f282 100644 --- a/frontend/packages/swarm-ui/test/subagent-list.test.mjs +++ b/frontend/packages/swarm-ui/test/subagent-list.test.mjs @@ -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); +});