From c8f0ecf39ada51143e7b6627825fa31419d9c1aa Mon Sep 17 00:00:00 2001 From: me2seeks Date: Fri, 4 Sep 2026 17:26:19 +0800 Subject: [PATCH 1/4] feat(i18n): add Traditional Chinese locale Add separate zh-CN and zh-TW resolved locales, including Taiwan-localized UI catalogs and system-locale aliases, while preserving the former persisted zh preference as zh-CN. Replace the Desktop Settings language segmented control with the @maka/ui Selector, offering Follow system, Simplified Chinese, Traditional Chinese, and English. Rebased onto latest main; resolved conflicts with the models settings redesign (#4682), the RuntimeInvocation event spine (#4631), the intentional Runtime Host quit (#4709), and the removed layout-tier E2E specs (#4741). Co-authored-by: AcerYue <33564291+AcerYue0@users.noreply.github.com> Co-authored-by: JLee --- apps/desktop/.storybook/preview.tsx | 12 +- .../e2e/accessibility-coverage.spec.ts | 2 +- .../e2e/request-header-row-contract.spec.ts | 2 +- apps/desktop/e2e/zh-tw-locale.spec.ts | 42 ++ apps/desktop/renderer-architecture.json | 13 +- .../__tests__/about-update-status.test.ts | 4 +- ...tive-execution-boundary-read-model.test.ts | 2 +- .../__tests__/agent-graph-panel-copy.test.ts | 30 ++ .../__tests__/command-palette-retired.test.ts | 4 +- .../computer-use-status-item-locale.test.ts | 2 +- .../__tests__/config-transfer-service.test.ts | 21 + .../desktop-locale-authority.test.ts | 57 +- .../__tests__/desktop-native-copy.test.ts | 16 +- .../main/__tests__/e2e-fixture-locale.test.ts | 46 ++ .../main/__tests__/goal-arm-outcome.test.ts | 2 +- .../main/__tests__/health-center-copy.test.ts | 22 +- .../import-tasks-settings-page.test.ts | 4 +- .../__tests__/message-queue-ui-state.test.ts | 2 +- .../__tests__/new-task-staged-content.test.ts | 10 +- .../__tests__/notifications-policy.test.ts | 21 +- .../__tests__/permission-center-copy.test.ts | 2 +- .../src/main/__tests__/plan-mode-copy.test.ts | 2 +- .../provider-connection-status.test.ts | 10 +- .../__tests__/renderer-error-report.test.ts | 2 +- .../__tests__/runtime-host-boot-copy.test.ts | 30 ++ .../__tests__/runtime-host-quit-copy.test.ts | 4 +- .../runtime-host-upgrade-dialog.test.ts | 12 +- .../session-inspector-context-format.test.ts | 2 +- .../session-status-presentation.test.ts | 22 +- .../settings-preferences-copy.test.ts | 74 +++ .../settings-test-result-copy.test.ts | 2 +- .../main/__tests__/streaming-handoff.test.ts | 24 +- .../subscription-result-message.test.ts | 33 ++ .../__tests__/task-readiness-notice.test.ts | 16 +- ...traditional-chinese-peer-mesh-copy.test.ts | 59 ++ .../__tests__/ui-locale-update-gate.test.ts | 18 +- .../__tests__/use-onboarding-snapshot.test.ts | 12 +- .../main/__tests__/use-session-todo.test.ts | 151 ++++++ .../__tests__/workhub-surface-flow.test.ts | 8 +- apps/desktop/src/main/browser-message-box.ts | 6 +- .../main/client-settings-confirmation-copy.ts | 38 +- .../src/main/computer-use/status-item.ts | 7 +- .../src/main/config-transfer-service.ts | 15 +- .../src/main/desktop-locale-authority.ts | 7 +- apps/desktop/src/main/e2e-fixture.ts | 4 +- apps/desktop/src/main/main.ts | 23 +- .../src/main/native-diagnostic-dialog-copy.ts | 51 +- apps/desktop/src/main/notifications-policy.ts | 5 + .../permission-overlay-copy.ts | 21 +- apps/desktop/src/main/project-picker-copy.ts | 4 +- .../src/main/runtime-host-boot-copy.ts | 90 ++++ .../src/main/runtime-host-quit-copy.ts | 18 +- .../src/main/runtime-host-upgrade-copy.ts | 25 +- .../preload/runtime-host-session-catalog.ts | 2 +- .../src/renderer/agent-graph-panel.tsx | 62 ++- apps/desktop/src/renderer/app-shell-copy.ts | 6 +- .../desktop/src/renderer/app-shell-effects.ts | 1 - apps/desktop/src/renderer/app-shell.tsx | 2 +- .../src/renderer/attachment-preflight.ts | 2 +- .../src/renderer/conversation-markdown.ts | 2 +- .../src/renderer/daily-review-actions.ts | 4 +- .../renderer/derive-turn-lineage-badges.ts | 2 +- .../features/connection-settings/index.ts | 1 + .../provider-panel-shared.ts | 30 +- .../settings-provider-copy.ts | 199 ++++++- .../subscription-result-message.ts | 53 ++ .../model/task-readiness-notice.ts | 78 ++- .../features/runtime-host-management/index.ts | 23 +- .../ui/runtime-host-peer-mesh-dialog.tsx | 197 ++++++- .../model/session-project-grouping.ts | 2 +- .../src/renderer/features/workbar/testing.ts | 1 + .../workbar/tools/artifacts/artifact-pane.tsx | 8 +- .../tools/inspector/use-session-trace.ts | 10 +- .../tools/review/session-review-panel.tsx | 6 +- .../tools/side-chat/quote-companion-panel.tsx | 9 +- .../workbar/tools/tasks/use-session-todo.ts | 7 +- .../tools/terminal/session-terminal-panel.tsx | 13 +- .../src/renderer/locales/artifact-copy.ts | 38 +- .../src/renderer/locales/browser-copy.ts | 26 +- .../src/renderer/locales/conversation-copy.ts | 247 ++++++++- .../locales/external-session-import-copy.ts | 58 +- apps/desktop/src/renderer/locales/mcp-copy.ts | 61 ++- .../src/renderer/locales/onboarding-copy.ts | 51 +- .../locales/permission-center-copy.ts | 49 +- .../src/renderer/locales/plan-mode-copy.ts | 21 +- .../locales/session-collaboration-copy.ts | 105 +++- .../src/renderer/locales/settings-bot-copy.ts | 92 +++- .../locales/settings-daily-review-copy.ts | 21 +- .../renderer/locales/settings-data-copy.ts | 29 +- .../renderer/locales/settings-health-copy.ts | 86 ++- .../renderer/locales/settings-memory-copy.ts | 47 +- .../locales/settings-navigation-copy.ts | 28 +- .../locales/settings-preferences-copy.ts | 97 +++- .../locales/settings-projects-copy.ts | 305 ++++++++++- .../renderer/locales/settings-shared-copy.ts | 42 +- .../locales/settings-subagents-copy.ts | 84 ++- .../renderer/locales/settings-tasks-copy.ts | 29 +- .../locales/settings-test-result-copy.ts | 27 +- .../renderer/locales/settings-usage-copy.ts | 24 +- .../locales/settings-web-search-copy.ts | 19 +- .../src/renderer/locales/shell-copy.ts | 508 +++++++++++++++++- .../renderer/locales/shell-remaining-copy.ts | 78 ++- apps/desktop/src/renderer/mcp-catalog.ts | 22 +- .../src/renderer/model-catalog-choices.ts | 2 +- .../src/renderer/model-connection-errors.ts | 6 +- .../renderer/session-error-presentation.ts | 2 +- .../renderer/session-status-presentation.ts | 4 +- .../renderer/settings/about-settings-page.tsx | 10 +- .../renderer/settings/about-update-status.ts | 8 +- .../src/renderer/settings/bot-chat-detail.tsx | 8 +- .../renderer/settings/bot-chat-overview.tsx | 4 +- .../src/renderer/settings/bot-chat-shared.tsx | 4 +- .../settings/bot-onboarding-modal.tsx | 6 +- .../settings/daily-review-settings-page.tsx | 2 +- .../renderer/settings/data-settings-page.tsx | 2 +- .../settings/memory-settings-view-model.ts | 2 +- .../settings/permission-center-page.tsx | 4 +- .../personalization-settings-section.tsx | 23 +- .../renderer/settings/provider-add-form.tsx | 2 +- .../settings/provider-catalog-page.tsx | 5 +- .../settings/provider-connection-status.ts | 2 +- .../settings/provider-display-copy.ts | 183 ++++--- .../settings/provider-oauth-section.tsx | 2 +- .../src/renderer/settings/providers-panel.tsx | 3 +- .../runtime-host-management-dialog.tsx | 28 +- .../runtime-host-profiles-section.tsx | 42 +- .../renderer/settings/settings-error-copy.ts | 9 +- .../use-memory-settings-controller.ts | 2 +- .../renderer/settings/use-oauth-login-flow.ts | 37 +- .../src/renderer/turn-footer-actions.ts | 2 +- .../src/renderer/use-onboarding-snapshot.ts | 4 +- apps/desktop/src/renderer/workhub-surface.tsx | 74 ++- .../stories/agent-graph-panel.stories.tsx | 2 +- apps/desktop/stories/app-shell.stories.tsx | 2 +- .../stories/error-boundary.stories.tsx | 3 +- .../settings/settings-pages.stories.tsx | 8 +- docs/astryx-surface-file-inventory.md | 2 +- .../cli/src/__tests__/cli-ui-locale.test.ts | 42 +- packages/cli/src/__tests__/cli.test.ts | 2 +- .../cli/src/__tests__/pi-transcript.test.ts | 10 +- .../src/__tests__/pi-tui-mcp-status.test.ts | 270 +++++++--- .../cli/src/__tests__/pi-tui-runner.test.ts | 16 +- .../src/__tests__/tui-copy-catalog.test.ts | 91 ++-- .../src/__tests__/tui-copy-command.test.ts | 2 +- .../__tests__/tui-primary-guidance.test.ts | 10 +- .../src/__tests__/tui-session-status.test.ts | 4 +- packages/cli/src/cli-ui-locale.ts | 10 +- packages/cli/src/pi-tui-mcp-status.ts | 16 +- packages/cli/src/tui-copy-catalog.ts | 361 ++++++++++++- packages/core/src/__tests__/redaction.test.ts | 37 ++ .../core/src/__tests__/relative-time.test.ts | 10 +- packages/core/src/__tests__/settings.test.ts | 26 + .../src/__tests__/tool-quiet-preview.test.ts | 16 +- packages/core/src/__tests__/ui-locale.test.ts | 64 ++- packages/core/src/redaction.ts | 87 ++- packages/core/src/relative-time.ts | 11 +- packages/core/src/settings.ts | 10 +- packages/core/src/tool-quiet-preview.ts | 20 +- packages/core/src/ui-locale.ts | 24 +- .../src/__tests__/conversation-copy.test.ts | 15 +- .../src/__tests__/daily-review-copy.test.ts | 26 + .../__tests__/live-turn-projection.test.ts | 2 +- .../ui/src/__tests__/markdown-body.test.ts | 2 +- packages/ui/src/__tests__/materialize.test.ts | 4 +- .../src/__tests__/runtime-resume-copy.test.ts | 8 +- .../__tests__/session-hover-card-copy.test.ts | 2 +- .../tool-activity-presentation.test.ts | 6 +- packages/ui/src/__tests__/tool-format.test.ts | 35 ++ .../__tests__/transcript-projection.test.ts | 2 +- .../__tests__/turn-running-spinner.test.tsx | 4 +- packages/ui/src/artifact-preview-registry.ts | 2 +- packages/ui/src/assistant-stream.ts | 4 +- packages/ui/src/astryx-copy.ts | 72 +++ packages/ui/src/astryx-i18n.tsx | 6 +- packages/ui/src/chat-model-helpers.ts | 4 +- packages/ui/src/chat-turn.tsx | 3 +- packages/ui/src/chat-view.tsx | 2 +- packages/ui/src/conversation-copy.ts | 160 +++++- packages/ui/src/daily-review-copy.ts | 37 +- packages/ui/src/daily-review-helpers.ts | 6 +- packages/ui/src/live-turn-projection.ts | 2 +- packages/ui/src/locale-helpers.ts | 10 +- packages/ui/src/runtime-resume-copy.ts | 39 +- packages/ui/src/scheduled-task-copy.ts | 37 +- packages/ui/src/scheduled-task-panel.tsx | 12 +- packages/ui/src/search-modal.tsx | 6 +- packages/ui/src/session-hover-card-copy.ts | 14 +- .../ui/src/session-status-presentation.ts | 4 +- packages/ui/src/shared-ui-copy.ts | 86 ++- packages/ui/src/shell-controls-copy.ts | 33 +- packages/ui/src/skills-copy.ts | 17 +- packages/ui/src/thinking-stream.ts | 4 +- packages/ui/src/tool-activity.tsx | 2 +- packages/ui/src/tool-activity/copy.ts | 106 +++- .../ui/src/tool-activity/preview-utils.ts | 2 +- .../ui/src/tool-activity/result-projection.ts | 2 +- packages/ui/src/tool-format.ts | 6 +- packages/ui/src/tool-output-stream.ts | 2 +- scripts/fixture-env.mjs | 2 +- 199 files changed, 5651 insertions(+), 800 deletions(-) create mode 100644 apps/desktop/e2e/zh-tw-locale.spec.ts create mode 100644 apps/desktop/src/main/__tests__/agent-graph-panel-copy.test.ts create mode 100644 apps/desktop/src/main/__tests__/e2e-fixture-locale.test.ts create mode 100644 apps/desktop/src/main/__tests__/runtime-host-boot-copy.test.ts create mode 100644 apps/desktop/src/main/__tests__/settings-preferences-copy.test.ts create mode 100644 apps/desktop/src/main/__tests__/subscription-result-message.test.ts create mode 100644 apps/desktop/src/main/__tests__/traditional-chinese-peer-mesh-copy.test.ts create mode 100644 apps/desktop/src/main/__tests__/use-session-todo.test.ts create mode 100644 apps/desktop/src/main/runtime-host-boot-copy.ts create mode 100644 apps/desktop/src/renderer/features/connection-settings/subscription-result-message.ts create mode 100644 packages/ui/src/__tests__/daily-review-copy.test.ts create mode 100644 packages/ui/src/__tests__/tool-format.test.ts 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 8d55ab9c85..e04e7ddd66 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/request-header-row-contract.spec.ts b/apps/desktop/e2e/request-header-row-contract.spec.ts index c820b17743..772d5f5ae9 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/features/connection-set * 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/renderer-architecture.json b/apps/desktop/renderer-architecture.json index b4d193049f..b43b3e90fc 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -410,8 +410,8 @@ "./locales/shell-copy.js": 1, "@maka/core/redaction": 1 }, - "importSpecifiers": 3, - "nonTriviaTokens": 515 + "importSpecifiers": 2, + "nonTriviaTokens": 504 }, "src/renderer/app-shell-detail-panel.tsx": { "importDeclarations": 0, @@ -446,7 +446,7 @@ "nonTriviaTokens": 672 }, "src/renderer/app-shell-effects.ts": { - "importDeclarations": 13, + "importDeclarations": 12, "bridgePaths": { "window.maka.app.info": 1, "window.maka.appWindow.subscribeCommand": 1, @@ -492,12 +492,11 @@ "./theme": 1, "./titlebar-modal-sync": 1, "@astryxdesign/core/hooks": 1, - "@maka/core/redaction": 1, "@maka/core/session-event-health": 1, "react": 1 }, - "importSpecifiers": 21, - "nonTriviaTokens": 3823 + "importSpecifiers": 20, + "nonTriviaTokens": 3816 }, "src/renderer/app-shell-overlays.tsx": { "importDeclarations": 8, @@ -2920,7 +2919,6 @@ "./settings-expandable-row": 1, "./settings-section": 1, "./settings-skeleton.js": 1, - "@astryxdesign/core": 1, "@maka/ui": 1, "react": 1 } @@ -3905,7 +3903,6 @@ "../features/connection-settings": 1, "./oauth-login-flow-guard": 1, "./runtime-host-settings-target.js": 1, - "@maka/core/redaction": 1, "@maka/ui": 1, "react": 1 } diff --git a/apps/desktop/src/main/__tests__/about-update-status.test.ts b/apps/desktop/src/main/__tests__/about-update-status.test.ts index c3a33fae15..a3e7e70ac2 100644 --- a/apps/desktop/src/main/__tests__/about-update-status.test.ts +++ b/apps/desktop/src/main/__tests__/about-update-status.test.ts @@ -25,7 +25,7 @@ import { } from '../../renderer/settings/about-update-status.js'; import { getSettingsPreferencesCopy } from '../../renderer/locales/settings-preferences-copy.js'; -const copy = getSettingsPreferencesCopy('zh').about; +const copy = getSettingsPreferencesCopy('zh-CN').about; test('a packaged nightly is tokened Nightly, never 正式版', () => { const facts = aboutChannelFacts({ buildMode: 'packaged', updateChannel: 'nightly' }, copy); @@ -47,7 +47,7 @@ test('buildMode decides before updateChannel, whose dev value is a placeholder', test('the nightly steady states each read as themselves', () => { const detail = (status: Parameters[0]) => - aboutUpdateStatusDetail(status, copy); + aboutUpdateStatusDetail(status, copy, { isDevBuild: false }); assert.equal( detail({ 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__/command-palette-retired.test.ts b/apps/desktop/src/main/__tests__/command-palette-retired.test.ts index 9df42247d3..46b8fff553 100644 --- a/apps/desktop/src/main/__tests__/command-palette-retired.test.ts +++ b/apps/desktop/src/main/__tests__/command-palette-retired.test.ts @@ -85,7 +85,7 @@ test('a stale in-memory default pointing at a retired connection is not testable assert.ok(!ids.includes('diag:test-default')); }); -for (const locale of ['en', 'zh'] as const) { +for (const locale of ['en', 'zh-CN'] as const) { test(`${locale} static shortcut hints preserve both platform variants`, () => { const commands = buildCommandList({ locale, @@ -111,6 +111,6 @@ for (const locale of ['en', 'zh'] as const) { other: 'Ctrl+,', }); assert.equal(byId.get('diag:copy-diagnostics')?.platformHint?.other, 'Ctrl+Shift+D · ' + - (locale === 'zh' ? '脱敏日志 · 仅写入剪贴板' : 'Redacted logs · clipboard only')); + (locale === 'zh-CN' ? '脱敏日志 · 仅写入剪贴板' : 'Redacted logs · clipboard only')); }); } 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 2d15fb0eab..ebee2a46c0 100644 --- a/apps/desktop/src/main/__tests__/config-transfer-service.test.ts +++ b/apps/desktop/src/main/__tests__/config-transfer-service.test.ts @@ -104,6 +104,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('reports a settings-carried proxy credential skipped by Host target binding', async () => { const { deps } = makeDeps({ settingsStore: { 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..cec94f10f1 --- /dev/null +++ b/apps/desktop/src/main/__tests__/e2e-fixture-locale.test.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 assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { resolveE2eFixture } from '../e2e-fixture.js'; + +test('preserves both canonical Chinese locale fixture flags', () => { + for (const locale of ['zh-CN', 'zh-TW', 'en'] 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, '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 ee1ca35b77..b0fa5ee43e 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, { @@ -275,16 +275,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__/renderer-error-report.test.ts b/apps/desktop/src/main/__tests__/renderer-error-report.test.ts index 367e118023..530f26b45c 100644 --- a/apps/desktop/src/main/__tests__/renderer-error-report.test.ts +++ b/apps/desktop/src/main/__tests__/renderer-error-report.test.ts @@ -64,7 +64,7 @@ test('bounds and redacts the browser-only renderer error report', () => { test('keeps raw exceptions out of the localized crash surface', () => { for (const [locale, title] of [ - ['zh', 'Maka 渲染层崩溃了'], + ['zh-CN', 'Maka 渲染层崩溃了'], ['en', 'The Maka renderer crashed'], ] as const) { const markup = renderToStaticMarkup( 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 0b03b5a540..1c03be70d7 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 @@ -41,7 +41,7 @@ const manualFailure = new DesktopLocalHostRetirementError( { cause: failure.cause }, ); -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(manualFailure, locale); @@ -54,7 +54,7 @@ for (const locale of ['en', 'zh'] as const) { test('manual recovery copy names a cross-platform process-management concept', () => { const english = buildRuntimeHostQuitFailureDialog(manualFailure, 'en').options.detail ?? ''; - const chinese = buildRuntimeHostQuitFailureDialog(manualFailure, 'zh').options.detail ?? ''; + const chinese = buildRuntimeHostQuitFailureDialog(manualFailure, 'zh-CN').options.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 fa1da71958..2c4abd86c1 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, 'restart', - 'zh', + 'zh-CN', + ).options; + const zhTw = buildRuntimeHostUpgradeDialog( + conflict, + 'restart', + '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\):/); }); @@ -148,7 +156,7 @@ test('explains when the safe replacement check could not verify idle state', () const dialog = buildRuntimeHostUpgradeDialog( conflict, 'replace_may_interrupt_work', - 'zh', + 'zh-CN', ); assert.match(dialog.options.detail ?? '', /无法确认此 Host 是否处于空闲状态/u); 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..133ad3e7da 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__/settings-test-result-copy.test.ts b/apps/desktop/src/main/__tests__/settings-test-result-copy.test.ts index 757ced374c..9f86548727 100644 --- a/apps/desktop/src/main/__tests__/settings-test-result-copy.test.ts +++ b/apps/desktop/src/main/__tests__/settings-test-result-copy.test.ts @@ -29,7 +29,7 @@ test("missing proxy credentials have actionable bilingual copy", () => { } as never; assert.equal( - settingsTestResultMessage(result, "zh"), + settingsTestResultMessage(result, "zh-CN"), "代理认证已开启,请输入代理密码后再测试。", ); assert.equal( diff --git a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts index 16e8f1de69..1f910c38f5 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, @@ -724,7 +724,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, @@ -773,7 +773,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, @@ -846,7 +846,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, @@ -886,7 +886,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..843717e9f0 --- /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/features/connection-settings/index.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..67af56d965 --- /dev/null +++ b/apps/desktop/src/main/__tests__/traditional-chinese-peer-mesh-copy.test.ts @@ -0,0 +1,59 @@ +/* + * 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/index.js'; +import { getDesktopConversationCopy } from '../../renderer/locales/conversation-copy.js'; +import { getProviderSettingsCopy } from '../../renderer/features/connection-settings/index.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.peerPathDirect, '直接連線'); + assert.equal(copy.peerPathTransit, '成員轉送'); + assert.equal(copy.peerPathOther, '其他'); + 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 d60c58c767..35084cadae 100644 --- a/apps/desktop/src/main/__tests__/use-onboarding-snapshot.test.ts +++ b/apps/desktop/src/main/__tests__/use-onboarding-snapshot.test.ts @@ -102,7 +102,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: '鉴权失败' }]); @@ -130,7 +130,7 @@ describe('createOnboardingSnapshotPoller', () => { /* not expected */ }, }, - () => 'zh', + () => 'zh-CN', ); // Fire two overlapping pulls. const pull1 = poller.pull(); @@ -168,7 +168,7 @@ describe('createOnboardingSnapshotPoller', () => { onSnapshot: (s) => snaps.push(s), onError: (m) => errs.push(m), }, - () => 'zh', + () => 'zh-CN', ); const pull1 = poller.pull(); const pull2 = poller.pull(); @@ -194,7 +194,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(); @@ -219,7 +219,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(); @@ -238,7 +238,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__/use-session-todo.test.ts b/apps/desktop/src/main/__tests__/use-session-todo.test.ts new file mode 100644 index 0000000000..ca05257f6a --- /dev/null +++ b/apps/desktop/src/main/__tests__/use-session-todo.test.ts @@ -0,0 +1,151 @@ +/* + * 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 { strict as assert } from 'node:assert'; +import { afterEach, describe, it } from 'node:test'; +import { act, createElement } from 'react'; +import type { SessionTodoItem } from '@maka/core/session-todo'; +import type { UiLocale } from '@maka/ui'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; +import { + createFakeWorkbarServices, + useSessionTodo, + WorkbarServicesProvider, + type WorkbarServices, +} from '../../renderer/features/workbar/testing.js'; + +/** + * Regression coverage for the Traditional Chinese error-copy leak + * (2026-09-02 review): this hook used to route zh-CN and zh-TW through the + * same hard-coded Simplified helper, so a zh-TW reader saw 请求超时 instead + * of 請求逾時. It now goes through the shared, locale-aware classifier like + * every sibling surface (use-session-trace.ts, session-review-panel.tsx, + * session-terminal-panel.tsx, artifact-pane.tsx). + */ + +function Probe(props: { + services: WorkbarServices; + sessionId?: string; + locale: UiLocale; + onSnapshot: (snapshot: ReturnType) => void; +}) { + return createElement( + WorkbarServicesProvider, + { services: props.services }, + createElement(TodoProbe, props), + ); +} + +function TodoProbe(props: { + sessionId?: string; + locale: UiLocale; + onSnapshot: (snapshot: ReturnType) => void; +}) { + const snapshot = useSessionTodo(props.sessionId, { + locale: props.locale, + loadFailed: 'load failed', + }); + props.onSnapshot(snapshot); + return null; +} + +function createHarness(read: () => Promise): WorkbarServices { + return createFakeWorkbarServices({ + todo: { + read, + subscribeChanges: () => () => undefined, + }, + }); +} + +describe('useSessionTodo', () => { + afterEach(() => { + cleanupFakeDom(); + delete (globalThis as { window?: unknown }).window; + }); + + it('renders the Traditional Chinese category copy for a zh-TW reader, not Simplified', async () => { + const { root } = installReactRenderer(); + const services = createHarness(async () => { + throw new Error('request timeout'); + }); + let snapshot: ReturnType | undefined; + + await act(async () => { + root.render( + createElement(Probe, { + services, + sessionId: 'session-1', + locale: 'zh-TW', + onSnapshot: (value) => { + snapshot = value; + }, + }), + ); + }); + + assert.equal(snapshot?.error, '請求逾時', 'a zh-TW reader must not see Simplified 请求超时'); + }); + + it('still renders the Simplified Chinese category copy for a zh-CN reader', async () => { + const { root } = installReactRenderer(); + const services = createHarness(async () => { + throw new Error('request timeout'); + }); + let snapshot: ReturnType | undefined; + + await act(async () => { + root.render( + createElement(Probe, { + services, + sessionId: 'session-1', + locale: 'zh-CN', + onSnapshot: (value) => { + snapshot = value; + }, + }), + ); + }); + + assert.equal(snapshot?.error, '请求超时'); + }); + + it('falls back to the caller-supplied copy for an unclassified error in English', async () => { + const { root } = installReactRenderer(); + const services = createHarness(async () => { + throw new Error('unexpected boom'); + }); + let snapshot: ReturnType | undefined; + + await act(async () => { + root.render( + createElement(Probe, { + services, + sessionId: 'session-1', + locale: 'en', + onSnapshot: (value) => { + snapshot = value; + }, + }), + ); + }); + + assert.equal(snapshot?.error, 'load failed'); + }); +}); 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 076040c109..8d9166794e 100644 --- a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts @@ -186,7 +186,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, { @@ -200,7 +200,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, { @@ -244,7 +244,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/browser-message-box.ts b/apps/desktop/src/main/browser-message-box.ts index 755ecfb8d4..7ab4302cd7 100644 --- a/apps/desktop/src/main/browser-message-box.ts +++ b/apps/desktop/src/main/browser-message-box.ts @@ -266,7 +266,7 @@ function normalizeBrowserMessageBoxPresentation( defaultId, cancelId, dark: appearance.dark, - locale: appearance.locale === 'zh' ? 'zh' : 'en', + locale: appearance.locale, palette: isThemePalette(appearance.palette) ? appearance.palette : 'default', }; } @@ -348,7 +348,7 @@ export function buildBrowserMessageBoxHtml( function renderBrowserMessageBoxHtml(input: BrowserMessageBoxPresentation): string { const nonce = randomUUID().replaceAll('-', ''); - const closeLabel = input.locale === 'zh' ? '关闭' : 'Close'; + const closeLabel = input.locale === 'zh-CN' ? '关闭' : input.locale === 'zh-TW' ? '關閉' : 'Close'; const closeButton = ``; @@ -386,7 +386,7 @@ function renderBrowserMessageBoxHtml(input: BrowserMessageBoxPresentation): stri : ''; return ` - + diff --git a/apps/desktop/src/main/client-settings-confirmation-copy.ts b/apps/desktop/src/main/client-settings-confirmation-copy.ts index 4bec4fd2b9..422e9aef92 100644 --- a/apps/desktop/src/main/client-settings-confirmation-copy.ts +++ b/apps/desktop/src/main/client-settings-confirmation-copy.ts @@ -24,30 +24,38 @@ 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', '界面语言', '介面語言'], + runComplete: ['Run-complete notifications', '回答完成通知', '回答完成通知'], + keepSystemAwake: ['Keep system awake', '保持系统唤醒', '保持系統喚醒'], }; + const localeIndex = locale === 'en' ? 0 : locale === 'zh-CN' ? 1 : 2; 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' ? '开启' : '開啟'; + if (input === false) return locale === 'zh-CN' ? '关闭' : '關閉'; 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 更新此客户端的设置吗?' + : '允許 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' + ? ['应用更改', '取消'] + : ['套用變更', '取消'], }; } diff --git a/apps/desktop/src/main/computer-use/status-item.ts b/apps/desktop/src/main/computer-use/status-item.ts index 0506a4d5a9..1dba08cb10 100644 --- a/apps/desktop/src/main/computer-use/status-item.ts +++ b/apps/desktop/src/main/computer-use/status-item.ts @@ -132,11 +132,16 @@ 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', diff --git a/apps/desktop/src/main/config-transfer-service.ts b/apps/desktop/src/main/config-transfer-service.ts index 13c6972208..a412a6c2a2 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 { UpdateAppSettingsInput } from '@maka/core/settings'; +import { normalizeUiLocalePreference } from '@maka/core/ui-locale'; import { reconcileConnectionAfterEnabledModelsChange, type LlmConnection, @@ -123,8 +124,20 @@ export async function applyConfigImport( let settingsCredentialSkips = 0; if (bundle.data.settings && typeof bundle.data.settings === 'object') { + const patch = bundle.data.settings as unknown as UpdateAppSettingsInput; + const importedPersonalization = patch.personalization as + | (NonNullable & { uiLocale?: unknown }) + | undefined; const applied = await deps.settingsStore.update( - bundle.data.settings as unknown as UpdateAppSettingsInput, + importedPersonalization && Object.hasOwn(importedPersonalization, 'uiLocale') + ? { + ...patch, + personalization: { + ...importedPersonalization, + uiLocale: normalizeUiLocalePreference(importedPersonalization.uiLocale), + }, + } + : patch, ); settingsCredentialSkips = applied.skippedCredentials; 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 08dc5b226d..6c741c5248 100644 --- a/apps/desktop/src/main/e2e-fixture.ts +++ b/apps/desktop/src/main/e2e-fixture.ts @@ -137,7 +137,9 @@ 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'; + return normalized === 'en' ? 'en' : null; } function parseTimezoneFlag(raw: string | undefined): string | null { diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 16133b4515..565583503f 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -105,18 +105,27 @@ if (!app.requestSingleInstanceLock()) { .whenReady() .then(() => { const locale = resolveSystemUiLocale(app.getPreferredSystemLanguages()); - const isChinese = locale === 'zh'; + const isSimplifiedChinese = locale === 'zh-CN'; + const isTraditionalChinese = locale === 'zh-TW'; return showBrowserMessageBox( { type: 'warning', - title: isChinese ? 'Maka Dev 已在运行' : 'Maka Dev is already running', - message: isChinese + title: isSimplifiedChinese + ? 'Maka Dev 已在运行' + : isTraditionalChinese + ? 'Maka Dev 已在執行' + : 'Maka Dev is already running', + message: isSimplifiedChinese ? '另一个 Maka Dev 实例正在使用此开发配置。' - : 'Another Maka Dev instance is using this development profile.', - detail: isChinese + : isTraditionalChinese + ? '另一個 Maka Dev 執行個體正在使用此開發設定。' + : 'Another Maka Dev instance is using this development profile.', + detail: isSimplifiedChinese ? `开发配置:${profilePath}\n\n请先退出正在运行的实例,然后重试。` - : `Development profile: ${profilePath}\n\nQuit the running instance, then retry.`, - buttons: [isChinese ? '退出' : 'Exit'], + : isTraditionalChinese + ? `開發設定:${profilePath}\n\n請先退出正在執行的執行個體,然後重試。` + : `Development profile: ${profilePath}\n\nQuit the running instance, then retry.`, + buttons: [isSimplifiedChinese ? '退出' : isTraditionalChinese ? '退出' : 'Exit'], defaultId: 0, cancelId: 0, }, diff --git a/apps/desktop/src/main/native-diagnostic-dialog-copy.ts b/apps/desktop/src/main/native-diagnostic-dialog-copy.ts index c671249561..bd9ccee1eb 100644 --- a/apps/desktop/src/main/native-diagnostic-dialog-copy.ts +++ b/apps/desktop/src/main/native-diagnostic-dialog-copy.ts @@ -118,7 +118,7 @@ const COPY = { exit: 'Exit', }, }, - zh: { + 'zh-CN': { dialog: { copy: '复制诊断信息', copyAgain: '再次复制', @@ -167,6 +167,55 @@ const COPY = { exit: '退出', }, }, + 'zh-TW': { + dialog: { + copy: '複製診斷資訊', + copyAgain: '再次複製', + copied: '診斷資訊已複製,可直接貼上到問題報告中。', + copyFailed: '無法複製診斷資訊。', + }, + fatalStartup: { + title: 'Maka 啟動失敗', + message: 'Maka 無法完成啟動。', + detail: '啟動時發生未預期的錯誤。複製診斷資訊可檢視詳細資料。', + exit: '退出', + }, + rendererGone: { + title: 'Maka 需要復原', + message: 'Maka 介面意外停止執行。', + detail: '只復原介面,不重新啟動 Maka。Runtime Host、正在執行的工作和背景服務都會保留。', + recover: '復原介面', + exit: '退出', + }, + runtimeHostRecovery: { + 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: '退出', + }, + defaultRuntimeHostRecovery: { + title: '預設 Runtime Host 無法連線', + connectFailed: (profileName) => `無法連線至 ${profileName}`, + detail: + '你可以重試、改用 Local 作為預設 Host,或保留目前選擇並稍後在設定中處理。複製診斷資訊可檢視連線失敗的詳細資料。', + retry: '重試', + useLocal: '改用 Local', + keepOffline: '保持離線', + }, + storageRootRepair: { + title: 'Maka 工作區需要修復', + message: 'Maka 無法驗證這個工作區。', + detail: (workspaceRoot) => + `系統中的磁碟識別資訊可能已變更。僅當這是本機原本的 Maka 工作區,而不是複製的工作區時,才選擇修復。\n\n${workspaceRoot}`, + repair: '修復工作區', + exit: '退出', + }, + }, } satisfies UiCatalog; export function getNativeDiagnosticDialogCopy(locale: UiLocale): NativeDiagnosticDialogCopy { diff --git a/apps/desktop/src/main/notifications-policy.ts b/apps/desktop/src/main/notifications-policy.ts index efd72ba96b..8795aa00d9 100644 --- a/apps/desktop/src/main/notifications-policy.ts +++ b/apps/desktop/src/main/notifications-policy.ts @@ -86,6 +86,11 @@ 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 (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..5311ff0469 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`, diff --git a/apps/desktop/src/main/project-picker-copy.ts b/apps/desktop/src/main/project-picker-copy.ts index 80e2a347be..45a2598914 100644 --- a/apps/desktop/src/main/project-picker-copy.ts +++ b/apps/desktop/src/main/project-picker-copy.ts @@ -20,5 +20,7 @@ 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 '新增專案'; + 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..fb1489d8c7 --- /dev/null +++ b/apps/desktop/src/main/runtime-host-boot-copy.ts @@ -0,0 +1,90 @@ +/* + * 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'], + }, + }, +} satisfies UiCatalog; + +export function getStartupRecoveryCopy(locale: UiLocale): StartupRecoveryCopy { + return STARTUP_RECOVERY_COPY[locale]; +} diff --git a/apps/desktop/src/main/runtime-host-quit-copy.ts b/apps/desktop/src/main/runtime-host-quit-copy.ts index 26db6a37e4..e53cf84b89 100644 --- a/apps/desktop/src/main/runtime-host-quit-copy.ts +++ b/apps/desktop/src/main/runtime-host-quit-copy.ts @@ -109,7 +109,7 @@ const COPY = { retry: 'Retry Quit', forceQuit: 'Force Quit Maka', }, - zh: { + 'zh-CN': { activeTitle: 'Maka 正在后台工作', activeMessage: '仍有后台工作正在运行。', activeDetail: '现在退出会停止 Runtime Host,并可能中断正在执行或等待运行的后台任务。', @@ -125,4 +125,20 @@ const COPY = { retry: '重试退出', forceQuit: '强制退出 Maka', }, + 'zh-TW': { + activeTitle: 'Maka 正在背景工作', + activeMessage: '仍有背景工作正在執行。', + activeDetail: '現在退出會停止 Runtime Host,並可能中斷正在執行或等待執行的背景工作。', + stopAndQuit: '停止工作並退出', + keepRunning: '繼續執行 Maka', + title: '無法安全退出 Maka', + message: '本地 Runtime Host 未能安全停止,Maka 仍在執行。', + detail: '退出已取消。請重試;如果問題持續存在,請檢視診斷資訊。', + process: (pid: number) => `Runtime Host 程序 PID:${pid}`, + manual: '如果重試仍然失敗,請先確認沒有需要保留的執行,再透過作業系統的程序管理工具停止該 PID。', + forceWarning: '強制退出可能丟棄尚未完成的外部工作。', + cause: '原因', + retry: '重試退出', + forceQuit: '強制退出 Maka', + }, } 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 53fd212d68..3f03574171 100644 --- a/apps/desktop/src/main/runtime-host-upgrade-copy.ts +++ b/apps/desktop/src/main/runtime-host-upgrade-copy.ts @@ -160,7 +160,7 @@ const UPGRADE_COPY = { other: 'Other background activity', }, }, - zh: { + 'zh-CN': { title: '旧版 Runtime Host 正在运行', message: '另一个 Runtime Host 进程仍占用此工作区。', restart: '重启 Runtime Host', @@ -184,4 +184,27 @@ 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} 個操作正在執行`, + idleNotVerified: '安全替換檢查無法確認此 Host 是否處於閒置狀態。', + 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: '其他後臺活動', + }, + }, } 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 08b506ea97..532c771192 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-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 00d596e466..42afdd3708 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 3e17e55038..ba14fe1bab 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -2867,7 +2867,7 @@ function AppShellContent({ {workHubEnabled && navSelection.section === 'sessions' && activeId ? (