| author | |
| committer | |
| log | c5c6f9cb485b2db0f205797394bc95f9f575d139 |
| tree | 7057fd2b67ce7c8c4aeb73c17393ec8cf8da010c |
| parent | 72d5c32ba67953805feb6fe9ad3b70b632a64c47 |
| signature | Signed by PGP key B5690EEEBB952194 |
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(() => ({ |
| 17 | 17 | projects: [] as unknown[], |
| 18 | 18 | configs: new Map<string, unknown>(), |
| 19 | 19 | showTooltips: false, |
| 20 | command: vi.fn().mockResolvedValue({ _tag: "Success" }), | |
| 20 | 21 | })); |
| 21 | 22 | |
| 22 | 23 | vi.mock("@tanstack/react-router", () => ({ useNavigate: () => state.navigate })); |
| ... | ... | @@ -29,7 +30,7 @@ vi.mock("../../state/entities", () => ({ |
| 29 | 30 | vi.mock("../../lib/archivedThreadsState", () => ({ |
| 30 | 31 | useArchivedThreadSnapshots: () => ({ snapshots: [] }), |
| 31 | 32 | })); |
| 32 | vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); | |
| 33 | vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.command })); | |
| 33 | 34 | vi.mock("../ui/tooltip", () => ({ |
| 34 | 35 | Tooltip: ({ children }: { children: ReactNode }) => children, |
| 35 | 36 | TooltipTrigger: ({ render, children }: { render: ReactElement; children: ReactNode }) => |
| ... | ... | @@ -48,8 +49,116 @@ afterEach(async () => { |
| 48 | 49 | state.projects = []; |
| 49 | 50 | state.configs.clear(); |
| 50 | 51 | state.showTooltips = false; |
| 52 | state.command.mockClear(); | |
| 53 | state.projection = null; | |
| 51 | 54 | }); |
| 52 | 55 | |
| 56 | it.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 | ||
| 53 | 162 | it("shows the matching child agent details and refreshes them when the agent settles", async () => { |
| 54 | 163 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); |
| 55 | 164 | const agent = { |
apps/web/src/components/chat/ThreadRelationshipsControl.tsx+51-2| ... | ... | @@ -36,6 +36,7 @@ import { |
| 36 | 36 | LoaderCircleIcon, |
| 37 | 37 | MoreHorizontalIcon, |
| 38 | 38 | PlusIcon, |
| 39 | SquareIcon, | |
| 39 | 40 | UnplugIcon, |
| 40 | 41 | } from "lucide-react"; |
| 41 | 42 | import { useMemo, useState, type ReactNode } from "react"; |
| ... | ... | @@ -55,6 +56,7 @@ import { ThreadRelationshipIcon, threadRelationshipStatusLabel } from "./ThreadR |
| 55 | 56 | |
| 56 | 57 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; |
| 57 | 58 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 59 | import { toastManager } from "../ui/toast"; | |
| 58 | 60 | import { |
| 59 | 61 | THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS, |
| 60 | 62 | THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS, |
| ... | ... | @@ -234,7 +236,9 @@ export function ThreadRelationshipsPanel(props: { |
| 234 | 236 | const navigate = useNavigate(); |
| 235 | 237 | const mergeBack = useAtomCommand(threadEnvironment.mergeBack); |
| 236 | 238 | const stopSession = useAtomCommand(threadEnvironment.stopSession); |
| 239 | const interruptTurn = useAtomCommand(threadEnvironment.interruptTurn); | |
| 237 | 240 | const [busyAction, setBusyAction] = useState<"merge" | "detach" | null>(null); |
| 241 | const [stoppingThreadId, setStoppingThreadId] = useState<ThreadId | null>(null); | |
| 238 | 242 | const latestMergeBackRun = projection === null ? null : resolveLatestMergeBackRun(projection); |
| 239 | 243 | const mergeTargetThreadId = resolveMergeBackTargetThreadId(projection); |
| 240 | 244 | const relationshipRows = useMemo( |
| ... | ... | @@ -310,6 +314,19 @@ export function ThreadRelationshipsPanel(props: { |
| 310 | 314 | setBusyAction(null); |
| 311 | 315 | }; |
| 312 | 316 | |
| 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 | ||
| 313 | 330 | const parentTitle = |
| 314 | 331 | mergeTargetThreadId === null |
| 315 | 332 | ? null |
| ... | ... | @@ -365,6 +382,10 @@ export function ThreadRelationshipsPanel(props: { |
| 365 | 382 | isSubagent && !isParent ? subagentsByThreadId.get(threadId) : undefined, |
| 366 | 383 | node?.thread, |
| 367 | 384 | ); |
| 385 | const canStop = | |
| 386 | agent?.origin === "app_owned" && | |
| 387 | agent.startedAt && | |
| 388 | ["pending", "running", "waiting"].includes(agent.status); | |
| 368 | 389 | const threadTitle = relationshipThreadTitle({ |
| 369 | 390 | title: node?.thread?.title ?? agent?.title ?? threadId, |
| 370 | 391 | isSubagent, |
| ... | ... | @@ -416,7 +437,9 @@ export function ThreadRelationshipsPanel(props: { |
| 416 | 437 | </span> |
| 417 | 438 | {agent ? ( |
| 418 | 439 | 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 | > | |
| 420 | 443 | <AgentElapsed agent={agent} /> |
| 421 | 444 | </span> |
| 422 | 445 | ) : null |
| ... | ... | @@ -431,7 +454,7 @@ export function ThreadRelationshipsPanel(props: { |
| 431 | 454 | </> |
| 432 | 455 | ); |
| 433 | 456 | 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"> | |
| 435 | 458 | {isMergeTarget ? ( |
| 436 | 459 | <div className={THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS}> |
| 437 | 460 | <Tooltip> |
| ... | ... | @@ -510,6 +533,32 @@ export function ThreadRelationshipsPanel(props: { |
| 510 | 533 | <RelationshipPopup side="left">{relationshipTooltip}</RelationshipPopup> |
| 511 | 534 | </Tooltip> |
| 512 | 535 | )} |
| 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} | |
| 513 | 562 | </li> |
| 514 | 563 | ); |
| 515 | 564 | }) |