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