From 974271d6b1db4513af1ac88fe1722bed9fd236f1 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:18:15 +1000 Subject: [PATCH 01/22] feat(app): add workspace flows to new layout --- .../app/e2e/regression/workspaces.spec.ts | 419 +++++++++++++ packages/app/src/components/help-button.tsx | 8 - .../components/prompt-input/submit.test.ts | 546 ++++++++++++++-- .../app/src/components/prompt-input/submit.ts | 591 ++++++++++-------- .../components/prompt-workspace-selector.tsx | 78 ++- .../src/components/session-workspace-menu.tsx | 192 ++++++ .../context/global-sync/event-reducer.test.ts | 51 ++ packages/app/src/context/prompt.tsx | 19 +- packages/app/src/context/server-sdk.test.ts | 94 ++- packages/app/src/context/server-sdk.tsx | 45 +- .../app/src/context/server-session.test.ts | 52 ++ packages/app/src/context/server-session.ts | 35 +- packages/app/src/context/server-sync.test.ts | 69 +- packages/app/src/context/server-sync.tsx | 50 +- packages/app/src/i18n/ar.ts | 6 + packages/app/src/i18n/br.ts | 6 + packages/app/src/i18n/bs.ts | 6 + packages/app/src/i18n/da.ts | 6 + packages/app/src/i18n/de.ts | 6 + packages/app/src/i18n/en.ts | 6 + packages/app/src/i18n/es.ts | 6 + packages/app/src/i18n/fr.ts | 6 + packages/app/src/i18n/ja.ts | 6 + packages/app/src/i18n/ko.ts | 6 + packages/app/src/i18n/no.ts | 6 + packages/app/src/i18n/pl.ts | 6 + packages/app/src/i18n/ru.ts | 6 + packages/app/src/i18n/th.ts | 6 + packages/app/src/i18n/tr.ts | 6 + packages/app/src/i18n/uk.ts | 6 + packages/app/src/i18n/zh.ts | 6 + packages/app/src/i18n/zht.ts | 6 + packages/app/src/index.css | 5 + .../pages/home/home-sessions-controller.tsx | 2 + packages/app/src/pages/layout.tsx | 2 + packages/app/src/pages/new-session.tsx | 47 +- packages/app/src/pages/session.tsx | 42 +- .../composer/session-composer-controls.ts | 2 +- .../session/timeline/message-timeline.tsx | 417 +++++++++++- .../pages/session/timeline/projection.test.ts | 19 +- .../src/pages/session/timeline/projection.ts | 12 +- .../session/timeline/row-reconciliation.ts | 5 + .../session/timeline/rows-current.test.ts | 32 + .../app/src/pages/session/timeline/rows.ts | 16 +- .../pages/session/timeline/timeline-row.ts | 8 + .../pages/session/use-session-commands.tsx | 17 +- .../app/src/utils/workspace-operation.test.ts | 39 ++ packages/app/src/utils/workspace-operation.ts | 74 +++ .../app/src/utils/workspace-request.test.ts | 36 ++ packages/app/src/utils/workspace-request.ts | 25 + packages/app/src/utils/workspace.test.ts | 35 ++ packages/app/src/utils/workspace.ts | 25 + 52 files changed, 2807 insertions(+), 410 deletions(-) create mode 100644 packages/app/e2e/regression/workspaces.spec.ts create mode 100644 packages/app/src/components/session-workspace-menu.tsx create mode 100644 packages/app/src/utils/workspace-operation.test.ts create mode 100644 packages/app/src/utils/workspace-operation.ts create mode 100644 packages/app/src/utils/workspace-request.test.ts create mode 100644 packages/app/src/utils/workspace-request.ts create mode 100644 packages/app/src/utils/workspace.test.ts create mode 100644 packages/app/src/utils/workspace.ts diff --git a/packages/app/e2e/regression/workspaces.spec.ts b/packages/app/e2e/regression/workspaces.spec.ts new file mode 100644 index 00000000000..4cac32fc6a6 --- /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 18dd727084e..122ae8c0142 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 b3201b3ef68..ef352ed8ec0 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 051bf4d06cb..6c3f47a4cd9 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() - if (isNewSession) { - if (worktreeSelection === "create") { - const createdWorktree = await client.worktree - .create({ directory: projectDirectory }) - .then((x) => x.data) - .catch((err) => { + 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 (!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 (!session) { + showToast({ + title: language.t("prompt.toast.promptSendFailed.title"), + description: language.t("prompt.toast.promptSendFailed.description"), + }) + return } - if (worktreeSelection !== "main" && worktreeSelection !== "create") { - sessionDirectory = worktreeSelection + 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, } - if (sessionDirectory !== projectDirectory) { - client = sdk().createClient({ - directory: sessionDirectory, - throwOnError: true, - }) - serverSync().child(sessionDirectory) + const clearInput = () => { + submission.clear() + input.setMode("normal") + input.setPopover(null) } - input.onNewSessionWorktreeReset?.() - } - - 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, - } + if (!isNewSession && mode === "normal" && input.shouldQueue?.()) { + input.onQueue?.(draft) + clearContext(submission.target()) + clearInput() + return + } - const clearInput = () => { - submission.clear() - input.setMode("normal") - input.setPopover(null) - } + 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) + } - 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 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) + } - if (!isNewSession && mode === "normal" && input.shouldQueue?.()) { - input.onQueue?.(draft) - clearContext(submission.target()) - clearInput() - return - } + if (sessionDirectory === projectDirectory) { + submissionSync.set("session_status", session.id, { type: "busy" }) + } - input.onSubmit?.() - - 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), - }) + + 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 }, + ) + }) + + 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 eventID = Event.ID.create() + startWorkspaceOperation(eventID) + void waitForWorktree(() => { restoreInput() }) - 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) { - 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, - })), + .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() - - const waitForWorktree = async () => { - const worktree = WorktreeState.get(sdk().scope, sessionDirectory) - if (!worktree || worktree.status !== "pending") return true - - if (sessionDirectory === projectDirectory) { - sync().set("session_status", session.id, { type: "busy" }) + 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 controller = new AbortController() + const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim()) + const messageID = Identifier.ascending("message") + startWorkspaceOperation(messageID) + + const removeOptimisticMessage = () => { + submissionSync.session.optimistic.remove({ + directory: sessionDirectory, + sessionID: session.id, + messageID, + }) + } + + 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 }, - ) + showToast({ + title: language.t("prompt.toast.promptSendFailed.title"), + description: errorMessage(err), + }) + removeOptimisticMessage() + if (restoreInput()) restoreCommentItems(submission.target(), commentItems) }) - - 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) - }) - 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 a7a35374341..bb36eb9ea81 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 00000000000..ed83d2e0369 --- /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 b53fb691b3c..9b2a46bb94f 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 41a99f3b67e..72b6628085f 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 57e1cd86f3a..35c47cdcb36 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 7dd2a6e59ed..146ad95bc59 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 1e1046fb76f..67cc893a06e 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 e8f91cda3f6..fc468dcf489 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 9f625c94324..a3c44679792 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 109a7bf7d65..6eb1e41496b 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 320738a6b02..15abb946e95 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 9d6765ec94e..0d496aea2c9 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 915313c3e2b..22c9ee7cedb 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 22fcdf39ceb..4fd86c1c9b8 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 bc2ac9be381..56e743281a1 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 f03147f151d..1e12f3b2bbd 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 00dca6704b2..bb7474408ef 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 d1487ff61ef..470b6fcd283 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 6a3b1ebcb02..f38c10ef036 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 3c262ce3b93..a28915b77cb 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 a4e8efb79d2..6df07e139f7 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 f2e32ffeb87..15a65c722ba 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 fa6fb04d04e..e7a6996f459 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 0ae67ee34eb..c41422d6487 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 d401519af9c..d505ec89d8a 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 c949f41de2d..f7aa5669d54 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 82d73ac3a6a..42d097a5bf1 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 fbe80d95eef..60bd3193a3d 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 78a9e103a7f..495fe7490b6 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 06d86c30c9e..ee88c94add8 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 59474423184..0cc8e898cdb 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 d97f5779ec1..a07f5cdc4b5 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 d4dd6ff7efb..22b2d914e81 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 4ae7827e210..c2c35dd5c6a 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 48b432667e1..4fedbfe7af5 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 ( -
+
+ + + + + } + > + + + + } + > + + + + + + + + +
+

+ {language.t("workspace.onboarding.description")} +

