Skip to content

Commit 83853cc

Browse files
committed
fix(app): new session in workspace choosing wrong workspace
1 parent 4a73d51 commit 83853cc

5 files changed

Lines changed: 321 additions & 3 deletions

File tree

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { base64Decode } from "@opencode-ai/util/encode"
2+
import type { Page } from "@playwright/test"
3+
import { test, expect } from "../fixtures"
4+
import { cleanupTestProject, openSidebar, sessionIDFromUrl, setWorkspacesEnabled } from "../actions"
5+
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
6+
import { createSdk } from "../utils"
7+
8+
function slugFromUrl(url: string) {
9+
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
10+
}
11+
12+
async function waitWorkspaceReady(page: Page, slug: string) {
13+
await openSidebar(page)
14+
await expect
15+
.poll(
16+
async () => {
17+
const item = page.locator(workspaceItemSelector(slug)).first()
18+
try {
19+
await item.hover({ timeout: 500 })
20+
return true
21+
} catch {
22+
return false
23+
}
24+
},
25+
{ timeout: 60_000 },
26+
)
27+
.toBe(true)
28+
}
29+
30+
async function createWorkspace(page: Page, root: string, seen: string[]) {
31+
await openSidebar(page)
32+
await page.getByRole("button", { name: "New workspace" }).first().click()
33+
34+
await expect
35+
.poll(
36+
() => {
37+
const slug = slugFromUrl(page.url())
38+
if (!slug) return ""
39+
if (slug === root) return ""
40+
if (seen.includes(slug)) return ""
41+
return slug
42+
},
43+
{ timeout: 45_000 },
44+
)
45+
.not.toBe("")
46+
47+
const slug = slugFromUrl(page.url())
48+
const directory = base64Decode(slug)
49+
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
50+
return { slug, directory }
51+
}
52+
53+
async function openWorkspaceNewSession(page: Page, slug: string) {
54+
await waitWorkspaceReady(page, slug)
55+
56+
const item = page.locator(workspaceItemSelector(slug)).first()
57+
await item.hover()
58+
59+
const button = page.locator(workspaceNewSessionSelector(slug)).first()
60+
await expect(button).toBeVisible()
61+
await button.click({ force: true })
62+
63+
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
64+
await expect(page).toHaveURL(new RegExp(`/${slug}/session(?:[/?#]|$)`))
65+
}
66+
67+
async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
68+
await openWorkspaceNewSession(page, slug)
69+
70+
const prompt = page.locator(promptSelector)
71+
await expect(prompt).toBeVisible()
72+
await prompt.click()
73+
await page.keyboard.type(text)
74+
await page.keyboard.press("Enter")
75+
76+
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
77+
await expect(page).toHaveURL(new RegExp(`/${slug}/session/[^/?#]+`), { timeout: 30_000 })
78+
79+
const sessionID = sessionIDFromUrl(page.url())
80+
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
81+
return sessionID
82+
}
83+
84+
async function sessionDirectory(directory: string, sessionID: string) {
85+
const info = await createSdk(directory)
86+
.session.get({ sessionID })
87+
.then((x) => x.data)
88+
.catch(() => undefined)
89+
if (!info) return ""
90+
return info.directory
91+
}
92+
93+
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
94+
await page.setViewportSize({ width: 1400, height: 800 })
95+
96+
await withProject(async ({ directory, slug: root }) => {
97+
const workspaces = [] as { slug: string; directory: string }[]
98+
const sessions = [] as string[]
99+
100+
try {
101+
await openSidebar(page)
102+
await setWorkspacesEnabled(page, root, true)
103+
104+
const first = await createWorkspace(page, root, [])
105+
workspaces.push(first)
106+
await waitWorkspaceReady(page, first.slug)
107+
108+
const second = await createWorkspace(page, root, [first.slug])
109+
workspaces.push(second)
110+
await waitWorkspaceReady(page, second.slug)
111+
112+
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
113+
sessions.push(firstSession)
114+
115+
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
116+
sessions.push(secondSession)
117+
118+
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
119+
sessions.push(thirdSession)
120+
121+
await expect.poll(() => sessionDirectory(first.directory, firstSession)).toBe(first.directory)
122+
await expect.poll(() => sessionDirectory(second.directory, secondSession)).toBe(second.directory)
123+
await expect.poll(() => sessionDirectory(first.directory, thirdSession)).toBe(first.directory)
124+
} finally {
125+
const dirs = [directory, ...workspaces.map((workspace) => workspace.directory)]
126+
await Promise.all(
127+
sessions.map((sessionID) =>
128+
Promise.all(
129+
dirs.map((dir) =>
130+
createSdk(dir)
131+
.session.delete({ sessionID })
132+
.catch(() => undefined),
133+
),
134+
),
135+
),
136+
)
137+
await Promise.all(workspaces.map((workspace) => cleanupTestProject(workspace.directory)))
138+
}
139+
})
140+
})

