Browse Source

fix(app): new session in workspace choosing wrong workspace

Adam 2 weeks ago
parent
commit
83853cc5e6

+ 140 - 0
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)))
+    }
+  })
+})

+ 3 - 0
packages/app/e2e/selectors.ts

@@ -48,6 +48,9 @@ export const workspaceItemSelector = (slug: string) =>
 export const workspaceMenuTriggerSelector = (slug: string) =>
 export const workspaceMenuTriggerSelector = (slug: string) =>
   `${sidebarNavSelector} [data-action="workspace-menu"][data-workspace="${slug}"]`
   `${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 listItemSelector = '[data-slot="list-item"]'
 
 
 export const listItemKeyStartsWithSelector = (prefix: string) => `${listItemSelector}[data-key^="${prefix}"]`
 export const listItemKeyStartsWithSelector = (prefix: string) => `${listItemSelector}[data-key^="${prefix}"]`

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

@@ -787,7 +787,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     },
     },
     setMode: (mode) => setStore("mode", mode),
     setMode: (mode) => setStore("mode", mode),
     setPopover: (popover) => setStore("popover", popover),
     setPopover: (popover) => setStore("popover", popover),
-    newSessionWorktree: props.newSessionWorktree,
+    newSessionWorktree: () => props.newSessionWorktree,
     onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
     onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
     onSubmit: props.onSubmit,
     onSubmit: props.onSubmit,
   })
   })

+ 175 - 0
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"])
+  })
+})

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

@@ -37,7 +37,7 @@ type PromptSubmitInput = {
   resetHistoryNavigation: () => void
   resetHistoryNavigation: () => void
   setMode: (mode: "normal" | "shell") => void
   setMode: (mode: "normal" | "shell") => void
   setPopover: (popover: "at" | "slash" | null) => void
   setPopover: (popover: "at" | "slash" | null) => void
-  newSessionWorktree?: string
+  newSessionWorktree?: Accessor<string | undefined>
   onNewSessionWorktreeReset?: () => void
   onNewSessionWorktreeReset?: () => void
   onSubmit?: () => void
   onSubmit?: () => void
 }
 }
@@ -137,7 +137,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
 
 
     const projectDirectory = sdk.directory
     const projectDirectory = sdk.directory
     const isNewSession = !params.id
     const isNewSession = !params.id
-    const worktreeSelection = input.newSessionWorktree || "main"
+    const worktreeSelection = input.newSessionWorktree?.() || "main"
 
 
     let sessionDirectory = projectDirectory
     let sessionDirectory = projectDirectory
     let client = sdk.client
     let client = sdk.client