+
+
+
diff --git a/packages/app/src/pages/new-session/new-session-workspace-controller.ts b/packages/app/src/pages/new-session/new-session-workspace-controller.ts index ab7bc61a368..efcda27b6ac 100644 --- a/packages/app/src/pages/new-session/new-session-workspace-controller.ts +++ b/packages/app/src/pages/new-session/new-session-workspace-controller.ts @@ -5,7 +5,7 @@ import { useServerSync } from "@/context/server-sync" import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" import { pathKey } from "@/utils/path-key" -import { isWorkspaceSelection, workspaceDefaultSelection } from "@/utils/workspace" +import { isWorkspaceSelection, workspaceDefaultSelection, workspaceDirectories } from "@/utils/workspace" export function resolveNewSessionWorktree(input: { enabled: boolean @@ -96,7 +96,10 @@ export function createNewSessionWorkspaceController(input: { }, project: { root: projectRoot, - workspaces: () => sync().project?.sandboxes ?? [], + workspaces: () => { + const project = sync().project + return project ? workspaceDirectories(project) : [] + }, git: () => sync().project?.vcs === "git", openAll: input.onViewAll, }, diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 4ebc314f419..528481b4d87 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -271,8 +271,8 @@ function WorkspaceMoveAction(props: {
@@ -286,16 +286,20 @@ function WorkspaceMoveAction(props: { gutter={inline() ? 4 : -2} class={ inline() - ? "flex h-6 w-full items-center gap-1.5 rounded-[4px] px-1.5 pr-7 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed" - : "flex h-[46px] w-full items-center gap-1.5 rounded-b-[6px] px-3 pr-9 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed" + ? "flex h-5 w-full items-center gap-1.5 rounded-[4px] pr-6 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed" + : "flex h-[46px] w-full items-center gap-1.5 rounded-b-[6px] px-3 pr-9 pt-[10px] text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03" } > - + {language.t("workspace.move.title")}
diff --git a/packages/app/src/utils/workspace.test.ts b/packages/app/src/utils/workspace.test.ts index 9025569f823..8c926fe53bd 100644 --- a/packages/app/src/utils/workspace.test.ts +++ b/packages/app/src/utils/workspace.test.ts @@ -11,6 +11,7 @@ import { runWorkspaceDeleteTransaction, sessionsForWorkspace, workspaceDefaultSelection, + workspaceDirectories, workspaceInventory, } from "./workspace" @@ -73,7 +74,7 @@ describe("isProjectDirectory", () => { test("groups and filters workspace inventory by project", () => { const inventory = workspaceInventory([ - { id: "a", worktree: "/a", sandboxes: ["/a/one", "/a/two"] }, + { id: "a", worktree: "/a", sandboxes: ["/a", "/a/one", "/a/two"] }, { id: "b", worktree: "/b", sandboxes: ["/b/one"] }, ]) @@ -86,6 +87,12 @@ test("groups and filters workspace inventory by project", () => { expect(filterWorkspaceInventory(inventory, "all")).toEqual(inventory) }) +test("excludes the project checkout from workspace directories", () => { + expect(workspaceDirectories({ worktree: "C:\\repo", sandboxes: ["c:\\repo\\", "C:\\workspaces\\one"] })).toEqual([ + "C:\\workspaces\\one", + ]) +}) + test("deletes all workspaces sequentially", async () => { const calls: string[] = [] let release = () => {} @@ -131,6 +138,21 @@ test("blocks unsafe workspace deletion", () => { status: "clean", }), ).toBe("linked") + expect( + inspectWorkspaceDeletion({ + workspace: "/workspace", + activeDirectory: "/workspace/app", + sessions: [], + status: "dirty", + }), + ).toBe("active") + expect( + inspectWorkspaceDeletion({ + workspace: "/workspace", + sessions: [session("/workspace/packages/app")], + status: "dirty", + }), + ).toBe("linked") expect(inspectWorkspaceDeletion({ workspace: "/workspace", sessions: [], status: "dirty" })).toBe("dirty") expect(inspectWorkspaceDeletion({ workspace: "/workspace", sessions: [], status: "unknown" })).toBe("unknown") expect(inspectWorkspaceDeletion({ workspace: "/workspace", sessions: [], status: "clean" })).toBe("safe") diff --git a/packages/app/src/utils/workspace.ts b/packages/app/src/utils/workspace.ts index 7ab5950ee96..38c5890ba1b 100644 --- a/packages/app/src/utils/workspace.ts +++ b/packages/app/src/utils/workspace.ts @@ -4,8 +4,14 @@ import type { Session } from "@opencode-ai/sdk/v2/client" type WorkspaceProject = { worktree: string; sandboxes?: readonly string[] } +export function workspaceDirectories(project: WorkspaceProject) { + return (project.sandboxes ?? []).filter( + (directory) => !containsDirectory(project.worktree, directory) || !containsDirectory(directory, project.worktree), + ) +} + export function workspaceInventory(projects: readonly T[]) { - return projects.flatMap((project) => (project.sandboxes ?? []).map((directory) => ({ directory, project }))) + return projects.flatMap((project) => workspaceDirectories(project).map((directory) => ({ directory, project }))) } export function filterWorkspaceInventory( @@ -66,16 +72,16 @@ export function inspectWorkspaceDeletion(input: { sessions: readonly Session[] status: "clean" | "dirty" | "unknown" }): WorkspaceDeleteInspection { - if (input.status === "unknown") return "unknown" - if (input.status === "dirty") return "dirty" if (input.activeDirectory && containsDirectory(input.workspace, input.activeDirectory)) return "active" if (input.sessions.some((session) => containsDirectory(input.workspace, session.directory))) return "linked" + if (input.status === "unknown") return "unknown" + if (input.status === "dirty") return "dirty" return "safe" } export function isWorkspaceDirectory(project: WorkspaceProject | undefined, directory: string) { if (!project || containsDirectory(project.worktree, directory)) return false - return project.sandboxes?.some((workspace) => containsDirectory(workspace, directory)) ?? false + return workspaceDirectories(project).some((workspace) => containsDirectory(workspace, directory)) } export function isProjectDirectory(project: WorkspaceProject | undefined, directory: string) { diff --git a/packages/ui/src/v2/components/dialog-v2.css b/packages/ui/src/v2/components/dialog-v2.css index b74d2723972..afa3d5b606a 100644 --- a/packages/ui/src/v2/components/dialog-v2.css +++ b/packages/ui/src/v2/components/dialog-v2.css @@ -93,6 +93,9 @@ [data-slot="dialog-description"] { flex: none; flex-grow: 0; + min-width: 0; + max-width: 100%; + overflow-wrap: anywhere; user-select: none; font-weight: 440; font-size: 13px; From bbb1a19ed639bd2de95c1a0734fb4558a5161fbc Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 6 Aug 2026 08:02:02 +1000 Subject: [PATCH 05/22] fix(app): preserve timeline content width --- packages/app/src/pages/session/timeline/message-timeline.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 8f961525206..a7fe95ee206 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -1336,8 +1336,7 @@ export function MessageTimeline(props: { data-timeline-row={input.row()._tag} classList={{ "min-w-0 w-full max-w-full": true, - "md:max-w-[720px]": props.centered && settings.general.newLayoutDesigns(), - "md:max-w-200 2xl:max-w-[1000px]": props.centered && !settings.general.newLayoutDesigns(), + "md:max-w-200 2xl:max-w-[1000px]": props.centered, "md:mx-auto": props.centered, "pt-3": previousAssistantPart(), }} From a16dd3d4edfe189961cc8f8bc30abe28931631ac Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 6 Aug 2026 08:21:10 +1000 Subject: [PATCH 06/22] fix(app): disable legacy file tree in new layout --- packages/app/src/components/settings-v2/general.tsx | 12 ------------ packages/app/src/context/settings.tsx | 7 ++++++- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index d082c011227..8e131f3a41d 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -420,18 +420,6 @@ export const SettingsGeneralV2: Component<{

{language.t("settings.general.section.advanced")}

- -
- settings.general.setShowFileTree(checked)} - /> -
-
- { + if (!ready() || !newLayoutDesigns() || store.general?.showFileTree !== true) return + setStore("general", "showFileTree", false) + }) + return { ready, get current() { @@ -465,7 +470,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont }, }, visibility: { - fileTree: visible(showFileTree), + fileTree: createMemo(() => !newLayoutDesigns()), search: visible(showSearch), status: visible(showStatus), customAgents: visible(showCustomAgents), From 6c1aabd5e0da49b75c69a54fba26e617a2dfbf15 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 6 Aug 2026 08:21:11 +1000 Subject: [PATCH 07/22] fix(app): align workspace move menu styling --- .../app/src/components/session-workspace-menu.tsx | 9 +++++---- packages/app/src/i18n/en.ts | 1 + .../src/pages/session/timeline/message-timeline.tsx | 12 +++++++----- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/app/src/components/session-workspace-menu.tsx b/packages/app/src/components/session-workspace-menu.tsx index eac8687833a..6c35e06941e 100644 --- a/packages/app/src/components/session-workspace-menu.tsx +++ b/packages/app/src/components/session-workspace-menu.tsx @@ -28,6 +28,7 @@ export function SessionWorkspaceMenu(props: { placement?: ComponentProps["placement"] gutter?: number class?: string + contentClass?: string children: JSX.Element onOpenChange?: (open: boolean) => void }) { @@ -118,19 +119,19 @@ export function SessionWorkspaceMenu(props: { {props.children} - + - {language.t("workspace.move.title")} + {language.t("workspace.move.menu.title")} void move("create")}> {language.t("workspace.new")} 0}> - + - + {language.t("session.new.workspace.existing").replace(/…$/, "")} diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 6f487e60fde..ac845a88b40 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -1141,6 +1141,7 @@ export const dict = { "settings.workspaces.default.local": "Local directory", "settings.workspaces.default.new": "New workspace", "workspace.move.title": "Move to workspace", + "workspace.move.menu.title": "Move session to", "workspace.move.failed": "Failed to move session", "workspace.lifecycle.creating": "Creating workspace", "workspace.lifecycle.created": "Workspace created", diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index a7fe95ee206..8e12d7af507 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -269,7 +269,8 @@ function WorkspaceMoveAction(props: { classList={{ "group/workspace-move relative shrink-0": true, "ml-auto h-5 w-[167px]": inline(), - "h-[46px] w-full rounded-b-[6px] bg-v2-background-bg-layer-02 hover:bg-v2-background-bg-layer-03": !inline(), + "-mt-2.5 h-[46px] w-full rounded-b-[6px] bg-v2-background-bg-layer-02 hover:bg-v2-background-bg-layer-03": + !inline(), invisible: props.dismissed, }} > @@ -280,7 +281,8 @@ function WorkspaceMoveAction(props: { directory={props.directory} messageID={props.messageID} placement={inline() ? "bottom-end" : "left-start"} - gutter={inline() ? 4 : -2} + gutter={inline() ? 4 : -22} + contentClass={inline() ? undefined : "relative top-3.5"} class={ inline() ? "flex h-5 w-full items-center gap-1.5 rounded-[4px] pr-6 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed" @@ -332,9 +334,9 @@ function SessionSummaryPanel(props: { return (
-
+
From d985fa4307055f6889f24188a5f903a2b8b597e9 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 6 Aug 2026 08:48:55 +1000 Subject: [PATCH 08/22] fix(app): anchor workspace onboarding flow --- .../app/e2e/regression/workspaces.spec.ts | 20 +++++++++++++ .../components/prompt-workspace-selector.tsx | 25 +++++++++++++++-- .../pages/new-session/new-session-view.tsx | 28 +++---------------- 3 files changed, 47 insertions(+), 26 deletions(-) diff --git a/packages/app/e2e/regression/workspaces.spec.ts b/packages/app/e2e/regression/workspaces.spec.ts index c853a579028..57f04f2f495 100644 --- a/packages/app/e2e/regression/workspaces.spec.ts +++ b/packages/app/e2e/regression/workspaces.spec.ts @@ -78,17 +78,37 @@ test("selects local, new, and existing workspaces from the ready-ish start menu" await expectAppVisible(page.locator('[data-component="prompt-input"]')) const trigger = page.getByRole("button", { name: /^local$/i }) + const intro = page.locator('[data-component="workspace-onboarding-card"]') + const dot = page.locator('[data-slot="workspace-onboarding-dot"]') await expect(trigger).toBeVisible() + await expect(intro).toContainText("Isolate sessions with workspaces") + await expect(dot).toBeVisible() + await expect + .poll(async () => { + const [triggerBox, introBox] = await Promise.all([trigger.boundingBox(), intro.boundingBox()]) + if (!triggerBox || !introBox) return + return { + right: Math.round(introBox.x + introBox.width - triggerBox.x - triggerBox.width), + top: Math.round(introBox.y - triggerBox.y - triggerBox.height), + } + }) + .toEqual({ right: 0, top: 0 }) await trigger.hover() await expect(page.getByRole("tooltip")).toContainText("Select where to run session") await trigger.click() + await expect(intro).toBeHidden() await expect(page.getByRole("menuitem", { name: /Local repository/ })).toBeVisible() + await page.getByRole("menuitem", { name: /Local repository/ }).click() + await expect(dot).toBeVisible() + + await trigger.click() const newWorkspace = page.getByRole("menuitem", { name: /New workspace/ }) await expect(newWorkspace).toBeVisible() await expect(page.getByRole("menuitem", { name: /Workspace/ })).toBeVisible() await expect(page.getByRole("menuitem", { name: "View all" })).toBeVisible() await newWorkspace.click() + await expect(dot).toBeHidden() await expect(page.getByRole("button", { name: /New workspace/ })).toBeVisible() await expect(page.getByText("from main", { exact: true })).toBeVisible() diff --git a/packages/app/src/components/prompt-workspace-selector.tsx b/packages/app/src/components/prompt-workspace-selector.tsx index cad04aaa0c0..9b8a87cd4b3 100644 --- a/packages/app/src/components/prompt-workspace-selector.tsx +++ b/packages/app/src/components/prompt-workspace-selector.tsx @@ -11,6 +11,8 @@ export function PromptWorkspaceSelector(props: { workspaces: string[] branch?: string onboarding?: boolean + intro?: boolean + onIntroDismiss?: () => void onChange: (value: string) => void onDone: () => void onViewAll: () => void @@ -37,6 +39,7 @@ export function PromptWorkspaceSelector(props: { const onOpenChange = (open: boolean) => { if (open) { setSearch("") + props.onIntroDismiss?.() return } const action = pending @@ -61,7 +64,7 @@ export function PromptWorkspaceSelector(props: { placement="top" openDelay={800} value={language.t("session.new.workspace.trigger.tooltip")} - class="min-w-0" + class="relative min-w-0" > {label()} - @@ -203,6 +210,20 @@ export function PromptWorkspaceSelector(props: { + +
+
+ + {language.t("workspace.onboarding.title")} +
+

+ {language.t("workspace.onboarding.description")} +

+
+
diff --git a/packages/app/src/pages/new-session/new-session-view.tsx b/packages/app/src/pages/new-session/new-session-view.tsx index 2668f49186e..e66bb17d634 100644 --- a/packages/app/src/pages/new-session/new-session-view.tsx +++ b/packages/app/src/pages/new-session/new-session-view.tsx @@ -31,7 +31,6 @@ export function NewSessionView(props: { project: PromptProjectController workspace: NewSessionWorkspaceController }) { - const language = useLanguage() const [onboarding, setOnboarding, , onboardingReady] = persisted( Persist.global("workspace-onboarding"), createStore({ cardDismissed: false, used: false }), @@ -41,7 +40,7 @@ export function NewSessionView(props: { ) const select = (value: string) => { props.workspace.selection.set(value) - setOnboarding("used", true) + if (value !== "main") setOnboarding("used", true) } return ( @@ -72,7 +71,9 @@ export function NewSessionView(props: { projectRoot={props.workspace.project.root()} workspaces={props.workspace.project.workspaces()} branch={props.workspace.bar.branch()} - onboarding={onboarding.cardDismissed && !onboarding.used} + onboarding={onboardingReady() && !onboarding.used} + intro={showCard()} + onIntroDismiss={() => setOnboarding("cardDismissed", true)} onChange={select} onDone={props.input.restoreFocus} onViewAll={props.workspace.project.openAll} @@ -83,27 +84,6 @@ export function NewSessionView(props: {
- -
-
-
- - {language.t("workspace.onboarding.title")} - -
-

- {language.t("workspace.onboarding.description")} -

-
-
-
From c40aecbdbe6c577a9604a1fa362a37c5e4b80029 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:26:57 +1000 Subject: [PATCH 09/22] fix(app): restore timeline content inset --- packages/app/src/pages/session/timeline/message-timeline.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 8e12d7af507..3ee7aaa7341 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -511,7 +511,7 @@ export function MessageTimeline(props: { setWorkspaceSuggestionDismissed(false) }), ) - const turnPadding = () => (settings.general.newLayoutDesigns() ? "px-3" : "px-4 md:px-5") + const turnPadding = () => "px-4 md:px-5" const workspaceOperation = createMemo(() => { const id = sessionID() if (!id) return From a597364516dd257d8522beafc4da8554fb500877 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:26:02 +1000 Subject: [PATCH 10/22] fix(app): align workspace interactions and styling with designs --- .../app/e2e/regression/workspaces.spec.ts | 23 ++++-- .../components/prompt-workspace-selector.tsx | 68 +++++++++-------- .../src/components/session-workspace-menu.tsx | 47 +++++++----- .../components/settings-v2/settings-v2.css | 22 ++++-- .../src/components/settings-v2/workspaces.tsx | 27 +++++-- .../session/timeline/message-timeline.tsx | 76 +++++++++---------- packages/ui/src/v2/components/icon.tsx | 4 + 7 files changed, 154 insertions(+), 113 deletions(-) diff --git a/packages/app/e2e/regression/workspaces.spec.ts b/packages/app/e2e/regression/workspaces.spec.ts index 57f04f2f495..92bb667552a 100644 --- a/packages/app/e2e/regression/workspaces.spec.ts +++ b/packages/app/e2e/regression/workspaces.spec.ts @@ -104,7 +104,9 @@ test("selects local, new, and existing workspaces from the ready-ish start menu" await trigger.click() const newWorkspace = page.getByRole("menuitem", { name: /New workspace/ }) await expect(newWorkspace).toBeVisible() - await expect(page.getByRole("menuitem", { name: /Workspace/ })).toBeVisible() + const workspaceTrigger = page.getByRole("menuitem", { name: /Workspace/ }) + await expect(workspaceTrigger).toBeVisible() + await workspaceTrigger.hover() await expect(page.getByRole("menuitem", { name: "View all" })).toBeVisible() await newWorkspace.click() @@ -133,7 +135,7 @@ test("searches long workspace lists within the available viewport", async ({ pag await page.goto(`/new-session?draftId=${draftID}`) await expectAppVisible(page.locator('[data-component="prompt-input"]')) - await page.getByRole("button", { name: /^local$/i }).click() + await page.getByRole("button", { name: /local|new workspace/i }).click() await page.getByRole("menuitem", { name: /Workspace/ }).focus() await page.keyboard.press("ArrowRight") @@ -203,7 +205,9 @@ test("lists and manually deletes workspaces from settings", async ({ page }) => await page.goto(`/new-session?draftId=${draftID}`) await expectAppVisible(page.locator('[data-component="prompt-input"]')) - await page.getByRole("button", { name: /^local$/i }).click() + await page.getByRole("button", { name: /local|new workspace/i }).click() + const workspacesTrigger = page.getByRole("menuitem", { name: /Workspace/ }) + if (await workspacesTrigger.isVisible()) await workspacesTrigger.hover() await page.getByRole("menuitem", { name: "View all" }).click() const settings = page.locator(".settings-v2-dialog") @@ -215,7 +219,8 @@ test("lists and manually deletes workspaces from settings", async ({ page }) => const firstSessionInventory = sessionListRequests await page.keyboard.press("Escape") await expect(settings).toHaveCount(0) - await page.getByRole("button", { name: /^local$/i }).click() + await page.getByRole("button", { name: /local|new workspace/i }).click() + if (await workspacesTrigger.isVisible()) await workspacesTrigger.hover() await page.getByRole("menuitem", { name: "View all" }).click() await expect(settings.getByText("Workspace settings session", { exact: true })).toBeVisible() expect(sessionListRequests).toBeGreaterThan(firstSessionInventory) @@ -273,6 +278,8 @@ test("blocks deletion of the currently active workspace", async ({ page }) => { await page.goto(`/new-session?draftId=${draftID}`) await page.getByRole("button", { name: /feature/ }).click() + const workspacesTrigger = page.getByRole("menuitem", { name: /Workspace/ }) + if (await workspacesTrigger.isVisible()) await workspacesTrigger.hover() await page.getByRole("menuitem", { name: "View all" }).click() const settings = page.locator(".settings-v2-dialog") await settings.getByRole("button", { name: 'Delete workspace "feature"?' }).click() @@ -313,7 +320,9 @@ test("wraps the workspace toolbar for long project filters on mobile", async ({ await page.goto(`/new-session?draftId=${draftID}`) const dismissTabs = page.getByRole("button", { name: "Dismiss Tabs information" }) if (await dismissTabs.isVisible()) await dismissTabs.click() - await page.getByRole("button", { name: /^local$/i }).click() + await page.getByRole("button", { name: /local|new workspace/i }).click() + const workspacesTrigger = page.getByRole("menuitem", { name: /Workspace/ }) + if (await workspacesTrigger.isVisible()) await workspacesTrigger.hover() await page.getByRole("menuitem", { name: "View all" }).click() const settings = page.locator(".settings-v2-dialog") await expect(settings.getByRole("button", { name: "All projects" })).toBeVisible() @@ -389,7 +398,9 @@ test("bulk deletion snapshots inventory and skips dirty or unknown workspaces", await page.goto(`/new-session?draftId=${draftID}`) await transport.waitForConnection() - await page.getByRole("button", { name: /^local$/i }).click() + await page.getByRole("button", { name: /local|new workspace/i }).click() + const workspacesTrigger = page.getByRole("menuitem", { name: /Workspace/ }) + if (await workspacesTrigger.isVisible()) await workspacesTrigger.hover() await page.getByRole("menuitem", { name: "View all" }).click() const settings = page.locator(".settings-v2-dialog") await settings.getByRole("button", { name: "More options" }).click() diff --git a/packages/app/src/components/prompt-workspace-selector.tsx b/packages/app/src/components/prompt-workspace-selector.tsx index 9b8a87cd4b3..55748e04238 100644 --- a/packages/app/src/components/prompt-workspace-selector.tsx +++ b/packages/app/src/components/prompt-workspace-selector.tsx @@ -129,7 +129,17 @@ export function PromptWorkspaceSelector(props: { - 0}> + 0} + fallback={ + <> + + (pending = { type: "viewAll" })}> + {language.t("common.viewAll")} + + + } + > )} + + (pending = { type: "viewAll" })}> + {language.t("common.viewAll")} + - - (pending = { type: "viewAll" })}> - {language.t("common.viewAll")} - @@ -225,12 +235,12 @@ export function PromptWorkspaceSelector(props: {
- + ) } -export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?: boolean; connected?: boolean }) { +export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?: boolean }) { const language = useLanguage() const label = () => { if (props.noGit) return language.t("session.new.git.none") @@ -239,32 +249,30 @@ export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from? return props.branch } + const icon = () => { + if (props.noGit) return "monitor" + if (props.from) return "branch-out" + return "branch" + } + return ( {(value) => ( - <> - - - - -
- - {value()} -
-
- + +
+ + {value()} +
+
)}
) diff --git a/packages/app/src/components/session-workspace-menu.tsx b/packages/app/src/components/session-workspace-menu.tsx index 6c35e06941e..26119fa2156 100644 --- a/packages/app/src/components/session-workspace-menu.tsx +++ b/packages/app/src/components/session-workspace-menu.tsx @@ -7,6 +7,7 @@ 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 { useSettingsDialog } from "@/components/settings-dialog" import { pathKey } from "@/utils/path-key" import { Worktree } from "@/utils/worktree" import { WorkspaceOperation } from "@/utils/workspace-operation" @@ -35,6 +36,7 @@ export function SessionWorkspaceMenu(props: { const language = useLanguage() const serverSDK = useServerSDK() const serverSync = useServerSync() + const openWorkspaces = useSettingsDialog("workspaces") const [store, setStore] = createStore({ selected: undefined as string | undefined }) const operationPending = () => WorkspaceOperation.get(serverSDK().scope, props.sessionID)?.status === "pending" const blocked = () => @@ -126,28 +128,31 @@ export function SessionWorkspaceMenu(props: { {language.t("workspace.new")} + 0}> + + + + {language.t("session.new.workspace.existing").replace(/…$/, "")} + + + + + {(workspace) => ( + void move(workspace)}> + + {getFilename(workspace)} + + )} + + + + + - 0}> - - - - - {language.t("session.new.workspace.existing").replace(/…$/, "")} - - - - - {(workspace) => ( - void move(workspace)}> - - {getFilename(workspace)} - - )} - - - - - + + openWorkspaces()}> + {language.t("common.viewAll")} + diff --git a/packages/app/src/components/settings-v2/settings-v2.css b/packages/app/src/components/settings-v2/settings-v2.css index 2ab8e0a6625..db4e98c078e 100644 --- a/packages/app/src/components/settings-v2/settings-v2.css +++ b/packages/app/src/components/settings-v2/settings-v2.css @@ -784,8 +784,11 @@ .settings-v2-workspaces-sessions { display: flex; flex-direction: column; - gap: 6px; - padding-top: 4px; + border: 0.5px solid var(--v2-border-border-base); + border-radius: 4px; + background-color: var(--v2-background-bg-base); + overflow: hidden; + margin-top: 4px; } .settings-v2-workspaces-session { @@ -794,26 +797,29 @@ align-items: center; justify-content: space-between; gap: 12px; - overflow: hidden; + padding: 8px 12px; font-size: 13px; font-weight: 440; line-height: 16px; color: var(--v2-text-text-base); - text-overflow: ellipsis; - white-space: nowrap; +} + +.settings-v2-workspaces-session:not(:last-child) { + border-bottom: 0.5px solid var(--v2-border-border-base); } .settings-v2-workspaces-session > span:first-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; } -.settings-v2-workspaces-session-active { +.settings-v2-workspaces-session-time { flex-shrink: 0; - color: var(--v2-text-text-muted); font-size: 11px; - line-height: 14px; + line-height: 1; + color: var(--v2-text-text-muted); } .settings-v2-workspaces-empty { diff --git a/packages/app/src/components/settings-v2/workspaces.tsx b/packages/app/src/components/settings-v2/workspaces.tsx index ecc904673db..d1771ed87b5 100644 --- a/packages/app/src/components/settings-v2/workspaces.tsx +++ b/packages/app/src/components/settings-v2/workspaces.tsx @@ -107,6 +107,10 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr if (!updated) return undefined return getRelativeTime(new Date(updated).toISOString(), language.t) } + const sessionTime = (session: Session) => { + if (!session.time.updated) return undefined + return getRelativeTime(new Date(session.time.updated).toISOString(), language.t) + } const inspect = async (workspace: Workspace, context = captureDeleteContext()) => { const [status, sessions] = await Promise.all([ @@ -331,22 +335,29 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr - {(value) => {value()}} + {(value) => ( + + + {value()} + + + )}
{sessionCount(workspace)} 0}>
- {(session, index) => ( - + {(session) => ( +
{session.title} - - - {language.t("settings.workspaces.lastActiveSession")} - + + {(time) => {time()}} - +
)}
diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 3ee7aaa7341..24fbcce2aac 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -269,7 +269,7 @@ function WorkspaceMoveAction(props: { classList={{ "group/workspace-move relative shrink-0": true, "ml-auto h-5 w-[167px]": inline(), - "-mt-2.5 h-[46px] w-full rounded-b-[6px] bg-v2-background-bg-layer-02 hover:bg-v2-background-bg-layer-03": + "-mt-2.5 h-[46px] w-full rounded-b-[6px] bg-v2-background-bg-layer-02 hover:bg-v2-background-bg-layer-03 transition-colors": !inline(), invisible: props.dismissed, }} @@ -286,10 +286,10 @@ function WorkspaceMoveAction(props: { class={ inline() ? "flex h-5 w-full items-center gap-1.5 rounded-[4px] pr-6 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed" - : "flex h-[46px] w-full items-center gap-1.5 rounded-b-[6px] px-3 pr-9 pt-[10px] text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03" + : "flex h-[46px] w-full items-center gap-2 rounded-b-[6px] px-3 pr-9 pt-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted focus-visible:outline-none" } > - + {language.t("workspace.move.title")}
- - - {location()} -
- } + - - - {location()} - - - + + {location()} + +
-
- - }> - + + +
+ } + > +
- - - - - {directory()} - - + {status()} - + + · + + + {directory()} + + +
-
diff --git a/packages/ui/src/v2/components/icon.tsx b/packages/ui/src/v2/components/icon.tsx index b9f35037d57..2b933bcabd1 100644 --- a/packages/ui/src/v2/components/icon.tsx +++ b/packages/ui/src/v2/components/icon.tsx @@ -18,6 +18,10 @@ const icons = { viewBox: "0 0 16 16", body: ``, }, + "branch-out": { + viewBox: "0 0 16 16", + body: ``, + }, "grid-plus": { viewBox: "0 0 16 16", body: ``, From f1476dc7d4e3d7c37c620eb51e6482aa6762c533 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:53:38 +1000 Subject: [PATCH 11/22] fix(app): show rich onboarding tooltip on location trigger hover --- .../app/e2e/regression/workspaces.spec.ts | 15 +------- .../components/prompt-workspace-selector.tsx | 36 +++++++++---------- .../pages/new-session/new-session-view.tsx | 2 -- 3 files changed, 18 insertions(+), 35 deletions(-) diff --git a/packages/app/e2e/regression/workspaces.spec.ts b/packages/app/e2e/regression/workspaces.spec.ts index 92bb667552a..10849961498 100644 --- a/packages/app/e2e/regression/workspaces.spec.ts +++ b/packages/app/e2e/regression/workspaces.spec.ts @@ -78,25 +78,12 @@ test("selects local, new, and existing workspaces from the ready-ish start menu" await expectAppVisible(page.locator('[data-component="prompt-input"]')) const trigger = page.getByRole("button", { name: /^local$/i }) - const intro = page.locator('[data-component="workspace-onboarding-card"]') const dot = page.locator('[data-slot="workspace-onboarding-dot"]') await expect(trigger).toBeVisible() - await expect(intro).toContainText("Isolate sessions with workspaces") await expect(dot).toBeVisible() - await expect - .poll(async () => { - const [triggerBox, introBox] = await Promise.all([trigger.boundingBox(), intro.boundingBox()]) - if (!triggerBox || !introBox) return - return { - right: Math.round(introBox.x + introBox.width - triggerBox.x - triggerBox.width), - top: Math.round(introBox.y - triggerBox.y - triggerBox.height), - } - }) - .toEqual({ right: 0, top: 0 }) await trigger.hover() - await expect(page.getByRole("tooltip")).toContainText("Select where to run session") + await expect(page.getByRole("tooltip")).toContainText("Isolate sessions with workspaces") await trigger.click() - await expect(intro).toBeHidden() await expect(page.getByRole("menuitem", { name: /Local repository/ })).toBeVisible() await page.getByRole("menuitem", { name: /Local repository/ }).click() await expect(dot).toBeVisible() diff --git a/packages/app/src/components/prompt-workspace-selector.tsx b/packages/app/src/components/prompt-workspace-selector.tsx index 55748e04238..8c4bb185fe7 100644 --- a/packages/app/src/components/prompt-workspace-selector.tsx +++ b/packages/app/src/components/prompt-workspace-selector.tsx @@ -11,8 +11,6 @@ export function PromptWorkspaceSelector(props: { workspaces: string[] branch?: string onboarding?: boolean - intro?: boolean - onIntroDismiss?: () => void onChange: (value: string) => void onDone: () => void onViewAll: () => void @@ -39,7 +37,6 @@ export function PromptWorkspaceSelector(props: { const onOpenChange = (open: boolean) => { if (open) { setSearch("") - props.onIntroDismiss?.() return } const action = pending @@ -63,8 +60,23 @@ export function PromptWorkspaceSelector(props: { +
+ + {language.t("workspace.onboarding.title")} +
+ + {language.t("workspace.onboarding.description")} + +
+ ) : ( + language.t("session.new.workspace.trigger.tooltip") + ) + } + contentClass={props.onboarding ? "max-w-[280px]" : undefined} + class="min-w-0" > - -
-
- - {language.t("workspace.onboarding.title")} -
-

- {language.t("workspace.onboarding.description")} -

-
-
diff --git a/packages/app/src/pages/new-session/new-session-view.tsx b/packages/app/src/pages/new-session/new-session-view.tsx index e66bb17d634..3c84b1d58ef 100644 --- a/packages/app/src/pages/new-session/new-session-view.tsx +++ b/packages/app/src/pages/new-session/new-session-view.tsx @@ -72,8 +72,6 @@ export function NewSessionView(props: { workspaces={props.workspace.project.workspaces()} branch={props.workspace.bar.branch()} onboarding={onboardingReady() && !onboarding.used} - intro={showCard()} - onIntroDismiss={() => setOnboarding("cardDismissed", true)} onChange={select} onDone={props.input.restoreFocus} onViewAll={props.workspace.project.openAll} From 9590b51a3f3a9a6da9b821313a4d43f6a3ee7ea5 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:21:22 +1000 Subject: [PATCH 12/22] fix(app): polish workspace localization and move targets --- .../src/components/prompt-workspace-selector.tsx | 4 ++-- .../app/src/components/session-workspace-menu.tsx | 8 +++++++- packages/app/src/i18n/en.ts | 3 +++ .../pages/session/timeline/message-timeline.tsx | 14 ++++++++++---- 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/packages/app/src/components/prompt-workspace-selector.tsx b/packages/app/src/components/prompt-workspace-selector.tsx index 8c4bb185fe7..ce9af107d03 100644 --- a/packages/app/src/components/prompt-workspace-selector.tsx +++ b/packages/app/src/components/prompt-workspace-selector.tsx @@ -49,7 +49,7 @@ export function PromptWorkspaceSelector(props: { props.onDone() } const label = () => { - if (selected() === "main") return language.t("workspace.type.local") + if (selected() === "main") return language.t("session.new.workspace.triggerLocal") if (props.value === "create") return language.t("workspace.new") return getFilename(props.value) } @@ -180,7 +180,7 @@ export function PromptWorkspaceSelector(props: { > - {language.t("session.new.workspace.existing").replace(/…$/, "")} + {language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")} diff --git a/packages/app/src/components/session-workspace-menu.tsx b/packages/app/src/components/session-workspace-menu.tsx index 26119fa2156..ce3e0d49153 100644 --- a/packages/app/src/components/session-workspace-menu.tsx +++ b/packages/app/src/components/session-workspace-menu.tsx @@ -124,6 +124,12 @@ export function SessionWorkspaceMenu(props: { {language.t("workspace.move.menu.title")} + + void move(props.project.worktree)}> + + {language.t("session.new.workspace.local")} + + void move("create")}> {language.t("workspace.new")} @@ -132,7 +138,7 @@ export function SessionWorkspaceMenu(props: { - {language.t("session.new.workspace.existing").replace(/…$/, "")} + {language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")} diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index ac845a88b40..4f1ee43099f 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -1150,6 +1150,9 @@ export const dict = { "workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository", "workspace.lifecycle.moving": "Moving to workspace", "workspace.lifecycle.set": "Workspace set", + "session.summary.title": "Session details", + "session.summary.noBranch": "No branch", + "session.summary.basedOn": "Based on {{branch}}", "workspace.type.local": "local", "workspace.type.sandbox": "sandbox", "workspace.create.failed.title": "Failed to create workspace", diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 24fbcce2aac..01fdea42f5c 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -364,10 +364,16 @@ function SessionSummaryPanel(props: { when={props.branch} fallback={ - No branch + {language.t("session.summary.noBranch")} - · - Based on {props.baseBranch} + {(base) => ( + <> + · + + {language.t("session.summary.basedOn", { branch: base() })} + + + )} } @@ -1893,7 +1899,7 @@ export function MessageTimeline(props: { variant="ghost-muted" size="large" state={summaryOpen() ? "pressed" : undefined} - aria-label="Session details" + aria-label={language.t("session.summary.title")} aria-expanded={summaryOpen()} /> From 627e640c69e150bb935f3e3b902254f75467b02e Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:16:34 +1000 Subject: [PATCH 13/22] fix(app): resolve parity plurals, stream lifecycle, and menu state bugs --- .../components/prompt-input/submit.test.ts | 2 + .../src/components/session-workspace-menu.tsx | 94 ++++++++++--------- .../settings-v2/dialog-settings-v2.tsx | 4 +- packages/app/src/context/server-sdk.tsx | 47 ---------- packages/app/src/i18n/ar.ts | 28 +++++- packages/app/src/i18n/az.ts | 46 +++++++++ packages/app/src/i18n/br.ts | 18 ++++ packages/app/src/i18n/bs.ts | 17 ++++ packages/app/src/i18n/da.ts | 15 +++ packages/app/src/i18n/de.ts | 17 +++- packages/app/src/i18n/es.ts | 17 ++++ packages/app/src/i18n/fi.ts | 46 +++++++++ packages/app/src/i18n/fr.ts | 17 ++++ packages/app/src/i18n/hi.ts | 46 +++++++++ packages/app/src/i18n/id.ts | 46 +++++++++ packages/app/src/i18n/it.ts | 51 ++++++++++ packages/app/src/i18n/ja.ts | 15 +++ packages/app/src/i18n/ko.ts | 15 +++ packages/app/src/i18n/nl.ts | 46 +++++++++ packages/app/src/i18n/no.ts | 17 +++- packages/app/src/i18n/pa.ts | 46 +++++++++ packages/app/src/i18n/pl.ts | 19 ++++ packages/app/src/i18n/ru.ts | 19 ++++ packages/app/src/i18n/sv.ts | 46 +++++++++ packages/app/src/i18n/th.ts | 15 +++ packages/app/src/i18n/tr.ts | 17 +++- packages/app/src/i18n/uk.ts | 19 ++++ packages/app/src/i18n/ur.ts | 46 +++++++++ packages/app/src/i18n/vi.ts | 46 +++++++++ packages/app/src/i18n/zh.ts | 17 +++- packages/app/src/i18n/zht.ts | 17 +++- .../pages/new-session/new-session-view.tsx | 11 +-- .../session/timeline/message-timeline.tsx | 4 +- packages/app/src/utils/workspace.test.ts | 13 +++ packages/app/src/utils/workspace.ts | 10 +- 35 files changed, 837 insertions(+), 112 deletions(-) diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index ef352ed8ec0..3a058a443be 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -170,6 +170,7 @@ beforeAll(async () => { mock.module("@opencode-ai/ui/toast", () => ({ Toast: { Region: () => null }, + toaster: { create: () => undefined, show: () => undefined, dismiss: () => undefined }, showToast: () => 0, })) @@ -794,6 +795,7 @@ describe("prompt submit worktree selection", () => { }) await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) + await settle() expect(sentCommands).toEqual([ { diff --git a/packages/app/src/components/session-workspace-menu.tsx b/packages/app/src/components/session-workspace-menu.tsx index ce3e0d49153..1ac2ad20cb1 100644 --- a/packages/app/src/components/session-workspace-menu.tsx +++ b/packages/app/src/components/session-workspace-menu.tsx @@ -61,53 +61,57 @@ export function SessionWorkspaceMenu(props: { 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 + try { + 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))) - } + 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")) - }) - .catch((error) => - fail(scope, sessionID, error instanceof Error ? error.message : language.t("common.requestFailed")), + 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")), + ) + } finally { + setStore("selected", undefined) + } } return ( @@ -141,7 +145,7 @@ export function SessionWorkspaceMenu(props: { {language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")} - + {(workspace) => ( void move(workspace)}> diff --git a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx index d3bc5237775..1e0443d26e6 100644 --- a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx +++ b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx @@ -1,7 +1,7 @@ import { Component, createMemo, createSignal, startTransition } from "solid-js" import { Dialog } from "@opencode-ai/ui/v2/dialog-v2" import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2" -import { Icon } from "@opencode-ai/ui/icon" +import { Icon } from "@opencode-ai/ui/v2/icon" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { SettingsGeneralV2 } from "./general" @@ -70,7 +70,7 @@ export const DialogSettings: Component<{ {language.t("settings.section.server")}
- + {language.t("settings.tab.workspaces")} diff --git a/packages/app/src/context/server-sdk.tsx b/packages/app/src/context/server-sdk.tsx index d6f2f260d3c..1a52a394214 100644 --- a/packages/app/src/context/server-sdk.tsx +++ b/packages/app/src/context/server-sdk.tsx @@ -291,57 +291,12 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS } let streamErrorLogged = false - let worktreeStreamErrorLogged = false const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) let attempt: AbortController | undefined - let worktreeAttempt: AbortController | undefined let run: Promise | undefined let started = false let generation = 0 - const consumeWorktreeEvents = async (active: number) => { - // Current worktree lifecycle events are still emitted only on the global compatibility stream. - while (!abort.signal.aborted && started && generation === active) { - const controller = new AbortController() - worktreeAttempt = controller - const onAbort = () => controller.abort() - abort.signal.addEventListener("abort", onAbort) - try { - const events = (await eventSdk.global.event({ signal: controller.signal })).stream - let yielded = Date.now() - for await (const event of events) { - const queued = adaptWorktreeCompatibilityEvent({ - directory: event.directory, - payload: event.payload as Event, - }) - if (queued) { - worktreeStreamErrorLogged = false - if (enqueueServerEvent(queue, queued)) schedule() - } - - if (Date.now() - yielded < STREAM_YIELD_MS) continue - yielded = Date.now() - await wait(0) - } - } catch (error) { - if (!isStreamClosed(error, controller.signal) && !worktreeStreamErrorLogged) { - worktreeStreamErrorLogged = true - console.error("[global-sdk] worktree event stream failed", { - url: server.http.url, - fetch: eventFetch ? "platform" : "webview", - error, - }) - } - } finally { - abort.signal.removeEventListener("abort", onAbort) - if (worktreeAttempt === controller) worktreeAttempt = undefined - } - - if (abort.signal.aborted || !started || generation !== active) return - await wait(RECONNECT_DELAY_MS) - } - } - const start = () => { if (started) return run started = true @@ -350,7 +305,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS const current = (async () => { if (previous) await previous const kind = await protocol - if (kind === "v2") void consumeWorktreeEvents(active) // oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit while (!abort.signal.aborted && started && generation === active) { attempt = new AbortController() @@ -406,7 +360,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS started = false generation++ attempt?.abort() - worktreeAttempt?.abort() } onMount(() => { diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index b7daf06cedd..30a767c8390 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -1042,10 +1042,18 @@ export const dict = { "settings.tab.workspaces": "مساحات العمل", "settings.workspaces.filter.all": "جميع المشاريع", "settings.workspaces.empty": "لا توجد مساحات عمل", + "settings.workspaces.count.zero": "لا توجد مساحات عمل ({{count}})", "settings.workspaces.count.one": "{{count}} مساحة عمل", - "settings.workspaces.count.other": "{{count}} مساحات عمل", + "settings.workspaces.count.two": "مساحتا عمل ({{count}})", + "settings.workspaces.count.few": "{{count}} مساحات عمل", + "settings.workspaces.count.many": "{{count}} مساحة عمل", + "settings.workspaces.count.other": "{{count}} مساحة عمل", + "settings.workspaces.sessions.zero": "لا توجد جلسات ({{count}}) في {{project}}", "settings.workspaces.sessions.one": "{{count}} جلسة في {{project}}", - "settings.workspaces.sessions.other": "{{count}} جلسات في {{project}}", + "settings.workspaces.sessions.two": "جلستان ({{count}}) في {{project}}", + "settings.workspaces.sessions.few": "{{count}} جلسات في {{project}}", + "settings.workspaces.sessions.many": "{{count}} جلسة في {{project}}", + "settings.workspaces.sessions.other": "{{count}} جلسة في {{project}}", "settings.workspaces.lastActiveSession": "آخر جلسة نشطة", "settings.workspaces.deleteAll": "حذف جميع مساحات العمل", "settings.workspaces.deleteAll.confirm": "حذف جميع مساحات العمل البالغ عددها {{count}}؟", @@ -1061,11 +1069,18 @@ export const dict = { "settings.workspaces.default.local": "دليل محلي", "settings.workspaces.default.new": "مساحة عمل جديدة", "workspace.move.title": "نقل إلى مساحة العمل", + "workspace.move.menu.title": "نقل الجلسة إلى", "workspace.move.failed": "تعذّر نقل الجلسة", "workspace.lifecycle.creating": "جارٍ إنشاء مساحة العمل", "workspace.lifecycle.created": "تم إنشاء مساحة العمل", + "workspace.lifecycle.starting": "جارٍ بدء الجلسة", + "workspace.onboarding.title": "عزل الجلسات باستخدام مساحات العمل", + "workspace.onboarding.description": "تحصل كل جلسة على نسختها الخاصة، حتى لا يتداخل أي شيء مع مستودعك المحلي", "workspace.lifecycle.moving": "جارٍ النقل إلى مساحة العمل", "workspace.lifecycle.set": "تم تعيين مساحة العمل", + "session.summary.title": "تفاصيل الجلسة", + "session.summary.noBranch": "لا يوجد فرع", + "session.summary.basedOn": "بناءً على {{branch}}", "workspace.type.local": "محلي", "workspace.type.sandbox": "بيئة معزولة", "workspace.create.failed.title": "فشل إنشاء مساحة العمل", @@ -1188,4 +1203,13 @@ export const dict = { "error.childStore.persistedProjectIconCreateFailed": "فشل إنشاء أيقونة المشروع الدائمة", "error.childStore.storeCreateFailed": "فشل إنشاء المخزن", "terminal.connectionLost.abnormalClose": "تم إغلاق WebSocket بشكل غير طبيعي: {{code}}", + + "command.session.export": "تصدير الجلسة", + "command.session.export.description": "تصدير بيانات الجلسة", + "context.export.session": "تصدير الجلسة", + "toast.session.export.success.title": "تم تصدير الجلسة", + "toast.session.export.success.description": "تم حفظ {{filename}} بنجاح", + "toast.session.export.failed.title": "فشل تصدير الجلسة", + "toast.session.export.failed.description": "حدث خطأ أثناء تصدير الجلسة", + "common.export": "تصدير", } diff --git a/packages/app/src/i18n/az.ts b/packages/app/src/i18n/az.ts index df3506c3d55..11f79695a85 100644 --- a/packages/app/src/i18n/az.ts +++ b/packages/app/src/i18n/az.ts @@ -1152,4 +1152,50 @@ export const dict = { "workspace.reset.archived.one": "1 sessiya arxivlənəcək.", "workspace.reset.archived.many": "{{count}} sessiya arxivlənəcək.", "workspace.reset.note": "Bu iş sahəsini standart branch ilə uyğunlaşdırmaq üçün sıfırlayacaq.", + "command.session.export": "Export session", + "command.session.export.description": "Export the full session transcript as JSON", + "context.export.session": "Export session", + "toast.session.export.success.title": "Session exported", + "toast.session.export.success.description": "Saved session to {{filename}}", + "toast.session.export.failed.title": "Failed to export session", + "toast.session.export.failed.description": "An error occurred while exporting the session", + "common.export": "Export", + "common.viewAll": "View all", + "session.new.workspace.local.tooltip": "Use current checkout", + "session.new.workspace.new.tooltip": "Create isolated checkout", + "session.new.workspace.fromBranch": "from {{branch}}", + "session.new.workspace.trigger.tooltip": "Select where to run session", + "session.new.workspace.search.placeholder": "Search workspaces", + "settings.tab.workspaces": "Workspaces", + "settings.workspaces.filter.all": "All projects", + "settings.workspaces.empty": "No workspaces", + "settings.workspaces.count.one": "{{count}} workspace", + "settings.workspaces.count.other": "{{count}} workspaces", + "settings.workspaces.sessions.one": "{{count}} session in {{project}}", + "settings.workspaces.sessions.other": "{{count}} sessions in {{project}}", + "settings.workspaces.lastActiveSession": "Last active session", + "settings.workspaces.deleteAll": "Delete all workspaces", + "settings.workspaces.deleteAll.confirm": "Delete all {{count}} workspaces?", + "settings.workspaces.delete.warning": "The workspace directory and branch will be permanently removed. Deletion proceeds only if it is clean, inactive, and has no linked sessions.", + "settings.workspaces.deleteAll.warning": "The {{count}} selected workspaces in {{project}} will be permanently removed only if each is clean, inactive, and has no linked sessions.", + "settings.workspaces.delete.blocked.active": "The active workspace cannot be deleted.", + "settings.workspaces.delete.blocked.linked": "This workspace has linked sessions and cannot be deleted.", + "settings.workspaces.default.title": "Default environment", + "settings.workspaces.default.description": "Choose where new sessions start", + "settings.workspaces.default.lastUsed": "Last used per project", + "settings.workspaces.default.local": "Local directory", + "settings.workspaces.default.new": "New workspace", + "workspace.move.title": "Move to workspace", + "workspace.move.menu.title": "Move session to", + "workspace.move.failed": "Failed to move session", + "workspace.lifecycle.creating": "Creating workspace", + "workspace.lifecycle.created": "Workspace created", + "workspace.lifecycle.starting": "Starting session", + "workspace.onboarding.title": "Isolate sessions with workspaces", + "workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository", + "workspace.lifecycle.moving": "Moving to workspace", + "workspace.lifecycle.set": "Workspace set", + "session.summary.title": "Session details", + "session.summary.noBranch": "No branch", + "session.summary.basedOn": "Based on {{branch}}", } diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index c39cbe5aa30..dea8badca4a 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -1051,8 +1051,10 @@ export const dict = { "settings.workspaces.filter.all": "Todos os projetos", "settings.workspaces.empty": "Nenhum espaço de trabalho", "settings.workspaces.count.one": "{{count}} espaço de trabalho", + "settings.workspaces.count.many": "{{count}} espaços de trabalho", "settings.workspaces.count.other": "{{count}} espaços de trabalho", "settings.workspaces.sessions.one": "{{count}} sessão em {{project}}", + "settings.workspaces.sessions.many": "{{count}} sessões em {{project}}", "settings.workspaces.sessions.other": "{{count}} sessões em {{project}}", "settings.workspaces.lastActiveSession": "Última sessão ativa", "settings.workspaces.deleteAll": "Excluir todos os espaços de trabalho", @@ -1070,11 +1072,18 @@ export const dict = { "settings.workspaces.default.local": "Diretório local", "settings.workspaces.default.new": "Novo espaço de trabalho", "workspace.move.title": "Mover para o espaço de trabalho", + "workspace.move.menu.title": "Mover sessão para", "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.starting": "Iniciando sessão", + "workspace.onboarding.title": "Isole sessões com espaços de trabalho", + "workspace.onboarding.description": "Cada um recebe seu próprio checkout, para não interferir com seu repositório local", "workspace.lifecycle.moving": "Movendo para o espaço de trabalho", "workspace.lifecycle.set": "Espaço de trabalho definido", + "session.summary.title": "Detalhes da sessão", + "session.summary.noBranch": "Nenhum branch", + "session.summary.basedOn": "Com base em {{branch}}", "workspace.type.local": "local", "workspace.type.sandbox": "sandbox", "workspace.create.failed.title": "Falha ao criar espaço de trabalho", @@ -1199,4 +1208,13 @@ export const dict = { "error.childStore.persistedProjectIconCreateFailed": "Falha ao criar ícone de projeto persistente", "error.childStore.storeCreateFailed": "Falha ao criar armazenamento", "terminal.connectionLost.abnormalClose": "WebSocket fechado anormalmente: {{code}}", + + "command.session.export": "Export session", + "command.session.export.description": "Export the full session transcript as JSON", + "context.export.session": "Export session", + "toast.session.export.success.title": "Session exported", + "toast.session.export.success.description": "Saved session to {{filename}}", + "toast.session.export.failed.title": "Failed to export session", + "toast.session.export.failed.description": "An error occurred while exporting the session", + "common.export": "Export", } diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index e7682a91e7b..0c45b7c9315 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -1127,8 +1127,10 @@ export const dict = { "settings.workspaces.filter.all": "Svi projekti", "settings.workspaces.empty": "Nema radnih prostora", "settings.workspaces.count.one": "{{count}} radni prostor", + "settings.workspaces.count.few": "{{count}} radna prostora", "settings.workspaces.count.other": "{{count}} radnih prostora", "settings.workspaces.sessions.one": "{{count}} sesija u projektu {{project}}", + "settings.workspaces.sessions.few": "{{count}} sesije u projektu {{project}}", "settings.workspaces.sessions.other": "{{count}} sesija u projektu {{project}}", "settings.workspaces.lastActiveSession": "Posljednja aktivna sesija", "settings.workspaces.deleteAll": "Izbriši sve radne prostore", @@ -1145,11 +1147,18 @@ export const dict = { "settings.workspaces.default.local": "Lokalni direktorij", "settings.workspaces.default.new": "Novi radni prostor", "workspace.move.title": "Premjesti u radni prostor", + "workspace.move.menu.title": "Premjesti sesiju u", "workspace.move.failed": "Premještanje sesije nije uspjelo", "workspace.lifecycle.creating": "Kreiranje radnog prostora", "workspace.lifecycle.created": "Radni prostor je kreiran", + "workspace.lifecycle.starting": "Pokretanje sesije", + "workspace.onboarding.title": "Izolujte sesije pomoću radnih prostora", + "workspace.onboarding.description": "Svaki dobija vlastiti checkout, tako da ništa ne ometa vaš lokalni repozitorij", "workspace.lifecycle.moving": "Premještanje u radni prostor", "workspace.lifecycle.set": "Radni prostor je postavljen", + "session.summary.title": "Detalji sesije", + "session.summary.noBranch": "Nema grane", + "session.summary.basedOn": "Na osnovu {{branch}}", "workspace.type.local": "lokalno", "workspace.type.sandbox": "sandbox", "workspace.create.failed.title": "Neuspješno kreiranje radnog prostora", @@ -1274,4 +1283,12 @@ export const dict = { "error.childStore.persistedProjectIconCreateFailed": "Nije uspjelo kreiranje trajne ikone projekta", "error.childStore.storeCreateFailed": "Nije uspjelo kreiranje skladišta", "terminal.connectionLost.abnormalClose": "WebSocket zatvoren nenormalno: {{code}}", + "command.session.export": "Export session", + "command.session.export.description": "Export the full session transcript as JSON", + "context.export.session": "Export session", + "toast.session.export.success.title": "Session exported", + "toast.session.export.success.description": "Saved session to {{filename}}", + "toast.session.export.failed.title": "Failed to export session", + "toast.session.export.failed.description": "An error occurred while exporting the session", + "common.export": "Export", } diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index bca2f56b3d3..28a8b690c6b 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -1265,4 +1265,19 @@ export const dict = { "help.tabs.persistence": "Dine faner er stadig åbne, når du åbner appen igen.", "help.tabs.worktrees": "Det nye design understøtter endnu ikke Git Worktrees, men understøttelsen kommer snart. Hvis du foretrækker at fortsætte med det tidligere layout, kan du skifte mellem layoutene under Indstillinger. Bemærk, at det nye layout bliver permanent om nogle få uger.", + "command.session.export": "Export session", + "command.session.export.description": "Export the full session transcript as JSON", + "context.export.session": "Export session", + "toast.session.export.success.title": "Session exported", + "toast.session.export.success.description": "Saved session to {{filename}}", + "toast.session.export.failed.title": "Failed to export session", + "toast.session.export.failed.description": "An error occurred while exporting the session", + "common.export": "Export", + "workspace.move.menu.title": "Move session to", + "workspace.lifecycle.starting": "Starting session", + "workspace.onboarding.title": "Isolate sessions with workspaces", + "workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository", + "session.summary.title": "Session details", + "session.summary.noBranch": "No branch", + "session.summary.basedOn": "Based on {{branch}}", } diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index c77f7f6738e..c802abd1bcd 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -1203,4 +1203,19 @@ export const dict = { "help.tabs.persistence": "Wenn Sie die App erneut öffnen, sind Ihre Tabs weiterhin geöffnet.", "help.tabs.worktrees": "Das neue Design unterstützt Git-Worktrees noch nicht. Diese Funktion folgt in Kürze. Wenn Sie das bisherige Layout vorerst weiterverwenden möchten, können Sie in den Einstellungen zwischen den Layouts wechseln. Beachten Sie jedoch, dass das neue Layout in einigen Wochen dauerhaft verwendet wird.", -} satisfies Partial> + "command.session.export": "Export session", + "command.session.export.description": "Export the full session transcript as JSON", + "context.export.session": "Export session", + "toast.session.export.success.title": "Session exported", + "toast.session.export.success.description": "Saved session to {{filename}}", + "toast.session.export.failed.title": "Failed to export session", + "toast.session.export.failed.description": "An error occurred while exporting the session", + "common.export": "Export", + "workspace.move.menu.title": "Move session to", + "workspace.lifecycle.starting": "Starting session", + "workspace.onboarding.title": "Isolate sessions with workspaces", + "workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository", + "session.summary.title": "Session details", + "session.summary.noBranch": "No branch", + "session.summary.basedOn": "Based on {{branch}}", +} diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 4c97144741c..f7aa6095fe0 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -1135,8 +1135,10 @@ export const dict = { "settings.workspaces.filter.all": "Todos los proyectos", "settings.workspaces.empty": "No hay espacios de trabajo", "settings.workspaces.count.one": "{{count}} espacio de trabajo", + "settings.workspaces.count.many": "{{count}} espacios de trabajo", "settings.workspaces.count.other": "{{count}} espacios de trabajo", "settings.workspaces.sessions.one": "{{count}} sesión en {{project}}", + "settings.workspaces.sessions.many": "{{count}} sesiones en {{project}}", "settings.workspaces.sessions.other": "{{count}} sesiones en {{project}}", "settings.workspaces.lastActiveSession": "Última sesión activa", "settings.workspaces.deleteAll": "Eliminar todos los espacios de trabajo", @@ -1154,11 +1156,18 @@ export const dict = { "settings.workspaces.default.local": "Directorio local", "settings.workspaces.default.new": "Nuevo espacio de trabajo", "workspace.move.title": "Mover al espacio de trabajo", + "workspace.move.menu.title": "Mover sesión a", "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.starting": "Iniciando sesión", + "workspace.onboarding.title": "Aísla sesiones con espacios de trabajo", + "workspace.onboarding.description": "Cada uno obtiene su propio checkout, para no interferir con tu repositorio local", "workspace.lifecycle.moving": "Moviendo al espacio de trabajo", "workspace.lifecycle.set": "Espacio de trabajo establecido", + "session.summary.title": "Detalles de la sesión", + "session.summary.noBranch": "Sin rama", + "session.summary.basedOn": "Basado en {{branch}}", "workspace.type.local": "local", "workspace.type.sandbox": "sandbox", "workspace.create.failed.title": "Fallo al crear espacio de trabajo", @@ -1283,4 +1292,12 @@ export const dict = { "error.childStore.persistedProjectIconCreateFailed": "Error al crear icono de proyecto persistente", "error.childStore.storeCreateFailed": "Error al crear almacén", "terminal.connectionLost.abnormalClose": "WebSocket cerrado anormalmente: {{code}}", + "command.session.export": "Export session", + "command.session.export.description": "Export the full session transcript as JSON", + "context.export.session": "Export session", + "toast.session.export.success.title": "Session exported", + "toast.session.export.success.description": "Saved session to {{filename}}", + "toast.session.export.failed.title": "Failed to export session", + "toast.session.export.failed.description": "An error occurred while exporting the session", + "common.export": "Export", } diff --git a/packages/app/src/i18n/fi.ts b/packages/app/src/i18n/fi.ts index 7955cda59a6..4ed775e6f99 100644 --- a/packages/app/src/i18n/fi.ts +++ b/packages/app/src/i18n/fi.ts @@ -1152,4 +1152,50 @@ export const dict = { "help.tabs.persistence": "Kun avaat sovelluksen uudelleen, välilehtesi ovat yhä avoinna.", "help.tabs.worktrees": "Uusi ulkoasu ei vielä tue Git-työpuita, mutta tuki on tulossa pian. Jos haluat jatkaa aiemman ulkoasun käyttöä, voit vaihtaa ulkoasua asetuksissa. Huomaa kuitenkin, että uudesta ulkoasusta tulee pysyvä muutaman viikon kuluttua.", + "command.session.export": "Export session", + "command.session.export.description": "Export the full session transcript as JSON", + "context.export.session": "Export session", + "toast.session.export.success.title": "Session exported", + "toast.session.export.success.description": "Saved session to {{filename}}", + "toast.session.export.failed.title": "Failed to export session", + "toast.session.export.failed.description": "An error occurred while exporting the session", + "common.export": "Export", + "common.viewAll": "View all", + "session.new.workspace.local.tooltip": "Use current checkout", + "session.new.workspace.new.tooltip": "Create isolated checkout", + "session.new.workspace.fromBranch": "from {{branch}}", + "session.new.workspace.trigger.tooltip": "Select where to run session", + "session.new.workspace.search.placeholder": "Search workspaces", + "settings.tab.workspaces": "Workspaces", + "settings.workspaces.filter.all": "All projects", + "settings.workspaces.empty": "No workspaces", + "settings.workspaces.count.one": "{{count}} workspace", + "settings.workspaces.count.other": "{{count}} workspaces", + "settings.workspaces.sessions.one": "{{count}} session in {{project}}", + "settings.workspaces.sessions.other": "{{count}} sessions in {{project}}", + "settings.workspaces.lastActiveSession": "Last active session", + "settings.workspaces.deleteAll": "Delete all workspaces", + "settings.workspaces.deleteAll.confirm": "Delete all {{count}} workspaces?", + "settings.workspaces.delete.warning": "The workspace directory and branch will be permanently removed. Deletion proceeds only if it is clean, inactive, and has no linked sessions.", + "settings.workspaces.deleteAll.warning": "The {{count}} selected workspaces in {{project}} will be permanently removed only if each is clean, inactive, and has no linked sessions.", + "settings.workspaces.delete.blocked.active": "The active workspace cannot be deleted.", + "settings.workspaces.delete.blocked.linked": "This workspace has linked sessions and cannot be deleted.", + "settings.workspaces.default.title": "Default environment", + "settings.workspaces.default.description": "Choose where new sessions start", + "settings.workspaces.default.lastUsed": "Last used per project", + "settings.workspaces.default.local": "Local directory", + "settings.workspaces.default.new": "New workspace", + "workspace.move.title": "Move to workspace", + "workspace.move.menu.title": "Move session to", + "workspace.move.failed": "Failed to move session", + "workspace.lifecycle.creating": "Creating workspace", + "workspace.lifecycle.created": "Workspace created", + "workspace.lifecycle.starting": "Starting session", + "workspace.onboarding.title": "Isolate sessions with workspaces", + "workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository", + "workspace.lifecycle.moving": "Moving to workspace", + "workspace.lifecycle.set": "Workspace set", + "session.summary.title": "Session details", + "session.summary.noBranch": "No branch", + "session.summary.basedOn": "Based on {{branch}}", } diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index f4ceda65574..55db57fc41f 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -1061,8 +1061,10 @@ export const dict = { "settings.workspaces.filter.all": "Tous les projets", "settings.workspaces.empty": "Aucun espace de travail", "settings.workspaces.count.one": "{{count}} espace de travail", + "settings.workspaces.count.many": "{{count}} espaces de travail", "settings.workspaces.count.other": "{{count}} espaces de travail", "settings.workspaces.sessions.one": "{{count}} session dans {{project}}", + "settings.workspaces.sessions.many": "{{count}} sessions dans {{project}}", "settings.workspaces.sessions.other": "{{count}} sessions dans {{project}}", "settings.workspaces.lastActiveSession": "Dernière session active", "settings.workspaces.deleteAll": "Supprimer tous les espaces de travail", @@ -1080,11 +1082,18 @@ export const dict = { "settings.workspaces.default.local": "Dossier local", "settings.workspaces.default.new": "Nouvel espace de travail", "workspace.move.title": "Déplacer vers l’espace de travail", + "workspace.move.menu.title": "Déplacer la session vers", "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.starting": "Démarrage de la session", + "workspace.onboarding.title": "Isolez vos sessions avec les espaces de travail", + "workspace.onboarding.description": "Chacun dispose de son propre checkout pour ne pas interférer avec votre dépôt local", "workspace.lifecycle.moving": "Déplacement vers l’espace de travail", "workspace.lifecycle.set": "Espace de travail défini", + "session.summary.title": "Détails de la session", + "session.summary.noBranch": "Aucune branche", + "session.summary.basedOn": "Basé sur {{branch}}", "workspace.type.local": "local", "workspace.type.sandbox": "bac à sable", "workspace.create.failed.title": "Échec de la création de l'espace de travail", @@ -1217,4 +1226,12 @@ export const dict = { "titlebar.update": "Mettre à jour", "titlebar.updateVersion": "Mettre à jour {{version}}", + "command.session.export": "Export session", + "command.session.export.description": "Export the full session transcript as JSON", + "context.export.session": "Export session", + "toast.session.export.success.title": "Session exported", + "toast.session.export.success.description": "Saved session to {{filename}}", + "toast.session.export.failed.title": "Failed to export session", + "toast.session.export.failed.description": "An error occurred while exporting the session", + "common.export": "Export", } diff --git a/packages/app/src/i18n/hi.ts b/packages/app/src/i18n/hi.ts index a704e6233ef..5e6ee8c9f30 100644 --- a/packages/app/src/i18n/hi.ts +++ b/packages/app/src/i18n/hi.ts @@ -1147,4 +1147,50 @@ export const dict = { "workspace.reset.archived.one": "1 सेशन संग्रहित किया जाएगा।", "workspace.reset.archived.many": "{{count}} सेशन संग्रहित किए जाएँगे।", "workspace.reset.note": "यह डिफ़ॉल्ट शाखा से मिलान करने के लिए वर्कस्पेस को रीसेट कर देगा।", + "command.session.export": "Export session", + "command.session.export.description": "Export the full session transcript as JSON", + "context.export.session": "Export session", + "toast.session.export.success.title": "Session exported", + "toast.session.export.success.description": "Saved session to {{filename}}", + "toast.session.export.failed.title": "Failed to export session", + "toast.session.export.failed.description": "An error occurred while exporting the session", + "common.export": "Export", + "common.viewAll": "View all", + "session.new.workspace.local.tooltip": "Use current checkout", + "session.new.workspace.new.tooltip": "Create isolated checkout", + "session.new.workspace.fromBranch": "from {{branch}}", + "session.new.workspace.trigger.tooltip": "Select where to run session", + "session.new.workspace.search.placeholder": "Search workspaces", + "settings.tab.workspaces": "Workspaces", + "settings.workspaces.filter.all": "All projects", + "settings.workspaces.empty": "No workspaces", + "settings.workspaces.count.one": "{{count}} workspace", + "settings.workspaces.count.other": "{{count}} workspaces", + "settings.workspaces.sessions.one": "{{count}} session in {{project}}", + "settings.workspaces.sessions.other": "{{count}} sessions in {{project}}", + "settings.workspaces.lastActiveSession": "Last active session", + "settings.workspaces.deleteAll": "Delete all workspaces", + "settings.workspaces.deleteAll.confirm": "Delete all {{count}} workspaces?", + "settings.workspaces.delete.warning": "The workspace directory and branch will be permanently removed. Deletion proceeds only if it is clean, inactive, and has no linked sessions.", + "settings.workspaces.deleteAll.warning": "The {{count}} selected workspaces in {{project}} will be permanently removed only if each is clean, inactive, and has no linked sessions.", + "settings.workspaces.delete.blocked.active": "The active workspace cannot be deleted.", + "settings.workspaces.delete.blocked.linked": "This workspace has linked sessions and cannot be deleted.", + "settings.workspaces.default.title": "Default environment", + "settings.workspaces.default.description": "Choose where new sessions start", + "settings.workspaces.default.lastUsed": "Last used per project", + "settings.workspaces.default.local": "Local directory", + "settings.workspaces.default.new": "New workspace", + "workspace.move.title": "Move to workspace", + "workspace.move.menu.title": "Move session to", + "workspace.move.failed": "Failed to move session", + "workspace.lifecycle.creating": "Creating workspace", + "workspace.lifecycle.created": "Workspace created", + "workspace.lifecycle.starting": "Starting session", + "workspace.onboarding.title": "Isolate sessions with workspaces", + "workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository", + "workspace.lifecycle.moving": "Moving to workspace", + "workspace.lifecycle.set": "Workspace set", + "session.summary.title": "Session details", + "session.summary.noBranch": "No branch", + "session.summary.basedOn": "Based on {{branch}}", } diff --git a/packages/app/src/i18n/id.ts b/packages/app/src/i18n/id.ts index e693869089e..35bbac6ee1b 100644 --- a/packages/app/src/i18n/id.ts +++ b/packages/app/src/i18n/id.ts @@ -1234,4 +1234,50 @@ export const dict = { "workspace.reset.archived.one": "1 sesi akan diarsipkan.", "workspace.reset.archived.many": "{{count}} sesi akan diarsipkan.", "workspace.reset.note": "Ini akan mengatur ulang ruang kerja agar cocok dengan cabang bawaan.", + "command.session.export": "Export session", + "command.session.export.description": "Export the full session transcript as JSON", + "context.export.session": "Export session", + "toast.session.export.success.title": "Session exported", + "toast.session.export.success.description": "Saved session to {{filename}}", + "toast.session.export.failed.title": "Failed to export session", + "toast.session.export.failed.description": "An error occurred while exporting the session", + "common.export": "Export", + "common.viewAll": "View all", + "session.new.workspace.local.tooltip": "Use current checkout", + "session.new.workspace.new.tooltip": "Create isolated checkout", + "session.new.workspace.fromBranch": "from {{branch}}", + "session.new.workspace.trigger.tooltip": "Select where to run session", + "session.new.workspace.search.placeholder": "Search workspaces", + "settings.tab.workspaces": "Workspaces", + "settings.workspaces.filter.all": "All projects", + "settings.workspaces.empty": "No workspaces", + "settings.workspaces.count.one": "{{count}} workspace", + "settings.workspaces.count.other": "{{count}} workspaces", + "settings.workspaces.sessions.one": "{{count}} session in {{project}}", + "settings.workspaces.sessions.other": "{{count}} sessions in {{project}}", + "settings.workspaces.lastActiveSession": "Last active session", + "settings.workspaces.deleteAll": "Delete all workspaces", + "settings.workspaces.deleteAll.confirm": "Delete all {{count}} workspaces?", + "settings.workspaces.delete.warning": "The workspace directory and branch will be permanently removed. Deletion proceeds only if it is clean, inactive, and has no linked sessions.", + "settings.workspaces.deleteAll.warning": "The {{count}} selected workspaces in {{project}} will be permanently removed only if each is clean, inactive, and has no linked sessions.", + "settings.workspaces.delete.blocked.active": "The active workspace cannot be deleted.", + "settings.workspaces.delete.blocked.linked": "This workspace has linked sessions and cannot be deleted.", + "settings.workspaces.default.title": "Default environment", + "settings.workspaces.default.description": "Choose where new sessions start", + "settings.workspaces.default.lastUsed": "Last used per project", + "settings.workspaces.default.local": "Local directory", + "settings.workspaces.default.new": "New workspace", + "workspace.move.title": "Move to workspace", + "workspace.move.menu.title": "Move session to", + "workspace.move.failed": "Failed to move session", + "workspace.lifecycle.creating": "Creating workspace", + "workspace.lifecycle.created": "Workspace created", + "workspace.lifecycle.starting": "Starting session", + "workspace.onboarding.title": "Isolate sessions with workspaces", + "workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository", + "workspace.lifecycle.moving": "Moving to workspace", + "workspace.lifecycle.set": "Workspace set", + "session.summary.title": "Session details", + "session.summary.noBranch": "No branch", + "session.summary.basedOn": "Based on {{branch}}", } diff --git a/packages/app/src/i18n/it.ts b/packages/app/src/i18n/it.ts index 3f4089018dd..0caa391f4ba 100644 --- a/packages/app/src/i18n/it.ts +++ b/packages/app/src/i18n/it.ts @@ -1160,4 +1160,55 @@ export const dict = { "desktop.wsl.error.failedPort": "Impossibile ottenere la porta", "desktop.picker.error.notSelected": "Il file non è stato selezionato nella finestra di selezione", "desktop.picker.error.sizeLimit": "Gli allegati selezionati superano il limite di {{limit}} MB", + + "common.viewAll": "Mostra tutto", + "session.new.workspace.local.tooltip": "Usa il checkout corrente", + "session.new.workspace.new.tooltip": "Crea un checkout isolato", + "session.new.workspace.fromBranch": "da {{branch}}", + "session.new.workspace.trigger.tooltip": "Seleziona dove eseguire la sessione", + "session.new.workspace.search.placeholder": "Cerca aree di lavoro", + "settings.tab.workspaces": "Aree di lavoro", + "settings.workspaces.filter.all": "Tutti i progetti", + "settings.workspaces.empty": "Nessuna area di lavoro", + "settings.workspaces.count.one": "{{count}} area di lavoro", + "settings.workspaces.count.many": "{{count}} aree di lavoro", + "settings.workspaces.count.other": "{{count}} aree di lavoro", + "settings.workspaces.sessions.one": "{{count}} sessione in {{project}}", + "settings.workspaces.sessions.many": "{{count}} sessioni in {{project}}", + "settings.workspaces.sessions.other": "{{count}} sessioni in {{project}}", + "settings.workspaces.lastActiveSession": "Ultima sessione attiva", + "settings.workspaces.deleteAll": "Elimina tutte le aree di lavoro", + "settings.workspaces.deleteAll.confirm": "Eliminare tutte le {{count}} aree di lavoro?", + "settings.workspaces.delete.warning": + "La directory e il branch dell'area di lavoro verranno rimossi definitivamente. L'eliminazione procede solo se l'area è pulita, inattiva e non ha sessioni collegate.", + "settings.workspaces.deleteAll.warning": + "Le {{count}} aree di lavoro selezionate in {{project}} verranno rimosse definitivamente solo se ciascuna è pulita, inattiva e non ha sessioni collegate.", + "settings.workspaces.delete.blocked.active": "L'area di lavoro attiva non può essere eliminata.", + "settings.workspaces.delete.blocked.linked": "Questa area di lavoro ha sessioni collegate e non può essere eliminata.", + "settings.workspaces.default.title": "Ambiente predefinito", + "settings.workspaces.default.description": "Scegli dove avviare le nuove sessioni", + "settings.workspaces.default.lastUsed": "Ultimo usato per progetto", + "settings.workspaces.default.local": "Directory locale", + "settings.workspaces.default.new": "Nuova area di lavoro", + "workspace.move.title": "Sposta nell'area di lavoro", + "workspace.move.menu.title": "Sposta la sessione in", + "workspace.move.failed": "Impossibile spostare la sessione", + "workspace.lifecycle.creating": "Creazione dell'area di lavoro", + "workspace.lifecycle.created": "Area di lavoro creata", + "workspace.lifecycle.starting": "Avvio sessione", + "workspace.onboarding.title": "Isola le sessioni con le aree di lavoro", + "workspace.onboarding.description": "Ognuna ottiene il proprio checkout per non interferire con il repository locale", + "workspace.lifecycle.moving": "Spostamento nell'area di lavoro", + "workspace.lifecycle.set": "Area di lavoro impostata", + "session.summary.title": "Dettagli della sessione", + "session.summary.noBranch": "Nessun branch", + "session.summary.basedOn": "Basato su {{branch}}", + "command.session.export": "Export session", + "command.session.export.description": "Export the full session transcript as JSON", + "context.export.session": "Export session", + "toast.session.export.success.title": "Session exported", + "toast.session.export.success.description": "Saved session to {{filename}}", + "toast.session.export.failed.title": "Failed to export session", + "toast.session.export.failed.description": "An error occurred while exporting the session", + "common.export": "Export", } diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 0ed09171dd1..69cc4a2fed1 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -1186,4 +1186,19 @@ export const dict = { "titlebar.update": "アップデート", "titlebar.updateVersion": "アップデート {{version}}", + "command.session.export": "Export session", + "command.session.export.description": "Export the full session transcript as JSON", + "context.export.session": "Export session", + "toast.session.export.success.title": "Session exported", + "toast.session.export.success.description": "Saved session to {{filename}}", + "toast.session.export.failed.title": "Failed to export session", + "toast.session.export.failed.description": "An error occurred while exporting the session", + "common.export": "Export", + "workspace.move.menu.title": "Move session to", + "workspace.lifecycle.starting": "Starting session", + "workspace.onboarding.title": "Isolate sessions with workspaces", + "workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository", + "session.summary.title": "Session details", + "session.summary.noBranch": "No branch", + "session.summary.basedOn": "Based on {{branch}}", } diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index dd54eb5f893..2c4a0a1debc 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -1184,4 +1184,19 @@ export const dict = { "desktop.picker.error.notSelected": "선택기에서 파일을 선택하지 않았습니다", "desktop.picker.error.sizeLimit": "선택한 첨부 파일이 {{limit}} MB 제한을 초과합니다", + "command.session.export": "Export session", + "command.session.export.description": "Export the full session transcript as JSON", + "context.export.session": "Export session", + "toast.session.export.success.title": "Session exported", + "toast.session.export.success.description": "Saved session to {{filename}}", + "toast.session.export.failed.title": "Failed to export session", + "toast.session.export.failed.description": "An error occurred while exporting the session", + "common.export": "Export", + "workspace.move.menu.title": "Move session to", + "workspace.lifecycle.starting": "Starting session", + "workspace.onboarding.title": "Isolate sessions with workspaces", + "workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository", + "session.summary.title": "Session details", + "session.summary.noBranch": "No branch", + "session.summary.basedOn": "Based on {{branch}}", } diff --git a/packages/app/src/i18n/nl.ts b/packages/app/src/i18n/nl.ts index 7ac999c7a79..6107e879665 100644 --- a/packages/app/src/i18n/nl.ts +++ b/packages/app/src/i18n/nl.ts @@ -1155,4 +1155,50 @@ export const dict = { "workspace.reset.archived.many": "{{count}} sessies worden gearchiveerd.", "workspace.reset.note": "Hierdoor wordt de werkruimte opnieuw ingesteld zodat deze overeenkomt met de standaardbranch.", + "command.session.export": "Export session", + "command.session.export.description": "Export the full session transcript as JSON", + "context.export.session": "Export session", + "toast.session.export.success.title": "Session exported", + "toast.session.export.success.description": "Saved session to {{filename}}", + "toast.session.export.failed.title": "Failed to export session", + "toast.session.export.failed.description": "An error occurred while exporting the session", + "common.export": "Export", + "common.viewAll": "View all", + "session.new.workspace.local.tooltip": "Use current checkout", + "session.new.workspace.new.tooltip": "Create isolated checkout", + "session.new.workspace.fromBranch": "from {{branch}}", + "session.new.workspace.trigger.tooltip": "Select where to run session", + "session.new.workspace.search.placeholder": "Search workspaces", + "settings.tab.workspaces": "Workspaces", + "settings.workspaces.filter.all": "All projects", + "settings.workspaces.empty": "No workspaces", + "settings.workspaces.count.one": "{{count}} workspace", + "settings.workspaces.count.other": "{{count}} workspaces", + "settings.workspaces.sessions.one": "{{count}} session in {{project}}", + "settings.workspaces.sessions.other": "{{count}} sessions in {{project}}", + "settings.workspaces.lastActiveSession": "Last active session", + "settings.workspaces.deleteAll": "Delete all workspaces", + "settings.workspaces.deleteAll.confirm": "Delete all {{count}} workspaces?", + "settings.workspaces.delete.warning": "The workspace directory and branch will be permanently removed. Deletion proceeds only if it is clean, inactive, and has no linked sessions.", + "settings.workspaces.deleteAll.warning": "The {{count}} selected workspaces in {{project}} will be permanently removed only if each is clean, inactive, and has no linked sessions.", + "settings.workspaces.delete.blocked.active": "The active workspace cannot be deleted.", + "settings.workspaces.delete.blocked.linked": "This workspace has linked sessions and cannot be deleted.", + "settings.workspaces.default.title": "Default environment", + "settings.workspaces.default.description": "Choose where new sessions start", + "settings.workspaces.default.lastUsed": "Last used per project", + "settings.workspaces.default.local": "Local directory", + "settings.workspaces.default.new": "New workspace", + "workspace.move.title": "Move to workspace", + "workspace.move.menu.title": "Move session to", + "workspace.move.failed": "Failed to move session", + "workspace.lifecycle.creating": "Creating workspace", + "workspace.lifecycle.created": "Workspace created", + "workspace.lifecycle.starting": "Starting session", + "workspace.onboarding.title": "Isolate sessions with workspaces", + "workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository", + "workspace.lifecycle.moving": "Moving to workspace", + "workspace.lifecycle.set": "Workspace set", + "session.summary.title": "Session details", + "session.summary.noBranch": "No branch", + "session.summary.basedOn": "Based on {{branch}}", } diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 3350d0582e3..cd71b4819d2 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -1284,4 +1284,19 @@ export const dict = { "settings.general.row.pinchZoom.description": "Tillat knipebevegelser på styreflaten og Ctrl-rulling for å zoome", "settings.updates.action.downloading": "Laster ned...", "settings.updates.action.installing": "Installerer...", -} satisfies Partial> + "command.session.export": "Export session", + "command.session.export.description": "Export the full session transcript as JSON", + "context.export.session": "Export session", + "toast.session.export.success.title": "Session exported", + "toast.session.export.success.description": "Saved session to {{filename}}", + "toast.session.export.failed.title": "Failed to export session", + "toast.session.export.failed.description": "An error occurred while exporting the session", + "common.export": "Export", + "workspace.move.menu.title": "Move session to", + "workspace.lifecycle.starting": "Starting session", + "workspace.onboarding.title": "Isolate sessions with workspaces", + "workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository", + "session.summary.title": "Session details", + "session.summary.noBranch": "No branch", + "session.summary.basedOn": "Based on {{branch}}", +} diff --git a/packages/app/src/i18n/pa.ts b/packages/app/src/i18n/pa.ts index 4e0ff16f87e..1609ee78240 100644 --- a/packages/app/src/i18n/pa.ts +++ b/packages/app/src/i18n/pa.ts @@ -1145,4 +1145,50 @@ export const dict = { "workspace.reset.archived.one": "1 سیشن آرکائیو کیتا جائے گا۔", "workspace.reset.archived.many": "{{count}} سیشن آرکائیو کیتے جان گے۔", "workspace.reset.note": "ایہ ورک اسپیس نو ڈیفالٹ برانچ نال ملاون لئی ری سیٹ کرے گا۔", + "command.session.export": "Export session", + "command.session.export.description": "Export the full session transcript as JSON", + "context.export.session": "Export session", + "toast.session.export.success.title": "Session exported", + "toast.session.export.success.description": "Saved session to {{filename}}", + "toast.session.export.failed.title": "Failed to export session", + "toast.session.export.failed.description": "An error occurred while exporting the session", + "common.export": "Export", + "common.viewAll": "View all", + "session.new.workspace.local.tooltip": "Use current checkout", + "session.new.workspace.new.tooltip": "Create isolated checkout", + "session.new.workspace.fromBranch": "from {{branch}}", + "session.new.workspace.trigger.tooltip": "Select where to run session", + "session.new.workspace.search.placeholder": "Search workspaces", + "settings.tab.workspaces": "Workspaces", + "settings.workspaces.filter.all": "All projects", + "settings.workspaces.empty": "No workspaces", + "settings.workspaces.count.one": "{{count}} workspace", + "settings.workspaces.count.other": "{{count}} workspaces", + "settings.workspaces.sessions.one": "{{count}} session in {{project}}", + "settings.workspaces.sessions.other": "{{count}} sessions in {{project}}", + "settings.workspaces.lastActiveSession": "Last active session", + "settings.workspaces.deleteAll": "Delete all workspaces", + "settings.workspaces.deleteAll.confirm": "Delete all {{count}} workspaces?", + "settings.workspaces.delete.warning": "The workspace directory and branch will be permanently removed. Deletion proceeds only if it is clean, inactive, and has no linked sessions.", + "settings.workspaces.deleteAll.warning": "The {{count}} selected workspaces in {{project}} will be permanently removed only if each is clean, inactive, and has no linked sessions.", + "settings.workspaces.delete.blocked.active": "The active workspace cannot be deleted.", + "settings.workspaces.delete.blocked.linked": "This workspace has linked sessions and cannot be deleted.", + "settings.workspaces.default.title": "Default environment", + "settings.workspaces.default.description": "Choose where new sessions start", + "settings.workspaces.default.lastUsed": "Last used per project", + "settings.workspaces.default.local": "Local directory", + "settings.workspaces.default.new": "New workspace", + "workspace.move.title": "Move to workspace", + "workspace.move.menu.title": "Move session to", + "workspace.move.failed": "Failed to move session", + "workspace.lifecycle.creating": "Creating workspace", + "workspace.lifecycle.created": "Workspace created", + "workspace.lifecycle.starting": "Starting session", + "workspace.onboarding.title": "Isolate sessions with workspaces", + "workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository", + "workspace.lifecycle.moving": "Moving to workspace", + "workspace.lifecycle.set": "Workspace set", + "session.summary.title": "Session details", + "session.summary.noBranch": "No branch", + "session.summary.basedOn": "Based on {{branch}}", } diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 1eaa8d5cdb7..b199ad928bc 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -1053,8 +1053,12 @@ export const dict = { "settings.workspaces.filter.all": "Wszystkie projekty", "settings.workspaces.empty": "Brak obszarów roboczych", "settings.workspaces.count.one": "{{count}} obszar roboczy", + "settings.workspaces.count.few": "{{count}} obszary robocze", + "settings.workspaces.count.many": "{{count}} obszarów roboczych", "settings.workspaces.count.other": "{{count}} obszarów roboczych", "settings.workspaces.sessions.one": "{{count}} sesja w {{project}}", + "settings.workspaces.sessions.few": "{{count}} sesje w {{project}}", + "settings.workspaces.sessions.many": "{{count}} sesji w {{project}}", "settings.workspaces.sessions.other": "{{count}} sesji w {{project}}", "settings.workspaces.lastActiveSession": "Ostatnia aktywna sesja", "settings.workspaces.deleteAll": "Usuń wszystkie obszary robocze", @@ -1071,11 +1075,18 @@ export const dict = { "settings.workspaces.default.local": "Katalog lokalny", "settings.workspaces.default.new": "Nowy obszar roboczy", "workspace.move.title": "Przenieś do obszaru roboczego", + "workspace.move.menu.title": "Przenieś sesję do", "workspace.move.failed": "Nie udało się przenieść sesji", "workspace.lifecycle.creating": "Tworzenie obszaru roboczego", "workspace.lifecycle.created": "Utworzono obszar roboczy", + "workspace.lifecycle.starting": "Uruchamianie sesji", + "workspace.onboarding.title": "Izoluj sesje za pomocą przestrzeni roboczych", + "workspace.onboarding.description": "Każda otrzymuje własny checkout, więc nic nie zakłóca Twojego lokalnego repozytorium", "workspace.lifecycle.moving": "Przenoszenie do obszaru roboczego", "workspace.lifecycle.set": "Ustawiono obszar roboczy", + "session.summary.title": "Szczegóły sesji", + "session.summary.noBranch": "Brak gałęzi", + "session.summary.basedOn": "Na podstawie {{branch}}", "workspace.type.local": "lokalna", "workspace.type.sandbox": "piaskownica", "workspace.create.failed.title": "Nie udało się utworzyć przestrzeni roboczej", @@ -1201,4 +1212,12 @@ export const dict = { "error.childStore.persistedProjectIconCreateFailed": "Nie udało się utworzyć trwałej ikony projektu", "error.childStore.storeCreateFailed": "Nie udało się utworzyć magazynu", "terminal.connectionLost.abnormalClose": "WebSocket zamknięty nieprawidłowo: {{code}}", + "command.session.export": "Export session", + "command.session.export.description": "Export the full session transcript as JSON", + "context.export.session": "Export session", + "toast.session.export.success.title": "Session exported", + "toast.session.export.success.description": "Saved session to {{filename}}", + "toast.session.export.failed.title": "Failed to export session", + "toast.session.export.failed.description": "An error occurred while exporting the session", + "common.export": "Export", } diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index d2f0e1a893f..4719071092d 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -1132,8 +1132,12 @@ export const dict = { "settings.workspaces.filter.all": "Все проекты", "settings.workspaces.empty": "Нет рабочих пространств", "settings.workspaces.count.one": "{{count}} рабочее пространство", + "settings.workspaces.count.few": "{{count}} рабочих пространства", + "settings.workspaces.count.many": "{{count}} рабочих пространств", "settings.workspaces.count.other": "{{count}} рабочих пространств", "settings.workspaces.sessions.one": "{{count}} сессия в {{project}}", + "settings.workspaces.sessions.few": "{{count}} сессии в {{project}}", + "settings.workspaces.sessions.many": "{{count}} сессий в {{project}}", "settings.workspaces.sessions.other": "{{count}} сессий в {{project}}", "settings.workspaces.lastActiveSession": "Последний активный сеанс", "settings.workspaces.deleteAll": "Удалить все рабочие пространства", @@ -1151,11 +1155,18 @@ export const dict = { "settings.workspaces.default.local": "Локальный каталог", "settings.workspaces.default.new": "Новое рабочее пространство", "workspace.move.title": "Переместить в рабочую область", + "workspace.move.menu.title": "Переместить сессию в", "workspace.move.failed": "Не удалось переместить сеанс", "workspace.lifecycle.creating": "Создание рабочей области", "workspace.lifecycle.created": "Рабочая область создана", + "workspace.lifecycle.starting": "Запуск сессии", + "workspace.onboarding.title": "Изолируйте сессии с помощью рабочих пространств", + "workspace.onboarding.description": "Каждая получает отдельную копию, чтобы не влиять на ваш локальный репозиторий", "workspace.lifecycle.moving": "Перемещение в рабочую область", "workspace.lifecycle.set": "Рабочая область задана", + "session.summary.title": "Сведения о сеансе", + "session.summary.noBranch": "Нет ветки", + "session.summary.basedOn": "На основе {{branch}}", "workspace.type.local": "локальное", "workspace.type.sandbox": "песочница", "workspace.create.failed.title": "Не удалось создать рабочее пространство", @@ -1280,4 +1291,12 @@ export const dict = { "error.childStore.persistedProjectIconCreateFailed": "Не удалось создать постоянный значок проекта", "error.childStore.storeCreateFailed": "Не удалось создать хранилище", "terminal.connectionLost.abnormalClose": "WebSocket закрыт аварийно: {{code}}", + "command.session.export": "Export session", + "command.session.export.description": "Export the full session transcript as JSON", + "context.export.session": "Export session", + "toast.session.export.success.title": "Session exported", + "toast.session.export.success.description": "Saved session to {{filename}}", + "toast.session.export.failed.title": "Failed to export session", + "toast.session.export.failed.description": "An error occurred while exporting the session", + "common.export": "Export", } diff --git a/packages/app/src/i18n/sv.ts b/packages/app/src/i18n/sv.ts index ab5007238b6..9bab2f8502d 100644 --- a/packages/app/src/i18n/sv.ts +++ b/packages/app/src/i18n/sv.ts @@ -1144,4 +1144,50 @@ export const dict = { "workspace.reset.archived.one": "1 session kommer att arkiveras.", "workspace.reset.archived.many": "{{count}} sessioner kommer att arkiveras.", "workspace.reset.note": "Detta kommer att återställa arbetsytan så att den matchar standardgrenen.", + "command.session.export": "Export session", + "command.session.export.description": "Export the full session transcript as JSON", + "context.export.session": "Export session", + "toast.session.export.success.title": "Session exported", + "toast.session.export.success.description": "Saved session to {{filename}}", + "toast.session.export.failed.title": "Failed to export session", + "toast.session.export.failed.description": "An error occurred while exporting the session", + "common.export": "Export", + "common.viewAll": "View all", + "session.new.workspace.local.tooltip": "Use current checkout", + "session.new.workspace.new.tooltip": "Create isolated checkout", + "session.new.workspace.fromBranch": "from {{branch}}", + "session.new.workspace.trigger.tooltip": "Select where to run session", + "session.new.workspace.search.placeholder": "Search workspaces", + "settings.tab.workspaces": "Workspaces", + "settings.workspaces.filter.all": "All projects", + "settings.workspaces.empty": "No workspaces", + "settings.workspaces.count.one": "{{count}} workspace", + "settings.workspaces.count.other": "{{count}} workspaces", + "settings.workspaces.sessions.one": "{{count}} session in {{project}}", + "settings.workspaces.sessions.other": "{{count}} sessions in {{project}}", + "settings.workspaces.lastActiveSession": "Last active session", + "settings.workspaces.deleteAll": "Delete all workspaces", + "settings.workspaces.deleteAll.confirm": "Delete all {{count}} workspaces?", + "settings.workspaces.delete.warning": "The workspace directory and branch will be permanently removed. Deletion proceeds only if it is clean, inactive, and has no linked sessions.", + "settings.workspaces.deleteAll.warning": "The {{count}} selected workspaces in {{project}} will be permanently removed only if each is clean, inactive, and has no linked sessions.", + "settings.workspaces.delete.blocked.active": "The active workspace cannot be deleted.", + "settings.workspaces.delete.blocked.linked": "This workspace has linked sessions and cannot be deleted.", + "settings.workspaces.default.title": "Default environment", + "settings.workspaces.default.description": "Choose where new sessions start", + "settings.workspaces.default.lastUsed": "Last used per project", + "settings.workspaces.default.local": "Local directory", + "settings.workspaces.default.new": "New workspace", + "workspace.move.title": "Move to workspace", + "workspace.move.menu.title": "Move session to", + "workspace.move.failed": "Failed to move session", + "workspace.lifecycle.creating": "Creating workspace", + "workspace.lifecycle.created": "Workspace created", + "workspace.lifecycle.starting": "Starting session", + "workspace.onboarding.title": "Isolate sessions with workspaces", + "workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository", + "workspace.lifecycle.moving": "Moving to workspace", + "workspace.lifecycle.set": "Workspace set", + "session.summary.title": "Session details", + "session.summary.noBranch": "No branch", + "session.summary.basedOn": "Based on {{branch}}", } diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index a4713107096..94e0d2ae993 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -1255,4 +1255,19 @@ export const dict = { "error.childStore.persistedProjectIconCreateFailed": "ไม่สามารถสร้างไอคอนโปรเจกต์ถาวร", "error.childStore.storeCreateFailed": "ไม่สามารถสร้างที่เก็บ", "terminal.connectionLost.abnormalClose": "WebSocket ปิดอย่างผิดปกติ: {{code}}", + "command.session.export": "Export session", + "command.session.export.description": "Export the full session transcript as JSON", + "context.export.session": "Export session", + "toast.session.export.success.title": "Session exported", + "toast.session.export.success.description": "Saved session to {{filename}}", + "toast.session.export.failed.title": "Failed to export session", + "toast.session.export.failed.description": "An error occurred while exporting the session", + "common.export": "Export", + "workspace.move.menu.title": "Move session to", + "workspace.lifecycle.starting": "Starting session", + "workspace.onboarding.title": "Isolate sessions with workspaces", + "workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository", + "session.summary.title": "Session details", + "session.summary.noBranch": "No branch", + "session.summary.basedOn": "Based on {{branch}}", } diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index aa02f982821..fe35be7ad32 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -1277,4 +1277,19 @@ export const dict = { "error.childStore.persistedProjectIconCreateFailed": "Kalıcı proje simgesi oluşturulamadı", "error.childStore.storeCreateFailed": "Depo oluşturulamadı", "terminal.connectionLost.abnormalClose": "WebSocket anormal şekilde kapandı: {{code}}", -} satisfies Partial> + "command.session.export": "Export session", + "command.session.export.description": "Export the full session transcript as JSON", + "context.export.session": "Export session", + "toast.session.export.success.title": "Session exported", + "toast.session.export.success.description": "Saved session to {{filename}}", + "toast.session.export.failed.title": "Failed to export session", + "toast.session.export.failed.description": "An error occurred while exporting the session", + "common.export": "Export", + "workspace.move.menu.title": "Move session to", + "workspace.lifecycle.starting": "Starting session", + "workspace.onboarding.title": "Isolate sessions with workspaces", + "workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository", + "session.summary.title": "Session details", + "session.summary.noBranch": "No branch", + "session.summary.basedOn": "Based on {{branch}}", +} diff --git a/packages/app/src/i18n/uk.ts b/packages/app/src/i18n/uk.ts index e089b946d7c..57fc2969cf0 100644 --- a/packages/app/src/i18n/uk.ts +++ b/packages/app/src/i18n/uk.ts @@ -1237,8 +1237,12 @@ export const dict = { "settings.workspaces.filter.all": "Усі проєкти", "settings.workspaces.empty": "Немає робочих просторів", "settings.workspaces.count.one": "{{count}} робочий простір", + "settings.workspaces.count.few": "{{count}} робочі простори", + "settings.workspaces.count.many": "{{count}} робочих просторів", "settings.workspaces.count.other": "{{count}} робочих просторів", "settings.workspaces.sessions.one": "{{count}} сесія в {{project}}", + "settings.workspaces.sessions.few": "{{count}} сесії в {{project}}", + "settings.workspaces.sessions.many": "{{count}} сесій у {{project}}", "settings.workspaces.sessions.other": "{{count}} сесій у {{project}}", "settings.workspaces.lastActiveSession": "Останній активний сеанс", "settings.workspaces.deleteAll": "Видалити всі робочі простори", @@ -1255,11 +1259,18 @@ export const dict = { "settings.workspaces.default.local": "Локальний каталог", "settings.workspaces.default.new": "Новий робочий простір", "workspace.move.title": "Перемістити до робочого простору", + "workspace.move.menu.title": "Перемістити сесію до", "workspace.move.failed": "Не вдалося перемістити сеанс", "workspace.lifecycle.creating": "Створення робочого простору", "workspace.lifecycle.created": "Робочий простір створено", + "workspace.lifecycle.starting": "Запуск сесії", + "workspace.onboarding.title": "Ізолюйте сесії за допомогою робочих просторів", + "workspace.onboarding.description": "Кожен отримує власний checkout, тому ніщо не заважає вашому локальному репозиторію", "workspace.lifecycle.moving": "Переміщення до робочого простору", "workspace.lifecycle.set": "Робочий простір установлено", + "session.summary.title": "Відомості про сеанс", + "session.summary.noBranch": "Немає гілки", + "session.summary.basedOn": "На основі {{branch}}", "workspace.type.local": "локальна", "workspace.type.sandbox": "пісочниця", "workspace.create.failed.title": "Не вдалося створити робочу область", @@ -1284,4 +1295,12 @@ export const dict = { "workspace.reset.archived.one": "1 сесію буде заархівовано.", "workspace.reset.archived.many": "{{count}} сесій буде заархівовано.", "workspace.reset.note": "Це скине робочу область, щоб вона відповідала гілці за замовчуванням.", + "command.session.export": "Export session", + "command.session.export.description": "Export the full session transcript as JSON", + "context.export.session": "Export session", + "toast.session.export.success.title": "Session exported", + "toast.session.export.success.description": "Saved session to {{filename}}", + "toast.session.export.failed.title": "Failed to export session", + "toast.session.export.failed.description": "An error occurred while exporting the session", + "common.export": "Export", } diff --git a/packages/app/src/i18n/ur.ts b/packages/app/src/i18n/ur.ts index 661c013ebe7..1f536ef0d87 100644 --- a/packages/app/src/i18n/ur.ts +++ b/packages/app/src/i18n/ur.ts @@ -1148,4 +1148,50 @@ export const dict = { "workspace.reset.archived.one": "1 سیشن آرکائیو کیا جائے گا۔", "workspace.reset.archived.many": "{{count}} سیشنز آرکائیو کیے جائیں گے۔", "workspace.reset.note": "یہ ڈیفالٹ برانچ سے ملنے کے لیے ورک اسپیس کو دوبارہ ترتیب دے گا۔", + "command.session.export": "Export session", + "command.session.export.description": "Export the full session transcript as JSON", + "context.export.session": "Export session", + "toast.session.export.success.title": "Session exported", + "toast.session.export.success.description": "Saved session to {{filename}}", + "toast.session.export.failed.title": "Failed to export session", + "toast.session.export.failed.description": "An error occurred while exporting the session", + "common.export": "Export", + "common.viewAll": "View all", + "session.new.workspace.local.tooltip": "Use current checkout", + "session.new.workspace.new.tooltip": "Create isolated checkout", + "session.new.workspace.fromBranch": "from {{branch}}", + "session.new.workspace.trigger.tooltip": "Select where to run session", + "session.new.workspace.search.placeholder": "Search workspaces", + "settings.tab.workspaces": "Workspaces", + "settings.workspaces.filter.all": "All projects", + "settings.workspaces.empty": "No workspaces", + "settings.workspaces.count.one": "{{count}} workspace", + "settings.workspaces.count.other": "{{count}} workspaces", + "settings.workspaces.sessions.one": "{{count}} session in {{project}}", + "settings.workspaces.sessions.other": "{{count}} sessions in {{project}}", + "settings.workspaces.lastActiveSession": "Last active session", + "settings.workspaces.deleteAll": "Delete all workspaces", + "settings.workspaces.deleteAll.confirm": "Delete all {{count}} workspaces?", + "settings.workspaces.delete.warning": "The workspace directory and branch will be permanently removed. Deletion proceeds only if it is clean, inactive, and has no linked sessions.", + "settings.workspaces.deleteAll.warning": "The {{count}} selected workspaces in {{project}} will be permanently removed only if each is clean, inactive, and has no linked sessions.", + "settings.workspaces.delete.blocked.active": "The active workspace cannot be deleted.", + "settings.workspaces.delete.blocked.linked": "This workspace has linked sessions and cannot be deleted.", + "settings.workspaces.default.title": "Default environment", + "settings.workspaces.default.description": "Choose where new sessions start", + "settings.workspaces.default.lastUsed": "Last used per project", + "settings.workspaces.default.local": "Local directory", + "settings.workspaces.default.new": "New workspace", + "workspace.move.title": "Move to workspace", + "workspace.move.menu.title": "Move session to", + "workspace.move.failed": "Failed to move session", + "workspace.lifecycle.creating": "Creating workspace", + "workspace.lifecycle.created": "Workspace created", + "workspace.lifecycle.starting": "Starting session", + "workspace.onboarding.title": "Isolate sessions with workspaces", + "workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository", + "workspace.lifecycle.moving": "Moving to workspace", + "workspace.lifecycle.set": "Workspace set", + "session.summary.title": "Session details", + "session.summary.noBranch": "No branch", + "session.summary.basedOn": "Based on {{branch}}", } diff --git a/packages/app/src/i18n/vi.ts b/packages/app/src/i18n/vi.ts index 1e243ba17d6..516b05d823b 100644 --- a/packages/app/src/i18n/vi.ts +++ b/packages/app/src/i18n/vi.ts @@ -1153,4 +1153,50 @@ export const dict = { "workspace.reset.archived.one": "1 phiên sẽ được lưu trữ.", "workspace.reset.archived.many": "{{count}} phiên sẽ được lưu trữ.", "workspace.reset.note": "Điều này sẽ thiết lập lại không gian làm việc để phù hợp với nhánh mặc định.", + "command.session.export": "Export session", + "command.session.export.description": "Export the full session transcript as JSON", + "context.export.session": "Export session", + "toast.session.export.success.title": "Session exported", + "toast.session.export.success.description": "Saved session to {{filename}}", + "toast.session.export.failed.title": "Failed to export session", + "toast.session.export.failed.description": "An error occurred while exporting the session", + "common.export": "Export", + "common.viewAll": "View all", + "session.new.workspace.local.tooltip": "Use current checkout", + "session.new.workspace.new.tooltip": "Create isolated checkout", + "session.new.workspace.fromBranch": "from {{branch}}", + "session.new.workspace.trigger.tooltip": "Select where to run session", + "session.new.workspace.search.placeholder": "Search workspaces", + "settings.tab.workspaces": "Workspaces", + "settings.workspaces.filter.all": "All projects", + "settings.workspaces.empty": "No workspaces", + "settings.workspaces.count.one": "{{count}} workspace", + "settings.workspaces.count.other": "{{count}} workspaces", + "settings.workspaces.sessions.one": "{{count}} session in {{project}}", + "settings.workspaces.sessions.other": "{{count}} sessions in {{project}}", + "settings.workspaces.lastActiveSession": "Last active session", + "settings.workspaces.deleteAll": "Delete all workspaces", + "settings.workspaces.deleteAll.confirm": "Delete all {{count}} workspaces?", + "settings.workspaces.delete.warning": "The workspace directory and branch will be permanently removed. Deletion proceeds only if it is clean, inactive, and has no linked sessions.", + "settings.workspaces.deleteAll.warning": "The {{count}} selected workspaces in {{project}} will be permanently removed only if each is clean, inactive, and has no linked sessions.", + "settings.workspaces.delete.blocked.active": "The active workspace cannot be deleted.", + "settings.workspaces.delete.blocked.linked": "This workspace has linked sessions and cannot be deleted.", + "settings.workspaces.default.title": "Default environment", + "settings.workspaces.default.description": "Choose where new sessions start", + "settings.workspaces.default.lastUsed": "Last used per project", + "settings.workspaces.default.local": "Local directory", + "settings.workspaces.default.new": "New workspace", + "workspace.move.title": "Move to workspace", + "workspace.move.menu.title": "Move session to", + "workspace.move.failed": "Failed to move session", + "workspace.lifecycle.creating": "Creating workspace", + "workspace.lifecycle.created": "Workspace created", + "workspace.lifecycle.starting": "Starting session", + "workspace.onboarding.title": "Isolate sessions with workspaces", + "workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository", + "workspace.lifecycle.moving": "Moving to workspace", + "workspace.lifecycle.set": "Workspace set", + "session.summary.title": "Session details", + "session.summary.noBranch": "No branch", + "session.summary.basedOn": "Based on {{branch}}", } diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index bddf98a4259..b9e5f6f921f 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -1244,4 +1244,19 @@ export const dict = { "error.childStore.persistedProjectIconCreateFailed": "创建持久化项目图标失败", "error.childStore.storeCreateFailed": "创建存储失败", "terminal.connectionLost.abnormalClose": "WebSocket 异常关闭:{{code}}", -} satisfies Partial> + "command.session.export": "Export session", + "command.session.export.description": "Export the full session transcript as JSON", + "context.export.session": "Export session", + "toast.session.export.success.title": "Session exported", + "toast.session.export.success.description": "Saved session to {{filename}}", + "toast.session.export.failed.title": "Failed to export session", + "toast.session.export.failed.description": "An error occurred while exporting the session", + "common.export": "Export", + "workspace.move.menu.title": "Move session to", + "workspace.lifecycle.starting": "Starting session", + "workspace.onboarding.title": "Isolate sessions with workspaces", + "workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository", + "session.summary.title": "Session details", + "session.summary.noBranch": "No branch", + "session.summary.basedOn": "Based on {{branch}}", +} diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index d3df431e8a6..81c390b81b9 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -1240,4 +1240,19 @@ export const dict = { "error.childStore.persistedProjectIconCreateFailed": "建立持續性專案圖示失敗", "error.childStore.storeCreateFailed": "建立儲存區失敗", "terminal.connectionLost.abnormalClose": "WebSocket 異常關閉:{{code}}", -} satisfies Partial> + "command.session.export": "Export session", + "command.session.export.description": "Export the full session transcript as JSON", + "context.export.session": "Export session", + "toast.session.export.success.title": "Session exported", + "toast.session.export.success.description": "Saved session to {{filename}}", + "toast.session.export.failed.title": "Failed to export session", + "toast.session.export.failed.description": "An error occurred while exporting the session", + "common.export": "Export", + "workspace.move.menu.title": "Move session to", + "workspace.lifecycle.starting": "Starting session", + "workspace.onboarding.title": "Isolate sessions with workspaces", + "workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository", + "session.summary.title": "Session details", + "session.summary.noBranch": "No branch", + "session.summary.basedOn": "Based on {{branch}}", +} diff --git a/packages/app/src/pages/new-session/new-session-view.tsx b/packages/app/src/pages/new-session/new-session-view.tsx index 3c84b1d58ef..4e1373bf0f8 100644 --- a/packages/app/src/pages/new-session/new-session-view.tsx +++ b/packages/app/src/pages/new-session/new-session-view.tsx @@ -1,6 +1,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { Tooltip } from "@opencode-ai/ui/tooltip" -import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { Icon } from "@opencode-ai/ui/v2/icon" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2" import { Show, createMemo, createSignal, type Accessor } from "solid-js" @@ -33,10 +33,7 @@ export function NewSessionView(props: { }) { const [onboarding, setOnboarding, , onboardingReady] = persisted( Persist.global("workspace-onboarding"), - createStore({ cardDismissed: false, used: false }), - ) - const showCard = createMemo( - () => onboardingReady() && props.workspace.bar.visible() && !onboarding.cardDismissed && !onboarding.used, + createStore({ used: false }), ) const select = (value: string) => { props.workspace.selection.set(value) @@ -151,7 +148,7 @@ function ProviderTip() { > {language.t("home.providerTip")} setPersistedState("dismissedAt", Date.now())} > - +
diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 4ceb43dfa33..d4591bcd2f3 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -290,7 +290,7 @@ function WorkspaceMoveAction(props: { : "flex h-[46px] w-full items-center gap-2 rounded-b-[6px] px-3 pr-9 pt-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted focus-visible:outline-none" } > - + {language.t("workspace.move.title")}