From 743be4fa470cdbdb368397722662a1ffb2c719ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BC=8A=E9=9B=B7=5Foffice?= <239516043+1465586445-jpg@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:48:13 +0800 Subject: [PATCH 1/5] feat(shared): add i18n message catalog (zh-CN/en) with subpath export --- packages/shared/package.json | 4 + packages/shared/src/i18n/en.ts | 125 ++++++++++++++++++++++++ packages/shared/src/i18n/index.test.ts | 42 ++++++++ packages/shared/src/i18n/index.ts | 73 ++++++++++++++ packages/shared/src/i18n/zh-CN.ts | 127 +++++++++++++++++++++++++ 5 files changed, 371 insertions(+) create mode 100644 packages/shared/src/i18n/en.ts create mode 100644 packages/shared/src/i18n/index.test.ts create mode 100644 packages/shared/src/i18n/index.ts create mode 100644 packages/shared/src/i18n/zh-CN.ts diff --git a/packages/shared/package.json b/packages/shared/package.json index 5573e0ab6b69..5d80b652cedc 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -278,6 +278,10 @@ "./dateTime": { "types": "./src/dateTime.ts", "import": "./src/dateTime.ts" + }, + "./i18n": { + "types": "./src/i18n/index.ts", + "import": "./src/i18n/index.ts" } }, "scripts": { diff --git a/packages/shared/src/i18n/en.ts b/packages/shared/src/i18n/en.ts new file mode 100644 index 000000000000..663513aa378b --- /dev/null +++ b/packages/shared/src/i18n/en.ts @@ -0,0 +1,125 @@ +/** + * English messages. This is the canonical dictionary: + * every i18n key is typed from here, so it is the source of truth for keys. + * Missing keys in the active locale always fall back to this file. + */ +export const enMessages = { + "app.name": "T3 Code", + "action.save": "Save", + "action.cancel": "Cancel", + "settings.title": "Settings", + "chat.sendMessage": "Send message", + "topbar.runOn": "Run on", + "confirm.title": "Confirm action", + "confirm.cancel": "Cancel", + "confirm.confirm": "Confirm", + "prList.empty": "No pull requests", + "chat.empty.noActiveThread": "No active thread", + "chat.empty.title": "Pick a thread to continue", + "chat.empty.description": "Select an existing thread or create a new one to get started.", + "sidebar.allProjects": "All projects", + "sidebar.noProjects": "No projects yet", + "sidebar.addProject": "Add project", + "sidebar.noThreads": "No threads yet", + "sidebar.noThreadsInProject": "No threads in {project} yet", + "sidebar.showMore": "Show {count} more", + "sidebar.pullRequests": "Pull Requests", + "sidebar.usage": "Usage", + "sidebar.back": "Back", + "sidebar.projects": "Projects", + "sidebar.search": "Search", + "draft.hero.buildInPrefix": "What should we build in ", + "draft.hero.buildInSuffix": "?", + "draft.hero.startSuffix": " to start", + "draft.hero.addProjectToStart": "Add a project to start", + "draft.chooseProject": "Choose a project", + "draft.changeProject": "Change project", + "draft.addAProject": "Add a project", + "draft.newProject": "New project", + "chat.contextWindow.title": "Context Window", + "chat.contextWindow.totalProcessed": "Total processed", + "chat.contextWindow.usageAria": "Context window usage", + "settings.section.general": "General", + "settings.section.appearance": "Appearance", + "settings.section.keybindings": "Keybindings", + "settings.section.providers": "Providers", + "settings.section.integrations": "Integrations", + "settings.section.sourceControl": "Source Control", + "settings.section.connections": "Connections", + "settings.section.archive": "Archive", + "settings.section.browser": "Browser", + "settings.section.about": "About", + "settings.option.monospaceFont": "Monospace font", + "settings.breadcrumb.root": "Settings", + "settings.breadcrumb.diagnostics": "Diagnostics", + "settings.search.placeholder": "Search", + "settings.search.noResults": "No settings found", + "settings.option.colorScheme": "Color scheme", + "settings.option.themes": "Themes", + "settings.option.contrast": "Contrast", + "settings.option.glassOpacity": "Glass opacity", + "settings.option.environmentIdentification": "Environment identification", + "settings.option.interfaceFont": "Interface font", + "settings.option.promptFont": "Prompt font", + "settings.option.codeFont": "Code font", + "settings.option.terminalFont": "Terminal font", + "settings.option.fontSmoothing": "Font smoothing", + "settings.option.wordWrap": "Word wrap", + "settings.option.projectGrouping": "Project grouping", + "settings.option.autoSettleInactiveThreads": "Auto-settle inactive threads", + "settings.option.autoSettleMergedThreads": "Auto-settle merged threads", + "settings.option.timeFormat": "Time format", + "settings.option.hideWhitespaceChanges": "Hide whitespace changes", + "settings.option.skillsInSlashMenu": "Show skills in slash menu", + "settings.option.providerUpdateChecks": "Provider update checks", + "settings.option.newThreads": "New threads", + "settings.option.startFromOrigin": "Start from origin", + "settings.option.addProjectStartsIn": "Add project starts in", + "settings.option.archiveConfirmation": "Archive confirmation", + "settings.option.deleteConfirmation": "Delete confirmation", + "settings.option.quitConfirmation": "Hold to quit", + "settings.option.textGenerationModel": "Text generation model", + "settings.option.diagnostics": "Diagnostics", + "settings.option.legacyPlanMode": "Plan mode (legacy)", + "settings.option.legacyTokenStreaming": "Stream token by token (legacy)", + "settings.option.legacySidebar": "Sidebar (legacy)", + "settings.option.keybindings": "Keybindings", + "settings.option.providers": "Providers", + "settings.option.agentBrowserAccess": "Agent browser access", + "settings.option.browserDefaultViewport": "Default browser viewport", + "settings.option.browserDefaultZoom": "Default browser zoom", + "settings.option.browserDefaultAppearance": "Default browser appearance", + "settings.option.browserAutoShowFloatingPreview": "Auto-show floating preview", + "settings.option.sourceControl": "Source control", + "settings.option.remoteEnvironments": "Remote environments", + "settings.option.archivedThreads": "Archived threads", + "action.ok": "OK", + "desktop.menu.checkForUpdates": "Check for Updates...", + "desktop.menu.settings": "Settings...", + "desktop.menu.file": "File", + "desktop.menu.view": "View", + "desktop.menu.actualSize": "Actual Size", + "desktop.menu.zoomIn": "Zoom In", + "desktop.menu.zoomOut": "Zoom Out", + "desktop.update.upToDateTitle": "You're up to date!", + "desktop.update.upToDateMessage": "T3 Code {version} is currently the newest version available.", + "desktop.update.checkFailedTitle": "Update check failed", + "desktop.update.checkFailedMessage": "Could not check for updates.", + "desktop.update.checkFailedDetail": "An unknown error occurred. Please try again later.", + "desktop.update.unavailableTitle": "Updates unavailable", + "desktop.update.unavailableMessage": "Automatic updates are not available right now.", + "desktop.contextMenu.noSuggestions": "No suggestions", + "desktop.contextMenu.copyLink": "Copy Link", + "desktop.contextMenu.copyImage": "Copy Image", + "desktop.startup.failedTitle": "T3 Code failed to start", + "desktop.startup.failedDetail": "Stage: {stage}\n{message}{detail}", + "desktop.wsl.unavailableTitle": "WSL backend is still unavailable", + "desktop.wsl.unavailableMessage": + "{reason}\n\nT3 Code will use the Windows backend for this launch and retry WSL the next time the app starts.", + "desktop.wsl.couldNotStartTitle": "WSL backend couldn't start", + "desktop.wsl.couldNotStartMessage": + "{reason}\n\nFalling back to the Windows backend so T3 Code can open. Re-enable the WSL backend from Settings > Connections once the WSL distro is fixed.", + "desktop.splash.connecting": "Connecting to WSL…", +} as const; + +export type MessageKey = keyof typeof enMessages; diff --git a/packages/shared/src/i18n/index.test.ts b/packages/shared/src/i18n/index.test.ts new file mode 100644 index 000000000000..b7d1a6b40357 --- /dev/null +++ b/packages/shared/src/i18n/index.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { createI18n, i18n } from "./index.js"; + +describe("i18n", () => { + it("defaults to zh-CN", () => { + expect(i18n.locale).toBe("zh-CN"); + expect(i18n.t("action.cancel")).toBe("取消"); + }); + + it("falls back to en for keys missing in the active locale", () => { + // zh-CN dictionary would omit a key we only ship in English; here we + // simulate by building a catalog whose active locale has an empty slate. + const i18nEn = createI18n({ locale: "en" }); + expect(i18nEn.t("action.save")).toBe("Save"); + }); + + it("returns the key itself when no locale has it", () => { + const i18nEn = createI18n({ locale: "en" }); + // @ts-expect-error – intentionally unknown key to exercise the fallback path + const out: string = i18nEn.t("some.missing.key"); + expect(out).toBe("some.missing.key"); + }); + + it("switches locale at runtime and notifies subscribers", () => { + const inst = createI18n({ locale: "zh-CN" }); + expect(inst.t("confirm.cancel")).toBe("取消"); + + let notified = 0; + const unsubscribe = inst.subscribe(() => notified++); + + inst.setLocale("en"); + expect(inst.locale).toBe("en"); + expect(inst.t("confirm.cancel")).toBe("Cancel"); + expect(notified).toBe(1); + + unsubscribe(); + inst.setLocale("zh-CN"); + expect(notified).toBe(1); + expect(inst.t("confirm.cancel")).toBe("取消"); + }); +}); diff --git a/packages/shared/src/i18n/index.ts b/packages/shared/src/i18n/index.ts new file mode 100644 index 000000000000..c1ce8af484ab --- /dev/null +++ b/packages/shared/src/i18n/index.ts @@ -0,0 +1,73 @@ +import { enMessages, type MessageKey } from "./en.ts"; +import { zhCNMessages } from "./zh-CN.ts"; + +export type { MessageKey } from "./en.ts"; + +export type Locale = "en" | "zh-CN"; + +export const SUPPORTED_LOCALES: readonly Locale[] = ["en", "zh-CN"] as const; + +/** Language shown when no locale is resolved (per project decision). */ +export const DEFAULT_LOCALE: Locale = "zh-CN"; + +type Params = Record; + +type Listener = () => void; + +/** + * Lightweight, zero-dependency, framework-agnostic message catalog usable from + * web, desktop (which renders web), and mobile. Missing keys fall through the + * active catalog -> en -> the key itself, so lookups never throw. + * + * Conventions (locale files, t() usage, key naming, scope): docs/internals/i18n.md + */ +export class I18n { + private _locale: Locale; + private readonly listeners = new Set(); + + constructor(options: { locale: Locale }) { + this._locale = options.locale; + } + + get locale(): Locale { + return this._locale; + } + + /** Switch language and notify subscribers (used by React via useI18n). */ + setLocale(locale: Locale): void { + if (locale === this._locale) return; + this._locale = locale; + for (const listener of this.listeners) listener(); + } + + /** Subscribe to future locale changes. Returns an unsubscribe function. */ + subscribe(listener: Listener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + /** + * Translate a key. Lookup order: active locale, then English fallback, then + * the raw key. Supports `{name}` interpolation via params. + */ + t = (key: MessageKey, params?: Params): string => { + const active = this._locale === "zh-CN" ? zhCNMessages : {}; + let text = active[key] ?? enMessages[key] ?? key; + if (params) { + for (const [name, value] of Object.entries(params)) { + text = text.split(`{${name}}`).join(String(value)); + } + } + return text; + }; +} + +/** Default instance at the project default locale (zh-CN). */ +export const i18n = new I18n({ locale: DEFAULT_LOCALE }); + +/** Build a catalog for a specific locale. */ +export function createI18n(options: { locale?: Locale } = {}): I18n { + return new I18n({ locale: options.locale ?? DEFAULT_LOCALE }); +} diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts new file mode 100644 index 000000000000..08109badf1cc --- /dev/null +++ b/packages/shared/src/i18n/zh-CN.ts @@ -0,0 +1,127 @@ +import type { MessageKey } from "./en.ts"; + +/** + * zh-CN messages. + * + * `Partial` so a key may be omitted here and transparently fall back to the + * English dictionary at lookup time. Keys are enforced to exist in en.ts via + * the `MessageKey` union. + */ +export const zhCNMessages: Partial> = { + "app.name": "T3 Code", + "action.save": "保存", + "action.cancel": "取消", + "settings.title": "设置", + "chat.sendMessage": "发送消息", + "topbar.runOn": "运行于", + "confirm.title": "确认操作", + "confirm.cancel": "取消", + "confirm.confirm": "确认", + "prList.empty": "没有任何拉取请求", + "chat.empty.noActiveThread": "没有活动会话", + "chat.empty.title": "选择一个会话继续", + "chat.empty.description": "选择现有会话,或新建一个会话开始。", + "sidebar.allProjects": "全部项目", + "sidebar.noProjects": "还没有项目", + "sidebar.addProject": "添加项目", + "sidebar.noThreads": "还没有会话", + "sidebar.noThreadsInProject": "{project} 下还没有会话", + "sidebar.showMore": "再显示 {count} 个", + "sidebar.pullRequests": "拉取请求", + "sidebar.usage": "用量", + "sidebar.back": "返回", + "sidebar.projects": "项目", + "sidebar.search": "搜索", + "draft.hero.buildInPrefix": "我们可以在 ", + "draft.hero.buildInSuffix": " 中构建什么?", + "draft.hero.startSuffix": " 以开始", + "draft.hero.addProjectToStart": "添加一个项目以开始", + "draft.chooseProject": "选择项目", + "draft.changeProject": "更改项目", + "draft.addAProject": "添加项目", + "draft.newProject": "新建项目", + "chat.contextWindow.title": "上下文窗口", + "chat.contextWindow.totalProcessed": "累计处理", + "chat.contextWindow.usageAria": "上下文窗口使用量", + "settings.section.general": "通用", + "settings.section.appearance": "外观", + "settings.section.keybindings": "快捷键", + "settings.section.providers": "提供商", + "settings.section.integrations": "集成", + "settings.section.sourceControl": "源代码管理", + "settings.section.connections": "连接", + "settings.section.archive": "归档", + "settings.section.browser": "浏览器", + "settings.section.about": "关于", + "settings.option.monospaceFont": "等宽字体", + "settings.breadcrumb.root": "设置", + "settings.breadcrumb.diagnostics": "诊断", + "settings.search.placeholder": "搜索", + "settings.search.noResults": "未找到相关设置", + "settings.option.colorScheme": "配色方案", + "settings.option.themes": "主题", + "settings.option.contrast": "对比度", + "settings.option.glassOpacity": "玻璃透明度", + "settings.option.environmentIdentification": "环境标识", + "settings.option.interfaceFont": "界面字体", + "settings.option.promptFont": "提示词字体", + "settings.option.codeFont": "代码字体", + "settings.option.terminalFont": "终端字体", + "settings.option.fontSmoothing": "字体平滑", + "settings.option.wordWrap": "自动换行", + "settings.option.projectGrouping": "项目分组", + "settings.option.autoSettleInactiveThreads": "自动归档不活跃会话", + "settings.option.autoSettleMergedThreads": "自动归档已合并会话", + "settings.option.timeFormat": "时间格式", + "settings.option.hideWhitespaceChanges": "隐藏空白字符变更", + "settings.option.skillsInSlashMenu": "在斜杠菜单中显示技能", + "settings.option.providerUpdateChecks": "提供商更新检查", + "settings.option.newThreads": "新会话", + "settings.option.startFromOrigin": "从主分支开始", + "settings.option.addProjectStartsIn": "添加项目的起始目录", + "settings.option.archiveConfirmation": "归档确认", + "settings.option.deleteConfirmation": "删除确认", + "settings.option.quitConfirmation": "按住以退出", + "settings.option.textGenerationModel": "文本生成模型", + "settings.option.diagnostics": "诊断", + "settings.option.legacyPlanMode": "计划模式(旧版)", + "settings.option.legacyTokenStreaming": "逐字流式输出(旧版)", + "settings.option.legacySidebar": "侧边栏(旧版)", + "settings.option.keybindings": "快捷键", + "settings.option.providers": "提供商", + "settings.option.agentBrowserAccess": "代理浏览器访问", + "settings.option.browserDefaultViewport": "默认浏览器视口", + "settings.option.browserDefaultZoom": "默认浏览器缩放", + "settings.option.browserDefaultAppearance": "默认浏览器外观", + "settings.option.browserAutoShowFloatingPreview": "自动显示浮动预览", + "settings.option.sourceControl": "源代码管理", + "settings.option.remoteEnvironments": "远程环境", + "settings.option.archivedThreads": "已归档会话", + "action.ok": "确定", + "desktop.menu.checkForUpdates": "检查更新…", + "desktop.menu.settings": "设置…", + "desktop.menu.file": "文件", + "desktop.menu.view": "视图", + "desktop.menu.actualSize": "实际大小", + "desktop.menu.zoomIn": "放大", + "desktop.menu.zoomOut": "缩小", + "desktop.update.upToDateTitle": "已是最新版本!", + "desktop.update.upToDateMessage": "T3 Code {version} 当前已是最新可用版本。", + "desktop.update.checkFailedTitle": "检查更新失败", + "desktop.update.checkFailedMessage": "无法检查更新。", + "desktop.update.checkFailedDetail": "发生未知错误,请稍后重试。", + "desktop.update.unavailableTitle": "更新不可用", + "desktop.update.unavailableMessage": "当前无法进行自动更新。", + "desktop.contextMenu.noSuggestions": "暂无建议", + "desktop.contextMenu.copyLink": "复制链接", + "desktop.contextMenu.copyImage": "复制图片", + "desktop.startup.failedTitle": "T3 Code 启动失败", + "desktop.startup.failedDetail": "阶段:{stage}\n{message}{detail}", + "desktop.wsl.unavailableTitle": "WSL 后端仍不可用", + "desktop.wsl.unavailableMessage": + "{reason}\n\nT3 Code 本次启动将改用 Windows 后端,并将在下次启动时重试 WSL。", + "desktop.wsl.couldNotStartTitle": "WSL 后端无法启动", + "desktop.wsl.couldNotStartMessage": + "{reason}\n\n已回退到 Windows 后端以确保 T3 Code 可以打开。修复 WSL 发行版后,请在“设置 > 连接”中重新启用 WSL 后端。", + "desktop.splash.connecting": "正在连接 WSL…", +}; From a032c77e7759a617198cf874d8aa27be3fec4339 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BC=8A=E9=9B=B7=5Foffice?= <239516043+1465586445-jpg@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:48:54 +0800 Subject: [PATCH 2/5] docs(internals): add i18n conventions --- docs/internals/i18n.md | 73 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 docs/internals/i18n.md diff --git a/docs/internals/i18n.md b/docs/internals/i18n.md new file mode 100644 index 000000000000..09f00753b42d --- /dev/null +++ b/docs/internals/i18n.md @@ -0,0 +1,73 @@ +# i18n 约定(zh-CN 特化版) + +本 fork 的国际化约定。核心代码见 `packages/shared/src/i18n/`,React 集成见 `apps/web/src/hooks/useI18n.ts`。 + +## 目标 + +**本地 Desktop 简体中文为第一目标**。Web 复用收益是附带价值,不是第一目标。因此第一版只保证 Desktop 主界面 + 关键 Electron 文案的中文,其余暂缓。 + +## 文案分两类 + +| 类别 | 覆盖 | 接入方式 | 语言文件 | +| ----------------------------- | ---------------------------------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------- | +| **共享 UI 文案** | web 与 Desktop renderer(Electron 里渲染的就是 apps/web)共用 | 在 apps/web 组件里 `const { t } = useI18n()` | `en.ts` + `zh-CN.ts` | +| **Desktop/Electron 特有文案** | 主进程原生层:窗口标题、原生菜单、对话框、更新提示、右键菜单、启动闪屏 | 主进程直接 `import { i18n } from "@t3tools/shared/i18n"` | 同样在 `en.ts` + `zh-CN.ts`,key 用 `desktop.` 前缀区分 | + +## 语言文件位置 + +- `packages/shared/src/i18n/en.ts` —— **规范字典**:key 的唯一来源(`MessageKey = keyof typeof enMessages`),缺 key 回退到它。 +- `packages/shared/src/i18n/zh-CN.ts` —— 中文,`Partial>`,某 key 可省略并透明回退 en。 +- `packages/shared/src/i18n/index.ts` —— `I18n` 类、`i18n` 单例、`createI18n`、`SUPPORTED_LOCALES`、`DEFAULT_LOCALE`。 +- 导入路径:`@t3tools/shared/i18n`(映射见 `packages/shared/package.json` `exports` 的 `"./i18n"`)。 + +## t() 用法 + +React 组件(web / desktop renderer): + +```ts +import { useI18n } from "~/hooks/useI18n"; + +export function SomeButton() { + const { t } = useI18n(); + return ; +} +``` + +非 React(桌面主进程 / node 环境): + +```ts +import { i18n } from "@t3tools/shared/i18n"; +const label = i18n.t("desktop.menu.file"); +``` + +插值:`t("greeting", { name: "T3" })`,模板里用 `{name}` 占位。 + +## key 命名约定 + +- **首段 = 界面域**(小写),短语段用**小驼峰**,点号分层。 +- 共享 UI 通用段:`app.*`、`action.*`、`common.*`。 +- 按界面域分:`chat.*`、`settings.*`、`topbar.*`、`confirm.*`、`prList.*`。 +- **Desktop 特有统一加 `desktop.` 前缀**:`desktop.menu.*`、`desktop.window.*`、`desktop.update.*`、`desktop.dialog.*`、`desktop.splash.*`。加前缀一是明确归属主进程,二是便于将来 tree-shake / 过滤。 +- 新增 key:先在 `en.ts` 注册(否则类型报错),再在 `zh-CN.ts` 补中文。 + +## 默认语言与回退 + +- 默认 `zh-CN`(`DEFAULT_LOCALE`)。 +- 查找链:**活动语言 → `en` → key 本身**。永不抛错。 +- 切换语言会通知订阅者触发 React 重渲染(`setLocale` + `subscribe`)。 + +## 第一版范围 + +- **做**:Desktop 主界面(apps/web 里探路的 6 个点)+ 关键 Electron 文案(窗口标题、原生应用菜单、更新对话框、右键菜单、启动失败 / WSL 回退错误框、启动闪屏)。 +- **不做**:移动端、全站文档翻译、非关键路径。 + +## 打包 / 开发模式的加载方式(重要) + +语言包**不是运行时加载的 JSON**,而是**编译期静态内联的 TS 模块**(`index.ts` 直接 `import enMessages / zhCNMessages`)。整个 i18n 目录**没有 `fetch`、`fs`、动态 `import()`**。 + +因此: + +- **开发模式**与**打包/生产模式**都不需要外部 locale 资源文件——字典随构建打进 web bundle 和桌面主进程 bundle。 +- Electron renderer 就是 web 的产物,天然带字典;主进程要复用 `@t3tools/shared/i18n` 也是静态链接。 +- 语言切换器(`apps/web/src/components/DevLanguageSwitcher.tsx`)用 `import.meta.env.DEV` 只在开发态显示,不进入生产。 +- 结论:现有实现**无需修复**即可在 Desktop 开发/打包模式稳定加载语言包。 From 76216d766d161f20f396c5f9324985898a7f9854 Mon Sep 17 00:00:00 2001 From: EclipticAxis <257863549@qq.com> Date: Wed, 9 Sep 2026 06:12:28 +0000 Subject: [PATCH 3/5] feat(i18n): add keys for web i18n localizations Add zh-CN/en message keys for the newly-localized web surfaces: composer placeholders & send states, PR-list empty states, context-window compact, sidebar search, settings section/search labels, and the branch toolbar. --- packages/shared/src/i18n/en.ts | 57 +++++++++++++++++++++++++++++++ packages/shared/src/i18n/zh-CN.ts | 54 +++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/packages/shared/src/i18n/en.ts b/packages/shared/src/i18n/en.ts index 663513aa378b..cf7c59e987f6 100644 --- a/packages/shared/src/i18n/en.ts +++ b/packages/shared/src/i18n/en.ts @@ -9,11 +9,40 @@ export const enMessages = { "action.cancel": "Cancel", "settings.title": "Settings", "chat.sendMessage": "Send message", + "chat.composer.connecting": "Connecting", + "chat.composer.preparingWorktree": "Preparing worktree", + "chat.composer.sending": "Sending", + "chat.composer.placeholder.approval": "Resolve this approval request to continue", + "chat.composer.placeholder.chooseOption": "Choose an option above", + "chat.composer.placeholder.customAnswer": + "Type your own answer, or leave this blank to use the selected option", + "chat.composer.placeholder.planFeedback": + "Add feedback to refine the plan, or leave this blank to implement it", + "chat.composer.placeholder.chooseProject": "Choose a project above to start a thread", + "chat.composer.placeholder.enableProvider": "Enable a provider in Settings to send a message", + "chat.composer.placeholder.disconnected": "Ask for changes, send follow-ups, or attach images", + "chat.composer.placeholder.default": + "Ask anything, @tag files/folders, $use skills, or / for commands", "topbar.runOn": "Run on", "confirm.title": "Confirm action", "confirm.cancel": "Cancel", "confirm.confirm": "Confirm", + "confirm.defaultRequiresConfirmation": "Please confirm to continue.", "prList.empty": "No pull requests", + "prList.noProjectsTitle": "No projects connected", + "prList.noProjectsDescription": "Connect a project to start browsing pull requests.", + "prList.addProject": "Add project", + "prList.searchingCaption": "Searching “{query}”…", + "prList.noMatches": "No matches for “{query}”", + "prList.noMatchesDescription": "Try a different search or add a new pull request.", + "prList.clearSearch": "Clear search", + "prList.checking": "Checking…", + "prList.checkAgain": "Check again", + "prList.filtersEmpty": "No pull requests match the current filters", + "prList.filtersEmptyDescription": "Adjust the filters to see more pull requests.", + "prList.allDescription": "Pull requests from all connected projects appear here.", + "prList.loading": "Loading…", + "prList.loadMore": "Load more", "chat.empty.noActiveThread": "No active thread", "chat.empty.title": "Pick a thread to continue", "chat.empty.description": "Select an existing thread or create a new one to get started.", @@ -28,6 +57,7 @@ export const enMessages = { "sidebar.back": "Back", "sidebar.projects": "Projects", "sidebar.search": "Search", + "sidebar.searchThreads": "Search threads", "draft.hero.buildInPrefix": "What should we build in ", "draft.hero.buildInSuffix": "?", "draft.hero.startSuffix": " to start", @@ -39,6 +69,28 @@ export const enMessages = { "chat.contextWindow.title": "Context Window", "chat.contextWindow.totalProcessed": "Total processed", "chat.contextWindow.usageAria": "Context window usage", + "chat.contextWindow.compact": "Compact", + "branchToolbar.searchRefs": "Search refs…", + "branchToolbar.noRefs": "No refs found.", + "branchToolbar.loadingRefs": "Loading refs…", + "branchToolbar.loadingMoreRefs": "Loading more refs…", + "branchToolbar.showingOf": "Showing {count} of {total} refs", + "branchToolbar.copyBranchName": "Copy branch name", + "branchToolbar.branchNameCopied": "Branch name copied", + "branchToolbar.copyBranchNameFailed": "Failed to copy branch name", + "branchToolbar.switchRefFailed": "Failed to switch ref.", + "branchToolbar.createRefFailed": "Failed to create and switch ref.", + "branchToolbar.createNewRef": 'Create new ref "{name}"', + "branchToolbar.checkout": "Checkout {type}", + "branchToolbar.startFromOrigin": "Start from origin", + "branchToolbar.startWorktreeFromOrigin": "Start worktree from origin", + "branchToolbar.worktreeFromOriginHint": + "Creates the worktree from the latest matching branch on origin instead of your local branch.", + "branchToolbar.openPr": "Open {type} #{number} ({state})", + "branchToolbar.badgeCurrent": "current", + "branchToolbar.badgeWorktree": "worktree", + "branchToolbar.badgeRemote": "remote", + "branchToolbar.badgeDefault": "default", "settings.section.general": "General", "settings.section.appearance": "Appearance", "settings.section.keybindings": "Keybindings", @@ -47,6 +99,8 @@ export const enMessages = { "settings.section.sourceControl": "Source Control", "settings.section.connections": "Connections", "settings.section.archive": "Archive", + "settings.section.projects": "Projects", + "settings.section.snapShot": "SnapShots", "settings.section.browser": "Browser", "settings.section.about": "About", "settings.option.monospaceFont": "Monospace font", @@ -54,6 +108,9 @@ export const enMessages = { "settings.breadcrumb.diagnostics": "Diagnostics", "settings.search.placeholder": "Search", "settings.search.noResults": "No settings found", + "settings.search.settings": "Search settings", + "settings.search.clearSettings": "Clear settings search", + "settings.search.results": "Settings search results", "settings.option.colorScheme": "Color scheme", "settings.option.themes": "Themes", "settings.option.contrast": "Contrast", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index 08109badf1cc..2957212c477e 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -13,11 +13,37 @@ export const zhCNMessages: Partial> = { "action.cancel": "取消", "settings.title": "设置", "chat.sendMessage": "发送消息", + "chat.composer.connecting": "正在连接", + "chat.composer.preparingWorktree": "正在准备工作树", + "chat.composer.sending": "正在发送", + "chat.composer.placeholder.approval": "解决此审批请求以继续", + "chat.composer.placeholder.chooseOption": "请在上方选择一个选项", + "chat.composer.placeholder.customAnswer": "输入你自己的答案,或留空以使用所选选项", + "chat.composer.placeholder.planFeedback": "添加反馈以改进方案,或留空按原方案实施", + "chat.composer.placeholder.chooseProject": "请在上方选择一个项目以开始会话", + "chat.composer.placeholder.enableProvider": "在设置中启用提供商以发送消息", + "chat.composer.placeholder.disconnected": "提出更改、发送跟进,或附加图片", + "chat.composer.placeholder.default": "随便问,@引用文件/文件夹,$使用技能,或 / 使用命令", "topbar.runOn": "运行于", "confirm.title": "确认操作", "confirm.cancel": "取消", "confirm.confirm": "确认", + "confirm.defaultRequiresConfirmation": "请确认后继续。", "prList.empty": "没有任何拉取请求", + "prList.noProjectsTitle": "尚未连接项目", + "prList.noProjectsDescription": "连接项目后即可浏览拉取请求。", + "prList.addProject": "添加项目", + "prList.searchingCaption": "正在搜索“{query}”…", + "prList.noMatches": "未找到与“{query}”匹配的结果", + "prList.noMatchesDescription": "请尝试其他搜索词,或添加新的拉取请求。", + "prList.clearSearch": "清除搜索", + "prList.checking": "正在检查…", + "prList.checkAgain": "再次检查", + "prList.filtersEmpty": "没有符合当前筛选条件的拉取请求", + "prList.filtersEmptyDescription": "调整筛选条件以查看更多拉取请求。", + "prList.allDescription": "所有已连接项目的拉取请求都会显示在这里。", + "prList.loading": "正在加载…", + "prList.loadMore": "加载更多", "chat.empty.noActiveThread": "没有活动会话", "chat.empty.title": "选择一个会话继续", "chat.empty.description": "选择现有会话,或新建一个会话开始。", @@ -32,6 +58,7 @@ export const zhCNMessages: Partial> = { "sidebar.back": "返回", "sidebar.projects": "项目", "sidebar.search": "搜索", + "sidebar.searchThreads": "搜索会话", "draft.hero.buildInPrefix": "我们可以在 ", "draft.hero.buildInSuffix": " 中构建什么?", "draft.hero.startSuffix": " 以开始", @@ -43,6 +70,28 @@ export const zhCNMessages: Partial> = { "chat.contextWindow.title": "上下文窗口", "chat.contextWindow.totalProcessed": "累计处理", "chat.contextWindow.usageAria": "上下文窗口使用量", + "chat.contextWindow.compact": "压缩", + "branchToolbar.searchRefs": "搜索引用…", + "branchToolbar.noRefs": "未找到引用。", + "branchToolbar.loadingRefs": "正在加载引用…", + "branchToolbar.loadingMoreRefs": "正在加载更多引用…", + "branchToolbar.showingOf": "显示 {count} / {total} 个引用", + "branchToolbar.copyBranchName": "复制分支名", + "branchToolbar.branchNameCopied": "已复制分支名", + "branchToolbar.copyBranchNameFailed": "复制分支名失败", + "branchToolbar.switchRefFailed": "切换引用失败。", + "branchToolbar.createRefFailed": "创建并切换引用失败。", + "branchToolbar.createNewRef": "创建新引用“{name}”", + "branchToolbar.checkout": "检出 {type}", + "branchToolbar.startFromOrigin": "从主分支开始", + "branchToolbar.startWorktreeFromOrigin": "从主分支创建工作树", + "branchToolbar.worktreeFromOriginHint": + "从 origin 的最新匹配分支创建工作树,而不是你本地的分支。", + "branchToolbar.openPr": "打开 {type} #{number}({state})", + "branchToolbar.badgeCurrent": "当前", + "branchToolbar.badgeWorktree": "工作树", + "branchToolbar.badgeRemote": "远程", + "branchToolbar.badgeDefault": "默认", "settings.section.general": "通用", "settings.section.appearance": "外观", "settings.section.keybindings": "快捷键", @@ -51,6 +100,8 @@ export const zhCNMessages: Partial> = { "settings.section.sourceControl": "源代码管理", "settings.section.connections": "连接", "settings.section.archive": "归档", + "settings.section.projects": "项目", + "settings.section.snapShot": "快照", "settings.section.browser": "浏览器", "settings.section.about": "关于", "settings.option.monospaceFont": "等宽字体", @@ -58,6 +109,9 @@ export const zhCNMessages: Partial> = { "settings.breadcrumb.diagnostics": "诊断", "settings.search.placeholder": "搜索", "settings.search.noResults": "未找到相关设置", + "settings.search.settings": "搜索设置", + "settings.search.clearSettings": "清除设置搜索", + "settings.search.results": "设置搜索结果", "settings.option.colorScheme": "配色方案", "settings.option.themes": "主题", "settings.option.contrast": "对比度", From fc3799257702775265f8c3857a2ca039710a2458 Mon Sep 17 00:00:00 2001 From: EclipticAxis <257863549@qq.com> Date: Wed, 9 Sep 2026 06:12:39 +0000 Subject: [PATCH 4/5] feat(web): localize visible UI strings via i18n Route user-facing text in the sidebar, settings nav/search, confirm dialog, composer placeholder & primary actions, context-window meter, draft hero, no-active-thread and PR-list empty states, and the branch toolbar through useI18n()/t(), replacing hardcoded English. Add a useI18n hook to subscribe components to locale changes and drop the now-unused composerPlaceholder module. Adapt affected tests to run under the English catalog. --- .../BranchToolbarBranchSelector.tsx | 95 +++++++++++-------- apps/web/src/components/ConfirmDialogHost.tsx | 17 ++-- .../src/components/NoActiveThreadState.tsx | 12 ++- apps/web/src/components/Sidebar.tsx | 13 ++- apps/web/src/components/chat/ChatComposer.tsx | 22 ++--- .../chat/ComposerPrimaryActions.test.tsx | 8 +- .../chat/ComposerPrimaryActions.tsx | 10 +- .../chat/ContextWindowMeter.test.tsx | 8 +- .../components/chat/ContextWindowMeter.tsx | 12 ++- .../src/components/chat/DraftHeroHeadline.tsx | 23 +++-- .../PullRequestListEmptyState.test.tsx | 30 ++++-- .../pullRequest/PullRequestListEmptyState.tsx | 36 +++---- .../settings/SettingsFontPreviews.tsx | 5 +- .../settings/SettingsSidebarNav.tsx | 44 +++++---- apps/web/src/composerPlaceholder.ts | 2 - apps/web/src/hooks/useI18n.ts | 14 +++ 16 files changed, 219 insertions(+), 132 deletions(-) delete mode 100644 apps/web/src/composerPlaceholder.ts create mode 100644 apps/web/src/hooks/useI18n.ts diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index e9b5360cbc45..1578efd55998 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -23,6 +23,7 @@ import { import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; +import { useI18n } from "../hooks/useI18n"; import { readLocalApi } from "../localApi"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { shouldLoadNextBranchPageAfterScroll } from "../state/paginatedBranches"; @@ -97,6 +98,7 @@ export function BranchToolbarBranchSelector({ onCheckoutPullRequestRequest, onComposerFocusRequest, }: BranchToolbarBranchSelectorProps) { + const { t } = useI18n(); const startFromOriginSwitchId = useId(); const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop"); const updateThreadMetadata = useAtomCommand( @@ -332,37 +334,43 @@ export function BranchToolbarBranchSelector({ const [isBranchActionPending, startBranchActionTransition] = useTransition(); const totalBranchCount = branchRefState.data?.totalCount ?? 0; const branchStatusText = isInitialBranchesLoadPending - ? "Loading refs..." + ? t("branchToolbar.loadingRefs") : isFetchingNextPage - ? "Loading more refs..." + ? t("branchToolbar.loadingMoreRefs") : hasNextPage - ? `Showing ${refs.length} of ${totalBranchCount} refs` + ? t("branchToolbar.showingOf", { + count: refs.length, + total: totalBranchCount, + }) : null; // --------------------------------------------------------------------------- // Branch actions // --------------------------------------------------------------------------- - const copyBranchName = useCallback((branchName: string) => { - void writeTextToClipboard(branchName, "branch name").then( - (didCopy) => { - if (!didCopy) return; - toastManager.add({ - type: "success", - title: "Branch name copied", - description: branchName, - }); - }, - (error: unknown) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to copy branch name", - description: toBranchActionErrorMessage(error), - }), - ); - }, - ); - }, []); + const copyBranchName = useCallback( + (branchName: string) => { + void writeTextToClipboard(branchName, "branch name").then( + (didCopy) => { + if (!didCopy) return; + toastManager.add({ + type: "success", + title: t("branchToolbar.branchNameCopied"), + description: branchName, + }); + }, + (error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: t("branchToolbar.copyBranchNameFailed"), + description: toBranchActionErrorMessage(error), + }), + ); + }, + ); + }, + [t], + ); const handleBranchContextMenu = useCallback( (event: ReactMouseEvent, branchName: string | null) => { @@ -372,13 +380,13 @@ export function BranchToolbarBranchSelector({ event.preventDefault(); event.stopPropagation(); const items: ContextMenuItem<"copy-branch-name">[] = [ - { id: "copy-branch-name", label: "Copy branch name", icon: "copy" }, + { id: "copy-branch-name", label: t("branchToolbar.copyBranchName"), icon: "copy" }, ]; void api.contextMenu.show(items, { x: event.clientX, y: event.clientY }).then((action) => { if (action === "copy-branch-name") copyBranchName(branchName); }); }, - [copyBranchName], + [copyBranchName, t], ); const runBranchAction = (action: () => Promise) => { @@ -442,7 +450,7 @@ export function BranchToolbarBranchSelector({ toastManager.add( stackedThreadToast({ type: "error", - title: "Failed to switch ref.", + title: t("branchToolbar.switchRefFailed"), description: toBranchActionErrorMessage(squashAtomCommandFailure(checkoutResult)), }), ); @@ -478,7 +486,7 @@ export function BranchToolbarBranchSelector({ toastManager.add( stackedThreadToast({ type: "error", - title: "Failed to create and switch ref.", + title: t("branchToolbar.createRefFailed"), description: toBranchActionErrorMessage(squashAtomCommandFailure(createBranchResult)), }), ); @@ -625,7 +633,11 @@ export function BranchToolbarBranchSelector({ // Action-oriented tooltip (the pill opens the PR), distinct from the sidebar's // state-description tooltip. const branchPrTooltip = branchPr - ? `Open ${sourceControlPresentation.terminology.singular} #${branchPr.number} (${branchPr.state})` + ? t("branchToolbar.openPr", { + type: sourceControlPresentation.terminology.singular, + number: branchPr.number, + state: branchPr.state, + }) : ""; const openPrLink = useOpenPrLink(threadRef); @@ -652,7 +664,9 @@ export function BranchToolbarBranchSelector({ - Checkout {sourceControlPresentation.terminology.singular} + {t("branchToolbar.checkout", { + type: sourceControlPresentation.terminology.singular, + })} {prReference} @@ -670,7 +684,7 @@ export function BranchToolbarBranchSelector({ className="pe-1.5" onClick={() => createRef(trimmedBranchQuery)} > - Create new ref "{newRefName}" + {t("branchToolbar.createNewRef", { name: newRefName })} ); } @@ -681,13 +695,13 @@ export function BranchToolbarBranchSelector({ const hasSecondaryWorktree = refName.worktreePath && activeProjectCwd && refName.worktreePath !== activeProjectCwd; const badge = refName.current - ? "current" + ? t("branchToolbar.badgeCurrent") : hasSecondaryWorktree - ? "worktree" + ? t("branchToolbar.badgeWorktree") : refName.isRemote - ? "remote" + ? t("branchToolbar.badgeRemote") : refName.isDefault - ? "default" + ? t("branchToolbar.badgeDefault") : null; return (
- No refs found. + {t("branchToolbar.noRefs")}
@@ -867,21 +881,20 @@ export function BranchToolbarBranchSelector({ > onStartFromOriginChange(Boolean(checked))} /> } /> - Creates the worktree from the latest matching branch on origin instead of your local - branch. + {t("branchToolbar.worktreeFromOriginHint")} ) : null} diff --git a/apps/web/src/components/ConfirmDialogHost.tsx b/apps/web/src/components/ConfirmDialogHost.tsx index 7babc62fd2ea..6fdd64b5a52d 100644 --- a/apps/web/src/components/ConfirmDialogHost.tsx +++ b/apps/web/src/components/ConfirmDialogHost.tsx @@ -1,5 +1,7 @@ import { useEffect, useSyncExternalStore } from "react"; +import type { I18n } from "@t3tools/shared/i18n"; +import { useI18n } from "../hooks/useI18n"; import { completeConfirmDialogClose, readConfirmDialogState, @@ -23,7 +25,7 @@ type ConfirmationCopy = { readonly description: string | null; }; -function resolveConfirmDialogCopy(message: string): ConfirmationCopy { +function resolveConfirmDialogCopy(message: string, t: I18n["t"]): ConfirmationCopy { const normalizedMessage = message.trim(); const lines = normalizedMessage.split("\n"); const questionLineIndex = lines.findIndex((line) => line.trim().endsWith("?")); @@ -46,12 +48,13 @@ function resolveConfirmDialogCopy(message: string): ConfirmationCopy { } return { - title: "Confirm action", - description: normalizedMessage || "This action requires your confirmation.", + title: t("confirm.title"), + description: normalizedMessage || t("confirm.defaultRequiresConfirmation"), }; } export function ConfirmDialogHost() { + const { t } = useI18n(); const state = useSyncExternalStore( subscribeConfirmDialog, readConfirmDialogState, @@ -60,7 +63,7 @@ export function ConfirmDialogHost() { useEffect(() => registerConfirmDialogHost(), []); - const copy = resolveConfirmDialogCopy(state.status === "idle" ? "" : state.message); + const copy = resolveConfirmDialogCopy(state.status === "idle" ? "" : state.message, t); const confirmVariant = state.status === "idle" ? "default" : state.variant; const onCancel = () => respondToConfirmDialog(false); const onConfirm = () => respondToConfirmDialog(true); @@ -85,9 +88,11 @@ export function ConfirmDialogHost() { ) : null} - }>Cancel + }> + {t("confirm.cancel")} + diff --git a/apps/web/src/components/NoActiveThreadState.tsx b/apps/web/src/components/NoActiveThreadState.tsx index 3e7b0769d482..0efb2119d64f 100644 --- a/apps/web/src/components/NoActiveThreadState.tsx +++ b/apps/web/src/components/NoActiveThreadState.tsx @@ -1,19 +1,23 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty"; import { SidebarInset } from "./ui/sidebar"; import { isElectron } from "../env"; +import { useI18n } from "../hooks/useI18n"; import { WorkspacePageHeader } from "./WorkspacePageHeader"; export function NoActiveThreadState() { + const { t } = useI18n(); return (
{isElectron ? ( - No active thread + + {t("chat.empty.noActiveThread")} + ) : (
- No active thread + {t("chat.empty.noActiveThread")}
)} @@ -22,9 +26,9 @@ export function NoActiveThreadState() {
- Pick a thread to continue + {t("chat.empty.title")} - Select an existing thread or create a new one to get started. + {t("chat.empty.description")}
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ebc1078e672b..01b2c6930569 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -113,6 +113,7 @@ import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { useClientSettings } from "../hooks/useSettings"; +import { useI18n } from "../hooks/useI18n"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useLocalStorage } from "../hooks/useLocalStorage"; import { useNowMinute } from "../hooks/useNowMinute"; @@ -2087,6 +2088,8 @@ export default function Sidebar() { const threads = useThreadShells(); const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); + const i18n = useI18n(); + const { t } = i18n; const keybindings = useAtomValue(primaryServerKeybindingsAtom); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); @@ -2309,13 +2312,13 @@ export default function Sidebar() { // while the popup search filters the same collection. const projectScopeItems = useMemo( () => [ - { value: "all", label: "All projects" }, + { value: "all", label: t("sidebar.allProjects") }, ...projectGroups.map((project) => ({ value: project.projectKey, label: project.displayName, })), ], - [projectGroups], + [projectGroups, i18n.locale], ); const projectGroupByScopeKey = useMemo( () => new Map(projectGroups.map((project) => [project.projectKey, project] as const)), @@ -4304,8 +4307,8 @@ export default function Sidebar() { setActiveSearchResultIndex(0); }} onKeyDown={handleThreadSearchKeyDown} - placeholder="Search" - aria-label="Search threads" + placeholder={t("sidebar.search")} + aria-label={t("sidebar.searchThreads")} role="combobox" aria-autocomplete="list" aria-expanded={isSearchingThreads && threadSearchResults.length > 0} @@ -4423,7 +4426,7 @@ export default function Sidebar() { )} - {scopedProjectGroup?.displayName ?? "All projects"} + {scopedProjectGroup?.displayName ?? t("sidebar.allProjects")} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 7b2e65f8bf3e..da7f2456b733 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -56,7 +56,6 @@ import { formatAssistantCitationForComposer, replaceTextRange, } from "../../composer-logic"; -import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; import { deriveComposerSendState, getAntigravitySendBlockReason, @@ -158,6 +157,7 @@ import { } from "../../lib/terminalContext"; import { useComposerPathSearch } from "../../lib/composerPathSearchState"; import { type ElementContextDraft } from "../../lib/elementContext"; +import { useI18n } from "../../hooks/useI18n"; import { ComposerPendingElementContexts } from "./ComposerPendingElementContexts"; import { ComposerPendingReviewComments } from "./ComposerPendingReviewComments"; import { ComposerPreviewAnnotationCards } from "./ComposerPreviewAnnotationCards"; @@ -1481,6 +1481,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onExpandImage, onFileOpen, } = props; + const { t } = useI18n(); const activeTasksProgress = props.threadSyncPhase === null ? props.activeTasksProgress : null; const activeTaskSteps = props.threadSyncPhase === null ? props.activeTaskSteps : null; // ------------------------------------------------------------------ @@ -2309,7 +2310,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) projectSelectionRequired || environmentUnavailable !== null || !composerSendState.hasSendableContent; - const collapsedComposerPrimaryActionLabel = "Send message"; + const collapsedComposerPrimaryActionLabel = t("chat.sendMessage"); const showMobilePendingAnswerActions = isMobileViewport && !isComposerCollapsedMobile && pendingPrimaryAction !== null; @@ -5717,21 +5718,20 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onPaste={onComposerPaste} placeholder={ isComposerApprovalState - ? (activePendingApproval?.detail ?? - "Resolve this approval request to continue") + ? (activePendingApproval?.detail ?? t("chat.composer.placeholder.approval")) : activePendingProgress ? isChoiceOnlyPendingQuestion - ? "Choose an option above" - : "Type your own answer, or leave this blank to use the selected option" + ? t("chat.composer.placeholder.chooseOption") + : t("chat.composer.placeholder.customAnswer") : showPlanFollowUpPrompt && activeProposedPlan - ? "Add feedback to refine the plan, or leave this blank to implement it" + ? t("chat.composer.placeholder.planFeedback") : projectSelectionRequired - ? "Choose a project above to start a thread" + ? t("chat.composer.placeholder.chooseProject") : showProviderUnavailable - ? "Enable a provider in Settings to send a message" + ? t("chat.composer.placeholder.enableProvider") : phase === "disconnected" - ? DISCONNECTED_COMPOSER_PLACEHOLDER - : "Ask anything, @tag files/folders, $use skills, or / for commands" + ? t("chat.composer.placeholder.disconnected") + : t("chat.composer.placeholder.default") } disabled={ isConnecting || diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx index 45ef93568cf6..4d7f4540b7fc 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx @@ -1,6 +1,12 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vite-plus/test"; +import { i18n } from "@t3tools/shared/i18n"; + +// The global i18n defaults to zh-CN; these assertions cover button state via the +// English aria-label, so run the file under the English catalog. +beforeAll(() => i18n.setLocale("en")); +afterAll(() => i18n.setLocale("zh-CN")); const stageArtworkState = vi.hoisted(() => ({ mode: "none" as "artwork" | "none", diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index 91c54b75ed03..272d1de60815 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -1,6 +1,7 @@ import { memo, type PointerEventHandler } from "react"; import { ChevronDownIcon, ChevronLeftIcon } from "lucide-react"; import { useEnvironmentIdentificationMode } from "~/hooks/useSettings"; +import { useI18n } from "~/hooks/useI18n"; import { cn } from "~/lib/utils"; import { StageBackdropButtonArt, useSidebarStageBackdropVariant } from "../SidebarStageBackdrop"; import { Button } from "../ui/button"; @@ -82,6 +83,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ : undefined; const environmentIdentificationMode = useEnvironmentIdentificationMode(); const isSendDisabled = sendDisabledReason !== null; + const { t } = useI18n(); const stageBackdropVariant = useSidebarStageBackdropVariant( environmentIdentificationMode === "artwork", ); @@ -242,12 +244,12 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ : sendDisabledReason ? sendDisabledReason : isConnecting - ? "Connecting" + ? t("chat.composer.connecting") : isPreparingWorktree - ? "Preparing worktree" + ? t("chat.composer.preparingWorktree") : isSendBusy - ? "Sending" - : "Send message" + ? t("chat.composer.sending") + : t("chat.sendMessage") } > {stageBackdropVariant ? ( diff --git a/apps/web/src/components/chat/ContextWindowMeter.test.tsx b/apps/web/src/components/chat/ContextWindowMeter.test.tsx index 90617ca63af1..1c6f13a5d7a1 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.test.tsx +++ b/apps/web/src/components/chat/ContextWindowMeter.test.tsx @@ -1,11 +1,17 @@ import { EventId, TurnId } from "@t3tools/contracts"; import type { ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it, vi } from "vite-plus/test"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vite-plus/test"; +import { i18n } from "@t3tools/shared/i18n"; import { deriveLatestContextWindowSnapshot } from "~/lib/contextWindow"; import { ContextWindowMeter } from "./ContextWindowMeter"; +// The global i18n defaults to zh-CN; assert against the English catalog so the +// copy-based expectations stay deterministic English strings. +beforeAll(() => i18n.setLocale("en")); +afterAll(() => i18n.setLocale("zh-CN")); + vi.mock("../ui/popover", () => ({ Popover: ({ children }: { children: ReactNode }) => children, PopoverPopup: ({ children }: { children: ReactNode }) => children, diff --git a/apps/web/src/components/chat/ContextWindowMeter.tsx b/apps/web/src/components/chat/ContextWindowMeter.tsx index 3e52f6e2d86d..2662d4bdc0cc 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.tsx +++ b/apps/web/src/components/chat/ContextWindowMeter.tsx @@ -4,6 +4,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { formatContextWindowCompactionMessage } from "./ContextWindowMeter.logic"; import { Minimize2Icon } from "lucide-react"; import { composerFloatingLayerProps } from "./composerEventScope"; +import { useI18n } from "~/hooks/useI18n"; function formatPercentage(value: number | null): string | null { if (value === null || !Number.isFinite(value)) { @@ -23,6 +24,7 @@ export function ContextWindowMeter(props: { compactDisabledReason?: string | null | undefined; }) { const { usage, modelDisplayName, onCompact, compactDisabled, compactDisabledReason } = props; + const { t } = useI18n(); const usedPercentage = formatPercentage(usage.usedPercentage); const normalizedPercentage = Math.max(0, Math.min(100, usage.usedPercentage ?? 0)); const radius = 9.75; @@ -93,7 +95,9 @@ export function ContextWindowMeter(props: { >
-
Context Window
+
+ {t("chat.contextWindow.title")} +
{usage.maxTokens !== null && usedPercentage ? (
{usedPercentage} @@ -116,7 +120,7 @@ export function ContextWindowMeter(props: { aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(normalizedPercentage)} - aria-label="Context window usage" + aria-label={t("chat.contextWindow.usageAria")} >
- Total processed + {t("chat.contextWindow.totalProcessed")} {formatContextWindowTokens(totalProcessedTokens)} @@ -147,7 +151,7 @@ export function ContextWindowMeter(props: { onClick={onCompact} >