From af14a5b50b992c7e75cd900d067b40e5e5c88b37 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:49:17 -0700 Subject: [PATCH] fix(i18n): localize provider errors and shared/native UI labels (#34680) * fix(i18n): localize provider failures and remaining shared UI copy Translate Linear/Jira failures at the renderer boundary while preserving English errors and adding optional stable codes for existing clients. Localize host status, source-control metadata, discard confirmations, native dialogs, and catalog gaps across all six shipped locales. Inventory and reproduction paths contributed in #32207. Co-authored-by: Seiji Kohara <9543980+seijikohara@users.noreply.github.com> Co-authored-by: Evgenii <413011+smwbev@users.noreply.github.com> * test(i18n): assert stable task provider failure codes * test(i18n): exercise goal notice fallback with an older language pack * fix(i18n): address provider error and live title review findings Handle missing remote error text, type provider codes and keep codes intact through connection tests and optimistic edits. Fix the remaining Linear load fallback and redundant translation calls, apply Japanese wording corrections, and refresh native titles on language changes. Co-authored-by: Seiji Kohara <9543980+seijikohara@users.noreply.github.com> * fix(i18n): finish provider and shared-copy localization for all locales - Translate the branch's new keys into de, ru, tr and zh-Hant (added to main after this branch was cut), with Russian few/many forms. - Use whole sentences for file deletion failures and the mixed batch notice instead of inserting a 'Recycle Bin'/'Trash' word, and count keys for the batch titles. - Translate the workspace/activity options filter count (was a shared 'N filter(s)' literal) at the renderer call sites. - Use _one/_other families for MCP server counts, untracked-file discard copy, resume-chat counts, discard file counts and visible host counts. - Localize PR/MR card status labels, the unsupported-agent hint, Linear title/ description update failures, the Linear 'showing first N' notice and the Linear Connect button. Co-authored-by: Evgenii <413011+smwbev@users.noreply.github.com> --------- Co-authored-by: Seiji Kohara <9543980+seijikohara@users.noreply.github.com> Co-authored-by: Evgenii <413011+smwbev@users.noreply.github.com> Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- config/scripts/apply-translation-delta.mjs | 2 +- .../cookies/browser-cookie-import-pipeline.ts | 7 +- src/main/ipc/jira/register-handlers.ts | 20 +- src/main/ipc/pet-bundle-import.ts | 3 +- src/main/ipc/pet.ts | 5 +- src/main/ipc/shell.ts | 20 +- src/main/jira/client.test.ts | 15 +- src/main/jira/client.ts | 26 +- src/main/jira/jira-issue-mutations.ts | 23 +- src/main/linear/client.test.ts | 3 +- src/main/linear/client.ts | 5 +- src/main/linear/linear-issue-comments.ts | 5 +- src/main/linear/linear-issue-mutations.ts | 14 +- src/main/linear/linear-project-queries.ts | 5 +- src/main/menu/gpu-acceleration-about-panel.ts | 19 +- .../warp-themes/manual-warp-theme-files.ts | 12 +- .../window/dashboard-popout-window.test.ts | 21 ++ src/main/window/dashboard-popout-window.ts | 14 +- src/main/window/workspace-space-window.ts | 14 +- .../src/components/LinearIssueTextEditor.tsx | 14 +- .../NativeChatResumeOnRestartModal.tsx | 13 +- .../activity/activity-thread-options-menu.tsx | 4 +- .../automation-target-availability.ts | 6 +- .../browser-toolbar-menu-dropdown.tsx | 3 +- .../browser-viewport-preset-label.ts | 21 ++ .../feature-wall/AiCommitPrSettingsFields.tsx | 11 +- .../source-control-model-label.ts | 32 ++ .../github-project/ProjectPickerBrowse.tsx | 10 +- .../github-project/ProjectPickerPanels.tsx | 5 +- .../src/components/github-project/columns.ts | 5 +- .../slug-dialog/SlugDialogBody.tsx | 6 +- .../github-project/useProjectPickerBrowse.ts | 6 +- .../src/components/jira-connect-dialog.tsx | 14 +- .../jira-credential-storage-copy.ts | 13 + .../components/jira-issue-mutation-queue.ts | 3 +- .../jira-issue-transition-control.tsx | 5 +- .../src/components/linear-api-key-dialog.tsx | 43 ++- .../linear-issue-project-selector.tsx | 7 +- .../components/linear-issue-sub-issues.tsx | 7 +- .../linear-issue-workspace-detail-state.ts | 8 +- .../linear-item-drawer-comment-footer.tsx | 3 +- .../linear-item-drawer-edit-controller.tsx | 16 +- .../linear-project-view-surfaces.test.tsx | 2 +- .../linear-project-view-surfaces.tsx | 92 ++++- .../native-chat/NativeChatModelCombobox.tsx | 3 +- .../agent-session-write-notice-text.test.ts | 19 +- .../native-chat-model-description.ts | 57 +++ .../components/ports/WorkspacePortScanner.tsx | 12 +- .../file-explorer-delete-classification.ts | 37 ++ .../right-sidebar/host-scoped-ports-panel.tsx | 7 +- ...ource-control-discard-confirmation.test.ts | 15 +- .../source-control-discard-dialog.test.tsx | 24 +- .../commit/discard-confirmation.ts | 39 +- .../source-control/commit/discard-dialog.tsx | 13 +- .../right-sidebar/useFileDeletion.ts | 28 +- .../components/settings/McpConfigFileRow.tsx | 30 +- .../src/components/settings/ShortcutsPane.tsx | 4 +- .../src/components/settings/SshTargetCard.tsx | 28 +- .../settings/jira-integration-card.tsx | 5 +- .../settings/mcp-config-inspection.ts | 8 +- .../runtime-environment-host-details.ts | 2 +- .../task-tracker-integration-cards.test.tsx | 27 +- .../task-tracker-integration-cards.tsx | 5 +- ...-runtime-environment-connection-actions.ts | 3 +- .../sidebar/AddRepoHostSelector.tsx | 3 +- .../sidebar/HostSectionHeaderMenu.tsx | 3 +- .../sidebar/WorktreeCardStatusSlot.tsx | 20 +- .../sidebar/active-filter-count-label.ts | 7 + .../sidebar/sidebar-host-options.ts | 17 +- .../sidebar/workspace-options-menu-items.tsx | 8 +- .../SourceControlActionVariableChips.tsx | 3 +- .../source-control-variable-description.ts | 69 ++++ .../remote-host-connection-status.ts | 6 +- .../jira/use-task-page-jira-issue-creation.ts | 5 +- .../use-task-page-jira-summary-generation.ts | 5 +- .../task-page/linear/ConnectDialog.tsx | 7 +- .../components/task-page/linear/Content.tsx | 4 +- .../task-page/linear/CustomViewList.tsx | 2 +- .../task-page/linear/CustomViewProjects.tsx | 2 +- .../components/task-page/linear/IssueList.tsx | 12 +- .../task-page/linear/ProjectList.tsx | 2 +- .../linear/task-page-linear-issue-model.tsx | 3 +- .../linear/use-task-page-linear-board.ts | 3 +- ...use-task-page-linear-collection-effects.ts | 21 +- ...se-task-page-linear-custom-view-effects.ts | 13 +- .../use-task-page-linear-in-orca-effects.ts | 7 +- .../use-task-page-linear-issue-creation.ts | 3 +- .../use-task-page-linear-list-effects.ts | 7 +- .../use-task-page-linear-project-creation.ts | 5 +- .../src/hooks/useImmediateMutation.test.ts | 32 ++ .../src/hooks/useImmediateMutation.ts | 8 +- .../src/i18n/en-runtime-required.json | 54 ++- src/renderer/src/i18n/locales/de.json | 305 ++++++++++++++-- src/renderer/src/i18n/locales/en.json | 302 ++++++++++++++-- src/renderer/src/i18n/locales/es.json | 310 ++++++++++++++-- src/renderer/src/i18n/locales/fr.json | 310 ++++++++++++++-- src/renderer/src/i18n/locales/ja.json | 305 ++++++++++++++-- src/renderer/src/i18n/locales/ko.json | 305 ++++++++++++++-- src/renderer/src/i18n/locales/pt.json | 312 ++++++++++++++-- src/renderer/src/i18n/locales/ru.json | 333 ++++++++++++++++-- src/renderer/src/i18n/locales/tr.json | 305 ++++++++++++++-- src/renderer/src/i18n/locales/zh-Hant.json | 300 ++++++++++++++-- src/renderer/src/i18n/locales/zh.json | 302 ++++++++++++++-- .../src/lib/runtime-host-copy.test.ts | 35 ++ src/renderer/src/lib/runtime-host-copy.ts | 50 +++ .../src/lib/task-provider-error-copy.test.ts | 52 +++ .../src/lib/task-provider-error-copy.ts | 200 +++++++++++ .../src/lib/workspace-port-actions.ts | 11 +- .../src/lib/workspace-port-scan-refresh.ts | 8 +- src/renderer/src/lib/worktree-status.ts | 37 +- .../src/runtime/runtime-protocol-compat.ts | 3 +- .../src/store/github/project-actions.ts | 6 +- src/shared/task-provider-failure.ts | 50 +++ 113 files changed, 4537 insertions(+), 583 deletions(-) create mode 100644 src/renderer/src/components/browser-pane/assemble-chrome/browser-viewport-preset-label.ts create mode 100644 src/renderer/src/components/feature-wall/source-control-model-label.ts create mode 100644 src/renderer/src/components/jira-credential-storage-copy.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-model-description.ts create mode 100644 src/renderer/src/components/sidebar/active-filter-count-label.ts create mode 100644 src/renderer/src/components/source-control/source-control-variable-description.ts create mode 100644 src/renderer/src/hooks/useImmediateMutation.test.ts create mode 100644 src/renderer/src/lib/runtime-host-copy.test.ts create mode 100644 src/renderer/src/lib/runtime-host-copy.ts create mode 100644 src/renderer/src/lib/task-provider-error-copy.test.ts create mode 100644 src/renderer/src/lib/task-provider-error-copy.ts create mode 100644 src/shared/task-provider-failure.ts diff --git a/config/scripts/apply-translation-delta.mjs b/config/scripts/apply-translation-delta.mjs index 545e5379ec4..c3031dba937 100644 --- a/config/scripts/apply-translation-delta.mjs +++ b/config/scripts/apply-translation-delta.mjs @@ -5,7 +5,7 @@ import process from 'node:process' import { repairTranslatedValue } from './locale-translation-policy.mjs' const LOCALES_DIR = path.join('src', 'renderer', 'src', 'i18n', 'locales') -const SUPPORTED_LOCALES = new Set(['de', 'es', 'fr', 'ja', 'ko', 'ru', 'zh']) +const SUPPORTED_LOCALES = new Set(['de', 'es', 'fr', 'ja', 'ko', 'pt', 'ru', 'tr', 'zh', 'zh-Hant']) const PLACEHOLDER_RE = /\{\{[^}]+\}\}/g function placeholders(value) { diff --git a/src/main/browser/cookies/browser-cookie-import-pipeline.ts b/src/main/browser/cookies/browser-cookie-import-pipeline.ts index 14af27a6414..4ede93613e3 100644 --- a/src/main/browser/cookies/browser-cookie-import-pipeline.ts +++ b/src/main/browser/cookies/browser-cookie-import-pipeline.ts @@ -1,3 +1,4 @@ +import { translateMain } from '../../i18n/main-i18n' import { dialog, session, type BrowserWindow } from 'electron' import { summarizePartitionSkips } from './browser-cookie-partition-summary' import type { @@ -147,10 +148,10 @@ export async function importValidatedCookies( // Main owns the file picker so a compromised renderer cannot request arbitrary file reads. export async function pickCookieFile(parentWindow: BrowserWindow | null): Promise { const opts = { - title: 'Import Cookies', + title: translateMain('native.dialog.importCookies', 'Import Cookies'), filters: [ - { name: 'Cookie Files', extensions: ['json'] }, - { name: 'All Files', extensions: ['*'] } + { name: translateMain('native.dialog.cookieFiles', 'Cookie Files'), extensions: ['json'] }, + { name: translateMain('native.dialog.allFiles', 'All Files'), extensions: ['*'] } ], properties: ['openFile' as const] } diff --git a/src/main/ipc/jira/register-handlers.ts b/src/main/ipc/jira/register-handlers.ts index bd802ce12a1..d8471262d31 100644 --- a/src/main/ipc/jira/register-handlers.ts +++ b/src/main/ipc/jira/register-handlers.ts @@ -1,3 +1,4 @@ +import { taskProviderFailure } from '../../../shared/task-provider-failure' import { JiraIssueListFiltersParam } from '../../../shared/rpc-contract/jira-quick-filter-params' import { JiraFavouriteFilterParams } from '../../../shared/rpc-contract/jira-favourite-filter-params' import { ipcMain } from 'electron' @@ -39,7 +40,10 @@ export function registerJiraHandlers(): void { typeof args?.email !== 'string' || typeof args?.apiToken !== 'string' ) { - return { ok: false, error: 'Site URL, email, and API token are required.' } + return taskProviderFailure( + 'jira.credentialsRequired', + 'Site URL, email, and API token are required.' + ) } const result = await connect({ siteUrl: args.siteUrl, @@ -160,13 +164,13 @@ export function registerJiraHandlers(): void { ipcMain.handle('jira:createIssue', async (_event, args: JiraCreateIssueArgs) => { if (typeof args?.projectId !== 'string' || !args.projectId.trim()) { - return { ok: false, error: 'Project is required.' } + return taskProviderFailure('jira.projectRequired', 'Project is required.') } if (typeof args?.issueTypeId !== 'string' || !args.issueTypeId.trim()) { - return { ok: false, error: 'Issue type is required.' } + return taskProviderFailure('jira.issueTypeRequired', 'Issue type is required.') } if (typeof args?.title !== 'string' || !args.title.trim()) { - return { ok: false, error: 'Title is required.' } + return taskProviderFailure('jira.titleRequired', 'Title is required.') } return createIssue({ siteId: normalizeJiraSiteId(args.siteId), @@ -186,11 +190,11 @@ export function registerJiraHandlers(): void { 'jira:updateIssue', async (_event, args: { key: string; updates: JiraIssueUpdate; siteId?: string }) => { if (typeof args?.key !== 'string' || !args.key.trim()) { - return { ok: false, error: 'Issue key is required.' } + return taskProviderFailure('jira.issueKeyRequired', 'Issue key is required.') } const updates = normalizeJiraIssueUpdate(args.updates) if (!updates) { - return { ok: false, error: 'Updates object is required.' } + return taskProviderFailure('jira.updatesRequired', 'Updates object is required.') } return updateIssue(args.key.trim(), updates, normalizeJiraSiteId(args.siteId)) } @@ -200,10 +204,10 @@ export function registerJiraHandlers(): void { 'jira:addIssueComment', async (_event, args: { key: string; body: string; siteId?: string }) => { if (typeof args?.key !== 'string' || !args.key.trim()) { - return { ok: false, error: 'Issue key is required.' } + return taskProviderFailure('jira.issueKeyRequired', 'Issue key is required.') } if (typeof args?.body !== 'string' || !args.body.trim()) { - return { ok: false, error: 'Comment body is required.' } + return taskProviderFailure('jira.commentBodyRequired', 'Comment body is required.') } return addIssueComment(args.key.trim(), args.body.trim(), normalizeJiraSiteId(args.siteId)) } diff --git a/src/main/ipc/pet-bundle-import.ts b/src/main/ipc/pet-bundle-import.ts index deaf3de3491..4ecba5db827 100644 --- a/src/main/ipc/pet-bundle-import.ts +++ b/src/main/ipc/pet-bundle-import.ts @@ -1,3 +1,4 @@ +import { translateMain } from '../i18n/main-i18n' import { BrowserWindow, dialog } from 'electron' import { mkdir, readFile, rename, rm, stat } from 'node:fs/promises' import { randomUUID } from 'node:crypto' @@ -18,7 +19,7 @@ export async function importPetBundle( BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getFocusedWindow() // Finder may select pet.json inside the bundle; accept it and use its parent. const options: Electron.OpenDialogOptions = { - title: 'Pick a .codex-pet bundle', + title: translateMain('native.dialog.importPetBundle', 'Pick a .codex-pet bundle'), properties: ['openFile', 'openDirectory', 'treatPackageAsDirectory'] } const result = senderWindow diff --git a/src/main/ipc/pet.ts b/src/main/ipc/pet.ts index 9c7c4d479fb..38cc0f65488 100644 --- a/src/main/ipc/pet.ts +++ b/src/main/ipc/pet.ts @@ -1,3 +1,4 @@ +import { translateMain } from '../i18n/main-i18n' import { BrowserWindow, dialog, ipcMain } from 'electron' import { copyFile, mkdir, readFile, rm, stat } from 'node:fs/promises' import { randomUUID } from 'node:crypto' @@ -21,12 +22,12 @@ export function registerPetHandlers(): void { const senderWindow = BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getFocusedWindow() const options: Electron.OpenDialogOptions = { - title: 'Pick pet', + title: translateMain('native.dialog.pickPet', 'Pick pet'), properties: ['openFile'], // APNG uses .png; its missing macOS UTI can hide other formats when listed separately. filters: [ { - name: 'Pet image', + name: translateMain('native.dialog.petImage', 'Pet image'), extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'] } ] diff --git a/src/main/ipc/shell.ts b/src/main/ipc/shell.ts index 1e3126ed2d6..2a89deff991 100644 --- a/src/main/ipc/shell.ts +++ b/src/main/ipc/shell.ts @@ -1,3 +1,4 @@ +import { translateMain } from '../i18n/main-i18n' import { validatePathExistenceBatch } from '../../shared/path-existence-batch' import { ipcMain, shell, dialog } from 'electron' import { constants, copyFile, readFile, stat } from 'node:fs/promises' @@ -240,7 +241,10 @@ export function registerShellHandlers( const result = await dialog.showOpenDialog({ properties: ['openFile'], filters: [ - { name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico'] } + { + name: translateMain('native.dialog.images', 'Images'), + extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico'] + } ] }) if (result.canceled || result.filePaths.length === 0) { @@ -254,7 +258,12 @@ export function registerShellHandlers( async (): Promise<{ dataUrl: string; fileName: string } | null> => { const result = await dialog.showOpenDialog({ properties: ['openFile'], - filters: [{ name: 'Repo icon images', extensions: ['png'] }] + filters: [ + { + name: translateMain('native.dialog.repoIconImages', 'Repo icon images'), + extensions: ['png'] + } + ] }) if (result.canceled || result.filePaths.length === 0) { return null @@ -283,7 +292,12 @@ export function registerShellHandlers( ipcMain.handle('shell:pickAudio', async (): Promise => { const result = await dialog.showOpenDialog({ properties: ['openFile'], - filters: [{ name: 'Audio', extensions: ['ogg', 'mp3', 'wav', 'm4a', 'aac', 'flac'] }] + filters: [ + { + name: translateMain('native.dialog.audio', 'Audio'), + extensions: ['ogg', 'mp3', 'wav', 'm4a', 'aac', 'flac'] + } + ] }) if (result.canceled || result.filePaths.length === 0) { return null diff --git a/src/main/jira/client.test.ts b/src/main/jira/client.test.ts index fc9beac7ea4..8cd106d5e1b 100644 --- a/src/main/jira/client.test.ts +++ b/src/main/jira/client.test.ts @@ -358,7 +358,8 @@ describe('Jira client credential storage', () => { await expect(jira.testConnection(siteId)).resolves.toEqual({ ok: false, - error: 'Not connected to Jira.' + error: 'Not connected to Jira.', + errorCode: 'jira.notConnected' }) expect(fetchMock).not.toHaveBeenCalled() @@ -555,7 +556,11 @@ describe('Jira client credential storage', () => { apiToken: '', authType: 'server' }) - ).resolves.toEqual({ ok: false, error: 'Personal access token is required.' }) + ).resolves.toEqual({ + ok: false, + error: 'Personal access token is required.', + errorCode: 'jira.personalTokenRequired' + }) expect(netFetchMock).not.toHaveBeenCalled() }) @@ -569,7 +574,11 @@ describe('Jira client credential storage', () => { apiToken: '', authType: 'server' }) - ).resolves.toEqual({ ok: false, error: 'Password is required.' }) + ).resolves.toEqual({ + ok: false, + error: 'Password is required.', + errorCode: 'jira.passwordRequired' + }) expect(netFetchMock).not.toHaveBeenCalled() }) diff --git a/src/main/jira/client.ts b/src/main/jira/client.ts index 2d7c8fa4e97..9c673f3f301 100644 --- a/src/main/jira/client.ts +++ b/src/main/jira/client.ts @@ -1,3 +1,4 @@ +import { taskProviderFailure } from '../../shared/task-provider-failure' import { CredentialDecryptionError } from '../integration-credential-file' import type { JiraAuthType, @@ -82,7 +83,7 @@ export async function connect( try { siteUrl = normalizeJiraSiteUrl(args.siteUrl) } catch { - return { ok: false, error: 'Enter a valid Jira site URL.' } + return taskProviderFailure('jira.invalidSiteUrl', 'Enter a valid Jira site URL.') } const authType: JiraAuthType = args.authType === 'server' ? 'server' : 'cloud' @@ -92,13 +93,12 @@ export async function connect( if (!apiToken) { // A username present means classic Basic auth (password); its absence // means the credential is a personal access token sent as Bearer. - return { - ok: false, - error: email ? 'Password is required.' : 'Personal access token is required.' - } + return email + ? taskProviderFailure('jira.passwordRequired', 'Password is required.') + : taskProviderFailure('jira.personalTokenRequired', 'Personal access token is required.') } } else if (!apiToken) { - return { ok: false, error: 'API token is required.' } + return taskProviderFailure('jira.apiTokenRequired', 'API token is required.') } await acquire() @@ -126,7 +126,9 @@ export async function connect( }) return { ok: true, viewer } } catch (error) { - return { ok: false, error: error instanceof Error ? error.message : 'Connection failed.' } + return error instanceof Error + ? { ok: false, error: error.message } + : taskProviderFailure('jira.connectionFailed', 'Connection failed.') } finally { release() } @@ -167,10 +169,12 @@ export async function testConnection( try { client = getClients(siteId)[0] } catch (error) { - return { ok: false, error: error instanceof Error ? error.message : 'Connection failed.' } + return error instanceof Error + ? { ok: false, error: error.message } + : taskProviderFailure('jira.connectionFailed', 'Connection failed.') } if (!client) { - return { ok: false, error: 'Not connected to Jira.' } + return taskProviderFailure('jira.notConnected', 'Not connected to Jira.') } await acquire() try { @@ -180,7 +184,9 @@ export async function testConnection( ) return { ok: true, viewer } } catch (error) { - return { ok: false, error: error instanceof Error ? error.message : 'Connection failed.' } + return error instanceof Error + ? { ok: false, error: error.message } + : taskProviderFailure('jira.connectionFailed', 'Connection failed.') } finally { release() } diff --git a/src/main/jira/jira-issue-mutations.ts b/src/main/jira/jira-issue-mutations.ts index acc2d059a11..32bb3168693 100644 --- a/src/main/jira/jira-issue-mutations.ts +++ b/src/main/jira/jira-issue-mutations.ts @@ -1,3 +1,4 @@ +import { taskProviderFailure } from '../../shared/task-provider-failure' import type { JiraCreateIssueArgs, JiraCreateIssueResult, @@ -31,11 +32,11 @@ function toUserFieldValue(site: JiraSite, value: unknown): unknown { export async function createIssue(args: JiraCreateIssueArgs): Promise { const entry = getClients(args.siteId)[0] if (!entry) { - return { ok: false, error: 'Not connected to Jira.' } + return taskProviderFailure('jira.notConnected', 'Not connected to Jira.') } const title = args.title.trim() if (!title) { - return { ok: false, error: 'Title is required.' } + return taskProviderFailure('jira.titleRequired', 'Title is required.') } await acquire() @@ -69,7 +70,9 @@ export async function createIssue(args: JiraCreateIssueArgs): Promise { const entry = getClients(siteId)[0] if (!entry) { - return { ok: false, error: 'Not connected to Jira.' } + return taskProviderFailure('jira.notConnected', 'Not connected to Jira.') } if (updates.labels && updates.labelOperations) { - return { ok: false, error: 'Pass labels or labelOperations, not both.' } + return taskProviderFailure('jira.labelsConflict', 'Pass labels or labelOperations, not both.') } await acquire() try { @@ -141,7 +144,9 @@ export async function updateIssue( clearToken(entry.site.id) throw error } - return { ok: false, error: error instanceof Error ? error.message : 'Failed to update issue.' } + return error instanceof Error + ? { ok: false, error: error.message } + : taskProviderFailure('jira.updateFailed', 'Failed to update issue.') } finally { release() } @@ -154,7 +159,7 @@ export async function addIssueComment( ): Promise<{ ok: true; id: string } | { ok: false; error: string }> { const entry = getClients(siteId)[0] if (!entry) { - return { ok: false, error: 'Not connected to Jira.' } + return taskProviderFailure('jira.notConnected', 'Not connected to Jira.') } await acquire() try { @@ -172,7 +177,9 @@ export async function addIssueComment( clearToken(entry.site.id) throw error } - return { ok: false, error: error instanceof Error ? error.message : 'Failed to add comment.' } + return error instanceof Error + ? { ok: false, error: error.message } + : taskProviderFailure('jira.commentFailed', 'Failed to add comment.') } finally { release() } diff --git a/src/main/linear/client.test.ts b/src/main/linear/client.test.ts index b0c7ad1eb52..75a33adb5da 100644 --- a/src/main/linear/client.test.ts +++ b/src/main/linear/client.test.ts @@ -374,7 +374,8 @@ describe('Linear client workspace storage', () => { await expect(linear.testConnection('legacy')).resolves.toEqual({ ok: false, - error: 'No API key stored.' + error: 'No API key stored.', + errorCode: 'linear.noApiKey' }) expect(linearClientMock).not.toHaveBeenCalled() diff --git a/src/main/linear/client.ts b/src/main/linear/client.ts index 4ce92a0de20..103087bd8b9 100644 --- a/src/main/linear/client.ts +++ b/src/main/linear/client.ts @@ -1,3 +1,4 @@ +import { taskProviderFailure } from '../../shared/task-provider-failure' import type { LinearClient } from '@linear/sdk' import { createLinearClient, loadLinearSdk } from './linear-sdk' import { LEGACY_WORKSPACE_ID } from './linear-credential-paths' @@ -196,7 +197,7 @@ export async function testConnection( > { const resolvedWorkspaceId = resolveWorkspaceId(workspaceId) if (!resolvedWorkspaceId) { - return { ok: false, error: 'No API key stored.' } + return taskProviderFailure('linear.noApiKey', 'No API key stored.') } let token: string | null try { @@ -206,7 +207,7 @@ export async function testConnection( return { ok: false, error: message } } if (!token) { - return { ok: false, error: 'No API key stored.' } + return taskProviderFailure('linear.noApiKey', 'No API key stored.') } try { diff --git a/src/main/linear/linear-issue-comments.ts b/src/main/linear/linear-issue-comments.ts index 69051e09c72..2b7e727bbf3 100644 --- a/src/main/linear/linear-issue-comments.ts +++ b/src/main/linear/linear-issue-comments.ts @@ -1,3 +1,4 @@ +import { taskProviderFailure } from '../../shared/task-provider-failure' import type { LinearClient } from '@linear/sdk' import type { LinearComment } from '../../shared/linear/issue-types' import { acquire, release } from './linear-request-concurrency' @@ -33,7 +34,7 @@ export async function addIssueComment( > { const entry = getClients(workspaceId)[0] if (!entry) { - return { ok: false, error: 'Not connected to Linear' } + return taskProviderFailure('linear.notConnected', 'Not connected to Linear') } await acquire() @@ -45,7 +46,7 @@ export async function addIssueComment( ...(options?.parentId ? { parentId: options.parentId } : {}) }) if (!result.success) { - return { ok: false, error: 'Failed to create comment' } + return taskProviderFailure('linear.commentFailed', 'Failed to create comment') } const comment = await result.comment return { diff --git a/src/main/linear/linear-issue-mutations.ts b/src/main/linear/linear-issue-mutations.ts index 5ecf00b004b..bab149d3647 100644 --- a/src/main/linear/linear-issue-mutations.ts +++ b/src/main/linear/linear-issue-mutations.ts @@ -1,3 +1,4 @@ +import { taskProviderFailure } from '../../shared/task-provider-failure' import type { LinearClient } from '@linear/sdk' import type { LinearIssueUpdate } from '../../shared/issue-mutation-types' import { acquire, release } from './linear-request-concurrency' @@ -38,7 +39,7 @@ export async function createIssue( > { const entry = getClients(workspaceId)[0] if (!entry) { - return { ok: false, error: 'Not connected to Linear' } + return taskProviderFailure('linear.notConnected', 'Not connected to Linear') } await acquire() @@ -58,11 +59,14 @@ export async function createIssue( ...(options?.labelIds ? { labelIds: options.labelIds } : {}) }) if (!result.success) { - return { ok: false, error: 'Linear create failed' } + return taskProviderFailure('linear.createFailed', 'Linear create failed') } const issue = await result.issue if (!issue) { - return { ok: false, error: 'Issue was created but could not be retrieved' } + return taskProviderFailure( + 'linear.createdButUnavailable', + 'Issue was created but could not be retrieved' + ) } return { ok: true, @@ -159,7 +163,7 @@ export async function updateIssue( ): Promise<{ ok: true } | { ok: false; error: string }> { const entry = getClients(workspaceId)[0] if (!entry) { - return { ok: false, error: 'Not connected to Linear' } + return taskProviderFailure('linear.notConnected', 'Not connected to Linear') } await acquire() @@ -201,7 +205,7 @@ export async function updateIssue( const result = await entry.client.updateIssue(id, payload) if (!result.success) { - return { ok: false, error: 'Linear update failed' } + return taskProviderFailure('linear.updateFailed', 'Linear update failed') } return { ok: true } } catch (error) { diff --git a/src/main/linear/linear-project-queries.ts b/src/main/linear/linear-project-queries.ts index 961d2f642ba..588e899b52d 100644 --- a/src/main/linear/linear-project-queries.ts +++ b/src/main/linear/linear-project-queries.ts @@ -1,3 +1,4 @@ +import { taskProviderFailure } from '../../shared/task-provider-failure' import type { LinearProjectDetail, LinearProjectSummary } from '../../shared/linear/project-types' import type { LinearCollectionResult, @@ -168,7 +169,7 @@ export async function createProject( ): Promise<{ ok: true; project: LinearProjectDetail } | { ok: false; error: string }> { const entry = getClients(workspaceId)[0] if (!entry) { - return { ok: false, error: 'Not connected to Linear' } + return taskProviderFailure('linear.notConnected', 'Not connected to Linear') } await acquire() @@ -180,7 +181,7 @@ export async function createProject( const payload = result.data?.projectCreate const project = payload?.project if (!payload?.success || !project) { - return { ok: false, error: 'Linear project create failed' } + return taskProviderFailure('linear.projectCreateFailed', 'Linear project create failed') } return { ok: true, project: mapProjectDetailForWorkspace(entry, project) } } catch (error) { diff --git a/src/main/menu/gpu-acceleration-about-panel.ts b/src/main/menu/gpu-acceleration-about-panel.ts index 340897ce2b9..f6170412e95 100644 --- a/src/main/menu/gpu-acceleration-about-panel.ts +++ b/src/main/menu/gpu-acceleration-about-panel.ts @@ -1,3 +1,4 @@ +import { translateMain } from '../i18n/main-i18n' export type GpuAccelerationAboutPanelOptions = { appName: string appVersion: string @@ -11,26 +12,26 @@ export function describeGpuAcceleration( gpuFallbackActive: boolean ): string { if (gpuFallbackActive) { - return 'Disabled (Safe Graphics Mode)' + return translateMain('native.about.gpu.safeMode', 'Disabled (Safe Graphics Mode)') } const compositing = gpuFeatureStatus?.gpu_compositing.trim().toLowerCase() if (!compositing || compositing === 'undefined') { - return 'Status unavailable' + return translateMain('native.about.gpu.unknownStatus', 'Status unavailable') } if (compositing === 'enabled') { - return 'Enabled' + return translateMain('native.about.gpu.enabled', 'Enabled') } if (compositing.includes('software')) { - return 'Software rendering' + return translateMain('native.about.gpu.software', 'Software rendering') } if (compositing.startsWith('disabled')) { - return 'Disabled' + return translateMain('native.about.gpu.disabled', 'Disabled') } if (compositing.startsWith('unavailable')) { - return 'Unavailable' + return translateMain('native.about.gpu.unavailable', 'Unavailable') } - return `Unknown (${compositing})` + return translateMain('native.about.gpu.unknown', 'Unknown ({{status}})', { status: compositing }) } export function createGpuAccelerationAboutPanelOptions({ @@ -40,7 +41,9 @@ export function createGpuAccelerationAboutPanelOptions({ gpuFallbackActive, gpuFeatureStatus }: GpuAccelerationAboutPanelOptions): Electron.AboutPanelOptionsOptions { - const status = `GPU acceleration: ${describeGpuAcceleration(gpuFeatureStatus, gpuFallbackActive)}` + const status = translateMain('native.about.gpu.summary', 'GPU acceleration: {{status}}', { + status: describeGpuAcceleration(gpuFeatureStatus, gpuFallbackActive) + }) return { applicationName: appName, applicationVersion: appVersion, diff --git a/src/main/warp-themes/manual-warp-theme-files.ts b/src/main/warp-themes/manual-warp-theme-files.ts index 4deec3ede12..ed45e9f359b 100644 --- a/src/main/warp-themes/manual-warp-theme-files.ts +++ b/src/main/warp-themes/manual-warp-theme-files.ts @@ -1,3 +1,4 @@ +import { translateMain } from '../i18n/main-i18n' import { createHash } from 'node:crypto' import path from 'node:path' import { BrowserWindow, dialog, type OpenDialogOptions, type WebContents } from 'electron' @@ -39,9 +40,14 @@ export async function chooseManualWarpThemeFiles(webContents?: WebContents): Pro > { const ownerWindow = webContents ? BrowserWindow.fromWebContents(webContents) : null const options: OpenDialogOptions = { - title: 'Import Warp Theme', + title: translateMain('native.dialog.importWarpTheme', 'Import Warp Theme'), properties: ['openFile', 'multiSelections'], - filters: [{ name: 'Warp theme YAML', extensions: ['yaml', 'yml'] }] + filters: [ + { + name: translateMain('native.dialog.warpThemeYaml', 'Warp theme YAML'), + extensions: ['yaml', 'yml'] + } + ] } const result = ownerWindow ? await dialog.showOpenDialog(ownerWindow, options) @@ -73,7 +79,7 @@ export async function chooseManualWarpThemeFolderPath( ): Promise { const ownerWindow = webContents ? BrowserWindow.fromWebContents(webContents) : null const options: OpenDialogOptions = { - title: 'Import Warp Theme Folder', + title: translateMain('native.dialog.importWarpFolder', 'Import Warp Theme Folder'), properties: ['openDirectory'] } const result = ownerWindow diff --git a/src/main/window/dashboard-popout-window.test.ts b/src/main/window/dashboard-popout-window.test.ts index 8392e6ed685..60b09d2089a 100644 --- a/src/main/window/dashboard-popout-window.test.ts +++ b/src/main/window/dashboard-popout-window.test.ts @@ -1,4 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { setMainUiLanguage } from '../i18n/main-i18n' +import es from '../../renderer/src/i18n/locales/es.json' type Display = { workArea: { x: number; y: number; width: number; height: number } } @@ -48,6 +50,9 @@ const { } } bounds = { x: 100, y: 100, width: 960, height: 720 } + setTitle = vi.fn((title: string) => { + this.options.title = title + }) focus = vi.fn() show = vi.fn() restore = vi.fn(() => { @@ -201,6 +206,22 @@ describe('createOrFocusDashboardPopout', () => { vi.clearAllMocks() }) + it('updates the title after language initialization and unsubscribes when closed', async () => { + // SAFETY: The fixture implements the Store methods exercised by the popout. + createOrFocusDashboardPopout(makeStore() as never) + const win = instances[0] + try { + await setMainUiLanguage('es') + expect(win.options.title).toBe(es.native.dialog.dashboardTitle) + closeDashboardPopout() + win.setTitle.mockClear() + await setMainUiLanguage('en') + expect(win.setTitle).not.toHaveBeenCalled() + } finally { + await setMainUiLanguage('en') + } + }) + it('lets its preload expose saved WSL hosts in serve-only, and passes nothing otherwise', () => { vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', undefined) createOrFocusDashboardPopout(makeStore() as never) diff --git a/src/main/window/dashboard-popout-window.ts b/src/main/window/dashboard-popout-window.ts index 81519c3328f..e5497c02689 100644 --- a/src/main/window/dashboard-popout-window.ts +++ b/src/main/window/dashboard-popout-window.ts @@ -1,3 +1,4 @@ +import { mainI18n, translateMain } from '../i18n/main-i18n' import { app, BrowserWindow, nativeTheme, type WebContents, type Event } from 'electron' import { join } from 'node:path' import type { Store } from '../persistence' @@ -148,7 +149,7 @@ export function createOrFocusDashboardPopout( ...(savedBounds ? { x: savedBounds.x, y: savedBounds.y } : {}), minWidth: MIN_WIDTH, minHeight: MIN_HEIGHT, - title: 'Orca Agent Dashboard', + title: translateMain('native.dialog.dashboardTitle', 'Orca Agent Dashboard'), show: false, autoHideMenuBar: true, backgroundColor: nativeTheme.shouldUseDarkColors ? '#0a0a0a' : '#ffffff', @@ -255,7 +256,18 @@ export function createOrFocusDashboardPopout( window.on('close', freezeBounds) app.on('before-quit', freezeBounds) + window.on('page-title-updated', (event) => { + event.preventDefault() + }) + const updateTitle = (): void => { + if (!window.isDestroyed()) { + window.setTitle(translateMain('native.dialog.dashboardTitle', 'Orca Agent Dashboard')) + } + } + mainI18n.on('languageChanged', updateTitle) + window.on('closed', () => { + mainI18n.off('languageChanged', updateTitle) app.removeListener('before-quit', freezeBounds) unsubscribeUIChanged?.() if (dashboardPopoutWindow === window) { diff --git a/src/main/window/workspace-space-window.ts b/src/main/window/workspace-space-window.ts index 1cf7dd1cd55..dca2e5c96b8 100644 --- a/src/main/window/workspace-space-window.ts +++ b/src/main/window/workspace-space-window.ts @@ -1,3 +1,4 @@ +import { mainI18n, translateMain } from '../i18n/main-i18n' import { app, BrowserWindow, nativeTheme, type WebContents } from 'electron' import { join } from 'node:path' import { isBackgroundLaunch, showWindowWithoutStealingFocus } from './foreground-activation-policy' @@ -51,7 +52,7 @@ export function createOrFocusWorkspaceSpaceWindow(store: Store): BrowserWindow { height: 820, minWidth: 700, minHeight: 520, - title: 'Orca Space Beta', + title: translateMain('native.dialog.spaceTitle', 'Orca Space Beta'), show: false, autoHideMenuBar: true, backgroundColor: nativeTheme.shouldUseDarkColors ? '#0a0a0a' : '#ffffff', @@ -83,7 +84,18 @@ export function createOrFocusWorkspaceSpaceWindow(store: Store): BrowserWindow { } }) window.once('ready-to-show', () => showWindowWithoutStealingFocus(window)) + window.on('page-title-updated', (event) => { + event.preventDefault() + }) + const updateTitle = (): void => { + if (!window.isDestroyed()) { + window.setTitle(translateMain('native.dialog.spaceTitle', 'Orca Space Beta')) + } + } + mainI18n.on('languageChanged', updateTitle) + window.on('closed', () => { + mainI18n.off('languageChanged', updateTitle) offUIChanged() if (spaceWindow === window) { spaceWindow = null diff --git a/src/renderer/src/components/LinearIssueTextEditor.tsx b/src/renderer/src/components/LinearIssueTextEditor.tsx index 32fb8b9e71a..c0b2f30609b 100644 --- a/src/renderer/src/components/LinearIssueTextEditor.tsx +++ b/src/renderer/src/components/LinearIssueTextEditor.tsx @@ -1,3 +1,4 @@ +import { taskProviderErrorMessage } from '@/lib/task-provider-error-copy' import { ImeTextarea } from '@/lib/ime-text-field' import React, { useCallback, useRef, useState } from 'react' import { LoaderCircle } from 'lucide-react' @@ -102,7 +103,7 @@ export function LinearIssueTextEditor({ try { const result = await linearUpdateIssue(providerSettings, issue.id, patch, issue.workspaceId) if (!result.ok) { - throw new Error(result.error) + throw new Error(taskProviderErrorMessage(result)) } } catch (error) { const revert = @@ -124,11 +125,12 @@ export function LinearIssueTextEditor({ toast.error( error instanceof Error ? error.message - : translate( - 'auto.components.LinearIssueTextEditor.e8ff595db3', - 'Failed to update {{value0}}', - { value0: field } - ) + : field === 'title' + ? translate('taskProviders.linear.titleUpdateFailed', 'Failed to update title') + : translate( + 'taskProviders.linear.descriptionUpdateFailed', + 'Failed to update description' + ) ) } finally { if (mountedRef.current && lastIssueIdRef.current === issue.id) { diff --git a/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx b/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx index 92364869a99..d49bd7481fa 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx @@ -337,7 +337,7 @@ export function NativeChatResumeOccurrenceModal({ {translate( 'auto.components.NativeChatResumeOnRestartModal.dontAskAgainHint', - 'You can turn this off in Settings → Experimental → Chat UI.' + 'You can turn this off in Settings → Chat → Behavior.' )} @@ -388,13 +388,14 @@ export function NativeChatResumeOccurrenceModal({ ) : chosen.length === 1 ? translate( - 'auto.components.NativeChatResumeOnRestartModal.resumeSelectedOne', - 'Resume 1 chat' + 'auto.components.NativeChatResumeOnRestartModal.resumeChats_one', + 'Resume {{count}} chat', + { count: chosen.length } ) : translate( - 'auto.components.NativeChatResumeOnRestartModal.resumeSelected', - 'Resume {{value0}} chats', - { value0: chosen.length } + 'auto.components.NativeChatResumeOnRestartModal.resumeChats_other', + 'Resume {{count}} chats', + { count: chosen.length } )} diff --git a/src/renderer/src/components/activity/activity-thread-options-menu.tsx b/src/renderer/src/components/activity/activity-thread-options-menu.tsx index f3a39aef0bb..da1f3bebf6f 100644 --- a/src/renderer/src/components/activity/activity-thread-options-menu.tsx +++ b/src/renderer/src/components/activity/activity-thread-options-menu.tsx @@ -18,7 +18,7 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' import { OptionsFilterCountBadge } from '@/components/sidebar/options-filter-count-badge' -import { formatActiveFilterLabel } from '../../../../shared/workspace-view/workspace-row-filters' +import { activeFilterCountLabel } from '../sidebar/active-filter-count-label' import { ActivityScopeFilterMenuItems, useActivityScopeFilterCount @@ -82,7 +82,7 @@ export function ActivityThreadOptionsMenu({ const skipCloseAutoFocusRef = React.useRef(false) const activeFilterCount = useActivityScopeFilterCount() + (onUnreadOnlyChange && unreadOnly ? 1 : 0) - const activeFilterLabel = formatActiveFilterLabel(activeFilterCount) + const activeFilterLabel = activeFilterCountLabel(activeFilterCount) const optionsLabel = activeFilterCount > 0 ? translate( diff --git a/src/renderer/src/components/automations/automation-target-availability.ts b/src/renderer/src/components/automations/automation-target-availability.ts index a726ca63ebd..346217a6672 100644 --- a/src/renderer/src/components/automations/automation-target-availability.ts +++ b/src/renderer/src/components/automations/automation-target-availability.ts @@ -1,9 +1,7 @@ import type { Automation } from '../../../../shared/automations-types' import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' -import { - describeRuntimeCompatBlock, - evaluateRuntimeCompat -} from '../../../../shared/protocol-compat' +import { evaluateRuntimeCompat } from '../../../../shared/protocol-compat' +import { describeRuntimeCompatBlock } from '@/lib/runtime-host-copy' import { MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, RUNTIME_PROTOCOL_VERSION diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-toolbar-menu-dropdown.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-toolbar-menu-dropdown.tsx index 9f172c71707..8530b09a72b 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-toolbar-menu-dropdown.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-toolbar-menu-dropdown.tsx @@ -36,6 +36,7 @@ type DetectedBrowserEntry = { selectedProfile: string } import { BROWSER_VIEWPORT_PRESETS } from '../../../../../shared/browser-viewport-presets' +import { browserViewportPresetLabel } from './browser-viewport-preset-label' import { translate } from '@/i18n/i18n' import { BrowserChromeFoldedMenuItems, @@ -265,7 +266,7 @@ export function BrowserToolbarMenuDropdown({ {BROWSER_VIEWPORT_PRESETS.map((preset) => ( - {preset.label} + {browserViewportPresetLabel(preset)} ))} diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-viewport-preset-label.ts b/src/renderer/src/components/browser-pane/assemble-chrome/browser-viewport-preset-label.ts new file mode 100644 index 00000000000..328ad6735b7 --- /dev/null +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-viewport-preset-label.ts @@ -0,0 +1,21 @@ +import { translate } from '@/i18n/i18n' +import type { BrowserViewportPreset } from '../../../../../shared/browser-viewport-presets' + +export function browserViewportPresetLabel(preset: BrowserViewportPreset): string { + switch (preset.id) { + case 'mobile-s': + return translate('browser.viewport.mobile-s', 'Mobile S — 320 × 568') + case 'mobile-m': + return translate('browser.viewport.mobile-m', 'Mobile M — 375 × 667') + case 'mobile-l': + return translate('browser.viewport.mobile-l', 'Mobile L — 425 × 812') + case 'tablet': + return translate('browser.viewport.tablet', 'Tablet — 768 × 1024') + case 'laptop': + return translate('browser.viewport.laptop', 'Laptop — 1024 × 768') + case 'laptop-l': + return translate('browser.viewport.laptop-l', 'Laptop L — 1440 × 900') + case 'desktop': + return translate('browser.viewport.desktop', 'Desktop — 1920 × 1080') + } +} diff --git a/src/renderer/src/components/feature-wall/AiCommitPrSettingsFields.tsx b/src/renderer/src/components/feature-wall/AiCommitPrSettingsFields.tsx index 7560b4a6cb3..9ab02f1e766 100644 --- a/src/renderer/src/components/feature-wall/AiCommitPrSettingsFields.tsx +++ b/src/renderer/src/components/feature-wall/AiCommitPrSettingsFields.tsx @@ -1,3 +1,4 @@ +import { sourceControlModelLabel, sourceControlThinkingLabel } from './source-control-model-label' import type { JSX } from 'react' import { Terminal } from 'lucide-react' import { CUSTOM_PROMPT_PLACEHOLDER } from '../../../../shared/commit-message-prompt' @@ -116,10 +117,10 @@ export function AiCommitPrSettingsFields({ {unsupportedAgentLabel ? (

- {unsupportedAgentLabel}{' '} {translate( - 'auto.components.feature.wall.AiCommitPrSettingsCard.4d9b6d84df', - 'unsupported. Choose Claude, Codex, or Custom.' + 'auto.components.feature.wall.AiCommitPrSettingsCard.unsupportedAgent', + '{{agent}} is not supported. Choose Claude, Codex, or Custom.', + { agent: unsupportedAgentLabel } )}

) : null} @@ -137,7 +138,7 @@ export function AiCommitPrSettingsFields({ {activeCapability.models.map((model) => ( - {model.label} + {sourceControlModelLabel(model)} ))} @@ -160,7 +161,7 @@ export function AiCommitPrSettingsFields({ {activeModel.thinkingLevels.map((level) => ( - {level.label} + {sourceControlThinkingLabel(level)} ))} diff --git a/src/renderer/src/components/feature-wall/source-control-model-label.ts b/src/renderer/src/components/feature-wall/source-control-model-label.ts new file mode 100644 index 00000000000..165c49a730f --- /dev/null +++ b/src/renderer/src/components/feature-wall/source-control-model-label.ts @@ -0,0 +1,32 @@ +import { translate } from '@/i18n/i18n' + +export function sourceControlModelLabel(model: { id: string; label: string }): string { + if (model.id === 'default' && model.label === 'Config default') { + return translate('sourceControl.model.configDefault', 'Config default') + } + if (model.id === 'auto' && model.label === 'Auto') { + return translate('sourceControl.model.auto', 'Auto') + } + return model.label +} + +export function sourceControlThinkingLabel(level: { id: string; label: string }): string { + switch (level.id) { + case 'off': + return translate('sourceControl.thinking.off', 'Off') + case 'minimal': + return translate('sourceControl.thinking.minimal', 'Minimal') + case 'low': + return translate('sourceControl.thinking.low', 'Low') + case 'medium': + return translate('sourceControl.thinking.medium', 'Medium') + case 'high': + return translate('sourceControl.thinking.high', 'High') + case 'xhigh': + return translate('sourceControl.thinking.xhigh', 'Extra High') + case 'max': + return translate('sourceControl.thinking.max', 'Max') + default: + return level.label + } +} diff --git a/src/renderer/src/components/github-project/ProjectPickerBrowse.tsx b/src/renderer/src/components/github-project/ProjectPickerBrowse.tsx index d1afae9c979..7b92f398fcf 100644 --- a/src/renderer/src/components/github-project/ProjectPickerBrowse.tsx +++ b/src/renderer/src/components/github-project/ProjectPickerBrowse.tsx @@ -8,10 +8,7 @@ import type { GitHubProjectSummary } from '../../../../shared/github/project-types' import type { GitHubProjectViewError } from '../../../../shared/github/project-result-types' -import { - GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR, - isGitHubProjectRefInputTooLarge -} from '../../../../shared/github/project-ref-input' +import { isGitHubProjectRefInputTooLarge } from '../../../../shared/github/project-ref-input' import { githubProjectHost, githubProjectIdentityKey @@ -216,7 +213,10 @@ function ProjectPasteInput(props: Props): React.JSX.Element { props.onPasteInputChange( input, isGitHubProjectRefInputTooLarge(input) - ? GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR + ? translate( + 'projectViews.error.inputTooLarge', + 'Project reference is too large to resolve.' + ) : null ) }} diff --git a/src/renderer/src/components/github-project/ProjectPickerPanels.tsx b/src/renderer/src/components/github-project/ProjectPickerPanels.tsx index 36af5891189..9368b7cbe2e 100644 --- a/src/renderer/src/components/github-project/ProjectPickerPanels.tsx +++ b/src/renderer/src/components/github-project/ProjectPickerPanels.tsx @@ -174,7 +174,10 @@ export function ProjectPickerPartialFailures({ { failureCount: failures.length } ) const detail = failures - .map((failure) => `${failure.owner === '*' ? 'orgs' : failure.owner}: ${failure.message}`) + .map( + (failure) => + `${failure.owner === '*' ? translate('projectViews.picker.organizations', 'Organizations') : failure.owner}: ${failure.message}` + ) .join('\n') return (
{ if (rid !== requestIdRef.current) { diff --git a/src/renderer/src/components/github-project/useProjectPickerBrowse.ts b/src/renderer/src/components/github-project/useProjectPickerBrowse.ts index 097316cf1d8..cae40c0f6bd 100644 --- a/src/renderer/src/components/github-project/useProjectPickerBrowse.ts +++ b/src/renderer/src/components/github-project/useProjectPickerBrowse.ts @@ -1,3 +1,4 @@ +import { translate } from '@/i18n/i18n' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' import { useCallback, useLayoutEffect, useRef, useState } from 'react' import { useMountedRef } from '@/hooks/useMountedRef' @@ -73,7 +74,10 @@ export function useProjectPickerBrowse( if (mountedRef.current && activeCacheKeyRef.current === cacheKey) { setBrowseError({ type: 'unknown', - message: error instanceof Error ? error.message : 'Failed to list projects' + message: + error instanceof Error + ? error.message + : translate('projectViews.picker.listFailed', 'Failed to list projects') }) } } finally { diff --git a/src/renderer/src/components/jira-connect-dialog.tsx b/src/renderer/src/components/jira-connect-dialog.tsx index 6ab3d4fb4ca..2df524da932 100644 --- a/src/renderer/src/components/jira-connect-dialog.tsx +++ b/src/renderer/src/components/jira-connect-dialog.tsx @@ -1,3 +1,5 @@ +import { jiraCredentialStorageCopy } from './jira-credential-storage-copy' +import { taskProviderErrorMessage, taskProviderErrorText } from '@/lib/task-provider-error-copy' import { useId, useLayoutEffect, useState } from 'react' import { LoaderCircle, Lock } from 'lucide-react' import { useAppStore } from '@/store' @@ -90,9 +92,7 @@ export function JiraConnectDialog({ (!requiresIdentity || Boolean(email.trim())) && Boolean(apiToken.trim()) && connectState !== 'connecting' - const credentialStorageCopy = remoteRuntime - ? 'Your token is sent to the selected remote runtime and stored there with runtime-supported encryption.' - : 'Your token is stored locally and encrypted when local runtime storage supports it.' + const credentialStorageCopy = jiraCredentialStorageCopy(remoteRuntime) const clearErrorOnEdit = (): void => { if (connectState === 'error') { @@ -155,11 +155,15 @@ export function JiraConnectDialog({ return } setConnectState('error') - setConnectError(result.error) + setConnectError(taskProviderErrorMessage(result)) } catch (error) { if (mountedRef.current) { setConnectState('error') - setConnectError(error instanceof Error ? error.message : 'Connection failed') + setConnectError( + error instanceof Error + ? taskProviderErrorText(error.message) + : translate('taskProviders.jira.connectionFailed', 'Connection failed') + ) } } } diff --git a/src/renderer/src/components/jira-credential-storage-copy.ts b/src/renderer/src/components/jira-credential-storage-copy.ts new file mode 100644 index 00000000000..88fe02cee92 --- /dev/null +++ b/src/renderer/src/components/jira-credential-storage-copy.ts @@ -0,0 +1,13 @@ +import { translate } from '@/i18n/i18n' + +export function jiraCredentialStorageCopy(remoteRuntime: boolean): string { + return remoteRuntime + ? translate( + 'taskProviders.jira.remoteTokenStorage', + 'Your token is sent to the selected remote runtime and stored there with runtime-supported encryption.' + ) + : translate( + 'taskProviders.jira.localTokenStorage', + 'Your token is stored locally and encrypted when local runtime storage supports it.' + ) +} diff --git a/src/renderer/src/components/jira-issue-mutation-queue.ts b/src/renderer/src/components/jira-issue-mutation-queue.ts index 69f467e686d..b472c066647 100644 --- a/src/renderer/src/components/jira-issue-mutation-queue.ts +++ b/src/renderer/src/components/jira-issue-mutation-queue.ts @@ -1,3 +1,4 @@ +import { taskProviderErrorMessage } from '@/lib/task-provider-error-copy' import type { JiraIssue, JiraMutationResult } from '../../../shared/jira/jira-types' import type { GlobalSettings } from '../../../shared/global-settings-types' import type { TaskSourceContext } from '../../../shared/task-source-context' @@ -113,7 +114,7 @@ export function enqueueJiraIssueMutation({ try { const result = await update() if (!result.ok) { - throw new Error(result.error) + throw new Error(taskProviderErrorMessage(result)) } saved = true write.confirmed = { ...write.confirmed, ...optimistic } diff --git a/src/renderer/src/components/jira-issue-transition-control.tsx b/src/renderer/src/components/jira-issue-transition-control.tsx index a953fb74b56..0fd755c92b2 100644 --- a/src/renderer/src/components/jira-issue-transition-control.tsx +++ b/src/renderer/src/components/jira-issue-transition-control.tsx @@ -1,3 +1,4 @@ +import { taskProviderErrorMessage, taskProviderErrorText } from '@/lib/task-provider-error-copy' import { useState } from 'react' import { ChevronDown, CircleAlert, ExternalLink, Loader2 } from 'lucide-react' import { Button } from '@/components/ui/button' @@ -53,12 +54,12 @@ export function JiraIssueTransitionControl({ setOpen(false) reset() } else { - setSubmitError(result.error) + setSubmitError(taskProviderErrorMessage(result)) } } catch (error) { setSubmitError( error instanceof Error - ? error.message + ? taskProviderErrorText(error.message) : translate( 'auto.components.JiraIssueWorkspace.ea21952aa3', 'Failed to update Jira issue.' diff --git a/src/renderer/src/components/linear-api-key-dialog.tsx b/src/renderer/src/components/linear-api-key-dialog.tsx index 1d097b0ea23..b25b2d9f34e 100644 --- a/src/renderer/src/components/linear-api-key-dialog.tsx +++ b/src/renderer/src/components/linear-api-key-dialog.tsx @@ -1,3 +1,4 @@ +import { taskProviderErrorMessage, taskProviderErrorText } from '@/lib/task-provider-error-copy' import { useId, useMemo, useState } from 'react' import { ExternalLink, LoaderCircle, Lock } from 'lucide-react' import type { LinearWorkspace } from '../../../shared/linear/workspace-types' @@ -61,7 +62,11 @@ export function LinearApiKeyDialog({ const runtimeTarget = useMemo(() => defaultScopeHost(settings), [settings]) const personalKeyUrl = buildLinearPersonalApiKeySettingsUrl(workspace?.organizationUrlKey) const workspaceApiUrl = buildLinearWorkspaceApiSettingsUrl(workspace?.organizationUrlKey) - const submitLabel = connectLabel ?? (workspace ? 'Update access' : 'Connect') + const submitLabel = + connectLabel ?? + (workspace + ? translate('taskProviders.linear.updateAccess', 'Update access') + : translate('taskProviders.linear.connect', 'Connect')) const resolvedDialogState = resolveLinearApiKeyDialogState(dialogState, open) if (resolvedDialogState !== dialogState) { // Reset hidden drafts/errors before paint because parent closure can race a connect request. @@ -99,14 +104,17 @@ export function LinearApiKeyDialog({ setDialogState((current) => ({ ...current, connectState: 'error', - connectError: result.error + connectError: taskProviderErrorMessage(result) })) } catch (error) { if (mountedRef.current) { setDialogState((current) => ({ ...current, connectState: 'error', - connectError: error instanceof Error ? error.message : 'Connection failed' + connectError: + error instanceof Error + ? taskProviderErrorText(error.message) + : translate('taskProviders.linear.connectionFailed', 'Connection failed') })) } } @@ -114,16 +122,35 @@ export function LinearApiKeyDialog({ const resolvedTitle = title ?? - (workspace ? `Update Linear access for ${workspace.organizationName}` : 'Add Linear access') + (workspace + ? translate( + 'taskProviders.linear.updateWorkspaceAccess', + 'Update Linear access for {{workspace}}', + { workspace: workspace.organizationName } + ) + : translate('taskProviders.linear.addAccess', 'Add Linear access')) const resolvedDescription = description ?? (workspace - ? `Paste a Personal API key for ${workspace.organizationName}. If this workspace is already connected, Orca replaces its stored key.` - : 'Paste a Personal API key for the Linear workspace you want Orca to use. If that workspace is already connected, Orca replaces its stored key.') + ? translate( + 'taskProviders.linear.workspaceApiKeyHint', + 'Paste a Personal API key for {{workspace}}. If this workspace is already connected, Orca replaces its stored key.', + { workspace: workspace.organizationName } + ) + : translate( + 'taskProviders.linear.apiKeyHint', + 'Paste a Personal API key for the Linear workspace you want Orca to use. If that workspace is already connected, Orca replaces its stored key.' + )) const storageCopy = runtimeTarget.kind === 'environment' - ? 'This key is stored by the active remote runtime.' - : 'Local runtime keys are stored on this device using Electron encrypted storage when available.' + ? translate( + 'taskProviders.linear.remoteKeyStorage', + 'This key is stored by the active remote runtime.' + ) + : translate( + 'taskProviders.linear.localKeyStorage', + 'Local runtime keys are stored on this device using Electron encrypted storage when available.' + ) return ( diff --git a/src/renderer/src/components/linear-issue-project-selector.tsx b/src/renderer/src/components/linear-issue-project-selector.tsx index e9af0508464..a9e3cb7fe92 100644 --- a/src/renderer/src/components/linear-issue-project-selector.tsx +++ b/src/renderer/src/components/linear-issue-project-selector.tsx @@ -1,3 +1,4 @@ +import { taskProviderErrorMessage, taskProviderErrorText } from '@/lib/task-provider-error-copy' import React, { useCallback, useEffect, useRef, useState } from 'react' import { Check, ChevronDown, FolderKanban, LoaderCircle } from 'lucide-react' import { toast } from 'sonner' @@ -56,7 +57,7 @@ export function LinearIssueProjectSelector({ if (mountedRef.current && requestId === searchRequestIdRef.current) { toast.error( error instanceof Error - ? error.message + ? taskProviderErrorText(error.message) : translate( 'auto.components.LinearIssueWorkspace.38b80780c2', 'Failed to load projects' @@ -94,12 +95,12 @@ export function LinearIssueProjectSelector({ ) setOpen(false) } else { - toast.error(result.error) + toast.error(taskProviderErrorMessage(result)) } } catch (error) { toast.error( error instanceof Error - ? error.message + ? taskProviderErrorText(error.message) : translate( 'auto.components.LinearIssueWorkspace.8b5b593053', 'Failed to update project' diff --git a/src/renderer/src/components/linear-issue-sub-issues.tsx b/src/renderer/src/components/linear-issue-sub-issues.tsx index 63c0dca7856..394ed442137 100644 --- a/src/renderer/src/components/linear-issue-sub-issues.tsx +++ b/src/renderer/src/components/linear-issue-sub-issues.tsx @@ -1,3 +1,4 @@ +import { taskProviderErrorMessage, taskProviderErrorText } from '@/lib/task-provider-error-copy' import { ImeInput } from '@/lib/ime-text-field' import React, { useCallback, useMemo, useRef, useState } from 'react' import { ArrowRight, LoaderCircle, Plus } from 'lucide-react' @@ -71,7 +72,7 @@ export function LinearIssueSubIssues({ if (mountedRef.current && requestId === openRequestIdRef.current) { toast.error( error instanceof Error - ? error.message + ? taskProviderErrorText(error.message) : translate( 'auto.components.LinearIssueWorkspace.9a1317cdd3', 'Failed to load sub-issue' @@ -129,14 +130,14 @@ export function LinearIssueSubIssues({ setTitle('') setOpen(false) } else { - toast.error(result.error) + toast.error(taskProviderErrorMessage(result)) } }) .catch((error) => { if (mountedRef.current && requestId === createRequestIdRef.current) { toast.error( error instanceof Error - ? error.message + ? taskProviderErrorText(error.message) : translate( 'auto.components.LinearIssueWorkspace.b25e453c9d', 'Failed to create sub-issue' diff --git a/src/renderer/src/components/linear-issue-workspace-detail-state.ts b/src/renderer/src/components/linear-issue-workspace-detail-state.ts index 5d69d855be9..fb0277daea2 100644 --- a/src/renderer/src/components/linear-issue-workspace-detail-state.ts +++ b/src/renderer/src/components/linear-issue-workspace-detail-state.ts @@ -1,3 +1,5 @@ +import { translate } from '@/i18n/i18n' +import { taskProviderErrorText } from '@/lib/task-provider-error-copy' import { useCallback, useEffect, useRef, useState } from 'react' import { createBrowserUuid } from '@/lib/browser-uuid' @@ -126,7 +128,11 @@ export function useLinearIssueWorkspaceDetail({ }) .catch((error) => { if (mountedRef.current && requestId === commentsRequestIdRef.current) { - setCommentsError(error instanceof Error ? error.message : 'Failed to load comments.') + setCommentsError( + error instanceof Error + ? taskProviderErrorText(error.message) + : translate('taskProviders.linear.commentsLoadFailed', 'Failed to load comments.') + ) } }) .finally(() => { diff --git a/src/renderer/src/components/linear-item-drawer-comment-footer.tsx b/src/renderer/src/components/linear-item-drawer-comment-footer.tsx index a78843bf567..508b5c3b00c 100644 --- a/src/renderer/src/components/linear-item-drawer-comment-footer.tsx +++ b/src/renderer/src/components/linear-item-drawer-comment-footer.tsx @@ -1,3 +1,4 @@ +import { taskProviderErrorText } from '@/lib/task-provider-error-copy' import { ImeTextarea } from '@/lib/ime-text-field' import React, { useCallback, useRef, useState } from 'react' import { LoaderCircle, Send } from 'lucide-react' @@ -96,7 +97,7 @@ export function LinearIssueCommentFooter({ if (mountedRef.current) { toast.error( err instanceof Error - ? err.message + ? taskProviderErrorText(err.message) : translate('auto.components.LinearItemDrawer.6ab35eafd5', 'Failed to add comment') ) } diff --git a/src/renderer/src/components/linear-item-drawer-edit-controller.tsx b/src/renderer/src/components/linear-item-drawer-edit-controller.tsx index 773ce5f9fff..77260f76c05 100644 --- a/src/renderer/src/components/linear-item-drawer-edit-controller.tsx +++ b/src/renderer/src/components/linear-item-drawer-edit-controller.tsx @@ -1,3 +1,4 @@ +import { taskProviderErrorMessage, taskProviderErrorText } from '@/lib/task-provider-error-copy' import { useCallback, useState } from 'react' import { toast } from 'sonner' @@ -77,7 +78,8 @@ export function useLinearIssueEditController({ useAppStore.getState().invalidateLinearIssueLists({ sourceContext }) useAppStore.getState().recordFeatureInteraction('linear-tasks') }, - onError: (err) => toast.error(err) + formatError: taskProviderErrorMessage, + onError: (err) => toast.error(taskProviderErrorText(err)) }) }, [ @@ -112,7 +114,8 @@ export function useLinearIssueEditController({ useAppStore.getState().invalidateLinearIssueLists({ sourceContext }) useAppStore.getState().recordFeatureInteraction('linear-tasks') }, - onError: (err) => toast.error(err) + formatError: taskProviderErrorMessage, + onError: (err) => toast.error(taskProviderErrorText(err)) }) }, [ @@ -145,7 +148,8 @@ export function useLinearIssueEditController({ onSuccess: () => { useAppStore.getState().recordFeatureInteraction('linear-tasks') }, - onError: (err) => toast.error(err) + formatError: taskProviderErrorMessage, + onError: (err) => toast.error(taskProviderErrorText(err)) }) }, [ @@ -204,7 +208,8 @@ export function useLinearIssueEditController({ useAppStore.getState().invalidateLinearIssueLists({ sourceContext }) useAppStore.getState().recordFeatureInteraction('linear-tasks') }, - onError: (err) => toast.error(err) + formatError: taskProviderErrorMessage, + onError: (err) => toast.error(taskProviderErrorText(err)) }) }, [ @@ -260,7 +265,8 @@ export function useLinearIssueEditController({ useAppStore.getState().invalidateLinearIssueLists({ sourceContext }) useAppStore.getState().recordFeatureInteraction('linear-tasks') }, - onError: (err) => toast.error(err) + formatError: taskProviderErrorMessage, + onError: (err) => toast.error(taskProviderErrorText(err)) }) }, [ diff --git a/src/renderer/src/components/linear-project-view-surfaces.test.tsx b/src/renderer/src/components/linear-project-view-surfaces.test.tsx index 69fd0ab0874..92ecfee83a1 100644 --- a/src/renderer/src/components/linear-project-view-surfaces.test.tsx +++ b/src/renderer/src/components/linear-project-view-surfaces.test.tsx @@ -65,7 +65,7 @@ describe('Linear project view surfaces', () => { ]} hasMore count={50} - label="projects" + kind="projects" onLoadMore={onLoadMore} /> ) diff --git a/src/renderer/src/components/linear-project-view-surfaces.tsx b/src/renderer/src/components/linear-project-view-surfaces.tsx index 159caf943af..b21c7e2d849 100644 --- a/src/renderer/src/components/linear-project-view-surfaces.tsx +++ b/src/renderer/src/components/linear-project-view-surfaces.tsx @@ -1,3 +1,4 @@ +import { taskProviderErrorText } from '@/lib/task-provider-error-copy' import React from 'react' import { ArrowLeft, ArrowRight, ExternalLink, Layers3, LoaderCircle, RefreshCw } from 'lucide-react' @@ -31,11 +32,80 @@ type LinearProjectOverviewProps = { onOpenIssues?: () => void } +type LinearCollectionKind = 'views' | 'projects' | 'projectIssues' | 'viewIssues' | 'issues' + +function linearCollectionTruncatedText(kind: LinearCollectionKind, count: number): string { + const values = { count } + const one = count === 1 + switch (kind) { + case 'views': + return one + ? translate( + 'taskProviders.linear.truncated.views_one', + 'Showing the first {{count}} view. Search or open Linear for the full set.', + values + ) + : translate( + 'taskProviders.linear.truncated.views_other', + 'Showing the first {{count}} views. Search or open Linear for the full set.', + values + ) + case 'projects': + return one + ? translate( + 'taskProviders.linear.truncated.projects_one', + 'Showing the first {{count}} project. Search or open Linear for the full set.', + values + ) + : translate( + 'taskProviders.linear.truncated.projects_other', + 'Showing the first {{count}} projects. Search or open Linear for the full set.', + values + ) + case 'projectIssues': + return one + ? translate( + 'taskProviders.linear.truncated.projectIssues_one', + 'Showing the first {{count}} project issue. Search or open Linear for the full set.', + values + ) + : translate( + 'taskProviders.linear.truncated.projectIssues_other', + 'Showing the first {{count}} project issues. Search or open Linear for the full set.', + values + ) + case 'viewIssues': + return one + ? translate( + 'taskProviders.linear.truncated.viewIssues_one', + 'Showing the first {{count}} view issue. Search or open Linear for the full set.', + values + ) + : translate( + 'taskProviders.linear.truncated.viewIssues_other', + 'Showing the first {{count}} view issues. Search or open Linear for the full set.', + values + ) + case 'issues': + return one + ? translate( + 'taskProviders.linear.truncated.issues_one', + 'Showing the first {{count}} issue. Search or open Linear for the full set.', + values + ) + : translate( + 'taskProviders.linear.truncated.issues_other', + 'Showing the first {{count}} issues. Search or open Linear for the full set.', + values + ) + } +} + type LinearCollectionNoticeProps = { errors?: LinearWorkspaceError[] hasMore?: boolean count: number - label: string + kind: LinearCollectionKind onLoadMore?: () => void loading?: boolean loadMoreLabel?: string @@ -45,7 +115,7 @@ export function LinearCollectionNotice({ errors, hasMore, count, - label, + kind, onLoadMore, loading = false, loadMoreLabel = 'Load more' @@ -61,31 +131,19 @@ export function LinearCollectionNotice({ {errors.map((error) => ( {error.workspaceName ?? error.workspaceId}:{' '} - {isIntegrationCredentialDecryptionError(error.message) + {isIntegrationCredentialDecryptionError(taskProviderErrorText(error.message)) ? translate( 'auto.components.linear.project.view.surfaces.linearLoginUnreadable', 'Unable to read saved Linear login. Reconnect it in Settings.' ) - : error.message} + : taskProviderErrorText(error.message)} ))}
) : null} {hasMore ? (
- {onLoadMore ? null : ( - - {translate( - 'auto.components.linear.project.view.surfaces.06b887d622', - 'Showing first' - )}{' '} - {count} {label} - {translate( - 'auto.components.linear.project.view.surfaces.98730088a6', - '. Search or open Linear for the full set.' - )} - - )} + {onLoadMore ? null : {linearCollectionTruncatedText(kind, count)}} {onLoadMore ? (