diff --git a/packages/app/AGENTS.md b/packages/app/AGENTS.md index 2f587ea73d5..6aad884e755 100644 --- a/packages/app/AGENTS.md +++ b/packages/app/AGENTS.md @@ -22,6 +22,8 @@ ## Localization - NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, toasts, empty states, and displayed errors. +- Feature work adds English source strings only. Leave non-English keys absent so the runtime English fallback applies; translations land separately after language review. +- Render count-sensitive copy only through `language.plural(baseKey, count, params)`. Never select or pass `.zero`, `.one`, `.two`, `.few`, `.many`, or `.other` variants to `language.t(...)`. - When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change. - NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it. - Do not translate from model knowledge alone. Verify terminology and grammar with Unicode CLDR locale/plural data, Microsoft Localization Style Guides and terminology, Apple localization/style guidance and localized platform UI, Mozilla localization style guides, Mozilla Pontoon, and the Firefox localization corpus at `github.com/mozilla-l10n/firefox-l10n`. diff --git a/packages/app/e2e/regression/session-timeline-transport.spec.ts b/packages/app/e2e/regression/session-timeline-transport.spec.ts index 35980499787..1bc2e755949 100644 --- a/packages/app/e2e/regression/session-timeline-transport.spec.ts +++ b/packages/app/e2e/regression/session-timeline-transport.spec.ts @@ -99,13 +99,15 @@ test("reconnects after a stream error", async ({ page }) => { test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => { const timeline = await setupTimeline(page, { protocol: "v2" }) - const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), { - id: "timeline-event-7", - }) + const first = await timeline.transport.send( + partUpdated(textPart("prt_transport_id", "event with id")), + { id: "timeline-event-7" }, + "/api/event", + ) await timeline.waitForPart("prt_transport_id") - await timeline.transport.error("retry with event id") - const connection = await timeline.transport.waitForConnection({ after: first.connectionID }) + await timeline.transport.error("retry with event id", "/api/event") + const connection = await timeline.transport.waitForConnection({ after: first.connectionID, path: "/api/event" }) expect(first.eventID).toBe("timeline-event-7") expect(connection.headers["last-event-id"]).toBeUndefined() diff --git a/packages/app/e2e/regression/workspaces.spec.ts b/packages/app/e2e/regression/workspaces.spec.ts index a193097d4c0..d0c90767b62 100644 --- a/packages/app/e2e/regression/workspaces.spec.ts +++ b/packages/app/e2e/regression/workspaces.spec.ts @@ -63,7 +63,7 @@ async function init(page: Page, tab: Record) { ) } -test("selects local, new, and existing workspaces from the ready-ish start menu", async ({ page }) => { +test("selects an existing workspace from the start menu", async ({ page }) => { const draftID = "draft_workspaces" await mockOpenCodeServer(page, { directory: root, @@ -77,98 +77,23 @@ test("selects local, new, and existing workspaces from the ready-ish start menu" await page.goto(`/new-session?draftId=${draftID}`) await expectAppVisible(page.locator('[data-component="prompt-input"]')) - const trigger = page.getByRole("button", { name: /^local$/i }) - const dot = page.locator('[data-slot="workspace-onboarding-dot"]') - await expect(trigger).toBeVisible() - await expect(dot).toBeVisible() - await trigger.hover() - await expect(page.getByRole("tooltip")).toContainText("Isolate sessions with workspaces") - await trigger.click() - 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() - 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() - await expect(dot).toBeHidden() - await expect(page.getByRole("button", { name: /New workspace/ })).toBeVisible() - await expect(page.getByText("from main", { exact: true })).toBeVisible() - - await page.getByRole("button", { name: /New workspace/ }).click() + await page.getByRole("button", { name: /^local$/i }).click() await page.getByRole("menuitem", { name: /Workspace/ }).hover() await page.getByRole("menuitem", { name: "feature" }).click() await expect(page.getByRole("button", { name: /feature/ })).toBeVisible() }) -test("searches long workspace lists within the available viewport", async ({ page }) => { - const draftID = "draft_workspace_search" - const workspaces = Array.from({ length: 10 }, (_, index) => `${workspace}-${index}`) - await mockOpenCodeServer(page, { - directory: root, - project: { ...project, sandboxes: workspaces }, - provider, - sessions: [], - pageMessages: () => ({ items: [] }), - }) - await init(page, { type: "draft", draftID, directory: root }) - await page.setViewportSize({ width: 600, height: 360 }) - - await page.goto(`/new-session?draftId=${draftID}`) - await expectAppVisible(page.locator('[data-component="prompt-input"]')) - await page.getByRole("button", { name: /local|new workspace/i }).click() - await page.getByRole("menuitem", { name: /Workspace/ }).focus() - await page.keyboard.press("ArrowRight") - - const submenu = page - .locator('[data-component="menu-v2-content"]') - .filter({ has: page.getByPlaceholder("Search workspaces") }) - const search = submenu.getByPlaceholder("Search workspaces") - await expect(search).toBeFocused() - const box = await submenu.boundingBox() - expect(box).not.toBeNull() - expect(box!.y).toBeGreaterThanOrEqual(0) - expect(box!.y + box!.height).toBeLessThanOrEqual(360) - - await search.fill("feature-9") - await expect(submenu.getByRole("menuitem", { name: "feature-9" })).toBeVisible() - await expect(submenu.getByRole("menuitem", { name: "feature-0" })).toHaveCount(0) -}) - test("lists and manually deletes workspaces from settings", async ({ page }) => { const draftID = "draft_workspace_settings" const cleanWorkspace = `${workspace}-clean` - const inventory = { ...project, sandboxes: [workspace, cleanWorkspace] } - const session = { - id: "ses_workspace_settings", - slug: "workspace-settings", - projectID: project.id, - directory: workspace, - title: "Workspace settings session", - version: "dev", - time: { created: 1, updated: 2 }, - } - let removal: { directory: string | null; body: unknown } | undefined - let statusRequests = 0 - let sessionListRequests = 0 - page.on("request", (request) => { - const path = new URL(request.url()).pathname - if (path === "/vcs/status" || path === "/api/vcs/status") statusRequests++ - if (path === "/session" || path === "/api/session") sessionListRequests++ - }) + const inventory = { ...project, sandboxes: [cleanWorkspace] } const transport = await installSseTransport<{ directory: string; payload: Record }>(page, { server }) await mockOpenCodeServer(page, { directory: root, project: inventory, provider, - sessions: [session], + sessions: [], pageMessages: () => ({ items: [] }), }) await page.route("**/experimental/worktree**", async (route) => { @@ -180,14 +105,12 @@ test("lists and manually deletes workspaces from settings", async ({ page }) => return } if (route.request().method() !== "DELETE") return route.fallback() - const url = new URL(route.request().url()) - removal = { directory: url.searchParams.get("directory"), body: route.request().postDataJSON() } await transport.send({ directory: "global", payload: { id: "evt_workspace_deleted_settings", type: "project.updated", - properties: { ...inventory, sandboxes: [workspace] }, + properties: { ...inventory, sandboxes: [] }, }, }) await route.fulfill({ @@ -209,257 +132,20 @@ test("lists and manually deletes workspaces from settings", async ({ page }) => const settings = page.locator(".settings-v2-dialog") await expect(settings.getByRole("tab", { name: "Workspaces" })).toHaveAttribute("data-selected") - await expect(settings.getByText(workspace, { exact: true })).toBeVisible() await expect(settings.getByText(cleanWorkspace, { exact: true })).toBeVisible() - await expect(settings.getByText("Workspace settings session", { exact: true })).toBeVisible() - await expect(settings.getByText("Automatically delete old workspaces")).toHaveCount(0) - const firstSessionInventory = sessionListRequests - await page.keyboard.press("Escape") - await expect(settings).toHaveCount(0) - 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) - await page.setViewportSize({ width: 375, height: 700 }) - expect(await settings.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true) - await page.setViewportSize({ width: 1280, height: 720 }) - - await settings.getByRole("button", { name: "More options" }).click() - await page.getByRole("menuitem", { name: "Delete all workspaces" }).click() - const bulkConfirmation = page.locator('[data-dialog-layer="1"]') - await expect(bulkConfirmation.getByText("Delete all 2 workspaces?")).toBeVisible() - await expect(bulkConfirmation).toContainText("All projects") - await bulkConfirmation.getByRole("button", { name: "Cancel" }).click() - - await settings.getByRole("button", { name: 'Delete workspace "feature"?' }).click() - let confirmation = page.locator('[data-component="dialog-v2"]').filter({ hasText: 'Delete workspace "feature"?' }) - await expect(confirmation).toContainText("linked sessions") - await expect(confirmation.getByRole("button", { name: "Delete workspace" })).toBeDisabled() - const firstInspection = statusRequests - await confirmation.getByRole("button", { name: "Cancel" }).click() - - await settings.getByRole("button", { name: 'Delete workspace "feature"?' }).click() - confirmation = page.locator('[data-component="dialog-v2"]').filter({ hasText: 'Delete workspace "feature"?' }) - await expect(confirmation).toContainText("linked sessions") - expect(statusRequests).toBeGreaterThan(firstInspection) - await confirmation.getByRole("button", { name: "Cancel" }).click() await settings.getByRole("button", { name: 'Delete workspace "feature-clean"?' }).click() - confirmation = page.locator('[data-component="dialog-v2"]').filter({ hasText: 'Delete workspace "feature-clean"?' }) - await expect(confirmation).toContainText("permanently removed") - const beforeRemoval = statusRequests + const confirmation = page + .locator('[data-component="dialog-v2"]') + .filter({ hasText: 'Delete workspace "feature-clean"?' }) + const removed = page.waitForRequest( + (request) => request.method() === "DELETE" && new URL(request.url()).pathname === "/experimental/worktree", + ) await confirmation.getByRole("button", { name: "Delete workspace" }).click() - await expect.poll(() => removal).toEqual({ directory: root, body: { directory: cleanWorkspace } }) - expect(statusRequests).toBeGreaterThan(beforeRemoval) + const request = await removed + expect(new URL(request.url()).searchParams.get("directory")).toBe(root) + expect(request.postDataJSON()).toEqual({ directory: cleanWorkspace }) await expect(settings.getByText(cleanWorkspace, { exact: true })).toHaveCount(0) - await expect(settings.getByText(workspace, { exact: true })).toBeVisible() -}) - -test("blocks deletion of the currently active workspace", async ({ page }) => { - const draftID = "draft_workspace_active" - let removed = false - await mockOpenCodeServer(page, { - directory: workspace, - project, - provider, - sessions: [], - pageMessages: () => ({ items: [] }), - }) - await page.route("**/experimental/worktree**", async (route) => { - if (route.request().method() !== "DELETE") return route.fallback() - removed = true - await route.fulfill({ status: 200, contentType: "application/json", body: "true" }) - }) - await init(page, { type: "draft", draftID, directory: workspace }) - - 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() - const confirmation = page.locator('[data-component="dialog-v2"]').filter({ hasText: 'Delete workspace "feature"?' }) - await expect(confirmation).toContainText("active workspace") - await expect(confirmation.getByRole("button", { name: "Delete workspace" })).toBeDisabled() - expect(removed).toBe(false) -}) - -test("wraps the workspace toolbar for long project filters on mobile", async ({ page }) => { - const draftID = "draft_workspace_mobile_filter" - const other = { - ...project, - id: "proj_workspaces_other", - name: "A second project with a deliberately long workspace filter label", - worktree: "C:/OpenCode/AnotherWorkspaceProject", - sandboxes: ["C:/OpenCode/worktree/another/feature-with-a-long-name"], - } - await mockOpenCodeServer(page, { - directory: root, - project, - provider, - sessions: [], - pageMessages: () => ({ items: [] }), - }) - await page.route("**/project**", async (route) => { - if (new URL(route.request().url()).pathname !== "/project") return route.fallback() - await route.fulfill({ - status: 200, - contentType: "application/json", - headers: { "access-control-allow-origin": "*" }, - body: JSON.stringify([project, other]), - }) - }) - await init(page, { type: "draft", draftID, directory: root }) - await page.setViewportSize({ width: 375, height: 700 }) - - 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|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() - expect(await settings.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true) -}) - -test("bulk deletion snapshots inventory and skips dirty or unknown workspaces", async ({ page }) => { - const draftID = "draft_workspace_delete_all" - const unknown = `${workspace}-unknown` - const clean = `${workspace}-clean` - const later = `${workspace}-later` - const inventory = { ...project, sandboxes: [workspace, unknown, clean] } - const removals: string[] = [] - let release = () => {} - const deleteGate = new Promise((resolve) => { - release = resolve - }) - const transport = await installSseTransport<{ directory: string; payload: Record }>(page, { server }) - await mockOpenCodeServer(page, { - directory: root, - project: inventory, - provider, - sessions: [], - pageMessages: () => ({ items: [] }), - }) - await page.route("**/vcs/status**", async (route) => { - const directory = new URL(route.request().url()).searchParams.get("directory") - if (directory === workspace) { - await route.fulfill({ - status: 200, - contentType: "application/json", - headers: { "access-control-allow-origin": "*" }, - body: JSON.stringify([{ path: "dirty.ts", status: "modified" }]), - }) - return - } - if (directory === unknown) { - await route.fulfill({ - status: 500, - contentType: "application/json", - headers: { "access-control-allow-origin": "*" }, - body: JSON.stringify({ message: "status failed" }), - }) - return - } - await route.fulfill({ - status: 200, - contentType: "application/json", - headers: { "access-control-allow-origin": "*" }, - body: "[]", - }) - }) - 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": "DELETE" }, - }) - return - } - if (route.request().method() !== "DELETE") return route.fallback() - const body = route.request().postDataJSON() as { directory: string } - removals.push(body.directory) - await deleteGate - await route.fulfill({ - status: 200, - contentType: "application/json", - headers: { "access-control-allow-origin": "*" }, - body: "true", - }) - }) - await init(page, { type: "draft", draftID, directory: root }) - - await page.goto(`/new-session?draftId=${draftID}`) - await transport.waitForConnection() - 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() - await page.getByRole("menuitem", { name: "Delete all workspaces" }).click() - const confirmation = page.locator('[data-dialog-layer="1"]') - await expect(confirmation).toContainText("Delete all 3 workspaces?") - await transport.send({ - directory: "global", - payload: { - id: "evt_workspace_added_after_confirmation", - type: "project.updated", - properties: { ...inventory, sandboxes: [...inventory.sandboxes, later] }, - }, - }) - await confirmation.getByRole("button", { name: "Delete all workspaces" }).click() - - await expect.poll(() => removals).toEqual([clean]) - const deleteButtons = settings.getByRole("button", { name: /Delete workspace/ }) - await expect.poll(() => deleteButtons.count()).toBeGreaterThan(0) - expect(await deleteButtons.evaluateAll((buttons) => buttons.every((button) => button.hasAttribute("disabled")))).toBe( - true, - ) - release() - await expect(settings.getByText(workspace, { exact: true })).toBeVisible() - await expect(settings.getByText(unknown, { exact: true })).toBeVisible() - await expect(settings.getByText(clean, { exact: true })).toHaveCount(0) - await expect(settings.getByText(later, { exact: true })).toBeVisible() -}) - -test("applies the recovered new-workspace default without automatic cleanup", async ({ page }) => { - const draftID = "draft_workspace_default" - await mockOpenCodeServer(page, { - directory: root, - project, - provider, - sessions: [], - pageMessages: () => ({ items: [] }), - }) - await page.addInitScript( - ({ draftID, root, server }) => { - localStorage.setItem( - "settings.v3", - JSON.stringify({ - general: { newLayoutDesigns: true }, - workspaces: { defaultDestination: "new", lastUsed: {} }, - }), - ) - 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([{ type: "draft", draftID, server, directory: root }]), - ) - }, - { draftID, root, server }, - ) - - await page.goto(`/new-session?draftId=${draftID}`) - await expect(page.getByRole("button", { name: /New workspace/ })).toBeVisible() - await expect(page.getByText("from main", { exact: true })).toBeVisible() }) test("submits the owning prompt after a new workspace becomes ready", async ({ page }) => { @@ -474,7 +160,6 @@ test("submits the owning prompt after a new workspace becomes ready", async ({ p 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, @@ -525,7 +210,6 @@ test("submits the owning prompt after a new workspace becomes ready", async ({ p }) return } - prompt = route.request().postDataJSON() await route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" }, @@ -543,142 +227,20 @@ test("submits the owning prompt after a new workspace becomes ready", async ({ p 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 sent = page.waitForRequest( + (request) => + request.method() === "POST" && new URL(request.url()).pathname === `/session/${sessionID}/prompt_async`, ) - - 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 + await transport.send({ + directory: createdWorkspace, + payload: { + id: "evt_submit_ready", + type: "worktree.ready", + properties: { name: "quick-contrast-fix" }, + }, }) - 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") + await sent + await expect(lifecycle).toContainText("Workspace created") }) test("moves a changed local session through workspace creation without changing lifecycle semantics", async ({ @@ -695,7 +257,6 @@ test("moves a changed local session through workspace creation without changing 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, @@ -729,7 +290,7 @@ test("moves a changed local session through workspace creation without changing }) return } - move = route.request().postDataJSON() + session.directory = createdWorkspace await route.fulfill({ status: 200, contentType: "application/json", @@ -749,25 +310,23 @@ test("moves a changed local session through workspace creation without changing 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, - }) + const moved = page.waitForRequest( + (request) => + request.method() === "POST" && new URL(request.url()).pathname === "/experimental/control-plane/move-session", + ) + await transport.send({ + directory: createdWorkspace, + payload: { + id: "evt_worktree_ready", + type: "worktree.ready", + properties: { name: "quick-contrast-fix" }, + }, + }) + expect((await moved).postDataJSON()).toEqual({ + sessionID, + destination: { directory: createdWorkspace }, + moveChanges: true, + }) await transport.send({ directory: createdWorkspace, payload: { diff --git a/packages/app/e2e/utils/sse-transport.ts b/packages/app/e2e/utils/sse-transport.ts index a0a20a2e17f..a1e32e28daf 100644 --- a/packages/app/e2e/utils/sse-transport.ts +++ b/packages/app/e2e/utils/sse-transport.ts @@ -29,23 +29,38 @@ export type SseEventOptions = { export type SseTransport = { server: string - waitForConnection(options?: { after?: number; timeout?: number }): Promise - send(payload: T, options?: SseEventOptions): Promise + waitForConnection(options?: { + after?: number + timeout?: number + path?: SseConnectionRecord["path"] + }): Promise + send(payload: T, options?: SseEventOptions, path?: SseConnectionRecord["path"]): Promise burst(payloads: readonly T[], options?: readonly SseEventOptions[]): Promise split(payload: T, cuts: readonly number[], options?: SseEventOptions): Promise heartbeat(options?: SseEventOptions): Promise writeRaw(value: string | Uint8Array, cuts?: readonly number[], marker?: string): Promise close(): Promise disconnect(message?: string): Promise - error(message?: string): Promise + error(message?: string, path?: SseConnectionRecord["path"]): Promise connections(): Promise acknowledgements(): Promise } type BrowserCommand = - | { type: "send"; deliveries: { payload: T; options?: SseEventOptions }[]; burst: boolean; cuts?: number[] } + | { + type: "send" + deliveries: { payload: T; options?: SseEventOptions }[] + burst: boolean + cuts?: number[] + path?: SseConnectionRecord["path"] + } | { type: "raw"; bytes: number[]; cuts?: number[]; marker?: string } - | { type: "end"; mode: "close" | "disconnect" | "error"; message?: string } + | { + type: "end" + mode: "close" | "disconnect" | "error" + message?: string + path?: SseConnectionRecord["path"] + } | { type: "connections" } | { type: "acknowledgements" } @@ -73,7 +88,8 @@ export async function installSseTransport( let nextConnectionID = 0 let nextDeliveryID = 0 - const current = () => connections.findLast((connection) => connection.endedAt === undefined) + const current = (path?: SseConnectionRecord["path"]) => + connections.findLast((connection) => connection.endedAt === undefined && (!path || connection.path === path)) const chunks = (bytes: Uint8Array, cuts?: readonly number[]) => { const boundaries = [...new Set(cuts ?? [])] .filter((cut) => Number.isInteger(cut) && cut > 0 && cut < bytes.byteLength) @@ -125,8 +141,8 @@ export async function installSseTransport( acknowledgements.push(acknowledgement) return acknowledgement } - const end = (mode: "close" | "disconnect" | "error", message?: string) => { - const connection = current() + const end = (mode: "close" | "disconnect" | "error", message?: string, path?: SseConnectionRecord["path"]) => { + const connection = current(path) if (!connection) throw new Error("SSE transport has no active connection") connection.endedAt = performance.now() connection.endedBy = mode @@ -146,8 +162,8 @@ export async function installSseTransport( if (input.type === "connections") return connections.map(({ controller: _controller, ...connection }) => connection) if (input.type === "acknowledgements") return acknowledgements - if (input.type === "end") return end(input.mode, input.message) - const connection = current() + if (input.type === "end") return end(input.mode, input.message, input.path) + const connection = current(input.type === "send" ? input.path : undefined) if (!connection) throw new Error("SSE transport has no active connection") if (input.type === "raw") { marker(input.marker) @@ -248,12 +264,15 @@ export async function installSseTransport( server, async waitForConnection(input = {}) { const connection = await page.waitForFunction( - (after) => { + ({ after, path }) => { const transport = (window as BrowserTransport).__testSseTransport const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined - return connections?.findLast((connection) => connection.id > after && connection.endedAt === undefined) + return connections?.findLast( + (connection) => + connection.id > after && connection.endedAt === undefined && (!path || connection.path === path), + ) }, - input.after ?? 0, + { after: input.after ?? 0, path: input.path }, { timeout: input.timeout }, ) let result: SseConnectionRecord | undefined @@ -265,8 +284,8 @@ export async function installSseTransport( if (!result) throw new Error("SSE transport connection disappeared while waiting") return result }, - send(payload, eventOptions) { - return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false }) + send(payload, eventOptions, path) { + return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false, path }) }, burst(payloads, eventOptions = []) { return command({ @@ -304,8 +323,8 @@ export async function installSseTransport( disconnect(message) { return command({ type: "end", mode: "disconnect", message }) }, - error(message) { - return command({ type: "end", mode: "error", message }) + error(message, path) { + return command({ type: "end", mode: "error", message, path }) }, connections() { return command({ type: "connections" }) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 0c950871a33..996f912a73b 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -1,6 +1,7 @@ import "@/index.css" import * as Sentry from "@sentry/solid" import { I18nProvider } from "@opencode-ai/ui/context" +import type { UiI18n } from "@opencode-ai/ui/context/i18n" import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { FileComponentProvider } from "@opencode-ai/ui/context/file" import { File } from "@opencode-ai/session-ui/file" @@ -235,7 +236,13 @@ function UiI18nBridge(props: ParentProps) { const language = useLanguage() return ( {props.children} diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index 3a058a443be..3cb9f845cb0 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -1,21 +1,18 @@ 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 -const createdClients: string[] = [] const createdSessions: string[] = [] -const sessionCreateInputs: Array<{ +type SessionCreateInput = { agent?: string model?: { id: string; providerID: string; variant?: string } location?: { directory: string } -}> = [] -const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = [] +} const optimistic: Array<{ directory?: string sessionID?: string @@ -25,17 +22,13 @@ const optimistic: Array<{ variant?: string } }> = [] -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 sentCommandDirectories: string[] = [] const updatedDrafts: Array<{ draftID: string; worktree?: string }> = [] const syncedServers: string[] = [] const optimisticServers: string[] = [] @@ -46,9 +39,7 @@ let params: { id?: string } = {} let search: { draftId?: string } = {} 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 @@ -94,17 +85,14 @@ const prompt = { } const settle = () => new Promise((resolve) => setTimeout(resolve, 0)) -const clientFor = (directory: string, track = true) => { - if (track) createdClients.push(directory) +const clientFor = (directory: string) => { return { api: { session: { - create: async (input: (typeof sessionCreateInputs)[number]) => { + create: async (input: SessionCreateInput) => { await createSessionGate - if (sessionFailure) throw sessionFailure const location = input.location?.directory ?? directory createdSessions.push(location) - sessionCreateInputs.push(input) return { id: `session-${createdSessions.length}`, projectID: "project", @@ -124,7 +112,6 @@ const clientFor = (directory: string, track = true) => { }, command: async (input: unknown) => { sentCommands.push(input) - sentCommandDirectories.push(directory) }, shell: async (input: { sessionID: string; id?: string; command: string }) => { sentShell.push(input) @@ -163,7 +150,6 @@ beforeAll(async () => { mock.module("@opencode-ai/sdk/v2/client", () => ({ createOpencodeClient: (input: { directory: string }) => { - createdClients.push(input.directory) return clientFor(input.directory) }, })) @@ -188,20 +174,13 @@ beforeAll(async () => { current: () => ({ name: "agent" }), }, session: { - promote(directory: string, sessionID: string) { - promoted.push({ directory, sessionID }) - }, + promote: () => undefined, }, }), })) mock.module("@/context/permission", () => { - const state = (server: string) => ({ - enableAutoAccept(sessionID: string, directory: string) { - enabledAutoAccept.push({ server, sessionID, directory }) - }, - }) - return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) } + return { usePermission: () => ({ currentServerState: () => ({ enableAutoAccept: () => undefined }) }) } }) mock.module("@/context/server", () => ({ @@ -240,7 +219,7 @@ beforeAll(async () => { client: rootClient, api: rootClient.api, url: "http://localhost:4096", - createApi: (directory: string) => clientFor(directory, false).api, + createApi: (directory: string) => clientFor(directory).api, createClient(opts: any) { return clientFor(opts.directory) }, @@ -262,11 +241,6 @@ beforeAll(async () => { }) => { optimisticServers.push(server) optimistic.push(value) - optimisticSeeded.push( - !!value.directory && - !!value.sessionID && - !!storedSessions[value.directory]?.find((item) => item.id === value.sessionID)?.title, - ) }, remove: () => undefined, }, @@ -290,7 +264,6 @@ beforeAll(async () => { }, child: (directory: string) => { syncedServers.push(server) - syncedDirectories.push(directory) storedSessions[directory] ??= [] return [ { session: storedSessions[directory] }, @@ -328,17 +301,11 @@ beforeAll(async () => { }) beforeEach(() => { - createdClients.length = 0 createdSessions.length = 0 - sessionCreateInputs.length = 0 - enabledAutoAccept.length = 0 optimistic.length = 0 - optimisticSeeded.length = 0 - promoted.length = 0 promotedDrafts.length = 0 updatedDrafts.length = 0 sentCommands.length = 0 - sentCommandDirectories.length = 0 sentPrompts.length = 0 promptInputs.length = 0 syncedServers.length = 0 @@ -348,10 +315,8 @@ beforeEach(() => { 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" @@ -360,7 +325,6 @@ beforeEach(() => { worktreeDirectory = `/repo/new-${++worktreeID}` createSessionGate = undefined serverSessionSyncs = 0 - sessionFailure = undefined createWorktreeGate = undefined worktreeFailure = undefined worktreeHung = false @@ -369,6 +333,29 @@ beforeEach(() => { for (const key of Object.keys(storedSessions)) delete storedSessions[key] }) +const event = { preventDefault: () => undefined } as unknown as Event +const makeSubmit = (overrides: Partial[0]> = {}) => + 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, + ...overrides, + }) + describe("prompt submit worktree selection", () => { test("admits only one concurrent new-workspace submission", async () => { selected = "create" @@ -376,27 +363,8 @@ describe("prompt submit worktree selection", () => { 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 submit = makeSubmit() - const event = { preventDefault: () => undefined } as unknown as Event const first = submit.handleSubmit(event) const duplicate = submit.handleSubmit(event) expect(worktreeCreates).toBe(1) @@ -412,70 +380,15 @@ describe("prompt submit worktree selection", () => { 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, + const submit = makeSubmit({ onNewSessionWorktreeReset: () => resets++, - onSubmit: () => undefined, worktreeRequestTimeoutMs: 1, }) - const event = { preventDefault: () => undefined } as unknown as Event await submit.handleSubmit(event) expect(worktreeCreates).toBe(1) @@ -495,160 +408,6 @@ describe("prompt submit worktree selection", () => { 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, - 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, - }) - - const event = { preventDefault: () => undefined } as unknown as Event - - await submit.handleSubmit(event) - selected = "/repo/worktree-b" - await submit.handleSubmit(event) - - expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) - expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) - expect(sessionCreateInputs).toEqual([ - { - agent: "agent", - model: { id: "model", providerID: "provider", variant: undefined }, - location: { directory: "/repo/worktree-a" }, - }, - { - agent: "agent", - model: { id: "model", providerID: "provider", variant: undefined }, - location: { directory: "/repo/worktree-b" }, - }, - ]) - expect(sentShell).toEqual([ - expect.objectContaining({ sessionID: "session-1", id: expect.stringMatching(/^evt_/), command: "ls" }), - expect.objectContaining({ sessionID: "session-2", id: expect.stringMatching(/^evt_/), command: "ls" }), - ]) - expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"]) - expect(serverSessionSyncs).toBe(0) - expect(promoted).toEqual([ - { directory: "/repo/worktree-a", sessionID: "session-1" }, - { directory: "/repo/worktree-b", sessionID: "session-2" }, - ]) - expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"]) - }) - - test("applies auto-accept to newly created sessions", async () => { - const submit = createPromptSubmit({ - prompt, - info: () => undefined, - imageAttachments: () => [], - commentCount: () => 0, - autoAccept: () => true, - mode: () => "shell", - working: () => false, - editor: () => undefined, - queueScroll: () => undefined, - promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), - addToHistory: () => undefined, - resetHistoryNavigation: () => undefined, - setMode: () => undefined, - setPopover: () => undefined, - newSessionWorktree: () => selected, - onNewSessionWorktreeReset: () => undefined, - onSubmit: () => undefined, - }) - - const event = { preventDefault: () => undefined } as unknown as Event - - await submit.handleSubmit(event) - - expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }]) - }) - - test("keeps auto-accept bound to the submission server", async () => { - let release = () => {} - createSessionGate = new Promise((resolve) => { - release = resolve - }) - const submit = createPromptSubmit({ - prompt, - info: () => undefined, - imageAttachments: () => [], - commentCount: () => 0, - autoAccept: () => true, - mode: () => "shell", - working: () => false, - editor: () => undefined, - queueScroll: () => undefined, - promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), - addToHistory: () => undefined, - resetHistoryNavigation: () => undefined, - setMode: () => undefined, - setPopover: () => undefined, - newSessionWorktree: () => selected, - onNewSessionWorktreeReset: () => undefined, - onSubmit: () => undefined, - }) - - const result = submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) - permissionServer = "server-b" - release() - await result - - 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" @@ -658,27 +417,11 @@ describe("prompt submit worktree selection", () => { 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, + const submit = makeSubmit({ onSubmit: () => submitted++, }) - const result = submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) + const result = submit.handleSubmit(event) activeSDK = "server-b" activeServerSync = "server-b" activeDirectorySync = "server-b" @@ -697,57 +440,14 @@ describe("prompt submit worktree selection", () => { expect(submitted).toBe(0) }) - test("promotes drafts using the selected project's server", async () => { - search = { draftId: "draft-1" } - 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(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server", sessionId: "session-1" }]) - }) - test("includes the selected variant on optimistic prompts", async () => { params.id = "session-1" variant = "high" - const submit = createPromptSubmit({ - prompt, + const submit = makeSubmit({ info: () => ({ id: "session-1" }), - 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, - onSubmit: () => undefined, }) - const event = { preventDefault: () => undefined } as unknown as Event - await submit.handleSubmit(event) await Bun.sleep(0) @@ -777,24 +477,11 @@ describe("prompt submit worktree selection", () => { commands.push({ name: "review" }) promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }] - const submit = createPromptSubmit({ - prompt, + const submit = makeSubmit({ info: () => ({ id: "session-1" }), - 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, }) - await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) + await submit.handleSubmit(event) await settle() expect(sentCommands).toEqual([ @@ -811,92 +498,13 @@ describe("prompt submit worktree selection", () => { expect(serverSessionSyncs).toBe(0) }) - test("uses an injected model selection", async () => { - params = { id: "session-1" } - const model = { - current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }), - variant: { current: () => "draft-variant" }, - } as unknown as ModelSelection - const submit = createPromptSubmit({ - prompt, - info: () => ({ id: "session-1" }), - 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, - model, - }) - - await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) - - expect(optimistic[0]).toMatchObject({ - message: { - model: { providerID: "draft-provider", modelID: "draft-model", variant: "draft-variant" }, - }, - }) - }) - - test("seeds new sessions before optimistic prompts are added", async () => { - 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(storedSessions["/repo/worktree-a"]).toHaveLength(1) - 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, + const submit = makeSubmit({ 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) + await submit.handleSubmit(event) expect(sentShell).toEqual([]) expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("pending") @@ -913,71 +521,11 @@ describe("prompt submit worktree selection", () => { 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, - }) + const submit = makeSubmit() - await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) + await submit.handleSubmit(event) expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("pending") params = { id: "session-1" } @@ -987,40 +535,6 @@ describe("prompt submit worktree selection", () => { 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 4f59c88b037..4436be1aa1e 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -284,7 +284,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { if (queued) { queued.abort.abort() queued.cleanup() - WorkspaceOperation.fail(queued.scope, queued.sessionID, "aborted") + WorkspaceOperation.fail(queued.scope, queued.sessionID) pending.delete(key) return Promise.resolve() } @@ -543,7 +543,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { const worktree = WorktreeState.get(submissionScope, sessionDirectory) WorkspaceOperation.start(submissionScope, session.id, "create", sessionDirectory, messageID) if (worktree?.status === "ready") WorkspaceOperation.complete(submissionScope, session.id) - if (worktree?.status === "failed") WorkspaceOperation.fail(submissionScope, session.id, worktree.message) + if (worktree?.status === "failed") WorkspaceOperation.fail(submissionScope, session.id) } const waitForWorktree = async (cleanup: VoidFunction) => { @@ -554,7 +554,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { return true } if (worktree.status === "failed") { - WorkspaceOperation.fail(submissionScope, session.id, worktree.message) + WorkspaceOperation.fail(submissionScope, session.id) throw new Error(worktree.message) } @@ -610,7 +610,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { }) if (controller.signal.aborted) return false if (result.status === "failed") { - WorkspaceOperation.fail(submissionScope, session.id, result.message) + WorkspaceOperation.fail(submissionScope, session.id) throw new Error(result.message) } WorkspaceOperation.complete(submissionScope, session.id) diff --git a/packages/app/src/components/session-workspace-menu.tsx b/packages/app/src/components/session-workspace-menu.tsx index 1ac2ad20cb1..25ec19df27a 100644 --- a/packages/app/src/components/session-workspace-menu.tsx +++ b/packages/app/src/components/session-workspace-menu.tsx @@ -45,9 +45,8 @@ export function SessionWorkspaceMenu(props: { workspaceDirectories(props.project).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) + WorkspaceOperation.fail(scope, sessionID) showToast({ variant: "error", title: language.t("workspace.move.failed"), description: message }) } const move = async (selection: "create" | string) => { @@ -72,10 +71,7 @@ export function SessionWorkspaceMenu(props: { 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 - } + if (sync.session.data.session_working(sessionID)) throw new Error(language.t("workspace.move.failed")) await workspaceRequestWithTimeout( (signal) => sdk.client.experimental.controlPlane.moveSession( @@ -89,26 +85,17 @@ export function SessionWorkspaceMenu(props: { 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")), - ) + const session = await workspaceRequestWithTimeout( + (signal) => sync.session.resolve(sessionID, { force: true, signal }), + language.t("workspace.move.failed"), + WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS, + ) + if (!session || pathKey(session.directory) !== pathKey(destination)) + throw new Error(language.t("workspace.move.failed")) + WorkspaceOperation.complete(scope, sessionID, destination) + sync.reindexSession(sessionID, source) + } catch (error) { + fail(scope, sessionID, error instanceof Error ? error.message : language.t("common.requestFailed")) } finally { setStore("selected", undefined) } diff --git a/packages/app/src/components/settings-v2/workspaces.tsx b/packages/app/src/components/settings-v2/workspaces.tsx index d1771ed87b5..92bca05e49a 100644 --- a/packages/app/src/components/settings-v2/workspaces.tsx +++ b/packages/app/src/components/settings-v2/workspaces.tsx @@ -29,9 +29,7 @@ import { inspectWorkspaceDeletion, mergeWorkspaceSessionInventory, removeWorkspacesSequentially, - runWorkspaceDeleteTransaction, sessionsForWorkspace, - type WorkspaceDeleteTransaction, type WorkspaceDeleteInspection, workspaceInventory, } from "@/utils/workspace" @@ -53,9 +51,7 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr const platform = usePlatform() const [store, setStore] = createStore({ project: "all", - deleting: undefined as string | undefined, - deletingAll: false, - transaction: undefined as WorkspaceDeleteTransaction, + transaction: undefined as "confirm" | "running" | undefined, }) const workspaces = createMemo(() => workspaceInventory(serverSync().data.project)) @@ -97,7 +93,7 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr if (sessionQuery.isPending) return language.t("session.messages.loading") if (sessionQuery.isError) return language.t("common.requestFailed") const count = workspaceSessions(workspace).length - return language.t(count === 1 ? "settings.workspaces.sessions.one" : "settings.workspaces.sessions.other", { + return language.plural("settings.workspaces.sessions", count, { count, project: projectName(workspace.project), }) @@ -113,30 +109,23 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr } const inspect = async (workspace: Workspace, context = captureDeleteContext()) => { - const [status, sessions] = await Promise.all([ - Promise.all([ - context.sdk.api.vcs.status({ location: { directory: workspace.directory } }), - context.sdk.api.vcs.diff({ location: { directory: workspace.directory }, mode: "branch" }), - ]) - .then(([working, branch]) => - working.data.length > 0 || branch.data.length > 0 ? ("dirty" as const) : ("clean" as const), - ) - .catch(() => "unknown" as const), - loadSessions(context).catch(() => undefined), + const [working, branch, sessions] = await Promise.all([ + context.sdk.api.vcs.status({ location: { directory: workspace.directory } }), + context.sdk.api.vcs.diff({ location: { directory: workspace.directory }, mode: "branch" }), + loadSessions(context), ]) const result = inspectWorkspaceDeletion({ workspace: workspace.directory, activeDirectory: context.activeDirectory, - sessions: sessions ?? [], - status: sessions ? status : "unknown", + sessions, + status: working.data.length > 0 || branch.data.length > 0 ? "dirty" : "clean", }) - return { result, sessions: sessions ?? [] } + return { result, sessions } } const inspectionMessage = (result: WorkspaceDeleteInspection) => { if (result === "active") return language.t("settings.workspaces.delete.blocked.active") if (result === "linked") return language.t("settings.workspaces.delete.blocked.linked") if (result === "dirty") return language.t("workspace.status.dirty") - if (result === "unknown") return language.t("workspace.status.error") return language.t("workspace.status.clean") } const blocked = (result: WorkspaceDeleteInspection) => { @@ -147,12 +136,10 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr }) } - const remove = async (workspace: Workspace, bulk = false, context = captureDeleteContext()) => { - if (!bulk) setStore("deleting", pathKey(workspace.directory)) + const remove = async (workspace: Workspace, allowDirty = false, context = captureDeleteContext()) => { const preflight = await inspect(workspace, context) - if (preflight.result !== "safe" && (bulk || preflight.result !== "dirty")) { + if (preflight.result !== "safe" && (!allowDirty || preflight.result !== "dirty")) { blocked(preflight.result) - if (!bulk) setStore("deleting", undefined) return } const removed = await context.sdk.client.worktree @@ -169,7 +156,6 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr }) return false }) - if (!bulk) setStore("deleting", undefined) if (!removed) return tabs.store.forEach((tab) => { if (tab.type !== "draft" || tab.server !== context.server) return @@ -203,19 +189,20 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr const releaseConfirmation = () => { if (store.transaction === "confirm") setStore("transaction", undefined) } - let transactionID = 0 const transact = async (task: () => Promise) => { - await runWorkspaceDeleteTransaction({ - token: ++transactionID, - set: (update) => setStore("transaction", update), - task: async () => { - try { - await task() - } finally { - setStore({ deleting: undefined, deletingAll: false }) - } - }, - }) + if (store.transaction !== "confirm") return + setStore("transaction", "running") + try { + await task() + } catch (error) { + showToast({ + variant: "error", + title: language.t("workspace.delete.failed.title"), + description: error instanceof Error ? error.message : language.t("common.requestFailed"), + }) + } finally { + setStore("transaction", undefined) + } } const confirmDelete = (workspace: Workspace) => { if (store.transaction) return @@ -230,15 +217,14 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr inspectionID={current} inspect={() => inspect(workspace, context)} inspectionMessage={inspectionMessage} - onDelete={() => transact(() => remove(workspace, false, context))} + onDelete={() => transact(() => remove(workspace, true, context))} /> ), releaseConfirmation, ) } const removeAll = async (inventory: Workspace[], context: ReturnType) => { - setStore("deletingAll", true) - await removeWorkspacesSequentially(inventory, (workspace) => remove(workspace, true, context)) + await removeWorkspacesSequentially(inventory, (workspace) => remove(workspace, false, context)) } const confirmDeleteAll = () => { if (store.transaction) return @@ -267,12 +253,7 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
- {language.t( - filtered().length === 1 ? "settings.workspaces.count.one" : "settings.workspaces.count.other", - { - count: filtered().length, - }, - )} + {language.plural("settings.workspaces.count", filtered().length)}
1}> @@ -368,7 +349,7 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr variant="ghost-muted" size="small" aria-label={language.t("workspace.delete.confirm", { name: getFilename(workspace.directory) })} - disabled={!!store.transaction || store.deleting === pathKey(workspace.directory)} + disabled={!!store.transaction} icon={} onClick={() => confirmDelete(workspace)} /> @@ -473,9 +454,7 @@ function DialogDeleteWorkspace(props: { type="button" variant="danger" disabled={ - status.isPending || - status.isError || - (status.data?.result !== "safe" && status.data?.result !== "dirty") + status.isPending || status.isError || (status.data?.result !== "safe" && status.data?.result !== "dirty") } onClick={remove} > 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 9b2a46bb94f..b53fb691b3c 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -208,57 +208,6 @@ 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/language.tsx b/packages/app/src/context/language.tsx index 8772fd3111a..2b43c31ddbe 100644 --- a/packages/app/src/context/language.tsx +++ b/packages/app/src/context/language.tsx @@ -2,7 +2,12 @@ import * as i18n from "@solid-primitives/i18n" import { createEffect, createMemo, createResource } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" -import { pluralCategory, type UiI18nPluralKey } from "@opencode-ai/ui/context/i18n" +import { + pluralCategory, + type UiI18nPluralLookupKey, + type UiI18nPluralKey, + type UiPluralCategory, +} from "@opencode-ai/ui/context/i18n" import { Persist, persisted } from "@/utils/persist" import { dict as en } from "@/i18n/en" import { dict as uiEn } from "@opencode-ai/ui/i18n/en" @@ -25,11 +30,13 @@ function localeDirection(locale: Locale): Direction { type RawDictionary = typeof en & typeof uiEn type Dictionary = i18n.Flatten -type PluralKey = - | UiI18nPluralKey - | "session.question.pending" - | "session.followupDock.summary" - | "session.revertDock.summary" +type AppI18nKey = Extract +type AppI18nPluralKey = { + [Key in AppI18nKey]: Key extends `${infer Base}.other` ? (`${Base}.one` extends AppI18nKey ? Base : never) : never +}[AppI18nKey] +type PluralKey = AppI18nPluralKey | UiI18nPluralKey +type AppI18nPluralLookupKey = `${AppI18nPluralKey}.${UiPluralCategory}` +type TranslationKey = Key extends AppI18nPluralLookupKey | UiI18nPluralLookupKey ? never : Key type Source = { dict: Record } function cookie(locale: Locale) { @@ -69,7 +76,7 @@ const INTL: Record = { sv: "sv-SE", } -const LABEL_KEY: Partial> = { +const LABEL_KEY = { en: "language.en", zh: "language.zh", zht: "language.zht", @@ -88,7 +95,7 @@ const LABEL_KEY: Partial> = { th: "language.th", bs: "language.bs", tr: "language.tr", -} +} as const satisfies Partial> const LABEL: Partial> = { hi: "हिन्दी", @@ -259,21 +266,26 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont initialValue: dicts.get(initial) ?? base, }) - const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as ( - key: keyof Dictionary, + const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as ( + key: TranslationKey, params?: Record, ) => string - const plural = (key: PluralKey, count: number, params?: Record) => { - const category = pluralCategory(intl(), count) + const pluralForm = ( + key: PluralKey, + category: UiPluralCategory, + params?: Record, + ) => { const current = (dict.loading ? base : (dict() ?? base)) as Record const candidate = `${key}.${category}` const fallback = `${key}.other` - return i18n.resolveTemplate(current[candidate] ?? current[fallback] ?? fallback, { ...params, count }) + return i18n.resolveTemplate(current[candidate] ?? current[fallback] ?? fallback, params) } + const plural = (key: PluralKey, count: number, params?: Record) => + pluralForm(key, pluralCategory(intl(), count), { ...params, count }) const label = (value: Locale) => { - const key = LABEL_KEY[value] + const key = LABEL_KEY[value as keyof typeof LABEL_KEY] if (key) return t(key) return LABEL[value] ?? value } @@ -305,6 +317,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont label, t, plural, + pluralForm, setLocale(next: Locale) { setStore("locale", normalizeLocale(next)) }, diff --git a/packages/app/src/context/server-sdk.test.ts b/packages/app/src/context/server-sdk.test.ts index 442a5ea1ece..532c82a37ed 100644 --- a/packages/app/src/context/server-sdk.test.ts +++ b/packages/app/src/context/server-sdk.test.ts @@ -26,9 +26,6 @@ describe("applyWorktreeEvent", () => { Worktree.pending(ServerScope.local, directory) applyWorktreeEvent(ServerScope.local, event, "failed") expect(Worktree.get(ServerScope.local, directory)).toEqual({ status: "ready" }) - }) - - test("ignores unrelated global compatibility events", () => { expect( adaptWorktreeCompatibilityEvent({ directory: "/repo", @@ -41,20 +38,6 @@ describe("applyWorktreeEvent", () => { ).toBeUndefined() }) - 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) @@ -73,53 +56,33 @@ describe("applyWorktreeEvent", () => { ) 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, +test("legacy and current moved events complete matching operations", () => { + const events = [ + { + id: "legacy", + payload: { + id: "moved", + type: "session.next.moved", + properties: { timestamp: Date.now(), sessionID: "legacy", location: { directory: "/workspace" } }, + } as Event, + }, + { + id: "current", + payload: adaptServerEvent({ + id: "moved-current", + created: Date.now(), + type: "session.moved", + data: { sessionID: "current", location: { directory: "/workspace" } }, + } as OpenCodeEvent), + }, + ] + events.forEach((event) => { + WorkspaceOperation.start(ServerScope.local, event.id, "move", "/workspace") + applyWorkspaceOperationEvent(ServerScope.local, { directory: "/workspace", payload: event.payload }) + expect(WorkspaceOperation.get(ServerScope.local, event.id)?.status).toBe("complete") }) - 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", () => { diff --git a/packages/app/src/context/server-sdk.tsx b/packages/app/src/context/server-sdk.tsx index 1a52a394214..1d192d406ed 100644 --- a/packages/app/src/context/server-sdk.tsx +++ b/packages/app/src/context/server-sdk.tsx @@ -147,15 +147,12 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) { 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 } @@ -291,12 +288,57 @@ 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 @@ -305,6 +347,7 @@ 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() @@ -360,6 +403,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS started = false generation++ attempt?.abort() + worktreeAttempt?.abort() } onMount(() => { diff --git a/packages/app/src/context/server-session.test.ts b/packages/app/src/context/server-session.test.ts index 1c5b1c86f0d..a6bf2bae51a 100644 --- a/packages/app/src/context/server-session.test.ts +++ b/packages/app/src/context/server-session.test.ts @@ -240,30 +240,6 @@ describe("server session", () => { 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") }) @@ -384,17 +360,6 @@ 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-sync.test.ts b/packages/app/src/context/server-sync.test.ts index 27c88c4d284..edf9e52ce9f 100644 --- a/packages/app/src/context/server-sync.test.ts +++ b/packages/app/src/context/server-sync.test.ts @@ -17,7 +17,6 @@ import { loadMcpQuery, loadMcpResourcesQuery, seedActiveSessionStatuses, - shouldRefreshWorkspaceSessions, } from "./server-sync" import { ServerScope } from "@/utils/server-scope" import { createServerSession } from "./server-session" @@ -155,41 +154,6 @@ describe("session move normalization", () => { expect(session.get("session")).toMatchObject({ directory: "/destination", path: "packages/app" }) }) - test("refreshes workspace inventory for current and V1 lifecycle events", () => { - const current = adaptServerEvent({ - id: "event-current-move-refresh", - created: 10, - type: "session.moved", - data: { sessionID: "session", location: { directory: "/destination" } }, - } as OpenCodeEvent) - const legacy = { - type: "session.next.moved", - properties: { timestamp: 10, sessionID: "session", location: { directory: "/destination" } }, - } as Event - - expect(shouldRefreshWorkspaceSessions(current)).toBe(true) - expect( - shouldRefreshWorkspaceSessions( - adaptServerEvent({ - id: "event-current-rename-refresh", - created: 10, - type: "session.renamed", - data: { sessionID: "session", title: "Renamed" }, - } as OpenCodeEvent), - ), - ).toBe(true) - expect(shouldRefreshWorkspaceSessions(legacy)).toBe(true) - expect( - shouldRefreshWorkspaceSessions({ type: "session.created", properties: { info: sessionAt("/source") } } as Event), - ).toBe(true) - expect( - shouldRefreshWorkspaceSessions({ type: "session.updated", properties: { info: sessionAt("/source") } } as Event), - ).toBe(true) - expect( - shouldRefreshWorkspaceSessions({ type: "session.deleted", properties: { info: sessionAt("/source") } } as Event), - ).toBe(true) - expect(shouldRefreshWorkspaceSessions({ type: "server.connected", properties: {} } as Event)).toBe(false) - }) }) describe("pickDirectoriesToEvict", () => { diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 30a767c8390..f916bb487ae 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -1033,54 +1033,6 @@ export const dict = { "session.delete.confirm": 'حذف الجلسة "{{name}}"؟', "session.delete.button": "حذف الجلسة", "workspace.new": "مساحة عمل جديدة", - "common.viewAll": "عرض الكل", - "session.new.workspace.local.tooltip": "استخدام نسخة العمل الحالية", - "session.new.workspace.new.tooltip": "إنشاء نسخة عمل معزولة", - "session.new.workspace.fromBranch": "من {{branch}}", - "session.new.workspace.trigger.tooltip": "تحديد مكان تشغيل الجلسة", - "session.new.workspace.search.placeholder": "البحث في مساحات العمل", - "settings.tab.workspaces": "مساحات العمل", - "settings.workspaces.filter.all": "جميع المشاريع", - "settings.workspaces.empty": "لا توجد مساحات عمل", - "settings.workspaces.count.zero": "لا توجد مساحات عمل ({{count}})", - "settings.workspaces.count.one": "{{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.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}}؟", - "settings.workspaces.delete.warning": - "سيتم حذف دليل مساحة العمل والفرع نهائيًا. لن تتم المتابعة إلا إذا كانت نظيفة وغير نشطة ولا تحتوي على جلسات مرتبطة.", - "settings.workspaces.deleteAll.warning": - "سيتم حذف مساحات العمل المحددة وعددها {{count}} في {{project}} نهائيًا فقط إذا كانت كل واحدة نظيفة وغير نشطة ولا تحتوي على جلسات مرتبطة.", - "settings.workspaces.delete.blocked.active": "لا يمكن حذف مساحة العمل النشطة.", - "settings.workspaces.delete.blocked.linked": "تحتوي مساحة العمل هذه على جلسات مرتبطة ولا يمكن حذفها.", - "settings.workspaces.default.title": "البيئة الافتراضية", - "settings.workspaces.default.description": "اختر مكان بدء الجلسات الجديدة", - "settings.workspaces.default.lastUsed": "آخر بيئة مستخدمة لكل مشروع", - "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": "فشل إنشاء مساحة العمل", @@ -1203,13 +1155,4 @@ 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 11f79695a85..df3506c3d55 100644 --- a/packages/app/src/i18n/az.ts +++ b/packages/app/src/i18n/az.ts @@ -1152,50 +1152,4 @@ 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 dea8badca4a..90acd260370 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -1041,49 +1041,6 @@ export const dict = { "session.delete.confirm": 'Excluir sessão "{{name}}"?', "session.delete.button": "Excluir sessão", "workspace.new": "Novo espaço de trabalho", - "common.viewAll": "Ver todos", - "session.new.workspace.local.tooltip": "Usar o checkout atual", - "session.new.workspace.new.tooltip": "Criar checkout isolado", - "session.new.workspace.fromBranch": "de {{branch}}", - "session.new.workspace.trigger.tooltip": "Selecionar onde executar a sessão", - "session.new.workspace.search.placeholder": "Pesquisar espaços de trabalho", - "settings.tab.workspaces": "Espaços de trabalho", - "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", - "settings.workspaces.deleteAll.confirm": "Excluir todos os {{count}} espaços de trabalho?", - "settings.workspaces.delete.warning": - "O diretório e a ramificação do espaço de trabalho serão removidos permanentemente. A exclusão só prossegue se ele estiver limpo, inativo e sem sessões vinculadas.", - "settings.workspaces.deleteAll.warning": - "Os {{count}} espaços de trabalho selecionados em {{project}} serão removidos permanentemente somente se cada um estiver limpo, inativo e sem sessões vinculadas.", - "settings.workspaces.delete.blocked.active": "O espaço de trabalho ativo não pode ser excluído.", - "settings.workspaces.delete.blocked.linked": - "Este espaço de trabalho tem sessões vinculadas e não pode ser excluído.", - "settings.workspaces.default.title": "Ambiente padrão", - "settings.workspaces.default.description": "Escolha onde as novas sessões começam", - "settings.workspaces.default.lastUsed": "Último usado por projeto", - "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", @@ -1208,13 +1165,4 @@ 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 0c45b7c9315..6be962720b8 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -1117,48 +1117,6 @@ export const dict = { "session.delete.button": "Izbriši sesiju", "workspace.new": "Novi radni prostor", - "common.viewAll": "Prikaži sve", - "session.new.workspace.local.tooltip": "Koristi trenutni checkout", - "session.new.workspace.new.tooltip": "Kreiraj izolovani checkout", - "session.new.workspace.fromBranch": "iz {{branch}}", - "session.new.workspace.trigger.tooltip": "Odaberi gdje pokrenuti sesiju", - "session.new.workspace.search.placeholder": "Pretraži radne prostore", - "settings.tab.workspaces": "Radni prostori", - "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", - "settings.workspaces.deleteAll.confirm": "Izbrisati svih {{count}} radnih prostora?", - "settings.workspaces.delete.warning": - "Direktorij radnog prostora i grana bit će trajno izbrisani. Brisanje se nastavlja samo ako je radni prostor čist, neaktivan i nema povezanih sesija.", - "settings.workspaces.deleteAll.warning": - "Odabranih {{count}} radnih prostora u {{project}} bit će trajno izbrisano samo ako je svaki čist, neaktivan i nema povezanih sesija.", - "settings.workspaces.delete.blocked.active": "Aktivni radni prostor nije moguće izbrisati.", - "settings.workspaces.delete.blocked.linked": "Ovaj radni prostor ima povezane sesije i nije ga moguće izbrisati.", - "settings.workspaces.default.title": "Zadano okruženje", - "settings.workspaces.default.description": "Odaberite gdje počinju nove sesije", - "settings.workspaces.default.lastUsed": "Posljednje korišteno po projektu", - "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", @@ -1283,12 +1241,4 @@ 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 28a8b690c6b..8221b31af8e 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -991,39 +991,6 @@ export const dict = { "session.delete.button": "Slet session", "workspace.new": "Nyt arbejdsområde", - "common.viewAll": "Vis alle", - "session.new.workspace.local.tooltip": "Brug det aktuelle checkout", - "session.new.workspace.new.tooltip": "Opret et isoleret checkout", - "session.new.workspace.fromBranch": "fra {{branch}}", - "session.new.workspace.trigger.tooltip": "Vælg, hvor sessionen skal køre", - "session.new.workspace.search.placeholder": "Søg i arbejdsområder", - "settings.tab.workspaces": "Arbejdsområder", - "settings.workspaces.filter.all": "Alle projekter", - "settings.workspaces.empty": "Ingen arbejdsområder", - "settings.workspaces.count.one": "{{count}} arbejdsområde", - "settings.workspaces.count.other": "{{count}} arbejdsområder", - "settings.workspaces.sessions.one": "{{count}} session i {{project}}", - "settings.workspaces.sessions.other": "{{count}} sessioner i {{project}}", - "settings.workspaces.lastActiveSession": "Senest aktive session", - "settings.workspaces.deleteAll": "Slet alle arbejdsområder", - "settings.workspaces.deleteAll.confirm": "Slet alle {{count}} arbejdsområder?", - "settings.workspaces.delete.warning": - "Arbejdsområdets mappe og gren fjernes permanent. Sletning fortsætter kun, hvis det er rent, inaktivt og ikke har tilknyttede sessioner.", - "settings.workspaces.deleteAll.warning": - "De {{count}} valgte arbejdsområder i {{project}} fjernes permanent, men kun hvis hvert område er rent, inaktivt og ikke har tilknyttede sessioner.", - "settings.workspaces.delete.blocked.active": "Det aktive arbejdsområde kan ikke slettes.", - "settings.workspaces.delete.blocked.linked": "Dette arbejdsområde har tilknyttede sessioner og kan ikke slettes.", - "settings.workspaces.default.title": "Standardmiljø", - "settings.workspaces.default.description": "Vælg, hvor nye sessioner starter", - "settings.workspaces.default.lastUsed": "Senest brugt pr. projekt", - "settings.workspaces.default.local": "Lokal mappe", - "settings.workspaces.default.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", @@ -1265,19 +1232,4 @@ 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 c802abd1bcd..60be89a70a9 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -934,40 +934,6 @@ export const dict = { "session.delete.confirm": 'Sitzung "{{name}}" löschen?', "session.delete.button": "Sitzung löschen", "workspace.new": "Neuer Arbeitsbereich", - "common.viewAll": "Alle anzeigen", - "session.new.workspace.local.tooltip": "Aktuellen Checkout verwenden", - "session.new.workspace.new.tooltip": "Isolierten Checkout erstellen", - "session.new.workspace.fromBranch": "aus {{branch}}", - "session.new.workspace.trigger.tooltip": "Auswählen, wo die Sitzung ausgeführt werden soll", - "session.new.workspace.search.placeholder": "Arbeitsbereiche durchsuchen", - "settings.tab.workspaces": "Arbeitsbereiche", - "settings.workspaces.filter.all": "Alle Projekte", - "settings.workspaces.empty": "Keine Arbeitsbereiche", - "settings.workspaces.count.one": "{{count}} Arbeitsbereich", - "settings.workspaces.count.other": "{{count}} Arbeitsbereiche", - "settings.workspaces.sessions.one": "{{count}} Sitzung in {{project}}", - "settings.workspaces.sessions.other": "{{count}} Sitzungen in {{project}}", - "settings.workspaces.lastActiveSession": "Zuletzt aktive Sitzung", - "settings.workspaces.deleteAll": "Alle Arbeitsbereiche löschen", - "settings.workspaces.deleteAll.confirm": "Alle {{count}} Arbeitsbereiche löschen?", - "settings.workspaces.delete.warning": - "Verzeichnis und Branch des Arbeitsbereichs werden dauerhaft entfernt. Die Löschung erfolgt nur, wenn er sauber und inaktiv ist und keine verknüpften Sitzungen hat.", - "settings.workspaces.deleteAll.warning": - "Die {{count}} ausgewählten Arbeitsbereiche in {{project}} werden nur dauerhaft entfernt, wenn jeder sauber und inaktiv ist und keine verknüpften Sitzungen hat.", - "settings.workspaces.delete.blocked.active": "Der aktive Arbeitsbereich kann nicht gelöscht werden.", - "settings.workspaces.delete.blocked.linked": - "Dieser Arbeitsbereich hat verknüpfte Sitzungen und kann nicht gelöscht werden.", - "settings.workspaces.default.title": "Standardumgebung", - "settings.workspaces.default.description": "Wählen Sie, wo neue Sitzungen starten", - "settings.workspaces.default.lastUsed": "Zuletzt pro Projekt verwendet", - "settings.workspaces.default.local": "Lokales Verzeichnis", - "settings.workspaces.default.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", @@ -1203,19 +1169,4 @@ 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.", - "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}}", -} +} satisfies Partial> diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index f7aa6095fe0..ef728ec0849 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -1125,49 +1125,6 @@ export const dict = { "session.delete.button": "Eliminar sesión", "workspace.new": "Nuevo espacio de trabajo", - "common.viewAll": "Ver todo", - "session.new.workspace.local.tooltip": "Usar el checkout actual", - "session.new.workspace.new.tooltip": "Crear un checkout aislado", - "session.new.workspace.fromBranch": "desde {{branch}}", - "session.new.workspace.trigger.tooltip": "Seleccionar dónde ejecutar la sesión", - "session.new.workspace.search.placeholder": "Buscar espacios de trabajo", - "settings.tab.workspaces": "Espacios de trabajo", - "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", - "settings.workspaces.deleteAll.confirm": "¿Eliminar los {{count}} espacios de trabajo?", - "settings.workspaces.delete.warning": - "El directorio y la rama del espacio de trabajo se eliminarán permanentemente. La eliminación solo continuará si está limpio, inactivo y no tiene sesiones vinculadas.", - "settings.workspaces.deleteAll.warning": - "Los {{count}} espacios de trabajo seleccionados en {{project}} se eliminarán permanentemente solo si cada uno está limpio, inactivo y no tiene sesiones vinculadas.", - "settings.workspaces.delete.blocked.active": "El espacio de trabajo activo no se puede eliminar.", - "settings.workspaces.delete.blocked.linked": - "Este espacio de trabajo tiene sesiones vinculadas y no se puede eliminar.", - "settings.workspaces.default.title": "Entorno predeterminado", - "settings.workspaces.default.description": "Elige dónde se inician las nuevas sesiones", - "settings.workspaces.default.lastUsed": "Último usado en cada proyecto", - "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", @@ -1292,12 +1249,4 @@ 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 4ed775e6f99..7955cda59a6 100644 --- a/packages/app/src/i18n/fi.ts +++ b/packages/app/src/i18n/fi.ts @@ -1152,50 +1152,4 @@ 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 55db57fc41f..c628ec2f79f 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -1051,49 +1051,6 @@ export const dict = { "session.delete.confirm": 'Supprimer la session "{{name}}" ?', "session.delete.button": "Supprimer la session", "workspace.new": "Nouvel espace de travail", - "common.viewAll": "Tout afficher", - "session.new.workspace.local.tooltip": "Utiliser le checkout actuel", - "session.new.workspace.new.tooltip": "Créer un checkout isolé", - "session.new.workspace.fromBranch": "depuis {{branch}}", - "session.new.workspace.trigger.tooltip": "Choisir où exécuter la session", - "session.new.workspace.search.placeholder": "Rechercher des espaces de travail", - "settings.tab.workspaces": "Espaces de travail", - "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", - "settings.workspaces.deleteAll.confirm": "Supprimer les {{count}} espaces de travail ?", - "settings.workspaces.delete.warning": - "Le dossier et la branche de l’espace de travail seront supprimés définitivement. La suppression ne continue que s’il est propre, inactif et sans session liée.", - "settings.workspaces.deleteAll.warning": - "Les {{count}} espaces de travail sélectionnés dans {{project}} seront supprimés définitivement uniquement si chacun est propre, inactif et sans session liée.", - "settings.workspaces.delete.blocked.active": "L’espace de travail actif ne peut pas être supprimé.", - "settings.workspaces.delete.blocked.linked": - "Cet espace de travail contient des sessions liées et ne peut pas être supprimé.", - "settings.workspaces.default.title": "Environnement par défaut", - "settings.workspaces.default.description": "Choisissez où démarrent les nouvelles sessions", - "settings.workspaces.default.lastUsed": "Dernier utilisé par projet", - "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", @@ -1226,12 +1183,4 @@ 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 5e6ee8c9f30..a704e6233ef 100644 --- a/packages/app/src/i18n/hi.ts +++ b/packages/app/src/i18n/hi.ts @@ -1147,50 +1147,4 @@ 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 35bbac6ee1b..e693869089e 100644 --- a/packages/app/src/i18n/id.ts +++ b/packages/app/src/i18n/id.ts @@ -1234,50 +1234,4 @@ 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 0caa391f4ba..3f4089018dd 100644 --- a/packages/app/src/i18n/it.ts +++ b/packages/app/src/i18n/it.ts @@ -1160,55 +1160,4 @@ 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 69cc4a2fed1..37f6d5c5f69 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -1023,39 +1023,6 @@ export const dict = { "session.delete.confirm": 'セッション "{{name}}" を削除しますか?', "session.delete.button": "セッションを削除", "workspace.new": "新しいワークスペース", - "common.viewAll": "すべて表示", - "session.new.workspace.local.tooltip": "現在のチェックアウトを使用", - "session.new.workspace.new.tooltip": "隔離されたチェックアウトを作成", - "session.new.workspace.fromBranch": "{{branch}} から", - "session.new.workspace.trigger.tooltip": "セッションの実行先を選択します", - "session.new.workspace.search.placeholder": "ワークスペースを検索", - "settings.tab.workspaces": "ワークスペース", - "settings.workspaces.filter.all": "すべてのプロジェクト", - "settings.workspaces.empty": "ワークスペースなし", - "settings.workspaces.count.one": "{{count}}件のワークスペース", - "settings.workspaces.count.other": "{{count}}件のワークスペース", - "settings.workspaces.sessions.one": "{{project}}のセッション{{count}}件", - "settings.workspaces.sessions.other": "{{project}}のセッション{{count}}件", - "settings.workspaces.lastActiveSession": "最後にアクティブだったセッション", - "settings.workspaces.deleteAll": "すべてのワークスペースを削除", - "settings.workspaces.deleteAll.confirm": "{{count}}件のワークスペースをすべて削除しますか?", - "settings.workspaces.delete.warning": - "ワークスペースのディレクトリとブランチは完全に削除されます。クリーンで非アクティブかつリンクされたセッションがない場合にのみ削除されます。", - "settings.workspaces.deleteAll.warning": - "{{project}} で選択された{{count}}件のワークスペースは、それぞれがクリーンで非アクティブかつリンクされたセッションがない場合にのみ完全に削除されます。", - "settings.workspaces.delete.blocked.active": "アクティブなワークスペースは削除できません。", - "settings.workspaces.delete.blocked.linked": "このワークスペースにはリンクされたセッションがあるため削除できません。", - "settings.workspaces.default.title": "デフォルト環境", - "settings.workspaces.default.description": "新しいセッションの開始場所を選択します", - "settings.workspaces.default.lastUsed": "プロジェクトごとに前回使用した環境", - "settings.workspaces.default.local": "ローカルディレクトリ", - "settings.workspaces.default.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": "ワークスペースの作成に失敗しました", @@ -1186,19 +1153,4 @@ 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 2c4a0a1debc..9d3821660cb 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -748,39 +748,6 @@ export const dict = { "session.delete.confirm": '"{{name}}" 세션을 삭제하시겠습니까?', "session.delete.button": "세션 삭제", "workspace.new": "새 작업 공간", - "common.viewAll": "모두 보기", - "session.new.workspace.local.tooltip": "현재 체크아웃 사용", - "session.new.workspace.new.tooltip": "격리된 체크아웃 생성", - "session.new.workspace.fromBranch": "{{branch}}에서", - "session.new.workspace.trigger.tooltip": "세션을 실행할 위치를 선택하세요", - "session.new.workspace.search.placeholder": "작업 공간 검색", - "settings.tab.workspaces": "작업 공간", - "settings.workspaces.filter.all": "모든 프로젝트", - "settings.workspaces.empty": "작업 공간 없음", - "settings.workspaces.count.one": "작업 공간 {{count}}개", - "settings.workspaces.count.other": "작업 공간 {{count}}개", - "settings.workspaces.sessions.one": "{{project}}에 세션 {{count}}개", - "settings.workspaces.sessions.other": "{{project}}에 세션 {{count}}개", - "settings.workspaces.lastActiveSession": "마지막 활성 세션", - "settings.workspaces.deleteAll": "모든 작업 공간 삭제", - "settings.workspaces.deleteAll.confirm": "작업 공간 {{count}}개를 모두 삭제할까요?", - "settings.workspaces.delete.warning": - "작업 공간 디렉터리와 브랜치가 영구적으로 삭제됩니다. 깨끗하고 비활성 상태이며 연결된 세션이 없는 경우에만 삭제됩니다.", - "settings.workspaces.deleteAll.warning": - "{{project}}에서 선택한 작업 공간 {{count}}개는 각각 깨끗하고 비활성 상태이며 연결된 세션이 없는 경우에만 영구적으로 삭제됩니다.", - "settings.workspaces.delete.blocked.active": "활성 작업 공간은 삭제할 수 없습니다.", - "settings.workspaces.delete.blocked.linked": "이 작업 공간에는 연결된 세션이 있어 삭제할 수 없습니다.", - "settings.workspaces.default.title": "기본 환경", - "settings.workspaces.default.description": "새 세션을 시작할 위치를 선택하세요", - "settings.workspaces.default.lastUsed": "프로젝트별 마지막 사용 환경", - "settings.workspaces.default.local": "로컬 디렉터리", - "settings.workspaces.default.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": "작업 공간 생성 실패", @@ -1184,19 +1151,4 @@ 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 6107e879665..7ac999c7a79 100644 --- a/packages/app/src/i18n/nl.ts +++ b/packages/app/src/i18n/nl.ts @@ -1155,50 +1155,4 @@ 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 cd71b4819d2..7ff1ac5dc1d 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -944,39 +944,6 @@ export const dict = { "session.delete.button": "Slett sesjon", "workspace.new": "Nytt arbeidsområde", - "common.viewAll": "Vis alle", - "session.new.workspace.local.tooltip": "Bruk gjeldende checkout", - "session.new.workspace.new.tooltip": "Opprett isolert checkout", - "session.new.workspace.fromBranch": "fra {{branch}}", - "session.new.workspace.trigger.tooltip": "Velg hvor sesjonen skal kjøres", - "session.new.workspace.search.placeholder": "Søk i arbeidsområder", - "settings.tab.workspaces": "Arbeidsområder", - "settings.workspaces.filter.all": "Alle prosjekter", - "settings.workspaces.empty": "Ingen arbeidsområder", - "settings.workspaces.count.one": "{{count}} arbeidsområde", - "settings.workspaces.count.other": "{{count}} arbeidsområder", - "settings.workspaces.sessions.one": "{{count}} økt i {{project}}", - "settings.workspaces.sessions.other": "{{count}} økter i {{project}}", - "settings.workspaces.lastActiveSession": "Sist aktive økt", - "settings.workspaces.deleteAll": "Slett alle arbeidsområder", - "settings.workspaces.deleteAll.confirm": "Slette alle {{count}} arbeidsområder?", - "settings.workspaces.delete.warning": - "Arbeidsområdets mappe og gren fjernes permanent. Sletting fortsetter bare hvis det er rent, inaktivt og ikke har tilknyttede økter.", - "settings.workspaces.deleteAll.warning": - "De {{count}} valgte arbeidsområdene i {{project}} fjernes permanent bare hvis hvert område er rent, inaktivt og ikke har tilknyttede økter.", - "settings.workspaces.delete.blocked.active": "Det aktive arbeidsområdet kan ikke slettes.", - "settings.workspaces.delete.blocked.linked": "Dette arbeidsområdet har tilknyttede økter og kan ikke slettes.", - "settings.workspaces.default.title": "Standardmiljø", - "settings.workspaces.default.description": "Velg hvor nye økter starter", - "settings.workspaces.default.lastUsed": "Sist brukt per prosjekt", - "settings.workspaces.default.local": "Lokal mappe", - "settings.workspaces.default.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", @@ -1284,19 +1251,4 @@ 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...", - "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}}", -} +} satisfies Partial> diff --git a/packages/app/src/i18n/pa.ts b/packages/app/src/i18n/pa.ts index 1609ee78240..4e0ff16f87e 100644 --- a/packages/app/src/i18n/pa.ts +++ b/packages/app/src/i18n/pa.ts @@ -1145,50 +1145,4 @@ 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/parity.test.ts b/packages/app/src/i18n/parity.test.ts index 2aa721dfbe9..0c66992d739 100644 --- a/packages/app/src/i18n/parity.test.ts +++ b/packages/app/src/i18n/parity.test.ts @@ -30,17 +30,7 @@ const appLocales = [ "sv", ] as const const desktopLocales = appLocales -const pluralCategories: Partial> = { - ar: ["zero", "two", "few", "many"], - br: ["many"], - bs: ["few"], - es: ["many"], - fr: ["many"], - it: ["many"], - pl: ["few", "many"], - ru: ["few", "many"], - uk: ["few", "many"], -} +const pluralCategories = new Set(["zero", "one", "two", "few", "many", "other"]) const domains = [ { @@ -63,24 +53,20 @@ const domains = [ }, ] as const -describe.skipIf(!!process.env.CI)("i18n parity", () => { - test("non-English locales have every English key and required plural variants", async () => { +describe("i18n parity", () => { + test("non-English locales contain only English keys and their plural variants", async () => { for (const domain of domains) { const source = await dictionary(domain.source) + const families = new Set(pluralFamilies(source)) for (const locale of domain.locales) { const target = await dictionary(domain.target(locale)) - const missing = Object.keys(source).filter((key) => !Object.hasOwn(target, key)) const extra = Object.keys(target) - .filter((key) => !Object.hasOwn(source, key)) + .filter((key) => !Object.hasOwn(source, key) && !isPluralVariant(key, families)) .sort() - const expected = pluralFamilies(source) - .flatMap((key) => (pluralCategories[locale] ?? []).map((category) => `${key}.${category}`)) - .sort() - expect({ domain: domain.name, locale, missing, extra }).toEqual({ + expect({ domain: domain.name, locale, extra }).toEqual({ domain: domain.name, locale, - missing: [], - extra: expected, + extra: [], }) } } @@ -94,11 +80,11 @@ describe.skipIf(!!process.env.CI)("i18n parity", () => { const mismatched = Object.keys(source).filter( (key) => Object.hasOwn(target, key) && placeholders(source[key]).join() !== placeholders(target[key]).join(), ) - const pluralMismatched = pluralFamilies(source).flatMap((key) => - (pluralCategories[locale] ?? []) - .map((category) => `${key}.${category}`) - .filter((variant) => placeholders(source[`${key}.other`]).join() !== placeholders(target[variant]).join()), - ) + const pluralMismatched = Object.keys(target).filter((key) => { + const family = pluralFamily(key) + if (!family || !Object.hasOwn(source, `${family}.other`)) return false + return placeholders(source[`${family}.other`]).join() !== placeholders(target[key]).join() + }) expect({ domain: domain.name, locale, mismatched, pluralMismatched }).toEqual({ domain: domain.name, locale, @@ -136,38 +122,6 @@ describe.skipIf(!!process.env.CI)("i18n parity", () => { }) }) -describe("i18n plural parity", () => { - test("locale-specific categories exist and preserve count placeholders", async () => { - for (const domain of domains.slice(0, 2)) { - const source = await dictionary(domain.source) - const families = pluralFamilies(source) - for (const locale of domain.locales) { - const target = await dictionary(domain.target(locale)) - const missing = families.flatMap((key) => - (pluralCategories[locale] ?? []) - .map((category) => `${key}.${category}`) - .filter((variant) => !Object.hasOwn(target, variant)), - ) - const mismatched = families.flatMap((key) => - (pluralCategories[locale] ?? []) - .map((category) => `${key}.${category}`) - .filter( - (variant) => - Object.hasOwn(target, variant) && - placeholders(source[`${key}.other`]).join() !== placeholders(target[variant]).join(), - ), - ) - expect({ domain: domain.name, locale, missing, mismatched }).toEqual({ - domain: domain.name, - locale, - missing: [], - mismatched: [], - }) - } - } - }) -}) - async function dictionary(file: string) { const module: unknown = await import(file) if (typeof module !== "object" || module === null || !("dict" in module) || !isDictionary(module.dict)) { @@ -187,11 +141,17 @@ function placeholders(value: string) { function pluralFamilies(dictionary: Record) { return Object.keys(dictionary) - .filter( - (key) => - key.endsWith(".one") && - dictionary[key].includes("{{count}}") && - dictionary[`${key.slice(0, -4)}.other`]?.includes("{{count}}"), - ) + .filter((key) => key.endsWith(".one") && Object.hasOwn(dictionary, `${key.slice(0, -4)}.other`)) .map((key) => key.slice(0, -4)) } + +function pluralFamily(key: string) { + const split = key.lastIndexOf(".") + if (split === -1 || !pluralCategories.has(key.slice(split + 1))) return + return key.slice(0, split) +} + +function isPluralVariant(key: string, families: Set) { + const family = pluralFamily(key) + return family !== undefined && families.has(family) +} diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index b199ad928bc..17c9fb639fb 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -1043,50 +1043,6 @@ export const dict = { "session.delete.confirm": 'Usunąć sesję "{{name}}"?', "session.delete.button": "Usuń sesję", "workspace.new": "Nowa przestrzeń robocza", - "common.viewAll": "Pokaż wszystko", - "session.new.workspace.local.tooltip": "Użyj bieżącego checkoutu", - "session.new.workspace.new.tooltip": "Utwórz izolowany checkout", - "session.new.workspace.fromBranch": "z {{branch}}", - "session.new.workspace.trigger.tooltip": "Wybierz, gdzie uruchomić sesję", - "session.new.workspace.search.placeholder": "Szukaj przestrzeni roboczych", - "settings.tab.workspaces": "Obszary robocze", - "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", - "settings.workspaces.deleteAll.confirm": "Usunąć wszystkie obszary robocze ({{count}})?", - "settings.workspaces.delete.warning": - "Katalog i gałąź obszaru roboczego zostaną trwale usunięte. Usuwanie nastąpi tylko wtedy, gdy obszar jest czysty, nieaktywny i nie ma powiązanych sesji.", - "settings.workspaces.deleteAll.warning": - "Wybrane obszary robocze ({{count}}) w {{project}} zostaną trwale usunięte tylko wtedy, gdy każdy jest czysty, nieaktywny i nie ma powiązanych sesji.", - "settings.workspaces.delete.blocked.active": "Nie można usunąć aktywnego obszaru roboczego.", - "settings.workspaces.delete.blocked.linked": "Ten obszar roboczy ma powiązane sesje i nie można go usunąć.", - "settings.workspaces.default.title": "Domyślne środowisko", - "settings.workspaces.default.description": "Wybierz, gdzie mają się rozpoczynać nowe sesje", - "settings.workspaces.default.lastUsed": "Ostatnio używane w każdym projekcie", - "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", @@ -1212,12 +1168,4 @@ 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 4719071092d..6d119926148 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -1122,51 +1122,6 @@ export const dict = { "session.delete.button": "Удалить сессию", "workspace.new": "Новое рабочее пространство", - "common.viewAll": "Показать все", - "session.new.workspace.local.tooltip": "Использовать текущую рабочую копию", - "session.new.workspace.new.tooltip": "Создать изолированную копию", - "session.new.workspace.fromBranch": "из {{branch}}", - "session.new.workspace.trigger.tooltip": "Выбрать, где запустить сессию", - "session.new.workspace.search.placeholder": "Поиск рабочих пространств", - "settings.tab.workspaces": "Рабочие пространства", - "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": "Удалить все рабочие пространства", - "settings.workspaces.deleteAll.confirm": "Удалить все рабочие пространства ({{count}})?", - "settings.workspaces.delete.warning": - "Каталог и ветка рабочего пространства будут удалены безвозвратно. Удаление выполняется только для чистого, неактивного пространства без связанных сеансов.", - "settings.workspaces.deleteAll.warning": - "Выбранные рабочие пространства ({{count}}) в {{project}} будут удалены безвозвратно, только если каждое из них чистое, неактивное и не имеет связанных сеансов.", - "settings.workspaces.delete.blocked.active": "Активное рабочее пространство нельзя удалить.", - "settings.workspaces.delete.blocked.linked": - "У этого рабочего пространства есть связанные сеансы, поэтому его нельзя удалить.", - "settings.workspaces.default.title": "Среда по умолчанию", - "settings.workspaces.default.description": "Выберите, где будут запускаться новые сеансы", - "settings.workspaces.default.lastUsed": "Последняя использованная среда проекта", - "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": "Не удалось создать рабочее пространство", @@ -1291,12 +1246,4 @@ 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 9bab2f8502d..ab5007238b6 100644 --- a/packages/app/src/i18n/sv.ts +++ b/packages/app/src/i18n/sv.ts @@ -1144,50 +1144,4 @@ 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 94e0d2ae993..e87d0db164d 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -1098,39 +1098,6 @@ export const dict = { "session.delete.button": "ลบเซสชัน", "workspace.new": "พื้นที่ทำงานใหม่", - "common.viewAll": "ดูทั้งหมด", - "session.new.workspace.local.tooltip": "ใช้ checkout ปัจจุบัน", - "session.new.workspace.new.tooltip": "สร้าง checkout ที่แยกอิสระ", - "session.new.workspace.fromBranch": "จาก {{branch}}", - "session.new.workspace.trigger.tooltip": "เลือกตำแหน่งที่จะเรียกใช้เซสชัน", - "session.new.workspace.search.placeholder": "ค้นหาพื้นที่ทำงาน", - "settings.tab.workspaces": "พื้นที่ทำงาน", - "settings.workspaces.filter.all": "โปรเจกต์ทั้งหมด", - "settings.workspaces.empty": "ไม่มีพื้นที่ทำงาน", - "settings.workspaces.count.one": "{{count}} พื้นที่ทำงาน", - "settings.workspaces.count.other": "{{count}} พื้นที่ทำงาน", - "settings.workspaces.sessions.one": "{{count}} เซสชันใน {{project}}", - "settings.workspaces.sessions.other": "{{count}} เซสชันใน {{project}}", - "settings.workspaces.lastActiveSession": "เซสชันที่ใช้งานล่าสุด", - "settings.workspaces.deleteAll": "ลบพื้นที่ทำงานทั้งหมด", - "settings.workspaces.deleteAll.confirm": "ลบพื้นที่ทำงานทั้งหมด {{count}} รายการหรือไม่", - "settings.workspaces.delete.warning": - "ไดเรกทอรีและสาขาของพื้นที่ทำงานจะถูกลบอย่างถาวร การลบจะดำเนินการต่อเมื่อพื้นที่ทำงานสะอาด ไม่ได้ใช้งาน และไม่มีเซสชันที่เชื่อมโยงเท่านั้น", - "settings.workspaces.deleteAll.warning": - "พื้นที่ทำงานที่เลือก {{count}} รายการใน {{project}} จะถูกลบอย่างถาวรเมื่อแต่ละรายการสะอาด ไม่ได้ใช้งาน และไม่มีเซสชันที่เชื่อมโยงเท่านั้น", - "settings.workspaces.delete.blocked.active": "ไม่สามารถลบพื้นที่ทำงานที่กำลังใช้งานอยู่ได้", - "settings.workspaces.delete.blocked.linked": "พื้นที่ทำงานนี้มีเซสชันที่เชื่อมโยงอยู่และไม่สามารถลบได้", - "settings.workspaces.default.title": "สภาพแวดล้อมเริ่มต้น", - "settings.workspaces.default.description": "เลือกตำแหน่งเริ่มต้นของเซสชันใหม่", - "settings.workspaces.default.lastUsed": "ใช้ล่าสุดในแต่ละโปรเจกต์", - "settings.workspaces.default.local": "ไดเรกทอรีในเครื่อง", - "settings.workspaces.default.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": "ไม่สามารถสร้างพื้นที่ทำงาน", @@ -1255,19 +1222,4 @@ 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 fe35be7ad32..d0d3a495034 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -1124,39 +1124,6 @@ export const dict = { "session.delete.button": "Oturumu sil", "workspace.new": "Yeni çalışma alanı", - "common.viewAll": "Tümünü göster", - "session.new.workspace.local.tooltip": "Mevcut çalışma kopyasını kullan", - "session.new.workspace.new.tooltip": "Yalıtılmış çalışma kopyası oluştur", - "session.new.workspace.fromBranch": "{{branch}} dalından", - "session.new.workspace.trigger.tooltip": "Oturumun nerede çalıştırılacağını seç", - "session.new.workspace.search.placeholder": "Çalışma alanlarında ara", - "settings.tab.workspaces": "Çalışma alanları", - "settings.workspaces.filter.all": "Tüm projeler", - "settings.workspaces.empty": "Çalışma alanı yok", - "settings.workspaces.count.one": "{{count}} çalışma alanı", - "settings.workspaces.count.other": "{{count}} çalışma alanı", - "settings.workspaces.sessions.one": "{{project}} projesinde {{count}} oturum", - "settings.workspaces.sessions.other": "{{project}} projesinde {{count}} oturum", - "settings.workspaces.lastActiveSession": "Son etkin oturum", - "settings.workspaces.deleteAll": "Tüm çalışma alanlarını sil", - "settings.workspaces.deleteAll.confirm": "{{count}} çalışma alanının tümü silinsin mi?", - "settings.workspaces.delete.warning": - "Çalışma alanı dizini ve dalı kalıcı olarak kaldırılacak. Silme yalnızca alan temiz, etkin değil ve bağlantılı oturumu yoksa devam eder.", - "settings.workspaces.deleteAll.warning": - "{{project}} içindeki seçili {{count}} çalışma alanı, yalnızca her biri temiz, etkin değil ve bağlantılı oturumu yoksa kalıcı olarak kaldırılacak.", - "settings.workspaces.delete.blocked.active": "Etkin çalışma alanı silinemez.", - "settings.workspaces.delete.blocked.linked": "Bu çalışma alanında bağlantılı oturumlar var ve silinemez.", - "settings.workspaces.default.title": "Varsayılan ortam", - "settings.workspaces.default.description": "Yeni oturumların nerede başlayacağını seçin", - "settings.workspaces.default.lastUsed": "Proje başına son kullanılan", - "settings.workspaces.default.local": "Yerel dizin", - "settings.workspaces.default.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": "korumalı alan", "workspace.create.failed.title": "Çalışma alanı oluşturulamadı", @@ -1277,19 +1244,4 @@ 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}}", - "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}}", -} +} satisfies Partial> diff --git a/packages/app/src/i18n/uk.ts b/packages/app/src/i18n/uk.ts index 57fc2969cf0..fdeb0b83fb9 100644 --- a/packages/app/src/i18n/uk.ts +++ b/packages/app/src/i18n/uk.ts @@ -1227,50 +1227,6 @@ export const dict = { "session.delete.button": "Видалити сесію", "workspace.new": "Нова робоча область", - "common.viewAll": "Показати все", - "session.new.workspace.local.tooltip": "Використати поточну робочу копію", - "session.new.workspace.new.tooltip": "Створити ізольовану копію", - "session.new.workspace.fromBranch": "з {{branch}}", - "session.new.workspace.trigger.tooltip": "Вибрати, де запустити сесію", - "session.new.workspace.search.placeholder": "Пошук робочих областей", - "settings.tab.workspaces": "Робочі простори", - "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": "Видалити всі робочі простори", - "settings.workspaces.deleteAll.confirm": "Видалити всі робочі простори ({{count}})?", - "settings.workspaces.delete.warning": - "Каталог і гілку робочого простору буде видалено назавжди. Видалення виконується лише для чистого, неактивного простору без пов’язаних сеансів.", - "settings.workspaces.deleteAll.warning": - "Вибрані робочі простори ({{count}}) у {{project}} буде видалено назавжди, лише якщо кожен із них чистий, неактивний і не має пов’язаних сеансів.", - "settings.workspaces.delete.blocked.active": "Активний робочий простір не можна видалити.", - "settings.workspaces.delete.blocked.linked": "Цей робочий простір має пов’язані сеанси, тому його не можна видалити.", - "settings.workspaces.default.title": "Середовище за замовчуванням", - "settings.workspaces.default.description": "Виберіть, де починатимуться нові сеанси", - "settings.workspaces.default.lastUsed": "Останнє використане для кожного проєкту", - "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": "Не вдалося створити робочу область", @@ -1295,12 +1251,4 @@ 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 1f536ef0d87..661c013ebe7 100644 --- a/packages/app/src/i18n/ur.ts +++ b/packages/app/src/i18n/ur.ts @@ -1148,50 +1148,4 @@ 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 516b05d823b..1e243ba17d6 100644 --- a/packages/app/src/i18n/vi.ts +++ b/packages/app/src/i18n/vi.ts @@ -1153,50 +1153,4 @@ 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 b9e5f6f921f..08bda71a65e 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -1089,39 +1089,6 @@ export const dict = { "session.delete.button": "删除会话", "workspace.new": "新建工作区", - "common.viewAll": "查看全部", - "session.new.workspace.local.tooltip": "使用当前工作副本", - "session.new.workspace.new.tooltip": "创建隔离副本", - "session.new.workspace.fromBranch": "从 {{branch}}", - "session.new.workspace.trigger.tooltip": "选择会话的运行位置", - "session.new.workspace.search.placeholder": "搜索工作区", - "settings.tab.workspaces": "工作区", - "settings.workspaces.filter.all": "所有项目", - "settings.workspaces.empty": "没有工作区", - "settings.workspaces.count.one": "{{count}} 个工作区", - "settings.workspaces.count.other": "{{count}} 个工作区", - "settings.workspaces.sessions.one": "{{project}} 中有 {{count}} 个会话", - "settings.workspaces.sessions.other": "{{project}} 中有 {{count}} 个会话", - "settings.workspaces.lastActiveSession": "最近活跃的会话", - "settings.workspaces.deleteAll": "删除所有工作区", - "settings.workspaces.deleteAll.confirm": "删除全部 {{count}} 个工作区?", - "settings.workspaces.delete.warning": - "工作区目录和分支将被永久删除。仅当工作区干净、未处于活动状态且没有关联会话时才会继续删除。", - "settings.workspaces.deleteAll.warning": - "仅当 {{project}} 中选定的 {{count}} 个工作区均干净、未处于活动状态且没有关联会话时,才会永久删除它们。", - "settings.workspaces.delete.blocked.active": "无法删除当前活动的工作区。", - "settings.workspaces.delete.blocked.linked": "此工作区有关联会话,无法删除。", - "settings.workspaces.default.title": "默认环境", - "settings.workspaces.default.description": "选择新会话的启动位置", - "settings.workspaces.default.lastUsed": "每个项目上次使用的环境", - "settings.workspaces.default.local": "本地目录", - "settings.workspaces.default.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": "创建工作区失败", @@ -1244,19 +1211,4 @@ 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}}", -} +} satisfies Partial> diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 81c390b81b9..78a452070a7 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -1085,39 +1085,6 @@ export const dict = { "session.delete.button": "刪除工作階段", "workspace.new": "新增工作區", - "common.viewAll": "查看全部", - "session.new.workspace.local.tooltip": "使用目前的工作複本", - "session.new.workspace.new.tooltip": "建立隔離複本", - "session.new.workspace.fromBranch": "從 {{branch}}", - "session.new.workspace.trigger.tooltip": "選擇工作階段的執行位置", - "session.new.workspace.search.placeholder": "搜尋工作區", - "settings.tab.workspaces": "工作區", - "settings.workspaces.filter.all": "所有專案", - "settings.workspaces.empty": "沒有工作區", - "settings.workspaces.count.one": "{{count}} 個工作區", - "settings.workspaces.count.other": "{{count}} 個工作區", - "settings.workspaces.sessions.one": "{{project}} 中有 {{count}} 個工作階段", - "settings.workspaces.sessions.other": "{{project}} 中有 {{count}} 個工作階段", - "settings.workspaces.lastActiveSession": "最近活動的工作階段", - "settings.workspaces.deleteAll": "刪除所有工作區", - "settings.workspaces.deleteAll.confirm": "刪除全部 {{count}} 個工作區?", - "settings.workspaces.delete.warning": - "工作區目錄和分支將被永久刪除。只有在工作區乾淨、未處於活動狀態且沒有關聯工作階段時才會繼續刪除。", - "settings.workspaces.deleteAll.warning": - "只有在 {{project}} 中選取的 {{count}} 個工作區都乾淨、未處於活動狀態且沒有關聯工作階段時,才會永久刪除它們。", - "settings.workspaces.delete.blocked.active": "無法刪除目前作用中的工作區。", - "settings.workspaces.delete.blocked.linked": "此工作區有關聯工作階段,無法刪除。", - "settings.workspaces.default.title": "預設環境", - "settings.workspaces.default.description": "選擇新工作階段的啟動位置", - "settings.workspaces.default.lastUsed": "每個專案上次使用的環境", - "settings.workspaces.default.local": "本機目錄", - "settings.workspaces.default.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": "建立工作區失敗", @@ -1240,19 +1207,4 @@ 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}}", -} +} satisfies Partial> diff --git a/packages/app/src/pages/new-session.tsx b/packages/app/src/pages/new-session.tsx index 0bf9d0a8533..8ceced9e840 100644 --- a/packages/app/src/pages/new-session.tsx +++ b/packages/app/src/pages/new-session.tsx @@ -4,7 +4,7 @@ import { useTitlebarRightMount } from "@/components/titlebar" import { useSettings } from "@/context/settings" import { useTabs, type DraftTab } from "@/context/tabs" import { useSearchParams } from "@solidjs/router" -import { createEffect, createMemo, createResource, createSignal } from "solid-js" +import { createEffect, createMemo, createResource } from "solid-js" import { createNewSessionDraftController } from "./new-session/new-session-draft-controller" import { NewSessionStatus, NewSessionView } from "./new-session/new-session-view" import { createNewSessionWorkspaceController } from "./new-session/new-session-workspace-controller" @@ -17,18 +17,13 @@ export default function NewSessionPage() { const [search] = useSearchParams<{ draftId?: string }>() const tabs = useTabs() const openWorkspaces = useSettingsDialog("workspaces") - const [localWorktree, setLocalWorktree] = createSignal() const draftTab = createMemo(() => tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId), ) const workspace = createNewSessionWorkspaceController({ - selected: () => (search.draftId ? draftTab()?.worktree : localWorktree()), + selected: () => draftTab()?.worktree, setSelected: (worktree) => { - if (search.draftId) { - tabs.updateDraft(search.draftId, { worktree }) - return - } - setLocalWorktree(worktree) + if (search.draftId) tabs.updateDraft(search.draftId, { worktree }) }, onViewAll: openWorkspaces, }) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 3e8fe3b6e55..e71997689c9 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -2077,7 +2077,7 @@ export default function Page() { > {hasReview() ? language.t("session.review.filesChanged", { count: reviewCount() }) - : language.t("session.review.change.other")} + : language.plural("session.review.change", 0)} diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 6f074fa2030..31d54f42366 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -783,10 +783,7 @@ export function SessionSidePanel(props: { when={settings.general.newLayoutDesigns()} fallback={ <> - {props.reviewCount()}{" "} - {language.t( - props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other", - )} + {props.reviewCount()} {language.plural("session.review.change", props.reviewCount())} } > diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index d4591bcd2f3..27906cb97f1 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -333,10 +333,7 @@ function SessionSummaryPanel(props: { "flex h-7 w-full items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base" return ( -
+
0} fallback={{language.t("session.review.noChanges")}}> - - {language.t( - props.diffs.length === 1 ? "ui.sessionTurn.diffs.changed.one" : "ui.sessionTurn.diffs.changed.other", - { count: String(props.diffs.length) }, - )} - + {language.plural("ui.sessionTurn.diffs.changed", props.diffs.length)} · @@ -519,14 +511,13 @@ export function MessageTimeline(props: { if (operation?.status === "pending") { return { kind: "pending" as const, - workspace: true, text: language.t(operation.type === "create" ? "workspace.lifecycle.creating" : "workspace.lifecycle.moving"), } } if (operation?.type === "create" && titleValue()?.startsWith("New session")) - return { kind: "created" as const, workspace: true, text: language.t("workspace.lifecycle.created") } + return { kind: "created" as const, text: language.t("workspace.lifecycle.created") } if (titleValue()?.startsWith("New session")) - return { kind: "starting" as const, workspace: workspaceSession(), text: language.t("workspace.lifecycle.starting") } + return { kind: "starting" as const, text: language.t("workspace.lifecycle.starting") } return }) const workspaceOperationPending = (sessionID: string) => @@ -1450,18 +1441,7 @@ export function MessageTimeline(props: { } 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 operation = () => workspaceRow().notice.operation const pending = () => operation().status === "pending" const status = () => { if (operation().status === "failed") return language.t("workspace.move.failed") @@ -1488,9 +1468,7 @@ export function MessageTimeline(props: { "text-v2-state-fg-danger": operation().status === "failed", }} > - - {status()} - + {status()} · @@ -2096,10 +2074,7 @@ export function MessageTimeline(props: { {language.t("session.share.action.share")}... - exportSession(id)} - > + exportSession(id)}> {language.t("common.export")}... { ]) }) - test("appends lifecycle extensions after current turns", () => { - const source = [ - { id: "msg_user", type: "user", text: "question", time: { created: 1 } }, - ] satisfies SessionMessageInfo[] - const normalized = normalizeSessionMessages("ses_1", source) - const messages = new Map(normalized.messages.map((message) => [message.id, message])) - - const result = Timeline.constructSessionMessageRows( - source, - (messageID) => messages.get(messageID), - (messageID) => normalized.parts.get(messageID) ?? [], - true, - "idle", - true, - normalized.messages.filter((message) => message.role === "user"), - (message) => [ - new TimelineRow.WorkspaceLifecycle({ - userMessageID: message.id, - notice: { - type: "operation", - operation: { type: "move", status: "complete", directory: "/workspace", messageID: message.id }, - }, - }), - ], - ) - - expect(result.rows.map(TimelineRow.key)).toEqual([ - "user-message:msg_user", - "workspace-lifecycle:msg_user:operation", - ]) - }) - test("removes a failed assistant error when the turn continues streaming", () => { const source = [ { id: "msg_user", type: "user", text: "recover", time: { created: 1 } }, diff --git a/packages/app/src/utils/workspace-operation.test.ts b/packages/app/src/utils/workspace-operation.test.ts index 04f3ea3c04d..1eb29e7ed25 100644 --- a/packages/app/src/utils/workspace-operation.test.ts +++ b/packages/app/src/utils/workspace-operation.test.ts @@ -11,29 +11,13 @@ test("workspace moves require settled followup state", () => { }) describe("WorkspaceOperation", () => { - test("reacts to operation completion", () => { + test("settles only the matching pending operation", () => { WorkspaceOperation.start(ServerScope.local, "session", "move", "/workspace") expect(WorkspaceOperation.get(ServerScope.local, "session")?.status).toBe("pending") - WorkspaceOperation.complete(ServerScope.local, "session") + WorkspaceOperation.complete(ServerScope.local, "session", "/other") + expect(WorkspaceOperation.get(ServerScope.local, "session")?.status).toBe("pending") + WorkspaceOperation.complete(ServerScope.local, "session", "/workspace") + WorkspaceOperation.fail(ServerScope.local, "session") expect(WorkspaceOperation.get(ServerScope.local, "session")?.status).toBe("complete") }) - - test("ignores completion for a different destination", () => { - WorkspaceOperation.start(ServerScope.local, "destination", "move", "/workspace/expected") - WorkspaceOperation.complete(ServerScope.local, "destination", "/workspace/other") - expect(WorkspaceOperation.get(ServerScope.local, "destination")?.status).toBe("pending") - }) - - test("does not downgrade a completed move after cleanup fails", () => { - WorkspaceOperation.start(ServerScope.local, "cleanup", "move", "/workspace") - WorkspaceOperation.complete(ServerScope.local, "cleanup") - WorkspaceOperation.fail(ServerScope.local, "cleanup", "source cleanup failed") - expect(WorkspaceOperation.get(ServerScope.local, "cleanup")?.status).toBe("complete") - }) - - test("settles a pending create from its worktree directory", () => { - WorkspaceOperation.start(ServerScope.local, "create", "create", "/workspace/create") - WorkspaceOperation.completeCreate(ServerScope.local, "/workspace/create") - expect(WorkspaceOperation.get(ServerScope.local, "create")?.status).toBe("complete") - }) }) diff --git a/packages/app/src/utils/workspace-operation.ts b/packages/app/src/utils/workspace-operation.ts index 24b99df408c..acae45ac637 100644 --- a/packages/app/src/utils/workspace-operation.ts +++ b/packages/app/src/utils/workspace-operation.ts @@ -8,7 +8,6 @@ export type WorkspaceOperationState = { status: "pending" | "complete" | "failed" directory: string messageID?: string - message?: string } export function canMoveSessionToWorkspace(input: { @@ -31,21 +30,6 @@ const write = (scope: ServerScope, sessionID: string, value: WorkspaceOperationS state.set(key(scope, sessionID), value) setVersion((current) => current + 1) } -const settleCreate = (scope: ServerScope, directory: string, status: "complete" | "failed", message?: string) => { - const prefix = ScopedKey.prefix(scope) - const target = pathKey(directory) - const matches = [...state].filter( - ([id, item]) => - id.startsWith(prefix) && - item.type === "create" && - item.status === "pending" && - pathKey(item.directory) === target, - ) - if (matches.length === 0) return - matches.forEach(([id, item]) => state.set(id, { ...item, status, message })) - setVersion((current) => current + 1) -} - export const WorkspaceOperation = { get(scope: ServerScope, sessionID: string) { version() @@ -60,15 +44,9 @@ export const WorkspaceOperation = { if (directory && pathKey(directory) !== pathKey(current.directory)) return write(scope, sessionID, { ...current, status: "complete" }) }, - completeCreate(scope: ServerScope, directory: string) { - settleCreate(scope, directory, "complete") - }, - failCreate(scope: ServerScope, directory: string, message: string) { - settleCreate(scope, directory, "failed", message) - }, - fail(scope: ServerScope, sessionID: string, message: string) { + fail(scope: ServerScope, sessionID: string) { const current = state.get(key(scope, sessionID)) if (!current || current.status === "complete") return - write(scope, sessionID, { ...current, status: "failed", message }) + write(scope, sessionID, { ...current, status: "failed" }) }, } diff --git a/packages/app/src/utils/workspace-request.test.ts b/packages/app/src/utils/workspace-request.test.ts deleted file mode 100644 index 9722cc96ff8..00000000000 --- a/packages/app/src/utils/workspace-request.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { expect, test } from "bun:test" -import { workspaceRequestWithTimeout } from "./workspace-request" - -test("workspace requests abort at the bounded timeout", async () => { - let aborted = false - const request = workspaceRequestWithTimeout( - (signal) => - new Promise((_, reject) => { - signal.addEventListener( - "abort", - () => { - aborted = true - reject(signal.reason) - }, - { once: true }, - ) - }), - "Workspace request failed", - 1, - ) - - await expect(request).rejects.toThrow("Workspace request failed") - expect(aborted).toBe(true) -}) - -test("workspace requests preserve non-timeout failures", async () => { - await expect( - workspaceRequestWithTimeout(() => Promise.reject(new Error("Server failed")), "Workspace request failed", 1_000), - ).rejects.toThrow("Server failed") -}) - -test("workspace request timeout settles when the request ignores abort", async () => { - await expect( - workspaceRequestWithTimeout(() => new Promise(() => {}), "Workspace request failed", 1), - ).rejects.toThrow("Workspace request failed") -}) diff --git a/packages/app/src/utils/workspace.test.ts b/packages/app/src/utils/workspace.test.ts index eddfb7cfd94..9b61c22a31f 100644 --- a/packages/app/src/utils/workspace.test.ts +++ b/packages/app/src/utils/workspace.test.ts @@ -3,15 +3,10 @@ import type { Session } from "@opencode-ai/sdk/v2/client" import { filterWorkspaceInventory, inspectWorkspaceDeletion, - isProjectDirectory, isWorkspaceDirectory, isWorkspaceSelection, - removeWorkspacesSequentially, mergeWorkspaceSessionInventory, - runWorkspaceDeleteTransaction, sessionsForWorkspace, - workspaceDefaultSelection, - workspaceDirectories, workspaceInventory, } from "./workspace" @@ -26,14 +21,8 @@ describe("isWorkspaceDirectory", () => { expect(isWorkspaceDirectory(project, "C:\\repo-workspaces\\feature")).toBe(true) expect(isWorkspaceDirectory(project, "c:\\repo-workspaces\\feature\\packages\\app")).toBe(true) expect( - isWorkspaceDirectory( - { worktree: "/repo", sandboxes: ["/repo/.worktrees/feature"] }, - "/repo/.worktrees/feature", - ), + isWorkspaceDirectory({ worktree: "/repo", sandboxes: ["/repo/.worktrees/feature"] }, "/repo/.worktrees/feature"), ).toBe(true) - }) - - test("does not classify unknown directories", () => { expect(isWorkspaceDirectory(project, "C:\\other")).toBe(false) expect(isWorkspaceDirectory(undefined, "C:\\repo-workspaces\\feature")).toBe(false) }) @@ -48,36 +37,10 @@ describe("isWorkspaceSelection", () => { expect(isWorkspaceSelection(project, "/repo/")).toBe(true) expect(isWorkspaceSelection(project, "/workspaces/feature/")).toBe(true) expect(isWorkspaceSelection({ worktree: "C:\\repo" }, "c:\\repo\\")).toBe(true) - }) - - test("rejects selections from a different project", () => { expect(isWorkspaceSelection(project, "/other/workspace")).toBe(false) }) }) -describe("workspaceDefaultSelection", () => { - test("uses the explicit global default", () => { - expect(workspaceDefaultSelection("local", "workspace")).toBe("main") - expect(workspaceDefaultSelection("new", "local")).toBe("create") - }) - - test("falls back to the last mode used in each project", () => { - expect(workspaceDefaultSelection("last-used", "workspace")).toBe("create") - expect(workspaceDefaultSelection("last-used", "local")).toBe("main") - expect(workspaceDefaultSelection("last-used", undefined)).toBe("main") - }) -}) - -describe("isProjectDirectory", () => { - const project = { worktree: "/repo", sandboxes: ["/workspaces/feature"] } - - test("accepts local and workspace subdirectories only", () => { - expect(isProjectDirectory(project, "/repo/packages/app")).toBe(true) - expect(isProjectDirectory(project, "/workspaces/feature/packages/app")).toBe(true) - expect(isProjectDirectory(project, "/other/project")).toBe(false) - }) -}) - test("groups and filters workspace inventory by project", () => { const inventory = workspaceInventory([ { id: "a", worktree: "/a", sandboxes: ["/a", "/a/one", "/a/two"] }, @@ -93,57 +56,8 @@ 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 = () => {} - const first = new Promise((resolve) => { - release = resolve - }) - const removing = removeWorkspacesSequentially(["one", "two"], async (workspace) => { - calls.push(`start:${workspace}`) - if (workspace === "one") await first - calls.push(`end:${workspace}`) - }) - - await Promise.resolve() - expect(calls).toEqual(["start:one"]) - release() - await removing - expect(calls).toEqual(["start:one", "end:one", "start:two", "end:two"]) -}) - -test("continues sequential deletion after a handled request failure", async () => { - const calls: string[] = [] - await removeWorkspacesSequentially(["one", "two"], async (workspace) => { - calls.push(workspace) - if (workspace === "one") await Promise.reject(new Error("failed")).catch(() => {}) - }) - expect(calls).toEqual(["one", "two"]) -}) - test("blocks unsafe workspace deletion", () => { const session = (directory: string) => ({ directory }) as Session - expect( - inspectWorkspaceDeletion({ - workspace: "/workspace", - activeDirectory: "/workspace/app", - sessions: [], - status: "clean", - }), - ).toBe("active") - expect( - inspectWorkspaceDeletion({ - workspace: "/workspace", - sessions: [session("/workspace/packages/app")], - status: "clean", - }), - ).toBe("linked") expect( inspectWorkspaceDeletion({ workspace: "/workspace", @@ -160,7 +74,6 @@ test("blocks unsafe workspace deletion", () => { }), ).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") expect( inspectWorkspaceDeletion({ @@ -200,53 +113,3 @@ test("merges workspace placement by freshness with authoritative server ties", ( "/source", ) }) - -test.each(["single", "bulk"])("acquires one atomic %s workspace delete transaction", async () => { - let transaction: "confirm" | number | undefined = "confirm" - let release = () => {} - const gate = new Promise((resolve) => { - release = resolve - }) - let requests = 0 - const run = (token: number) => - runWorkspaceDeleteTransaction({ - token, - set: (update) => { - transaction = update(transaction) - }, - task: async () => { - requests++ - await gate - }, - }) - - const first = run(1) - const duplicate = run(2) - expect(requests).toBe(1) - expect(transaction as "confirm" | number | undefined).toBe(1) - expect(await duplicate).toBe(false) - release() - expect(await first).toBe(true) - expect(transaction).toBeUndefined() - expect(requests).toBe(1) -}) - -test("workspace delete completion does not unlock a different owner", async () => { - let transaction: "confirm" | number | undefined = "confirm" - let release = () => {} - const gate = new Promise((resolve) => { - release = resolve - }) - const removing = runWorkspaceDeleteTransaction({ - token: 1, - set: (update) => { - transaction = update(transaction) - }, - task: () => gate, - }) - - transaction = 2 - release() - expect(await removing).toBe(true) - expect(transaction).toBe(2) -}) diff --git a/packages/app/src/utils/workspace.ts b/packages/app/src/utils/workspace.ts index acb8fa3247d..f221361fb37 100644 --- a/packages/app/src/utils/workspace.ts +++ b/packages/app/src/utils/workspace.ts @@ -42,35 +42,13 @@ export function removeWorkspacesSequentially(workspaces: readonly T[], remove return workspaces.reduce((previous, workspace) => previous.then(() => remove(workspace)), Promise.resolve()) } -export type WorkspaceDeleteTransaction = "confirm" | number | undefined - -export async function runWorkspaceDeleteTransaction(input: { - token: number - set: (update: (current: WorkspaceDeleteTransaction) => WorkspaceDeleteTransaction) => void - task: () => Promise -}) { - let acquired = false - input.set((current) => { - if (current !== "confirm") return current - acquired = true - return input.token - }) - if (!acquired) return false - try { - await input.task() - return true - } finally { - input.set((current) => (current === input.token ? undefined : current)) - } -} - -export type WorkspaceDeleteInspection = "safe" | "active" | "linked" | "dirty" | "unknown" +export type WorkspaceDeleteInspection = "safe" | "active" | "linked" | "dirty" export function inspectWorkspaceDeletion(input: { workspace: string activeDirectory?: string sessions: readonly Session[] - status: "clean" | "dirty" | "unknown" + status: "clean" | "dirty" }): WorkspaceDeleteInspection { if (input.activeDirectory && containsDirectory(input.workspace, input.activeDirectory)) return "active" if ( @@ -79,7 +57,6 @@ export function inspectWorkspaceDeletion(input: { ) ) return "linked" - if (input.status === "unknown") return "unknown" if (input.status === "dirty") return "dirty" return "safe" } diff --git a/packages/enterprise/src/app.tsx b/packages/enterprise/src/app.tsx index 1ee3c674774..db46bcedb36 100644 --- a/packages/enterprise/src/app.tsx +++ b/packages/enterprise/src/app.tsx @@ -5,7 +5,14 @@ import { MetaProvider } from "@solidjs/meta" import { MarkedProvider } from "@opencode-ai/ui/context/marked" import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { I18nProvider } from "@opencode-ai/ui/context" -import { pluralCategory, pluralKey, type UiI18nParams, type UiI18nPluralKey } from "@opencode-ai/ui/context/i18n" +import { + pluralCategory, + pluralKey, + type UiI18nParams, + type UiI18nPluralKey, + type UiTranslate, + type UiPluralCategory, +} from "@opencode-ai/ui/context/i18n" import { dict as uiEn } from "@opencode-ai/ui/i18n/en" import { dict as uiZh } from "@opencode-ai/ui/i18n/zh" import { createEffect, createMemo, Suspense, type ParentProps } from "solid-js" @@ -58,20 +65,30 @@ function detectLocale() { function UiI18nBridge(props: ParentProps) { const locale = createMemo(() => detectLocale()) const zh = uiZh as Partial> - const t = (key: keyof typeof uiEn, params?: UiI18nParams) => { + const translate = (key: keyof typeof uiEn, params?: UiI18nParams) => { const value = locale() === "zh" ? (zh[key] ?? uiEn[key]) : uiEn[key] const text = value ?? String(key) return resolveTemplate(text, params) } + const t = translate as UiTranslate + const pluralForm = (key: UiI18nPluralKey, category: UiPluralCategory, params?: UiI18nParams) => { + const candidate = pluralKey(key, category) + const fallback = pluralKey(key, "other") + const value = + locale() === "zh" + ? (zh[candidate] ?? zh[fallback] ?? uiEn[candidate] ?? uiEn[fallback]) + : (uiEn[candidate] ?? uiEn[fallback]) + return resolveTemplate(value ?? fallback, params) + } const plural = (key: UiI18nPluralKey, count: number, params?: UiI18nParams) => - t(pluralKey(key, pluralCategory(locale(), count)), { ...params, count }) + pluralForm(key, pluralCategory(locale(), count), { ...params, count }) createEffect(() => { if (typeof document !== "object") return document.documentElement.lang = locale() }) - return {props.children} + return {props.children} } export default function App() { diff --git a/packages/session-ui/AGENTS.md b/packages/session-ui/AGENTS.md index 678ae1bf90d..e6af6bed781 100644 --- a/packages/session-ui/AGENTS.md +++ b/packages/session-ui/AGENTS.md @@ -1,6 +1,8 @@ ## Localization - NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, empty states, and displayed errors. +- Feature work adds English source strings only. Leave non-English keys absent so the runtime English fallback applies; translations land separately after language review. +- Render count-sensitive copy through `i18n.plural(baseKey, count, params)`. Never select or pass `.zero`, `.one`, `.two`, `.few`, `.many`, or `.other` variants to `i18n.t(...)`; `pluralForm(...)` is reserved for components that animate individual grammatical forms. - When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change. - NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it. - Do not translate from model knowledge alone. Verify terminology and grammar with Unicode CLDR locale/plural data, Microsoft Localization Style Guides and terminology, Apple localization/style guidance and localized platform UI, Mozilla localization style guides, Mozilla Pontoon, and the Firefox localization corpus at `github.com/mozilla-l10n/firefox-l10n`. diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx index d2a00b9d44c..3b6297c89d9 100644 --- a/packages/session-ui/src/components/message-part.tsx +++ b/packages/session-ui/src/components/message-part.tsx @@ -545,7 +545,7 @@ export function getToolInfo( icon: "code-lines", title: i18n.t("ui.tool.patch"), subtitle: input.files?.length - ? `${input.files.length} ${i18n.t(input.files.length > 1 ? "ui.common.file.other" : "ui.common.file.one")}` + ? `${input.files.length} ${i18n.plural("ui.common.file", input.files.length)}` : undefined, } case "todowrite": @@ -2344,7 +2344,7 @@ ToolRegistry.register({ const subtitle = createMemo(() => { const count = files().length if (count === 0) return "" - return `${count} ${i18n.t(count > 1 ? "ui.common.file.other" : "ui.common.file.one")}` + return `${count} ${i18n.plural("ui.common.file", count)}` }) return ( @@ -2585,7 +2585,7 @@ ToolRegistry.register({ const count = questions().length if (count === 0) return "" if (completed()) return i18n.t("ui.question.subtitle.answered", { count }) - return `${count} ${i18n.t(count > 1 ? "ui.common.question.other" : "ui.common.question.one")}` + return `${count} ${i18n.plural("ui.common.question", count)}` }) return ( diff --git a/packages/session-ui/src/components/tool-count-label.tsx b/packages/session-ui/src/components/tool-count-label.tsx index 1069bbd0d5b..c1281e17a30 100644 --- a/packages/session-ui/src/components/tool-count-label.tsx +++ b/packages/session-ui/src/components/tool-count-label.tsx @@ -27,9 +27,11 @@ function common(one: string, other: string) { export function AnimatedCountLabel(props: { count: number; plural: UiI18nPluralKey; class?: string }) { const i18n = useI18n() const category = createMemo(() => pluralCategory(i18n.locale(), Math.round(props.count))) - const one = createMemo(() => split(i18n.t(pluralKey(props.plural, "one")))) - const other = createMemo(() => split(i18n.t(pluralKey(props.plural, "other")))) - const active = createMemo(() => split(i18n.t(pluralKey(props.plural, category())))) + const form = (category: ReturnType) => + i18n.pluralForm?.(props.plural, category) ?? (i18n.t as (key: string) => string)(pluralKey(props.plural, category)) + const one = createMemo(() => split(form("one"))) + const other = createMemo(() => split(form("other"))) + const active = createMemo(() => split(form(category()))) const suffix = createMemo(() => common(one().after, other().after)) const splitSuffix = createMemo( () => diff --git a/packages/ui/AGENTS.md b/packages/ui/AGENTS.md index 476baeed7f6..82dbf5026c1 100644 --- a/packages/ui/AGENTS.md +++ b/packages/ui/AGENTS.md @@ -1,6 +1,8 @@ ## Localization - NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for component defaults, visible copy, placeholders, accessible labels, tooltips, dialogs, toasts, empty states, and displayed errors. +- Feature work adds English source strings only. Leave non-English keys absent so the runtime English fallback applies; translations land separately after language review. +- Render count-sensitive copy through `plural(baseKey, count, params)`. Never select or pass `.zero`, `.one`, `.two`, `.few`, `.many`, or `.other` variants to `t(...)`; `pluralForm(...)` is reserved for components that animate individual grammatical forms. - When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change. - NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it. - Do not translate from model knowledge alone. Verify terminology and grammar with Unicode CLDR locale/plural data, Microsoft Localization Style Guides and terminology, Apple localization/style guidance and localized platform UI, Mozilla localization style guides, Mozilla Pontoon, and the Firefox localization corpus at `github.com/mozilla-l10n/firefox-l10n`. diff --git a/packages/ui/src/context/i18n.tsx b/packages/ui/src/context/i18n.tsx index be7a4d4f1e9..afb6e97262e 100644 --- a/packages/ui/src/context/i18n.tsx +++ b/packages/ui/src/context/i18n.tsx @@ -1,26 +1,24 @@ import { createContext, useContext, type Accessor, type ParentProps } from "solid-js" import { I18nProvider } from "@kobalte/core/i18n" import { dict as en } from "../i18n/en" +import type { Key, LocaleKey, PluralCategory, PluralKey, PluralLookupKey } from "../i18n/en" -export type UiI18nKey = keyof typeof en - -export const UI_PLURAL_KEYS = [ - "ui.sessionTurn.diffs.changed", - "ui.messagePart.context.read", - "ui.messagePart.context.search", - "ui.messagePart.context.list", -] as const -export type UiI18nPluralKey = (typeof UI_PLURAL_KEYS)[number] -export type UiPluralCategory = "zero" | "one" | "two" | "few" | "many" | "other" -export type UiI18nPluralLookupKey = `${UiI18nPluralKey}.${UiPluralCategory}` +export type UiI18nKey = Key +export type UiI18nPluralKey = PluralKey +export type UiPluralCategory = PluralCategory +export type UiI18nPluralLookupKey = PluralLookupKey +export type UiI18nLocaleKey = LocaleKey +type UiTranslationKey = Key extends UiI18nPluralLookupKey ? never : Key export type UiI18nParams = Record +export type UiTranslate = (key: UiTranslationKey, params?: UiI18nParams) => string export type UiI18n = { locale: Accessor layoutLocale?: Accessor - t: (key: UiI18nKey, params?: UiI18nParams) => string + t: UiTranslate plural: (key: UiI18nPluralKey, count: number, params?: UiI18nParams) => string + pluralForm?: (key: UiI18nPluralKey, category: UiPluralCategory, params?: UiI18nParams) => string } const rules = new Map() @@ -50,11 +48,16 @@ function resolveTemplate(text: string, params?: UiI18nParams) { const fallback: UiI18n = { locale: () => "en", t: (key, params) => { - const value = en[key] ?? String(key) + const value = en[key as UiI18nKey] ?? String(key) return resolveTemplate(value, params) }, plural: (key, count, params) => - fallback.t(pluralKey(key, pluralCategory(fallback.locale(), count)), { ...params, count }), + fallback.pluralForm!(key, pluralCategory(fallback.locale(), count), { ...params, count }), + pluralForm: (key, category, params) => { + const values = en as Partial> + const value = values[pluralKey(key, category)] ?? values[`${key}.other`] ?? `${key}.other` + return resolveTemplate(value, params) + }, } const Context = createContext(fallback) diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index aa0ec9c5735..ab0e6fcce1c 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -1,4 +1,4 @@ -export const dict: Record = { +const source = { "ui.sessionReview.title": "Session changes", "ui.sessionReview.title.git": "Git changes", "ui.sessionReview.title.branch": "Branch changes", @@ -217,4 +217,13 @@ export const dict: Record = { "ui.question.multiHint": "Select all answers that apply", "ui.question.singleHint": "Select one answer", "ui.question.custom.placeholder": "Type your answer...", -} +} satisfies Record + +export type Key = keyof typeof source +export type PluralCategory = "zero" | "one" | "two" | "few" | "many" | "other" +export type PluralKey = { + [Entry in Key]: Entry extends `${infer Base}.other` ? (`${Base}.one` extends Key ? Base : never) : never +}[Key] +export type PluralLookupKey = `${PluralKey}.${PluralCategory}` +export type LocaleKey = Key | PluralLookupKey +export const dict: typeof source & Record = source