Skip to content
Open
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
93 changes: 50 additions & 43 deletions packages/app/e2e/regression/new-session-workspace-branch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
}
112 changes: 112 additions & 0 deletions packages/app/e2e/regression/session-worktree-capabilities.spec.ts
Original file line number Diff line number Diff line change
@@ -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", {
Comment on lines +62 to +65
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()
}
2 changes: 1 addition & 1 deletion packages/app/src/new-session/view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export function NewSessionView(props: {
fallback={
<PromptGitStatus
branch={props.workspace.bar.branch()}
noGit={!props.workspace.project.git()}
noGit={!props.workspace.bar.branch() && !props.workspace.project.git()}
class="ms-1"
/>
}
Expand Down
6 changes: 4 additions & 2 deletions packages/app/src/new-session/workspace/controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ describe("new session workspace selection", () => {
resolveNewSessionWorktree({
enabled: false,
selected: "/project/feature",
fallback: "create",
}),
).toBe("main")
})
Expand Down Expand Up @@ -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)
})
})
5 changes: 2 additions & 3 deletions packages/app/src/new-session/workspace/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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(() => {
Expand Down
4 changes: 3 additions & 1 deletion packages/app/src/session/timeline/message-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,9 @@ export function SessionSummaryPanel(props: {
<BackgroundWorkSummary tasks={props.backgroundTasks} mobile={props.mobile} />
</Show>
</div>
<Show when={props.local && props.diffs && props.diffs.length > 0 && props.moveEligible}>
<Show
when={props.local && props.diffs && props.diffs.length > 0 && props.moveEligible && props.project.vcs === "git"}
>
<WorkspaceMoveAction
variant="panel"
mobile={props.mobile}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,10 @@ export function SessionWorkspaceMenu(props: {
{language.t("session.new.workspace.local")}
</Menu.Item>
</Show>
<Menu.Item disabled={!!store.selected || blocked()} onSelect={() => void move("create")}>
<Menu.Item
disabled={props.project.vcs !== "git" || !!store.selected || blocked()}
onSelect={() => void move("create")}
>
<Icon name="workspace-new" />
{language.t("workspace.new")}
</Menu.Item>
Expand Down
Loading