diff --git a/packages/app/e2e/regression/new-session-workspace-branch.spec.ts b/packages/app/e2e/regression/new-session-workspace-branch.spec.ts index 4ccbc1609f00..919acdb290b5 100644 --- a/packages/app/e2e/regression/new-session-workspace-branch.spec.ts +++ b/packages/app/e2e/regression/new-session-workspace-branch.spec.ts @@ -6,48 +6,55 @@ const draftID = "draft_new_session_workspace_branch" const directory = "C:/OpenCode/WorkspaceBranch" const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` -test("selects a base branch for a new workspace", async ({ page }) => { - await mockOpenCodeServer(page, { - directory, - project: { - id: "proj_new_session_workspace_branch", - worktree: directory, - vcs: "git", - name: "workspace-branch", - time: { created: 1700000000000, updated: 1700000000000 }, - sandboxes: [], - }, - provider: { all: [], connected: [], default: {} }, - sessions: [], - pageMessages: () => ({ items: [] }), - vcsBranches: ["feature/api", "main", "origin/release"], - }) - await page.addInitScript( - ({ directory, draftID, server }) => { - localStorage.setItem( - "opencode.global.dat:server", - JSON.stringify({ - projects: { local: [{ worktree: directory, expanded: true }] }, - lastProject: { local: directory }, - }), - ) - localStorage.setItem( - "opencode.window.browser.dat:tabs", - JSON.stringify([{ type: "draft", draftID, server, directory }]), - ) - }, - { directory, draftID, server }, - ) +for (const vcs of ["git", "hg"]) { + test(`gates new workspace selection for ${vcs}`, async ({ page }) => { + await mockOpenCodeServer(page, { + directory, + project: { + id: "proj_new_session_workspace_branch", + worktree: directory, + vcs, + name: "workspace-branch", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { all: [], connected: [], default: {} }, + sessions: [], + pageMessages: () => ({ items: [] }), + vcsBranches: ["feature/api", "main", "origin/release"], + }) + await page.addInitScript( + ({ directory, draftID, server }) => { + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([{ type: "draft", draftID, server, directory }]), + ) + }, + { directory, draftID, server }, + ) - await page.goto(`/new-session?draftId=${draftID}`) - await expectAppVisible(page.locator('[data-component="composer-editor"]')) - await page.getByRole("button", { name: "Local", exact: true }).click() - await page.getByRole("menuitem", { name: "New worktree", exact: true }).click() - await page.getByRole("button", { name: "from main", exact: true }).click() - await page.getByRole("menuitemradio", { name: "feature/api", exact: true }).click() + await page.goto(`/new-session?draftId=${draftID}`) + await expectAppVisible(page.locator('[data-component="composer-editor"]')) + if (vcs !== "git") { + await expect(page.getByText("main", { exact: true })).toBeVisible() + await expect(page.getByRole("button", { name: "Local", exact: true })).toHaveCount(0) + return + } + await page.getByRole("button", { name: "Local", exact: true }).click() + await page.getByRole("menuitem", { name: "New worktree", exact: true }).click() + await page.getByRole("button", { name: "from main", exact: true }).click() + await page.getByRole("menuitemradio", { name: "feature/api", exact: true }).click() - const selected = page.getByRole("button", { name: "from feature/api", exact: true }) - await expect(selected).toBeVisible() - await selected.click() - await expect(page.getByRole("menuitemradio", { name: "feature/api", exact: true })).toBeChecked() -}) + const selected = page.getByRole("button", { name: "from feature/api", exact: true }) + await expect(selected).toBeVisible() + await selected.click() + await expect(page.getByRole("menuitemradio", { name: "feature/api", exact: true })).toBeChecked() + }) +} diff --git a/packages/app/e2e/regression/session-worktree-capabilities.spec.ts b/packages/app/e2e/regression/session-worktree-capabilities.spec.ts new file mode 100644 index 000000000000..86a0f9def5f8 --- /dev/null +++ b/packages/app/e2e/regression/session-worktree-capabilities.spec.ts @@ -0,0 +1,112 @@ +import { expect, test, type Page } from "@playwright/test" +import { base64Encode } from "@opencode-ai/util/encode" +import { fixture } from "../smoke/session-timeline.fixture" +import { mockOpenCodeServer } from "../utils/mock-server" +import { installSseTransport } from "../utils/sse-transport" + +test.use({ serviceWorkers: "block" }) + +for (const viewport of [ + { name: "desktop", width: 1280, height: 900 }, + { name: "mobile", width: 390, height: 844 }, +]) { + for (const vcs of ["git", "hg"]) { + test(`gates worktree creation but keeps existing moves for ${vcs} on ${viewport.name}`, async ({ page }, info) => { + await page.setViewportSize(viewport) + const destination = "C:/OpenCode/existing-checkout" + const session = { + id: "ses_worktree_capabilities", + projectID: fixture.project.id, + directory: fixture.directory, + title: "Worktree capabilities", + } + const transport = await installSseTransport(page, { server: fixture.serverKey }) + await mockOpenCodeServer(page, { + directory: fixture.directory, + project: { ...fixture.project, vcs, sandboxes: [destination] }, + provider: fixture.provider, + sessions: [session], + pageMessages: () => ({ + items: [{ id: "msg_saved", type: "user", text: "Review this change", time: { created: 1 } }], + }), + fileList: () => [], + vcsDiff: [ + { file: "file.txt", patch: "@@ -1 +1 @@\n-before\n+after", additions: 1, deletions: 1, status: "modified" }, + ], + }) + const creations: string[] = [] + page.on("request", (request) => { + if (request.method() === "POST" && new URL(request.url()).pathname === `/api/worktree/${fixture.project.id}`) + creations.push(request.url()) + }) + await page.route(`**/api/session/${session.id}/move`, (route) => + route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }), + ) + await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${session.id}`) + await expect(page.getByText("Review this change", { exact: true })).toBeVisible() + const prompt = page.getByRole("textbox", { name: "Prompt", exact: true }) + await prompt.fill("Keep this draft") + await openDetails(page, viewport.name === "mobile") + + const summary = page.locator('[data-component="session-summary-panel"]') + await expect(summary.getByRole("button", { name: /^1 Changed file/ })).toBeVisible() + await info.attach("summary", { + body: await page.screenshot({ path: info.outputPath("summary.png"), animations: "disabled" }), + contentType: "image/png", + }) + await expect + .soft(summary.getByRole("button", { name: "Move to worktree", exact: true })) + .toHaveCount(vcs === "git" ? 1 : 0) + + await summary.getByRole("button", { name: "Local repository", exact: true }).click() + const create = page.getByRole("menuitem", { name: "New worktree", exact: true }) + await expect(create).toBeVisible() + await expect.soft(create).toBeEnabled({ enabled: vcs === "git" }) + await info.attach("destinations", { + body: await page.screenshot({ path: info.outputPath("destinations.png"), animations: "disabled" }), + contentType: "image/png", + }) + await page.getByRole("menuitem", { name: "Worktree", exact: true }).press("ArrowRight") + const existing = page.getByRole("menuitem", { name: "existing-checkout", exact: true }) + await expect(existing).toBeVisible() + const move = page.waitForRequest( + (request) => + request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${session.id}/move`, + ) + await existing.press("Enter") + expect((await move).postDataJSON()).toEqual({ directory: destination }) + session.directory = destination + await transport.send({ + id: "evt_worktree_capabilities_moved", + type: "session.moved", + created: 2, + durable: { aggregateID: session.id, seq: 1, version: 1 }, + data: { sessionID: session.id, location: { directory: destination }, projectID: fixture.project.id }, + }) + await expect( + page.locator('[data-type="location-switched"]').getByText(destination, { exact: true }), + ).toBeVisible() + if (viewport.name === "mobile") await openDetails(page, true) + await expect(summary.getByRole("button", { name: "existing-checkout", exact: true })).toBeVisible() + await summary.getByRole("button", { name: "existing-checkout", exact: true }).click() + await expect(page.getByRole("menuitem", { name: "Local repository", exact: true })).toBeEnabled() + await page.keyboard.press("Escape") + await page.keyboard.press("Escape") + await expect(prompt).toHaveText("Keep this draft") + await expect(page.getByText("Review this change", { exact: true })).toBeVisible() + expect(creations).toEqual([]) + }) + } +} + +async function openDetails(page: Page, mobile: boolean) { + if (mobile) { + await page + .locator('[data-slot="session-mobile-view-navigation"]') + .getByRole("button", { name: "More options", exact: true }) + .click() + await page.getByRole("menuitem", { name: "Session details", exact: true }).click() + return + } + await page.getByRole("button", { name: "Session details", exact: true }).click() +} diff --git a/packages/app/src/new-session/view.tsx b/packages/app/src/new-session/view.tsx index ee7210c40a9f..fe2d8893d64c 100644 --- a/packages/app/src/new-session/view.tsx +++ b/packages/app/src/new-session/view.tsx @@ -70,7 +70,7 @@ export function NewSessionView(props: { fallback={ } diff --git a/packages/app/src/new-session/workspace/controller.test.ts b/packages/app/src/new-session/workspace/controller.test.ts index 44949821938a..2fe9bc72b9af 100644 --- a/packages/app/src/new-session/workspace/controller.test.ts +++ b/packages/app/src/new-session/workspace/controller.test.ts @@ -7,6 +7,7 @@ describe("new session workspace selection", () => { resolveNewSessionWorktree({ enabled: false, selected: "/project/feature", + fallback: "create", }), ).toBe("main") }) @@ -59,9 +60,10 @@ describe("new session workspace selection", () => { ).toBe("release") }) - test("uses location VCS state when the project inventory is stale", () => { - expect(resolveNewSessionGit({ branch: "dev" })).toBe(true) + test("requires Git project metadata before enabling worktree creation", () => { expect(resolveNewSessionGit({ projectVcs: "git" })).toBe(true) + expect(resolveNewSessionGit({ projectVcs: "hg" })).toBe(false) + expect(resolveNewSessionGit({ projectVcs: "custom" })).toBe(false) expect(resolveNewSessionGit({})).toBe(false) }) }) diff --git a/packages/app/src/new-session/workspace/controller.ts b/packages/app/src/new-session/workspace/controller.ts index b0e42ddb91f9..b335f78acfc1 100644 --- a/packages/app/src/new-session/workspace/controller.ts +++ b/packages/app/src/new-session/workspace/controller.ts @@ -33,8 +33,8 @@ export function resolveNewSessionBranch(input: { return input.worktreeBranch(directory) } -export function resolveNewSessionGit(input: { projectVcs?: string; branch?: string }) { - return input.projectVcs === "git" || input.branch !== undefined +export function resolveNewSessionGit(input: { projectVcs?: string }) { + return input.projectVcs === "git" } export function createNewSessionWorkspaceController(input: { @@ -59,7 +59,6 @@ export function createNewSessionWorkspaceController(input: { const visible = createMemo(() => resolveNewSessionGit({ projectVcs: currentProject()?.vcs, - branch: data.location.vcs.info({ directory: sdk().directory })?.branch.current, }), ) const selected = createMemo(() => { diff --git a/packages/app/src/session/timeline/message-timeline.tsx b/packages/app/src/session/timeline/message-timeline.tsx index f0a56539d3c9..92ce161c6995 100644 --- a/packages/app/src/session/timeline/message-timeline.tsx +++ b/packages/app/src/session/timeline/message-timeline.tsx @@ -292,7 +292,9 @@ export function SessionSummaryPanel(props: { - 0 && props.moveEligible}> + 0 && props.moveEligible && props.project.vcs === "git"} + > - void move("create")}> + void move("create")} + > {language.t("workspace.new")}