Add i18n foundation and translations (#1027)

* feat: add i18n foundation

* feat: localize sessions sidebar

* Localize multirun/scheduled tasks and fix dialog dropdown interactions

* localize git sidebar surface and add zh-CN keys

* feat(ui): localize context panel, diff/plan views, and context sidebar content

* fix(config): resolve user config home via fs/home before embedded home

* localize header/chat UI and complete model/worktree panel strings

* localize worktree + github issue/pr dialog flows

* localize settings sections and split settings i18n dictionaries

* localize additional settings sections and sidebars

* localize more settings pages and dialogs

* fix settings select trigger localization

* localize tunnel settings ui surface

* localize additional settings sections

* localize keyboard shortcuts labels in settings

* localize terminal and utility dialogs surfaces

* feat(i18n): localize remaining UI strings

* Add Ukrainian locale

* Add Spanish locale

* Add Brazilian Portuguese locale

* Polish locale translations
This commit is contained in:
Bohdan Triapitsyn
2026-04-26 14:03:39 +03:00
committed by GitHub
parent 87db2ea210
commit 7d7285655d
198 changed files with 24173 additions and 4365 deletions
+3 -3
View File
@@ -1,7 +1,7 @@
import type { Message, Part } from '@opencode-ai/sdk/v2';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { isVSCodeRuntime, openDesktopPath, revealDesktopPath, saveDesktopMarkdownFile } from '@/lib/desktop';
import { getRevealLabel } from '@/lib/utils';
import { getRevealLabelKey } from '@/lib/utils';
type SessionMessageRecord = { info: Message; parts: Part[] };
@@ -145,8 +145,8 @@ export async function revealExportedMarkdown(path: string): Promise<boolean> {
return openDesktopPath(path);
}
export function getExportRevealLabel(): string {
return getRevealLabel();
export function getExportRevealLabelKey() {
return getRevealLabelKey();
}
export function buildExportFilename(sessionTitle?: string | null): string {
+31
View File
@@ -0,0 +1,31 @@
import React from 'react';
import { useI18nStore, formatMessage, type I18nKey, type I18nParams } from './store';
import { I18nContext, type I18nContextValue } from './react-context';
import { LOCALE_LABEL_KEYS, LOCALES } from './runtime';
export const I18nProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const locale = useI18nStore((state) => state.locale);
const dictionary = useI18nStore((state) => state.dictionary);
const setLocale = useI18nStore((state) => state.setLocale);
React.useEffect(() => {
if (typeof document === 'undefined') {
return;
}
document.documentElement.lang = locale;
}, [locale]);
const value = React.useMemo<I18nContextValue>(() => {
const t = (key: I18nKey, params?: I18nParams) => formatMessage(dictionary, key, params);
return {
locale,
locales: LOCALES,
setLocale,
label: (targetLocale) => t(LOCALE_LABEL_KEYS[targetLocale]),
t,
};
}, [dictionary, locale, setLocale]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
};
+4
View File
@@ -0,0 +1,4 @@
export { I18nProvider } from './context';
export { useI18n } from './useI18n';
export { initializeLocale } from './store';
export type { I18nKey, Locale } from './store';
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
import React from 'react';
import type { I18nKey, I18nParams } from './store';
import type { Locale } from './runtime';
export type I18nContextValue = {
locale: Locale;
locales: readonly Locale[];
setLocale: (locale: Locale) => void;
label: (locale: Locale) => string;
t: (key: I18nKey, params?: I18nParams) => string;
};
export const I18nContext = React.createContext<I18nContextValue | null>(null);
+108
View File
@@ -0,0 +1,108 @@
export type Locale = 'en' | 'zh-CN' | 'uk' | 'es' | 'pt-BR';
export const LOCALES = ['en', 'zh-CN', 'uk', 'es', 'pt-BR'] as const satisfies readonly Locale[];
export const DEFAULT_LOCALE: Locale = 'en';
export const LOCALE_LABEL_KEYS: Record<Locale, 'common.language.english' | 'common.language.simplifiedChinese' | 'common.language.ukrainian' | 'common.language.spanish' | 'common.language.brazilianPortuguese'> = {
en: 'common.language.english',
'zh-CN': 'common.language.simplifiedChinese',
uk: 'common.language.ukrainian',
es: 'common.language.spanish',
'pt-BR': 'common.language.brazilianPortuguese',
};
export const LOCALE_STORAGE_KEY = 'openchamber.i18n.v1';
type StoredLocale = {
locale?: unknown;
};
export function normalizeLocale(value: string | undefined | null): Locale {
if (!value) {
return DEFAULT_LOCALE;
}
const normalized = value.toLowerCase().replace(/_/g, '-');
if (normalized === 'zh-cn' || normalized === 'zh-hans' || normalized.startsWith('zh-hans-')) {
return 'zh-CN';
}
if (normalized.startsWith('zh')) {
return 'zh-CN';
}
if (normalized.startsWith('en')) {
return 'en';
}
if (normalized === 'uk' || normalized.startsWith('uk-') || normalized === 'ua' || normalized.startsWith('ua-')) {
return 'uk';
}
if (normalized === 'es' || normalized.startsWith('es-')) {
return 'es';
}
if (normalized === 'pt' || normalized === 'pt-br' || normalized.startsWith('pt-br-')) {
return 'pt-BR';
}
return DEFAULT_LOCALE;
}
export function readStoredLocale(): Locale | undefined {
if (typeof window === 'undefined') {
return undefined;
}
try {
const raw = window.localStorage.getItem(LOCALE_STORAGE_KEY);
if (!raw) {
return undefined;
}
const parsed = JSON.parse(raw) as StoredLocale;
return typeof parsed.locale === 'string' ? normalizeLocale(parsed.locale) : undefined;
} catch {
return undefined;
}
}
export function writeStoredLocale(locale: Locale): void {
if (typeof window === 'undefined') {
return;
}
try {
window.localStorage.setItem(LOCALE_STORAGE_KEY, JSON.stringify({ locale }));
} catch {
return;
}
}
function getRuntimeLanguage(): string | undefined {
if (typeof window === 'undefined') {
return undefined;
}
return (window as unknown as { __OPENCHAMBER_RUNTIME_APIS__?: { runtime?: { language?: string } } })
.__OPENCHAMBER_RUNTIME_APIS__?.runtime?.language;
}
export function detectInitialLocale(): Locale {
const stored = readStoredLocale();
if (stored) {
return stored;
}
const runtimeLanguage = getRuntimeLanguage();
if (runtimeLanguage) {
return normalizeLocale(runtimeLanguage);
}
if (typeof navigator !== 'undefined') {
const languages = navigator.languages?.length ? navigator.languages : [navigator.language];
for (const language of languages) {
const locale = normalizeLocale(language);
if (locale !== DEFAULT_LOCALE) {
return locale;
}
}
}
return DEFAULT_LOCALE;
}
+90
View File
@@ -0,0 +1,90 @@
import { create } from 'zustand';
import { dict as enDict, type I18nKey } from './messages/en';
import { DEFAULT_LOCALE, detectInitialLocale, type Locale, writeStoredLocale } from './runtime';
export type I18nParams = Record<string, string | number | boolean | null | undefined>;
export type I18nDictionary = Record<I18nKey, string>;
type I18nState = {
locale: Locale;
dictionary: I18nDictionary;
loadingLocale: Locale | null;
setLocale: (locale: Locale) => void;
};
const dictionaries = new Map<Locale, I18nDictionary>([[DEFAULT_LOCALE, enDict]]);
async function loadDictionary(locale: Locale): Promise<I18nDictionary> {
const cached = dictionaries.get(locale);
if (cached) {
return cached;
}
const mod = locale === 'zh-CN'
? await import('./messages/zh-CN') as { dict: I18nDictionary }
: locale === 'es'
? await import('./messages/es') as { dict: I18nDictionary }
: locale === 'pt-BR'
? await import('./messages/pt-BR') as { dict: I18nDictionary }
: locale === 'uk'
? await import('./messages/uk') as { dict: I18nDictionary }
: { dict: enDict };
dictionaries.set(locale, mod.dict);
return mod.dict;
}
export const useI18nStore = create<I18nState>()((set, get) => ({
locale: DEFAULT_LOCALE,
dictionary: enDict,
loadingLocale: null,
setLocale: (locale) => {
const current = get();
if (current.locale === locale && current.loadingLocale !== locale) {
return;
}
writeStoredLocale(locale);
const cached = dictionaries.get(locale);
set({
locale,
dictionary: cached ?? current.dictionary,
loadingLocale: cached ? null : locale,
});
if (cached) {
return;
}
void loadDictionary(locale).then((dictionary) => {
if (get().locale !== locale) {
return;
}
set({ dictionary, loadingLocale: null });
}).catch((error) => {
console.error(`[i18n] failed to load locale ${locale}`, error);
if (get().locale === locale) {
set({ dictionary: enDict, loadingLocale: null });
}
});
},
}));
export function initializeLocale(): void {
useI18nStore.getState().setLocale(detectInitialLocale());
}
export function formatMessage(dictionary: I18nDictionary, key: I18nKey, params?: I18nParams): string {
const template = dictionary[key] ?? enDict[key] ?? key;
if (!params) {
return template;
}
return template.replace(/\{([^{}]+)\}/g, (match, rawKey) => {
const value = params[rawKey.trim()];
return value === null || value === undefined ? match : String(value);
});
}
export type { I18nKey, Locale };
+11
View File
@@ -0,0 +1,11 @@
import React from 'react';
import { I18nContext } from './react-context';
export function useI18n() {
const value = React.useContext(I18nContext);
if (!value) {
throw new Error('useI18n must be used within I18nProvider');
}
return value;
}
+20 -14
View File
@@ -197,26 +197,32 @@ const writeTextFile = async (path: string, content: string): Promise<boolean> =>
};
const resolveHomeDirectory = async (): Promise<string | null> => {
// VSCode webview sets __OPENCHAMBER_HOME__ to workspace folder (not OS home).
// For user config (~/.config/openchamber), always use /api/fs/home in VSCode.
// Use server-reported home as the source of truth for user config paths.
// In some runtimes, window.__OPENCHAMBER_HOME__ can be workspace/project-root
// scoped, which would incorrectly route writes into the project directory.
try {
const response = await fetch(`${getBaseUrl()}/fs/home`);
if (!response.ok) {
throw new Error('Failed to resolve home directory from API');
}
const payload = await response.json().catch(() => null) as { home?: unknown } | null;
const home = typeof payload?.home === 'string' ? payload.home.trim() : '';
if (home) {
return normalize(home);
}
} catch {
// fall through
}
// Fallback for environments where /api/fs/home is unavailable.
// VSCode intentionally avoids this because embedded home equals workspace path.
if (!isVSCodeRuntime()) {
const desktopHome = await getDesktopHomeDirectory().catch(() => null);
if (desktopHome && desktopHome.trim().length > 0) {
return normalize(desktopHome);
}
}
try {
const response = await fetch(`${getBaseUrl()}/fs/home`);
if (!response.ok) {
return null;
}
const payload = await response.json().catch(() => null) as { home?: unknown } | null;
const home = typeof payload?.home === 'string' ? payload.home.trim() : '';
return home ? normalize(home) : null;
} catch {
return null;
}
return null;
};
const getUserProjectsDirectory = async (): Promise<string | null> => {
+5 -4
View File
@@ -2,6 +2,7 @@ import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import { isTauriShell } from "@/lib/desktop";
import { matchesFuzzyQuery } from "@/lib/search/fuzzySearch";
import type { I18nKey } from "@/lib/i18n";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@@ -21,10 +22,10 @@ export const isWindows = (): boolean => {
return /Windows/.test(navigator.userAgent || '');
};
export const getRevealLabel = (): string => {
if (isMacOS()) return 'Reveal in Finder';
if (isWindows()) return 'Open in File Explorer';
return 'Open in File Manager';
export const getRevealLabelKey = (): I18nKey => {
if (isMacOS()) return 'common.revealPath.finder';
if (isWindows()) return 'common.revealPath.fileExplorer';
return 'common.revealPath.fileManager';
};
/**