diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1a1cd1bf7..7531747e7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -117,7 +117,13 @@ Use one of these standard type prefixes: - Formatting is handled by Prettier. `npm run format` is optional; review its changes before committing because it rewrites files across the repository. - Linting is enforced by ESLint; run `npm run lint`. -- Keep user-facing strings, comments, and documentation in English. +- Keep comments and documentation in English. +- User-facing strings go through i18next, not JSX literals. Author the copy in + `src/renderer/src/locales/en/`, then translate it in the `zh-Hans` and + `zh-Hant` folders — `docs/i18n-glossary.md` is the binding terminology + reference, and the catalog-parity tests fail on a missing or inconsistent + translation. Keys are semantic paths (`general.appearance.title`), never the + English source text. ## Required Checks diff --git a/docs/i18n-glossary.md b/docs/i18n-glossary.md new file mode 100644 index 000000000..2ce9bd462 --- /dev/null +++ b/docs/i18n-glossary.md @@ -0,0 +1,143 @@ +# Localization Glossary + +The binding reference for the `zh-Hans` and `zh-Hant` message catalogs under +`src/renderer/src/locales/`. English (`en`) is the source language: every key is authored there +first, and the other catalogs translate it. + +Traditional Chinese is a **separate translation**, not a character conversion of Simplified. The +software vocabulary genuinely differs (`file` is 文件 in Simplified but 檔案 in Traditional, where +文件 means _document_), so running a converter over `zh-Hans` produces wrong copy. Translate from the +English source and consult the tables below. + +## Kept in English + +Never translated, in any catalog: + +`Open Science` (product name), `Claude`, `Codex`, `opencode`, `Agent`, `Notebook`, `MCP`, `ACP`, +`API`, `CLI`, `SSH`, `GitHub`, `Star`, `Discord`, `Python`, `Jupyter`, `token`, and all model names. + +- `Agent` and `Notebook` are first-class feature names that appear in dense UI; the English terms + are shorter and less ambiguous than 智能体 / 笔记本. Explanatory prose may describe them in + Chinese, but the labels stay English. +- `token` stays English because `12k tokens` reads more clearly than 词元 or 令牌 in the context + indicator, and matches how the surrounding community writes it. +- The `Open Science` name is fixed by `docs/design.md`, but the home tagline beneath it **is** + translated. + +## Core domain nouns + +| en | zh-Hans | zh-Hant | +| ------------------ | ---------- | ---------- | +| project | 项目 | 專案 | +| session | 会话 | 會話 | +| conversation | 对话 | 對話 | +| workspace | 工作区 | 工作區 | +| message | 消息 | 訊息 | +| task | 任务 | 任務 | +| run | 运行 | 執行 | +| turn | 轮次 | 輪次 | +| agent framework | Agent 框架 | Agent 框架 | +| model | 模型 | 模型 | +| provider | 模型服务商 | 模型服務商 | +| subscription | 订阅 | 訂閱 | +| skill | 技能 | 技能 | +| connector | 连接器 | 連接器 | +| kernel | 内核 | 核心 | +| artifact | 产物 | 產物 | +| activity group | 活动分组 | 活動分組 | +| tool | 工具 | 工具 | +| compute host | 计算主机 | 運算主機 | +| runtime | 运行时 | 執行環境 | +| environment | 环境 | 環境 | +| preview | 预览 | 預覽 | +| reasoning effort | 推理强度 | 推理強度 | +| context | 上下文 | 上下文 | +| context compaction | 上下文压缩 | 上下文壓縮 | + +## Simplified / Traditional divergences + +The highest-risk table. A character converter gets most of these wrong. + +| en | zh-Hans | zh-Hant | +| ------------------ | ------- | -------- | +| file | 文件 | 檔案 | +| document | 文档 | 文件 | +| folder | 文件夹 | 資料夾 | +| data | 数据 | 資料 | +| information | 信息 | 資訊 | +| software | 软件 | 軟體 | +| program | 程序 | 程式 | +| default | 默认 | 預設 | +| settings | 设置 | 設定 | +| network | 网络 | 網路 | +| cache | 缓存 | 快取 | +| process | 进程 | 行程 | +| thread | 线程 | 執行緒 | +| queue | 队列 | 佇列 | +| storage | 存储 | 儲存 | +| credential | 凭据 | 憑證 | +| log | 日志 | 記錄檔 | +| mirror | 镜像源 | 鏡像來源 | +| tray | 托盘 | 系統匣 | +| bookmark | 书签 | 書籤 | +| archive (verb) | 归档 | 封存 | +| approve / approval | 批准 | 核准 | + +Note the `file` / `document` inversion: Traditional 文件 means what Simplified calls 文档. Getting +this pair backwards is the single most common failure in Simplified-to-Traditional conversion. + +## Actions and states + +| en | zh-Hans | zh-Hant | +| -------------------- | -------------- | -------------- | +| create / new | 新建 | 新增 | +| edit | 编辑 | 編輯 | +| rename | 重命名 | 重新命名 | +| delete | 删除 | 刪除 | +| retry | 重试 | 重試 | +| resume | 继续 | 繼續 | +| stop | 停止 | 停止 | +| cancel | 取消 | 取消 | +| install / uninstall | 安装 / 卸载 | 安裝 / 移除 | +| validate | 验证 | 驗證 | +| import / export | 导入 / 导出 | 匯入 / 匯出 | +| upload / download | 上传 / 下载 | 上傳 / 下載 | +| reveal in folder | 在文件夹中显示 | 在資料夾中顯示 | +| minimize to tray | 最小化到托盘 | 最小化至系統匣 | +| idle | 空闲 | 閒置 | +| running | 运行中 | 執行中 | +| waiting for approval | 等待批准 | 等待核准 | +| failed | 失败 | 失敗 | +| completed | 已完成 | 已完成 | +| pending | 待处理 | 待處理 | + +## Interface chrome + +| en | zh-Hans | zh-Hant | +| --------------------- | ------------------ | ------------------ | +| Home | 首页 | 首頁 | +| Onboarding | 初始设置 | 初始設定 | +| General | 通用 | 一般 | +| Appearance | 外观 | 外觀 | +| Theme | 主题 | 主題 | +| System / Light / Dark | 系统 / 浅色 / 深色 | 系統 / 淺色 / 深色 | +| Language | 语言 | 語言 | +| Notifications | 通知 | 通知 | +| Diagnostics | 诊断 | 診斷 | +| Permissions | 权限 | 權限 | +| Data root | 数据目录 | 資料目錄 | +| Command line tool | 命令行工具 | 命令列工具 | + +## Style rules + +- Full-width punctuation (`,。:;?`) in Chinese prose. Code, paths, and commands keep their + original half-width characters. +- One half-width space between Chinese and Latin script (`使用 Claude 模型`). No space between a + number and a Chinese unit that reads as one word (`5 分钟` takes the space; `12k` is not split). +- Second person is 你, never 您 — it matches the supportive, non-authoritative tone `docs/design.md` + asks for. +- Short labels (buttons, table headers, menu items) take no trailing period. Full sentences do. +- No exclamation points, per `docs/design.md`. +- Don't pad imperatives with 请. `Check the network` is 检查网络连接, not 请检查网络连接. +- Language names in the language picker are written in their own language and never translated: + `English`, `简体中文`, `繁體中文`. Only the `System` option follows the interface language. diff --git a/e2e/visual-regression.spec.ts-snapshots/settings-general-darwin.png b/e2e/visual-regression.spec.ts-snapshots/settings-general-darwin.png index f88087e3c..d214cb2e4 100644 Binary files a/e2e/visual-regression.spec.ts-snapshots/settings-general-darwin.png and b/e2e/visual-regression.spec.ts-snapshots/settings-general-darwin.png differ diff --git a/package-lock.json b/package-lock.json index c6ec90ec0..cde2d8882 100644 --- a/package-lock.json +++ b/package-lock.json @@ -72,6 +72,7 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", + "i18next": "^25.10.10", "jsdom": "^29.1.1", "lucide-react": "^1.23.0", "motion": "^12.42.2", @@ -82,6 +83,7 @@ "radix-ui": "^1.6.1", "react": "^19.2.1", "react-dom": "^19.2.1", + "react-i18next": "^16.6.6", "react-resizable-panels": "^4.12.0", "react-zoom-pan-pinch": "^4.0.3", "saxes": "6.0.0", @@ -13118,6 +13120,16 @@ "dev": true, "license": "MIT" }, + "node_modules/html-parse-stringify": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.1.0.tgz", + "integrity": "sha512-E0oAXcELOtsXe+BmpJ2EZyedbldPpriV5vICzEuo6xjC/D1lDukOI7KrpfQGF2Qc4wWEy0nk3bFORS2K5ZAhFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "license": "MIT", @@ -13199,6 +13211,38 @@ "node": ">=18.18.0" } }, + "node_modules/i18next": { + "version": "25.10.10", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.10.10.tgz", + "integrity": "sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "license": "MIT", @@ -18324,6 +18368,34 @@ "react-dom": ">=16.8.0" } }, + "node_modules/react-i18next": { + "version": "16.6.6", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.6.6.tgz", + "integrity": "sha512-ZgL2HUoW34UKUkOV7uSQFE1CDnRPD+tCR3ywSuWH7u2iapnz86U8Bi3Vrs620qNDzCf1F47NxglCEkchCTDOHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 25.10.9", + "react": ">= 16.8.0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "16.13.1", "license": "MIT" @@ -21102,7 +21174,6 @@ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", - "peer": true, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -21860,6 +21931,16 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "dev": true, diff --git a/package.json b/package.json index ab82138b0..03381d81b 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", + "i18next": "^25.10.10", "jsdom": "^29.1.1", "lucide-react": "^1.23.0", "motion": "^12.42.2", @@ -115,6 +116,7 @@ "radix-ui": "^1.6.1", "react": "^19.2.1", "react-dom": "^19.2.1", + "react-i18next": "^16.6.6", "react-resizable-panels": "^4.12.0", "react-zoom-pan-pinch": "^4.0.3", "saxes": "6.0.0", diff --git a/scripts/fix-electron-path.cjs b/scripts/fix-electron-path.cjs index 6e33dc7b8..513879c62 100644 --- a/scripts/fix-electron-path.cjs +++ b/scripts/fix-electron-path.cjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */ +/* eslint-disable @typescript-eslint/no-require-imports */ // Normalize node_modules/electron/path.txt so the electron binary resolves without a trailing // newline. Different npmmirror CDNs / extraction paths have shipped this 32-byte file with an // appended LF; electron's own index.js reads it via fs.readFileSync(pathFile, 'utf-8') without @@ -30,5 +30,4 @@ if (normalized === raw) { fs.writeFileSync(pathFile, normalized, 'utf-8') -// eslint-disable-next-line no-console console.log('fix-electron-path: stripped trailing newline from node_modules/electron/path.txt') diff --git a/src/main/compute/job-notifier.test.ts b/src/main/compute/job-notifier.test.ts index ee9f4f041..234d364e6 100644 --- a/src/main/compute/job-notifier.test.ts +++ b/src/main/compute/job-notifier.test.ts @@ -215,7 +215,12 @@ describe('emitJobNotification', () => { const hostRepo: Pick = { get: vi.fn() } const broadcast = vi.fn() - await emitJobNotification(job, { jobRepository: jobRepo, hostRepository: hostRepo, storageRoot, broadcast }) + await emitJobNotification(job, { + jobRepository: jobRepo, + hostRepository: hostRepo, + storageRoot, + broadcast + }) // Neither update nor broadcast should be called expect(mockUpdate).not.toHaveBeenCalled() diff --git a/src/main/settings/validate.test.ts b/src/main/settings/validate.test.ts index bf8f58c3e..6d09d8588 100644 --- a/src/main/settings/validate.test.ts +++ b/src/main/settings/validate.test.ts @@ -431,9 +431,7 @@ describe('validate: provider dispatch', () => { it('surfaces a 5xx error message from a bridge validation', async () => { const fetchImpl = vi .fn() - .mockResolvedValue( - new Response('{"error":{"message":"Gateway timeout"}}', { status: 502 }) - ) + .mockResolvedValue(new Response('{"error":{"message":"Gateway timeout"}}', { status: 502 })) const result = await validateProvider( { type: 'custom', apiEndpoints: ['openai'], baseUrl: 'https://g/v1', key: 'k', model: 'm' }, diff --git a/src/main/settings/validate.ts b/src/main/settings/validate.ts index 24dece909..b049de396 100644 --- a/src/main/settings/validate.ts +++ b/src/main/settings/validate.ts @@ -499,7 +499,9 @@ const validateProviderThroughLocalResponsesAdapter = async ( if (category !== 'ok') { return toResult(category, { status: response.status, - ...(category === 'unknown' || category === 'server-error' ? { message: providerMessage } : {}) + ...(category === 'unknown' || category === 'server-error' + ? { message: providerMessage } + : {}) }) } if (!adapter.hasRequiredToolCall(bodyText)) { diff --git a/src/main/skills/net-fetch.ts b/src/main/skills/net-fetch.ts index 4188a8ae5..5c6052df2 100644 --- a/src/main/skills/net-fetch.ts +++ b/src/main/skills/net-fetch.ts @@ -13,5 +13,4 @@ export const netFetch: FetchLike = (url, init) => // probe, which reads the error body and aborts on timeout. Same proxy-honoring Chromium stack. A lazy // arrow wrapper (like netFetch) so `net.fetch` is only read at call time — reading it eagerly at module // load crashes any test whose electron mock omits `net` — while the method call preserves the receiver. -export const netFetchStandard = ((input, init) => - net.fetch(input as string, init)) as typeof fetch +export const netFetchStandard = ((input, init) => net.fetch(input as string, init)) as typeof fetch diff --git a/src/renderer/src/components/CloseConfirmModal.tsx b/src/renderer/src/components/CloseConfirmModal.tsx index 55c62fab7..7bc631495 100644 --- a/src/renderer/src/components/CloseConfirmModal.tsx +++ b/src/renderer/src/components/CloseConfirmModal.tsx @@ -1,5 +1,6 @@ import { AlertDialog } from 'radix-ui' import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' import { dialogOverlayClassName, dialogPanelClassName } from '@/components/ui/dialog-chrome' @@ -7,6 +8,7 @@ import { useRetainedDialogValue } from '@/components/ui/use-retained-dialog-valu import { resolveActiveSessionDisplay, truncateLabel } from '@/lib/active-session-display' import { cn } from '@/lib/utils' import { useNavigationStore } from '@/stores/navigation-store' +import { APP } from '../../../shared/app-config' import type { ActiveSessionInfo } from '../../../shared/storage' import type { CloseConfirmChoice, @@ -30,6 +32,7 @@ export const CloseConfirmModal = ({ }: { onOpenChange?: (open: boolean) => void }): React.JSX.Element | null => { + const { t } = useTranslation(['components', 'common']) const [request, setRequest] = useState(undefined) const [remember, setRemember] = useState(true) @@ -61,10 +64,12 @@ export const CloseConfirmModal = ({ const isQuitVariant = dialogRequest.variant === 'quit' const hasSessions = dialogRequest.sessions.length > 0 - const title = isQuitVariant ? 'Quit Open Science?' : 'Minimize or quit?' + const title = isQuitVariant + ? t('components:closeConfirm.quitTitle', { appName: APP.name }) + : t('components:closeConfirm.chooseTitle') const description = isQuitVariant - ? 'Work is still running and will be interrupted if you quit.' - : 'This app can keep running in the tray, or you can quit.' + ? t('components:closeConfirm.quitDescription') + : t('components:closeConfirm.chooseDescription') return ( ) @@ -123,23 +134,23 @@ export const CloseConfirmModal = ({ onChange={(event) => setRemember(event.target.checked)} className="size-4 shrink-0 accent-primary" /> - Don't ask again + {t('components:closeConfirm.dontAskAgain')} ) : null}
{isQuitVariant ? ( ) : ( )}
diff --git a/src/renderer/src/components/CompletedJobCard.tsx b/src/renderer/src/components/CompletedJobCard.tsx index 373cfe54f..d547787ee 100644 --- a/src/renderer/src/components/CompletedJobCard.tsx +++ b/src/renderer/src/components/CompletedJobCard.tsx @@ -1,4 +1,5 @@ import { Zap, ChevronRight } from 'lucide-react' +import { useTranslation } from 'react-i18next' import type { JobSummary } from '../../../shared/compute' @@ -13,24 +14,28 @@ type CompletedJobCardProps = { onOpen: (job: JobSummary) => void } -// Returns the status label and color class based on job status. -function getStatusDisplay(job: JobSummary): { label: string; colorClass: string } { +type StatusKey = 'jobRow.finished' | 'jobRow.failed' | 'jobRow.timedOut' | 'jobRow.error' + +// Returns the catalog key and color class for a job's terminal status. +function getStatusLabelKey(job: JobSummary): { key: StatusKey | null; colorClass: string } { switch (job.status) { case 'success': - return { label: 'finished', colorClass: 'text-green-600 dark:text-green-400' } + return { key: 'jobRow.finished', colorClass: 'text-green-600 dark:text-green-400' } case 'failed': - return { label: 'failed', colorClass: 'text-red-600 dark:text-red-400' } + return { key: 'jobRow.failed', colorClass: 'text-red-600 dark:text-red-400' } case 'timeout': - return { label: 'timed out', colorClass: 'text-red-600 dark:text-red-400' } + return { key: 'jobRow.timedOut', colorClass: 'text-red-600 dark:text-red-400' } case 'error': - return { label: 'error', colorClass: 'text-red-600 dark:text-red-400' } + return { key: 'jobRow.error', colorClass: 'text-red-600 dark:text-red-400' } default: - return { label: job.status, colorClass: 'text-muted-foreground' } + return { key: null, colorClass: 'text-muted-foreground' } } } export function CompletedJobCard({ job, onOpen }: CompletedJobCardProps): React.JSX.Element { - const { label, colorClass } = getStatusDisplay(job) + const { t } = useTranslation('components') + const { key, colorClass } = getStatusLabelKey(job) + const label = key ? t(key) : job.status const intentDisplay = job.intent.length > 70 ? `${job.intent.slice(0, 67)}…` : job.intent @@ -40,7 +45,7 @@ export function CompletedJobCard({ job, onOpen }: CompletedJobCardProps): React. data-testid="completed-job-card" className="flex w-full cursor-pointer items-center gap-2.5 rounded-lg border border-border bg-muted/30 px-3.5 py-2.5 text-left text-[12px] hover:bg-muted/50 transition-colors" onClick={() => onOpen(job)} - aria-label={`Completed remote job: ${job.intent}`} + aria-label={t('jobRow.cardAriaLabel', { intent: job.intent })} >