refactor(app): simplify workspace flows and i18n

This commit is contained in:
LukeParkerDev 2026-08-06 15:14:44 +10:00
parent 5005898468
commit 1b99e64e67
60 changed files with 394 additions and 3047 deletions

View file

@ -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`.

View file

@ -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()

View file

@ -63,7 +63,7 @@ async function init(page: Page, tab: Record<string, unknown>) {
)
}
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<string, unknown> }>(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<void>((resolve) => {
release = resolve
})
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(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<string, unknown> }>(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<void>((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<string, unknown> }>(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<string, unknown> }>(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: {

View file

@ -29,23 +29,38 @@ export type SseEventOptions = {
export type SseTransport<T> = {
server: string
waitForConnection(options?: { after?: number; timeout?: number }): Promise<SseConnectionRecord>
send(payload: T, options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
waitForConnection(options?: {
after?: number
timeout?: number
path?: SseConnectionRecord["path"]
}): Promise<SseConnectionRecord>
send(payload: T, options?: SseEventOptions, path?: SseConnectionRecord["path"]): Promise<SseDeliveryAcknowledgement>
burst(payloads: readonly T[], options?: readonly SseEventOptions[]): Promise<SseDeliveryAcknowledgement[]>
split(payload: T, cuts: readonly number[], options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
heartbeat(options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
writeRaw(value: string | Uint8Array, cuts?: readonly number[], marker?: string): Promise<SseDeliveryAcknowledgement>
close(): Promise<void>
disconnect(message?: string): Promise<void>
error(message?: string): Promise<void>
error(message?: string, path?: SseConnectionRecord["path"]): Promise<void>
connections(): Promise<SseConnectionRecord[]>
acknowledgements(): Promise<SseDeliveryAcknowledgement[]>
}
type BrowserCommand<T> =
| { 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<T>(
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<T>(
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<T>(
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<T>(
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<T>(
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<T>(
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" })

View file

@ -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 (
<I18nProvider
value={{ locale: language.intl, layoutLocale: language.layoutLocale, t: language.t, plural: language.plural }}
value={{
locale: language.intl,
layoutLocale: language.layoutLocale,
t: language.t as UiI18n["t"],
plural: language.plural,
pluralForm: language.pluralForm,
}}
>
{props.children}
</I18nProvider>

View file

@ -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<string, Array<{ id: string; title?: string }>> = {}
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<void> | undefined
let sessionFailure: Error | undefined
let createWorktreeGate: Promise<void> | 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<Parameters<typeof createPromptSubmit>[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<void>((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<void>((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",
})
})
})

View file

@ -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)

View file

@ -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)
}

View file

@ -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<void>) => {
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<typeof captureDeleteContext>) => {
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
<div class="settings-v2-tab-body settings-v2-workspaces">
<div class="settings-v2-workspaces-toolbar">
<span class="settings-v2-workspaces-count">
{language.t(
filtered().length === 1 ? "settings.workspaces.count.one" : "settings.workspaces.count.other",
{
count: filtered().length,
},
)}
{language.plural("settings.workspaces.count", filtered().length)}
</span>
<div class="settings-v2-workspaces-toolbar-actions">
<Show when={projects().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={<Icon name="trash" size="small" />}
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}
>

View file

@ -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(

View file

@ -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<RawDictionary>
type PluralKey =
| UiI18nPluralKey
| "session.question.pending"
| "session.followupDock.summary"
| "session.revertDock.summary"
type AppI18nKey = Extract<keyof typeof en, string>
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 string> = Key extends AppI18nPluralLookupKey | UiI18nPluralLookupKey ? never : Key
type Source = { dict: Record<string, string> }
function cookie(locale: Locale) {
@ -69,7 +76,7 @@ const INTL: Record<Locale, string> = {
sv: "sv-SE",
}
const LABEL_KEY: Partial<Record<Locale, keyof Dictionary>> = {
const LABEL_KEY = {
en: "language.en",
zh: "language.zh",
zht: "language.zht",
@ -88,7 +95,7 @@ const LABEL_KEY: Partial<Record<Locale, keyof Dictionary>> = {
th: "language.th",
bs: "language.bs",
tr: "language.tr",
}
} as const satisfies Partial<Record<Locale, AppI18nKey>>
const LABEL: Partial<Record<Locale, string>> = {
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 extends string>(
key: TranslationKey<Key>,
params?: Record<string, string | number | boolean>,
) => string
const plural = (key: PluralKey, count: number, params?: Record<string, string | number | boolean>) => {
const category = pluralCategory(intl(), count)
const pluralForm = (
key: PluralKey,
category: UiPluralCategory,
params?: Record<string, string | number | boolean>,
) => {
const current = (dict.loading ? base : (dict() ?? base)) as Record<string, string>
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<string, string | number | boolean>) =>
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))
},

View file

@ -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", () => {

View file

@ -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<void>((resolve) => setTimeout(resolve, ms))
let attempt: AbortController | undefined
let worktreeAttempt: AbortController | undefined
let run: Promise<void> | 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(() => {

View file

@ -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<never>(() => {})
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)]

View file

@ -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", () => {

View file

@ -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": "تصدير",
}

View file

@ -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}}",
}

View file

@ -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",
}

View file

@ -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",
}

View file

@ -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}}",
}

View file

@ -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<Record<Keys, string>>

View file

@ -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",
}

View file

@ -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}}",
}

View file

@ -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 lespace de travail seront supprimés définitivement. La suppression ne continue que sil 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": "Lespace 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 lespace 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 lespace 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 lespace 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",
}

View file

@ -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}}",
}

View file

@ -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}}",
}

View file

@ -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",
}

View file

@ -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}}",
}

View file

@ -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}}",
}

View file

@ -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}}",
}

View file

@ -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<Record<Keys, string>>

View file

@ -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}}",
}

View file

@ -30,17 +30,7 @@ const appLocales = [
"sv",
] as const
const desktopLocales = appLocales
const pluralCategories: Partial<Record<(typeof appLocales)[number], readonly string[]>> = {
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<string, string>) {
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<string>) {
const family = pluralFamily(key)
return family !== undefined && families.has(family)
}

View file

@ -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",
}

View file

@ -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",
}

View file

@ -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}}",
}

View file

@ -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}}",
}

View file

@ -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<Record<Keys, string>>

View file

@ -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",
}

View file

@ -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}}",
}

View file

@ -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}}",
}

View file

@ -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<Record<Keys, string>>

View file

@ -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<Record<Keys, string>>

View file

@ -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<string>()
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,
})

View file

@ -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)}
</Tabs.Trigger>
</Tabs.List>
</Tabs>

View file

@ -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())}
</>
}
>

View file

@ -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 (
<div
data-component="session-summary-panel"
class="w-[280px]"
>
<div data-component="session-summary-panel" class="w-[280px]">
<div class="relative z-10 flex flex-col gap-1 overflow-hidden rounded-[6px] bg-v2-background-bg-base px-0.5 py-1.5 shadow-[var(--v2-elevation-raised)]">
<div class={row}>
<ProjectAvatar
@ -389,12 +386,7 @@ function SessionSummaryPanel(props: {
>
<IconV2 name="review" class="shrink-0 text-v2-icon-icon-muted" />
<Show when={props.diffs.length > 0} fallback={<span>{language.t("session.review.noChanges")}</span>}>
<span>
{language.t(
props.diffs.length === 1 ? "ui.sessionTurn.diffs.changed.one" : "ui.sessionTurn.diffs.changed.other",
{ count: String(props.diffs.length) },
)}
</span>
<span>{language.plural("ui.sessionTurn.diffs.changed", props.diffs.length)}</span>
<span class="text-v2-text-text-muted">·</span>
<DiffChangesV2 changes={props.diffs} />
</Show>
@ -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<TimelineRowByTag<"WorkspaceLifecycle">>
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",
}}
>
<span class={operation().status === "failed" ? "" : "text-v2-text-text-base"}>
{status()}
</span>
<span class={operation().status === "failed" ? "" : "text-v2-text-text-base"}>{status()}</span>
<Show when={operation().status !== "failed"}>
<span class="text-[11px] font-[530] italic text-v2-text-text-muted">·</span>
<IconV2 name="workspace-isolated" class="shrink-0 text-v2-icon-icon-muted" />
@ -2096,10 +2074,7 @@ export function MessageTimeline(props: {
{language.t("session.share.action.share")}...
</MenuV2.Item>
</Show>
<MenuV2.Item
disabled={workspaceOperationPending(id)}
onSelect={() => exportSession(id)}
>
<MenuV2.Item disabled={workspaceOperationPending(id)} onSelect={() => exportSession(id)}>
{language.t("common.export")}...
</MenuV2.Item>
<MenuV2.Item

View file

@ -168,38 +168,6 @@ describe("current session timeline rows", () => {
])
})
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 } },

View file

@ -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")
})
})

View file

@ -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" })
},
}

View file

@ -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<never>((_, 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<never>(() => {}), "Workspace request failed", 1),
).rejects.toThrow("Workspace request failed")
})

View file

@ -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<void>((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<void>((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<void>((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)
})

View file

@ -42,35 +42,13 @@ export function removeWorkspacesSequentially<T>(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<void>
}) {
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"
}

View file

@ -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<Record<string, string>>
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 <I18nProvider value={{ locale, t, plural }}>{props.children}</I18nProvider>
return <I18nProvider value={{ locale, t, plural, pluralForm }}>{props.children}</I18nProvider>
}
export default function App() {

View file

@ -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`.

View file

@ -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 (

View file

@ -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<typeof pluralCategory>) =>
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(
() =>

View file

@ -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`.

View file

@ -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 string> = Key extends UiI18nPluralLookupKey ? never : Key
export type UiI18nParams = Record<string, string | number | boolean>
export type UiTranslate = <Key extends string>(key: UiTranslationKey<Key>, params?: UiI18nParams) => string
export type UiI18n = {
locale: Accessor<string>
layoutLocale?: Accessor<string>
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<string, Intl.PluralRules>()
@ -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<Record<UiI18nLocaleKey, string>>
const value = values[pluralKey(key, category)] ?? values[`${key}.other`] ?? `${key}.other`
return resolveTemplate(value, params)
},
}
const Context = createContext<UiI18n>(fallback)

View file

@ -1,4 +1,4 @@
export const dict: Record<string, string> = {
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<string, string> = {
"ui.question.multiHint": "Select all answers that apply",
"ui.question.singleHint": "Select one answer",
"ui.question.custom.placeholder": "Type your answer...",
}
} satisfies Record<string, string>
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<string, string> = source