packages/app/e2e/selectors.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ export const workspaceItemSelector = (slug: string) =>
4848
export const workspaceMenuTriggerSelector = (slug: string) =>
4949
`${sidebarNavSelector} [data-action="workspace-menu"][data-workspace="${slug}"]`
5050

51+
export const workspaceNewSessionSelector = (slug: string) =>
52+
`${sidebarNavSelector} [data-action="workspace-new-session"][data-workspace="${slug}"]`
53+
5154
export const listItemSelector = '[data-slot="list-item"]'
5255

5356
export const listItemKeyStartsWithSelector = (prefix: string) => `${listItemSelector}[data-key^="${prefix}"]`

packages/app/src/components/prompt-input.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -787,7 +787,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
787787
},
788788
setMode: (mode) => setStore("mode", mode),
789789
setPopover: (popover) => setStore("popover", popover),
790-
newSessionWorktree: props.newSessionWorktree,
790+
newSessionWorktree: () => props.newSessionWorktree,
791791
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
792792
onSubmit: props.onSubmit,
793793
})
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
2+
import type { Prompt } from "@/context/prompt"
3+
4+
let createPromptSubmit: typeof import("./submit").createPromptSubmit
5+
6+
const createdClients: string[] = []
7+
const createdSessions: string[] = []
8+
const sentShell: string[] = []
9+
const syncedDirectories: string[] = []
10+
11+
let selected = "/repo/worktree-a"
12+
13+
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
14+
15+
const clientFor = (directory: string) => ({
16+
session: {
17+
create: async () => {
18+
createdSessions.push(directory)
19+
return { data: { id: `session-${createdSessions.length}` } }
20+
},
21+
shell: async () => {
22+
sentShell.push(directory)
23+
return { data: undefined }
24+
},
25+
prompt: async () => ({ data: undefined }),
26+
command: async () => ({ data: undefined }),
27+
abort: async () => ({ data: undefined }),
28+
},
29+
worktree: {
30+
create: async () => ({ data: { directory: `${directory}/new` } }),
31+
},
32+
})
33+
34+
beforeAll(async () => {
35+
const rootClient = clientFor("/repo/main")
36+
37+
mock.module("@solidjs/router", () => ({
38+
useNavigate: () => () => undefined,
39+
useParams: () => ({}),
40+
}))
41+
42+
mock.module("@opencode-ai/sdk/v2/client", () => ({
43+
createOpencodeClient: (input: { directory: string }) => {
44+
createdClients.push(input.directory)
45+
return clientFor(input.directory)
46+
},
47+
}))
48+
49+
mock.module("@opencode-ai/ui/toast", () => ({
50+
showToast: () => 0,
51+
}))
52+
53+
mock.module("@opencode-ai/util/encode", () => ({
54+
base64Encode: (value: string) => value,
55+
}))
56+
57+
mock.module("@/context/local", () => ({
58+
useLocal: () => ({
59+
model: {
60+
current: () => ({ id: "model", provider: { id: "provider" } }),
61+
variant: { current: () => undefined },
62+
},
63+
agent: {
64+
current: () => ({ name: "agent" }),
65+
},
66+
}),
67+
}))
68+
69+
mock.module("@/context/prompt", () => ({
70+
usePrompt: () => ({
71+
current: () => promptValue,
72+
reset: () => undefined,
73+
set: () => undefined,
74+
context: {
75+
add: () => undefined,
76+
remove: () => undefined,
77+
items: () => [],
78+
},
79+
}),
80+
}))
81+
82+
mock.module("@/context/layout", () => ({
83+
useLayout: () => ({
84+
handoff: {
85+
setTabs: () => undefined,
86+
},
87+
}),
88+
}))
89+
90+
mock.module("@/context/sdk", () => ({
91+
useSDK: () => ({
92+
directory: "/repo/main",
93+
client: rootClient,
94+
url: "http://localhost:4096",
95+
}),
96+
}))
97+
98+
mock.module("@/context/sync", () => ({
99+
useSync: () => ({
100+
data: { command: [] },
101+
session: {
102+
optimistic: {
103+
add: () => undefined,
104+
remove: () => undefined,
105+
},
106+
},
107+
set: () => undefined,
108+
}),
109+
}))
110+
111+
mock.module("@/context/global-sync", () => ({
112+
useGlobalSync: () => ({
113+
child: (directory: string) => {
114+
syncedDirectories.push(directory)
115+
return [{}, () => undefined]
116+
},
117+
}),
118+
}))
119+
120+
mock.module("@/context/platform", () => ({
121+
usePlatform: () => ({
122+
fetch: fetch,
123+
}),
124+
}))
125+
126+
mock.module("@/context/language", () => ({
127+
useLanguage: () => ({
128+
t: (key: string) => key,
129+
}),
130+
}))
131+
132+
const mod = await import("./submit")
133+
createPromptSubmit = mod.createPromptSubmit
134+
})
135+
136+
beforeEach(() => {
137+
createdClients.length = 0
138+
createdSessions.length = 0
139+
sentShell.length = 0
140+
syncedDirectories.length = 0
141+
selected = "/repo/worktree-a"
142+
})
143+
144+
describe("prompt submit worktree selection", () => {
145+
test("reads the latest worktree accessor value per submit", async () => {
146+
const submit = createPromptSubmit({
147+
info: () => undefined,
148+
imageAttachments: () => [],
149+
commentCount: () => 0,
150+
mode: () => "shell",
151+
working: () => false,
152+
editor: () => undefined,
153+
queueScroll: () => undefined,
154+
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
155+
addToHistory: () => undefined,
156+
resetHistoryNavigation: () => undefined,
157+
setMode: () => undefined,
158+
setPopover: () => undefined,
159+
newSessionWorktree: () => selected,
160+
onNewSessionWorktreeReset: () => undefined,
161+
onSubmit: () => undefined,
162+
})
163+
164+
const event = { preventDefault: () => undefined } as unknown as Event
165+
166+
await submit.handleSubmit(event)
167+
selected = "/repo/worktree-b"
168+
await submit.handleSubmit(event)
169+
170+
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
171+
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
172+
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
173+
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
174+
})
175+
})

packages/app/src/components/prompt-input/submit.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ type PromptSubmitInput = {
3737
resetHistoryNavigation: () => void
3838
setMode: (mode: "normal" | "shell") => void
3939
setPopover: (popover: "at" | "slash" | null) => void
40-
newSessionWorktree?: string
40+
newSessionWorktree?: Accessor<string | undefined>
4141
onNewSessionWorktreeReset?: () => void
4242
onSubmit?: () => void
4343
}
@@ -137,7 +137,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
137137

138138
const projectDirectory = sdk.directory
139139
const isNewSession = !params.id
140-
const worktreeSelection = input.newSessionWorktree || "main"
140+
const worktreeSelection = input.newSessionWorktree?.() || "main"
141141

142142
let sessionDirectory = projectDirectory
143143
let client = sdk.client

0 commit comments

Comments
 (0)