fix(app): new session in workspace choosing wrong workspace
This commit is contained in:
140
packages/app/e2e/projects/workspace-new-session.spec.ts
Normal file
140
packages/app/e2e/projects/workspace-new-session.spec.ts
Normal file
@@ -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)))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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}"]`
|
||||||
|
|||||||
@@ -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
packages/app/src/components/prompt-input/submit.test.ts
Normal file
175
packages/app/src/components/prompt-input/submit.test.ts
Normal file
@@ -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"])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user