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