summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-10 06:02:17 -0600
committerAdam <[email protected]>2026-02-10 06:02:17 -0600
commit83853cc5e6f5b3d262403692f96e370661312aaf (patch)
tree95399226c3a827f2cfb7fbcddd2abbfe732a3800
parent4a73d51acd6cc2610fa962a424a6d7049520f560 (diff)
downloadopencode-83853cc5e6f5b3d262403692f96e370661312aaf.tar.gz
opencode-83853cc5e6f5b3d262403692f96e370661312aaf.zip
fix(app): new session in workspace choosing wrong workspace
-rw-r--r--packages/app/e2e/projects/workspace-new-session.spec.ts140
-rw-r--r--packages/app/e2e/selectors.ts3
-rw-r--r--packages/app/src/components/prompt-input.tsx2
-rw-r--r--packages/app/src/components/prompt-input/submit.test.ts175
-rw-r--r--packages/app/src/components/prompt-input/submit.ts4
5 files changed, 321 insertions, 3 deletions
diff --git a/packages/app/e2e/projects/workspace-new-session.spec.ts b/packages/app/e2e/projects/workspace-new-session.spec.ts
new file mode 100644
index 000000000..5af314caf
--- /dev/null
+++ b/packages/app/e2e/projects/workspace-new-session.spec.ts
@@ -0,0 +1,140 @@
+import { base64Decode } from "@opencode-ai/util/encode"
+import type { Page } from "@playwright/test"
+import { test, expect } from "../fixtures"
+import { cleanupTestProject, openSidebar, sessionIDFromUrl, setWorkspacesEnabled } from "../actions"
+import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
+import { createSdk } from "../utils"
+
+function slugFromUrl(url: string) {
+ return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
+}
+
+async function waitWorkspaceReady(page: Page, slug: string) {
+ await openSidebar(page)
+ await expect
+ .poll(
+ async () => {
+ const item = page.locator(workspaceItemSelector(slug)).first()
+ try {
+ await item.hover({ timeout: 500 })
+ return true
+ } catch {
+ return false
+ }
+ },
+ { timeout: 60_000 },
+ )
+ .toBe(true)
+}
+
+async function createWorkspace(page: Page, root: string, seen: string[]) {
+ await openSidebar(page)
+ await page.getByRole("button", { name: "New workspace" }).first().click()
+
+ await expect
+ .poll(
+ () => {
+ const slug = slugFromUrl(page.url())
+ if (!slug) return ""
+ if (slug === root) return ""
+ if (seen.includes(slug)) return ""
+ return slug
+ },
+ { timeout: 45_000 },
+ )
+ .not.toBe("")
+
+ const slug = slugFromUrl(page.url())
+ const directory = base64Decode(slug)
+ if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
+ return { slug, directory }
+}
+
+async function openWorkspaceNewSession(page: Page, slug: string) {
+ await waitWorkspaceReady(page, slug)
+
+ const item = page.locator(workspaceItemSelector(slug)).first()
+ await item.hover()
+
+ const button = page.locator(workspaceNewSessionSelector(slug)).first()
+ await expect(button).toBeVisible()
+ await button.click({ force: true })
+
+ await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
+ await expect(page).toHaveURL(new RegExp(`/${slug}/session(?:[/?#]|$)`))
+}
+
+async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
+ await openWorkspaceNewSession(page, slug)
+
+ const prompt = page.locator(promptSelector)
+ await expect(prompt).toBeVisible()
+ await prompt.click()
+ await page.keyboard.type(text)
+ await page.keyboard.press("Enter")
+
+ await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
+ await expect(page).toHaveURL(new RegExp(`/${slug}/session/[^/?#]+`), { timeout: 30_000 })
+
+ const sessionID = sessionIDFromUrl(page.url())
+ if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
+ return sessionID
+}
+
+async function sessionDirectory(directory: string, sessionID: string) {
+ const info = await createSdk(directory)
+ .session.get({ sessionID })
+ .then((x) => x.data)
+ .catch(() => undefined)
+ if (!info) return ""
+ return info.directory
+}
+
+test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
+ await page.setViewportSize({ width: 1400, height: 800 })
+
+ await withProject(async ({ directory, slug: root }) => {
+ const workspaces = [] as { slug: string; directory: string }[]
+ const sessions = [] as string[]
+
+ try {
+ await openSidebar(page)
+ await setWorkspacesEnabled(page, root, true)
+
+ const first = await createWorkspace(page, root, [])
+ workspaces.push(first)
+ await waitWorkspaceReady(page, first.slug)
+
+ const second = await createWorkspace(page, root, [first.slug])
+ workspaces.push(second)
+ await waitWorkspaceReady(page, second.slug)
+
+ const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
+ sessions.push(firstSession)
+
+ const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
+ sessions.push(secondSession)
+
+ const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
+ sessions.push(thirdSession)
+
+ await expect.poll(() => sessionDirectory(first.directory, firstSession)).toBe(first.directory)
+ await expect.poll(() => sessionDirectory(second.directory, secondSession)).toBe(second.directory)
+ await expect.poll(() => sessionDirectory(first.directory, thirdSession)).toBe(first.directory)
+ } finally {
+ const dirs = [directory, ...workspaces.map((workspace) => workspace.directory)]
+ await Promise.all(
+ sessions.map((sessionID) =>
+ Promise.all(
+ dirs.map((dir) =>
+ createSdk(dir)
+ .session.delete({ sessionID })
+ .catch(() => undefined),
+ ),
+ ),
+ ),
+ )
+ await Promise.all(workspaces.map((workspace) => cleanupTestProject(workspace.directory)))
+ }
+ })
+})
diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts
index 317c70969..842433891 100644
--- a/packages/app/e2e/selectors.ts
+++ b/packages/app/e2e/selectors.ts
@@ -48,6 +48,9 @@ export const workspaceItemSelector = (slug: string) =>
export const workspaceMenuTriggerSelector = (slug: string) =>
`${sidebarNavSelector} [data-action="workspace-menu"][data-workspace="${slug}"]`
+export const workspaceNewSessionSelector = (slug: string) =>
+ `${sidebarNavSelector} [data-action="workspace-new-session"][data-workspace="${slug}"]`
+
export const listItemSelector = '[data-slot="list-item"]'
export const listItemKeyStartsWithSelector = (prefix: string) => `${listItemSelector}[data-key^="${prefix}"]`
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
index da45c351e..4f495d27d 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/packages/app/src/components/prompt-input.tsx
@@ -787,7 +787,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
},
setMode: (mode) => setStore("mode", mode),
setPopover: (popover) => setStore("popover", popover),
- newSessionWorktree: props.newSessionWorktree,
+ newSessionWorktree: () => props.newSessionWorktree,
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
onSubmit: props.onSubmit,
})
diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts
new file mode 100644
index 000000000..475a0e20f
--- /dev/null
+++ b/packages/app/src/components/prompt-input/submit.test.ts
@@ -0,0 +1,175 @@
+import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
+import type { Prompt } from "@/context/prompt"
+
+let createPromptSubmit: typeof import("./submit").createPromptSubmit
+
+const createdClients: string[] = []
+const createdSessions: string[] = []
+const sentShell: string[] = []
+const syncedDirectories: string[] = []
+
+let selected = "/repo/worktree-a"
+
+const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
+
+const clientFor = (directory: string) => ({
+ session: {
+ create: async () => {
+ createdSessions.push(directory)
+ return { data: { id: `session-${createdSessions.length}` } }
+ },
+ shell: async () => {
+ sentShell.push(directory)
+ return { data: undefined }
+ },
+ prompt: async () => ({ data: undefined }),
+ command: async () => ({ data: undefined }),
+ abort: async () => ({ data: undefined }),
+ },
+ worktree: {
+ create: async () => ({ data: { directory: `${directory}/new` } }),
+ },
+})
+
+beforeAll(async () => {
+ const rootClient = clientFor("/repo/main")
+
+ mock.module("@solidjs/router", () => ({
+ useNavigate: () => () => undefined,
+ useParams: () => ({}),
+ }))
+
+ mock.module("@opencode-ai/sdk/v2/client", () => ({
+ createOpencodeClient: (input: { directory: string }) => {
+ createdClients.push(input.directory)
+ return clientFor(input.directory)
+ },
+ }))
+
+ mock.module("@opencode-ai/ui/toast", () => ({
+ showToast: () => 0,
+ }))
+
+ mock.module("@opencode-ai/util/encode", () => ({
+ base64Encode: (value: string) => value,
+ }))
+
+ mock.module("@/context/local", () => ({
+ useLocal: () => ({
+ model: {
+ current: () => ({ id: "model", provider: { id: "provider" } }),
+ variant: { current: () => undefined },
+ },
+ agent: {
+ current: () => ({ name: "agent" }),
+ },
+ }),
+ }))
+
+ mock.module("@/context/prompt", () => ({
+ usePrompt: () => ({
+ current: () => promptValue,
+ reset: () => undefined,
+ set: () => undefined,
+ context: {
+ add: () => undefined,
+ remove: () => undefined,
+ items: () => [],
+ },
+ }),
+ }))
+
+ mock.module("@/context/layout", () => ({
+ useLayout: () => ({
+ handoff: {
+ setTabs: () => undefined,
+ },
+ }),
+ }))
+
+ mock.module("@/context/sdk", () => ({
+ useSDK: () => ({
+ directory: "/repo/main",
+ client: rootClient,
+ url: "http://localhost:4096",
+ }),
+ }))
+
+ mock.module("@/context/sync", () => ({
+ useSync: () => ({
+ data: { command: [] },
+ session: {
+ optimistic: {
+ add: () => undefined,
+ remove: () => undefined,
+ },
+ },
+ set: () => undefined,
+ }),
+ }))
+
+ mock.module("@/context/global-sync", () => ({
+ useGlobalSync: () => ({
+ child: (directory: string) => {
+ syncedDirectories.push(directory)
+ return [{}, () => undefined]
+ },
+ }),
+ }))
+
+ mock.module("@/context/platform", () => ({
+ usePlatform: () => ({
+ fetch: fetch,
+ }),
+ }))
+
+ mock.module("@/context/language", () => ({
+ useLanguage: () => ({
+ t: (key: string) => key,
+ }),
+ }))
+
+ const mod = await import("./submit")
+ createPromptSubmit = mod.createPromptSubmit
+})
+
+beforeEach(() => {
+ createdClients.length = 0
+ createdSessions.length = 0
+ sentShell.length = 0
+ syncedDirectories.length = 0
+ selected = "/repo/worktree-a"
+})
+
+describe("prompt submit worktree selection", () => {
+ test("reads the latest worktree accessor value per submit", async () => {
+ const submit = createPromptSubmit({
+ info: () => undefined,
+ imageAttachments: () => [],
+ commentCount: () => 0,
+ mode: () => "shell",
+ working: () => false,
+ editor: () => undefined,
+ queueScroll: () => undefined,
+ promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
+ addToHistory: () => undefined,
+ resetHistoryNavigation: () => undefined,
+ setMode: () => undefined,
+ setPopover: () => undefined,
+ newSessionWorktree: () => selected,
+ onNewSessionWorktreeReset: () => undefined,
+ onSubmit: () => undefined,
+ })
+
+ const event = { preventDefault: () => undefined } as unknown as Event
+
+ await submit.handleSubmit(event)
+ selected = "/repo/worktree-b"
+ await submit.handleSubmit(event)
+
+ expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
+ expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
+ expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
+ expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
+ })
+})
diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts
index c118f8cb2..49d75a95e 100644
--- a/packages/app/src/components/prompt-input/submit.ts
+++ b/packages/app/src/components/prompt-input/submit.ts
@@ -37,7 +37,7 @@ type PromptSubmitInput = {
resetHistoryNavigation: () => void
setMode: (mode: "normal" | "shell") => void
setPopover: (popover: "at" | "slash" | null) => void
- newSessionWorktree?: string
+ newSessionWorktree?: Accessor<string | undefined>
onNewSessionWorktreeReset?: () => void
onSubmit?: () => void
}
@@ -137,7 +137,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const projectDirectory = sdk.directory
const isNewSession = !params.id
- const worktreeSelection = input.newSessionWorktree || "main"
+ const worktreeSelection = input.newSessionWorktree?.() || "main"
let sessionDirectory = projectDirectory
let client = sdk.client