From f06bf47e72650c1c3189b6fc3af2c5b88d3df0fd Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 4 Sep 2026 08:25:42 +1000 Subject: [PATCH] fix(app): preserve slash commands when creating worktrees (#47155) --- .../new-session-workspace-pending.spec.ts | 79 ++++++++++++++++++- packages/app/src/composer/model.ts | 1 + packages/app/src/composer/submit.test.ts | 50 +++++++++++- packages/app/src/composer/submit.ts | 9 ++- 4 files changed, 129 insertions(+), 10 deletions(-) diff --git a/packages/app/e2e/regression/new-session-workspace-pending.spec.ts b/packages/app/e2e/regression/new-session-workspace-pending.spec.ts index 98c9be0517f..b44435d7b26 100644 --- a/packages/app/e2e/regression/new-session-workspace-pending.spec.ts +++ b/packages/app/e2e/regression/new-session-workspace-pending.spec.ts @@ -1,4 +1,5 @@ import { expect, test, type Page } from "@playwright/test" +import type { OpenCodeEvent } from "@opencode-ai/client/promise" import { base64Encode } from "@opencode-ai/util/encode" import { currentSession, mockOpenCodeServer } from "../utils/mock-server" import { expectAppVisible } from "../utils/waits" @@ -335,6 +336,61 @@ test("restores the draft after closing and revisiting a pending session that fai expect(mock.prompts).toEqual([]) }) +test("executes a selected slash command after creating its worktree", async ({ page }, testInfo) => { + const events: OpenCodeEvent[] = [] + const mock = await openDraft(page, { command: true, events: () => events.splice(0) }) + const commands: { sessionID: string; body: Record }[] = [] + const expanded = + "Review the latest commit for correctness and regressions. Check the relevant tests and report actionable findings." + await page.route("**/api/session/*/command", async (route) => { + if (route.request().method() !== "POST") return route.fallback() + const sessionID = new URL(route.request().url()).pathname.split("/")[3] + commands.push({ sessionID, body: route.request().postDataJSON() }) + // The server owns command expansion; the client receives the expanded inbox item. + events.push({ + id: "evt_workspace_review", + type: "session.inbox.enqueued", + created: Date.now(), + durable: { aggregateID: sessionID, seq: 1, version: 1 }, + data: { + sessionID, + inboxID: "msg_workspace_review", + item: { type: "user", payload: { text: expanded }, delivery: "steer" }, + }, + }) + await route.fulfill({ status: 204, headers }) + }) + const editor = page.locator('[data-component="composer-editor"]') + await editor.fill("/review") + const suggestion = page.getByRole("button", { name: "/review Review changes", exact: true }) + await expect(suggestion).toBeVisible() + await suggestion.click() + await expect(editor).toHaveText("/review") + const pending = await submitPending(page, mock, "/review latest commit") + await draftFollowUp(page) + + mock.worktree.resolve({ status: 200, json: { directory: workspace } }) + + await expect + .poll(() => commands) + .toEqual([ + { + sessionID: pending.sessionID, + body: { command: "review", text: "latest commit", files: [], agents: [], skills: [], delivery: "steer" }, + }, + ]) + await expect(pending.shimmer).toHaveCount(0) + await expect(page.locator('[data-slot="user-message-text"]')).toHaveText(expanded) + await expect(editor).toHaveText(followUp) + await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled() + expect(mock.creates).toEqual([expect.objectContaining({ id: pending.sessionID, location: { directory: workspace } })]) + expect(mock.prompts).toEqual([]) + await testInfo.attach("expanded-worktree-command", { + body: await page.screenshot({ path: testInfo.outputPath("expanded-worktree-command.png") }), + contentType: "image/png", + }) +}) + async function draftFollowUp(page: Page) { const editor = page.locator('[data-component="composer-editor"]') await editor.pressSequentially("!") @@ -346,7 +402,10 @@ async function draftFollowUp(page: Page) { await expect(editor).toHaveText(followUp) } -async function openDraft(page: Page, options?: { failSessionCreate?: boolean }) { +async function openDraft( + page: Page, + options?: { failSessionCreate?: boolean; command?: boolean; events?: () => OpenCodeEvent[] }, +) { const worktree = Promise.withResolvers<{ status: number; json: { directory?: string; message?: string } }>() const calls: string[] = [] const worktreeRequests: Record[] = [] @@ -378,6 +437,7 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean }) sessions, pageMessages: () => ({ items: [] }), onPrompt: (input) => prompts.push(input), + events: options?.events, }) page.on("request", (request) => { if (request.method() !== "POST") return @@ -436,6 +496,17 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean }) headers, }), ) + if (options?.command) { + await page.route("**/api/command?**", (route) => + route.fulfill({ + json: { + location: { directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory }, + data: [{ name: "review", description: "Review changes" }], + }, + headers, + }), + ) + } await page.addInitScript( ({ directory, draftID, otherID, server }) => { localStorage.setItem( @@ -464,8 +535,8 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean }) return { worktree, worktreeRequests, calls, creates, prompts } } -async function submitPending(page: Page, mock: Awaited>) { - await page.locator('[data-component="composer-editor"]').fill(text) +async function submitPending(page: Page, mock: Awaited>, prompt = text) { + await page.locator('[data-component="composer-editor"]').fill(prompt) await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled() await page.locator('[data-action="composer-submit"]').click() await expect(page).toHaveURL((url) => url.pathname.startsWith(sessionPath) && /\/ses_[^/]+$/.test(url.pathname)) @@ -481,7 +552,7 @@ async function submitPending(page: Page, mock: Awaited data.location.command.list({ directory: sdk().directory }), editor: () => editor, queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })), addToHistory: (value, mode) => controller.addHistory(value, mode), diff --git a/packages/app/src/composer/submit.test.ts b/packages/app/src/composer/submit.test.ts index 178dcd0db77..e209b92d098 100644 --- a/packages/app/src/composer/submit.test.ts +++ b/packages/app/src/composer/submit.test.ts @@ -52,10 +52,12 @@ function submitInput( adapter: ActiveComposerAdapter | NewSessionComposerAdapter, notify = { missingSelection() {}, failed(_kind: "shell" | "command" | "prompt", _error: unknown) {} }, mode: "normal" | "shell" = "normal", + commands: () => readonly { name: string }[] | undefined = () => [], ) { return createComposerSubmit({ adapter, mode: () => mode, + commands, editor: () => undefined, queueScroll() {}, addToHistory() {}, @@ -420,7 +422,6 @@ describe("Composer submission", () => { prompt: async () => undefined, command: async (value) => sent.resolve(value), }) - target.data.location.command.list = () => [{ name: "review", description: "Review changes", template: "" }] const adapter: ActiveComposerAdapter = { kind: "active-session", state, @@ -433,7 +434,7 @@ describe("Composer submission", () => { setEditor() {}, } - await submitInput(adapter).submit(new Event("submit")) + await submitInput(adapter, undefined, "normal", () => [{ name: "review" }]).submit(new Event("submit")) const request = await sent.promise expect(request.files).toMatchObject([{ name: "app.ts", mention: { text: "@src/app.ts" } }]) @@ -442,6 +443,51 @@ describe("Composer submission", () => { expect(request.delivery).toBe("steer") }) + test("captures commands before creating a session in a new worktree", async () => { + const state = createMemoryComposerState({ prompt: "/review https://github.com/example/repo/pull/1" }).capture() + const catalog = [{ name: "review" }] + const sent = Promise.withResolvers<"prompt" | "command">() + const requests: Parameters[0][] = [] + const target = session({ + calls: [], + prompt: async () => sent.resolve("prompt"), + command: async (value) => { + requests.push(value) + sent.resolve("command") + }, + }) + target.directory = "C:/new-worktree" + target.data.location.command.list = () => undefined + const adapter: NewSessionComposerAdapter = { + kind: "new-session", + state, + ready: () => true, + controls, + working: () => false, + submitted() {}, + async start() { + // The destination catalog has not loaded, and the source composer is leaving. + catalog.splice(0) + return { session: target, cleanupReady: Promise.resolve() } + }, + } + + await submitInput(adapter, undefined, "normal", () => catalog).submit(new Event("submit")) + + expect(await sent.promise).toBe("command") + expect(requests).toEqual([ + { + sessionID: target.id, + command: "review", + text: "https://github.com/example/repo/pull/1", + files: [], + agents: [], + skills: [], + delivery: "steer", + }, + ]) + }) + test("does not run an empty shell command from hidden attachments", async () => { const state = createMemoryComposerState().capture() state.set([ diff --git a/packages/app/src/composer/submit.ts b/packages/app/src/composer/submit.ts index e7a344a0fb3..2f8622cb4fc 100644 --- a/packages/app/src/composer/submit.ts +++ b/packages/app/src/composer/submit.ts @@ -27,6 +27,7 @@ type ComposerSubmission = { type ComposerSubmitInput = { adapter: ComposerAdapter mode: Accessor<"normal" | "shell"> + commands: Accessor editor: () => HTMLDivElement | undefined queueScroll: () => void addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void @@ -65,6 +66,8 @@ export function createComposerSubmit(input: ComposerSubmitInput) { if (submitting.has(input.adapter.state)) return submitting.add(input.adapter.state) const comments = input.comments.capture() + // Capture command intent before starting a session in a worktree whose catalog has not loaded. + const command = value.mode === "normal" ? findCommand(input.commands(), value.text) : undefined try { const started = @@ -78,7 +81,6 @@ export function createComposerSubmit(input: ComposerSubmitInput) { input.resetHistory() const restore = () => restoreSubmission(input, submission, value, comments) - const command = value.mode === "normal" ? findCommand(session, value.text) : undefined if (value.mode === "normal" && !command) { session.handoff?.set(handoffMessage(value)) const optimisticBusy = !input.adapter.working() @@ -277,12 +279,11 @@ async function sendShell(session: ComposerSession, value: ComposerSubmission) { await session.api.shell({ sessionID: session.id, id: Event.ID.create(), command: value.text }) } -function findCommand(session: ComposerSession, text: string) { +function findCommand(commands: ReturnType, text: string) { if (!text.startsWith("/")) return const [name, ...arguments_] = text.split(" ") const command = name.slice(1) - if (!session.data.location.command.list({ directory: session.directory })?.some((item) => item.name === command)) - return + if (!commands?.some((item) => item.name === command)) return return { command, arguments: arguments_.join(" ") } }