diff --git a/packages/app/e2e/regression/subagent-child-navigation.spec.ts b/packages/app/e2e/regression/subagent-child-navigation.spec.ts index 1e0a4519e8b2..f8f9b82b38f6 100644 --- a/packages/app/e2e/regression/subagent-child-navigation.spec.ts +++ b/packages/app/e2e/regression/subagent-child-navigation.spec.ts @@ -36,6 +36,33 @@ test("returns to the parent session with Escape", async ({ page }) => { await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)]) }) +test("stops the viewed running subagent with Ctrl+D", async ({ page }) => { + await setup(page, undefined, { [childID]: { type: "running" } }) + await openChildFromParent(page) + await expectSessionTitle(page, taskDescription) + await expect(page.getByRole("button", { name: "Stop subagent", exact: true })).toBeVisible() + + const interrupted = page.waitForRequest( + (request) => request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${childID}/interrupt`, + ) + await page.keyboard.press("Control+d") + + await interrupted +}) + +test("stops the viewed running subagent from the disabled composer", async ({ page }) => { + await setup(page, undefined, { [childID]: { type: "running" } }) + await openChildFromParent(page) + await expectSessionTitle(page, taskDescription) + + const interrupted = page.waitForRequest( + (request) => request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${childID}/interrupt`, + ) + await page.getByRole("button", { name: "Stop subagent", exact: true }).click() + + await interrupted +}) + test("shows parent lineage while the child timeline loads", async ({ page }) => { await setup(page) const requested = Promise.withResolvers() @@ -145,7 +172,7 @@ test("shows the not found fallback when the viewed session is deleted", async ({ await expect(page.getByRole("heading", { name: taskDescription })).toHaveCount(0) }) -async function setup(page: Page, events?: () => OpenCodeEvent[]) { +async function setup(page: Page, events?: () => OpenCodeEvent[], sessionStatus?: Record) { await mockOpenCodeServer(page, { directory, project: { @@ -170,6 +197,7 @@ async function setup(page: Page, events?: () => OpenCodeEvent[]) { default: { providerID: "opencode", modelID: "claude-opus-4-6" }, }, sessions: [session(parentID, parentTitle, 1700000000000), childSession()], + sessionStatus, pageMessages: (sessionID) => ({ items: sessionID === parentID ? parentMessages() : [] }), events, eventRetry: events ? 16 : undefined, diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index b9e26f0df623..99ce7c3e6d7c 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -681,6 +681,7 @@ export const dict = { "session.background.subagent.one": "{{count}} subagent", "session.background.subagent.other": "{{count}} subagents", "command.session.background": "Move to background", + "command.session.subagent.interrupt": "Stop subagent", "session.queue.count.one": "{{count}} queued", "session.queue.count.other": "{{count}} queued", "session.queue.steer": "Steer", diff --git a/packages/app/src/session/commands/use-session-commands.tsx b/packages/app/src/session/commands/use-session-commands.tsx index b066826e079e..ffe2b566272e 100644 --- a/packages/app/src/session/commands/use-session-commands.tsx +++ b/packages/app/src/session/commands/use-session-commands.tsx @@ -16,7 +16,7 @@ import type { SessionRevert } from "@/session/revert" type SessionCommandSource = { identity: SessionModel["identity"] - data: Pick + data: Pick history: Pick layout: SessionModel["layout"] ownership: SessionModel["ownership"] @@ -236,6 +236,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => { await serverSDK.api.session.compact({ sessionID }) } + const interruptSubagent = async () => { + const sessionID = actions.session.identity.params.id + if (!sessionID) return + + await serverSDK.api.session.interrupt({ sessionID }) + } + const fork = () => { const sessionID = actions.session.identity.params.id if (!sessionID) return @@ -284,6 +291,17 @@ export const useSessionCommands = (actions: SessionCommandContext) => { disabled: !actions.background.blocking(), onSelect: actions.background.move, }), + ...(actions.session.data.parentID() + ? [ + sessionCommand({ + id: "session.subagent.interrupt", + title: language.t("command.session.subagent.interrupt"), + keybind: "ctrl+d", + disabled: !actions.session.data.working(), + onSelect: interruptSubagent, + }), + ] + : []), sessionCommand({ id: "session.fork", title: language.t("command.session.fork"), diff --git a/packages/app/src/session/composer/region.tsx b/packages/app/src/session/composer/region.tsx index 375cc771acb8..70663a4da44f 100644 --- a/packages/app/src/session/composer/region.tsx +++ b/packages/app/src/session/composer/region.tsx @@ -33,6 +33,7 @@ import { resolveSessionComposerSelection } from "./selection" import { createSessionRequestModel } from "../requests/model" import { useSettings } from "@/settings/model" import { SessionLocationMissing } from "./location-missing" +import { useServerSDK } from "@/runtime/server/client" export function createActiveSessionRegion(input: { session: SessionModel @@ -221,6 +222,7 @@ export function ActiveSessionComposerRegion(props: { }) { const settings = useSettings() const location = useWorkspaceLocation() + const serverSDK = useServerSDK() const missing = createMemo(() => { const error = location().error const current = props.session.data.info()?.location @@ -233,7 +235,10 @@ export function ActiveSessionComposerRegion(props: { parentID: props.session.data.parentID, centered: props.model.region.centered, onResponseSubmit: props.onResponseSubmit, + onStop: () => + void serverSDK.api.session.interrupt({ sessionID: requireSessionID(props.session) }).catch(() => undefined), openParent: props.model.region.openParent, + working: props.session.data.working, setPromptRef: props.model.region.setPromptRef, setDockRef: props.model.region.setDockRef, }) diff --git a/packages/app/src/session/composer/session-composer-region-controller.ts b/packages/app/src/session/composer/session-composer-region-controller.ts index f07f5ddc1328..d147a116917a 100644 --- a/packages/app/src/session/composer/session-composer-region-controller.ts +++ b/packages/app/src/session/composer/session-composer-region-controller.ts @@ -6,7 +6,9 @@ export function createSessionComposerRegionController(input: { parentID: Accessor centered: Accessor onResponseSubmit: () => void + onStop: () => void openParent: () => void + working: Accessor setPromptRef: (el: HTMLDivElement) => void setDockRef: (el: HTMLDivElement) => void }) { @@ -14,7 +16,9 @@ export function createSessionComposerRegionController(input: { state: input.state, centered: input.centered, onResponseSubmit: input.onResponseSubmit, + onStop: input.onStop, openParent: input.openParent, + working: input.working, setPromptRef: input.setPromptRef, setDockRef: input.setDockRef, parentID: input.parentID, diff --git a/packages/app/src/session/composer/session-composer-region.tsx b/packages/app/src/session/composer/session-composer-region.tsx index a429cc1b2e7f..a233a9c60eb3 100644 --- a/packages/app/src/session/composer/session-composer-region.tsx +++ b/packages/app/src/session/composer/session-composer-region.tsx @@ -1,4 +1,7 @@ import { Show, type JSX } from "solid-js" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Tooltip } from "@opencode-ai/ui/tooltip" import { useLanguage } from "@/runtime/i18n/language" import { SessionPermissionDock } from "@/session/requests/session-permission-dock" import { SessionQuestionDock } from "@/session/requests/session-question-dock" @@ -13,7 +16,9 @@ export type SessionComposerRegionViewController = Pick< SessionComposerRegionController, | "centered" | "onResponseSubmit" + | "onStop" | "openParent" + | "working" | "setPromptRef" | "setDockRef" | "parentID" @@ -68,23 +73,35 @@ export function SessionComposerRegion(props: { "relative z-[70]": true, }} > - {props.composer}} - > + {props.composer}}>
- {language.t("session.child.promptDisabled")} - - + + {language.t("session.child.promptDisabled")}{" "} + + + + + + + } + variant="contrast" + class="size-7 shrink-0 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)]" + aria-label={language.t("command.session.subagent.interrupt")} + aria-keyshortcuts="Control+D" + onClick={controller.onStop} + /> +
diff --git a/packages/app/src/session/story-model.tsx b/packages/app/src/session/story-model.tsx index 74ca3c9d1194..9f26523109d0 100644 --- a/packages/app/src/session/story-model.tsx +++ b/packages/app/src/session/story-model.tsx @@ -202,7 +202,9 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void setState("request", undefined) setState("activity", "Submitted the answer locally") }, + onStop: () => setState("activity", "Requested a local stop"), openParent: () => setState("activity", "Opened the parent Session locally"), + working: () => props.document.status.type !== "idle", setPromptRef() {}, setDockRef() {}, parentID: () => props.child?.parentID,