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 = (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 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