Skip to content
Closed
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
30 changes: 29 additions & 1 deletion packages/app/e2e/regression/subagent-child-navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>()
Expand Down Expand Up @@ -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<string, unknown>) {
await mockOpenCodeServer(page, {
directory,
project: {
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/runtime/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 19 additions & 1 deletion packages/app/src/session/commands/use-session-commands.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import type { SessionRevert } from "@/session/revert"

type SessionCommandSource = {
identity: SessionModel["identity"]
data: Pick<SessionModel["data"], "info" | "revertMessageID">
data: Pick<SessionModel["data"], "info" | "parentID" | "revertMessageID" | "working">
history: Pick<SessionModel["history"], "visibleUserMessages">
layout: SessionModel["layout"]
ownership: SessionModel["ownership"]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"),
Expand Down
5 changes: 5 additions & 0 deletions packages/app/src/session/composer/region.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,19 @@ export function createSessionComposerRegionController(input: {
parentID: Accessor<string | undefined>
centered: Accessor<boolean>
onResponseSubmit: () => void
onStop: () => void
openParent: () => void
working: Accessor<boolean>
setPromptRef: (el: HTMLDivElement) => void
setDockRef: (el: HTMLDivElement) => void
}) {
return {
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,
Expand Down
45 changes: 31 additions & 14 deletions packages/app/src/session/composer/session-composer-region.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -13,7 +16,9 @@ export type SessionComposerRegionViewController = Pick<
SessionComposerRegionController,
| "centered"
| "onResponseSubmit"
| "onStop"
| "openParent"
| "working"
| "setPromptRef"
| "setDockRef"
| "parentID"
Expand Down Expand Up @@ -68,23 +73,35 @@ export function SessionComposerRegion(props: {
"relative z-[70]": true,
}}
>
<Show
when={controller.child()}
fallback={<Show when={!controller.state.blocked()}>{props.composer}</Show>}
>
<Show when={controller.child()} fallback={<Show when={!controller.state.blocked()}>{props.composer}</Show>}>
<div
ref={controller.setPromptRef}
class="w-full rounded-[12px] border border-border-weak-base bg-background-base p-3 text-16-regular text-text-weak"
class="w-full rounded-[12px] border border-border-weak-base bg-background-base p-3 text-16-regular text-text-weak flex items-center justify-between gap-3"
>
<span>{language.t("session.child.promptDisabled")} </span>
<Show when={controller.parentID()}>
<button
type="button"
class="text-text-base transition-colors hover:text-text-strong"
onClick={controller.openParent}
>
{language.t("session.child.backToParent")}
</button>
<span>
{language.t("session.child.promptDisabled")}{" "}
<Show when={controller.parentID()}>
<button
type="button"
class="text-text-base transition-colors hover:text-text-strong"
onClick={controller.openParent}
>
{language.t("session.child.backToParent")}
</button>
</Show>
</span>
<Show when={controller.working()}>
<Tooltip placement="top" value={language.t("command.session.subagent.interrupt")}>
<IconButton
type="button"
icon={<Icon name="stop" />}
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}
/>
</Tooltip>
</Show>
</div>
</Show>
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/session/story-model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading