fix(app): preserve slash commands when creating worktrees (#47155)

This commit is contained in:
Luke Parker
2026-09-04 08:25:42 +10:00
committed by GitHub
Unverified
parent fa6e7e179d
commit f06bf47e72
4 changed files with 129 additions and 10 deletions
@@ -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<string, unknown> }[] = []
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<string, unknown>[] = []
@@ -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<ReturnType<typeof openDraft>>) {
await page.locator('[data-component="composer-editor"]').fill(text)
async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDraft>>, 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<ReturnType<typeof openDra
await expect(page.locator('[data-action="composer-submit"]')).toBeDisabled()
await expect(preparing.locator('[data-component="user-message"]')).toHaveCount(1)
await expect(message).toHaveCount(1)
await expect(message.locator('[data-slot="user-message-text"]')).toHaveText(text)
await expect(message.locator('[data-slot="user-message-text"]')).toHaveText(prompt)
await expect(message).toHaveAttribute("data-timeline-part-id", /^.+:text:0$/)
const messageID = (await message.getAttribute("data-timeline-part-id"))!.replace(/:text:0$/, "")
await expect(shimmer).toBeVisible()
+1
View File
@@ -251,6 +251,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
const submission = createComposerSubmit({
adapter,
mode,
commands: () => data.location.command.list({ directory: sdk().directory }),
editor: () => editor,
queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
addToHistory: (value, mode) => controller.addHistory(value, mode),
+48 -2
View File
@@ -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<ComposerSession["api"]["command"]>[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([
+5 -4
View File
@@ -27,6 +27,7 @@ type ComposerSubmission = {
type ComposerSubmitInput = {
adapter: ComposerAdapter
mode: Accessor<"normal" | "shell">
commands: Accessor<readonly { name: string }[] | undefined>
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<ComposerSubmitInput["commands"]>, 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(" ") }
}