Compare commits

...
7 changed files with 91 additions and 16 deletions
@@ -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>()
@@ -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: {
@@ -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,
+1
View File
@@ -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",
@@ -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"]
@@ -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"),
@@ -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,
})
@@ -6,7 +6,9 @@ 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
}) {
@@ -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,
@@ -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,
}}
>
<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>
+2
View File
@@ -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,