diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f0018371..f8af442e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,3 +1,5 @@ +[**English**](CONTRIBUTING.md) | [**简体中文**](CONTRIBUTING.zh.md) + # Contributing to Browser Use Desktop Install these first: @@ -86,6 +88,39 @@ Please describe both a problem + solution! Include: If you plan to send a PR for the feature, open an issue first! and tag it in your PR. +## Internationalization (i18n) + +The app ships with English and Chinese (简体中文) interfaces. All UI strings use the **key = fallback** pattern — the English text itself serves as the translation key, so missing keys fall back gracefully to English. + +### Adding or editing strings + +1. Edit the component — wrap user-facing strings with `t('...')` (inside a React component) or `i18n.t('...')` (in module-level code). +2. Add the English key to `app/src/renderer/locales/en.json` (value = key). +3. Add the corresponding translation to `app/src/renderer/locales/zh.json` (or your target locale). +4. Run `cd app && task typecheck` to verify. + +### Adding a new locale + +1. Create `app/src/renderer/locales/{locale}.json` with all keys from `en.json`. +2. Add the locale option to the language dropdown in `app/src/renderer/hub/SettingsPane.tsx`. +3. Open a PR — one locale per PR please. + +### File structure + +``` +app/src/renderer/ + i18n.ts # i18next initialization + locales/ + en.json # English source strings + zh.json # Chinese (simplified) translations +``` + +### Adding i18n to a new file + +- **React components:** import `useTranslation` from `react-i18next`, call the `t()` function. +- **Module-level code (outside components):** import `i18n` from the relative path to `i18n.ts` (e.g. `../i18n` or `../../i18n` depending on file depth), call `i18n.t()`. +- All 5 renderer entry points (`hub/`, `onboarding/`, `pill/`, `popup/`, `logs/`) already wrap their content with ``, so any component inside them can use the hook. + ## Where to ask questions [Browser Use Discord](https://discord.com/invite/fqPB2NCNKV) diff --git a/CONTRIBUTING.zh.md b/CONTRIBUTING.zh.md new file mode 100644 index 00000000..eda2e991 --- /dev/null +++ b/CONTRIBUTING.zh.md @@ -0,0 +1,124 @@ +[**English**](CONTRIBUTING.md) | [**简体中文**](CONTRIBUTING.zh.md) + +# 贡献指南 + +首先安装以下工具: + +- [Task](https://taskfile.dev)(macOS: `brew install go-task`) +- Node.js 22 +- Yarn + +从根目录开始: + +```bash +git clone https://github.com/browser-use/desktop.git +cd desktop +task up +``` + +`task up` 会安装依赖、修补本地 Electron 应用包并启动桌面应用。 + +常用开发命令: + +```bash +task --list # 查看所有可用任务 +task lint # 运行 ESLint +task typecheck # 运行 tsc --noEmit +cd app && yarn test # 运行单元测试和集成测试 +task make # 构建平台安装包 +``` + +Linux 包在 Docker 中构建: + +```bash +task linux:make:docker +``` + +## Pull Request 规范 + +优秀的 PR 应聚焦且易于审查: + +1. 说明为什么需要这个改动。 +2. 将 PR 范围限定在一个 bug 修复、功能或清理上。 +3. UI 改动请附带截图或简短录屏。 + +在 Discord、Twitter 或邮件中联系 Browser Use 团队成员,可加快 PR 审查速度。 + +## 报告 Bug + +在 [browser-use/desktop/issues](https://github.com/browser-use/desktop/issues) 创建 issue,提供足够细节以便他人复现。 + +良好的 Bug 报告包括: + +- 应用版本 / git commit +- 您的操作系统 +- 您使用的提供商(如 Claude Code 或 Codex) +- 复现问题的清晰步骤 +- 期望结果与实际结果 +- Bug 可见时的截图或录屏 +- 相关日志(请打码密钥和私有 URL) + +有用的日志命令: + +```bash +task logs:all +task logs:app +task logs:browser +task logs:agent SESSION_ID=<会话ID> +task logs:engine +task logs:errors +``` + +默认日志路径: + +```text +~/Library/Application Support/Browser Use/logs +``` + +## 功能请求 + +请同时描述问题和解决方案!包括: + +- 当前应用为何无法很好解决该问题 +- 您期望的具体成果 +- 截图、录屏或示例网站(如有助说明) + +如果您打算为该功能提交 PR,请先创建 issue!并在 PR 中关联该 issue。 + +## 国际化(i18n) + +本应用内置简体中文和英文界面。所有 UI 字符串采用 **key = fallback** 模式——英文原文即翻译键,缺失的键会优雅地回退为英文。 + +### 添加或编辑字符串 + +1. 修改组件——用 `t('...')`(React 组件内)或 `i18n.t('...')`(模块级代码)包裹面向用户的字符串。 +2. 将英文键添加到 `app/src/renderer/locales/en.json`(值 = 键)。 +3. 将对应翻译添加到 `app/src/renderer/locales/zh.json`(或您要添加的语言)。 +4. 运行 `cd app && task typecheck` 验证。 + +### 添加新语言 + +1. 根据 `en.json` 创建 `app/src/renderer/locales/{语言}.json`,包含所有键。 +2. 在 `app/src/renderer/hub/SettingsPane.tsx` 的语言下拉框中添加新语言选项。 +3. 提交 PR——每次只添加一种语言。 + +### 文件结构 + +``` +app/src/renderer/ + i18n.ts # i18next 初始化 + locales/ + en.json # 英文源字符串 + zh.json # 简体中文翻译 +``` + +### 在新文件中使用 i18n + +- **React 组件:** 从 `react-i18next` 导入 `useTranslation`,调用 `t()` 函数。 +- **模块级代码(组件外):** 根据文件深度从相对路径导入 `i18n`(如 `../i18n` 或 `../../i18n`),调用 `i18n.t()`。 +- 全部 5 个渲染进程入口(`hub/`、`onboarding/`、`pill/`、`popup/`、`logs/`)已包裹 ``,内部的任何组件均可使用该 hook。 + +## 提问 + +[Browser Use Discord](https://discord.com/invite/fqPB2NCNKV) +[Twitter](https://x.com/browser_use) diff --git a/README.md b/README.md index ef8be4ea..9f90c84c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ desktop-app-banner +[**English**](README.md) | [**简体中文**](README.zh.md) + # Browser Use Desktop App > Run a team of browser agents on your desktop. @@ -37,19 +39,9 @@ Inbound message channels can trigger agent sessions automatically. - **WhatsApp** — text yourself with `@BU` to send and receive agent messages -## Development - -Requires [Task](https://taskfile.dev) (`brew install go-task`). - -```bash -task up # Install deps and start the app -``` - -Linux packages are built in Docker so local distro tools do not affect the output: +## Internationalization -```bash -task linux:make:docker -``` +This app supports English and 简体中文. Switch language in **Settings → Language**. ## License diff --git a/README.zh.md b/README.zh.md new file mode 100644 index 00000000..edb404b7 --- /dev/null +++ b/README.zh.md @@ -0,0 +1,48 @@ +desktop-app-banner + +[**English**](README.md) | [**简体中文**](README.zh.md) + +# Browser Use Desktop App + +> 在桌面上运行一组浏览器代理。 + +其他 AI 浏览器总想把浏览器和代理合二为一。你不用换 Chrome——这个只是代理端。 + +把你的 Cookie 导入全新的 Chromium,代理就能在你访问过的所有网站自动保持登录。还可以用全局快捷键从任何地方启动任务。 + +基于 [Browser Harness](https://github.com/browser-use/browser-harness) 构建。 + +CleanShot 2026-05-01 at 12 18 27@2x + +## 下载 + +[![下载 macOS 版](https://img.shields.io/badge/Download_for_macOS-000000?style=for-the-badge&logo=apple&logoColor=white)](https://github.com/browser-use/desktop/releases/latest/download/Browser-Use-arm64.dmg) +[![下载 Windows 版](https://img.shields.io/badge/Download_for_Windows-0078D4?style=for-the-badge&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA4OCA4OCI%2BPHBhdGggZmlsbD0iI2ZmZiIgZD0iTTAgMTIuNCAzNiA3LjV2MzQuOEgwem00MC4zLTUuNUw4OCAwdjQxLjhINDAuM3pNMCA0NS43aDM2djM0LjhMMCA3NS42em00MC4zLjVIODhWODhsLTQ3LjctNi43eiIvPjwvc3ZnPg%3D%3D&logoColor=white)](https://github.com/browser-use/desktop/releases/latest/download/Browser-Use-Setup.exe) +[![下载 Linux 版](https://img.shields.io/badge/Download_for_Linux-FCC624?style=for-the-badge&logo=linux&logoColor=black)](https://github.com/browser-use/desktop/releases/latest/download/Browser-Use-x64.AppImage) + +**macOS (Apple Silicon):** [Browser-Use-arm64.dmg](https://github.com/browser-use/desktop/releases/latest/download/Browser-Use-arm64.dmg) + +**Windows (x64):** [Browser-Use-Setup.exe](https://github.com/browser-use/desktop/releases/latest/download/Browser-Use-Setup.exe) + +**Linux:** [Browser-Use-x64.AppImage](https://github.com/browser-use/desktop/releases/latest/download/Browser-Use-x64.AppImage) 支持应用内自动更新。`.deb` 和 `.rpm` 包也发布在 GitHub Releases 中,可手动安装。 + +上述按钮和链接始终指向最新版本。 + +## 提供商 + +- **Anthropic** - Claude Code 订阅或 API 密钥 +- **Codex** - ChatGPT 订阅或 API 密钥 + +## 通道 + +消息通道可以自动触发代理会话。 + +- **WhatsApp** — 给自己发一条带 `@BU` 的消息,即可收发代理消息 + +## 国际化 + +本应用支持简体中文和 English 界面。在 **设置 → 语言** 中切换。 + +## 许可证 + +MIT diff --git a/app/package.json b/app/package.json index 0318e3a5..2dc13429 100644 --- a/app/package.json +++ b/app/package.json @@ -136,6 +136,8 @@ "remark-gfm": "^4.0.1", "ws": "^8.20.0", "zod": "^4.3.6", + "i18next": "^25.2.0", + "react-i18next": "^15.6.0", "zustand": "^5.0.13" } } diff --git a/app/src/renderer/components/base/Toast.tsx b/app/src/renderer/components/base/Toast.tsx index 912c1f93..a81ca88f 100644 --- a/app/src/renderer/components/base/Toast.tsx +++ b/app/src/renderer/components/base/Toast.tsx @@ -21,6 +21,7 @@ import React, { useRef, ReactNode, } from 'react'; +import { useTranslation } from 'react-i18next'; import { createPortal } from 'react-dom'; // --------------------------------------------------------------------------- @@ -86,6 +87,7 @@ interface ToastEntryProps { } function ToastEntry({ item, onDismiss }: ToastEntryProps) { + const { t } = useTranslation(); const timerRef = useRef | null>(null); const scheduleDismiss = useCallback(() => { @@ -129,7 +131,7 @@ function ToastEntry({ item, onDismiss }: ToastEntryProps) { ); diff --git a/app/src/renderer/components/empty/OfflineBanner.tsx b/app/src/renderer/components/empty/OfflineBanner.tsx index 322db902..2e22d2c1 100644 --- a/app/src/renderer/components/empty/OfflineBanner.tsx +++ b/app/src/renderer/components/empty/OfflineBanner.tsx @@ -7,19 +7,14 @@ */ import React, { useState, useEffect, useCallback } from 'react'; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -const OFFLINE_MESSAGE = "You're offline. Some features may not work." as const; -const DISMISS_LABEL = 'Dismiss offline banner' as const; +import { useTranslation } from 'react-i18next'; // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export function OfflineBanner(): React.ReactElement | null { + const { t } = useTranslation(); const [isOffline, setIsOffline] = useState(!navigator.onLine); const [dismissed, setDismissed] = useState(false); @@ -42,7 +37,7 @@ export function OfflineBanner(): React.ReactElement | null { className="offline-banner" role="status" aria-live="polite" - aria-label={OFFLINE_MESSAGE} + aria-label={t("You're offline. Some features may not work.")} > {/* Warning icon */} - {OFFLINE_MESSAGE} + {t("You're offline. Some features may not work.")} )} {onRerun && ( @@ -1101,8 +1104,8 @@ export function AgentPane({ session, focused, onRerun, onResume, onPause, onFoll @@ -1111,8 +1114,8 @@ export function AgentPane({ session, focused, onRerun, onResume, onPause, onFoll @@ -1121,8 +1124,8 @@ export function AgentPane({ session, focused, onRerun, onResume, onPause, onFoll @@ -1131,8 +1134,8 @@ export function AgentPane({ session, focused, onRerun, onResume, onPause, onFoll @@ -1158,14 +1161,14 @@ export function AgentPane({ session, focused, onRerun, onResume, onPause, onFoll {frameRect && (showErrorUi || browserDead || browserMissing || session.status === 'draft' || endedWithoutBrowser) && (() => { const isStarting = !showErrorUi && !browserDead && !browserMissing && session.status === 'draft'; const browserLine = browserDead - ? 'Browser ended' + ? i18n.t('Browser ended') : browserMissing - ? (session.status === 'stopped' || session.status === 'idle' || session.status === 'stuck' ? 'Browser stopped' : 'No browser started yet') - : (endedWithoutBrowser ? 'Browser ended' : null); + ? (session.status === 'stopped' || session.status === 'idle' || session.status === 'stuck' ? i18n.t('Browser stopped') : i18n.t('No browser started yet')) + : (endedWithoutBrowser ? i18n.t('Browser ended') : null); const primaryLine = showErrorUi ? friendlyError(session.error!) : isCancellation - ? 'Task was cancelled.' + ? i18n.t('Task was cancelled.') : browserLine; const subLine = (showErrorUi || isCancellation) ? browserLine : null; const showActions = !isStarting && (onRerun || canResume || (showErrorUi && isApiKeyError(session.error) && onOpenSettings)); @@ -1189,7 +1192,7 @@ export function AgentPane({ session, focused, onRerun, onResume, onPause, onFoll {isStarting ? ( <> - Browser starting… + {i18n.t('Browser starting…')} ) : ( {primaryLine} @@ -1206,12 +1209,12 @@ export function AgentPane({ session, focused, onRerun, onResume, onPause, onFoll onClick={() => onResume?.(session.id)} > - Resume + {t('Resume')} )} {showErrorUi && isApiKeyError(session.error) && onOpenSettings && ( )} {onRerun && ( @@ -1220,7 +1223,7 @@ export function AgentPane({ session, focused, onRerun, onResume, onPause, onFoll onClick={() => onRerun(session.id)} > - Rerun task + {t('Rerun task')} )} diff --git a/app/src/renderer/hub/BrowserCodeModelPicker.tsx b/app/src/renderer/hub/BrowserCodeModelPicker.tsx index b8cd3a3b..e7f56696 100644 --- a/app/src/renderer/hub/BrowserCodeModelPicker.tsx +++ b/app/src/renderer/hub/BrowserCodeModelPicker.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import kimiLogoDark from './kimi-color.svg'; import kimiLogoLight from './kimi-light.svg'; import minimaxLogo from './minimax-color.svg'; @@ -84,6 +85,7 @@ export function BrowserCodeModelPicker({ compact = false, onOpenChange, }: BrowserCodeModelPickerProps): React.ReactElement | null { + const { t } = useTranslation(); const [status, setStatus] = useState({ keys: {}, active: null, providers: [] }); const [loaded, setLoaded] = useState(false); const [popupId, setPopupId] = useState(null); @@ -176,7 +178,7 @@ export function BrowserCodeModelPicker({ type="button" className="browsercode-model-picker__toggle" onClick={(e) => { e.stopPropagation(); void openMenu(); }} - title={loadingStatus ? 'Loading BrowserCode model' : hasAnyKey ? `BrowserCode model: ${currentModelLabel}` : 'Set up BrowserCode model'} + title={loadingStatus ? t('Loading BrowserCode model') : hasAnyKey ? `${t('BrowserCode model')}: ${currentModelLabel}` : t('Set up BrowserCode model')} aria-haspopup="menu" aria-expanded={Boolean(popupId)} aria-busy={loadingStatus} @@ -207,6 +209,7 @@ export function BrowserCodeModelMenuContent({ onChanged, onClose, }: BrowserCodeModelMenuContentProps): React.ReactElement { + const { t } = useTranslation(); const [status, setStatus] = useState({ keys: {}, active: null, providers: [] }); const [loaded, setLoaded] = useState(false); const [drilledProviderId, setDrilledProviderId] = useState(null); @@ -265,7 +268,7 @@ export function BrowserCodeModelMenuContent({ return (
{loadingStatus ? ( -
+
) : !canSwitchModels && ( diff --git a/app/src/renderer/hub/CommandBar.tsx b/app/src/renderer/hub/CommandBar.tsx index 79b7d940..1edc2686 100644 --- a/app/src/renderer/hub/CommandBar.tsx +++ b/app/src/renderer/hub/CommandBar.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { useTranslation } from 'react-i18next'; import type { KeyBinding, ActionId, ScreenId } from './keybindings'; import { SCREEN_COMMANDS } from './keybindings'; @@ -11,13 +12,14 @@ interface CommandBarProps { } export function CommandBar({ screen, keybindings, onClose, onInvoke, formatShortcut }: CommandBarProps): React.ReactElement { + const { t } = useTranslation(); const actionIds = SCREEN_COMMANDS[screen] ?? []; const items = actionIds .map((id) => keybindings.find((kb) => kb.id === id)) .filter((kb): kb is KeyBinding => Boolean(kb)); return ( -
+
{items.map((kb) => ( ))}
@@ -40,8 +42,8 @@ export function CommandBar({ screen, keybindings, onClose, onInvoke, formatShort type="button" className="cmdhints__close" onClick={onClose} - aria-label="Hide command bar" - title="Hide command bar" + aria-label={t('Hide command bar')} + title={t('Hide command bar')} >
- {!embedded && Connections} + {!embedded && {t('Connections')}}
-

Model providers

+

{t('Model providers')}

@@ -713,7 +715,7 @@ export function ConnectionsPane({ />
- Anthropic + {t('Anthropic')}
{anthropicLoading ? ( @@ -721,18 +723,22 @@ export function ConnectionsPane({ ) : ( {editing - ? 'Enter a new key — it will be tested before saving' + ? t('Enter a new key — it will be tested before saving') : !claudeStatus.installed && authStatus.type !== 'none' - ? 'Credentials saved · Claude Code CLI not installed' + ? t('Credentials saved · Claude Code CLI not installed') : !claudeStatus.installed - ? 'Claude Code CLI not installed' + ? t('Claude Code CLI not installed') : claudeWaiting - ? 'Finish the OAuth flow in your browser…' + ? t('Finish the OAuth flow in your browser…') : authStatus.type === 'oauth' - ? `Signed in with Claude ${authStatus.subscriptionType === 'max' ? 'Max' : authStatus.subscriptionType === 'pro' ? 'Pro' : 'subscription'}` + ? authStatus.subscriptionType === 'max' + ? t('Signed in with Claude Max') + : authStatus.subscriptionType === 'pro' + ? t('Signed in with Claude Pro') + : t('Signed in with Claude subscription') : authStatus.type === 'apiKey' && authStatus.masked - ? `API key · ${authStatus.masked}` - : 'Not connected'} + ? t('API key · $1', { '1': authStatus.masked }) + : t('Not connected')} )}
@@ -744,7 +750,7 @@ export function ConnectionsPane({ onClick={() => handleInstallEngine('claude-code')} disabled={installingEngine === 'claude-code'} > - {installingEngine === 'claude-code' ? 'Installing…' : 'Install Claude Code'} + {installingEngine === 'claude-code' ? t('Installing…') : t('Install Claude Code')} )} {!anthropicLoading && !editing && claudeStatus.installed && authStatus.type === 'none' && ( @@ -753,7 +759,7 @@ export function ConnectionsPane({ onClick={handleUseClaudeCode} disabled={claudeWaiting} > - {claudeWaiting ? 'Waiting…' : 'Sign in with Claude'} + {claudeWaiting ? t('Waiting…') : t('Sign in with Claude')} )} {!anthropicLoading && !editing && claudeStatus.installed && authStatus.type === 'none' && ( @@ -761,7 +767,7 @@ export function ConnectionsPane({ className="conn-card__btn conn-card__btn--secondary" onClick={() => { setEditing(true); setDraftKey(''); setKeyStatus('idle'); setKeyError(null); }} > - Add API key + {t('Add API key')} )} {!anthropicLoading && !editing && claudeStatus.installed && authStatus.type === 'apiKey' && ( @@ -769,12 +775,12 @@ export function ConnectionsPane({ className="conn-card__btn conn-card__btn--primary" onClick={() => { setEditing(true); setDraftKey(''); setKeyStatus('idle'); setKeyError(null); }} > - Change + {t('Change')} )} {!anthropicLoading && !editing && authStatus.type !== 'none' && ( )} {!anthropicLoading && editing && ( @@ -782,7 +788,7 @@ export function ConnectionsPane({ className="conn-card__btn conn-card__btn--secondary" onClick={() => { setEditing(false); setDraftKey(''); setKeyError(null); setKeyStatus('idle'); }} > - Cancel + {t('Cancel')} )}
@@ -803,7 +809,7 @@ export function ConnectionsPane({ onClick={handleSaveKey} disabled={!draftKey.trim() || keyStatus === 'testing'} > - {keyStatus === 'testing' ? 'Testing...' : 'Save'} + {keyStatus === 'testing' ? t('Testing...') : t('Save')} {keyStatus === 'error' && keyError && ( {keyError} @@ -822,7 +828,7 @@ export function ConnectionsPane({
- BrowserCode + {t('BrowserCode')} 0 && browserCodeStatus.installed?.installed !== false ? 'conn-card__dot--connected' : installingEngine === 'browsercode' ? 'conn-card__dot--connecting' : 'conn-card__dot--disconnected'}`} />
{!browserCodeLoaded && browserCodeStatus.providers.length === 0 ? ( @@ -830,10 +836,12 @@ export function ConnectionsPane({ ) : ( {browserCodeStatus.installed?.installed === false - ? 'bcode CLI not installed' + ? t('bcode CLI not installed') : Object.keys(browserCodeStatus.keys).length === 0 - ? 'Connect a provider to use BrowserCode with your own API key' - : `${Object.keys(browserCodeStatus.keys).length} provider${Object.keys(browserCodeStatus.keys).length === 1 ? '' : 's'} connected`} + ? t('Connect a provider to use BrowserCode with your own API key') + : Object.keys(browserCodeStatus.keys).length === 1 + ? t('1 provider connected') + : t('$1 providers connected', { '1': Object.keys(browserCodeStatus.keys).length })} )}
@@ -844,7 +852,7 @@ export function ConnectionsPane({ onClick={() => handleInstallEngine('browsercode')} disabled={installingEngine === 'browsercode'} > - {installingEngine === 'browsercode' ? 'Installing…' : 'Install BrowserCode'} + {installingEngine === 'browsercode' ? t('Installing…') : t('Install BrowserCode')} )}
@@ -883,10 +891,10 @@ export function ConnectionsPane({
{isEditing - ? 'Enter a new key — it will be tested before saving' + ? t('Enter a new key — it will be tested before saving') : connected - ? `API key · ${entry.masked}` - : 'No API key connected'} + ? t('API key · $1', { '1': entry.masked }) + : t('No API key connected')}
@@ -895,7 +903,7 @@ export function ConnectionsPane({ className="conn-card__btn conn-card__btn--primary" onClick={() => handleStartEditBrowserCode(provider.id)} > - Connect + {t('Connect')} )} {!isEditing && connected && ( @@ -904,7 +912,7 @@ export function ConnectionsPane({ onClick={() => handleTestBrowserCodeKey(provider.id)} disabled={testingProviderId === provider.id} > - {testingProviderId === provider.id ? 'Testing...' : 'Test'} + {testingProviderId === provider.id ? t('Testing...') : t('Test')} )} {!isEditing && connected && ( @@ -912,7 +920,7 @@ export function ConnectionsPane({ className="conn-card__btn conn-card__btn--primary" onClick={() => handleStartEditBrowserCode(provider.id)} > - Update key + {t('Update key')} )} {!isEditing && connected && ( @@ -920,7 +928,7 @@ export function ConnectionsPane({ className="conn-card__btn conn-card__btn--secondary" onClick={() => handleRemoveBrowserCodeKey(provider.id)} > - Remove + {t('Remove')} )} {isEditing && ( @@ -928,7 +936,7 @@ export function ConnectionsPane({ className="conn-card__btn conn-card__btn--secondary" onClick={handleCancelEditBrowserCode} > - Cancel + {t('Cancel')} )}
@@ -956,7 +964,7 @@ export function ConnectionsPane({ onClick={() => handleSaveBrowserCode(provider.id)} disabled={!browserCodeKeyDraft.trim() || browserCodeKeyStatus === 'testing'} > - {browserCodeKeyStatus === 'testing' ? 'Testing...' : 'Save'} + {browserCodeKeyStatus === 'testing' ? t('Testing...') : t('Save')} {browserCodeKeyStatus === 'error' && browserCodeError && browserCodeErrorProviderId === provider.id && ( {browserCodeError} @@ -982,7 +990,7 @@ export function ConnectionsPane({ />
- OpenAI + {t('OpenAI')}
{openaiLoading ? ( @@ -990,20 +998,22 @@ export function ConnectionsPane({ ) : ( {openaiEditing - ? 'Enter a new key — it will be tested before saving' + ? t('Enter a new key — it will be tested before saving') : !codexStatus.installed && openaiStatus.present - ? 'API key saved · Codex CLI not installed' + ? t('API key saved · Codex CLI not installed') : !codexStatus.installed - ? 'Codex CLI not installed' + ? t('Codex CLI not installed') : openaiStatus.present && openaiStatus.masked - ? `API key · ${openaiStatus.masked}` + ? t('API key · $1', { '1': openaiStatus.masked }) : codexStatus.authed - ? `Signed in with ChatGPT subscription${codexStatus.version ? ` · Codex v${codexStatus.version}` : ''}` + ? codexStatus.version + ? t('Signed in with ChatGPT subscription · Codex v$1', { '1': codexStatus.version }) + : t('Signed in with ChatGPT subscription') : codexWaiting && codexDeviceCode - ? 'Enter the code shown below on the verification page.' + ? t('Enter the code shown below on the verification page.') : codexWaiting - ? 'Finish the OAuth flow in your browser…' - : 'Not connected'} + ? t('Finish the OAuth flow in your browser…') + : t('Not connected')} )}
@@ -1015,7 +1025,7 @@ export function ConnectionsPane({ onClick={() => handleInstallEngine('codex')} disabled={installingEngine === 'codex'} > - {installingEngine === 'codex' ? 'Installing…' : 'Install Codex'} + {installingEngine === 'codex' ? t('Installing…') : t('Install Codex')} )} {!openaiLoading && !openaiEditing && !openaiStatus.present && !codexStatus.authed && codexStatus.installed && ( @@ -1023,7 +1033,7 @@ export function ConnectionsPane({ className="conn-card__btn conn-card__btn--primary" onClick={handleCodexLoginPlain} > - {codexWaiting ? 'Restart' : 'Sign in with Codex'} + {codexWaiting ? t('Restart') : t('Sign in with Codex')} )} {!openaiLoading && !openaiEditing && codexStatus.installed && !openaiStatus.present && !codexStatus.authed && ( @@ -1031,7 +1041,7 @@ export function ConnectionsPane({ className="conn-card__btn conn-card__btn--secondary" onClick={() => { setOpenaiEditing(true); setOpenaiDraft(''); setOpenaiKeyStatus('idle'); setOpenaiError(null); }} > - Add API key + {t('Add API key')} )} {!openaiLoading && !openaiEditing && codexStatus.installed && openaiStatus.present && ( @@ -1039,17 +1049,17 @@ export function ConnectionsPane({ className="conn-card__btn conn-card__btn--secondary" onClick={() => { setOpenaiEditing(true); setOpenaiDraft(''); setOpenaiKeyStatus('idle'); setOpenaiError(null); }} > - Change API key + {t('Change API key')} )} {!openaiLoading && !openaiEditing && openaiStatus.present && !codexStatus.authed && ( )} {!openaiLoading && !openaiEditing && codexStatus.authed && ( )} {!openaiLoading && openaiEditing && ( @@ -1057,21 +1067,21 @@ export function ConnectionsPane({ className="conn-card__btn conn-card__btn--secondary" onClick={() => { setOpenaiEditing(false); setOpenaiDraft(''); setOpenaiError(null); setOpenaiKeyStatus('idle'); }} > - Cancel + {t('Cancel')} )} {codexDeviceCode && (
-
One-time code
+
{t('One-time code')}
{codexDeviceCode}
{codexVerificationUrl && (
- Verification page should have opened automatically.{' '} - If not, navigate to{' '} + {t('Verification page should have opened automatically.')}{' '} + {t('If not, navigate to')}{' '} {codexVerificationUrl}{' '} - and enter the code above. + {t('and enter the code above.')}
)}
@@ -1085,7 +1095,7 @@ export function ConnectionsPane({ className="codex-device-auth__link codex-device-auth__link--secondary codex-device-auth__fallback" onClick={handleCodexLoginDeviceAuth} > - Having trouble? Use device code flow instead + {t('Having trouble? Use device code flow instead')} )} {openaiEditing && ( @@ -1104,14 +1114,14 @@ export function ConnectionsPane({ onClick={handleSaveOpenai} disabled={!openaiDraft.trim() || openaiKeyStatus === 'testing'} > - {openaiKeyStatus === 'testing' ? 'Testing...' : 'Save'} + {openaiKeyStatus === 'testing' ? t('Testing...') : t('Save')} {openaiStatus.present && ( )} {openaiKeyStatus === 'error' && openaiError && ( @@ -1132,7 +1142,7 @@ export function ConnectionsPane({ className={embedded ? 'settings-page__section' : 'conn-pane__group'} >
-

Connections

+

{t('Connections')}

@@ -1144,36 +1154,36 @@ export function ConnectionsPane({ />
- WhatsApp + {t('WhatsApp')}
{waStatus === 'connected' && waIdentity - ? `Connected as +${waIdentity.replace(/(\d{1})(\d{3})(\d{3})(\d{4})/, '$1 ($2) $3-$4')} — text yourself with @BU to start a session (e.g. "@BU find me a flight to NYC"). Messages without @BU are ignored, so the chat still works as a notes app.` + ? t('Connected as $1', { '1': '+' + waIdentity.replace(/(\d{1})(\d{3})(\d{3})(\d{4})/, '$1 ($2) $3-$4') }) : waStatus === 'disconnected' - ? 'Connect WhatsApp so you can text yourself @BU to launch sessions and get agent notifications back in the same chat.' + ? t('Connect WhatsApp so you can text yourself @BU to launch sessions and get agent notifications back in the same chat.') : statusText}
{waStatus === 'disconnected' && ( )} {(waStatus === 'qr_ready' || waStatus === 'connecting') && ( )} {waStatus === 'connected' && ( )} {waStatus === 'error' && ( )}
@@ -1185,13 +1195,13 @@ export function ConnectionsPane({ WhatsApp QR code ) : ( -
Generating QR...
+
{t('Generating QR...')}
)}

- Open WhatsApp on your phone, go to Linked Devices, and scan this code. After linking, text yourself with @BU followed by a task (e.g. "@BU summarize my Linear inbox") to start a session — plain notes without @BU are ignored. + {t('Open WhatsApp on your phone, go to Linked Devices, and scan this code. After linking, text yourself with @BU followed by a task (e.g. "@BU summarize my Linear inbox") to start a session — plain notes without @BU are ignored.')}

)} @@ -1213,7 +1223,7 @@ export function ConnectionsPane({ className={embedded ? 'settings-page__section' : 'conn-pane__group'} >
-

Browser Sync

+

{t('Browser Sync')}

{cookieBrowserApi ? ( @@ -1226,11 +1236,11 @@ export function ConnectionsPane({
C
- Browser cookies + {t('Browser cookies')}
- Cookie sync is unavailable in this environment. + {t('Cookie sync is unavailable in this environment.')}
diff --git a/app/src/renderer/hub/Dashboard.tsx b/app/src/renderer/hub/Dashboard.tsx index 83b666ef..949c5a06 100644 --- a/app/src/renderer/hub/Dashboard.tsx +++ b/app/src/renderer/hub/Dashboard.tsx @@ -10,6 +10,7 @@ import type { TaskInputHandle } from './TaskInput'; import { DashboardBackground } from './DashboardBackground'; import { useUIStore } from './state/uiStore'; import type { AgentSession } from './types'; +import { useTranslation } from 'react-i18next'; const HOUR = 3600 * 1000; const DAY = 24 * HOUR; @@ -99,6 +100,7 @@ interface DashboardProps { } export function Dashboard({ sessions, onSubmitTask }: DashboardProps): React.ReactElement { + const { t } = useTranslation(); const runningCount = sessions.filter((s) => s.status === 'running').length; const idleCount = sessions.filter((s) => s.status === 'idle').length; @@ -184,12 +186,12 @@ export function Dashboard({ sessions, onSubmitTask }: DashboardProps): React.Rea
- {isDragging &&
} + {isDragging &&
}
- Running now + {t('Running now')}
{runningCount}
@@ -199,7 +201,7 @@ export function Dashboard({ sessions, onSubmitTask }: DashboardProps): React.Rea
- Idle + {t('Idle')}
{idleCount}
@@ -209,7 +211,7 @@ export function Dashboard({ sessions, onSubmitTask }: DashboardProps): React.Rea
- Total sessions + {t('Total sessions')}
{sessions.length}
diff --git a/app/src/renderer/hub/EnginePicker.tsx b/app/src/renderer/hub/EnginePicker.tsx index 06df93fe..afe58619 100644 --- a/app/src/renderer/hub/EnginePicker.tsx +++ b/app/src/renderer/hub/EnginePicker.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import claudeLogoSrc from './claude-logo.svg?raw'; import openaiLogoDarkSrc from './openai-logo.svg?raw'; import openaiLogoLightSrc from './openai-logo-light.svg?raw'; @@ -65,6 +66,7 @@ async function fetchEngines(): Promise { } export function EnginePicker({ value, onChange, onOpenChange }: EnginePickerProps): React.ReactElement { + const { t } = useTranslation(); const [engines, setEngines] = useState([]); const [statuses, setStatuses] = useState>({}); const [popupId, setPopupId] = useState(null); @@ -150,11 +152,11 @@ export function EnginePicker({ value, onChange, onOpenChange }: EnginePickerProp onClick={(e) => { e.stopPropagation(); void openMenu(); }} aria-haspopup="menu" aria-expanded={Boolean(popupId)} - title={currentEngine ? `Engine: ${currentEngine.displayName}${!currentAuthed ? ' — not logged in' : ''}` : 'Pick engine'} + title={currentEngine ? `${t('Engine')}: ${currentEngine.displayName}${!currentAuthed ? ` — ${t('not logged in')}` : ''}` : t('Pick engine')} > {currentEngine && } {currentEngine?.displayName ?? '…'} - {(!currentInstalled || !currentAuthed) && } + {(!currentInstalled || !currentAuthed) && }
@@ -172,6 +174,7 @@ export function EnginePickerMenuContent({ onChange, onClose, }: EnginePickerMenuContentProps): React.ReactElement { + const { t } = useTranslation(); const [engines, setEngines] = useState([]); const [statuses, setStatuses] = useState>({}); const [loggingIn, setLoggingIn] = useState(null); @@ -376,8 +379,8 @@ export function EnginePickerMenuContent({ const authed = st?.authed?.authed ?? true; const needsSetup = !installed || !authed; const actionPending = installing === e.id || loggingIn === e.id; - const setupLabel = e.id === 'browsercode' ? 'Set up' : 'Log in'; - const installLabel = installing === e.id ? 'Installing…' : 'Install'; + const setupLabel = e.id === 'browsercode' ? t('Set up') : t('Log in'); + const installLabel = installing === e.id ? t('Installing…') : t('Install'); const isBrowserCode = e.id === 'browsercode'; return (
+ :
{t('No session selected.')}
) : viewMode === 'dashboard' ? ( window.electronAPI?.pill.toggle()} > - press {shortcut ? {shortcut} : 'the global command'} to start a new task + {t('press')} {shortcut ? {shortcut} : t('the global command')} {t('to start a new task')}
); diff --git a/app/src/renderer/hub/KeybindingsOverlay.tsx b/app/src/renderer/hub/KeybindingsOverlay.tsx index f077863a..3e7e22d2 100644 --- a/app/src/renderer/hub/KeybindingsOverlay.tsx +++ b/app/src/renderer/hub/KeybindingsOverlay.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { useTranslation } from 'react-i18next'; import type { KeyBinding } from './keybindings'; interface KeybindingsOverlayProps { @@ -10,6 +11,7 @@ interface KeybindingsOverlayProps { } export function KeybindingsOverlay({ open, onClose, keybindings, onOpenSettings, formatShortcut }: KeybindingsOverlayProps): React.ReactElement | null { + const { t } = useTranslation(); if (!open) return null; const categories = new Map(); @@ -23,16 +25,16 @@ export function KeybindingsOverlay({ open, onClose, keybindings, onOpenSettings,
e.stopPropagation()}>
- Keyboard shortcuts - + {t('Keyboard shortcuts')} +
{Array.from(categories, ([category, bindings]) => (
- {category} + {t(category)} {bindings.map((kb) => (
- {kb.label} + {t(kb.label)} {kb.keys.map((k, i) => ( {formatShortcut(k)} diff --git a/app/src/renderer/hub/MemoryIndicator.tsx b/app/src/renderer/hub/MemoryIndicator.tsx index 4b71d596..2837eeff 100644 --- a/app/src/renderer/hub/MemoryIndicator.tsx +++ b/app/src/renderer/hub/MemoryIndicator.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; import { closeAppPopup, openAnchoredAppPopup } from '../shared/appPopup'; interface ProcessInfo { @@ -53,6 +54,7 @@ interface MemoryIndicatorProps { } export function MemoryIndicator({ onOpenSettings, settingsShortcut }: MemoryIndicatorProps): React.ReactElement | null { + const { t } = useTranslation(); const [popupId, setPopupId] = useState(null); const buttonRef = useRef(null); @@ -120,13 +122,13 @@ export function MemoryIndicator({ onOpenSettings, settingsShortcut }: MemoryIndi {appInfo?.version && ( @@ -138,6 +140,7 @@ export function MemoryIndicator({ onOpenSettings, settingsShortcut }: MemoryIndi } export function MemoryIndicatorContent(): React.ReactElement { + const { t } = useTranslation(); const [data, setData] = useState(null); useEffect(() => { @@ -158,13 +161,13 @@ export function MemoryIndicatorContent(): React.ReactElement { }, []); if (!data) { - return
Loading…
; + return
{t('Loading…')}
; } return (
- Resource usage + {t('Resource usage')} {formatGb(data.totalMb)} / {formatCpu(data.totalCpuPercent)}
@@ -187,7 +190,7 @@ export function MemoryIndicatorContent(): React.ReactElement { if (!api || !p.sessionId) return; api.sessions.cancel(p.sessionId).catch(() => {}); }} - aria-label="Stop session" + aria-label={t('Stop session')} > diff --git a/app/src/renderer/hub/SettingsPane.tsx b/app/src/renderer/hub/SettingsPane.tsx index aa7f3fc6..7c24db5c 100644 --- a/app/src/renderer/hub/SettingsPane.tsx +++ b/app/src/renderer/hub/SettingsPane.tsx @@ -12,6 +12,8 @@ import { MAX_CYCLE_MS, type SpinnerPresetId, } from './chat/spinnerVerbs'; +import { useTranslation } from 'react-i18next'; +import { getStoredLanguage, setStoredLanguage } from '../i18n'; /** * Generic settings primitives. Add a new option type and every section that @@ -70,36 +72,48 @@ function SegmentedControl({ value, options, onChange, ariaLabe ); } -const APPEARANCE_OPTIONS: ReadonlyArray> = [ - { value: 'light', label: 'Light' }, - { value: 'dark', label: 'Dark' }, - { value: 'system', label: 'System', hint: 'Follow your operating system' }, -]; - function AppearanceSection(): React.ReactElement { + const { t, i18n } = useTranslation(); const { mode, setMode, resolved } = useThemeMode(); + const appearanceOptions: ReadonlyArray> = [ + { value: 'light', label: t('Light') }, + { value: 'dark', label: t('Dark') }, + { value: 'system', label: t('System'), hint: t('Follow your operating system') }, + ]; return (
+ + +
); } function SpinnerVerbsSection(): React.ReactElement { + const { t } = useTranslation(); const presetId = useSpinnerVerbsStore((s) => s.presetId); const customVerbs = useSpinnerVerbsStore((s) => s.customVerbs); const cycleMs = useSpinnerVerbsStore((s) => s.cycleMs); @@ -108,8 +122,6 @@ function SpinnerVerbsSection(): React.ReactElement { const setCycleMs = useSpinnerVerbsStore((s) => s.setCycleMs); const [draft, setDraft] = useState(customVerbs.join('\n')); - // Keep the local textarea in sync when something else mutates the store - // (e.g. preset reset), but don't fight the user mid-edit. const lastSyncedRef = useRef(customVerbs.join('\n')); useEffect(() => { const next = customVerbs.join('\n'); @@ -124,7 +136,7 @@ function SpinnerVerbsSection(): React.ReactElement { ]; const activePreview = presetId === 'custom' - ? (customVerbs.length > 0 ? customVerbs : ['Working']) + ? (customVerbs.length > 0 ? customVerbs : [t('Working')]) : SPINNER_VERB_PRESETS[presetId].verbs; const commitDraft = (): void => { @@ -136,27 +148,27 @@ function SpinnerVerbsSection(): React.ReactElement { return (
{activePreview.slice(0, 6).join(' / ')}{activePreview.length > 6 ? ' ...' : ''} @@ -165,15 +177,15 @@ function SpinnerVerbsSection(): React.ReactElement { {presetId === 'custom' && (