Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const state = vi.hoisted(() => ({
projects: [] as unknown[],
configs: new Map<string, unknown>(),
showTooltips: false,
command: vi.fn().mockResolvedValue({ _tag: "Success" }),
}));

vi.mock("@tanstack/react-router", () => ({ useNavigate: () => state.navigate }));
Expand All @@ -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 }) =>
Expand All @@ -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 = (
<ThreadRelationshipsPanel
environmentId={EnvironmentId.make("test")}
threadId={ThreadId.make("parent")}
/>
);
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 = {
Expand Down
53 changes: 51 additions & 2 deletions apps/web/src/components/chat/ThreadRelationshipsControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
LoaderCircleIcon,
MoreHorizontalIcon,
PlusIcon,
SquareIcon,
UnplugIcon,
} from "lucide-react";
import { useMemo, useState, type ReactNode } from "react";
Expand All @@ -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,
Expand Down Expand Up @@ -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<ThreadId | null>(null);
const latestMergeBackRun = projection === null ? null : resolveLatestMergeBackRun(projection);
const mergeTargetThreadId = resolveMergeBackTargetThreadId(projection);
const relationshipRows = useMemo(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -365,6 +382,10 @@ export function ThreadRelationshipsPanel(props: {
isSubagent && !isParent ? subagentsByThreadId.get(threadId) : undefined,
node?.thread,
);
const canStop =
Comment thread
Bil0000 marked this conversation as resolved.
agent?.origin === "app_owned" &&
agent.startedAt &&
["pending", "running", "waiting"].includes(agent.status);
const threadTitle = relationshipThreadTitle({
title: node?.thread?.title ?? agent?.title ?? threadId,
isSubagent,
Expand Down Expand Up @@ -416,7 +437,9 @@ export function ThreadRelationshipsPanel(props: {
</span>
{agent ? (
agent.startedAt ? (
<span className="shrink-0 text-2xs font-normal tabular-nums text-muted-foreground">
<span
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" : ""}`}
>
<AgentElapsed agent={agent} />
</span>
) : null
Expand All @@ -431,7 +454,7 @@ export function ThreadRelationshipsPanel(props: {
</>
);
return (
<li key={threadId} className="group flex h-8 items-center rounded-lg">
<li key={threadId} className="group relative flex h-8 items-center rounded-lg">
{isMergeTarget ? (
<div className={THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS}>
<Tooltip>
Expand Down Expand Up @@ -510,6 +533,32 @@ export function ThreadRelationshipsPanel(props: {
<RelationshipPopup side="left">{relationshipTooltip}</RelationshipPopup>
</Tooltip>
)}
{canStop && agent ? (
<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">
<Tooltip>
<TooltipTrigger
render={
<ThreadDetailsControl
size="icon-xs"
variant="ghost"
part="icon"
tone="destructive"
aria-label={`Stop subagent ${threadTitle}`}
disabled={stoppingThreadId !== null}
onClick={() => void stopSubagent(threadId)}
/>
}
>
{stoppingThreadId === threadId ? (
<LoaderCircleIcon aria-hidden className="size-3 animate-spin" />
) : (
<SquareIcon aria-hidden className="size-3 fill-current" />
)}
</TooltipTrigger>
<TooltipPopup side="left">Stop subagent</TooltipPopup>
</Tooltip>
</div>
) : null}
</li>
);
})
Expand Down
Loading