authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-10-07 03:00:21+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 17:00:21-07:00
logc5c6f9cb485b2db0f205797394bc95f9f575d139
tree7057fd2b67ce7c8c4aeb73c17393ec8cf8da010c
parent72d5c32ba67953805feb6fe9ad3b70b632a64c47
signature Signed by PGP key B5690EEEBB952194

feat(web): stop T3-owned subagents from Lineage (#15211)


2 files changed, 161 insertions(+), 3 deletions(-)

apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx+110-1
......@@ -17,6 +17,7 @@ const state = vi.hoisted(() => ({
1717 projects: [] as unknown[],
1818 configs: new Map<string, unknown>(),
1919 showTooltips: false,
20 command: vi.fn().mockResolvedValue({ _tag: "Success" }),
2021}));
2122
2223vi.mock("@tanstack/react-router", () => ({ useNavigate: () => state.navigate }));
......@@ -29,7 +30,7 @@ vi.mock("../../state/entities", () => ({
2930vi.mock("../../lib/archivedThreadsState", () => ({
3031 useArchivedThreadSnapshots: () => ({ snapshots: [] }),
3132}));
32vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() }));
33vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.command }));
3334vi.mock("../ui/tooltip", () => ({
3435 Tooltip: ({ children }: { children: ReactNode }) => children,
3536 TooltipTrigger: ({ render, children }: { render: ReactElement; children: ReactNode }) =>
......@@ -48,8 +49,116 @@ afterEach(async () => {
4849 state.projects = [];
4950 state.configs.clear();
5051 state.showTooltips = false;
52 state.command.mockClear();
53 state.projection = null;
5154});
5255
56it.each(["codex", "claudeAgent"])(
57 "stops only active app-owned %s subagents without opening their thread",
58 async (driver) => {
59 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
60 const parent = {
61 id: "parent",
62 lineage: { relationshipToParent: null },
63 activeProviderThreadId: null,
64 };
65 const child = {
66 id: "child",
67 title: "Worker",
68 lineage: { parentThreadId: "parent", relationshipToParent: "subagent" },
69 };
70 const agent = {
71 id: "agent",
72 childThreadId: "child",
73 origin: "app_owned",
74 driver,
75 providerInstanceId: "codex",
76 title: "Worker",
77 prompt: "Check the change",
78 model: "gpt-5.4",
79 status: "running",
80 progress: null,
81 result: null,
82 startedAt: DateTime.makeUnsafe("2026-09-16T12:00:00Z"),
83 completedAt: null,
84 updatedAt: DateTime.makeUnsafe("2026-09-16T12:00:00Z"),
85 };
86 state.shells = [{ environmentId: "test", source: child }];
87 const projection = {
88 thread: parent,
89 runs: [],
90 providerThreads: [],
91 providerSessions: [],
92 contextTransfers: [],
93 subagents: [agent],
94 };
95 state.projection = projection;
96 const panel = (
97 <ThreadRelationshipsPanel
98 environmentId={EnvironmentId.make("test")}
99 threadId={ThreadId.make("parent")}
100 />
101 );
102 await act(async () => {
103 renderer = create(panel);
104 });
105 const stopButton = () => renderer.root.findByProps({ "aria-label": "Stop subagent Worker" });
106 await act(async () => stopButton().props.onClick());
107 expect(state.command).toHaveBeenCalledWith({
108 environmentId: "test",
109 input: { threadId: "child" },
110 });
111 expect(state.navigate).not.toHaveBeenCalled();
112
113 for (const status of ["starting", "running", "waiting"] as const) {
114 state.command.mockClear();
115 state.shells = [
116 {
117 environmentId: "test",
118 source: {
119 ...child,
120 activityRunStatus: status,
121 activityRunStartedAt: DateTime.makeUnsafe("2026-09-16T12:05:00Z"),
122 },
123 },
124 ];
125 state.projection = {
126 ...projection,
127 subagents: [{ ...agent, origin: "provider_native", status: "completed" }],
128 };
129 await act(async () => renderer.update(cloneElement(panel)));
130 expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength(
131 0,
132 );
133 state.projection = { ...projection, subagents: [{ ...agent, status: "completed" }] };
134 await act(async () => renderer.update(cloneElement(panel)));
135 await act(async () => stopButton().props.onClick());
136 expect(state.command).toHaveBeenCalledTimes(1);
137 expect(state.command).toHaveBeenLastCalledWith({
138 environmentId: "test",
139 input: { threadId: "child" },
140 });
141 }
142 state.shells = [{ environmentId: "test", source: child }];
143 for (const status of ["completed", "failed", "interrupted"]) {
144 state.projection = { ...projection, subagents: [{ ...agent, status }] };
145 await act(async () => renderer.update(cloneElement(panel)));
146 expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength(
147 0,
148 );
149 }
150 state.projection = { ...projection, subagents: [{ ...agent, startedAt: null }] };
151 await act(async () => renderer.update(cloneElement(panel)));
152 expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength(0);
153 state.projection = {
154 ...projection,
155 subagents: [{ ...agent, origin: "provider_native", driver: "claudeAgent" }],
156 };
157 await act(async () => renderer.update(cloneElement(panel)));
158 expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength(0);
159 },
160);
161
53162it("shows the matching child agent details and refreshes them when the agent settles", async () => {
54163 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
55164 const agent = {
apps/web/src/components/chat/ThreadRelationshipsControl.tsx+51-2
......@@ -36,6 +36,7 @@ import {
3636 LoaderCircleIcon,
3737 MoreHorizontalIcon,
3838 PlusIcon,
39 SquareIcon,
3940 UnplugIcon,
4041} from "lucide-react";
4142import { useMemo, useState, type ReactNode } from "react";
......@@ -55,6 +56,7 @@ import { ThreadRelationshipIcon, threadRelationshipStatusLabel } from "./ThreadR
5556
5657import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
5758import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
59import { toastManager } from "../ui/toast";
5860import {
5961 THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS,
6062 THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS,
......@@ -234,7 +236,9 @@ export function ThreadRelationshipsPanel(props: {
234236 const navigate = useNavigate();
235237 const mergeBack = useAtomCommand(threadEnvironment.mergeBack);
236238 const stopSession = useAtomCommand(threadEnvironment.stopSession);
239 const interruptTurn = useAtomCommand(threadEnvironment.interruptTurn);
237240 const [busyAction, setBusyAction] = useState<"merge" | "detach" | null>(null);
241 const [stoppingThreadId, setStoppingThreadId] = useState<ThreadId | null>(null);
238242 const latestMergeBackRun = projection === null ? null : resolveLatestMergeBackRun(projection);
239243 const mergeTargetThreadId = resolveMergeBackTargetThreadId(projection);
240244 const relationshipRows = useMemo(
......@@ -310,6 +314,19 @@ export function ThreadRelationshipsPanel(props: {
310314 setBusyAction(null);
311315 };
312316
317 const stopSubagent = async (childThreadId: ThreadId) => {
318 if (stoppingThreadId !== null) return;
319 setStoppingThreadId(childThreadId);
320 const result = await interruptTurn({
321 environmentId: props.environmentId,
322 input: { threadId: childThreadId },
323 });
324 setStoppingThreadId(null);
325 if (result._tag === "Failure") {
326 toastManager.add({ type: "error", title: "Could not stop subagent" });
327 }
328 };
329
313330 const parentTitle =
314331 mergeTargetThreadId === null
315332 ? null
......@@ -365,6 +382,10 @@ export function ThreadRelationshipsPanel(props: {
365382 isSubagent && !isParent ? subagentsByThreadId.get(threadId) : undefined,
366383 node?.thread,
367384 );
385 const canStop =
386 agent?.origin === "app_owned" &&
387 agent.startedAt &&
388 ["pending", "running", "waiting"].includes(agent.status);
368389 const threadTitle = relationshipThreadTitle({
369390 title: node?.thread?.title ?? agent?.title ?? threadId,
370391 isSubagent,
......@@ -416,7 +437,9 @@ export function ThreadRelationshipsPanel(props: {
416437 </span>
417438 {agent ? (
418439 agent.startedAt ? (
419 <span className="shrink-0 text-2xs font-normal tabular-nums text-muted-foreground">
440 <span
441 className={`shrink-0 text-2xs font-normal tabular-nums text-muted-foreground ${canStop ? "group-hover:opacity-0 group-focus-within:opacity-0 pointer-coarse:opacity-0 [@media(hover:none)]:opacity-0" : ""}`}
442 >
420443 <AgentElapsed agent={agent} />
421444 </span>
422445 ) : null
......@@ -431,7 +454,7 @@ export function ThreadRelationshipsPanel(props: {
431454 </>
432455 );
433456 return (
434 <li key={threadId} className="group flex h-8 items-center rounded-lg">
457 <li key={threadId} className="group relative flex h-8 items-center rounded-lg">
435458 {isMergeTarget ? (
436459 <div className={THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS}>
437460 <Tooltip>
......@@ -510,6 +533,32 @@ export function ThreadRelationshipsPanel(props: {
510533 <RelationshipPopup side="left">{relationshipTooltip}</RelationshipPopup>
511534 </Tooltip>
512535 )}
536 {canStop && agent ? (
537 <div className="pointer-events-none absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100 pointer-coarse:pointer-events-auto pointer-coarse:opacity-100 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100">
538 <Tooltip>
539 <TooltipTrigger
540 render={
541 <ThreadDetailsControl
542 size="icon-xs"
543 variant="ghost"
544 part="icon"
545 tone="destructive"
546 aria-label={`Stop subagent ${threadTitle}`}
547 disabled={stoppingThreadId !== null}
548 onClick={() => void stopSubagent(threadId)}
549 />
550 }
551 >
552 {stoppingThreadId === threadId ? (
553 <LoaderCircleIcon aria-hidden className="size-3 animate-spin" />
554 ) : (
555 <SquareIcon aria-hidden className="size-3 fill-current" />
556 )}
557 </TooltipTrigger>
558 <TooltipPopup side="left">Stop subagent</TooltipPopup>
559 </Tooltip>
560 </div>
561 ) : null}
513562 </li>
514563 );
515564 })