diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx index af1d42f3876b..6c7534d04884 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx @@ -17,6 +17,7 @@ const state = vi.hoisted(() => ({ projects: [] as unknown[], configs: new Map(), showTooltips: false, + command: vi.fn().mockResolvedValue({ _tag: "Success" }), })); vi.mock("@tanstack/react-router", () => ({ useNavigate: () => state.navigate })); @@ -29,7 +30,7 @@ vi.mock("../../state/entities", () => ({ vi.mock("../../lib/archivedThreadsState", () => ({ useArchivedThreadSnapshots: () => ({ snapshots: [] }), })); -vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.command })); vi.mock("../ui/tooltip", () => ({ Tooltip: ({ children }: { children: ReactNode }) => children, TooltipTrigger: ({ render, children }: { render: ReactElement; children: ReactNode }) => @@ -48,8 +49,116 @@ afterEach(async () => { state.projects = []; state.configs.clear(); state.showTooltips = false; + state.command.mockClear(); + state.projection = null; }); +it.each(["codex", "claudeAgent"])( + "stops only active app-owned %s subagents without opening their thread", + async (driver) => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const parent = { + id: "parent", + lineage: { relationshipToParent: null }, + activeProviderThreadId: null, + }; + const child = { + id: "child", + title: "Worker", + lineage: { parentThreadId: "parent", relationshipToParent: "subagent" }, + }; + const agent = { + id: "agent", + childThreadId: "child", + origin: "app_owned", + driver, + providerInstanceId: "codex", + title: "Worker", + prompt: "Check the change", + model: "gpt-5.4", + status: "running", + progress: null, + result: null, + startedAt: DateTime.makeUnsafe("2026-09-16T12:00:00Z"), + completedAt: null, + updatedAt: DateTime.makeUnsafe("2026-09-16T12:00:00Z"), + }; + state.shells = [{ environmentId: "test", source: child }]; + const projection = { + thread: parent, + runs: [], + providerThreads: [], + providerSessions: [], + contextTransfers: [], + subagents: [agent], + }; + state.projection = projection; + const panel = ( + + ); + await act(async () => { + renderer = create(panel); + }); + const stopButton = () => renderer.root.findByProps({ "aria-label": "Stop subagent Worker" }); + await act(async () => stopButton().props.onClick()); + expect(state.command).toHaveBeenCalledWith({ + environmentId: "test", + input: { threadId: "child" }, + }); + expect(state.navigate).not.toHaveBeenCalled(); + + for (const status of ["starting", "running", "waiting"] as const) { + state.command.mockClear(); + state.shells = [ + { + environmentId: "test", + source: { + ...child, + activityRunStatus: status, + activityRunStartedAt: DateTime.makeUnsafe("2026-09-16T12:05:00Z"), + }, + }, + ]; + state.projection = { + ...projection, + subagents: [{ ...agent, origin: "provider_native", status: "completed" }], + }; + await act(async () => renderer.update(cloneElement(panel))); + expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength( + 0, + ); + state.projection = { ...projection, subagents: [{ ...agent, status: "completed" }] }; + await act(async () => renderer.update(cloneElement(panel))); + await act(async () => stopButton().props.onClick()); + expect(state.command).toHaveBeenCalledTimes(1); + expect(state.command).toHaveBeenLastCalledWith({ + environmentId: "test", + input: { threadId: "child" }, + }); + } + state.shells = [{ environmentId: "test", source: child }]; + for (const status of ["completed", "failed", "interrupted"]) { + state.projection = { ...projection, subagents: [{ ...agent, status }] }; + await act(async () => renderer.update(cloneElement(panel))); + expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength( + 0, + ); + } + state.projection = { ...projection, subagents: [{ ...agent, startedAt: null }] }; + await act(async () => renderer.update(cloneElement(panel))); + expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength(0); + state.projection = { + ...projection, + subagents: [{ ...agent, origin: "provider_native", driver: "claudeAgent" }], + }; + await act(async () => renderer.update(cloneElement(panel))); + expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength(0); + }, +); + it("shows the matching child agent details and refreshes them when the agent settles", async () => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); const agent = { diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index 6d5236818699..cca32d57ab04 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -36,6 +36,7 @@ import { LoaderCircleIcon, MoreHorizontalIcon, PlusIcon, + SquareIcon, UnplugIcon, } from "lucide-react"; import { useMemo, useState, type ReactNode } from "react"; @@ -55,6 +56,7 @@ import { ThreadRelationshipIcon, threadRelationshipStatusLabel } from "./ThreadR import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { toastManager } from "../ui/toast"; import { THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS, THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS, @@ -234,7 +236,9 @@ export function ThreadRelationshipsPanel(props: { const navigate = useNavigate(); const mergeBack = useAtomCommand(threadEnvironment.mergeBack); const stopSession = useAtomCommand(threadEnvironment.stopSession); + const interruptTurn = useAtomCommand(threadEnvironment.interruptTurn); const [busyAction, setBusyAction] = useState<"merge" | "detach" | null>(null); + const [stoppingThreadId, setStoppingThreadId] = useState(null); const latestMergeBackRun = projection === null ? null : resolveLatestMergeBackRun(projection); const mergeTargetThreadId = resolveMergeBackTargetThreadId(projection); const relationshipRows = useMemo( @@ -310,6 +314,19 @@ export function ThreadRelationshipsPanel(props: { setBusyAction(null); }; + const stopSubagent = async (childThreadId: ThreadId) => { + if (stoppingThreadId !== null) return; + setStoppingThreadId(childThreadId); + const result = await interruptTurn({ + environmentId: props.environmentId, + input: { threadId: childThreadId }, + }); + setStoppingThreadId(null); + if (result._tag === "Failure") { + toastManager.add({ type: "error", title: "Could not stop subagent" }); + } + }; + const parentTitle = mergeTargetThreadId === null ? null @@ -365,6 +382,10 @@ export function ThreadRelationshipsPanel(props: { isSubagent && !isParent ? subagentsByThreadId.get(threadId) : undefined, node?.thread, ); + const canStop = + agent?.origin === "app_owned" && + agent.startedAt && + ["pending", "running", "waiting"].includes(agent.status); const threadTitle = relationshipThreadTitle({ title: node?.thread?.title ?? agent?.title ?? threadId, isSubagent, @@ -416,7 +437,9 @@ export function ThreadRelationshipsPanel(props: { {agent ? ( agent.startedAt ? ( - + ) : null @@ -431,7 +454,7 @@ export function ThreadRelationshipsPanel(props: { ); return ( -
  • +
  • {isMergeTarget ? (
    @@ -510,6 +533,32 @@ export function ThreadRelationshipsPanel(props: { {relationshipTooltip} )} + {canStop && agent ? ( +
    + + void stopSubagent(threadId)} + /> + } + > + {stoppingThreadId === threadId ? ( + + ) : ( + + )} + + Stop subagent + +
    + ) : null}
  • ); })