diff --git a/packages/app/e2e/regression/workspaces.spec.ts b/packages/app/e2e/regression/workspaces.spec.ts new file mode 100644 index 000000000000..4cac32fc6a62 --- /dev/null +++ b/packages/app/e2e/regression/workspaces.spec.ts @@ -0,0 +1,419 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test, type Page } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible } from "../utils/waits" +import { installSseTransport } from "../utils/sse-transport" + +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` +const root = "C:/OpenCode/WorkspaceProject" +const workspace = "C:/OpenCode/worktree/project/feature" +const createdWorkspace = "C:/OpenCode/worktree/project/quick-contrast-fix" +const project = { + id: "proj_workspaces", + worktree: root, + vcs: "git" as const, + name: "workspace-project", + time: { created: 1, updated: 1 }, + sandboxes: [workspace], +} +const provider = { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { test: { id: "test", name: "Test model", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "test" }, +} +const diff = { + file: "src/workspace.ts", + additions: 3, + deletions: 1, + patch: "@@ -1 +1 @@\n-export const workspace = false\n+export const workspace = true", +} + +function userMessage(sessionID: string, id: string, text: string, withDiff = false) { + return { + info: { + id, + sessionID, + role: "user", + time: { created: 1 }, + agent: "build", + model: { providerID: "opencode", modelID: "test" }, + ...(withDiff ? { summary: { diffs: [diff] } } : {}), + }, + parts: [{ id: `prt_${id}`, sessionID, messageID: id, type: "text", text }], + } +} + +async function init(page: Page, tab: Record) { + await page.addInitScript( + ({ root, server, tab }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ projects: { local: [{ worktree: root, expanded: true }] }, lastProject: { local: root } }), + ) + localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify([{ server, ...tab }])) + }, + { root, server, tab }, + ) +} + +test("selects local, new, and existing workspaces from the ready-ish start menu", async ({ page }) => { + const draftID = "draft_workspaces" + await mockOpenCodeServer(page, { + directory: root, + project, + provider, + sessions: [], + pageMessages: () => ({ items: [] }), + }) + await init(page, { type: "draft", draftID, directory: root }) + + await page.goto(`/new-session?draftId=${draftID}`) + await expectAppVisible(page.locator('[data-component="prompt-input"]')) + + const trigger = page.getByRole("button", { name: /^local$/i }) + await expect(trigger).toBeVisible() + await trigger.click() + await expect(page.getByRole("menuitem", { name: "Local repository" })).toBeVisible() + await expect(page.getByRole("menuitem", { name: "New workspace" })).toBeVisible() + await expect(page.getByRole("menuitem", { name: "Workspace", exact: true })).toBeVisible() + await expect(page.getByRole("menuitem", { name: "View all" })).toHaveCount(0) + + await page.getByRole("menuitem", { name: "New workspace" }).click() + await expect(page.getByRole("button", { name: /New workspace/ })).toBeVisible() + await expect(page.getByText("main", { exact: true })).toBeVisible() + + await page.getByRole("button", { name: /New workspace/ }).click() + await page.getByRole("menuitem", { name: "Workspace", exact: true }).hover() + await page.getByRole("menuitem", { name: "feature" }).click() + await expect(page.getByRole("button", { name: /feature/ })).toBeVisible() +}) + +test("submits the owning prompt after a new workspace becomes ready", async ({ page }) => { + const draftID = "draft_workspace_submit" + const sessionID = "ses_workspace_submit" + const session = { + id: sessionID, + slug: "workspace-submit", + projectID: project.id, + directory: createdWorkspace, + title: "New session", + version: "dev", + time: { created: 1, updated: 2 }, + } + let prompt: unknown + const transport = await installSseTransport<{ directory: string; payload: Record }>(page, { server }) + await mockOpenCodeServer(page, { + directory: root, + project, + provider, + sessions: [session], + pageMessages: () => ({ items: [] }), + }) + await page.route("**/experimental/worktree**", async (route) => { + if (route.request().method() === "OPTIONS") { + await route.fulfill({ + status: 204, + headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" }, + }) + return + } + if (route.request().method() !== "POST") return route.fallback() + await route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify({ name: "quick-contrast-fix", directory: createdWorkspace, branch: "quick-contrast-fix" }), + }) + }) + await page.route("**/session**", async (route) => { + const url = new URL(route.request().url()) + if (url.pathname !== "/session") return route.fallback() + if (route.request().method() === "OPTIONS") { + await route.fulfill({ + status: 204, + headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" }, + }) + return + } + if (route.request().method() !== "POST") return route.fallback() + await route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify(session), + }) + }) + await page.route(`**/session/${sessionID}/prompt_async**`, async (route) => { + if (route.request().method() === "OPTIONS") { + await route.fulfill({ + status: 204, + headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" }, + }) + return + } + prompt = route.request().postDataJSON() + await route.fulfill({ + status: 204, + headers: { "access-control-allow-origin": "*" }, + }) + }) + await init(page, { type: "draft", draftID, directory: root }) + + await page.goto(`/new-session?draftId=${draftID}`) + await transport.waitForConnection() + await page.getByRole("button", { name: /^local$/i }).click() + await page.getByRole("menuitem", { name: "New workspace" }).click() + const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]') + await editor.fill("Build workspace support") + await page.locator('[data-action="prompt-submit"]').click() + + const lifecycle = page.locator('[data-timeline-row="WorkspaceLifecycle"]') + await expect(lifecycle).toContainText("Creating workspace") + for (const attempt of [1, 2, 3, 4, 5]) { + await transport.send({ + directory: createdWorkspace, + payload: { + id: `evt_submit_ready_${attempt}`, + type: "worktree.ready", + properties: { name: "quick-contrast-fix" }, + }, + }) + await page.waitForTimeout(100) + if (prompt) break + } + await expect.poll(() => prompt).not.toBeUndefined() + await expect(lifecycle).toContainText("Workspace created") +}) + +test("shows neutral workspace identity and the ready-ish session summary panel", async ({ page }) => { + const sessionID = "ses_workspace_summary" + const messageID = "msg_workspace_summary" + const session = { + id: sessionID, + slug: "workspace-summary", + projectID: project.id, + directory: workspace, + title: "Workspace summary session", + version: "dev", + time: { created: 1, updated: 2 }, + } + const vcsRequests: string[] = [] + page.on("request", (request) => { + if (new URL(request.url()).pathname === "/api/vcs/diff") vcsRequests.push(request.url()) + }) + await mockOpenCodeServer(page, { + protocol: "v2", + directory: workspace, + project, + provider, + sessions: [session], + pageMessages: () => ({ items: [userMessage(sessionID, messageID, "Implement workspace support", true)] }), + vcsDiff: [diff], + }) + await init(page, { type: "session", sessionId: sessionID }) + + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) + const timeline = page.locator("[data-workspace-session]") + await expect(timeline).toBeVisible() + await expect(timeline.locator(`[aria-label="${workspace}"]`)).toHaveAttribute("tabindex", "0") + await expect(timeline.locator('[data-slot="session-title-child"]')).toHaveClass(/text-v2-text-text-base/) + await expect(timeline.locator('[data-slot="user-message-text"]')).not.toHaveCSS( + "background-color", + "rgb(59, 92, 246)", + ) + + const title = page.locator("[data-session-title]") + await title.getByRole("button", { name: "Session details" }).click() + const panel = page.locator('[data-component="session-summary-panel"]') + await expect(panel).toBeVisible() + await expect(panel).toContainText("workspace-project") + await expect(panel).toContainText("feature") + await expect(panel).toContainText("1 Changed file") + await expect.poll(() => vcsRequests.length).toBeGreaterThan(0) + const request = new URL(vcsRequests.at(-1)!) + expect(request.searchParams.get("mode")).toBe("working") + expect(request.searchParams.get("location[directory]")).toBe(workspace) +}) + +test("moves a changed local session to an existing workspace with an end-of-turn divider", async ({ page }) => { + const sessionID = "ses_workspace_move_existing" + const messageID = "msg_workspace_move_existing" + const session = { + id: sessionID, + slug: "workspace-move-existing", + projectID: project.id, + directory: root, + title: "Move this session", + version: "dev", + time: { created: 1, updated: 2 }, + } + let move: unknown + let releaseMove = () => {} + const moveGate = new Promise((resolve) => { + releaseMove = resolve + }) + const transport = await installSseTransport<{ directory: string; payload: Record }>(page, { server }) + await mockOpenCodeServer(page, { + directory: root, + project, + provider, + sessions: [session], + pageMessages: () => ({ items: [userMessage(sessionID, messageID, "Move this work", true)] }), + vcsDiff: [diff], + }) + await page.route("**/experimental/control-plane/move-session", async (route) => { + if (route.request().method() === "OPTIONS") { + await route.fulfill({ + status: 204, + headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" }, + }) + return + } + move = route.request().postDataJSON() + await moveGate + session.directory = workspace + await route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: "null", + }) + }) + await init(page, { type: "session", sessionId: sessionID }) + + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) + await transport.waitForConnection() + const inlineMove = page + .locator('[data-component="session-turn-diffs-group"]') + .getByRole("button", { name: "Move to workspace" }) + await expect(inlineMove).toBeVisible() + await inlineMove.click() + await page.getByRole("menuitem", { name: "Workspace", exact: true }).hover() + await page.getByRole("menuitem", { name: "feature" }).click() + + await expect + .poll(() => move) + .toEqual({ + sessionID, + destination: { directory: workspace }, + moveChanges: true, + }) + await page.locator("[data-session-title]").getByRole("button", { name: "More options" }).click() + await expect(page.getByRole("menuitem", { name: "Archive" })).toBeDisabled() + await expect(page.getByRole("menuitem", { name: /Delete/ })).toBeDisabled() + await page.keyboard.press("Escape") + releaseMove() + const lifecycle = page.locator('[data-timeline-row="WorkspaceLifecycle"]') + await expect(lifecycle).toContainText("Workspace set") +}) + +test("moves a changed local session through workspace creation without changing lifecycle semantics", async ({ + page, +}) => { + const sessionID = "ses_workspace_move_new" + const messageID = "msg_workspace_move_new" + const session = { + id: sessionID, + slug: "workspace-move-new", + projectID: project.id, + directory: root, + title: "Create a workspace", + version: "dev", + time: { created: 1, updated: 2 }, + } + let move: unknown + const transport = await installSseTransport<{ directory: string; payload: Record }>(page, { server }) + await mockOpenCodeServer(page, { + directory: root, + project, + provider, + sessions: [session], + pageMessages: () => ({ items: [userMessage(sessionID, messageID, "Create isolated workspace", true)] }), + vcsDiff: [diff], + }) + await page.route("**/experimental/worktree**", async (route) => { + if (route.request().method() === "OPTIONS") { + await route.fulfill({ + status: 204, + headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" }, + }) + return + } + if (route.request().method() !== "POST") return route.fallback() + await route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify({ name: "quick-contrast-fix", directory: createdWorkspace, branch: "quick-contrast-fix" }), + }) + }) + await page.route("**/experimental/control-plane/move-session", async (route) => { + if (route.request().method() === "OPTIONS") { + await route.fulfill({ + status: 204, + headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" }, + }) + return + } + move = route.request().postDataJSON() + await route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: "null", + }) + }) + await init(page, { type: "session", sessionId: sessionID }) + + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) + await transport.waitForConnection() + await page.locator("[data-session-title]").getByRole("button", { name: "Session details" }).click() + await page + .locator('[data-component="session-summary-panel"]') + .getByRole("button", { name: "Move to workspace" }) + .click() + await page.getByRole("menuitem", { name: "New workspace" }).click() + + const lifecycle = page.locator('[data-timeline-row="WorkspaceLifecycle"]') + await expect(lifecycle).toContainText("Creating workspace") + for (const attempt of [1, 2, 3, 4, 5]) { + await transport.send({ + directory: createdWorkspace, + payload: { + id: `evt_worktree_ready_${attempt}`, + type: "worktree.ready", + properties: { name: "quick-contrast-fix" }, + }, + }) + await page.waitForTimeout(100) + if (move) break + } + await expect + .poll(() => move) + .toEqual({ + sessionID, + destination: { directory: createdWorkspace }, + moveChanges: true, + }) + await transport.send({ + directory: createdWorkspace, + payload: { + id: "evt_workspace_created", + type: "session.next.moved", + properties: { + timestamp: Date.now(), + sessionID, + location: { directory: createdWorkspace }, + subdirectory: "", + }, + }, + }) + await expect(lifecycle).toContainText("Workspace created") +}) diff --git a/packages/app/src/components/help-button.tsx b/packages/app/src/components/help-button.tsx index 18dd727084e4..122ae8c0142a 100644 --- a/packages/app/src/components/help-button.tsx +++ b/packages/app/src/components/help-button.tsx @@ -8,9 +8,6 @@ import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4" import homeImage from "@/assets/help/home.png" import tabsImage from "@/assets/help/tabs.png" -// TODO: wire to changelog / seen-state when available -const showPopover = () => true - // can remove this after the tabs rollout has been out for a while export function TabsInfoPopup() { const settings = useSettings() @@ -135,11 +132,6 @@ export function TabsInfoPopup() {

When you reopen the app, your tabs are still open.

-

- The new design does not support Git Worktrees yet, it's coming soon. So if you'd prefer to continue using - the previous layout, you can switch between layouts in Settings. Just keep in mind that the new layout - will become permanent in a few weeks. -

diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index b3201b3ef68a..ef352ed8ec0f 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -2,6 +2,9 @@ import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" import { createStore } from "solid-js/store" import type { Prompt, PromptStore } from "@/context/prompt" import type { ModelSelection } from "@/context/local" +import { Worktree } from "@/utils/worktree" +import { WorkspaceOperation } from "@/utils/workspace-operation" +import { ServerScope } from "@/utils/server-scope" let createPromptSubmit: typeof import("./submit").createPromptSubmit @@ -26,12 +29,17 @@ const optimisticSeeded: boolean[] = [] const storedSessions: Record> = {} const promoted: Array<{ directory: string; sessionID: string }> = [] const sentShell: Array<{ sessionID: string; id?: string; command: string }> = [] +const sentShellDirectories: string[] = [] const syncedDirectories: string[] = [] const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = [] const sentPrompts: string[] = [] const promptInputs: unknown[] = [] const sentCommands: unknown[] = [] -const commands: Array<{ name: string }> = [] +const sentCommandDirectories: string[] = [] +const updatedDrafts: Array<{ draftID: string; worktree?: string }> = [] +const syncedServers: string[] = [] +const optimisticServers: string[] = [] +const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = [] let serverSessionSyncs = 0 let params: { id?: string } = {} @@ -40,6 +48,18 @@ let selected = "/repo/worktree-a" let variant: string | undefined let permissionServer = "server-a" let createSessionGate: Promise | undefined +let sessionFailure: Error | undefined +let createWorktreeGate: Promise | undefined +let worktreeFailure: Error | undefined +let worktreeHung = false +let worktreeCreates = 0 +let activeSDK = "server-a" +let activeServerSync = "server-a" +let activeDirectorySync = "server-a" +let commands: Array<{ name: string }> = [] +let worktreeDirectory = "/repo/new-0" +let worktreeID = 0 +const draftServers: Record = {} let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] const [promptStore, setPromptStore] = createStore({ @@ -67,16 +87,21 @@ const prompt = { replaceComments: () => undefined, items: () => [], }, - capture: () => prompt, + capture: (scope?: unknown, target?: unknown) => { + promptCaptures.push({ scope, target }) + return prompt + }, } +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)) -const clientFor = (directory: string) => { - createdClients.push(directory) +const clientFor = (directory: string, track = true) => { + if (track) createdClients.push(directory) return { api: { session: { create: async (input: (typeof sessionCreateInputs)[number]) => { await createSessionGate + if (sessionFailure) throw sessionFailure const location = input.location?.directory ?? directory createdSessions.push(location) sessionCreateInputs.push(input) @@ -99,9 +124,11 @@ const clientFor = (directory: string) => { }, command: async (input: unknown) => { sentCommands.push(input) + sentCommandDirectories.push(directory) }, shell: async (input: { sessionID: string; id?: string; command: string }) => { sentShell.push(input) + sentShellDirectories.push(directory) }, }, }, @@ -110,7 +137,16 @@ const clientFor = (directory: string) => { abort: async () => ({ data: undefined }), }, worktree: { - create: async () => ({ data: { directory: `${directory}/new` } }), + create: async (_input: unknown, options?: { signal?: AbortSignal }) => { + worktreeCreates++ + if (worktreeHung) + return new Promise((_, reject) => { + options?.signal?.addEventListener("abort", () => reject(options.signal?.reason), { once: true }) + }) + await createWorktreeGate + if (worktreeFailure) throw worktreeFailure + return { data: { directory: worktreeDirectory } } + }, }, } } @@ -173,7 +209,10 @@ beforeAll(async () => { mock.module("@/context/tabs", () => ({ useTabs: () => ({ - draft: () => ({ server: "project-server" }), + draft: (draftID: string) => ({ server: draftServers[draftID] ?? "project-server" }), + updateDraft: (draftID: string, draft: { worktree?: string }) => { + updatedDrafts.push({ draftID, ...draft }) + }, promoteDraft: (draftID: string, session: { server: string; sessionId: string }) => { promotedDrafts.push({ draftID, ...session }) }, @@ -194,72 +233,81 @@ beforeAll(async () => { mock.module("@/context/sdk", () => ({ useSDK: () => { - const sdk = { - scope: "local", - directory: "/repo/main", + return () => ({ + scope: activeSDK === "server-a" ? ServerScope.local : "server-b", + directory: activeSDK === "server-a" ? "/repo/main" : "/repo/other", client: rootClient, api: rootClient.api, url: "http://localhost:4096", + createApi: (directory: string) => clientFor(directory, false).api, createClient(opts: any) { return clientFor(opts.directory) }, - } - return () => sdk + }) }, })) mock.module("@/context/sync", () => ({ - useSync: () => () => ({ - data: { command: commands }, - session: { - optimistic: { - add: (value: { - directory?: string - sessionID?: string - message: { agent: string; model: { providerID: string; modelID: string; variant?: string } } - }) => { - optimistic.push(value) - optimisticSeeded.push( - !!value.directory && - !!value.sessionID && - !!storedSessions[value.directory]?.find((item) => item.id === value.sessionID)?.title, - ) + useSync: () => () => { + const server = activeDirectorySync + return { + data: { command: commands }, + session: { + optimistic: { + add: (value: { + directory?: string + sessionID?: string + message: { agent: string; model: { providerID: string; modelID: string; variant?: string } } + }) => { + optimisticServers.push(server) + optimistic.push(value) + optimisticSeeded.push( + !!value.directory && + !!value.sessionID && + !!storedSessions[value.directory]?.find((item) => item.id === value.sessionID)?.title, + ) + }, + remove: () => undefined, }, - remove: () => undefined, }, - }, - set: () => undefined, - }), + set: () => undefined, + project: { worktree: server === "server-a" ? "/repo/main" : "/repo/other" }, + } + }, })) mock.module("@/context/server-sync", () => ({ - useServerSync: () => () => ({ - session: { - remember: () => undefined, - set: () => undefined, - sync: async () => { - serverSessionSyncs++ - }, - }, - child: (directory: string) => { - syncedDirectories.push(directory) - storedSessions[directory] ??= [] - return [ - { session: storedSessions[directory] }, - (...args: unknown[]) => { - if (args[0] !== "session") return - const next = args[1] - if (typeof next === "function") { - storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }> - return - } - if (Array.isArray(next)) { - storedSessions[directory] = next as Array<{ id: string; title?: string }> - } + useServerSync: () => () => { + const server = activeServerSync + return { + session: { + remember: () => undefined, + set: () => undefined, + sync: async () => { + serverSessionSyncs++ }, - ] - }, - }), + }, + child: (directory: string) => { + syncedServers.push(server) + syncedDirectories.push(directory) + storedSessions[directory] ??= [] + return [ + { session: storedSessions[directory] }, + (...args: unknown[]) => { + if (args[0] !== "session") return + const next = args[1] + if (typeof next === "function") { + storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }> + return + } + if (Array.isArray(next)) { + storedSessions[directory] = next as Array<{ id: string; title?: string }> + } + }, + ] + }, + } + }, })) mock.module("@/context/platform", () => ({ @@ -287,24 +335,204 @@ beforeEach(() => { optimisticSeeded.length = 0 promoted.length = 0 promotedDrafts.length = 0 + updatedDrafts.length = 0 + sentCommands.length = 0 + sentCommandDirectories.length = 0 sentPrompts.length = 0 promptInputs.length = 0 - sentCommands.length = 0 - commands.length = 0 - promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }] + syncedServers.length = 0 + optimisticServers.length = 0 + promptCaptures.length = 0 params = {} search = {} sentShell.length = 0 + sentShellDirectories.length = 0 syncedDirectories.length = 0 selected = "/repo/worktree-a" variant = undefined permissionServer = "server-a" + activeSDK = "server-a" + activeServerSync = "server-a" + activeDirectorySync = "server-a" + commands = [] + promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }] + worktreeDirectory = `/repo/new-${++worktreeID}` createSessionGate = undefined serverSessionSyncs = 0 + sessionFailure = undefined + createWorktreeGate = undefined + worktreeFailure = undefined + worktreeHung = false + worktreeCreates = 0 + for (const key of Object.keys(draftServers)) delete draftServers[key] for (const key of Object.keys(storedSessions)) delete storedSessions[key] }) describe("prompt submit worktree selection", () => { + test("admits only one concurrent new-workspace submission", async () => { + selected = "create" + let release = () => {} + createWorktreeGate = new Promise((resolve) => { + release = resolve + }) + const submit = createPromptSubmit({ + prompt, + info: () => undefined, + imageAttachments: () => [], + commentCount: () => 0, + autoAccept: () => false, + mode: () => "normal", + 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 + const first = submit.handleSubmit(event) + const duplicate = submit.handleSubmit(event) + expect(worktreeCreates).toBe(1) + + release() + await Promise.all([first, duplicate]) + expect(createdSessions).toEqual([worktreeDirectory]) + Worktree.ready(ServerScope.local, worktreeDirectory) + await settle() + + expect(worktreeCreates).toBe(1) + expect(createdSessions).toHaveLength(1) + expect(sentPrompts).toEqual([worktreeDirectory]) + }) + + test("allows retry after new-workspace submission fails", async () => { + selected = "create" + worktreeFailure = new Error("create failed") + const submit = createPromptSubmit({ + prompt, + info: () => undefined, + imageAttachments: () => [], + commentCount: () => 0, + autoAccept: () => false, + mode: () => "normal", + 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) + expect(worktreeCreates).toBe(1) + expect(createdSessions).toEqual([]) + + worktreeFailure = undefined + await submit.handleSubmit(event) + Worktree.ready(ServerScope.local, worktreeDirectory) + await settle() + + expect(worktreeCreates).toBe(2) + expect(createdSessions).toEqual([worktreeDirectory]) + expect(sentPrompts).toEqual([worktreeDirectory]) + }) + + test("aborts a hung new-workspace request and allows retry", async () => { + selected = "create" + worktreeHung = true + let resets = 0 + const submit = createPromptSubmit({ + prompt, + info: () => undefined, + imageAttachments: () => [], + commentCount: () => 0, + autoAccept: () => false, + mode: () => "normal", + 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: () => resets++, + onSubmit: () => undefined, + worktreeRequestTimeoutMs: 1, + }) + + const event = { preventDefault: () => undefined } as unknown as Event + await submit.handleSubmit(event) + + expect(worktreeCreates).toBe(1) + expect(createdSessions).toEqual([]) + expect(selected).toBe("create") + expect(promptValue).toEqual([{ type: "text", content: "ls", start: 0, end: 2 }]) + expect(resets).toBe(0) + + worktreeHung = false + await submit.handleSubmit(event) + Worktree.ready(ServerScope.local, worktreeDirectory) + await settle() + + expect(worktreeCreates).toBe(2) + expect(createdSessions).toEqual([worktreeDirectory]) + expect(sentPrompts).toEqual([worktreeDirectory]) + expect(resets).toBe(1) + }) + + test("retains workspace selection after session creation failure and clears it after retry", async () => { + sessionFailure = new Error("session create failed") + let resets = 0 + const submit = createPromptSubmit({ + prompt, + info: () => undefined, + imageAttachments: () => [], + commentCount: () => 0, + autoAccept: () => false, + mode: () => "normal", + 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: () => { + resets++ + selected = "main" + }, + onSubmit: () => undefined, + }) + + const event = { preventDefault: () => undefined } as unknown as Event + await submit.handleSubmit(event) + expect(resets).toBe(0) + expect(selected).toBe("/repo/worktree-a") + + sessionFailure = undefined + await submit.handleSubmit(event) + await settle() + + expect(resets).toBe(1) + expect(selected).toBe("main") + }) + test("reads the latest worktree accessor value per submit", async () => { const submit = createPromptSubmit({ prompt, @@ -420,6 +648,54 @@ describe("prompt submit worktree selection", () => { expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }]) }) + test("keeps async submission effects bound to the initiating context", async () => { + search = { draftId: "draft-1" } + draftServers["draft-1"] = "project-server-a" + draftServers["draft-2"] = "project-server-b" + let release = () => {} + createSessionGate = new Promise((resolve) => { + release = resolve + }) + let submitted = 0 + const submit = createPromptSubmit({ + prompt, + info: () => undefined, + imageAttachments: () => [], + commentCount: () => 0, + autoAccept: () => false, + mode: () => "normal", + 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: () => submitted++, + }) + + const result = submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) + activeSDK = "server-b" + activeServerSync = "server-b" + activeDirectorySync = "server-b" + search.draftId = "draft-2" + release() + await result + await settle() + + expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }]) + expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server-a", sessionId: "session-1" }]) + expect(syncedServers.every((server) => server === "server-a")).toBe(true) + expect(optimisticServers).toEqual(["server-a"]) + expect(promptCaptures.at(-1)?.target).toEqual({ server: "project-server-a", scope: ServerScope.local }) + expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("complete") + expect(WorkspaceOperation.get("server-b" as ServerScope, "session-1")).toBeUndefined() + expect(submitted).toBe(0) + }) + test("promotes drafts using the selected project's server", async () => { search = { draftId: "draft-1" } const submit = createPromptSubmit({ @@ -448,7 +724,7 @@ describe("prompt submit worktree selection", () => { }) test("includes the selected variant on optimistic prompts", async () => { - params = { id: "session-1" } + params.id = "session-1" variant = "high" const submit = createPromptSubmit({ @@ -595,4 +871,154 @@ describe("prompt submit worktree selection", () => { expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" }) expect(optimisticSeeded).toEqual([true]) }) + + test("waits for a new workspace before sending an initial shell", async () => { + selected = "create" + const submit = createPromptSubmit({ + prompt, + info: () => undefined, + imageAttachments: () => [], + commentCount: () => 0, + autoAccept: () => false, + 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, + }) + + await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) + expect(sentShell).toEqual([]) + expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("pending") + + Worktree.ready(ServerScope.local, worktreeDirectory) + await settle() + + expect(sentShellDirectories).toEqual([worktreeDirectory]) + expect(sentShell[0]).toMatchObject({ + sessionID: "session-1", + command: "ls", + agent: "agent", + model: { providerID: "provider", modelID: "model" }, + }) + expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("complete") + }) + + test("waits for a new workspace before sending an initial custom command", async () => { + selected = "create" + commands = [{ name: "deploy" }] + promptValue = [{ type: "text", content: "/deploy now", start: 0, end: 11 }] + const submit = createPromptSubmit({ + prompt, + info: () => undefined, + imageAttachments: () => [], + commentCount: () => 0, + autoAccept: () => false, + mode: () => "normal", + 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, + }) + + await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) + expect(sentCommands).toEqual([]) + + Worktree.ready(ServerScope.local, worktreeDirectory) + await settle() + + expect(sentCommands).toHaveLength(1) + expect(sentCommands[0]).toMatchObject({ + sessionID: "session-1", + command: "deploy", + arguments: "now", + agent: "agent", + model: { id: "model", providerID: "provider" }, + files: [], + }) + expect(sentCommandDirectories).toEqual([worktreeDirectory]) + }) + + test("settles a pending workspace operation when the initial prompt is aborted", async () => { + selected = "create" + const submit = createPromptSubmit({ + prompt, + info: () => undefined, + imageAttachments: () => [], + commentCount: () => 0, + autoAccept: () => false, + mode: () => "normal", + 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, + }) + + await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) + expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("pending") + params = { id: "session-1" } + + await submit.abort() + await settle() + + expect(sentPrompts).toEqual([]) + expect(WorkspaceOperation.get(ServerScope.local, "session-1")).toMatchObject({ + status: "failed", + message: "aborted", + }) + }) + + test("settles a pending workspace operation when preparation fails", async () => { + selected = "create" + const submit = createPromptSubmit({ + prompt, + info: () => undefined, + imageAttachments: () => [], + commentCount: () => 0, + autoAccept: () => false, + 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, + }) + + await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) + Worktree.failed(ServerScope.local, worktreeDirectory, "bootstrap failed") + await settle() + + expect(sentShell).toEqual([]) + expect(WorkspaceOperation.get(ServerScope.local, "session-1")).toMatchObject({ + status: "failed", + message: "bootstrap failed", + }) + }) }) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 051bf4d06cb9..6c3f47a4cd90 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -15,10 +15,12 @@ import { useSDK, type DirectorySDK } from "@/context/sdk" import { useSync, type DirectorySync } from "@/context/sync" import { Identifier } from "@/utils/id" import { Worktree as WorktreeState } from "@/utils/worktree" +import { WorkspaceOperation } from "@/utils/workspace-operation" +import { WORKSPACE_PREPARATION_TIMEOUT_MS, workspaceRequestWithTimeout } from "@/utils/workspace-request" import { buildRequestParts } from "./build-request-parts" import { setCursorPosition } from "./editor-dom" import { formatServerError } from "@/utils/server-errors" -import { ScopedKey } from "@/utils/server-scope" +import { ScopedKey, type ServerScope } from "@/utils/server-scope" import { createPromptSubmissionState } from "./submission-state" import { normalizeSessionInfo } from "@/utils/session" import { Event } from "@opencode-ai/schema/event" @@ -26,9 +28,13 @@ import { Event } from "@opencode-ai/schema/event" type PendingPrompt = { abort: AbortController cleanup: VoidFunction + scope: ServerScope + sessionID: string + serverSync: ServerSync } const pending = new Map() +const submitting = new Set() export type FollowupDraft = { sessionID: string @@ -42,6 +48,7 @@ export type FollowupDraft = { type FollowupSendInput = { api: DirectorySDK["api"]["session"] + scope: ServerScope serverSync: ServerSync sync: DirectorySync draft: FollowupDraft @@ -55,6 +62,8 @@ const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image") export async function sendFollowupDraft(input: FollowupSendInput) { + const operation = WorkspaceOperation.get(input.scope, input.draft.sessionID) + if (operation?.status === "pending" && operation.messageID !== input.messageID) return false const text = draftText(input.draft.prompt) const images = draftImages(input.draft.prompt) const setBusy = () => { @@ -220,6 +229,7 @@ type PromptSubmitInput = { onAbort?: () => void onSubmit?: () => void model?: ModelSelection + worktreeRequestTimeoutMs?: number } export function createPromptSubmit(input: PromptSubmitInput) { @@ -235,7 +245,8 @@ export function createPromptSubmit(input: PromptSubmitInput) { const params = useParams() const [search] = useSearchParams<{ draftId?: string }>() const tabs = useTabs() - const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID) + const pendingKey = (scope: ServerScope, sessionID: string) => ScopedKey.from(scope, sessionID) + let pendingSubmission: { key: string; scope: ServerScope; sessionID: string } | undefined const errorMessage = (err: unknown) => { if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message @@ -248,18 +259,23 @@ export function createPromptSubmit(input: PromptSubmitInput) { } const abort = async () => { - const sessionID = params.id + const routeSessionID = params.id + const owned = + pendingSubmission && (!routeSessionID || routeSessionID === pendingSubmission.sessionID) + ? pending.get(pendingSubmission.key) + : undefined + const sessionID = routeSessionID ?? owned?.sessionID if (!sessionID) return Promise.resolve() - - serverSync().session.set("todo", sessionID, []) + ;(owned?.serverSync ?? serverSync()).session.set("todo", sessionID, []) input.onAbort?.() - const key = pendingKey(sessionID) - const queued = pending.get(key) + const key = owned ? pendingSubmission!.key : pendingKey(sdk().scope, sessionID) + const queued = owned ?? pending.get(key) if (queued) { queued.abort.abort() queued.cleanup() + WorkspaceOperation.fail(queued.scope, queued.sessionID, "aborted") pending.delete(key) return Promise.resolve() } @@ -291,9 +307,9 @@ export function createPromptSubmit(input: PromptSubmitInput) { } } - const seed = (dir: string, info: Session) => { - serverSync().session.remember(info) - const [, setStore] = serverSync().child(dir) + const seed = (target: ServerSync, dir: string, info: Session) => { + target.session.remember(info) + const [, setStore] = target.child(dir) setStore("session", (list: Session[]) => { const result = Binary.search(list, info.id, (item) => item.id) const next = [...list] @@ -325,6 +341,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { if (input.working()) void abort() return } + if (params.id && WorkspaceOperation.get(sdk().scope, params.id)?.status === "pending") return const modelSelection = input.model ?? local.model const currentModel = modelSelection.current() @@ -338,293 +355,375 @@ export function createPromptSubmit(input: PromptSubmitInput) { return } - input.addToHistory(currentPrompt, mode) - input.resetHistoryNavigation() - - const projectDirectory = sdk().directory + const submissionSDK = sdk() + const submissionSync = sync() + const submissionServerSync = serverSync() + const submissionScope = submissionSDK.scope + const projectDirectory = submissionSDK.directory + const projectRoot = submissionSync.project?.worktree ?? projectDirectory + const sessionID = params.id + const isNewSession = !sessionID + const currentSession = input.info() + const draftID = search.draftId + const draftServer = draftID ? tabs.draft(draftID).server : undefined + const capturePrompt = prompt.capture + const localSession = local.session + const handoff = layout.handoff + const resetWorktree = input.onNewSessionWorktreeReset + const onSubmit = input.onSubmit const permissionState = permission.currentServerState() - const isNewSession = !params.id const shouldAutoAccept = isNewSession && input.autoAccept() const worktreeSelection = input.newSessionWorktree?.() || "main" + const submissionKey = ScopedKey.from( + submissionScope, + draftID ? `draft:${draftID}` : sessionID ? `session:${sessionID}` : `directory:${projectDirectory}`, + ) + if (submitting.has(submissionKey)) return + submitting.add(submissionKey) - let sessionDirectory = projectDirectory - let client = sdk().client + try { + input.addToHistory(currentPrompt, mode) + input.resetHistoryNavigation() + + let sessionDirectory = projectDirectory + let client = submissionSDK.client + let api = submissionSDK.api.session + + if (isNewSession) { + if (worktreeSelection === "create") { + const createdWorktree = await workspaceRequestWithTimeout( + (signal) => client.worktree.create({ directory: projectDirectory }, { signal }), + language.t("prompt.toast.worktreeCreateFailed.title"), + input.worktreeRequestTimeoutMs ?? WORKSPACE_PREPARATION_TIMEOUT_MS, + ) + .then((x) => x.data) + .catch((err) => { + showToast({ + title: language.t("prompt.toast.worktreeCreateFailed.title"), + description: errorMessage(err), + }) + return undefined + }) - if (isNewSession) { - if (worktreeSelection === "create") { - const createdWorktree = await client.worktree - .create({ directory: projectDirectory }) - .then((x) => x.data) - .catch((err) => { + if (!createdWorktree?.directory) { showToast({ title: language.t("prompt.toast.worktreeCreateFailed.title"), + description: language.t("common.requestFailed"), + }) + return + } + WorktreeState.pending(submissionScope, createdWorktree.directory) + sessionDirectory = createdWorktree.directory + } + + if (worktreeSelection !== "main" && worktreeSelection !== "create") { + sessionDirectory = worktreeSelection + } + + if (sessionDirectory !== projectDirectory) { + client = submissionSDK.createClient({ + directory: sessionDirectory, + throwOnError: true, + }) + api = submissionSDK.createApi(sessionDirectory).session + submissionServerSync.child(sessionDirectory) + } + } + + let session = currentSession + if (!session && isNewSession) { + const created = await submissionSDK.api.session + .create({ + agent: currentAgent.name, + model: { id: currentModel.id, providerID: currentModel.provider.id, variant }, + location: { directory: sessionDirectory }, + }) + .then(normalizeSessionInfo) + .catch((err) => { + showToast({ + title: language.t("prompt.toast.sessionCreateFailed.title"), description: errorMessage(err), }) return undefined }) - - if (!createdWorktree?.directory) { - showToast({ - title: language.t("prompt.toast.worktreeCreateFailed.title"), - description: language.t("common.requestFailed"), + if (created) { + seed(submissionServerSync, sessionDirectory, created) + session = created + await startTransition(() => { + if (!session) return + if (draftID) tabs.updateDraft(draftID, { worktree: undefined }) + if (!draftID) resetWorktree?.() + if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory) + localSession.promote(sessionDirectory, session.id, { + agent: currentAgent.name, + model: { providerID: currentModel.provider.id, modelID: currentModel.id }, + variant: variant ?? null, + }) + handoff.setTabs(base64Encode(sessionDirectory), session.id) + if (draftID && draftServer) tabs.promoteDraft(draftID, { server: draftServer, sessionId: session.id }) + else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) + submission.retarget( + capturePrompt( + { dir: base64Encode(sessionDirectory), id: session.id }, + { server: draftServer, scope: submissionScope }, + ), + ) }) - return } - WorktreeState.pending(sdk().scope, createdWorktree.directory) - sessionDirectory = createdWorktree.directory } - - if (worktreeSelection !== "main" && worktreeSelection !== "create") { - sessionDirectory = worktreeSelection + if (!session) { + showToast({ + title: language.t("prompt.toast.promptSendFailed.title"), + description: language.t("prompt.toast.promptSendFailed.description"), + }) + return } - if (sessionDirectory !== projectDirectory) { - client = sdk().createClient({ - directory: sessionDirectory, - throwOnError: true, - }) - serverSync().child(sessionDirectory) + const model = { + modelID: currentModel.id, + providerID: currentModel.provider.id, + } + const agent = currentAgent.name + const draft: FollowupDraft = { + sessionID: session.id, + sessionDirectory, + prompt: currentPrompt, + context, + agent, + model, + variant, } - input.onNewSessionWorktreeReset?.() - } + const clearInput = () => { + submission.clear() + input.setMode("normal") + input.setPopover(null) + } - let session = input.info() - if (!session && isNewSession) { - const created = await sdk() - .api.session.create({ - agent: currentAgent.name, - model: { id: currentModel.id, providerID: currentModel.provider.id, variant }, - location: { directory: sessionDirectory }, - }) - .then(normalizeSessionInfo) - .catch((err) => { - showToast({ - title: language.t("prompt.toast.sessionCreateFailed.title"), - description: errorMessage(err), - }) - return undefined - }) - if (created) { - seed(sessionDirectory, created) - session = created - await startTransition(() => { - if (!session) return - if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory) - local.session.promote(sessionDirectory, session.id, { - agent: currentAgent.name, - model: { providerID: currentModel.provider.id, modelID: currentModel.id }, - variant: variant ?? null, - }) - layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) - const draftID = search.draftId - if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id }) - else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) - submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id })) + const restoreInput = () => { + const restored = submission.restore() + if (!restored) return false + restored.target.set(restored.prompt, input.promptLength(restored.prompt)) + if (!submission.current(prompt.capture())) return true + input.setMode(mode) + input.setPopover(null) + requestAnimationFrame(() => { + const editor = input.editor() + if (!editor) return + editor.focus() + setCursorPosition(editor, input.promptLength(currentPrompt)) + input.queueScroll() }) + return true } - } - if (!session) { - showToast({ - title: language.t("prompt.toast.promptSendFailed.title"), - description: language.t("prompt.toast.promptSendFailed.description"), - }) - return - } - - const model = { - modelID: currentModel.id, - providerID: currentModel.provider.id, - } - const agent = currentAgent.name - const draft: FollowupDraft = { - sessionID: session.id, - sessionDirectory, - prompt: currentPrompt, - context, - agent, - model, - variant, - } - const clearInput = () => { - submission.clear() - input.setMode("normal") - input.setPopover(null) - } + if (!isNewSession && mode === "normal" && input.shouldQueue?.()) { + input.onQueue?.(draft) + clearContext(submission.target()) + clearInput() + return + } - const restoreInput = () => { - const restored = submission.restore() - if (!restored) return false - restored.target.set(restored.prompt, input.promptLength(restored.prompt)) - if (!submission.current(prompt.capture())) return true - input.setMode(mode) - input.setPopover(null) - requestAnimationFrame(() => { - const editor = input.editor() - if (!editor) return - editor.focus() - setCursorPosition(editor, input.promptLength(currentPrompt)) - input.queueScroll() - }) - return true - } + const startWorkspaceOperation = (messageID: string) => { + if (!isNewSession) return + if (worktreeSelection !== "main" && worktreeSelection !== "create" && sessionDirectory !== projectRoot) { + WorkspaceOperation.start(submissionScope, session.id, "move", sessionDirectory, messageID) + WorkspaceOperation.complete(submissionScope, session.id) + } + if (worktreeSelection !== "create") return + const worktree = WorktreeState.get(submissionScope, sessionDirectory) + WorkspaceOperation.start(submissionScope, session.id, "create", sessionDirectory, messageID) + if (worktree?.status === "ready") WorkspaceOperation.complete(submissionScope, session.id) + if (worktree?.status === "failed") WorkspaceOperation.fail(submissionScope, session.id, worktree.message) + } - if (!isNewSession && mode === "normal" && input.shouldQueue?.()) { - input.onQueue?.(draft) - clearContext(submission.target()) - clearInput() - return - } + const waitForWorktree = async (cleanup: VoidFunction) => { + const worktree = WorktreeState.get(submissionScope, sessionDirectory) + if (!worktree) return true + if (worktree.status === "ready") { + WorkspaceOperation.complete(submissionScope, session.id) + return true + } + if (worktree.status === "failed") { + WorkspaceOperation.fail(submissionScope, session.id, worktree.message) + throw new Error(worktree.message) + } - input.onSubmit?.() + if (sessionDirectory === projectDirectory) { + submissionSync.set("session_status", session.id, { type: "busy" }) + } - if (mode === "shell") { - clearInput() - const eventID = Event.ID.create() - sdk() - .api.session.shell({ + const controller = new AbortController() + const key = pendingKey(submissionScope, session.id) + pendingSubmission = { key, scope: submissionScope, sessionID: session.id } + pending.set(key, { + abort: controller, + cleanup, + scope: submissionScope, sessionID: session.id, - id: eventID, - command: text, - agent, - model, + serverSync: submissionServerSync, }) - .catch((err) => { - showToast({ - title: language.t("prompt.toast.shellSendFailed.title"), - description: errorMessage(err), - }) - restoreInput() + + const abortWait = new Promise>>((resolve) => { + if (controller.signal.aborted) { + resolve({ status: "failed", message: "aborted" }) + return + } + controller.signal.addEventListener( + "abort", + () => { + resolve({ status: "failed", message: "aborted" }) + }, + { once: true }, + ) }) - return - } - if (text.startsWith("/")) { - const [cmdName, ...args] = text.split(" ") - const commandName = cmdName.slice(1) - const customCommand = sync().data.command.find((c) => c.name === commandName) - if (customCommand) { + const timeoutMs = 5 * 60 * 1000 + const timer = { id: undefined as number | undefined } + const timeout = new Promise>>((resolve) => { + timer.id = window.setTimeout(() => { + resolve({ + status: "failed", + message: language.t("workspace.error.stillPreparing"), + }) + }, timeoutMs) + }) + + const result = await Promise.race([ + WorktreeState.wait(submissionScope, sessionDirectory), + abortWait, + timeout, + ]).finally(() => { + pending.delete(key) + if (pendingSubmission?.key === key) pendingSubmission = undefined + if (timer.id === undefined) return + clearTimeout(timer.id) + }) + if (controller.signal.aborted) return false + if (result.status === "failed") { + WorkspaceOperation.fail(submissionScope, session.id, result.message) + throw new Error(result.message) + } + WorkspaceOperation.complete(submissionScope, session.id) + return true + } + + if (!draftID || search.draftId === draftID) onSubmit?.() + + if (mode === "shell") { clearInput() - const messageID = Identifier.ascending("message") - serverSync().session.set("session_status", session.id, { type: "busy" }) - sdk() - .api.session.command({ - sessionID: session.id, - id: messageID, - command: commandName, - arguments: args.join(" "), - agent, - model: { id: model.modelID, providerID: model.providerID, variant }, - files: images.map((attachment) => ({ - uri: attachment.dataUrl, - name: attachment.filename, - })), + const eventID = Event.ID.create() + startWorkspaceOperation(eventID) + void waitForWorktree(() => { + restoreInput() + }) + .then((ready) => { + if (!ready) return + return api.shell({ + sessionID: session.id, + id: eventID, + command: text, + agent, + model, + }) }) .catch((err) => { - serverSync().session.set("session_status", session.id, { type: "idle" }) showToast({ - title: language.t("prompt.toast.commandSendFailed.title"), - description: formatServerError(err, language.t, language.t("common.requestFailed")), + title: language.t("prompt.toast.shellSendFailed.title"), + description: errorMessage(err), }) restoreInput() }) return } - } - - const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim()) - const messageID = Identifier.ascending("message") - const removeOptimisticMessage = () => { - sync().session.optimistic.remove({ - directory: sessionDirectory, - sessionID: session.id, - messageID, - }) - } - - for (const item of commentItems) submission.target().context.remove(item.key) - clearInput() + if (text.startsWith("/")) { + const [cmdName, ...args] = text.split(" ") + const commandName = cmdName.slice(1) + const customCommand = submissionSync.data.command.find((c) => c.name === commandName) + if (customCommand) { + clearInput() + const messageID = Identifier.ascending("message") + startWorkspaceOperation(messageID) + submissionServerSync.session.set("session_status", session.id, { type: "busy" }) + void waitForWorktree(() => { + submissionServerSync.session.set("session_status", session.id, { type: "idle" }) + restoreInput() + }) + .then((ready) => { + if (!ready) return + return api.command({ + sessionID: session.id, + id: messageID, + command: commandName, + arguments: args.join(" "), + agent, + model: { id: model.modelID, providerID: model.providerID, variant }, + files: images.map((attachment) => ({ + uri: attachment.dataUrl, + name: attachment.filename, + })), + }) + }) + .catch((err) => { + submissionServerSync.session.set("session_status", session.id, { type: "idle" }) + showToast({ + title: language.t("prompt.toast.commandSendFailed.title"), + description: formatServerError(err, language.t, language.t("common.requestFailed")), + }) + restoreInput() + }) + return + } + } - const waitForWorktree = async () => { - const worktree = WorktreeState.get(sdk().scope, sessionDirectory) - if (!worktree || worktree.status !== "pending") return true + const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim()) + const messageID = Identifier.ascending("message") + startWorkspaceOperation(messageID) - if (sessionDirectory === projectDirectory) { - sync().set("session_status", session.id, { type: "busy" }) + const removeOptimisticMessage = () => { + submissionSync.session.optimistic.remove({ + directory: sessionDirectory, + sessionID: session.id, + messageID, + }) } - const controller = new AbortController() + for (const item of commentItems) submission.target().context.remove(item.key) + clearInput() + const cleanup = () => { if (sessionDirectory === projectDirectory) { - sync().set("session_status", session.id, { type: "idle" }) + submissionSync.set("session_status", session.id, { type: "idle" }) } removeOptimisticMessage() if (restoreInput()) restoreCommentItems(submission.target(), commentItems) } - pending.set(pendingKey(session.id), { abort: controller, cleanup }) - - const abortWait = new Promise>>((resolve) => { - if (controller.signal.aborted) { - resolve({ status: "failed", message: "aborted" }) - return + void sendFollowupDraft({ + api, + scope: submissionScope, + sync: submissionSync, + serverSync: submissionServerSync, + draft, + messageID, + optimisticBusy: sessionDirectory === projectDirectory, + before: () => waitForWorktree(cleanup), + }).catch((err) => { + pending.delete(pendingKey(submissionScope, session.id)) + if (sessionDirectory === projectDirectory) { + submissionSync.set("session_status", session.id, { type: "idle" }) } - controller.signal.addEventListener( - "abort", - () => { - resolve({ status: "failed", message: "aborted" }) - }, - { once: true }, - ) - }) - - const timeoutMs = 5 * 60 * 1000 - const timer = { id: undefined as number | undefined } - const timeout = new Promise>>((resolve) => { - timer.id = window.setTimeout(() => { - resolve({ - status: "failed", - message: language.t("workspace.error.stillPreparing"), - }) - }, timeoutMs) - }) - - const result = await Promise.race([ - WorktreeState.wait(sdk().scope, sessionDirectory), - abortWait, - timeout, - ]).finally(() => { - if (timer.id === undefined) return - clearTimeout(timer.id) + showToast({ + title: language.t("prompt.toast.promptSendFailed.title"), + description: errorMessage(err), + }) + removeOptimisticMessage() + if (restoreInput()) restoreCommentItems(submission.target(), commentItems) }) - pending.delete(pendingKey(session.id)) - if (controller.signal.aborted) return false - if (result.status === "failed") throw new Error(result.message) - return true + } finally { + submitting.delete(submissionKey) } - - void sendFollowupDraft({ - api: sdk().api.session, - sync: sync(), - serverSync: serverSync(), - draft, - messageID, - optimisticBusy: sessionDirectory === projectDirectory, - before: waitForWorktree, - }).catch((err) => { - pending.delete(pendingKey(session.id)) - if (sessionDirectory === projectDirectory) { - sync().set("session_status", session.id, { type: "idle" }) - } - showToast({ - title: language.t("prompt.toast.promptSendFailed.title"), - description: errorMessage(err), - }) - removeOptimisticMessage() - if (restoreInput()) restoreCommentItems(submission.target(), commentItems) - }) } return { diff --git a/packages/app/src/components/prompt-workspace-selector.tsx b/packages/app/src/components/prompt-workspace-selector.tsx index a7a353743412..bb36eb9ea816 100644 --- a/packages/app/src/components/prompt-workspace-selector.tsx +++ b/packages/app/src/components/prompt-workspace-selector.tsx @@ -1,8 +1,7 @@ -import { For, Show } from "solid-js" +import { createMemo, createSignal, For, Show } from "solid-js" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" -import { Icon } from "@opencode-ai/ui/icon" -import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { Icon } from "@opencode-ai/ui/v2/icon" import { getFilename } from "@opencode-ai/core/util/path" import { useLanguage } from "@/context/language" @@ -15,25 +14,33 @@ export function PromptWorkspaceSelector(props: { onDone: () => void }) { const language = useLanguage() - let pending: string | undefined + const [search, setSearch] = createSignal("") + let pending: { type: "select"; value: string } | undefined const selected = () => (props.value === props.projectRoot ? "main" : props.value) + const workspaces = createMemo(() => { + const query = search().trim().toLowerCase() + if (!query) return props.workspaces + return props.workspaces.filter((workspace) => getFilename(workspace).toLowerCase().includes(query)) + }) const icon = () => { if (selected() === "main") return "monitor" - if (selected() === "create") return "workspace-new" - return "workspace" + return "workspace-isolated" } const select = (value: string) => { - pending = value + pending = { type: "select", value } } const onOpenChange = (open: boolean) => { - if (open) return - const value = pending + if (open) { + setSearch("") + return + } + const action = pending pending = undefined - if (value) props.onChange(value) + if (action?.type === "select") props.onChange(action.value) props.onDone() } const label = () => { - if (selected() === "main") return language.t("session.new.workspace.triggerLocal") + if (selected() === "main") return language.t("workspace.type.local") if (props.value === "create") return language.t("workspace.new") return getFilename(props.value) } @@ -42,9 +49,9 @@ export function PromptWorkspaceSelector(props: { <> - - - {label()} + + + {label()} @@ -52,14 +59,14 @@ export function PromptWorkspaceSelector(props: { {language.t("session.new.workspace.runIn")} select("main")}> - + {language.t("session.new.workspace.local")} select("create")}> - + {language.t("workspace.new")} @@ -67,18 +74,40 @@ export function PromptWorkspaceSelector(props: { 0}> - + - - {language.t("session.new.workspace.existing")} + + {language.t("session.new.workspace.existing").replace(/…$/, "")} - - + + = 10}> +
+ + setSearch(event.currentTarget.value)} + onKeyDown={(event) => { + if ( + event.key === "Escape" || + event.key === "ArrowDown" || + event.key === "ArrowUp" || + event.key === "Enter" + ) + return + event.stopPropagation() + }} + /> +
+
+ {(workspace) => ( select(workspace)}> - + {getFilename(workspace)} @@ -102,6 +131,7 @@ export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) { const language = useLanguage() const label = () => { if (props.noGit) return language.t("session.new.git.none") + if (!props.branch) return undefined return props.branch } @@ -116,8 +146,8 @@ export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) { class="min-w-0 max-w-[220px]" contentClass="max-w-[calc(100vw-32px)] break-all" > -
- +
+ {value()}
diff --git a/packages/app/src/components/session-workspace-menu.tsx b/packages/app/src/components/session-workspace-menu.tsx new file mode 100644 index 000000000000..ed83d2e03693 --- /dev/null +++ b/packages/app/src/components/session-workspace-menu.tsx @@ -0,0 +1,192 @@ +import type { Project } from "@opencode-ai/sdk/v2/client" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { Icon } from "@opencode-ai/ui/v2/icon" +import { getFilename } from "@opencode-ai/core/util/path" +import { createStore } from "solid-js/store" +import { For, Show, type ComponentProps, type JSX } from "solid-js" +import { useLanguage } from "@/context/language" +import { useServerSDK } from "@/context/server-sdk" +import { useServerSync } from "@/context/server-sync" +import { pathKey } from "@/utils/path-key" +import { Worktree } from "@/utils/worktree" +import { WorkspaceOperation } from "@/utils/workspace-operation" +import { showToast } from "@/utils/toast" +import type { ServerScope } from "@/utils/server-scope" +import { + WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS, + WORKSPACE_PREPARATION_TIMEOUT_MS, + workspaceRequestWithTimeout, +} from "@/utils/workspace-request" + +export function SessionWorkspaceMenu(props: { + eligible: boolean + sessionID: string + project: Project + directory: string + messageID?: string + placement?: ComponentProps["placement"] + gutter?: number + class?: string + children: JSX.Element + onOpenChange?: (open: boolean) => void +}) { + const language = useLanguage() + const serverSDK = useServerSDK() + const serverSync = useServerSync() + const [store, setStore] = createStore({ selected: undefined as string | undefined }) + const operationPending = () => WorkspaceOperation.get(serverSDK().scope, props.sessionID)?.status === "pending" + const blocked = () => + !props.eligible || operationPending() || serverSync().session.data.session_working(props.sessionID) + const workspaces = () => + (props.project.sandboxes ?? []).filter((workspace) => pathKey(workspace) !== pathKey(props.directory)) + + const fail = (scope: ServerScope, sessionID: string, message: string) => { + setStore("selected", undefined) + if (WorkspaceOperation.get(scope, sessionID)?.status === "complete") return + WorkspaceOperation.fail(scope, sessionID, message) + showToast({ variant: "error", title: language.t("workspace.move.failed"), description: message }) + } + const move = async (selection: "create" | string) => { + if (store.selected || blocked()) return + const sdk = serverSDK() + const sync = serverSync() + const scope = sdk.scope + const sessionID = props.sessionID + const messageID = props.messageID + const root = props.project.worktree + const source = props.directory + setStore("selected", selection) + + const destination = + selection === "create" + ? await createWorkspace(root, sessionID, messageID, sdk, (message) => fail(scope, sessionID, message), { + createFailed: language.t("prompt.toast.worktreeCreateFailed.title"), + stillPreparing: language.t("workspace.error.stillPreparing"), + }) + : selection + if (!destination) return + + WorkspaceOperation.start(scope, sessionID, selection === "create" ? "create" : "move", destination, messageID) + if (sync.session.data.session_working(sessionID)) { + fail(scope, sessionID, language.t("workspace.move.failed")) + return + } + await workspaceRequestWithTimeout( + (signal) => + sdk.client.experimental.controlPlane.moveSession( + { + sessionID, + destination: { directory: destination }, + moveChanges: true, + }, + { signal }, + ), + language.t("workspace.move.failed"), + WORKSPACE_PREPARATION_TIMEOUT_MS, + ) + .then(async () => { + for (const attempt of Array.from({ length: 20 }, (_, index) => index)) { + const session = await workspaceRequestWithTimeout( + (signal) => sync.session.resolve(sessionID, { force: true, signal }), + language.t("workspace.move.failed"), + WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS, + ).catch(() => undefined) + if (session && pathKey(session.directory) === pathKey(destination)) { + WorkspaceOperation.complete(scope, sessionID, destination) + sync.reindexSession(sessionID, source) + return + } + if (WorkspaceOperation.get(scope, sessionID)?.status === "complete") return + await new Promise((resolve) => setTimeout(resolve, Math.min(250 + attempt * 50, 1_000))) + } + fail(scope, sessionID, language.t("workspace.move.failed")) + }) + .catch((error) => + fail(scope, sessionID, error instanceof Error ? error.message : language.t("common.requestFailed")), + ) + } + + return ( + + + {props.children} + + + + + {language.t("workspace.move.title")} + void move("create")}> + + {language.t("workspace.new")} + + + 0}> + + + + + {language.t("session.new.workspace.existing").replace(/…$/, "")} + + + + + {(workspace) => ( + void move(workspace)}> + + {getFilename(workspace)} + + )} + + + + + + + + + ) +} + +async function createWorkspace( + root: string, + sessionID: string, + messageID: string | undefined, + serverSDK: ReturnType>, + fail: (message: string) => void, + messages: { createFailed: string; stillPreparing: string }, +) { + WorkspaceOperation.start(serverSDK.scope, sessionID, "create", root, messageID) + const created = await workspaceRequestWithTimeout( + (signal) => serverSDK.client.worktree.create({ directory: root }, { signal }), + messages.createFailed, + WORKSPACE_PREPARATION_TIMEOUT_MS, + ) + .then((result) => result.data) + .catch((error) => { + fail(error instanceof Error ? error.message : messages.createFailed) + return undefined + }) + if (!created?.directory) return + WorkspaceOperation.start(serverSDK.scope, sessionID, "create", created.directory, messageID) + Worktree.pending(serverSDK.scope, created.directory) + const timer = { id: undefined as ReturnType | undefined } + const timeout = new Promise>((resolve) => { + timer.id = setTimeout( + () => resolve({ status: "failed", message: messages.stillPreparing }), + WORKSPACE_PREPARATION_TIMEOUT_MS, + ) + }) + const ready = await Promise.race([Worktree.wait(serverSDK.scope, created.directory), timeout]).finally(() => { + if (timer.id) clearTimeout(timer.id) + }) + if (!ready || ready.status === "failed") { + fail(ready?.message ?? messages.createFailed) + return + } + return created.directory +} diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index b53fb691b3cf..9b2a46bb94f5 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -208,6 +208,57 @@ describe("applyDirectoryEvent", () => { expect(store.sessionTotal).toBe(2) }) + test("moves a root between loaded directory indexes exactly once", () => { + const info = { ...rootSession({ id: "session" }), directory: "/destination" } + const [source, setSource] = createStore( + baseState({ session: [{ ...info, directory: "/source" }], sessionTotal: 1 }), + ) + const [destination, setDestination] = createStore(baseState({ session: [], sessionTotal: 0 })) + const created = { type: "session.created", properties: { info } } + const moved = { + type: "session.moved", + properties: { sessionID: info.id, location: { directory: info.directory } }, + } + + applyDirectoryEvent({ + event: created, + store: destination, + setStore: setDestination, + push() {}, + directory: "/destination", + loadLsp() {}, + }) + applyDirectoryEvent({ + event: moved, + store: source, + setStore: setSource, + push() {}, + directory: "/source", + loadLsp() {}, + }) + applyDirectoryEvent({ + event: created, + store: destination, + setStore: setDestination, + push() {}, + directory: "/destination", + loadLsp() {}, + }) + applyDirectoryEvent({ + event: moved, + store: source, + setStore: setSource, + push() {}, + directory: "/source", + loadLsp() {}, + }) + + expect(source.session).toEqual([]) + expect(source.sessionTotal).toBe(0) + expect(destination.session.map((session) => session.id)).toEqual([info.id]) + expect(destination.sessionTotal).toBe(1) + }) + test("cleans session caches when archived", () => { const message = userMessage("msg_1", "ses_1") const [store, setStore] = createStore( diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 41a99f3b67e1..72b6628085f3 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -8,6 +8,7 @@ import { useServerSDK } from "./server-sdk" import { useSettings } from "./settings" import { useSDK } from "./sdk" import { useTabs, type Tab } from "./tabs" +import type { ServerScope } from "@/utils/server-scope" import { createPromptReady, createPromptSession, @@ -104,11 +105,13 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server) const scope = (): PromptScope => search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id } - const load = (scope: PromptScope) => { - const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined - if (current) return createTabPromptState(tabs, current, serverSDK().scope, scope) + const load = (scope: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) => { + const current = settings.general.newLayoutDesigns() + ? selectPromptTab(tabs.store, scope, target?.server ?? serverKey()) + : undefined + if (current) return createTabPromptState(tabs, current, target?.scope ?? serverSDK().scope, scope) - const key = scopeKey(scope) + const key = target ? `${target.scope}:${scopeKey(scope)}` : scopeKey(scope) const existing = cache.get(key) if (existing) { cache.delete(key) @@ -118,7 +121,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( const entry = createRoot( (dispose) => ({ - value: createPromptSession(serverSDK().scope, scope), + value: createPromptSession(target?.scope ?? serverSDK().scope, scope), dispose, }), owner, @@ -130,7 +133,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( } const session = createMemo(() => load(scope())) - const pick = (scope?: PromptScope) => (scope ? load(scope) : session()) + const pick = (scope?: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) => + scope ? load(scope, target) : session() const ready = createPromptReady(session) const withSuspense = (cb: () => T): (() => T) => @@ -146,7 +150,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( return { ready, - capture: (scope?: PromptScope) => pick(scope).capture(), + capture: (scope?: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) => + pick(scope, target).capture(), current: withSuspense(() => session().current()), cursor: withSuspense(() => session().cursor()), dirty: withSuspense(() => session().dirty()), diff --git a/packages/app/src/context/server-sdk.test.ts b/packages/app/src/context/server-sdk.test.ts index 57e1cd86f3ac..35c47cdcb36f 100644 --- a/packages/app/src/context/server-sdk.test.ts +++ b/packages/app/src/context/server-sdk.test.ts @@ -1,7 +1,99 @@ import { describe, expect, test } from "bun:test" -import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk" +import { + adaptServerEvent, + applyWorkspaceOperationEvent, + applyWorktreeEvent, + coalesceServerEvents, + enqueueServerEvent, + resumeStreamAfterPageShow, +} from "./server-sdk" import type { OpenCodeEvent } from "@opencode-ai/client/promise" import type { Event } from "@opencode-ai/sdk/v2/client" +import { ServerScope } from "@/utils/server-scope" +import { Worktree } from "@/utils/worktree" +import { WorkspaceOperation } from "@/utils/workspace-operation" + +describe("applyWorktreeEvent", () => { + test("resolves readiness in the server scope that received the event", () => { + const directory = "/repo/worktree-ready" + Worktree.pending(ServerScope.local, directory) + + expect( + applyWorktreeEvent( + ServerScope.local, + { directory, payload: { id: "ready", type: "worktree.ready", properties: { name: "ready" } } as Event }, + "failed", + ), + ).toBe(true) + expect(Worktree.get(ServerScope.local, directory)).toEqual({ status: "ready" }) + }) + + test("preserves the server failure message", () => { + const directory = "/repo/worktree-failed" + Worktree.pending(ServerScope.local, directory) + + applyWorktreeEvent( + ServerScope.local, + { + directory, + payload: { + id: "failed", + type: "worktree.failed", + properties: { name: "failed", message: "bootstrap failed" }, + } as Event, + }, + "fallback", + ) + expect(Worktree.get(ServerScope.local, directory)).toEqual({ status: "failed", message: "bootstrap failed" }) + }) + + test("does not let duplicate readiness settle placement after creation", () => { + const directory = "/repo/worktree-placement" + Worktree.ready(ServerScope.local, directory) + WorkspaceOperation.start(ServerScope.local, "session-worktree-placement", "create", directory) + + applyWorktreeEvent( + ServerScope.local, + { directory, payload: { id: "ready-again", type: "worktree.ready", properties: { name: "ready" } } as Event }, + "failed", + ) + expect(WorkspaceOperation.get(ServerScope.local, "session-worktree-placement")?.status).toBe("pending") + }) +}) + +test("moved events complete the matching workspace operation", () => { + WorkspaceOperation.start(ServerScope.local, "session-move", "move", "/workspace") + applyWorkspaceOperationEvent(ServerScope.local, { + directory: "/workspace", + payload: { + id: "moved", + type: "session.next.moved", + properties: { + timestamp: Date.now(), + sessionID: "session-move", + location: { directory: "/workspace" }, + }, + } as Event, + }) + expect(WorkspaceOperation.get(ServerScope.local, "session-move")?.status).toBe("complete") +}) + +test("current moved events complete the matching workspace operation", () => { + WorkspaceOperation.start(ServerScope.local, "session-current-move", "move", "/workspace") + applyWorkspaceOperationEvent(ServerScope.local, { + directory: "/workspace", + payload: adaptServerEvent({ + id: "moved-current", + created: Date.now(), + type: "session.moved", + data: { + sessionID: "session-current-move", + location: { directory: "/workspace" }, + }, + } as OpenCodeEvent), + }) + expect(WorkspaceOperation.get(ServerScope.local, "session-current-move")?.status).toBe("complete") +}) describe("resumeStreamAfterPageShow", () => { test("restarts a stream only after a back-forward cache restore", () => { diff --git a/packages/app/src/context/server-sdk.tsx b/packages/app/src/context/server-sdk.tsx index 7dd2a6e59edf..146ad95bc59f 100644 --- a/packages/app/src/context/server-sdk.tsx +++ b/packages/app/src/context/server-sdk.tsx @@ -13,6 +13,8 @@ import { useGlobal } from "./global" import { ServerScope } from "@/utils/server-scope" import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol" import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat" +import { Worktree } from "@/utils/worktree" +import { WorkspaceOperation } from "@/utils/workspace-operation" const isAbortError = (error: unknown) => error !== null && typeof error === "object" && "name" in error && error.name === "AbortError" @@ -138,6 +140,34 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) { return output } +export function applyWorktreeEvent(scope: ServerScope, event: QueuedServerEvent, fallback: string) { + if (event.payload.type === "worktree.ready") { + const pending = Worktree.get(scope, event.directory)?.status === "pending" + Worktree.ready(scope, event.directory) + if (pending) WorkspaceOperation.completeCreate(scope, event.directory) + return true + } + if (event.payload.type !== "worktree.failed") return false + const message = event.payload.properties.message ?? fallback + Worktree.failed(scope, event.directory, message) + WorkspaceOperation.failCreate(scope, event.directory, message) + return true +} + +export function applyWorkspaceOperationEvent(scope: ServerScope, event: QueuedServerEvent) { + if (event.payload.current?.type === "session.moved") { + WorkspaceOperation.complete( + scope, + event.payload.current.data.sessionID, + event.payload.current.data.location.directory, + ) + return true + } + if (event.payload.type !== "session.next.moved") return false + WorkspaceOperation.complete(scope, event.payload.properties.sessionID, event.payload.properties.location.directory) + return true +} + function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined { if ( event?.type === "session.text.delta" || @@ -186,6 +216,7 @@ type ServerSDKBase = { function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase { const platform = usePlatform() + const language = useLanguage() const abort = new AbortController() const eventFetch = (() => { @@ -238,7 +269,11 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS last = Date.now() const output = coalesceServerEvents(events) batch(() => { - output.forEach((event) => emitter.emit(event.directory, event.payload)) + output.forEach((event) => { + applyWorktreeEvent(scope, event, language.t("common.requestFailed")) + applyWorkspaceOperationEvent(scope, event) + emitter.emit(event.directory, event.payload) + }) }) buffer.length = 0 @@ -433,6 +468,14 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) { legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }), directory, }), + createApi(next: string) { + return createCompatibleApi({ + protocol: serverSDK.protocol, + current: serverSDK.currentApi, + legacy: (target) => serverSDK.createClient({ directory: target ?? next, throwOnError: true }), + directory: next, + }) + }, event: emitter, get url() { return serverSDK.url diff --git a/packages/app/src/context/server-session.test.ts b/packages/app/src/context/server-session.test.ts index 1e1046fb76fd..67cc893a06ea 100644 --- a/packages/app/src/context/server-session.test.ts +++ b/packages/app/src/context/server-session.test.ts @@ -220,6 +220,47 @@ describe("server session", () => { expect(ctx.store.lineage.peek("child")).toEqual(result) }) + test("applies moved session locations without evicting cached state", () => { + const current = { ...session("child"), directory: "/repo/worktree", path: undefined } + const ctx = setup({ child: current }) + ctx.store.remember(current) + + ctx.store.apply({ + type: "session.next.moved", + properties: { + sessionID: "child", + location: { directory: "/repo" }, + subdirectory: "packages/app", + }, + }) + + expect(ctx.store.get("child")).toMatchObject({ directory: "/repo", path: "packages/app" }) + }) + + test("does not reuse a hung signal-scoped placement refresh", async () => { + let calls = 0 + const client = { + session: { + get: async () => { + calls++ + if (calls === 1) return new Promise(() => {}) + return { data: { ...session("child"), directory: "/destination" } } + }, + }, + } as unknown as OpencodeClient + const store = createServerSession(client) + store.remember({ ...session("child"), directory: "/source" }) + const first = new AbortController() + + void store.resolve("child", { force: true, signal: first.signal }) + first.abort() + const result = await store.resolve("child", { force: true, signal: new AbortController().signal }) + + expect(calls).toBe(2) + expect(result.directory).toBe("/destination") + expect(store.get("child")?.directory).toBe("/destination") + }) + test("loads session content through the server client", async () => { const ctx = setup({ root: session("root") }) @@ -340,6 +381,17 @@ describe("server session", () => { expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id]) }) + test("indexes optimistic users for the current timeline projection", () => { + const ctx = setup({}) + const message = userMessage("message-optimistic") + + ctx.store.optimistic.add({ sessionID: "child", message, parts: [] }) + + expect(ctx.store.data.session_message.child.map((item) => item.id)).toEqual([message.id]) + ctx.store.optimistic.remove({ sessionID: "child", messageID: message.id }) + expect(ctx.store.data.session_message.child).toEqual([]) + }) + test("backfills an assistant-only initial page through its user root", async () => { const user = userMessage("message-1") const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)] diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts index e8f91cda3f6f..fc468dcf4897 100644 --- a/packages/app/src/context/server-session.ts +++ b/packages/app/src/context/server-session.ts @@ -298,22 +298,24 @@ export function createServerSession( return session } - const resolve = (sessionID: string, options?: { force?: boolean }) => { + const resolve = (sessionID: string, options?: { force?: boolean; signal?: AbortSignal }) => { const cached = data.info[sessionID] if (cached && !options?.force) return Promise.resolve(cached) - const pending = requests.get(sessionID) + const pending = options?.signal ? undefined : requests.get(sessionID) if (pending) return pending const active = generation(sessionID) const request = sessionApi - ? sessionApi.get({ sessionID }).then(normalizeSessionInfo) - : client.session.get({ sessionID }).then((result) => { + ? sessionApi.get({ sessionID }, { signal: options?.signal }).then(normalizeSessionInfo) + : client.session.get({ sessionID }, { signal: options?.signal }).then((result) => { if (!result.data) throw sessionNotFoundError(sessionID) return result.data }) const resolved = request.then((result) => { + if (options?.signal?.aborted) return result if (generations.get(sessionID) !== active) return result return remember(result) }) + if (options?.signal) return resolved requests.set(sessionID, resolved) const cleanup = () => { if (requests.get(sessionID) === resolved) requests.delete(sessionID) @@ -1015,6 +1017,27 @@ export function createServerSession( evict([sessionID]) return } + case "session.next.moved": { + const props = event.properties as { + timestamp: number + sessionID: string + location: { directory: string; workspaceID?: string } + subdirectory?: string + } + const current = data.info[props.sessionID] + if (!current) { + void resolve(props.sessionID, { force: true }).catch(() => {}) + return + } + remember({ + ...current, + directory: props.location.directory, + path: props.subdirectory, + workspaceID: props.location.workspaceID, + time: { ...current.time, updated: props.timestamp }, + }) + return + } case "todo.updated": { const props = event.properties as { sessionID: string; todos: Todo[] } setData("todo", props.sessionID, reconcile(props.todos, { key: "id" })) @@ -1338,6 +1361,7 @@ export function createServerSession( if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] }) if (!items) optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]])) + indexLegacyMessage(input.message) setData("message", input.sessionID, (messages = []) => merge(messages, [input.message])) setData( "part_text_accum_delta", @@ -1371,6 +1395,9 @@ export function createServerSession( ) return } + setData("session_message", input.sessionID, (messages) => + messages?.filter((message) => message.id !== input.messageID), + ) setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID)) setData(produce((draft) => deleteMessageParts(draft, input.messageID))) }, diff --git a/packages/app/src/context/server-sync.test.ts b/packages/app/src/context/server-sync.test.ts index 9f625c94324a..a3c446797922 100644 --- a/packages/app/src/context/server-sync.test.ts +++ b/packages/app/src/context/server-sync.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" +import type { Event, OpencodeClient, Session } from "@opencode-ai/sdk/v2/client" import type { McpApi, McpListInput, @@ -7,13 +7,21 @@ import type { SessionApi, SessionInfo, SessionListInput, + OpenCodeEvent, } from "@opencode-ai/client/promise" import { QueryClient } from "@tanstack/solid-query" import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction" import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load" -import { loadActiveSessionsQuery, loadMcpQuery, loadMcpResourcesQuery, seedActiveSessionStatuses } from "./server-sync" +import { + captureSessionMove, + loadActiveSessionsQuery, + loadMcpQuery, + loadMcpResourcesQuery, + seedActiveSessionStatuses, +} from "./server-sync" import { ServerScope } from "@/utils/server-scope" import { createServerSession } from "./server-session" +import { adaptServerEvent } from "./server-sdk" describe("MCP queries", () => { test("loads current servers for the requested location", async () => { @@ -100,6 +108,51 @@ describe("active session query", () => { }) }) +describe("session move normalization", () => { + test("captures and applies current moves from the source placement", () => { + const session = createServerSession({} as OpencodeClient) + session.remember(sessionAt("/source")) + const current = { + id: "event-current-move", + created: 10, + type: "session.moved", + data: { sessionID: "session", location: { directory: "/destination" } }, + } as OpenCodeEvent + const event = adaptServerEvent(current) + + expect(captureSessionMove(event, session.get)).toEqual({ + sessionID: "session", + from: "/source", + refresh: "session.next.moved", + }) + session.applyV2(current) + session.apply(event) + expect(session.get("session")?.directory).toBe("/destination") + }) + + test("captures and applies V1 moves from the source placement", () => { + const session = createServerSession({} as OpencodeClient) + session.remember(sessionAt("/source")) + const event = { + type: "session.next.moved", + properties: { + timestamp: 10, + sessionID: "session", + location: { directory: "/destination" }, + subdirectory: "packages/app", + }, + } as Event + + expect(captureSessionMove(event, session.get)).toEqual({ + sessionID: "session", + from: "/source", + refresh: "session.next.moved", + }) + session.apply(event) + expect(session.get("session")).toMatchObject({ directory: "/destination", path: "packages/app" }) + }) +}) + describe("pickDirectoriesToEvict", () => { test("keeps pinned stores and evicts idle stores", () => { const now = 5_000 @@ -172,6 +225,18 @@ function sessionInfo(id: string) { } as SessionInfo } +function sessionAt(directory: string): Session { + return { + id: "session", + slug: "session", + projectID: "project", + directory, + title: "Session", + version: "", + time: { created: 1, updated: 1 }, + } +} + describe("estimateRootSessionTotal", () => { test("keeps exact total for full fetches", () => { expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42) diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 109a7bf7d65c..6eb1e41496b1 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -12,7 +12,7 @@ import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack import { createStore, produce, reconcile } from "solid-js/store" import { useLanguage } from "@/context/language" import type { InitError } from "../pages/error" -import { ServerSDK } from "./server-sdk" +import { ServerSDK, type ServerEvent } from "./server-sdk" import { bootstrapDirectory, bootstrapGlobal, @@ -46,6 +46,17 @@ import { ServerConnection, useServer } from "./server" import { retry } from "@opencode-ai/core/util/retry" import type { ServerScope } from "@/utils/server-scope" import { createHomeSessionIndexCache } from "./global-sync/home-session-index" + +export function captureSessionMove(event: ServerEvent, get: (sessionID: string) => { directory: string } | undefined) { + const sessionID = + event.current?.type === "session.moved" + ? event.current.data.sessionID + : event.type === "session.next.moved" + ? event.properties.sessionID + : undefined + if (!sessionID) return + return { sessionID, from: get(sessionID)?.directory, refresh: "session.next.moved" as const } +} import { persisted } from "@/utils/persist" import type { ServerApi } from "@/utils/server" import type { @@ -523,19 +534,49 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { }) } + const reindexSession = (sessionID: string, from?: string) => { + const next = session.get(sessionID) + if (!next) return + indexSession(next) + if (!from) return + const source = children.children[directoryKey(from)] + if (!source) return + applyDirectoryEvent({ + event: { + type: "session.moved", + properties: { + sessionID, + projectID: next.projectID, + location: { directory: next.directory, workspaceID: next.workspaceID }, + subpath: next.path, + }, + }, + directory: from, + store: source[0], + setStore: source[1], + push: queue.push, + retainedLimit: sessionMeta.get(directoryKey(from))?.limit, + sessionContent: false, + permission: session.data.permission, + loadLsp() {}, + }) + } + const unsub = serverSDK.event.listen((e) => { const directory = e.name const key = directoryKey(directory) const event = e.details const eventType: string = event.type const recent = bootingRoot || Date.now() - bootedAt < 1500 + const moved = captureSessionMove(event, session.get) if (event.current) session.applyV2(event.current) session.apply(event) + if (moved) reindexSession(moved.sessionID, moved.from) if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") { homeSessions.apply(event) } - homeSessions.refresh(event.type) + homeSessions.refresh(moved?.refresh ?? event.type) if (directory === "global") { if (eventType === "server.connected" && activeSessionsQuery.data === undefined && !activeSessionsQuery.isFetching) @@ -566,10 +607,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { return } - if (event.current?.type === "session.moved") { - const info = session.get(event.current.data.sessionID) - if (info) indexSession(info) - } if (event.current?.type === "session.forked") void session .resolve(event.current.data.sessionID, { force: true }) @@ -681,6 +718,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { updateConfig: updateConfigMutation.mutateAsync, project: projectApi, session, + reindexSession, homeSessions, mcp: { toggle: async (directory: string, name: string) => { diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 320738a6b027..15abb946e951 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -894,6 +894,12 @@ export const dict = { "session.delete.confirm": 'حذف الجلسة "{{name}}"؟', "session.delete.button": "حذف الجلسة", "workspace.new": "مساحة عمل جديدة", + "workspace.move.title": "نقل إلى مساحة العمل", + "workspace.move.failed": "تعذّر نقل الجلسة", + "workspace.lifecycle.creating": "جارٍ إنشاء مساحة العمل", + "workspace.lifecycle.created": "تم إنشاء مساحة العمل", + "workspace.lifecycle.moving": "جارٍ النقل إلى مساحة العمل", + "workspace.lifecycle.set": "تم تعيين مساحة العمل", "workspace.type.local": "محلي", "workspace.type.sandbox": "صندوق رمل", "workspace.create.failed.title": "فشل إنشاء مساحة العمل", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 9d6765ec94ef..0d496aea2c9e 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -908,6 +908,12 @@ export const dict = { "session.delete.confirm": 'Excluir sessão "{{name}}"?', "session.delete.button": "Excluir sessão", "workspace.new": "Novo espaço de trabalho", + "workspace.move.title": "Mover para o espaço de trabalho", + "workspace.move.failed": "Não foi possível mover a sessão", + "workspace.lifecycle.creating": "Criando espaço de trabalho", + "workspace.lifecycle.created": "Espaço de trabalho criado", + "workspace.lifecycle.moving": "Movendo para o espaço de trabalho", + "workspace.lifecycle.set": "Espaço de trabalho definido", "workspace.type.local": "local", "workspace.type.sandbox": "sandbox", "workspace.create.failed.title": "Falha ao criar espaço de trabalho", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index 915313c3e2b5..22c9ee7cedbe 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -984,6 +984,12 @@ export const dict = { "session.delete.button": "Izbriši sesiju", "workspace.new": "Novi radni prostor", + "workspace.move.title": "Premjesti u radni prostor", + "workspace.move.failed": "Premještanje sesije nije uspjelo", + "workspace.lifecycle.creating": "Kreiranje radnog prostora", + "workspace.lifecycle.created": "Radni prostor je kreiran", + "workspace.lifecycle.moving": "Premještanje u radni prostor", + "workspace.lifecycle.set": "Radni prostor je postavljen", "workspace.type.local": "lokalno", "workspace.type.sandbox": "sandbox", "workspace.create.failed.title": "Neuspješno kreiranje radnog prostora", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 22fcdf39ceb0..4fd86c1c9b8c 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -976,6 +976,12 @@ export const dict = { "session.delete.button": "Slet session", "workspace.new": "Nyt arbejdsområde", + "workspace.move.title": "Flyt til arbejdsområde", + "workspace.move.failed": "Sessionen kunne ikke flyttes", + "workspace.lifecycle.creating": "Opretter arbejdsområde", + "workspace.lifecycle.created": "Arbejdsområde oprettet", + "workspace.lifecycle.moving": "Flytter til arbejdsområde", + "workspace.lifecycle.set": "Arbejdsområde angivet", "workspace.type.local": "lokal", "workspace.type.sandbox": "sandkasse", "workspace.create.failed.title": "Kunne ikke oprette arbejdsområde", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index bc2ac9be381d..56e743281a15 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -920,6 +920,12 @@ export const dict = { "session.delete.confirm": 'Sitzung "{{name}}" löschen?', "session.delete.button": "Sitzung löschen", "workspace.new": "Neuer Arbeitsbereich", + "workspace.move.title": "In Arbeitsbereich verschieben", + "workspace.move.failed": "Sitzung konnte nicht verschoben werden", + "workspace.lifecycle.creating": "Arbeitsbereich wird erstellt", + "workspace.lifecycle.created": "Arbeitsbereich erstellt", + "workspace.lifecycle.moving": "Wird in den Arbeitsbereich verschoben", + "workspace.lifecycle.set": "Arbeitsbereich festgelegt", "workspace.type.local": "lokal", "workspace.type.sandbox": "Sandbox", "workspace.create.failed.title": "Arbeitsbereich konnte nicht erstellt werden", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index f03147f151d1..1e12f3b2bbd0 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -1075,6 +1075,12 @@ export const dict = { "session.delete.button": "Delete session", "workspace.new": "New workspace", + "workspace.move.title": "Move to workspace", + "workspace.move.failed": "Failed to move session", + "workspace.lifecycle.creating": "Creating workspace", + "workspace.lifecycle.created": "Workspace created", + "workspace.lifecycle.moving": "Moving to workspace", + "workspace.lifecycle.set": "Workspace set", "workspace.type.local": "local", "workspace.type.sandbox": "sandbox", "workspace.create.failed.title": "Failed to create workspace", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 00dca6704b25..bb7474408ef3 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -992,6 +992,12 @@ export const dict = { "session.delete.button": "Eliminar sesión", "workspace.new": "Nuevo espacio de trabajo", + "workspace.move.title": "Mover al espacio de trabajo", + "workspace.move.failed": "No se pudo mover la sesión", + "workspace.lifecycle.creating": "Creando espacio de trabajo", + "workspace.lifecycle.created": "Espacio de trabajo creado", + "workspace.lifecycle.moving": "Moviendo al espacio de trabajo", + "workspace.lifecycle.set": "Espacio de trabajo establecido", "workspace.type.local": "local", "workspace.type.sandbox": "sandbox", "workspace.create.failed.title": "Fallo al crear espacio de trabajo", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index d1487ff61ef3..470b6fcd2834 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -919,6 +919,12 @@ export const dict = { "session.delete.confirm": 'Supprimer la session "{{name}}" ?', "session.delete.button": "Supprimer la session", "workspace.new": "Nouvel espace de travail", + "workspace.move.title": "Déplacer vers l’espace de travail", + "workspace.move.failed": "Impossible de déplacer la session", + "workspace.lifecycle.creating": "Création de l’espace de travail", + "workspace.lifecycle.created": "Espace de travail créé", + "workspace.lifecycle.moving": "Déplacement vers l’espace de travail", + "workspace.lifecycle.set": "Espace de travail défini", "workspace.type.local": "local", "workspace.type.sandbox": "bac à sable", "workspace.create.failed.title": "Échec de la création de l'espace de travail", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 6a3b1ebcb02e..f38c10ef036e 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -901,6 +901,12 @@ export const dict = { "session.delete.confirm": 'セッション "{{name}}" を削除しますか?', "session.delete.button": "セッションを削除", "workspace.new": "新しいワークスペース", + "workspace.move.title": "ワークスペースに移動", + "workspace.move.failed": "セッションを移動できませんでした", + "workspace.lifecycle.creating": "ワークスペースを作成中", + "workspace.lifecycle.created": "ワークスペースを作成しました", + "workspace.lifecycle.moving": "ワークスペースに移動中", + "workspace.lifecycle.set": "ワークスペースを設定しました", "workspace.type.local": "ローカル", "workspace.type.sandbox": "サンドボックス", "workspace.create.failed.title": "ワークスペースの作成に失敗しました", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 3c262ce3b93a..a28915b77cbe 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -742,6 +742,12 @@ export const dict = { "session.delete.confirm": '"{{name}}" 세션을 삭제하시겠습니까?', "session.delete.button": "세션 삭제", "workspace.new": "새 작업 공간", + "workspace.move.title": "작업 공간으로 이동", + "workspace.move.failed": "세션을 이동하지 못했습니다", + "workspace.lifecycle.creating": "작업 공간 생성 중", + "workspace.lifecycle.created": "작업 공간을 만들었습니다", + "workspace.lifecycle.moving": "작업 공간으로 이동 중", + "workspace.lifecycle.set": "작업 공간을 설정했습니다", "workspace.type.local": "로컬", "workspace.type.sandbox": "샌드박스", "workspace.create.failed.title": "작업 공간 생성 실패", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index a4e8efb79d27..6df07e139f7f 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -829,6 +829,12 @@ export const dict = { "session.delete.button": "Slett sesjon", "workspace.new": "Nytt arbeidsområde", + "workspace.move.title": "Flytt til arbeidsområde", + "workspace.move.failed": "Kunne ikke flytte økten", + "workspace.lifecycle.creating": "Oppretter arbeidsområde", + "workspace.lifecycle.created": "Arbeidsområdet er opprettet", + "workspace.lifecycle.moving": "Flytter til arbeidsområde", + "workspace.lifecycle.set": "Arbeidsområdet er angitt", "workspace.type.local": "lokal", "workspace.type.sandbox": "sandkasse", "workspace.create.failed.title": "Kunne ikke opprette arbeidsområde", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index f2e32ffeb879..15a65c722ba1 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -907,6 +907,12 @@ export const dict = { "session.delete.confirm": 'Usunąć sesję "{{name}}"?', "session.delete.button": "Usuń sesję", "workspace.new": "Nowa przestrzeń robocza", + "workspace.move.title": "Przenieś do obszaru roboczego", + "workspace.move.failed": "Nie udało się przenieść sesji", + "workspace.lifecycle.creating": "Tworzenie obszaru roboczego", + "workspace.lifecycle.created": "Utworzono obszar roboczy", + "workspace.lifecycle.moving": "Przenoszenie do obszaru roboczego", + "workspace.lifecycle.set": "Ustawiono obszar roboczy", "workspace.type.local": "lokalna", "workspace.type.sandbox": "piaskownica", "workspace.create.failed.title": "Nie udało się utworzyć przestrzeni roboczej", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index fa6fb04d04e1..e7a6996f4590 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -987,6 +987,12 @@ export const dict = { "session.delete.button": "Удалить сессию", "workspace.new": "Новое рабочее пространство", + "workspace.move.title": "Переместить в рабочую область", + "workspace.move.failed": "Не удалось переместить сеанс", + "workspace.lifecycle.creating": "Создание рабочей области", + "workspace.lifecycle.created": "Рабочая область создана", + "workspace.lifecycle.moving": "Перемещение в рабочую область", + "workspace.lifecycle.set": "Рабочая область задана", "workspace.type.local": "локальное", "workspace.type.sandbox": "песочница", "workspace.create.failed.title": "Не удалось создать рабочее пространство", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 0ae67ee34eb1..c41422d64877 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -971,6 +971,12 @@ export const dict = { "session.delete.button": "ลบเซสชัน", "workspace.new": "พื้นที่ทำงานใหม่", + "workspace.move.title": "ย้ายไปยังพื้นที่ทำงาน", + "workspace.move.failed": "ย้ายเซสชันไม่สำเร็จ", + "workspace.lifecycle.creating": "กำลังสร้างพื้นที่ทำงาน", + "workspace.lifecycle.created": "สร้างพื้นที่ทำงานแล้ว", + "workspace.lifecycle.moving": "กำลังย้ายไปยังพื้นที่ทำงาน", + "workspace.lifecycle.set": "ตั้งค่าพื้นที่ทำงานแล้ว", "workspace.type.local": "ในเครื่อง", "workspace.type.sandbox": "แซนด์บ็อกซ์", "workspace.create.failed.title": "ไม่สามารถสร้างพื้นที่ทำงาน", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index d401519af9c9..d505ec89d8a6 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -991,6 +991,12 @@ export const dict = { "session.delete.button": "Oturumu sil", "workspace.new": "Yeni çalışma alanı", + "workspace.move.title": "Çalışma alanına taşı", + "workspace.move.failed": "Oturum taşınamadı", + "workspace.lifecycle.creating": "Çalışma alanı oluşturuluyor", + "workspace.lifecycle.created": "Çalışma alanı oluşturuldu", + "workspace.lifecycle.moving": "Çalışma alanına taşınıyor", + "workspace.lifecycle.set": "Çalışma alanı ayarlandı", "workspace.type.local": "yerel", "workspace.type.sandbox": "sandbox", "workspace.create.failed.title": "Çalışma alanı oluşturulamadı", diff --git a/packages/app/src/i18n/uk.ts b/packages/app/src/i18n/uk.ts index c949f41de2dc..f7aa5669d54e 100644 --- a/packages/app/src/i18n/uk.ts +++ b/packages/app/src/i18n/uk.ts @@ -1078,6 +1078,12 @@ export const dict = { "session.delete.button": "Видалити сесію", "workspace.new": "Нова робоча область", + "workspace.move.title": "Перемістити до робочого простору", + "workspace.move.failed": "Не вдалося перемістити сеанс", + "workspace.lifecycle.creating": "Створення робочого простору", + "workspace.lifecycle.created": "Робочий простір створено", + "workspace.lifecycle.moving": "Переміщення до робочого простору", + "workspace.lifecycle.set": "Робочий простір установлено", "workspace.type.local": "локальна", "workspace.type.sandbox": "пісочниця", "workspace.create.failed.title": "Не вдалося створити робочу область", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 82d73ac3a6a6..42d097a5bf16 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -964,6 +964,12 @@ export const dict = { "session.delete.button": "删除会话", "workspace.new": "新建工作区", + "workspace.move.title": "移动到工作区", + "workspace.move.failed": "无法移动会话", + "workspace.lifecycle.creating": "正在创建工作区", + "workspace.lifecycle.created": "工作区已创建", + "workspace.lifecycle.moving": "正在移动到工作区", + "workspace.lifecycle.set": "工作区已设置", "workspace.type.local": "本地", "workspace.type.sandbox": "沙盒", "workspace.create.failed.title": "创建工作区失败", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index fbe80d95eeff..60bd3193a3d0 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -960,6 +960,12 @@ export const dict = { "session.delete.button": "刪除工作階段", "workspace.new": "新增工作區", + "workspace.move.title": "移動到工作區", + "workspace.move.failed": "無法移動工作階段", + "workspace.lifecycle.creating": "正在建立工作區", + "workspace.lifecycle.created": "工作區已建立", + "workspace.lifecycle.moving": "正在移動到工作區", + "workspace.lifecycle.set": "工作區已設定", "workspace.type.local": "本地", "workspace.type.sandbox": "沙盒", "workspace.create.failed.title": "建立工作區失敗", diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 78a9e103a7f5..495fe7490b65 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -327,4 +327,9 @@ animation-range: 0 0.1px; } } + + body[data-new-layout] [data-slot="session-turn-diffs-header"] { + height: 24px; + padding-block: 0; + } } diff --git a/packages/app/src/pages/home/home-sessions-controller.tsx b/packages/app/src/pages/home/home-sessions-controller.tsx index 06d86c30c9ed..ee88c94add88 100644 --- a/packages/app/src/pages/home/home-sessions-controller.tsx +++ b/packages/app/src/pages/home/home-sessions-controller.tsx @@ -20,6 +20,7 @@ import { displayName, errorMessage, projectForSession } from "@/pages/layout/hel import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state" import { pathKey } from "@/utils/path-key" import { showToast } from "@/utils/toast" +import { WorkspaceOperation } from "@/utils/workspace-operation" import { Binary } from "@opencode-ai/core/util/binary" import { archiveHomeSession } from "../home-session-archive" import type { HomeController } from "./home-controller" @@ -210,6 +211,7 @@ export function createHomeSessionsController(home: HomeController) { const conn = home.server.focused() const ctx = home.server.focusedContext() if (!conn || !ctx) return + if (WorkspaceOperation.get(ctx.sdk.scope, session.id)?.status === "pending") return const [, setStore] = ctx.sync.child(session.directory) await archiveHomeSession({ server: ServerConnection.key(conn), diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 59474423184a..0cc8e898cdbc 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -62,6 +62,7 @@ import { useDirectoryPicker } from "@/components/directory-picker" import { ServerConnection, useServer } from "@/context/server" import { useLanguage, type Locale } from "@/context/language" import { pathKey } from "@/utils/path-key" +import { WorkspaceOperation } from "@/utils/workspace-operation" import { displayName, effectiveWorkspaceOrder, @@ -872,6 +873,7 @@ export default function LegacyLayout(props: ParentProps) { } async function archiveSession(session: Session) { + if (WorkspaceOperation.get(serverSDK().scope, session.id)?.status === "pending") return const [store, setStore] = serverSync().child(session.directory) const sessions = store.session ?? [] const index = sessions.findIndex((s) => s.id === session.id) diff --git a/packages/app/src/pages/new-session.tsx b/packages/app/src/pages/new-session.tsx index d97f5779ec19..a07f5cdc4b55 100644 --- a/packages/app/src/pages/new-session.tsx +++ b/packages/app/src/pages/new-session.tsx @@ -1,4 +1,4 @@ -import { Show, createEffect, createMemo, createResource, createSignal, onCleanup, untrack } from "solid-js" +import { Show, createEffect, createMemo, createResource, createSignal, untrack } from "solid-js" import { createStore } from "solid-js/store" import { Portal } from "solid-js/web" import { useSearchParams } from "@solidjs/router" @@ -34,10 +34,10 @@ import { Persist, persisted } from "@/utils/persist" import createPresence from "solid-presence" import { useLocal } from "@/context/local" import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection" +import { useTabs, type DraftTab } from "@/context/tabs" +import { isWorkspaceSelection } from "@/utils/workspace" -const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000 -const providerTipExitDuration = 250 /** * The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt @@ -63,6 +63,7 @@ export default function NewSessionPage() { useSettingsCommand() const route = useSessionKey() const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>() + const tabs = useTabs() const local = useLocal() const model = createPromptModelSelection({ agent: local.agent.current }) @@ -76,13 +77,26 @@ export default function NewSessionPage() { }) const projectControls = createPromptProjectControls() - const [store, setStore] = createStore<{ worktree?: string }>({}) const rightMount = useTitlebarRightMount() - - const showWorkspaceBar = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git") + const draft = createMemo(() => + tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === searchParams.draftId), + ) + const setWorktree = (worktree: string | undefined) => { + const id = searchParams.draftId + if (!id) return + tabs.updateDraft(id, { worktree }) + } + const showWorkspaceBar = createMemo(() => sync().project?.vcs === "git") + const selectedWorktree = createMemo(() => { + const project = sync().project + const worktree = draft()?.worktree + if (!project || !worktree) return undefined + return isWorkspaceSelection(project, worktree) ? worktree : undefined + }) const newSessionWorktree = createMemo(() => { if (!showWorkspaceBar()) return "main" - if (store.worktree) return store.worktree + const worktree = selectedWorktree() + if (worktree) return worktree const project = sync().project if (project && sdk().directory !== project.worktree) return sdk().directory return "main" @@ -92,8 +106,12 @@ export default function NewSessionPage() { const selectedBranch = createMemo(() => { const worktree = newSessionWorktree() if (worktree === "main" || worktree === "create") return localBranch() - return serverSync().child(worktree)[0].vcs?.branch ?? localBranch() + return serverSync().child(worktree)[0].vcs?.branch }) + const selectWorktree = (worktree: string) => { + const project = sync().project + setWorktree(worktree === "main" && project?.worktree !== sdk().directory ? project?.worktree : worktree) + } const promptInputV2Controller = usePromptInputV2Controller({ get controls() { return inputController() @@ -101,8 +119,8 @@ export default function NewSessionPage() { get newSessionWorktree() { return newSessionWorktree() }, - onNewSessionWorktreeReset: () => setStore("worktree", undefined), - onSubmit: () => comments.clear(), + onNewSessionWorktreeReset: () => setWorktree(undefined), + onSubmit: comments.clear, }) const projectController = createPromptProjectController({ controls: projectControls, @@ -185,14 +203,7 @@ export default function NewSessionPage() { projectRoot={projectRoot()} workspaces={sync().project?.sandboxes ?? []} branch={selectedBranch()} - onChange={(value) => - setStore( - "worktree", - value === "main" && sync().project?.worktree !== sdk().directory - ? sync().project?.worktree - : value, - ) - } + onChange={selectWorktree} onDone={promptInputV2Controller.restoreFocus} /> diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index d4dd6ff7efbb..22b2d914e819 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -99,6 +99,7 @@ import { Persist, persisted } from "@/utils/persist" import { extractPromptFromParts } from "@/utils/prompt" import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors" import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route" +import { canMoveSessionToWorkspace, WorkspaceOperation } from "@/utils/workspace-operation" import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs" import { createSessionOwnership } from "./session/session-ownership" import { createSessionLineage } from "./session/session-lineage" @@ -599,6 +600,7 @@ export default function Page() { const [store, setStore] = createStore({ ...sessionViewState(), newSessionWorktree: "main", + sessionDetailsOpen: false, deferRender: false, }) @@ -698,6 +700,19 @@ export default function Page() { : skipToken, } }) + const sessionDetailsQuery = createQuery(() => ({ + queryKey: [...vcsKey(), "git"] as const, + enabled: store.sessionDetailsOpen && sync().project?.vcs === "git", + queryFn: () => + sdk() + .api.vcs.diff({ location: { directory: sdk().directory }, mode: "working" }) + .then((result) => result.data) + .catch((error) => { + console.debug("[session-review] failed to load session details diff", { error }) + return [] + }), + })) + const sessionDetailsDiffs = () => (sessionDetailsQuery.isFetched ? (sessionDetailsQuery.data ?? []) : []) const refreshVcs = debounce(() => void queryClient.invalidateQueries({ queryKey: vcsKey() }), 100) const reviewDiffs = () => { if (reviewMode() === "git" || reviewMode() === "branch") @@ -1700,6 +1715,8 @@ export default function Page() { } const busy = (sessionID: string) => sync().data.session_working(sessionID) + const workspaceOperationPending = (sessionID: string) => + WorkspaceOperation.get(serverSDK().scope, sessionID)?.status === "pending" const queuedFollowups = createMemo(() => { const id = params.id @@ -1713,8 +1730,20 @@ export default function Page() { return followup.edit[id] }) + const workspaceMoveEligible = createMemo(() => { + const id = params.id + if (!id) return false + return canMoveSessionToWorkspace({ + queued: followup.items[id]?.length ?? 0, + failed: !!followup.failed[id], + paused: !!followup.paused[id], + editing: !!followup.edit[id], + }) + }) + const followupMutation = useMutation(() => ({ mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => { + if (workspaceOperationPending(input.sessionID)) return const owner = sessionOwnership.capture() const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id) if (!item) return @@ -1724,6 +1753,7 @@ export default function Page() { const ok = await sendFollowupDraft({ api: sdk().api.session, + scope: serverSDK().scope, sync: sync(), serverSync: serverSync(), draft: item, @@ -1786,6 +1816,7 @@ export default function Page() { const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => { if (sync().session.get(sessionID)?.parentID) return Promise.resolve() + if (workspaceOperationPending(sessionID)) return Promise.resolve() const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id) if (!item) return Promise.resolve() if (followupBusy(sessionID)) return Promise.resolve() @@ -1825,6 +1856,7 @@ export default function Page() { const revertMutation = useMutation(() => ({ mutationFn: async (input: { sessionID: string; messageID: string }) => { + if (workspaceOperationPending(input.sessionID)) return const session = sdk().api.session const target = sync() const last = target.session.get(input.sessionID)?.revert @@ -1847,6 +1879,7 @@ export default function Page() { mutationFn: async (id: string) => { const sessionID = params.id if (!sessionID) return + if (workspaceOperationPending(sessionID)) return const session = sdk().api.session const target = sync() @@ -1874,7 +1907,10 @@ export default function Page() { }, })) - const reverting = createMemo(() => revertMutation.isPending || restoreMutation.isPending) + const reverting = createMemo(() => { + const id = params.id + return revertMutation.isPending || restoreMutation.isPending || (!!id && workspaceOperationPending(id)) + }) const restoring = createMemo(() => (restoreMutation.isPending ? restoreMutation.variables : undefined)) const revert = (input: { sessionID: string; messageID: string }) => { @@ -1932,6 +1968,7 @@ export default function Page() { if (isChildSession()) return if (composer.blocked()) return if (busy(sessionID)) return + if (workspaceOperationPending(sessionID)) return void sendFollowup(sessionID, item.id) }) @@ -2102,6 +2139,9 @@ export default function Page() { if (root) scheduleScrollState(root) }} userMessages={visibleUserMessages()} + diffs={sessionDetailsDiffs} + workspaceMoveEligible={workspaceMoveEligible()} + onSummaryOpenChange={(open) => setStore("sessionDetailsOpen", open)} setHistoryAnchor={(handlers) => { captureHistoryAnchor = handlers.capture restoreHistoryAnchor = handlers.restore diff --git a/packages/app/src/pages/session/composer/session-composer-controls.ts b/packages/app/src/pages/session/composer/session-composer-controls.ts index 4ae7827e210a..c2c35dd5c6a3 100644 --- a/packages/app/src/pages/session/composer/session-composer-controls.ts +++ b/packages/app/src/pages/session/composer/session-composer-controls.ts @@ -87,7 +87,7 @@ export function createPromptProjectControls() { const target = global.ensureServerCtx(conn) target.projects.open(worktree) target.projects.touch(worktree) - tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree }) + tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree, worktree: undefined }) return } diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 48b432667e17..4fedbfe7af58 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -28,6 +28,7 @@ import { type UserActions, } from "@opencode-ai/session-ui/message-part" import { DiffChanges } from "@opencode-ai/ui/diff-changes" +import { DiffChanges as DiffChangesV2 } from "@opencode-ai/ui/v2/diff-changes-v2" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" @@ -35,6 +36,8 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" import { Dialog } from "@opencode-ai/ui/dialog" import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2" import { InlineInput } from "@opencode-ai/ui/inline-input" @@ -49,6 +52,7 @@ import type { AssistantMessage, Message as MessageType, Part as PartType, + Project, ToolPart, UserMessage, } from "@opencode-ai/sdk/v2" @@ -63,6 +67,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { useLanguage } from "@/context/language" import { useSessionKey } from "@/pages/session/session-layout" import { useServerSDK } from "@/context/server-sdk" +import { useServerSync } from "@/context/server-sync" import { usePlatform } from "@/context/platform" import { useSettings } from "@/context/settings" import { useTabs } from "@/context/tabs" @@ -76,6 +81,12 @@ import { observeElementOffsetReconnectAware } from "./observe-element-offset" import { createTimelineProjection } from "./projection" import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows" import { filterVirtualIndexes } from "./virtual-items" +import { isWorkspaceDirectory } from "@/utils/workspace" +import { useCommand } from "@/context/command" +import { WorkspaceOperation } from "@/utils/workspace-operation" +import { SessionWorkspaceMenu } from "@/components/session-workspace-menu" +import { getProjectAvatarVariant } from "@/context/layout" +import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers" const emptyMessages: MessageType[] = [] const emptyParts: PartType[] = [] @@ -141,7 +152,7 @@ function TimelineThinkingRow(props: { reasoningHeading?: string; showReasoningSu ) } -function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) { +function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[]; action?: JSX.Element }) { const language = useLanguage() const maxFiles = 10 const [state, setState] = createStore({ @@ -172,6 +183,7 @@ function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) { {showAll() ? language.t("ui.sessionTurn.diffs.showLess") : language.t("ui.sessionTurn.diffs.showAll")} + {props.action}
@@ -1081,7 +1301,8 @@ export function MessageTimeline(props: { data-timeline-row={input.row()._tag} classList={{ "min-w-0 w-full max-w-full": true, - "md:max-w-200 2xl:max-w-[1000px]": props.centered, + "md:max-w-[720px]": props.centered && settings.general.newLayoutDesigns(), + "md:max-w-200 2xl:max-w-[1000px]": props.centered && !settings.general.newLayoutDesigns(), "md:mx-auto": props.centered, "pt-3": previousAssistantPart(), }} @@ -1104,7 +1325,7 @@ export function MessageTimeline(props: { ) return ( -
+
@@ -1155,7 +1376,7 @@ export function MessageTimeline(props: { {(message) => ( -
+
) } + case "WorkspaceLifecycle": { + const workspaceRow = row as Accessor> + const operation = createMemo(() => { + const rowOperation = workspaceRow().notice.operation + const current = WorkspaceOperation.get(serverSDK().scope, sessionID()!) + if (current?.messageID === workspaceRow().userMessageID) return current + if ( + !rowOperation.messageID && + current?.directory === rowOperation.directory && + current.type === rowOperation.type + ) + return current + return rowOperation + }) + const pending = () => operation().status === "pending" + const status = () => { + if (operation().status === "failed") return language.t("workspace.move.failed") + if (operation().type === "create") + return language.t(pending() ? "workspace.lifecycle.creating" : "workspace.lifecycle.created") + return language.t(pending() ? "workspace.lifecycle.moving" : "workspace.lifecycle.set") + } + const directory = () => getFilename(operation().directory) + return ( + +
+
+ + }> + + + + + + {directory()} + + + {status()} + + + + +
+
+
+ ) + } case "TurnDivider": { const turnDividerRow = row as Accessor> return ( -
+
> return ( -
+
> return ( -
+
> return ( -
+
@@ -1227,10 +1499,35 @@ export function MessageTimeline(props: { } case "DiffSummary": { const diffSummaryRow = row as Accessor> + const canMove = () => + settings.general.newLayoutDesigns() && + diffSummaryRow().userMessageID === props.userMessages.at(-1)?.id && + !workspaceSession() && + props.workspaceMoveEligible && + sync().project?.vcs === "git" && + sessionStatus().type === "idle" return ( -
- +
+ + {(project) => ( + setWorkspaceSuggestionDismissed(true)} + /> + )} + + } + />
) @@ -1239,7 +1536,7 @@ export function MessageTimeline(props: { const errorRow = row as Accessor> return ( -
+
{errorRow().text} @@ -1321,7 +1618,7 @@ export function MessageTimeline(props: { } return ( -
+
+ + + + + } + > + + + + } + > + + + + + + + +