Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,8 +228,16 @@ Documentation-impact: none - <why the docs stay correct> # not edited
## Adding i18n strings

1. Add the field to `internal/i18n/i18n.go` (`Messages` struct)
2. Add the value in `internal/i18n/messages_en.go` and `messages_zh.go`
3. The `TestCatalogsComplete` test will fail if you miss a locale
2. Add the value in every existing catalogue — `internal/i18n/messages_en.go`,
`messages_zh.go`, and `messages_zh_tw.go`
3. Adding a new locale means a new `internal/i18n/messages_<tag>.go` file with a
full `Messages` value, plus a `setLanguage`/`normalize` case in `i18n.go`,
the `/language` option in `internal/cli/language.go` and
`internal/control/slash.go`, and an entry in the catalog maps of
`i18n_test.go` and `catalog_parity_test.go`. Follow the es-419 (neutral
Latin American) conventions of `messages_es.go` as the Spanish baseline.
4. The `TestCatalogsComplete`, placeholder, and code-token parity tests fail if
a locale misses a field or drifts from the English baseline

## Submitting changes

Expand Down
13 changes: 9 additions & 4 deletions desktop/frontend/scripts/check-bundle-budget.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ const appShellCSSGzip = appShellCSS.reduce((total, path) => total + gzipBytes(pa
const largestInitialJS = Math.max(...initialJS.map(gzipBytes));
const largestInitialJSRaw = Math.max(...initialJS.map((path) => statSync(path).size));
const localeChunks = readdirSync(resolve(distDir, "assets"))
.filter((name) => /^(?:zh|zh-TW)-.+\.js$/.test(name))
.filter((name) => /^(?:zh|zh-TW|es)-.+\.js$/.test(name))
.map((name) => resolve(distDir, "assets", name));

console.log("\nbundle budgets");
Expand Down Expand Up @@ -236,8 +236,8 @@ if (initialCSS.length > 0) {
// card, branch switcher, tab strip, add menu, empty-state picker and the
// spacer that starts the panel below the topic bar).
assertBudget("deferred app-shell CSS gzip", appShellCSSGzip, 121.2 * 1024);
if (localeChunks.length !== 2) {
throw new Error(`expected 2 on-demand Chinese locale chunks, found ${localeChunks.length}`);
if (localeChunks.length !== 3) {
throw new Error(`expected 3 on-demand locale chunks (zh, zh-TW, es), found ${localeChunks.length}`);
}
for (const path of localeChunks) {
const name = basename(path);
Expand Down Expand Up @@ -314,7 +314,12 @@ for (const path of localeChunks) {
// 64606 / 65349 B, so both dialect ceilings ratchet to the next tenth.
// Model-application copy on the read-pause base measures 64734 / 65499 B,
// adding 128 / 150 B. Retain only the next one-decimal ceiling.
const budget = name.startsWith("zh-TW-") ? 64.0 * 1024 : 63.3 * 1024;
// Spanish (es-419) prose is longer per key than Chinese: the full 3,332-key
// dictionary measures 59567 B (58.171 KiB) gzip. Keep the next one-decimal
// ceiling with bounded headroom like the dialects above.
const budget = name.startsWith("zh-TW-") ? 64.0 * 1024
: name.startsWith("zh-") ? 63.3 * 1024
: 58.3 * 1024;
assertBudget(`${name} gzip`, gzipBytes(path), budget);
}

Expand Down
4 changes: 2 additions & 2 deletions desktop/frontend/src/components/DiagnosticsSettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useI18n, useT, type Locale } from "../lib/i18n";
import type { CapabilityDiagnosticsReport, CapabilityIssue, RuntimeDoctorReport, SettingsTab } from "../lib/types";
import { FrontendDiagnosticsControl } from "./FrontendDiagnosticsControl";

const FRONTEND_COPY: Record<Locale, { title: string; hint: string }> = {
const FRONTEND_COPY: Record<Exclude<Locale, "es">, { title: string; hint: string }> = {
en: {
title: "Frontend interaction recording",
hint: "When scrolling jumps, sessions switch, or the UI flickers, turn this on, reproduce the issue, then turn it off and choose where to export. Only timing, events, and geometry are recorded; conversation content, input values, paths, and secrets are excluded.",
Expand All @@ -28,7 +28,7 @@ export function DiagnosticsSettingsPage({
}) {
const t = useT();
const { locale } = useI18n();
const frontendCopy = FRONTEND_COPY[locale];
const frontendCopy = FRONTEND_COPY[locale as keyof typeof FRONTEND_COPY] ?? FRONTEND_COPY.en;
const [report, setReport] = useState<CapabilityDiagnosticsReport | null>(null);
const [runtimeDoctor, setRuntimeDoctor] = useState<RuntimeDoctorReport | null>(null);
const [loading, setLoading] = useState(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
} from "../lib/frontendDiagnostics";

const defaultFilename = (reportId: string) => `reasonix-frontend-diagnostics-${reportId.slice(0, 8) || "trace"}.json`;
const COPY: Record<Locale, {
const COPY: Record<Exclude<Locale, "es">, {
start: string;
startHint: string;
recordingLabel: string;
Expand Down Expand Up @@ -72,7 +72,7 @@ export function FrontendDiagnosticsControl({
embedded = false,
}: FrontendDiagnosticsControlProps) {
const { locale } = useI18n();
const copy = COPY[locale];
const copy = COPY[locale as keyof typeof COPY] ?? COPY.en;
const scrollElementRef = useRef<HTMLElement | null>(scrollElement ?? null);
const totalRowsRef = useRef(totalRows);
scrollElementRef.current = scrollElement ?? null;
Expand Down
2 changes: 1 addition & 1 deletion desktop/frontend/src/components/InboxRecoveryBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function InboxRecoveryBanner({
onError: (error: unknown) => void;
}) {
const { locale } = useI18n();
const [recoveredTitle, pausedTitle, body, review, resume, keepPaused, paused] = COPY[locale];
const [recoveredTitle, pausedTitle, body, review, resume, keepPaused, paused] = COPY[locale as keyof typeof COPY] ?? COPY.en;
const title = recovered ? recoveredTitle.replace("{n}", String(count)) : pausedTitle;
const [busy, setBusy] = useState(false);
const [keptPaused, setKeptPaused] = useState(false);
Expand Down
4 changes: 2 additions & 2 deletions desktop/frontend/src/components/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -881,7 +881,7 @@ const COMPACT_RATIO_PRESETS = [
const REASONING_PROTOCOLS: readonly string[] = ["", "deepseek", "glm", "kimi-k3", "openai", "none"];
const THINKING_MODES: readonly string[] = ["", "enabled", "disabled", "adaptive"];
const PROXY_TYPES = ["http", "https", "socks5", "socks5h"] as const;
const LANGUAGE_PREFS: LangPref[] = ["", "zh", "en"];
const LANGUAGE_PREFS: LangPref[] = ["", "zh", "es", "en"];
const TOOL_APPROVAL_MODES = ["read-only", "workspace-write", "danger-full-access"] as const;
const BOT_TOOL_APPROVAL_MODES = ["", "read-only", "workspace-write", "danger-full-access"] as const;
const BOT_QUEUE_MODES = ["steer", "followup", "collect", "interrupt"] as const;
Expand Down Expand Up @@ -1707,7 +1707,7 @@ function GeneralSection({ s, busy, apply, agentRunning }: SectionProps & { agent
disabled={busy}
onClick={() => setLanguage(pref)}
>
{pref === "" ? t("settings.langAuto") : pref === "zh" ? "中文" : "English"}
{pref === "" ? t("settings.langAuto") : pref === "zh" ? "中文" : pref === "es" ? "Español" : "English"}
</button>
))}
</SettingsOptions>
Expand Down
2 changes: 1 addition & 1 deletion desktop/frontend/src/components/UsageStatsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ function localDay(offsetDays: number): string {

export function UsageStatsPanel() {
const { locale } = useI18n();
const t = useCallback<UsageStatsTranslator>((key) => USAGE_STATS_TRANSLATIONS[locale][key], [locale]);
const t = useCallback<UsageStatsTranslator>((key) => (USAGE_STATS_TRANSLATIONS[locale as keyof typeof USAGE_STATS_TRANSLATIONS] ?? USAGE_STATS_TRANSLATIONS.en)[key], [locale]);
const [range, setRange] = useState<string>("30");
const [customFrom, setCustomFrom] = useState("");
const [customTo, setCustomTo] = useState("");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ const heartbeatFeatureZhTW = {

export const heartbeatFeatureKeys = Object.keys(heartbeatFeatureEn) as HeartbeatFeatureKey[];

const heartbeatFeatureDictionaries: Record<Locale, Record<HeartbeatFeatureKey, string>> = {
const heartbeatFeatureDictionaries: Record<Exclude<Locale, "es">, Record<HeartbeatFeatureKey, string>> = {
en: heartbeatFeatureEn,
zh: heartbeatFeatureZh,
"zh-TW": heartbeatFeatureZhTW,
Expand All @@ -224,7 +224,8 @@ function interpolate(message: string, vars?: Record<string, string | number>): s
export function useHeartbeatT(): HeartbeatTranslator {
const { locale, t } = useI18n();
return useCallback<HeartbeatTranslator>((key, vars) => {
const message = heartbeatFeatureDictionaries[locale][key as HeartbeatFeatureKey];
const dict = heartbeatFeatureDictionaries[locale as keyof typeof heartbeatFeatureDictionaries] ?? heartbeatFeatureDictionaries.en;
const message = dict[key as HeartbeatFeatureKey];
return message === undefined ? t(key as DictKey, vars) : interpolate(message, vars);
}, [locale, t]);
}
6 changes: 3 additions & 3 deletions desktop/frontend/src/lib/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -674,8 +674,8 @@ export interface AppBindings extends ToolRecoveryBindings, ModelSettingsBindings
SetAgentParams(temperature: number, maxSteps: number, plannerMaxSteps: number, systemPrompt: string): Promise<void>;
SetCompactRatio(ratio: number): Promise<void>;
SetReasoningLanguage(lang: string): Promise<void>;
SetTrayLocale(locale: "en" | "zh" | "zh-TW"): Promise<void>;
// SetBypass is the legacy desktop name for YOLO/full-access tool auto-approval
SetTrayLocale(locale: "en" | "zh" | "zh-TW" | "es"): Promise<void>;
// SetBypass is the legacy Wails name for YOLO/full-access tool auto-approval
// (ask questions and plan approvals still wait; deny rules still apply).
// Runtime-only.
SetBypass(on: boolean): Promise<void>;
Expand Down Expand Up @@ -5056,7 +5056,7 @@ function makeMockApp(): AppBindings {
async CancelTaskForTab() { return { schema_version: 1, command: "cancel", task_id: "", accepted: false, idempotent: false, error: { code: "mock", message: "not available in browser mock" } }; },
async RequeueTaskForTab() { return { schema_version: 1, command: "requeue", task_id: "", accepted: false, idempotent: false, error: { code: "mock", message: "not available in browser mock" } }; },
async OpenTaskSessionForTab() { return { schema_version: 1, command: "open_session", task_id: "", accepted: false, idempotent: false, error: { code: "mock", message: "not available in browser mock" } }; },
async SetTrayLocale(_locale: "en" | "zh" | "zh-TW") {},
async SetTrayLocale(_locale: "en" | "zh" | "zh-TW" | "es") {},
async SetAutoApproveTools(_on: boolean) {
await this.SetToolApprovalMode("workspace-write");
},
Expand Down
4 changes: 2 additions & 2 deletions desktop/frontend/src/lib/desktopPreferencesMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export function createDesktopPreferencesMock(settings: SettingsView) {
settings.statusBarItems = normalizeStatusBarItems(items);
},
async SetDesktopLanguage(lang: string) {
settings.desktopLanguage = lang === "en" || lang === "zh" ? lang : "";
settings.desktopLanguage = lang === "en" || lang === "zh" || lang === "zh-TW" || lang === "es" ? lang : "";
},
async SetDesktopCurrency(currency: string) {
settings.desktopCurrency = currency === "CNY" || currency === "USD" ? currency : "";
Expand All @@ -39,7 +39,7 @@ export function createDesktopPreferencesMock(settings: SettingsView) {
async SetSessionExperience(mode: "standard" | "deep") { applyMockSessionExperience(settings, mode); },
async SetExpandThinking() { applyMockSessionExperience(settings, "standard"); },
async MigrateDesktopPreferences(language: string, theme: string, style: string) {
if (!settings.desktopLanguage) settings.desktopLanguage = language === "en" || language === "zh" || language === "zh-TW" ? language : "";
if (!settings.desktopLanguage) settings.desktopLanguage = language === "en" || language === "zh" || language === "zh-TW" || language === "es" ? language : "";
if (!settings.desktopTheme && !settings.desktopThemeStyle) {
settings.desktopTheme = theme === "auto" || theme === "light" ? theme : "dark";
settings.desktopThemeStyle = style;
Expand Down
20 changes: 14 additions & 6 deletions desktop/frontend/src/lib/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ import { createContext, useCallback, useContext, useEffect, useState } from "rea
import type { ReactNode } from "react";
import { en, type DictKey } from "../locales/en";

export type Locale = "en" | "zh" | "zh-TW";
export type Locale = "en" | "zh" | "zh-TW" | "es";
export type { DictKey };
// LangPref is the stored preference: "" means auto-detect from the OS.
export type LangPref = "" | "en" | "zh" | "zh-TW";
export type LangPref = "" | "en" | "zh" | "zh-TW" | "es";

type Dict = Record<DictKey, string>;

Expand Down Expand Up @@ -49,11 +49,17 @@ export const SPINNER_WORDS: Record<Locale, string[]> = {
"醃製入味", "嘎吱運算", "孵化中", "盤算中", "嗡嗡運轉", "鍛造中",
"探洞中", "擺弄中", "來感覺了",
],
es: [
"Pensando", "Reflexionando", "Rumiando", "Gestando", "Conjurando", "Cogitando",
"Procesando", "Sintetizando", "Ajustando", "Marinando", "Calculando", "Incubando",
"Sopesando", "Zumbando", "Forjando", "Explorando", "Vibrando",
],
};

export function detectLocale(pref: LangPref): Locale {
if (pref === "en" || pref === "zh" || pref === "zh-TW") return pref;
if (pref === "en" || pref === "zh" || pref === "zh-TW" || pref === "es") return pref;
const nav = typeof navigator !== "undefined" ? navigator.language.toLowerCase() : "en";
if (nav.startsWith("es")) return "es";
if (nav.startsWith("zh-tw") || nav.startsWith("zh-hant") || nav === "zh-hk" || nav === "zh-mo") return "zh-TW";
return nav.startsWith("zh") ? "zh" : "en";
}
Expand All @@ -62,7 +68,9 @@ export function preloadLocale(locale: Locale): Promise<void> {
if (DICTS[locale]) return Promise.resolve();
const pending = localeLoads.get(locale);
if (pending) return pending;
const load = locale === "zh"
const load = locale === "es"
? import("../locales/es").then(({ es }) => { DICTS.es = es; })
: locale === "zh"
? import("../locales/zh").then(({ zh }) => { DICTS.zh = zh; })
: import("../locales/zh-TW").then(({ zhTW }) => { DICTS["zh-TW"] = zhTW; });
localeLoads.set(locale, load);
Expand All @@ -79,7 +87,7 @@ function readPref(): LangPref {
}

export function normalizeLangPref(v: unknown): LangPref {
return v === "en" || v === "zh" || v === "zh-TW" ? v : "";
return v === "en" || v === "zh" || v === "zh-TW" || v === "es" ? (v as LangPref) : "";
}

export function readLegacyLangPref(): LangPref {
Expand Down Expand Up @@ -132,7 +140,7 @@ export function LocaleProvider({ children }: { children: ReactNode }) {

useEffect(() => {
if (typeof document === "undefined") return;
document.documentElement.lang = locale === "zh" ? "zh-CN" : locale === "zh-TW" ? "zh-TW" : "en";
document.documentElement.lang = locale === "zh" ? "zh-CN" : locale === "zh-TW" ? "zh-TW" : locale === "es" ? "es" : "en";
}, [locale]);

useEffect(() => {
Expand Down
11 changes: 7 additions & 4 deletions desktop/frontend/src/lib/inboxError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const UNKNOWN_INDEX = 13;
const STEER_QUEUED_INDEX = 14;
const CANCEL_FAILED_INDEX = 15;

const ERROR_COPY: Record<Locale, readonly string[]> = {
const ERROR_COPY: Record<Exclude<Locale, "es">, readonly string[]> = {
en: [
"Inbox is paused",
"The inbox has reached its item limit",
Expand Down Expand Up @@ -113,11 +113,13 @@ export function formatInboxError(error: unknown, locale: Locale): string {
const code = encodedCode || legacyCode;
if (!code) return raw;
const index = CODE_INDEX[code as InboxErrorCode];
return ERROR_COPY[locale][index ?? UNKNOWN_INDEX];
const copy = ERROR_COPY[locale as keyof typeof ERROR_COPY] ?? ERROR_COPY.en;
return copy[index ?? UNKNOWN_INDEX];
}

export function inboxSteerQueuedMessage(locale: Locale): string {
return ERROR_COPY[locale][STEER_QUEUED_INDEX];
const copy = ERROR_COPY[locale as keyof typeof ERROR_COPY] ?? ERROR_COPY.en;
return copy[STEER_QUEUED_INDEX];
}

export function isInboxItemMissing(error: unknown): boolean {
Expand All @@ -131,5 +133,6 @@ export function isTurnNotRunning(error: unknown): boolean {
}

export function formatInboxCancelError(error: unknown, locale: Locale): string {
return ERROR_COPY[locale][CANCEL_FAILED_INDEX].replace("{error}", formatInboxError(error, locale));
const copy = ERROR_COPY[locale as keyof typeof ERROR_COPY] ?? ERROR_COPY.en;
return copy[CANCEL_FAILED_INDEX].replace("{error}", formatInboxError(error, locale));
}
2 changes: 1 addition & 1 deletion desktop/frontend/src/lib/managementLocale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export type ManagementKey = keyof typeof messages;
export function useManagementT() {
const { locale } = useI18n();
return useCallback((key: ManagementKey, vars?: Record<string, string | number>) => {
const text = messages[key][locale === "en" ? 2 : locale === "zh-TW" ? 1 : 0];
const text = messages[key][locale === "zh" ? 0 : locale === "zh-TW" ? 1 : 2];
return text.replace(/\{(\w+)\}/g, (match, name: string) => vars?.[name] === undefined ? match : String(vars[name]));
}, [locale]);
}
Loading