diff --git a/I18N.md b/I18N.md index 274ed4fd7..c07792efc 100644 --- a/I18N.md +++ b/I18N.md @@ -100,11 +100,15 @@ Compiled files go to `src/i18n/compiled/` (gitignored). The locale is resolved at startup in the following order: -1. **`GOOSE_LOCALE`** — explicit override (set on the `window` object or via env) -2. **`navigator.language`** — the browser/OS locale -3. **`"en"`** — fallback default +1. **Desktop language setting** — selected in Settings, unless set to System Default +2. **`GOOSE_LOCALE`** — explicit override from env/app config when the desktop setting is + System Default +3. **`navigator.language`** — the browser/OS locale +4. **`"en"`** — fallback default -The resolved locale is used for both text translations and all Intl formatting (dates, numbers, relative times). +The resolved locale is used for both text translations and all Intl formatting (dates, numbers, +relative times). Changing the desktop language setting reloads the renderer so startup-only locale +helpers pick up the new value. ## Date and number formatting @@ -134,7 +138,8 @@ This ensures date/number formatting uses the same locale as the rest of the UI. 1. Copy `src/i18n/messages/en.json` to a new file, e.g., `src/i18n/messages/ja.json`. 2. Translate the `defaultMessage` values. Keep ICU syntax intact (e.g., `{count, plural, ...}`). 3. Add the locale code to `SUPPORTED_LOCALES` in `src/i18n/index.ts`. -4. Optionally run `pnpm i18n:compile` to pre-compile. +4. Add the locale to the language selector in `src/components/settings/app/AppSettingsSection.tsx`. +5. Optionally run `pnpm i18n:compile` to pre-compile. No other code changes are needed — `loadMessages()` dynamically imports the correct catalog at runtime. diff --git a/ui/desktop/src/components/settings/app/AppSettingsSection.tsx b/ui/desktop/src/components/settings/app/AppSettingsSection.tsx index 82d8683b6..d756f5c75 100644 --- a/ui/desktop/src/components/settings/app/AppSettingsSection.tsx +++ b/ui/desktop/src/components/settings/app/AppSettingsSection.tsx @@ -2,8 +2,15 @@ import { useState, useEffect, useRef } from 'react'; import { defineMessages, useIntl } from '../../../i18n'; import { Switch } from '../../ui/switch'; import { Button } from '../../ui/button'; -import { Settings } from 'lucide-react'; +import { ChevronDown, Settings } from 'lucide-react'; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../../ui/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from '../../ui/dropdown-menu'; import UpdateSection from './UpdateSection'; import { COST_TRACKING_ENABLED, UPDATES_ENABLED } from '../../../updates'; @@ -13,6 +20,7 @@ import BlockLogoBlack from './icons/block-lockup_black.png'; import BlockLogoWhite from './icons/block-lockup_white.png'; import TelemetrySettings from './TelemetrySettings'; import { trackSettingToggled } from '../../../utils/analytics'; +import type { LanguageSetting } from '../../../utils/settings'; const i18n = defineMessages({ appearanceTitle: { id: 'settings.appearance.title', defaultMessage: 'Appearance' }, @@ -58,6 +66,21 @@ const i18n = defineMessages({ id: 'settings.theme.description', defaultMessage: 'Customize the look and feel of goose', }, + languageTitle: { id: 'settings.language.title', defaultMessage: 'Language' }, + languageDesc: { + id: 'settings.language.description', + defaultMessage: 'Choose the display language for goose', + }, + languageSystem: { id: 'settings.language.systemDefault', defaultMessage: 'System Default' }, + languageEnglish: { id: 'settings.language.english', defaultMessage: 'English' }, + languageChineseSimplified: { + id: 'settings.language.zhCN', + defaultMessage: 'Chinese (Simplified)', + }, + languageRussian: { id: 'settings.language.russian', defaultMessage: 'Russian' }, + languageTurkish: { id: 'settings.language.turkish', defaultMessage: 'Turkish' }, + languageHindi: { id: 'settings.language.hindi', defaultMessage: 'Hindi' }, + languageJapanese: { id: 'settings.language.japanese', defaultMessage: 'Japanese' }, helpTitle: { id: 'settings.help.title', defaultMessage: 'Help & feedback' }, helpDesc: { id: 'settings.help.description', @@ -118,6 +141,16 @@ const i18n = defineMessages({ close: { id: 'settings.close', defaultMessage: 'Close' }, }); +const LANGUAGE_OPTIONS: Array<{ value: LanguageSetting; message: keyof typeof i18n }> = [ + { value: 'system', message: 'languageSystem' }, + { value: 'en', message: 'languageEnglish' }, + { value: 'hi', message: 'languageHindi' }, + { value: 'ja', message: 'languageJapanese' }, + { value: 'ru', message: 'languageRussian' }, + { value: 'tr', message: 'languageTurkish' }, + { value: 'zh-CN', message: 'languageChineseSimplified' }, +]; + interface AppSettingsSectionProps { scrollToSection?: string; } @@ -131,6 +164,7 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti const [isDockSwitchDisabled, setIsDockSwitchDisabled] = useState(false); const [showNotificationModal, setShowNotificationModal] = useState(false); const [showPricing, setShowPricing] = useState(true); + const [language, setLanguage] = useState('system'); const [isDarkMode, setIsDarkMode] = useState(false); const updateSectionRef = useRef(null); const shouldShowUpdates = !window.appConfig.get('GOOSE_VERSION'); @@ -157,6 +191,7 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti useEffect(() => { window.electron.getSetting('showPricing').then(setShowPricing); + window.electron.getSetting('language').then((value) => setLanguage(value ?? 'system')); }, []); useEffect(() => { @@ -252,7 +287,25 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti window.dispatchEvent(new CustomEvent('showPricingChanged')); }; + const handleLanguageChange = async (value: string) => { + const nextLanguage = LANGUAGE_OPTIONS.find((option) => option.value === value)?.value; + if (!nextLanguage || nextLanguage === language) { + return; + } + + setLanguage(nextLanguage); + try { + await window.electron.setSetting('language', nextLanguage); + window.electron.reloadApp(); + } catch (error) { + console.error('Failed to update language setting:', error); + setLanguage(language); + } + }; + const intl = useIntl(); + const selectedLanguage = + LANGUAGE_OPTIONS.find((option) => option.value === language) ?? LANGUAGE_OPTIONS[0]; return (
@@ -400,6 +453,29 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti + + + {intl.formatMessage(i18n.languageTitle)} + {intl.formatMessage(i18n.languageDesc)} + + + + + {intl.formatMessage(i18n[selectedLanguage.message])} + + + + + {LANGUAGE_OPTIONS.map((option) => ( + + {intl.formatMessage(i18n[option.message])} + + ))} + + + + + diff --git a/ui/desktop/src/i18n/index.ts b/ui/desktop/src/i18n/index.ts index 662b4b3eb..d6d632235 100644 --- a/ui/desktop/src/i18n/index.ts +++ b/ui/desktop/src/i18n/index.ts @@ -2,7 +2,7 @@ * Locale detection and message loading for the i18n system. * * Locale resolution order: - * 1. GOOSE_LOCALE config value (set via environment variable, passed through appConfig) + * 1. GOOSE_LOCALE config value (manual setting or environment variable, passed through appConfig) * 2. navigator.languages (full accept-language list from OS/browser) * 3. "en" (fallback) * @@ -15,7 +15,9 @@ export { defineMessages, useIntl } from 'react-intl'; /** The set of locales that have translation catalogs. */ -const SUPPORTED_LOCALES = new Set(['en', 'hi', 'ru', 'tr', 'zh-CN', 'ja']); +export const SUPPORTED_LOCALES = ['en', 'hi', 'ja', 'ru', 'tr', 'zh-CN'] as const; +export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]; +const SUPPORTED_LOCALE_SET = new Set(SUPPORTED_LOCALES); /** * Map Simplified Chinese aliases (zh, zh-Hans*, zh-SG, zh-MY) to "zh-CN". @@ -61,12 +63,12 @@ export function getLocale(): { locale: string; messageLocale: string } { const tag = resolveChineseAlias(normalized); // Exact match first - if (SUPPORTED_LOCALES.has(tag)) return { locale: tag, messageLocale: tag }; + if (SUPPORTED_LOCALE_SET.has(tag)) return { locale: tag, messageLocale: tag }; // Try base language (e.g. "pt-BR" → "pt") for the catalog, but keep the // full regional tag for formatting so date/number output respects the region. const base = tag.split('-')[0]; - if (SUPPORTED_LOCALES.has(base)) { + if (SUPPORTED_LOCALE_SET.has(base)) { // Validate the full tag is a well-formed BCP 47 locale before using it // for formatting. Invalid tags (e.g. "en-") would cause RangeError in // Intl APIs, so fall back to the base language in that case. diff --git a/ui/desktop/src/i18n/messages/en.json b/ui/desktop/src/i18n/messages/en.json index c9b3214e9..0b85605f7 100644 --- a/ui/desktop/src/i18n/messages/en.json +++ b/ui/desktop/src/i18n/messages/en.json @@ -4046,6 +4046,33 @@ "settings.help.title": { "defaultMessage": "Help & feedback" }, + "settings.language.description": { + "defaultMessage": "Choose the display language for goose" + }, + "settings.language.english": { + "defaultMessage": "English" + }, + "settings.language.hindi": { + "defaultMessage": "Hindi" + }, + "settings.language.japanese": { + "defaultMessage": "Japanese" + }, + "settings.language.russian": { + "defaultMessage": "Russian" + }, + "settings.language.systemDefault": { + "defaultMessage": "System Default" + }, + "settings.language.title": { + "defaultMessage": "Language" + }, + "settings.language.turkish": { + "defaultMessage": "Turkish" + }, + "settings.language.zhCN": { + "defaultMessage": "Chinese (Simplified)" + }, "settings.menuBarIcon.description": { "defaultMessage": "Show goose in the menu bar" }, diff --git a/ui/desktop/src/i18n/messages/hi.json b/ui/desktop/src/i18n/messages/hi.json index db5426024..76c9a5903 100644 --- a/ui/desktop/src/i18n/messages/hi.json +++ b/ui/desktop/src/i18n/messages/hi.json @@ -4046,6 +4046,33 @@ "settings.help.title": { "defaultMessage": "सहायता एवं प्रतिक्रिया" }, + "settings.language.description": { + "defaultMessage": "goose के लिए प्रदर्शन भाषा चुनें" + }, + "settings.language.english": { + "defaultMessage": "अंग्रेज़ी" + }, + "settings.language.hindi": { + "defaultMessage": "हिन्दी" + }, + "settings.language.japanese": { + "defaultMessage": "जापानी" + }, + "settings.language.russian": { + "defaultMessage": "रूसी" + }, + "settings.language.systemDefault": { + "defaultMessage": "सिस्टम डिफ़ॉल्ट" + }, + "settings.language.title": { + "defaultMessage": "भाषा" + }, + "settings.language.turkish": { + "defaultMessage": "तुर्की" + }, + "settings.language.zhCN": { + "defaultMessage": "चीनी (सरलीकृत)" + }, "settings.menuBarIcon.description": { "defaultMessage": "मेनू बार में goose दिखाएँ" }, diff --git a/ui/desktop/src/i18n/messages/ja.json b/ui/desktop/src/i18n/messages/ja.json index 16f2d196f..f18fe9ed0 100644 --- a/ui/desktop/src/i18n/messages/ja.json +++ b/ui/desktop/src/i18n/messages/ja.json @@ -4046,6 +4046,33 @@ "settings.help.title": { "defaultMessage": "ヘルプとフィードバック" }, + "settings.language.description": { + "defaultMessage": "goose の表示言語を選択" + }, + "settings.language.english": { + "defaultMessage": "英語" + }, + "settings.language.hindi": { + "defaultMessage": "ヒンディー語" + }, + "settings.language.japanese": { + "defaultMessage": "日本語" + }, + "settings.language.russian": { + "defaultMessage": "ロシア語" + }, + "settings.language.systemDefault": { + "defaultMessage": "システムデフォルト" + }, + "settings.language.title": { + "defaultMessage": "言語" + }, + "settings.language.turkish": { + "defaultMessage": "トルコ語" + }, + "settings.language.zhCN": { + "defaultMessage": "中国語(簡体字)" + }, "settings.menuBarIcon.description": { "defaultMessage": "メニューバーにgooseを表示します" }, diff --git a/ui/desktop/src/i18n/messages/ru.json b/ui/desktop/src/i18n/messages/ru.json index 2ebb91da7..cd7b1de8b 100644 --- a/ui/desktop/src/i18n/messages/ru.json +++ b/ui/desktop/src/i18n/messages/ru.json @@ -4046,6 +4046,33 @@ "settings.help.title": { "defaultMessage": "Помощь и отзывы" }, + "settings.language.description": { + "defaultMessage": "Выберите язык интерфейса goose" + }, + "settings.language.english": { + "defaultMessage": "Английский" + }, + "settings.language.hindi": { + "defaultMessage": "Хинди" + }, + "settings.language.japanese": { + "defaultMessage": "Японский" + }, + "settings.language.russian": { + "defaultMessage": "Русский" + }, + "settings.language.systemDefault": { + "defaultMessage": "Системный язык" + }, + "settings.language.title": { + "defaultMessage": "Язык" + }, + "settings.language.turkish": { + "defaultMessage": "Турецкий" + }, + "settings.language.zhCN": { + "defaultMessage": "Китайский (упрощенный)" + }, "settings.menuBarIcon.description": { "defaultMessage": "Показывать goose в строке меню" }, diff --git a/ui/desktop/src/i18n/messages/tr.json b/ui/desktop/src/i18n/messages/tr.json index f993fdb2a..c5838ba2f 100644 --- a/ui/desktop/src/i18n/messages/tr.json +++ b/ui/desktop/src/i18n/messages/tr.json @@ -4046,6 +4046,33 @@ "settings.help.title": { "defaultMessage": "Yardım ve geri bildirim" }, + "settings.language.description": { + "defaultMessage": "goose için görüntüleme dilini seçin" + }, + "settings.language.english": { + "defaultMessage": "İngilizce" + }, + "settings.language.hindi": { + "defaultMessage": "Hintçe" + }, + "settings.language.japanese": { + "defaultMessage": "Japonca" + }, + "settings.language.russian": { + "defaultMessage": "Rusça" + }, + "settings.language.systemDefault": { + "defaultMessage": "Sistem varsayılanı" + }, + "settings.language.title": { + "defaultMessage": "Dil" + }, + "settings.language.turkish": { + "defaultMessage": "Türkçe" + }, + "settings.language.zhCN": { + "defaultMessage": "Çince (Basitleştirilmiş)" + }, "settings.menuBarIcon.description": { "defaultMessage": "Menü çubuğunda goose'yi göster" }, diff --git a/ui/desktop/src/i18n/messages/zh-CN.json b/ui/desktop/src/i18n/messages/zh-CN.json index 66578ed21..1c4389454 100644 --- a/ui/desktop/src/i18n/messages/zh-CN.json +++ b/ui/desktop/src/i18n/messages/zh-CN.json @@ -4046,6 +4046,33 @@ "settings.help.title": { "defaultMessage": "帮助与反馈" }, + "settings.language.description": { + "defaultMessage": "选择 goose 的显示语言" + }, + "settings.language.english": { + "defaultMessage": "英语" + }, + "settings.language.hindi": { + "defaultMessage": "印地语" + }, + "settings.language.japanese": { + "defaultMessage": "日语" + }, + "settings.language.russian": { + "defaultMessage": "俄语" + }, + "settings.language.systemDefault": { + "defaultMessage": "系统默认" + }, + "settings.language.title": { + "defaultMessage": "语言" + }, + "settings.language.turkish": { + "defaultMessage": "土耳其语" + }, + "settings.language.zhCN": { + "defaultMessage": "简体中文" + }, "settings.menuBarIcon.description": { "defaultMessage": "在菜单栏显示 goose" }, diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index cc4eecbd2..562d6526c 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -130,13 +130,7 @@ const MENU_TRANSLATIONS_ZH_CN: Record = { }; function detectMenuLocale(): string { - const explicit = process.env.GOOSE_LOCALE; - if (explicit) return explicit; - try { - return app.getSystemLocale() || 'en'; - } catch { - return 'en'; - } + return getConfiguredGooseLocale() ?? 'en'; } function menuT(label: string): string { @@ -174,6 +168,19 @@ function translateMenuLabels(items: MenuItem[]): void { // Settings management const SETTINGS_FILE = path.join(app.getPath('userData'), 'settings.json'); const STARTUP_LOGS_DIR = path.join(app.getPath('userData'), 'logs', 'startup'); +const validLanguageSettings = new Set([ + 'system', + 'en', + 'hi', + 'ja', + 'ru', + 'tr', + 'zh-CN', +]); + +function isValidLanguageSetting(value: unknown): value is Settings['language'] { + return typeof value === 'string' && validLanguageSettings.has(value as Settings['language']); +} function getSettings(): Settings { if (fsSync.existsSync(SETTINGS_FILE)) { @@ -211,6 +218,23 @@ function updateSettings(modifier: (settings: Settings) => void): void { fsSync.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2)); } +function getConfiguredGooseLocale(): string | undefined { + const language = getSettings().language; + if (isValidLanguageSetting(language) && language !== 'system') { + return language; + } + + if (process.env.GOOSE_LOCALE) { + return process.env.GOOSE_LOCALE; + } + + try { + return app.isReady() ? app.getSystemLocale() || undefined : undefined; + } catch { + return undefined; + } +} + function listGitWorktreeDirs(dir: string): Promise { return new Promise((resolve) => { if (!dir?.trim()) { @@ -314,20 +338,8 @@ app.on('certificate-error', (event, _webContents, url, _error, certificate, call } }); -// Fill in GOOSE_LOCALE from the OS region locale once Electron is ready. -// Kept separate from the initial appConfig assignment above because -// app.getSystemLocale() is only available after the app.ready event fires. app.whenReady().then(() => { - if (!appConfig.GOOSE_LOCALE) { - try { - const sysLocale = app.getSystemLocale(); - if (sysLocale) { - appConfig.GOOSE_LOCALE = sysLocale; - } - } catch { - // Locale detection is best-effort; renderer will fall back to navigator.language. - } - } + appConfig.GOOSE_LOCALE = getConfiguredGooseLocale(); }); // Main-process net.fetch: pin to the exact cert goosed generated. @@ -905,6 +917,7 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => { additionalArguments: [ JSON.stringify({ ...appConfig, + GOOSE_LOCALE: getConfiguredGooseLocale(), GOOSE_API_HOST: baseUrl, GOOSE_WORKING_DIR: workingDir, REQUEST_DIR: dir, @@ -1245,7 +1258,12 @@ const createLauncher = () => { preload: path.join(__dirname, 'preload.js'), nodeIntegration: false, contextIsolation: true, - additionalArguments: [JSON.stringify(appConfig)], + additionalArguments: [ + JSON.stringify({ + ...appConfig, + GOOSE_LOCALE: getConfiguredGooseLocale(), + }), + ], partition: 'persist:goose', }, skipTaskbar: true, @@ -1635,6 +1653,7 @@ const validSettingKeys: Set = new Set([ 'keyboardShortcuts', 'theme', 'useSystemTheme', + 'language', 'responseStyle', 'showPricing', 'sessionSharing', @@ -1648,11 +1667,20 @@ ipcMain.handle('set-setting', (_event, key: SettingKey, value: unknown) => { return; } + if (key === 'language' && !isValidLanguageSetting(value)) { + console.error(`Invalid language setting rejected: ${String(value)}`); + return; + } + const settings = getSettings(); // eslint-disable-next-line @typescript-eslint/no-explicit-any (settings as any)[key] = value; fsSync.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2)); + if (key === 'language') { + appConfig.GOOSE_LOCALE = getConfiguredGooseLocale(); + } + // Re-register shortcuts if keyboard shortcuts changed if (key === 'keyboardShortcuts') { registerGlobalShortcuts(); @@ -2661,6 +2689,10 @@ async function appMain() { event.returnValue = app.getVersion(); }); + ipcMain.on('get-app-locale', (event) => { + event.returnValue = getConfiguredGooseLocale(); + }); + ipcMain.handle('open-directory-in-explorer', async (_event, path: string) => { try { return !!(await shell.openPath(path)); diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index 364325908..eca1e0ea0 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -352,9 +352,17 @@ const electronAPI: ElectronAPI = { listGitWorktreeDirs: (dir: string) => ipcRenderer.invoke('list-git-worktree-dirs', dir), }; +function getAppLocale(): unknown { + try { + return ipcRenderer.sendSync('get-app-locale') ?? config.GOOSE_LOCALE; + } catch { + return config.GOOSE_LOCALE; + } +} + const appConfigAPI: AppConfigAPI = { - get: (key: string) => config[key], - getAll: () => config, + get: (key: string) => (key === 'GOOSE_LOCALE' ? getAppLocale() : config[key]), + getAll: () => ({ ...config, GOOSE_LOCALE: getAppLocale() }), }; // Expose the APIs diff --git a/ui/desktop/src/test/setup.ts b/ui/desktop/src/test/setup.ts index bed778d48..23ff58cad 100644 --- a/ui/desktop/src/test/setup.ts +++ b/ui/desktop/src/test/setup.ts @@ -67,6 +67,7 @@ const mockSettings: Record = { }, theme: 'light', useSystemTheme: true, + language: 'system', responseStyle: 'concise', showPricing: true, sessionSharing: { @@ -86,6 +87,7 @@ Object.defineProperty(window, 'electron', { mockSettings[key] = value; return Promise.resolve(); }), + reloadApp: vi.fn(), showMessageBox: vi.fn(() => Promise.resolve({ response: 0 })), getIsFullScreen: vi.fn(() => Promise.resolve(false)), on: vi.fn(), diff --git a/ui/desktop/src/utils/settings.ts b/ui/desktop/src/utils/settings.ts index 5f50fc6e0..7c1c0ed9b 100644 --- a/ui/desktop/src/utils/settings.ts +++ b/ui/desktop/src/utils/settings.ts @@ -28,6 +28,8 @@ export interface SessionSharingConfig { baseUrl: string; } +export type LanguageSetting = 'system' | 'en' | 'hi' | 'ja' | 'ru' | 'tr' | 'zh-CN'; + export interface Settings { // Desktop app settings showMenuBarIcon: boolean; @@ -42,6 +44,7 @@ export interface Settings { // UI preferences (migrated from localStorage) theme: 'dark' | 'light'; useSystemTheme: boolean; + language: LanguageSetting; responseStyle: string; showPricing: boolean; sessionSharing: SessionSharingConfig; @@ -81,6 +84,7 @@ export const defaultSettings: Settings = { // UI preferences theme: 'light', useSystemTheme: true, + language: 'system', responseStyle: 'concise', showPricing: true, sessionSharing: {