diff --git a/apps/desktop/.storybook/preview.tsx b/apps/desktop/.storybook/preview.tsx index f460a84495..52b022704d 100644 --- a/apps/desktop/.storybook/preview.tsx +++ b/apps/desktop/.storybook/preview.tsx @@ -38,7 +38,12 @@ const withMakaRoot: Decorator = (Story, context) => { // lines are ~1.8× the width of the Chinese copy, so a row that fits in zh // overflows, truncates, or clips in en. Stories were locked to `zh`, which // is exactly why those breakages only ever showed up in the shipped app. - const locale = context.globals.locale === 'en' ? 'en' : 'zh'; + const locale = + context.globals.locale === 'en' + ? 'en' + : context.globals.locale === 'zh-TW' + ? 'zh-TW' + : 'zh-CN'; root.classList.toggle('dark', colorScheme === 'dark'); root.style.colorScheme = colorScheme; @@ -96,7 +101,8 @@ const preview: Preview = { toolbar: { icon: 'globe', items: [ - { title: '中文', value: 'zh' }, + { title: '简体中文', value: 'zh-CN' }, + { title: '繁體中文', value: 'zh-TW' }, { title: 'English', value: 'en' }, ], }, @@ -114,7 +120,7 @@ const preview: Preview = { }, initialGlobals: { colorScheme: 'light', - locale: 'zh', + locale: 'zh-CN', palette: 'default', }, parameters: { diff --git a/apps/desktop/e2e/accessibility-coverage.spec.ts b/apps/desktop/e2e/accessibility-coverage.spec.ts index 322ed8d8fc..36c73cbd7f 100644 --- a/apps/desktop/e2e/accessibility-coverage.spec.ts +++ b/apps/desktop/e2e/accessibility-coverage.spec.ts @@ -72,7 +72,7 @@ test('every settings page exposes named actionable controls', async ({ window: p const sectionLabels = (await navigation.getByRole('button').allTextContents()) .map((label) => label.trim().replace(/\s*Beta$/, '')) .filter((label) => label.length > 0 && label !== '返回应用'); - const expectedSectionLabels = groupedNav('zh') + const expectedSectionLabels = groupedNav('zh-CN') .flatMap(({ items }) => items) .filter(({ enabled }) => enabled) .map(({ label }) => label); diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index e3c7a46c2b..29fdb52b0a 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -173,7 +173,7 @@ async function seedE2eConnection(userDataDir: string): Promise { } } -async function seedE2eLocale(userDataDir: string, locale: 'zh' | 'en'): Promise { +async function seedE2eLocale(userDataDir: string, locale: 'zh-CN' | 'zh-TW' | 'en' | 'ko'): Promise { const workspaceRoot = path.join(userDataDir, 'workspaces', 'default'); await createSettingsStore(workspaceRoot).update({ personalization: { uiLocale: locale }, @@ -397,7 +397,7 @@ async function withE2eWindow( seed: boolean; readinessSelector: string; e2eFixtureScenario?: string; - locale?: 'zh' | 'en'; + locale?: 'zh-CN' | 'zh-TW' | 'en' | 'ko'; /** Opt this window back into animated scrolling; see `scroll-motion-policy`. */ scrollMotion?: 'auto' | 'smooth'; /** #1312: force app:info's platform so the window boots natively into that platform's `data-os` cascade. */ @@ -523,13 +523,13 @@ export const test = base.extend<{ }, // Seeded: a pre-staged connection clears onboarding so the composer is ready. window: async ({}, use) => { - await withE2eWindow({ seed: true, readinessSelector: COMPOSER_INPUT, locale: 'zh' }, use); + await withE2eWindow({ seed: true, readinessSelector: COMPOSER_INPUT, locale: 'zh-CN' }, use); }, onboardingWindow: async ({}, use) => { await withE2eWindow({ seed: false, readinessSelector: '[data-maka-contract="onboarding-card"]', - locale: 'zh', + locale: 'zh-CN', showWindow: true, }, use); }, @@ -538,7 +538,7 @@ export const test = base.extend<{ { seed: true, readinessSelector: COMPOSER_INPUT, - locale: 'zh', + locale: 'zh-CN', gitReviewExtraFiles: 0, }, async (page, context) => { @@ -554,7 +554,7 @@ export const test = base.extend<{ await withE2eWindow({ seed: true, readinessSelector: COMPOSER_INPUT, - locale: 'zh', + locale: 'zh-CN', invocableSkills: true, }, use); }, @@ -573,7 +573,7 @@ export const test = base.extend<{ await withE2eWindow({ seed: true, readinessSelector: COMPOSER_INPUT, - locale: 'zh', + locale: 'zh-CN', newTaskProject: true, showWindow: true, }, use); @@ -583,7 +583,7 @@ export const test = base.extend<{ seed: false, readinessSelector: '[data-maka-contract="search-modal"][open]', e2eFixtureScenario: 'sidebar-search-modal-open', - locale: 'zh', + locale: 'zh-CN', showWindow: true, }, use); }, @@ -592,7 +592,7 @@ export const test = base.extend<{ { seed: true, readinessSelector: COMPOSER_INPUT, - locale: 'zh', + locale: 'zh-CN', parentRemovalSessions: true, }, use, @@ -603,7 +603,7 @@ export const test = base.extend<{ { seed: true, readinessSelector: COMPOSER_INPUT, - locale: 'zh', + locale: 'zh-CN', railRenderSessions: true, }, use, @@ -630,7 +630,7 @@ export const test = base.extend<{ seed: false, readinessSelector: '[data-turn-id]', e2eFixtureScenario: 'chat-partial-history', - locale: 'zh', + locale: 'zh-CN', showWindow: true, }, use); }, @@ -656,7 +656,7 @@ export const test = base.extend<{ seed: false, readinessSelector: '.settingsSurface', e2eFixtureScenario: 'settings-models', - locale: 'zh', + locale: 'zh-CN', showWindow: true, }, use); }, diff --git a/apps/desktop/e2e/ko-locale.spec.ts b/apps/desktop/e2e/ko-locale.spec.ts new file mode 100644 index 0000000000..1dd75d4ccd --- /dev/null +++ b/apps/desktop/e2e/ko-locale.spec.ts @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ensureSidebarExpanded, expect, test } from './fixtures'; + +test('persists Korean locale preference through reload', async ({ window: page }) => { + await ensureSidebarExpanded(page); + await page.getByRole('button', { name: 'Settings', exact: true }).click(); + await expect(page.getByRole('main', { name: 'Settings content' })).toBeVisible(); + await page.getByRole('button', { name: 'General', exact: true }).click(); + await expect(page.getByText('UI language', { exact: true }).first()).toBeVisible(); + await page.keyboard.press('Escape'); + + await page.evaluate(async () => { + await window.maka.settings.update({ personalization: { uiLocale: 'ko' } }); + }); + await page.reload(); + await page.waitForSelector('.maka-composer-editor'); + await ensureSidebarExpanded(page); + + const locale = await page.evaluate(async () => { + const settings = await window.maka.settings.read(); + return settings.personalization.uiLocale; + }); + expect(locale).toBe('ko'); + + await page.getByRole('button', { name: 'Settings', exact: true }).click(); + await page.getByRole('button', { name: 'General', exact: true }).click(); + await expect(page.getByText('UI language', { exact: true }).first()).toBeVisible(); +}); diff --git a/apps/desktop/e2e/request-header-row-contract.spec.ts b/apps/desktop/e2e/request-header-row-contract.spec.ts index 743afe201d..2ff42e67fd 100644 --- a/apps/desktop/e2e/request-header-row-contract.spec.ts +++ b/apps/desktop/e2e/request-header-row-contract.spec.ts @@ -37,7 +37,7 @@ import { getProviderSettingsCopy } from '../src/renderer/locales/settings-provid * surfaces as they first render, and this editor is three clicks deep. */ -const copy = getProviderSettingsCopy('zh').detail; +const copy = getProviderSettingsCopy('zh-CN').detail; test('the request header remove button centres on its field', async ({ requestHeaderRowWindow: page, diff --git a/apps/desktop/e2e/zh-tw-locale.spec.ts b/apps/desktop/e2e/zh-tw-locale.spec.ts new file mode 100644 index 0000000000..801542b2b2 --- /dev/null +++ b/apps/desktop/e2e/zh-tw-locale.spec.ts @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ensureSidebarExpanded, expect, test } from './fixtures'; + +test('switches General Settings from Simplified to Traditional Chinese', async ({ + window: page, +}) => { + await ensureSidebarExpanded(page); + await page.getByRole('button', { name: '设置', exact: true }).click(); + await expect(page.getByRole('main', { name: '设置内容' })).toBeVisible(); + await page.getByRole('button', { name: '通用', exact: true }).click(); + await expect(page.getByText('界面语言', { exact: true }).first()).toBeVisible(); + await page.keyboard.press('Escape'); + + await page.evaluate(async () => { + await window.maka.settings.update({ personalization: { uiLocale: 'zh-TW' } }); + }); + await page.reload(); + await page.waitForSelector('.maka-composer-editor'); + await ensureSidebarExpanded(page); + await page.getByRole('button', { name: '設定', exact: true }).click(); + await expect(page.getByRole('main', { name: '設定內容' })).toBeVisible(); + await page.getByRole('button', { name: '通用', exact: true }).click(); + await expect(page.getByText('介面語言', { exact: true }).first()).toBeVisible(); +}); diff --git a/apps/desktop/src/main/__tests__/active-execution-boundary-read-model.test.ts b/apps/desktop/src/main/__tests__/active-execution-boundary-read-model.test.ts index c2fbe9867d..bdf9b42987 100644 --- a/apps/desktop/src/main/__tests__/active-execution-boundary-read-model.test.ts +++ b/apps/desktop/src/main/__tests__/active-execution-boundary-read-model.test.ts @@ -278,7 +278,7 @@ describe('Boundary decisions notify the read model', () => { function handlersWithRecorder() { const boundaryChanges: string[] = []; const handlers = createAppShellSessionEventHandlers({ - uiLocale: 'zh', + uiLocale: 'zh-CN', activeIdRef: { current: 'session-a' }, liveTurnBySessionRef: { current: {} }, refreshMessages: async () => true, diff --git a/apps/desktop/src/main/__tests__/agent-graph-panel-copy.test.ts b/apps/desktop/src/main/__tests__/agent-graph-panel-copy.test.ts new file mode 100644 index 0000000000..0ef254444c --- /dev/null +++ b/apps/desktop/src/main/__tests__/agent-graph-panel-copy.test.ts @@ -0,0 +1,30 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { getAgentGraphPanelCopy } from '../../renderer/agent-graph-panel.js'; + +test('Traditional Chinese Agent Graph copy does not use Simplified fallbacks', () => { + const copy = getAgentGraphPanelCopy('zh-TW'); + assert.equal(copy.loading, '正在讀取 Graph 狀態…'); + assert.equal(copy.openSession, '開啟子任務'); + assert.equal(copy.currentEpoch, '目前'); + assert.equal(copy.status('active'), '執行中'); +}); diff --git a/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts b/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts index 098cfa48cc..88ade5abda 100644 --- a/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts @@ -109,7 +109,7 @@ function createHarness(options: { }); const actions = createAppShellSessionSettingsActions({ - uiLocale: 'zh', + uiLocale: 'zh-CN', activeIdRef, connections: options.connections ?? ([{ slug: 'e2e', name: 'E2E' }] as LlmConnection[]), messages: options.messages ?? [], diff --git a/apps/desktop/src/main/__tests__/computer-use-status-item-locale.test.ts b/apps/desktop/src/main/__tests__/computer-use-status-item-locale.test.ts index cf3912e50e..3b2827c882 100644 --- a/apps/desktop/src/main/__tests__/computer-use-status-item-locale.test.ts +++ b/apps/desktop/src/main/__tests__/computer-use-status-item-locale.test.ts @@ -50,7 +50,7 @@ test('rebuilds an active Computer Use menu when its resolved locale changes', () assert.deepEqual(menus.at(-1)?.map((row) => row.label), ['Stop Using Safari']); locale.observe(mergeSettings(createDefaultSettings(), { - personalization: { uiLocale: 'zh' }, + personalization: { uiLocale: 'zh-CN' }, })); assert.deepEqual(menus.at(-1)?.map((row) => row.label), ['停止操作 Safari']); diff --git a/apps/desktop/src/main/__tests__/config-transfer-service.test.ts b/apps/desktop/src/main/__tests__/config-transfer-service.test.ts index a904296104..8d5f8098a6 100644 --- a/apps/desktop/src/main/__tests__/config-transfer-service.test.ts +++ b/apps/desktop/src/main/__tests__/config-transfer-service.test.ts @@ -100,6 +100,27 @@ describe('config-transfer-service', () => { assert.deepEqual(writtenMemory, ['# imported memory']); }); + it('canonicalizes a legacy zh preference before the imported settings reach observers', async () => { + const { deps, updatedSettings } = makeDeps(); + const bundle = { + schemaVersion: 1, + exportedAt: '', + appVersion: '0.1.0', + includedData: ['settings'] as const, + data: { + settings: { + personalization: { uiLocale: 'zh', displayName: 'Maka user' }, + }, + }, + }; + + await applyConfigImport(bundle as any, 'skip', deps); + + assert.deepEqual(updatedSettings, [ + { personalization: { uiLocale: 'zh-CN', displayName: 'Maka user' } }, + ]); + }); + it('restores the selection a backup states instead of re-enabling its default', async () => { // A backup can hold a connection whose default model the user had disabled. // `save()` cannot tell a stated selection from one a sync echoed back, so it diff --git a/apps/desktop/src/main/__tests__/desktop-locale-authority.test.ts b/apps/desktop/src/main/__tests__/desktop-locale-authority.test.ts index 1b6ae08f01..184049a523 100644 --- a/apps/desktop/src/main/__tests__/desktop-locale-authority.test.ts +++ b/apps/desktop/src/main/__tests__/desktop-locale-authority.test.ts @@ -24,15 +24,15 @@ import { createDesktopLocaleAuthority } from '../desktop-locale-authority.js'; test('resolves explicit preferences and observes a mid-session settings change', async () => { let settings = mergeSettings(createDefaultSettings(), { - personalization: { uiLocale: 'zh' }, + personalization: { uiLocale: 'zh-CN' }, }); const authority = createDesktopLocaleAuthority({ readSettings: async () => settings, preferredSystemLanguages: () => ['en-US'], }); - assert.equal(await authority.resolve(), 'zh'); - assert.equal(authority.current(), 'zh'); + assert.equal(await authority.resolve(), 'zh-CN'); + assert.equal(authority.current(), 'zh-CN'); settings = mergeSettings(settings, { personalization: { uiLocale: 'en' } }); authority.observe(settings); assert.equal(authority.current(), 'en'); @@ -50,14 +50,14 @@ test('publishes only resolved-locale changes and allows listeners to detach', () personalization: { uiLocale: 'en' }, })); authority.observe(mergeSettings(createDefaultSettings(), { - personalization: { uiLocale: 'zh' }, + personalization: { uiLocale: 'zh-CN' }, })); unsubscribe(); authority.observe(mergeSettings(createDefaultSettings(), { personalization: { uiLocale: 'en' }, })); - assert.deepEqual(observed, ['zh']); + assert.deepEqual(observed, ['zh-CN']); }); test('keeps automatic preference live against the current system fallback', async () => { @@ -68,8 +68,10 @@ test('keeps automatic preference live against the current system fallback', asyn }); assert.equal(await authority.resolve(), 'en'); + languages = ['zh-TW']; + assert.equal(authority.current(), 'zh-TW'); languages = ['zh-CN']; - assert.equal(authority.current(), 'zh'); + assert.equal(authority.current(), 'zh-CN'); }); test('does not let a stale read replace a newer observed preference', async () => { @@ -86,13 +88,13 @@ test('does not let a stale read replace a newer observed preference', async () = const read = authority.resolve(); authority.observe(mergeSettings(createDefaultSettings(), { - personalization: { uiLocale: 'zh' }, + personalization: { uiLocale: 'zh-CN' }, })); release(createDefaultSettings()); - assert.equal(await read, 'zh'); - assert.equal(authority.current(), 'zh'); - assert.deepEqual(observed, ['zh']); + assert.equal(await read, 'zh-CN'); + assert.equal(authority.current(), 'zh-CN'); + assert.deepEqual(observed, ['zh-CN']); }); test('falls back to its current projection when settings cannot be read', async () => { @@ -100,5 +102,38 @@ test('falls back to its current projection when settings cannot be read', async readSettings: async () => { throw new Error('unreadable'); }, preferredSystemLanguages: () => ['zh-CN'], }); - assert.equal(await authority.resolve(), 'zh'); + assert.equal(await authority.resolve(), 'zh-CN'); +}); + +test('resolves a delayed persisted preference before startup native copy is selected', async () => { + let release!: (settings: ReturnType) => void; + const pending = new Promise>((resolve) => { + release = resolve; + }); + const authority = createDesktopLocaleAuthority({ + readSettings: () => pending, + preferredSystemLanguages: () => ['en-US'], + }); + + const startupLocale = authority.resolve(); + release(mergeSettings(createDefaultSettings(), { + personalization: { uiLocale: 'zh-TW' }, + })); + + assert.equal(await startupLocale, 'zh-TW'); +}); + +test('never projects the legacy zh preference into native copy lookups', async () => { + const legacy = mergeSettings(createDefaultSettings(), { + personalization: { uiLocale: 'zh-CN' }, + }); + (legacy.personalization as { uiLocale: string }).uiLocale = 'zh'; + const authority = createDesktopLocaleAuthority({ + readSettings: async () => legacy, + preferredSystemLanguages: () => ['en-US'], + }); + + assert.equal(authority.observe(legacy), 'zh-CN'); + assert.equal(authority.current(), 'zh-CN'); + assert.equal(await authority.resolve(), 'zh-CN'); }); diff --git a/apps/desktop/src/main/__tests__/desktop-native-copy.test.ts b/apps/desktop/src/main/__tests__/desktop-native-copy.test.ts index f5678fc671..821ecb27e7 100644 --- a/apps/desktop/src/main/__tests__/desktop-native-copy.test.ts +++ b/apps/desktop/src/main/__tests__/desktop-native-copy.test.ts @@ -25,11 +25,12 @@ import { import { projectPickerTitle } from '../project-picker-copy.js'; test('localizes native picker and client settings confirmation copy', () => { - assert.equal(projectPickerTitle('zh'), '添加项目'); + assert.equal(projectPickerTitle('zh-CN'), '添加项目'); + assert.equal(projectPickerTitle('zh-TW'), '新增專案'); assert.deepEqual( clientSettingsConfirmation( [{ key: 'keepSystemAwake', current: false, next: true }], - 'zh', + 'zh-CN', ), { message: '允许 Maka 更新此客户端的设置吗?', @@ -37,4 +38,15 @@ test('localizes native picker and client settings confirmation copy', () => { buttons: ['应用更改', '取消'], }, ); + assert.deepEqual( + clientSettingsConfirmation( + [{ key: 'theme', current: 'light', next: 'dark' }], + 'zh-TW', + ), + { + message: '允許 Maka 更新此用戶端的設定嗎?', + detail: '主題: light → dark', + buttons: ['套用變更', '取消'], + }, + ); }); diff --git a/apps/desktop/src/main/__tests__/e2e-fixture-locale.test.ts b/apps/desktop/src/main/__tests__/e2e-fixture-locale.test.ts new file mode 100644 index 0000000000..b1b85b2ee7 --- /dev/null +++ b/apps/desktop/src/main/__tests__/e2e-fixture-locale.test.ts @@ -0,0 +1,54 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { resolveE2eFixture } from '../e2e-fixture.js'; + +test('preserves canonical Chinese and Korean locale fixture flags', () => { + for (const locale of ['zh-CN', 'zh-TW', 'en', 'ko'] as const) { + const fixture = resolveE2eFixture( + 'settings-general', + false, + undefined, + undefined, + locale, + ); + assert.equal(fixture?.locale, locale); + } +}); + +test('normalizes locale fixture flag casing without widening the contract', () => { + assert.equal( + resolveE2eFixture('settings-general', false, undefined, undefined, 'ZH-tw')?.locale, + 'zh-TW', + ); + assert.equal( + resolveE2eFixture('settings-general', false, undefined, undefined, 'KO')?.locale, + 'ko', + ); + assert.equal( + resolveE2eFixture('settings-general', false, undefined, undefined, 'ko-kr')?.locale, + 'ko', + ); + assert.equal( + resolveE2eFixture('settings-general', false, undefined, undefined, 'zh-Hant')?.locale, + null, + ); +}); diff --git a/apps/desktop/src/main/__tests__/goal-arm-outcome.test.ts b/apps/desktop/src/main/__tests__/goal-arm-outcome.test.ts index 09a2b13c11..da86114d6d 100644 --- a/apps/desktop/src/main/__tests__/goal-arm-outcome.test.ts +++ b/apps/desktop/src/main/__tests__/goal-arm-outcome.test.ts @@ -83,7 +83,7 @@ test('successful Goal arming closes while every reconciliation result locks the }); test('Goal reconciliation copy explains authoritative state in Chinese and English', () => { - const zh = getShellCopy('zh').goalDialog; + const zh = getShellCopy('zh-CN').goalDialog; assert.match( zh.reconciledMatching('所有测试通过', zh.statusLabels.active), /所有测试通过.*进行中.*无法确认.*提交/, diff --git a/apps/desktop/src/main/__tests__/health-center-copy.test.ts b/apps/desktop/src/main/__tests__/health-center-copy.test.ts index e8f7a6aa5f..6ab57b2855 100644 --- a/apps/desktop/src/main/__tests__/health-center-copy.test.ts +++ b/apps/desktop/src/main/__tests__/health-center-copy.test.ts @@ -23,7 +23,7 @@ import { getHealthCenterCopy } from '../../renderer/locales/settings-health-copy test('labels blocker counts as global across filtered health views', () => { assert.equal( - getHealthCenterCopy('zh').blockers.send(1, 6), + getHealthCenterCopy('zh-CN').blockers.send(1, 6), '全部健康信号中,1/6 条会阻塞发送', ); assert.equal( @@ -31,3 +31,23 @@ test('labels blocker counts as global across filtered health views', () => { 'Across all health signals, 1 of 6 blocks sending', ); }); + +test('Traditional Chinese health copy localizes structured signal text', () => { + const copy = getHealthCenterCopy('zh-TW'); + const signal = { + id: 'connection:test', + label: '測試 运行态', + scope: 'llm_connection' as const, + layer: 'validation' as const, + status: 'ok' as const, + source: 'connection_test' as const, + checkedAt: 1, + message: '凭据与端点验证已通过。', + detail: '这是连接验证结果,不代表发送、流式输出或中断通路已经运行通过。', + blocksSend: false, + }; + assert.equal(copy.layers.configuration.label, '設定'); + assert.equal(copy.signalLabel(signal), '測試 執行狀態'); + assert.equal(copy.signalMessage(signal), '憑證與端點驗證已通過。'); + assert.match(copy.signalDetail(signal) ?? '', /串流輸出/); +}); diff --git a/apps/desktop/src/main/__tests__/import-tasks-settings-page.test.ts b/apps/desktop/src/main/__tests__/import-tasks-settings-page.test.ts index a62994ecaf..885cdcea20 100644 --- a/apps/desktop/src/main/__tests__/import-tasks-settings-page.test.ts +++ b/apps/desktop/src/main/__tests__/import-tasks-settings-page.test.ts @@ -136,7 +136,7 @@ describe('ImportTasksSettingsPage durable import state', () => { it('renders durable repeat-import state in Chinese', async () => { const harness = await renderPage({ - locale: 'zh', + locale: 'zh-CN', catalog: catalog( externalSession({ importState: { @@ -1211,7 +1211,7 @@ async function renderPage(options: { */ importBySource?: Record; onOpenImported?: (sessionId: string) => void; - locale?: 'en' | 'zh'; + locale?: 'en' | 'zh-CN'; }): Promise<{ container: HTMLElement; root: Root; diff --git a/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts index ece5f859fc..1eddb50f65 100644 --- a/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts +++ b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts @@ -27,7 +27,7 @@ test('queue_update events drive the independent desktop queue projection', () => const transientMessages: unknown[] = []; const removedTransientMessageIds: string[] = []; const handlers = createAppShellSessionEventHandlers({ - uiLocale: 'zh', + uiLocale: 'zh-CN', activeIdRef: { current: 'session-1' }, liveTurnBySessionRef: controller.liveTurnBySessionRef, refreshMessages: async () => true, diff --git a/apps/desktop/src/main/__tests__/new-task-staged-content.test.ts b/apps/desktop/src/main/__tests__/new-task-staged-content.test.ts index 0eba968b64..3c201eb673 100644 --- a/apps/desktop/src/main/__tests__/new-task-staged-content.test.ts +++ b/apps/desktop/src/main/__tests__/new-task-staged-content.test.ts @@ -66,7 +66,7 @@ afterEach(async () => { async function mountProbe(useHook: (options: { draftKey: string }) => T): Promise<{ latest(): T; - render(draftKey: string, locale?: 'en' | 'zh'): Promise; + render(draftKey: string, locale?: 'en' | 'zh-CN'): Promise; }> { const { document, window } = parseHTML('
'); Object.assign(globalThis, { @@ -88,7 +88,7 @@ async function mountProbe(useHook: (options: { draftKey: string }) => T): Pro return null; } - const render = async (draftKey: string, locale: 'en' | 'zh' = 'en') => { + const render = async (draftKey: string, locale: 'en' | 'zh-CN' = 'en') => { await act(async () => { root.render( createElement(LocaleProvider, { @@ -238,16 +238,16 @@ test('AppShell composition shows the localized non-vision image notice once per description: en.imageAttachmentNotDirectDescription, }]); - await probe.render('session-zh', 'zh'); + await probe.render('session-zh', 'zh-CN'); await act(() => probe.latest().attachFilePaths([image])); - const zh = getDesktopConversationCopy('zh').actions; + const zh = getDesktopConversationCopy('zh-CN').actions; assert.deepEqual(calls[1], { title: zh.imageAttachmentNotDirectTitle, description: zh.imageAttachmentNotDirectDescription, }); probe.latest().imageNoticeLifecycle.transfer('session-zh', 'session-transferred'); - await probe.render('session-transferred', 'zh'); + await probe.render('session-transferred', 'zh-CN'); await act(() => probe.latest().attachFilePaths([image])); assert.equal(calls.length, 2); diff --git a/apps/desktop/src/main/__tests__/notifications-policy.test.ts b/apps/desktop/src/main/__tests__/notifications-policy.test.ts index fde041c2b9..a20d0c1300 100644 --- a/apps/desktop/src/main/__tests__/notifications-policy.test.ts +++ b/apps/desktop/src/main/__tests__/notifications-policy.test.ts @@ -48,38 +48,43 @@ it('recognizes terminal kinds and keeps distinct localized fallback copy', () => assert.equal(isRunNotificationKind(value), false); } - const completed = runNotificationCopy('completed', 'zh'); - const errored = runNotificationCopy('errored', 'zh'); + const completed = runNotificationCopy('completed', 'zh-CN'); + const errored = runNotificationCopy('errored', 'zh-CN'); assert.ok(completed.title && completed.body); assert.ok(errored.title && errored.body); assert.notEqual(completed.title, errored.title); + + assert.deepEqual(runNotificationCopy('completed', 'zh-TW'), { + title: '回答已產生', + body: 'Maka 已完成本次回答,按一下以檢視。', + }); }); it('sanitizes renderer content, caps it, and falls back per field', () => { const clean = resolveNotificationContent( { kind: 'completed', title: ' 会话 A ', body: 'line one\n\nline two\tindented' }, - 'zh', + 'zh-CN', ); assert.deepEqual(clean, { title: '会话 A', body: 'line one line two indented' }); - const completedFallback = runNotificationCopy('completed', 'zh'); + const completedFallback = runNotificationCopy('completed', 'zh-CN'); for (const value of ['', ' ', undefined, null, 42, {}]) { assert.deepEqual( - resolveNotificationContent({ kind: 'completed', title: value, body: value }, 'zh'), + resolveNotificationContent({ kind: 'completed', title: value, body: value }, 'zh-CN'), completedFallback, ); } const capped = resolveNotificationContent( { kind: 'completed', title: 'S', body: 'x'.repeat(500) }, - 'zh', + 'zh-CN', ); assert.equal(capped.body.length, 160); assert.ok(capped.body.endsWith('…')); - const erroredFallback = runNotificationCopy('errored', 'zh'); + const erroredFallback = runNotificationCopy('errored', 'zh-CN'); assert.deepEqual( - resolveNotificationContent({ kind: 'errored', title: '出错的会话', body: '' }, 'zh'), + resolveNotificationContent({ kind: 'errored', title: '出错的会话', body: '' }, 'zh-CN'), { title: '出错的会话', body: erroredFallback.body }, ); }); diff --git a/apps/desktop/src/main/__tests__/permission-center-copy.test.ts b/apps/desktop/src/main/__tests__/permission-center-copy.test.ts index 6333051056..318b15a877 100644 --- a/apps/desktop/src/main/__tests__/permission-center-copy.test.ts +++ b/apps/desktop/src/main/__tests__/permission-center-copy.test.ts @@ -22,6 +22,6 @@ import { test } from 'node:test'; import { getPermissionCenterCopy } from '../../renderer/locales/permission-center-copy.js'; test('presents a granted OS permission as a verified success', () => { - assert.equal(getPermissionCenterCopy('zh').osStates.granted.tone, 'success'); + assert.equal(getPermissionCenterCopy('zh-CN').osStates.granted.tone, 'success'); assert.equal(getPermissionCenterCopy('en').osStates.granted.tone, 'success'); }); diff --git a/apps/desktop/src/main/__tests__/plan-mode-copy.test.ts b/apps/desktop/src/main/__tests__/plan-mode-copy.test.ts index fbe73bb661..af8cc19329 100644 --- a/apps/desktop/src/main/__tests__/plan-mode-copy.test.ts +++ b/apps/desktop/src/main/__tests__/plan-mode-copy.test.ts @@ -22,7 +22,7 @@ import { test } from 'node:test'; import { getPlanModeCopy } from '../../renderer/locales/plan-mode-copy.js'; test('localizes Plan Mode chrome and abandon confirmation without rewriting plan content', () => { - const zh = getPlanModeCopy('zh'); + const zh = getPlanModeCopy('zh-CN'); const en = getPlanModeCopy('en'); assert.equal(zh.proposal.statuses.approved, '已批准'); diff --git a/apps/desktop/src/main/__tests__/provider-connection-status.test.ts b/apps/desktop/src/main/__tests__/provider-connection-status.test.ts index a0429cb72f..f6d1e41658 100644 --- a/apps/desktop/src/main/__tests__/provider-connection-status.test.ts +++ b/apps/desktop/src/main/__tests__/provider-connection-status.test.ts @@ -49,7 +49,7 @@ test('a retired connection reads as broken rather than repairable', () => { // Nothing else in the list marks this row, so without a status the only // signal that it has to go is on the detail page the user has no reason to // open. - assert.deepEqual(connectionChipStatus(retired, 'zh'), { + assert.deepEqual(connectionChipStatus(retired, 'zh-CN'), { label: '已停用 · 请删除', tone: 'error', }); @@ -69,7 +69,7 @@ test('retirement outranks every repairable state', () => { { enabled: false }, ]) { assert.deepEqual( - connectionChipStatus({ ...retired, ...overrides }, 'zh'), + connectionChipStatus({ ...retired, ...overrides }, 'zh-CN'), { label: '已停用 · 请删除', tone: 'error' }, `retirement must win over ${JSON.stringify(overrides)}`, ); @@ -77,12 +77,12 @@ test('retirement outranks every repairable state', () => { }); test('a live connection keeps its existing statuses', () => { - assert.equal(connectionChipStatus(connection({ lastTestStatus: 'verified' }), 'zh'), null); - assert.deepEqual(connectionChipStatus(connection({ lastTestStatus: 'needs_reauth' }), 'zh'), { + assert.equal(connectionChipStatus(connection({ lastTestStatus: 'verified' }), 'zh-CN'), null); + assert.deepEqual(connectionChipStatus(connection({ lastTestStatus: 'needs_reauth' }), 'zh-CN'), { label: '需要重新登录', tone: 'attention', }); - assert.deepEqual(connectionChipStatus(connection({ enabled: false }), 'zh'), { + assert.deepEqual(connectionChipStatus(connection({ enabled: false }), 'zh-CN'), { label: '暂不可用', tone: 'neutral', }); diff --git a/apps/desktop/src/main/__tests__/runtime-host-boot-copy.test.ts b/apps/desktop/src/main/__tests__/runtime-host-boot-copy.test.ts new file mode 100644 index 0000000000..22aa914054 --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-boot-copy.test.ts @@ -0,0 +1,30 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { getStartupRecoveryCopy } from '../runtime-host-boot-copy.js'; + +test('startup recovery copy preserves Traditional Chinese', () => { + const copy = getStartupRecoveryCopy('zh-TW'); + assert.equal(copy.storageRoot.title, 'Maka 工作區需要修復'); + assert.match(copy.storageRoot.detail('C:\\Maka'), /磁碟識別碼/); + assert.equal(copy.runtimeHost.title, '預設 Runtime Host 無法連線'); + assert.match(copy.runtimeHost.detail('失敗'), /設定中處理/); +}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-quit-copy.test.ts b/apps/desktop/src/main/__tests__/runtime-host-quit-copy.test.ts index bf1aab2f04..defbaa8e27 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-quit-copy.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-quit-copy.test.ts @@ -33,7 +33,7 @@ const failure = new DesktopLocalHostRetirementError( { cause: new Error('writer release timed out') }, ); -for (const locale of ['en', 'zh'] as const) { +for (const locale of ['en', 'zh-CN'] as const) { test(`quit failure copy exposes actionable Host facts in ${locale}`, () => { const dialog = buildRuntimeHostQuitFailureDialog(failure, locale); @@ -46,7 +46,7 @@ for (const locale of ['en', 'zh'] as const) { test('manual recovery copy names a cross-platform process-management concept', () => { const english = buildRuntimeHostQuitFailureDialog(failure, 'en').detail ?? ''; - const chinese = buildRuntimeHostQuitFailureDialog(failure, 'zh').detail ?? ''; + const chinese = buildRuntimeHostQuitFailureDialog(failure, 'zh-CN').detail ?? ''; assert.match(english, /operating system's process-management tool/); assert.match(chinese, /操作系统的进程管理工具/); diff --git a/apps/desktop/src/main/__tests__/runtime-host-upgrade-dialog.test.ts b/apps/desktop/src/main/__tests__/runtime-host-upgrade-dialog.test.ts index 9edb5f5888..9790a94fe9 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-upgrade-dialog.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-upgrade-dialog.test.ts @@ -48,7 +48,12 @@ test('localizes upgrade activity without changing decision indexes', () => { const zh = buildRuntimeHostUpgradeDialog( conflict, { action: 'restart', canWait: true }, - 'zh', + 'zh-CN', + ).options; + const zhTw = buildRuntimeHostUpgradeDialog( + conflict, + { action: 'restart', canWait: true }, + 'zh-TW', ).options; assert.deepEqual(en.buttons, ['Restart Runtime Host', 'Wait', 'Cancel Startup']); assert.deepEqual(zh.buttons, ['重启 Runtime Host', '等待', '取消启动']); @@ -58,6 +63,9 @@ test('localizes upgrade activity without changing decision indexes', () => { assert.match(zh.detail ?? '', /每日回顾: 1/); assert.match(en.detail ?? '', /Scheduled Task: 2/); assert.match(zh.detail ?? '', /计划任务: 2/); + assert.deepEqual(zhTw.buttons, ['重啟 Runtime Host', '等待', '取消啟動']); + assert.match(zhTw.detail ?? '', /仍有 2 個其他客戶端連線/); + assert.match(zhTw.detail ?? '', /每日回顧: 1/); assert.match(en.detail ?? '', /Process ID \(PID\):/); }); diff --git a/apps/desktop/src/main/__tests__/session-inspector-context-format.test.ts b/apps/desktop/src/main/__tests__/session-inspector-context-format.test.ts index b8fd943106..410f265169 100644 --- a/apps/desktop/src/main/__tests__/session-inspector-context-format.test.ts +++ b/apps/desktop/src/main/__tests__/session-inspector-context-format.test.ts @@ -22,7 +22,7 @@ import { test } from 'node:test'; import { compactNumberFormatter } from '../../renderer/features/workbar/testing.js'; test('formats context-window capacity with stable K/M units in every UI locale', () => { - for (const locale of ['en', 'zh'] as const) { + for (const locale of ['en', 'zh-CN', 'zh-TW'] as const) { const format = compactNumberFormatter(locale); assert.equal(format(256_000), '256K'); diff --git a/apps/desktop/src/main/__tests__/session-status-presentation.test.ts b/apps/desktop/src/main/__tests__/session-status-presentation.test.ts index 7f2473c70f..07f2256651 100644 --- a/apps/desktop/src/main/__tests__/session-status-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/session-status-presentation.test.ts @@ -33,7 +33,8 @@ const NOTHING_RAN = { describe('failed turn presentation', () => { it('presents persisted provider server errors as provider failures', () => { - assert.match(describeTurnErrorClass('server_error', 'zh'), /模型服务返回错误/); + assert.match(describeTurnErrorClass('server_error', 'zh-CN'), /模型服务返回错误/); + assert.match(describeTurnErrorClass('server_error', 'zh-TW'), /模型服務返回錯誤/); assert.match(describeTurnErrorClass('server_error', 'en'), /model service returned an error/i); }); @@ -41,7 +42,7 @@ describe('failed turn presentation', () => { // The banner offers a button only for `app_restarted`; every other class // has to point at the one action that always exists — send a message. for (const errorClass of ['rate_limit', 'network', 'timeout', 'unknown_failure']) { - assert.match(describeTurnErrorClass(errorClass, 'zh'), /重新发消息|再发消息|发消息/); + assert.match(describeTurnErrorClass(errorClass, 'zh-CN'), /重新发消息|再发消息|发消息/); } }); @@ -59,7 +60,7 @@ describe('failed turn execution state', () => { it('warns that a completed tool may already have taken effect', () => { // A blind resend after a side-effecting tool can repeat that effect, so // this has to survive alongside a transport failure like `timeout`. - const zh = describeFailedTurnExecutionState({ ...NOTHING_RAN, toolActivityCount: 1 }, 'zh'); + const zh = describeFailedTurnExecutionState({ ...NOTHING_RAN, toolActivityCount: 1 }, 'zh-CN'); assert.match(zh ?? '', /执行过工具|实际改动/); const en = describeFailedTurnExecutionState({ ...NOTHING_RAN, toolActivityCount: 1 }, 'en'); assert.match(en ?? '', /tools already ran/i); @@ -70,26 +71,27 @@ describe('failed turn execution state', () => { // the tool branch win, so `auth` plus an errored tool advised "inspect the // tool result" and dropped the sign-in step. They are separate slots now. const state = { ...NOTHING_RAN, toolActivityCount: 1, erroredToolCount: 1 }; - assert.match(describeTurnErrorClass('auth', 'zh'), /重新连接或登录/); - assert.match(describeFailedTurnExecutionState(state, 'zh') ?? '', /工具执行出错/); - assert.match(describeTurnErrorClass('context_overflow', 'zh'), /减少附件|开启新任务/); + assert.match(describeTurnErrorClass('auth', 'zh-CN'), /重新连接或登录/); + assert.match(describeFailedTurnExecutionState(state, 'zh-CN') ?? '', /工具执行出错/); + assert.match(describeTurnErrorClass('context_overflow', 'zh-CN'), /减少附件|开启新任务/); + assert.match(describeFailedTurnExecutionState(state, 'zh-TW') ?? '', /工具執行出錯/); }); it('reports nothing when the turn left nothing to re-read', () => { - assert.equal(describeFailedTurnExecutionState(NOTHING_RAN, 'zh'), undefined); + assert.equal(describeFailedTurnExecutionState(NOTHING_RAN, 'zh-CN'), undefined); }); it('prefers the most specific state the turn reached', () => { const all = { partialOutputRetained: true, toolActivityCount: 2, erroredToolCount: 1 }; - assert.match(describeFailedTurnExecutionState(all, 'zh') ?? '', /工具执行出错/); + assert.match(describeFailedTurnExecutionState(all, 'zh-CN') ?? '', /工具执行出错/); assert.match( - describeFailedTurnExecutionState({ ...all, erroredToolCount: 0 }, 'zh') ?? '', + describeFailedTurnExecutionState({ ...all, erroredToolCount: 0 }, 'zh-CN') ?? '', /执行过工具/, ); assert.match( describeFailedTurnExecutionState( { ...NOTHING_RAN, partialOutputRetained: true }, - 'zh', + 'zh-CN', ) ?? '', /部分回答/, ); diff --git a/apps/desktop/src/main/__tests__/settings-preferences-copy.test.ts b/apps/desktop/src/main/__tests__/settings-preferences-copy.test.ts new file mode 100644 index 0000000000..c0c5a05203 --- /dev/null +++ b/apps/desktop/src/main/__tests__/settings-preferences-copy.test.ts @@ -0,0 +1,74 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { getSettingsPreferencesCopy } from '../../renderer/locales/settings-preferences-copy.js'; +import { getMcpCatalog } from '../../renderer/mcp-catalog.js'; +import { providerDisplay } from '../../renderer/settings/provider-display-copy.js'; +import { getBotSettingsCopy } from '../../renderer/locales/settings-bot-copy.js'; + +test('language selector offers every preference with locale-appropriate labels', () => { + assert.deepEqual(getSettingsPreferencesCopy('zh-CN').personalization.localeOptions, [ + ['auto', '跟随系统'], + ['zh-CN', '简体中文'], + ['zh-TW', '繁體中文'], + ['en', 'English'], + ]); + assert.deepEqual(getSettingsPreferencesCopy('zh-TW').personalization.localeOptions, [ + ['auto', '自動(跟隨系統)'], + ['zh-CN', '简体中文'], + ['zh-TW', '繁體中文'], + ['en', 'English'], + ]); + assert.deepEqual(getSettingsPreferencesCopy('en').personalization.localeOptions, [ + ['auto', 'Follow system'], + ['zh-CN', 'Simplified Chinese'], + ['zh-TW', 'Traditional Chinese'], + ['en', 'English'], + ]); +}); + +test('Traditional Chinese settings copy uses Taiwan terminology', () => { + const copy = getSettingsPreferencesCopy('zh-TW'); + assert.equal(copy.sections.network, '網路'); + assert.equal(copy.appearance.paletteLabels.default, '預設'); + assert.equal(copy.appearance.appIconImport, '匯入圖示…'); + assert.equal(copy.about.clipboardUnavailable, '剪貼簿不可用或被系統拒絕。'); +}); + +test('Traditional Chinese MCP catalog does not fall back to Simplified Chinese', () => { + const catalog = getMcpCatalog('zh-TW'); + assert.equal(catalog.find((entry) => entry.id === 'filesystem')?.name, '本機檔案'); + assert.equal(catalog.find((entry) => entry.id === 'google-calendar')?.name, 'Google 日曆'); + assert.equal(catalog.find((entry) => entry.id === 'playwright')?.category, '設計與開發'); +}); + +test('Traditional Chinese provider cards use Taiwan connection terminology', () => { + assert.equal(providerDisplay('deepseek', 'zh-TW').description, 'DeepSeek 官方 API 連線'); + assert.match(providerDisplay('github-copilot', 'zh-TW').description, /訂閱連線/); +}); + +test('Traditional Chinese bot settings use Taiwan integration terminology', () => { + const copy = getBotSettingsCopy('zh-TW'); + assert.match(copy.providers.wecom.help, /串接/); + assert.doesNotMatch(copy.providers.wecom.help, /接入/); + assert.match(copy.overview.more, /串接更多/); + assert.doesNotMatch(copy.overview.more, /渠道|訪問|匹配/); +}); diff --git a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts index df1ac3b1c6..d31696a794 100644 --- a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts @@ -39,7 +39,7 @@ import { waitFor as pollFor } from '@maka/core/test-only/async-primitives'; function renderWithLocale(child: ReactNode): string { return renderToStaticMarkup( createElement(LocaleProvider, { - locale: 'zh', + locale: 'zh-CN', children: createElement(ChatSurfaceLayout, { composer: null, children: child }), }), ); @@ -303,7 +303,7 @@ describe('single live-turn handoff', () => { liveTurnBySessionRef.current = liveTurns.get(); }; const handlers = createAppShellSessionEventHandlers({ - uiLocale: 'zh', + uiLocale: 'zh-CN', activeIdRef: { current: 'session-1' }, liveTurnBySessionRef, refreshMessages: async (sessionId, options) => { @@ -356,7 +356,7 @@ describe('single live-turn handoff', () => { const frames: Array<() => void> = []; let publications = 0; const handlers = createAppShellSessionEventHandlers({ - uiLocale: 'zh', + uiLocale: 'zh-CN', activeIdRef: { current: 'session-1' }, liveTurnBySessionRef, refreshMessages: async () => true, @@ -410,7 +410,7 @@ describe('single live-turn handoff', () => { const displayBatch = createAppShellSessionDisplayBatch(); let publications = 0; const handlers = createAppShellSessionEventHandlers({ - uiLocale: 'zh', + uiLocale: 'zh-CN', activeIdRef: { current: 'session-1' }, liveTurnBySessionRef, refreshMessages: async () => true, @@ -477,7 +477,7 @@ describe('single live-turn handoff', () => { const interactions = createStateSetter({}); const frames: Array<() => void> = []; const handlers = createAppShellSessionEventHandlers({ - uiLocale: 'zh', + uiLocale: 'zh-CN', activeIdRef: { current: 'session-1' }, liveTurnBySessionRef, refreshMessages: async () => true, @@ -515,7 +515,7 @@ describe('single live-turn handoff', () => { const frames: Array<() => void> = []; let publications = 0; const handlers = createAppShellSessionEventHandlers({ - uiLocale: 'zh', + uiLocale: 'zh-CN', activeIdRef: { current: 'session-1' }, liveTurnBySessionRef, refreshMessages: async () => true, @@ -578,7 +578,7 @@ describe('single live-turn handoff', () => { const displayBatch = createAppShellSessionDisplayBatch(); let publications = 0; const deps = { - uiLocale: 'zh' as const, + uiLocale: 'zh-CN' as const, activeIdRef: { current: 'session-1' }, liveTurnBySessionRef, refreshMessages: async () => true, @@ -625,7 +625,7 @@ describe('single live-turn handoff', () => { ref.current = liveTurns.get(); }; const handlers = createAppShellSessionEventHandlers({ - uiLocale: 'zh', + uiLocale: 'zh-CN', activeIdRef: { current: 'session-1' }, liveTurnBySessionRef: ref, refreshMessages: async () => true, @@ -682,7 +682,7 @@ describe('single live-turn handoff', () => { resolveRefresh = resolve; }); const handlers = createAppShellSessionEventHandlers({ - uiLocale: 'zh', + uiLocale: 'zh-CN', activeIdRef: { current: 'session-1' }, liveTurnBySessionRef: ref, refreshMessages: async () => refresh, @@ -731,7 +731,7 @@ describe('single live-turn handoff', () => { | { sessionId: string; turnId: string; eventId: string } | undefined; const handlers = createAppShellSessionEventHandlers({ - uiLocale: 'zh', + uiLocale: 'zh-CN', activeIdRef: { current: 'session-1' }, liveTurnBySessionRef: ref, refreshMessages: async () => false, @@ -804,7 +804,7 @@ describe('single live-turn handoff', () => { const ref = { current: liveTurns.get() }; const interactions = createStateSetter({}); const handlers = createAppShellSessionEventHandlers({ - uiLocale: 'zh', + uiLocale: 'zh-CN', activeIdRef: { current: 'session-1' }, liveTurnBySessionRef: ref, refreshMessages: async () => true, @@ -844,7 +844,7 @@ describe('single live-turn handoff', () => { resolveRefresh = resolve; }); const handlers = createAppShellSessionEventHandlers({ - uiLocale: 'zh', + uiLocale: 'zh-CN', activeIdRef: { current: 'session-1' }, liveTurnBySessionRef: ref, refreshMessages: async () => refresh, diff --git a/apps/desktop/src/main/__tests__/subscription-result-message.test.ts b/apps/desktop/src/main/__tests__/subscription-result-message.test.ts new file mode 100644 index 0000000000..14dd03b785 --- /dev/null +++ b/apps/desktop/src/main/__tests__/subscription-result-message.test.ts @@ -0,0 +1,33 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { subscriptionResultMessage } from '../../renderer/settings/subscription-result-message.js'; + +test('Traditional Chinese subscription errors reject raw Simplified backend copy', () => { + assert.equal( + subscriptionResultMessage('GitHub Copilot 登录已过期。', '請重新登入。', 'zh-TW'), + '請重新登入。', + ); + assert.equal( + subscriptionResultMessage('network unreachable', '請重新登入。', 'zh-TW'), + '網路錯誤', + ); +}); diff --git a/apps/desktop/src/main/__tests__/task-readiness-notice.test.ts b/apps/desktop/src/main/__tests__/task-readiness-notice.test.ts index 5fb9c4dd3f..5937431acd 100644 --- a/apps/desktop/src/main/__tests__/task-readiness-notice.test.ts +++ b/apps/desktop/src/main/__tests__/task-readiness-notice.test.ts @@ -63,11 +63,25 @@ test('confirmed repair and unavailable states block, while loading uncertainty d test('model blockers stay owned by existing connection recovery surfaces', () => { assert.equal( - deriveTaskReadinessNotice(snapshot('repair_required', 'model_target'), 'zh'), + deriveTaskReadinessNotice(snapshot('repair_required', 'model_target'), 'zh-CN'), undefined, ); }); +test('Traditional Chinese readiness notices use Taiwan terminology', () => { + assert.deepEqual(deriveTaskReadinessNotice(snapshot('unavailable', 'runtime'), 'zh-TW'), { + tone: 'destructive', + title: 'Maka 執行服務暫時無法使用。', + description: '任務尚未送出。重新檢查執行服務後再試。', + actionLabel: '重新檢查', + action: 'retry', + }); + assert.equal( + deriveTaskReadinessNotice(snapshot('repair_required', 'workspace'), 'zh-TW')?.actionLabel, + '選擇工作區', + ); +}); + function snapshot( state: TaskSubmissionReadinessSnapshot['state'], id: 'runtime' | 'model_target' | 'workspace', diff --git a/apps/desktop/src/main/__tests__/traditional-chinese-peer-mesh-copy.test.ts b/apps/desktop/src/main/__tests__/traditional-chinese-peer-mesh-copy.test.ts new file mode 100644 index 0000000000..40ef8678d7 --- /dev/null +++ b/apps/desktop/src/main/__tests__/traditional-chinese-peer-mesh-copy.test.ts @@ -0,0 +1,56 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { getRuntimeHostPeerMeshCopy } from '../../renderer/features/runtime-host-management/ui/runtime-host-peer-mesh-dialog.js'; +import { getDesktopConversationCopy } from '../../renderer/locales/conversation-copy.js'; +import { getProviderSettingsCopy } from '../../renderer/locales/settings-provider-copy.js'; +import { settingsTestResultMessage } from '../../renderer/locales/settings-test-result-copy.js'; + +test('Traditional Chinese Peer Mesh copy does not use the Simplified Chinese branch', () => { + const copy = getRuntimeHostPeerMeshCopy('zh-TW'); + + assert.equal(copy.experimental, '實驗性'); + assert.equal(copy.invalidResult, 'Peer Mesh 回傳了無效結果'); + assert.equal(copy.copyPeerId('peer-1'), '複製完整 Peer ID:peer-1'); + assert.equal(copy.peerIdCopyFailed, '無法複製 Peer ID'); + assert.equal(copy.joinHint, '貼上另一個 Peer 產生的一次性邀請碼。'); +}); + +test('Traditional Chinese connection copy uses 回傳, 回應, and 發送', () => { + const conversation = getDesktopConversationCopy('zh-TW'); + assert.equal(conversation.quoteCompanion.errors.respondFailed, '回應失敗,請稍後重試。'); + assert.match(conversation.health.reauth.tooltip, /回傳鑑權失敗/); + assert.match(conversation.health.reauth.tooltip, /攔截發送/); + assert.match(conversation.health.testError.tooltip, /攔截發送/); + assert.match(conversation.turnError.provider, /模型服務回傳錯誤/); + + const provider = getProviderSettingsCopy('zh-TW'); + assert.equal(provider.shared.lastTest['模型服务返回错误'], '模型服務回傳錯誤'); + assert.equal(provider.shared.lastTest['provider returned an error'], '模型服務回傳錯誤'); + + assert.equal( + settingsTestResultMessage( + { ok: false, code: 'proxy_http_error', message: '', details: { status: 502 } }, + 'zh-TW', + ), + '代理測試回傳 HTTP 502,請檢查代理服務或測試地址。', + ); +}); diff --git a/apps/desktop/src/main/__tests__/ui-locale-update-gate.test.ts b/apps/desktop/src/main/__tests__/ui-locale-update-gate.test.ts index 87ff3c2f8f..edb1b7acfd 100644 --- a/apps/desktop/src/main/__tests__/ui-locale-update-gate.test.ts +++ b/apps/desktop/src/main/__tests__/ui-locale-update-gate.test.ts @@ -48,8 +48,8 @@ describe('UI locale settings update gate', () => { const savedLocales: string[] = []; assert.equal(gate.commit(firstLocaleTicket, 'en', (next) => savedLocales.push(next)), false); - assert.equal(gate.commit(latestLocaleTicket, 'zh', (next) => savedLocales.push(next)), true); - assert.deepEqual(savedLocales, ['zh']); + assert.equal(gate.commit(latestLocaleTicket, 'zh-CN', (next) => savedLocales.push(next)), true); + assert.deepEqual(savedLocales, ['zh-CN']); }); it('delivers the persisted auto preference without resolving it locally', () => { @@ -71,7 +71,7 @@ describe('UI locale settings update gate', () => { assert.equal(gate.commit(saveTicket, 'en', (next) => applied.push(next)), true); assert.equal( - gate.commitHydration(hydration, 'zh', (next) => applied.push(next)), + gate.commitHydration(hydration, 'zh-CN', (next) => applied.push(next)), false, ); assert.deepEqual(applied, ['en']); @@ -84,7 +84,7 @@ describe('UI locale settings update gate', () => { const applied: string[] = []; assert.equal( - gate.commitHydration(hydration, 'zh', (next) => applied.push(next)), + gate.commitHydration(hydration, 'zh-CN', (next) => applied.push(next)), false, ); assert.equal(gate.commit(saveTicket, 'en', (next) => applied.push(next)), true); @@ -102,7 +102,7 @@ describe('UI locale settings update gate', () => { true, ); assert.equal( - appShellGate.commit(firstSurfaceTicket, 'zh', (next) => applied.push(next)), + appShellGate.commit(firstSurfaceTicket, 'zh-CN', (next) => applied.push(next)), false, ); assert.deepEqual(applied, ['en']); @@ -116,10 +116,10 @@ describe('UI locale settings update gate', () => { gate.cancel(failedSaveTicket); assert.equal( - gate.commitHydration(blockedHydration, 'zh', (next) => applied.push(next)), + gate.commitHydration(blockedHydration, 'zh-CN', (next) => applied.push(next)), true, ); - assert.deepEqual(applied, ['zh']); + assert.deepEqual(applied, ['zh-CN']); }); it('applies the latest blocked hydration when an intervening locale save fails', () => { @@ -129,13 +129,13 @@ describe('UI locale settings update gate', () => { const applied: string[] = []; assert.equal( - gate.commitHydration(hydration, 'zh', (next) => applied.push(next)), + gate.commitHydration(hydration, 'zh-CN', (next) => applied.push(next)), false, ); assert.deepEqual(applied, []); gate.cancel(failedSaveTicket); - assert.deepEqual(applied, ['zh']); + assert.deepEqual(applied, ['zh-CN']); }); it('accepts an older pending save after the newer save fails', () => { diff --git a/apps/desktop/src/main/__tests__/use-onboarding-snapshot.test.ts b/apps/desktop/src/main/__tests__/use-onboarding-snapshot.test.ts index 16a9822e9c..eb6c5c3227 100644 --- a/apps/desktop/src/main/__tests__/use-onboarding-snapshot.test.ts +++ b/apps/desktop/src/main/__tests__/use-onboarding-snapshot.test.ts @@ -104,7 +104,7 @@ describe('createOnboardingSnapshotPoller', () => { onSnapshot: (s) => events.push({ type: 'snap', payload: s }), onError: (m) => events.push({ type: 'err', payload: m }), }, - () => 'zh', + () => 'zh-CN', ); await poller.pull(); assert.deepEqual(events, [{ type: 'err', payload: '鉴权失败' }]); @@ -132,7 +132,7 @@ describe('createOnboardingSnapshotPoller', () => { /* not expected */ }, }, - () => 'zh', + () => 'zh-CN', ); // Fire two overlapping pulls. const pull1 = poller.pull(); @@ -170,7 +170,7 @@ describe('createOnboardingSnapshotPoller', () => { onSnapshot: (s) => snaps.push(s), onError: (m) => errs.push(m), }, - () => 'zh', + () => 'zh-CN', ); const pull1 = poller.pull(); const pull2 = poller.pull(); @@ -196,7 +196,7 @@ describe('createOnboardingSnapshotPoller', () => { onSnapshot: (s) => events.push({ type: 'snap', payload: s }), onError: (m) => events.push({ type: 'err', payload: m }), }, - () => 'zh', + () => 'zh-CN', ); const pull = poller.pull(); @@ -221,7 +221,7 @@ describe('createOnboardingSnapshotPoller', () => { onSnapshot: (s) => events.push({ type: 'snap', payload: s }), onError: (m) => events.push({ type: 'err', payload: m }), }, - () => 'zh', + () => 'zh-CN', ); const pull = poller.pull(); @@ -240,7 +240,7 @@ describe('createOnboardingSnapshotPoller', () => { onSnapshot: (s) => events.push({ type: 'snap', payload: s }), onError: (m) => events.push({ type: 'err', payload: m }), }, - () => 'zh', + () => 'zh-CN', ); poller.dispose(); diff --git a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts index 447b5969b5..3042072a04 100644 --- a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts @@ -185,7 +185,7 @@ test('durable delegation renders terminal link state instead of stale execution }, updatedAt: 10, }; - const render = (locale: 'en' | 'zh') => renderToStaticMarkup( + const render = (locale: 'en' | 'zh-CN') => renderToStaticMarkup( createElement(LocaleProvider, { locale, children: createElement(AstryxLocaleProvider, { @@ -199,7 +199,7 @@ test('durable delegation renders terminal link state instead of stale execution }), ); const englishMarkup = render('en'); - const chineseMarkup = render('zh'); + const chineseMarkup = render('zh-CN'); assert.match(englishMarkup, new RegExp(english, 'u')); assert.doesNotMatch(englishMarkup, />Running { }, updatedAt: 10, }; - const render = (locale: 'en' | 'zh') => renderToStaticMarkup( + const render = (locale: 'en' | 'zh-CN') => renderToStaticMarkup( createElement(LocaleProvider, { locale, children: createElement(AstryxLocaleProvider, { @@ -243,7 +243,7 @@ test('durable creation explicitly announces the new work', () => { ); assert.match(render('en'), /Created new work:/u); - assert.match(render('zh'), /已创建新工作:/u); + assert.match(render('zh-CN'), /已创建新工作:/u); }); test('surface projection refresh gate rejects older reads after a newer refresh starts', () => { diff --git a/apps/desktop/src/main/client-settings-confirmation-copy.ts b/apps/desktop/src/main/client-settings-confirmation-copy.ts index 4bec4fd2b9..347e5e3fea 100644 --- a/apps/desktop/src/main/client-settings-confirmation-copy.ts +++ b/apps/desktop/src/main/client-settings-confirmation-copy.ts @@ -24,30 +24,47 @@ export function clientSettingsConfirmation( changes: readonly ClientSettingsChange[], locale: UiLocale, ): { message: string; detail: string; buttons: [string, string] } { - const zh = locale === 'zh'; - const labels: Record = { - theme: ['Theme', '主题'], - palette: ['Palette', '配色'], - uiLocale: ['UI language', '界面语言'], - runComplete: ['Run-complete notifications', '回答完成通知'], - keepSystemAwake: ['Keep system awake', '保持系统唤醒'], + const labels: Record = { + theme: ['Theme', '主题', '主題', '테마'], + palette: ['Palette', '配色', '色彩配置', '팔레트'], + uiLocale: ['UI language', '界面语言', '介面語言', 'UI 언어'], + runComplete: ['Run-complete notifications', '回答完成通知', '回答完成通知', '응답 완료 알림'], + keepSystemAwake: ['Keep system awake', '保持系统唤醒', '保持系統喚醒', '시스템 깨우기 유지'], }; + const localeIndex = + locale === 'en' ? 0 : locale === 'zh-CN' ? 1 : locale === 'zh-TW' ? 2 : 3; const value = (input: string | boolean | undefined): string => { - if (!zh) return String(input); - if (input === true) return '开启'; - if (input === false) return '关闭'; + if (locale === 'en') return String(input); + if (input === true) { + return locale === 'zh-CN' ? '开启' : locale === 'zh-TW' ? '開啟' : '켜기'; + } + if (input === false) { + return locale === 'zh-CN' ? '关闭' : locale === 'zh-TW' ? '關閉' : '끄기'; + } return String(input); }; return { - message: zh - ? '允许 Maka 更新此客户端的设置吗?' - : "Allow Maka to update this client's settings?", + message: + locale === 'en' + ? "Allow Maka to update this client's settings?" + : locale === 'zh-CN' + ? '允许 Maka 更新此客户端的设置吗?' + : locale === 'zh-TW' + ? '允許 Maka 更新此用戶端的設定嗎?' + : 'Maka가 이 클라이언트 설정을 업데이트하도록 허용할까요?', detail: changes .map( (change) => - `${labels[change.key][zh ? 1 : 0]}: ${value(change.current)} → ${value(change.next)}`, + `${labels[change.key][localeIndex]}: ${value(change.current)} → ${value(change.next)}`, ) .join('\n'), - buttons: zh ? ['应用更改', '取消'] : ['Apply changes', 'Cancel'], + buttons: + locale === 'en' + ? ['Apply changes', 'Cancel'] + : locale === 'zh-CN' + ? ['应用更改', '取消'] + : locale === 'zh-TW' + ? ['套用變更', '取消'] + : ['변경 적용', '취소'], }; } diff --git a/apps/desktop/src/main/computer-use/status-item.ts b/apps/desktop/src/main/computer-use/status-item.ts index 0506a4d5a9..4d466ae734 100644 --- a/apps/desktop/src/main/computer-use/status-item.ts +++ b/apps/desktop/src/main/computer-use/status-item.ts @@ -132,16 +132,26 @@ interface StatusItemCopy { } const COPY: UiCatalog = { - zh: { + 'zh-CN': { stopUsing: (appName) => `停止操作 ${appName}`, stopUnnamed: '停止 Computer Use', empty: '没有正在进行的任务', }, + 'zh-TW': { + stopUsing: (appName) => `停止操作 ${appName}`, + stopUnnamed: '停止 Computer Use', + empty: '沒有正在進行的任務', + }, en: { stopUsing: (appName) => `Stop Using ${appName}`, stopUnnamed: 'Stop Computer Use', empty: 'No Active Sessions', }, + ko: { + stopUsing: (appName) => `${appName} 사용 중지`, + stopUnnamed: 'Computer Use 중지', + empty: '활성 세션 없음', + }, }; function defaultResolveLocale(): UiLocale { diff --git a/apps/desktop/src/main/config-transfer-service.ts b/apps/desktop/src/main/config-transfer-service.ts index a66cc53701..326ddc1d60 100644 --- a/apps/desktop/src/main/config-transfer-service.ts +++ b/apps/desktop/src/main/config-transfer-service.ts @@ -18,6 +18,7 @@ */ import type { AppSettings, UpdateAppSettingsInput } from '@maka/core/settings'; +import { normalizeUiLocalePreference } from '@maka/core/ui-locale'; import { reconcileConnectionAfterEnabledModelsChange, type LlmConnection, @@ -101,7 +102,21 @@ export async function applyConfigImport( } if (bundle.data.settings && typeof bundle.data.settings === 'object') { - await deps.settingsStore.update(bundle.data.settings as unknown as UpdateAppSettingsInput); + const patch = bundle.data.settings as unknown as UpdateAppSettingsInput; + const importedPersonalization = patch.personalization as + | (NonNullable & { uiLocale?: unknown }) + | undefined; + await deps.settingsStore.update( + importedPersonalization && Object.hasOwn(importedPersonalization, 'uiLocale') + ? { + ...patch, + personalization: { + ...importedPersonalization, + uiLocale: normalizeUiLocalePreference(importedPersonalization.uiLocale), + }, + } + : patch, + ); result.settings = { applied: true }; } diff --git a/apps/desktop/src/main/desktop-locale-authority.ts b/apps/desktop/src/main/desktop-locale-authority.ts index 8690d2ce73..2e2f423b55 100644 --- a/apps/desktop/src/main/desktop-locale-authority.ts +++ b/apps/desktop/src/main/desktop-locale-authority.ts @@ -18,6 +18,7 @@ */ import { + normalizeUiLocalePreference, resolveSystemUiLocale, resolveUiLocale, type UiLocale, @@ -65,7 +66,7 @@ export function createDesktopLocaleAuthority(input: { }; const observe = (settings: LocaleSettings): UiLocale => { observation += 1; - preference = settings.personalization.uiLocale; + preference = normalizeUiLocalePreference(settings.personalization.uiLocale); return publish(); }; return Object.freeze({ @@ -79,7 +80,9 @@ export function createDesktopLocaleAuthority(input: { const request = ++observation; try { const settings = await input.readSettings(); - if (request === observation) preference = settings.personalization.uiLocale; + if (request === observation) { + preference = normalizeUiLocalePreference(settings.personalization.uiLocale); + } } catch { // Locale presentation must not block startup or an unrelated native action. } diff --git a/apps/desktop/src/main/e2e-fixture.ts b/apps/desktop/src/main/e2e-fixture.ts index e61e89f1c7..6b8d3fffd6 100644 --- a/apps/desktop/src/main/e2e-fixture.ts +++ b/apps/desktop/src/main/e2e-fixture.ts @@ -129,7 +129,10 @@ function parseThemeFlag(raw: string | undefined): 'light' | 'dark' | 'auto' | nu function parseLocaleFlag(raw: string | undefined): UiLocale | null { const normalized = raw?.trim().toLowerCase(); - return normalized === 'zh' || normalized === 'en' ? normalized : null; + if (normalized === 'zh-cn') return 'zh-CN'; + if (normalized === 'zh-tw') return 'zh-TW'; + if (normalized === 'ko' || normalized === 'ko-kr') return 'ko'; + return normalized === 'en' ? 'en' : null; } function parseTimezoneFlag(raw: string | undefined): string | null { diff --git a/apps/desktop/src/main/native-diagnostic-dialog.ts b/apps/desktop/src/main/native-diagnostic-dialog.ts index 9e848805b7..b5f06d651f 100644 --- a/apps/desktop/src/main/native-diagnostic-dialog.ts +++ b/apps/desktop/src/main/native-diagnostic-dialog.ts @@ -192,12 +192,24 @@ const DIALOG_COPY = { copied: 'Diagnostics copied. You can paste them into an issue report.', copyFailed: 'Could not copy diagnostics.', }, - zh: { + 'zh-CN': { copy: '复制诊断信息', copyAgain: '再次复制', copied: '诊断信息已复制,可直接粘贴到问题报告中。', copyFailed: '无法复制诊断信息。', }, + 'zh-TW': { + copy: '複製診斷資訊', + copyAgain: '再次複製', + copied: '診斷資訊已複製,可直接貼上到問題報告中。', + copyFailed: '無法複製診斷資訊。', + }, + ko: { + copy: '진단 정보 복사', + copyAgain: '다시 복사', + copied: '진단 정보가 복사되었습니다. 이슈 보고서에 붙여넣을 수 있습니다.', + copyFailed: '진단 정보를 복사할 수 없습니다.', + }, } as const; const FATAL_STARTUP_COPY = { @@ -207,12 +219,24 @@ const FATAL_STARTUP_COPY = { unknownError: 'An unknown startup error occurred.', exit: 'Exit', }, - zh: { + 'zh-CN': { title: 'Maka 启动失败', message: 'Maka 无法完成启动。', unknownError: '启动时发生未知错误。', exit: '退出', }, + 'zh-TW': { + title: 'Maka 啟動失敗', + message: 'Maka 無法完成啟動。', + unknownError: '啟動時發生未知錯誤。', + exit: '退出', + }, + ko: { + title: 'Maka 시작 실패', + message: 'Maka가 시작을 완료하지 못했습니다.', + unknownError: '알 수 없는 시작 오류가 발생했습니다.', + exit: '종료', + }, } as const; const MAIN_RENDERER_GONE_COPY = { @@ -223,13 +247,27 @@ const MAIN_RENDERER_GONE_COPY = { relaunch: 'Relaunch', exit: 'Exit', }, - zh: { + 'zh-CN': { title: 'Maka 需要恢复', message: 'Maka 界面意外停止运行。', detail: '重新启动 Maka 以继续,或退出后稍后再打开。', relaunch: '重新启动', exit: '退出', }, + 'zh-TW': { + title: 'Maka 需要恢復', + message: 'Maka 介面意外停止執行。', + detail: '重新啟動 Maka 以繼續,或退出後稍後再開啟。', + relaunch: '重新啟動', + exit: '退出', + }, + ko: { + title: 'Maka 복구 필요', + message: 'Maka 인터페이스가 예기치 않게 중지되었습니다.', + detail: '계속하려면 Maka를 다시 시작하거나, 종료한 뒤 나중에 다시 여세요.', + relaunch: '다시 시작', + exit: '종료', + }, } as const; const RUNTIME_HOST_STARTUP_RECOVERY_COPY = { @@ -245,7 +283,7 @@ const RUNTIME_HOST_STARTUP_RECOVERY_COPY = { repairAndRestart: 'Repair and Restart Host', exit: 'Exit', }, - zh: { + 'zh-CN': { title: 'Maka 需要修复 Runtime Host', message: '管理此工作区的 Runtime Host 无法启动。', detail: @@ -256,4 +294,27 @@ const RUNTIME_HOST_STARTUP_RECOVERY_COPY = { repairAndRestart: '修复并重启 Host', exit: '退出', }, + 'zh-TW': { + title: 'Maka 需要修復 Runtime Host', + message: '管理此工作區的 Runtime Host 無法啟動。', + detail: + 'Maka 可以修復此 Desktop 選擇的受管理 Runtime Host。工作區、Host 身分、認證資料和設定都會保留。即使無法確認自動更新相容性,修復也可能使用此 Desktop 選擇的版本取代目前 Host。', + activeTasks: 'Host 可能仍有正在執行的任務。繼續會先中斷這些任務,再重新啟動 Host。', + repairFailed: '上一次修復未能完成:', + repair: '修復 Runtime Host', + repairAndRestart: '修復並重新啟動 Host', + exit: '退出', + }, + ko: { + title: 'Maka가 Runtime Host 복구가 필요합니다', + message: '이 작업 공간의 Runtime Host를 시작할 수 없습니다.', + detail: + 'Maka가 이 Desktop이 선택한 관리형 Runtime Host를 복구할 수 있습니다. 작업 공간, Host ID, 자격 증명, 설정은 유지됩니다. 자동 업데이트 호환성을 확인할 수 없어도 복구 시 이 Desktop이 선택한 버전으로 Host가 교체될 수 있습니다.', + activeTasks: + 'Host에 아직 실행 중인 작업이 있을 수 있습니다. 계속하면 Host가 다시 시작되기 전에 해당 작업이 중단될 수 있습니다.', + repairFailed: '이전 복구 시도가 완료되지 않았습니다:', + repair: 'Runtime Host 복구', + repairAndRestart: '복구 후 Host 다시 시작', + exit: '종료', + }, } as const; diff --git a/apps/desktop/src/main/notifications-policy.ts b/apps/desktop/src/main/notifications-policy.ts index efd72ba96b..97093473c0 100644 --- a/apps/desktop/src/main/notifications-policy.ts +++ b/apps/desktop/src/main/notifications-policy.ts @@ -86,6 +86,16 @@ export function runNotificationCopy( ? { title: 'Conversation error', body: 'This response did not finish. Click to view details.' } : { title: 'Response ready', body: 'Maka finished this response. Click to view it.' }; } + if (locale === 'zh-TW') { + return kind === 'errored' + ? { title: '任務發生錯誤', body: '本次回答未能完成,按一下以檢視詳細資料。' } + : { title: '回答已產生', body: 'Maka 已完成本次回答,按一下以檢視。' }; + } + if (locale === 'ko') { + return kind === 'errored' + ? { title: '대화 오류', body: '이 응답이 완료되지 않았습니다. 클릭하여 자세히 보기.' } + : { title: '응답 준비됨', body: 'Maka가 이 응답을 완료했습니다. 클릭하여 보기.' }; + } if (kind === 'errored') { return { title: '任务出错', body: '本轮回答未能完成,点击查看详情。' }; } diff --git a/apps/desktop/src/main/permission-overlay/permission-overlay-copy.ts b/apps/desktop/src/main/permission-overlay/permission-overlay-copy.ts index afb7814138..f369c008ff 100644 --- a/apps/desktop/src/main/permission-overlay/permission-overlay-copy.ts +++ b/apps/desktop/src/main/permission-overlay/permission-overlay-copy.ts @@ -50,7 +50,7 @@ export interface PermissionOverlayCopy { type Catalog = UiCatalog>; const COPY: Catalog = { - zh: { + 'zh-CN': { accessibility: { headline: (appName) => `把 ${appName} 拖到上面的列表里,即可开启「辅助功能」`, fallback: '也可以在系统设置里点 + 号,从「应用程序」中选择本 App。', @@ -69,6 +69,25 @@ const COPY: Catalog = { noBundle: '当前不是以 .app 方式运行,无法拖拽。请在系统设置里手动添加。', }, }, + 'zh-TW': { + accessibility: { + headline: (appName) => `把 ${appName} 拖到上面的列表裡,即可開啟「輔助功能」`, + fallback: '也可以在系統設定裡點 + 號,從「應用程式」中選擇本 App。', + granted: '輔助功能已開啟', + dismiss: '關閉', + dragHint: '拖我', + noBundle: '目前不是以 .app 方式執行,無法拖拽。請在系統設定裡手動新增。', + }, + screen_recording: { + headline: (appName) => `把 ${appName} 拖到上面的列表裡,即可開啟「螢幕錄製」`, + fallback: '也可以在系統設定裡點 + 號,從「應用程式」中選擇本 App。', + granted: '螢幕錄製已開啟', + dismiss: '關閉', + dragHint: '拖我', + restartHint: '若仍顯示未授權,需要重啟 App —— macOS 會快取上一次的拒絕結果。', + noBundle: '目前不是以 .app 方式執行,無法拖拽。請在系統設定裡手動新增。', + }, + }, en: { accessibility: { headline: (appName) => `Drag ${appName} into the list above to allow Accessibility`, @@ -88,6 +107,25 @@ const COPY: Catalog = { noBundle: 'Not running from a .app bundle, so there is nothing to drag. Add it manually in System Settings.', }, }, + ko: { + accessibility: { + headline: (appName) => `위 목록에 ${appName}을(를) 끌어다 놓으면 손쉬운 사용 권한이 허용됩니다`, + fallback: '시스템 설정에서 +를 누르고 응용 프로그램에서 이 앱을 선택할 수도 있습니다.', + granted: '손쉬운 사용 권한이 허용되었습니다', + dismiss: '닫기', + dragHint: '끌기', + noBundle: '.app 번들로 실행 중이 아니어서 끌 수 없습니다. 시스템 설정에서 수동으로 추가하세요.', + }, + screen_recording: { + headline: (appName) => `위 목록에 ${appName}을(를) 끌어다 놓으면 화면 기록 권한이 허용됩니다`, + fallback: '시스템 설정에서 +를 누르고 응용 프로그램에서 이 앱을 선택할 수도 있습니다.', + granted: '화면 기록 권한이 허용되었습니다', + dismiss: '닫기', + dragHint: '끌기', + restartHint: '여전히 거부된 것으로 표시되면 앱을 다시 시작하세요. macOS가 이전 거부 결과를 캐시합니다.', + noBundle: '.app 번들로 실행 중이 아니어서 끌 수 없습니다. 시스템 설정에서 수동으로 추가하세요.', + }, + }, }; export function getPermissionOverlayCopy( diff --git a/apps/desktop/src/main/project-picker-copy.ts b/apps/desktop/src/main/project-picker-copy.ts index 80e2a347be..5caf231878 100644 --- a/apps/desktop/src/main/project-picker-copy.ts +++ b/apps/desktop/src/main/project-picker-copy.ts @@ -20,5 +20,8 @@ import type { UiLocale } from '@maka/core/ui-locale'; export function projectPickerTitle(locale: UiLocale): string { - return locale === 'zh' ? '添加项目' : 'Add project'; + if (locale === 'zh-CN') return '添加项目'; + if (locale === 'zh-TW') return '新增專案'; + if (locale === 'ko') return '프로젝트 추가'; + return 'Add project'; } diff --git a/apps/desktop/src/main/runtime-host-boot-copy.ts b/apps/desktop/src/main/runtime-host-boot-copy.ts new file mode 100644 index 0000000000..d60474c84c --- /dev/null +++ b/apps/desktop/src/main/runtime-host-boot-copy.ts @@ -0,0 +1,106 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; + +interface StartupRecoveryCopy { + storageRoot: { + title: string; + message: string; + detail(workspaceRoot: string): string; + buttons: [string, string]; + }; + runtimeHost: { + title: string; + message(profileName: string): string; + detail(message: string): string; + buttons: [string, string, string]; + }; +} + +const STARTUP_RECOVERY_COPY = { + 'zh-CN': { + storageRoot: { + title: 'Maka 工作区需要修复', + message: 'Maka 无法验证这个工作区。', + detail: (workspaceRoot) => + `系统中的磁盘标识可能发生了变化。仅当这是本机原来的 Maka 工作区、而不是复制出的工作区时,才选择修复。\n\n${workspaceRoot}`, + buttons: ['修复工作区', '退出'], + }, + runtimeHost: { + title: '默认 Runtime Host 无法连接', + message: (profileName) => `无法连接 ${profileName}`, + detail: (message) => + `${message}\n\n你可以重试、改用 Local 作为默认 Host,或保持当前选择并稍后在设置中处理。`, + buttons: ['重试', '改用 Local', '保持离线'], + }, + }, + 'zh-TW': { + storageRoot: { + title: 'Maka 工作區需要修復', + message: 'Maka 無法驗證這個工作區。', + detail: (workspaceRoot) => + `系統中的磁碟識別碼可能已變更。只有當這是本機原本的 Maka 工作區,而不是複製的工作區時,才選擇修復。\n\n${workspaceRoot}`, + buttons: ['修復工作區', '離開'], + }, + runtimeHost: { + title: '預設 Runtime Host 無法連線', + message: (profileName) => `無法連線 ${profileName}`, + detail: (message) => + `${message}\n\n你可以重試、改用 Local 作為預設 Host,或保留目前選擇並稍後在設定中處理。`, + buttons: ['重試', '改用 Local', '保持離線'], + }, + }, + en: { + storageRoot: { + title: 'Maka workspace needs repair', + message: 'Maka cannot verify this workspace.', + detail: (workspaceRoot) => + `The disk identity may have changed. Repair only if this is the original Maka workspace on this computer, not a copied workspace.\n\n${workspaceRoot}`, + buttons: ['Repair Workspace', 'Exit'], + }, + runtimeHost: { + title: 'Default Runtime Host is unavailable', + message: (profileName) => `Could not connect to ${profileName}`, + detail: (message) => + `${message}\n\nRetry, use Local as the default Host, or keep the current selection and resolve it later in Settings.`, + buttons: ['Retry', 'Use Local', 'Keep Offline'], + }, + }, + ko: { + storageRoot: { + title: 'Maka 작업 공간을 복구해야 합니다', + message: 'Maka가 이 작업 공간을 확인할 수 없습니다.', + detail: (workspaceRoot) => + `시스템의 디스크 식별자가 변경되었을 수 있습니다. 원본 Maka 작업 공간인 경우에만 복구하세요. 복사된 작업 공간이면 복구하지 마세요.\n\n${workspaceRoot}`, + buttons: ['작업 공간 복구', '종료'], + }, + runtimeHost: { + title: '기본 Runtime Host에 연결할 수 없습니다', + message: (profileName) => `${profileName}에 연결할 수 없습니다`, + detail: (message) => + `${message}\n\n다시 시도하거나 Local을 기본 Host로 사용하거나, 현재 선택을 유지한 뒤 나중에 설정에서 처리할 수 있습니다.`, + buttons: ['다시 시도', 'Local 사용', '오프라인 유지'], + }, + }, +} satisfies UiCatalog; + +export function getStartupRecoveryCopy(locale: UiLocale): StartupRecoveryCopy { + return STARTUP_RECOVERY_COPY[locale]; +} diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 21d1726c53..19c109a7bc 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -119,6 +119,7 @@ import { showMessageBoxWithDiagnostics, showRuntimeHostStartupRecoveryDialog, } from "./native-diagnostic-dialog.js"; +import { getStartupRecoveryCopy } from "./runtime-host-boot-copy.js"; import { resolveDesktopSessionWorkspace, } from "./new-session-project.js"; @@ -430,7 +431,7 @@ const mainWindowController = createMainWindowController({ details: `Exit code: ${details.exitCode}`, }); const decision = await showMainRendererProcessGoneDialog({ - locale: desktopLocale.current(), + locale: await desktopLocale.resolve(), copyDiagnostics: () => copyDesktopDiagnosticReport(desktopDiagnostics, diagnosticInput), showMessageBox: (options) => dialog.showMessageBox(options), @@ -1909,26 +1910,20 @@ async function confirmDesktopStorageRootRepair( console.log( "[storage-root] root-identity conflict; parking at repair dialog", ); - const isChinese = - resolveSystemUiLocale(app.getPreferredSystemLanguages()) === "zh"; + const locale = resolveSystemUiLocale(app.getPreferredSystemLanguages()); + const copy = getStartupRecoveryCopy(locale).storageRoot; const { response } = await showStartupDiagnosticDialog( { type: "warning", - title: isChinese ? "Maka 工作区需要修复" : "Maka workspace needs repair", - message: isChinese - ? "Maka 无法验证这个工作区。" - : "Maka cannot verify this workspace.", - detail: isChinese - ? `系统中的磁盘标识可能发生了变化。仅当这是本机原来的 Maka 工作区、而不是复制出的工作区时,才选择修复。\n\n${workspaceRoot}` - : `The disk identity may have changed. Repair only if this is the original Maka workspace on this computer, not a copied workspace.\n\n${workspaceRoot}`, - buttons: isChinese - ? ["修复工作区", "退出"] - : ["Repair Workspace", "Exit"], + title: copy.title, + message: copy.message, + detail: copy.detail(workspaceRoot), + buttons: copy.buttons, defaultId: 1, cancelId: 1, noLink: true, }, - isChinese ? "zh" : "en", + locale, ); return response === 0; } @@ -1937,28 +1932,20 @@ async function promptForDefaultRuntimeHostRecovery(input: { readonly profileName: string; readonly error: Error; }): Promise<"retry" | "use_local" | "keep_offline"> { - const isChinese = - resolveSystemUiLocale(app.getPreferredSystemLanguages()) === "zh"; + const locale = await desktopLocale.resolve(); + const copy = getStartupRecoveryCopy(locale).runtimeHost; const { response } = await showStartupDiagnosticDialog( { type: "warning", - title: isChinese - ? "默认 Runtime Host 无法连接" - : "Default Runtime Host is unavailable", - message: isChinese - ? `无法连接 ${input.profileName}` - : `Could not connect to ${input.profileName}`, - detail: isChinese - ? `${input.error.message}\n\n你可以重试、改用 Local 作为默认 Host,或保持当前选择并稍后在设置中处理。` - : `${input.error.message}\n\nRetry, use Local as the default Host, or keep the current selection and resolve it later in Settings.`, - buttons: isChinese - ? ["重试", "改用 Local", "保持离线"] - : ["Retry", "Use Local", "Keep Offline"], + title: copy.title, + message: copy.message(input.profileName), + detail: copy.detail(input.error.message), + buttons: copy.buttons, defaultId: 0, cancelId: 2, noLink: true, }, - isChinese ? "zh" : "en", + locale, ); return response === 0 ? "retry" : response === 1 ? "use_local" : "keep_offline"; } diff --git a/apps/desktop/src/main/runtime-host-quit-copy.ts b/apps/desktop/src/main/runtime-host-quit-copy.ts index f9bfff703d..4ecc3ee160 100644 --- a/apps/desktop/src/main/runtime-host-quit-copy.ts +++ b/apps/desktop/src/main/runtime-host-quit-copy.ts @@ -63,7 +63,7 @@ const COPY = { cause: 'Cause', button: 'OK', }, - zh: { + 'zh-CN': { title: '无法安全退出 Maka', message: '本地 Runtime Host 未能安全停止,Maka 仍在运行。', detail: '退出已取消。请重试;如果问题持续存在,请查看诊断信息。', @@ -72,4 +72,23 @@ const COPY = { cause: '原因', button: '好', }, + 'zh-TW': { + title: '無法安全退出 Maka', + message: '本地 Runtime Host 未能安全停止,Maka 仍在執行。', + detail: '退出已取消。請重試;如果問題持續存在,請檢視診斷資訊。', + process: (pid: number) => `Runtime Host 程序 PID:${pid}`, + manual: '如果重試仍然失敗,請先確認沒有需要保留的執行,再透過作業系統的程序管理工具停止該 PID。', + cause: '原因', + button: '好', + }, + ko: { + title: 'Maka를 안전하게 종료할 수 없습니다', + message: '로컬 Runtime Host를 안전하게 중지하지 못했습니다. Maka가 아직 실행 중입니다.', + detail: '종료가 취소되었습니다. 다시 시도하거나 문제가 계속되면 진단 정보를 확인하세요.', + process: (pid: number) => `Runtime Host 프로세스 PID: ${pid}`, + manual: + '다시 시도해도 실패하면, 보존할 실행이 없는지 확인한 뒤 운영체제의 프로세스 관리 도구로 해당 PID를 중지하세요.', + cause: '원인', + button: '확인', + }, } as const; diff --git a/apps/desktop/src/main/runtime-host-upgrade-copy.ts b/apps/desktop/src/main/runtime-host-upgrade-copy.ts index bcaa1a24ef..dfff9fe73b 100644 --- a/apps/desktop/src/main/runtime-host-upgrade-copy.ts +++ b/apps/desktop/src/main/runtime-host-upgrade-copy.ts @@ -156,7 +156,7 @@ const UPGRADE_COPY = { other: 'Other background activity', }, }, - zh: { + 'zh-CN': { title: '旧版 Runtime Host 正在运行', message: '另一个 Runtime Host 进程仍占用此工作区。', restart: '重启 Runtime Host', @@ -179,4 +179,49 @@ const UPGRADE_COPY = { resource: 'Runtime 资源', graph: 'Agent Graph', other: '其他后台活动', }, }, + 'zh-TW': { + title: '舊版 Runtime Host 正在執行', + message: '另一個 Runtime Host 程序仍佔用此工作區。', + restart: '重啟 Runtime Host', + replace: '停止 Host 並繼續', + wait: '等待', + cancel: '取消啟動', + uptime: (n: number) => `已執行約 ${n} 分鐘`, + connections: (n: number) => `仍有 ${n} 個其他客戶端連線`, + operations: (n: number) => `有 ${n} 個操作正在執行`, + unknownActivity: '此 Host 版本無法報告後臺活動。', + processId: (pid: number) => `程序 ID (PID):${pid}`, + restartWarning: '重啟會保留持久化狀態,但可能中斷正在進行的外部工作。', + replaceWarning: '停止 Host 會保留持久化狀態,但可能中斷正在進行的外部工作。', + replaceExplanation: 'Maka 將停止並安全替換此 Host,然後繼續啟動。', + exitOwner: (pid: number) => `請使用系統程序管理工具結束程序 ${pid},以便安全替換此 Host。`, + waitExplanation: '若選擇等待,目前 Host 退出後 Maka 將自動繼續。', + activity: { + goal: '目標', scheduledTask: '計劃任務', dailyReview: '每日回顧', execution: '活動執行', + resource: 'Runtime 資源', graph: 'Agent Graph', other: '其他後臺活動', + }, + }, + ko: { + title: '이전 Runtime Host가 실행 중입니다', + message: '다른 Runtime Host 프로세스가 이 작업 공간을 사용 중입니다.', + restart: 'Runtime Host 다시 시작', + replace: 'Host 중지 후 계속', + wait: '대기', + cancel: '시작 취소', + uptime: (n: number) => `약 ${n}분 실행 중`, + connections: (n: number) => `다른 클라이언트 ${n}개가 아직 연결되어 있습니다`, + operations: (n: number) => `실행 중인 작업 ${n}개`, + unknownActivity: '이 Host 버전은 백그라운드 활동을 보고할 수 없습니다.', + processId: (pid: number) => `프로세스 ID (PID): ${pid}`, + restartWarning: '다시 시작해도 영구 상태는 유지되지만, 진행 중인 외부 작업이 중단될 수 있습니다.', + replaceWarning: '중지해도 영구 상태는 유지되지만, 진행 중인 외부 작업이 중단될 수 있습니다.', + replaceExplanation: 'Maka가 이 Host를 중지하고 안전하게 교체한 뒤 시작을 계속합니다.', + exitOwner: (pid: number) => + `시스템 프로세스 관리 도구로 프로세스 ${pid}를 종료하면 이 Host를 안전하게 교체할 수 있습니다.`, + waitExplanation: '대기하면 이 Host가 종료된 뒤 Maka가 자동으로 계속합니다.', + activity: { + goal: '목표', scheduledTask: '예약 작업', dailyReview: '일일 리뷰', execution: '활성 실행', + resource: 'Runtime 리소스', graph: 'Agent Graph', other: '기타 백그라운드 활동', + }, + }, } as const; diff --git a/apps/desktop/src/preload/runtime-host-session-catalog.ts b/apps/desktop/src/preload/runtime-host-session-catalog.ts index d4755a8949..445f527572 100644 --- a/apps/desktop/src/preload/runtime-host-session-catalog.ts +++ b/apps/desktop/src/preload/runtime-host-session-catalog.ts @@ -17,7 +17,7 @@ * under the License. */ -import type { DesktopSessionSummary } from './bridge-contract.js'; +import type { DesktopSessionSummary } from '../shared/desktop-session-projection.js'; export interface RuntimeHostSessionCatalogRequest { readonly hostId: string; diff --git a/apps/desktop/src/renderer/agent-graph-panel.tsx b/apps/desktop/src/renderer/agent-graph-panel.tsx index 32e8a38810..48a3bbcda6 100644 --- a/apps/desktop/src/renderer/agent-graph-panel.tsx +++ b/apps/desktop/src/renderer/agent-graph-panel.tsx @@ -77,7 +77,7 @@ type GraphPanelCopy = { }; export function getAgentGraphPanelCopy(locale: UiLocale): GraphPanelCopy { - if (locale === 'zh') { + if (locale === 'zh-CN') { return { title: 'Agent Graph', loading: '正在读取 Graph 状态…', @@ -125,6 +125,54 @@ export function getAgentGraphPanelCopy(locale: UiLocale): GraphPanelCopy { wait: waitReasonZh, }; } + if (locale === 'zh-TW') { + return { + title: 'Agent Graph', + loading: '正在讀取 Graph 狀態…', + retry: '重試', + collapse: '收起 Agent Graph', + expand: '展開 Agent Graph', + dismiss: '關閉 Agent Graph', + stop: '停止 Graph', + stopping: '停止中…', + stopFailed: '停止 Graph 失敗,請重試。', + loadFailed: 'Graph 狀態重新整理失敗。', + openSession: '開啟子任務', + operators: 'Operators', + selectedResults: '已選取結果', + epoch: 'Graph 執行輪次', + currentEpoch: '目前', + historicalEpoch: '歷史記錄(唯讀)', + cappedEpochs: (count) => `僅顯示最近 ${count} 次執行`, + noOperators: '等待主 Agent 建立 operator…', + hiddenOperators: (count) => `另有 ${count} 個 operator`, + progress: (settled, total, hasOmitted) => + hasOmitted ? `可見項目中 ${settled}/${total} 已結束` : `${settled}/${total} 已結束`, + status: (status) => + ({ + empty: '等待排程', + active: '執行中', + closing: '收尾中', + waiting: '等待中', + stopped: '已停止', + failed: '失敗', + completed: '已完成', + })[status], + operatorStatus: (status) => + ({ + not_started: '尚未啟動', + waiting: '等待', + runnable: '可執行', + running: '執行中', + blocked: '受阻', + completed: '完成', + failed: '失敗', + aborted: '已中止', + cancelled: '已取消', + })[status], + wait: waitReasonZhTw, + }; + } return { title: 'Agent Graph', loading: 'Loading graph state…', @@ -566,3 +614,15 @@ function waitReasonZh(operator: AgentGraphClientOperator): string | undefined { } return `等待 ${wait.operatorId} 结束`; } + +function waitReasonZhTw(operator: AgentGraphClientOperator): string | undefined { + const wait = firstWait(operator); + if (!wait) return undefined; + if (wait.kind === 'input_route') { + return `等待 ${wait.upstreamOperatorIds.join('、')} 的輸入`; + } + if (wait.kind === 'activation_missing') { + return `等待 ${wait.operatorId} 啟用`; + } + return `等待 ${wait.operatorId} 結束`; +} diff --git a/apps/desktop/src/renderer/app-shell-command-actions.ts b/apps/desktop/src/renderer/app-shell-command-actions.ts index 0cfcb58143..f420751a91 100644 --- a/apps/desktop/src/renderer/app-shell-command-actions.ts +++ b/apps/desktop/src/renderer/app-shell-command-actions.ts @@ -299,7 +299,16 @@ export function buildAppShellCommandList( window.maka.memory.openFile(host), ); if (!result.ok) { - toastApi.error(copy.memoryOpenFailedTitle, result.message, undefined, diagnosticTarget); + toastApi.error( + copy.memoryOpenFailedTitle, + commandPaletteActionErrorMessage( + new Error(result.message), + copy.memoryOpenFallback, + options.uiLocale, + ), + undefined, + diagnosticTarget, + ); } } catch (err) { toastApi.error( diff --git a/apps/desktop/src/renderer/app-shell-copy.ts b/apps/desktop/src/renderer/app-shell-copy.ts index 2f15529095..8e5d7c82fb 100644 --- a/apps/desktop/src/renderer/app-shell-copy.ts +++ b/apps/desktop/src/renderer/app-shell-copy.ts @@ -20,7 +20,7 @@ import type { ConnectionTestResult } from '@maka/core/llm-connections'; import type { TextFileImportPreflightFailureReason } from '@maka/core/text-file-import'; import type { UiLocale } from '@maka/core/ui-locale'; -import { generalizedErrorMessage, generalizedErrorMessageChinese } from '@maka/core/redaction'; +import { generalizedErrorMessageForLocale } from '@maka/core/redaction'; import { getShellCopy } from './locales/shell-copy.js'; const SESSION_READ_MESSAGES_ERROR_MARKER = 'MAKA_SESSION_READ_MESSAGES_ERROR:'; @@ -36,13 +36,13 @@ export function messageRefreshErrorMessage(error: unknown, locale: UiLocale): st function sessionMessageErrorMessage(error: unknown, fallback: string, locale: UiLocale): string { const raw = error instanceof Error ? error.message : String(error); const markerIndex = raw.indexOf(SESSION_READ_MESSAGES_ERROR_MARKER); - if (markerIndex < 0 || locale === 'en') return localizedErrorMessage(error, fallback, locale); + if (markerIndex < 0 || locale !== 'zh-CN') return localizedErrorMessage(error, fallback, locale); const marked = raw.slice(markerIndex + SESSION_READ_MESSAGES_ERROR_MARKER.length).trim(); return marked.split(/\r?\n/, 1)[0]?.trim() || fallback; } function localizedErrorMessage(error: unknown, fallback: string, locale: UiLocale): string { - return locale === 'zh' ? generalizedErrorMessageChinese(error, fallback) : generalizedErrorMessage(error, fallback); + return generalizedErrorMessageForLocale(error, fallback, locale); } export function commandPaletteActionErrorMessage(error: unknown, fallback: string, locale: UiLocale): string { diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index 4a9b383fdf..a3c3119d55 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -25,7 +25,6 @@ import type { SessionEvent } from '@maka/core/events'; import type { SessionEventStreamSnapshot } from '@maka/core/session-event-health'; import type { ThemePalette, ThemePreference } from '@maka/core/settings'; import type { UiLocale } from '@maka/core/ui-locale'; -import { generalizedErrorMessageChinese } from '@maka/core/redaction'; import { sessionExpectsEventStream } from '@maka/core/session-event-health'; import { type ShellRunUpdate } from '@maka/core/events'; import type { LiveTurnProjection, NavSelection } from '@maka/ui'; diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index fa27552207..7f93db8150 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -2943,7 +2943,7 @@ function AppShellContent({ {workHubEnabled && navSelection.section === 'sessions' && activeId ? (