feat(i18n): language selection feature (#9405)

Signed-off-by: Dmitry Beskov <43372966+besdar@users.noreply.github.com>
This commit is contained in:
Dmitry Beskov
2026-06-17 03:40:38 +03:00
committed by GitHub
parent ddd9d3faa9
commit c9a82f3607
13 changed files with 324 additions and 33 deletions
+10 -5
View File
@@ -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.
@@ -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<LanguageSetting>('system');
const [isDarkMode, setIsDarkMode] = useState(false);
const updateSectionRef = useRef<HTMLDivElement>(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 (
<div className="space-y-4 pr-4 pb-8 mt-1">
@@ -400,6 +453,29 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
</CardContent>
</Card>
<Card className="rounded-lg">
<CardHeader className="pb-0">
<CardTitle className="mb-1">{intl.formatMessage(i18n.languageTitle)}</CardTitle>
<CardDescription>{intl.formatMessage(i18n.languageDesc)}</CardDescription>
</CardHeader>
<CardContent className="pt-4 px-4">
<DropdownMenu>
<DropdownMenuTrigger className="flex w-full max-w-[260px] items-center justify-between gap-2 rounded-md border border-border-primary bg-background-primary px-3 py-2 text-sm text-text-primary transition-colors hover:border-border-primary">
<span className="truncate">{intl.formatMessage(i18n[selectedLanguage.message])}</span>
<ChevronDown className="h-4 w-4 shrink-0" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[260px]">
<DropdownMenuRadioGroup value={language} onValueChange={handleLanguageChange}>
{LANGUAGE_OPTIONS.map((option) => (
<DropdownMenuRadioItem key={option.value} value={option.value}>
{intl.formatMessage(i18n[option.message])}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</CardContent>
</Card>
<TelemetrySettings />
<Card className="rounded-lg">
+6 -4
View File
@@ -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<string>(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.
+27
View File
@@ -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"
},
+27
View File
@@ -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 दिखाएँ"
},
+27
View File
@@ -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を表示します"
},
+27
View File
@@ -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 в строке меню"
},
+27
View File
@@ -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"
},
+27
View File
@@ -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"
},
+53 -21
View File
@@ -130,13 +130,7 @@ const MENU_TRANSLATIONS_ZH_CN: Record<string, string> = {
};
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<Settings['language']>([
'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<string[]> {
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<string> = 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));
+10 -2
View File
@@ -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
+2
View File
@@ -67,6 +67,7 @@ const mockSettings: Record<string, unknown> = {
},
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(),
+4
View File
@@ -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: {