feat(i18n): language reset-to-auto-detect + review nitpicks (#1352)

Resolves the remaining review threads:

- Reset to auto-detect across all three layers (Greptile): store passes
  language:null through as null-as-delete; dashboard gains an Auto option
  (clearLanguage + hasExplicitChoice in useLanguage, re-detects from
  navigator, syncs cross-tab); CLI accepts 'fn settings set language auto'.
  Catalog keys added for all five locales; tests at every layer.
- CLI i18n test singleton: afterEach locale restore so zh-CN/fr switches
  can't leak across cases (CodeRabbit nitpick)
- useLocaleFormat memoized per locale for stable formatter identities
  (CodeRabbit nitpick)
- settings-reference.md documents the auto reset path

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-03 13:02:23 -07:00
parent 75213bbb08
commit e08a5633fa
16 changed files with 217 additions and 31 deletions

View File

@@ -33,7 +33,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
|---|---|---:|---|
| `themeMode` | `"dark" \| "light" \| "system"` | `"dark"` | Dashboard theme mode. |
| `colorTheme` | `ColorTheme` | `"default"` | Dashboard color theme preset. |
| `language` | `"en" \| "zh-CN" \| "zh-TW" \| "fr" \| "es"` | `undefined` | UI language for the dashboard and TUI. When unset, the dashboard detects from localStorage → browser language and the CLI from `--lang` flag → environment locale, falling back to `en`. Validated at the store write boundary (`validateLocale`); invalid values are dropped. |
| `language` | `"en" \| "zh-CN" \| "zh-TW" \| "fr" \| "es"` | `undefined` | UI language for the dashboard and TUI. When unset, the dashboard detects from localStorage → browser language and the CLI from `--lang` flag → environment locale, falling back to `en`. Validated at the store write boundary (`validateLocale`); invalid values are dropped. Reset to auto-detect via the dashboard's "Auto" language option or `fn settings set language auto` (clears the persisted key). |
| `dashboardFontScalePct` | `number` | `100` | Dashboard font scale percentage used by Appearance settings. Valid range: `85` to `125`; applied pre-hydration via document root font-size so board typography (column headers/counts, task cards, and quick-entry text) scales with the setting from first paint. |
| `defaultProvider` | `string` | `undefined` | Default AI provider. |
| `defaultModelId` | `string` | `undefined` | Default AI model ID. |

View File

@@ -156,6 +156,34 @@ describe("settings commands", () => {
expect(resolveProject).not.toHaveBeenCalled();
});
it("runSettingsSet language persists a supported locale globally", async () => {
const updateSettings = vi.fn().mockResolvedValue(makeSettings({ language: "zh-TW" } as any));
const getSettings = vi.fn().mockResolvedValue(makeSettings({ language: "zh-TW" } as any));
(GlobalSettingsStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
updateSettings,
getSettings,
}));
await runSettingsSet("language", "zh-TW");
expect(updateSettings).toHaveBeenCalledWith({ language: "zh-TW" });
});
it("runSettingsSet language auto clears the persisted locale (null-as-delete)", async () => {
const updateSettings = vi.fn().mockResolvedValue(makeSettings({}));
const getSettings = vi.fn().mockResolvedValue(makeSettings({}));
(GlobalSettingsStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
updateSettings,
getSettings,
}));
await runSettingsSet("language", "auto");
expect(updateSettings).toHaveBeenCalledWith({ language: null });
});
it("runSettingsSet with project updates project-only settings", async () => {
const updateSettings = vi.fn().mockResolvedValue(makeSettings({ maxConcurrent: 6 }));
const getSettings = vi.fn().mockResolvedValue(makeSettings({ maxConcurrent: 6 }));

View File

@@ -66,7 +66,8 @@ const ENUM_SETTINGS: Record<string, readonly string[]> = {
worktreeNaming: ["random", "task-id", "task-title"],
unavailableNodePolicy: ["block", "fallback-local"],
"worktrunk.onFailure": ["fail", "fallback-native"],
language: SUPPORTED_LOCALES,
// "auto" clears the persisted locale and reverts to runtime detection.
language: [...SUPPORTED_LOCALES, "auto"],
};
const STRING_SETTINGS: readonly string[] = [
@@ -339,6 +340,16 @@ export async function runSettingsSet(key: string, value: string, projectName?: s
try {
const parsedValue = parseValue(validKey, value);
if (key === "language" && parsedValue === "auto") {
// null-as-delete: removes the persisted key so the dashboard re-detects
// from the browser and the TUI falls back to the environment locale.
await globalStore!.updateSettings({ language: null } as unknown as Partial<GlobalSettings>);
console.log();
console.log(" ✓ Language reset to auto-detect (browser/environment locale)");
console.log();
return;
}
if (key === "defaultModel") {
const parts = (parsedValue as string).split("/");
if (parts.length !== 2) {

View File

@@ -2,9 +2,17 @@ import { Text } from "ink";
import { render } from "ink-testing-library";
import { createElement } from "react";
import { I18nextProvider, useTranslation } from "react-i18next";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { cliI18n, detectEnvLocale, initCliI18n, resolveCliLocale } from "../index.js";
// cliI18n is a module singleton: restore the locale after each test so cases
// that switch language (zh-CN/fr) can't leak state into later tests.
afterEach(async () => {
if (cliI18n.isInitialized && cliI18n.language !== "en") {
await cliI18n.changeLanguage("en");
}
});
describe("detectEnvLocale", () => {
it("parses POSIX locale env values to a supported locale", () => {
expect(detectEnvLocale({ LANG: "fr_FR.UTF-8" })).toBe("fr");

View File

@@ -38,4 +38,18 @@ describe("settings precedence", () => {
});
expect(scoped.project.worktrunk).toEqual({ enabled: false });
});
it("validates language at the global write boundary and clears it via null", async () => {
// Valid locale persists.
await harness.store().updateGlobalSettings({ language: "fr" });
expect((await harness.store().getSettings()).language).toBe("fr");
// Invalid value is dropped at the boundary — prior choice survives.
await harness.store().updateGlobalSettings({ language: "klingon" } as never);
expect((await harness.store().getSettings()).language).toBe("fr");
// Explicit null clears the persisted key (reset to runtime auto-detect).
await harness.store().updateGlobalSettings({ language: null } as never);
expect((await harness.store().getSettings()).language).toBeUndefined();
});
});

View File

@@ -3255,12 +3255,17 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// Validate the optional UI locale at the write boundary: drop unrecognized
// values rather than persisting junk into settings.json. Runtime consumers
// also guard via isLocale, but the contract is `language?: Locale`.
// `null` passes through intact — GlobalSettingsStore treats null as
// "delete this key", which reverts the language to runtime auto-detect.
if ("language" in globalPatch) {
const validatedLanguage = validateLocale((globalPatch as Record<string, unknown>)["language"]);
if (validatedLanguage === undefined) {
delete (globalPatch as Record<string, unknown>)["language"];
} else {
globalPatch.language = validatedLanguage;
const rawLanguage = (globalPatch as Record<string, unknown>)["language"];
if (rawLanguage !== null) {
const validatedLanguage = validateLocale(rawLanguage);
if (validatedLanguage === undefined) {
delete (globalPatch as Record<string, unknown>)["language"];
} else {
globalPatch.language = validatedLanguage;
}
}
}

View File

@@ -15,7 +15,8 @@ const ENDONYMS: Record<Locale, string> = {
/** Settings control for choosing the UI language. Applies in place (no reload). */
export function LanguageSelector() {
const { t } = useTranslation("app");
const { language, supportedLocales, setLanguage } = useLanguage();
const { language, supportedLocales, setLanguage, clearLanguage, hasExplicitChoice } =
useLanguage();
const label = t("settings.appearance.language", "Language");
return (
@@ -24,13 +25,22 @@ export function LanguageSelector() {
{/* role="group" + aria-pressed: toggle-button semantics (radiogroup would
conflict with aria-pressed and confuse screen readers). */}
<div className="language-options" role="group" aria-label={label}>
<button
type="button"
className={`language-option${hasExplicitChoice ? "" : " active"}`}
onClick={clearLanguage}
aria-pressed={!hasExplicitChoice}
title={t("settings.appearance.languageAutoHint", "Follow the browser language")}
>
{t("settings.appearance.languageAuto", "Auto")}
</button>
{supportedLocales.map((locale) => (
<button
key={locale}
type="button"
className={`language-option${language === locale ? " active" : ""}`}
className={`language-option${hasExplicitChoice && language === locale ? " active" : ""}`}
onClick={() => setLanguage(locale)}
aria-pressed={language === locale}
aria-pressed={hasExplicitChoice && language === locale}
lang={locale}
>
{ENDONYMS[locale]}

View File

@@ -11,14 +11,18 @@ vi.mock("react-i18next", () => ({
const { LanguageSelector } = await import("../LanguageSelector");
const mockUseLanguage = vi.mocked(useLanguage);
const setLanguage = vi.fn();
const clearLanguage = vi.fn();
describe("LanguageSelector", () => {
beforeEach(() => {
setLanguage.mockClear();
clearLanguage.mockClear();
mockUseLanguage.mockReturnValue({
language: "en",
supportedLocales: SUPPORTED_LOCALES,
setLanguage,
clearLanguage,
hasExplicitChoice: true,
});
});
@@ -40,4 +44,25 @@ describe("LanguageSelector", () => {
fireEvent.click(screen.getByText("简体中文"));
expect(setLanguage).toHaveBeenCalledWith("zh-CN");
});
it("offers an Auto option that clears the explicit choice", () => {
render(<LanguageSelector />);
const auto = screen.getByText("Auto");
expect(auto.getAttribute("aria-pressed")).toBe("false");
fireEvent.click(auto);
expect(clearLanguage).toHaveBeenCalledTimes(1);
});
it("marks Auto as pressed (and no locale) when no explicit choice exists", () => {
mockUseLanguage.mockReturnValue({
language: "fr", // detected, not chosen
supportedLocales: SUPPORTED_LOCALES,
setLanguage,
clearLanguage,
hasExplicitChoice: false,
});
render(<LanguageSelector />);
expect(screen.getByText("Auto").getAttribute("aria-pressed")).toBe("true");
expect(screen.getByText("Français").getAttribute("aria-pressed")).toBe("false");
});
});

View File

@@ -124,6 +124,29 @@ describe("useLanguage", () => {
expect(i18nMock.changeLanguage).toHaveBeenCalledWith("zh-TW");
});
it("clearLanguage removes the local key, clears the server setting, and re-detects", () => {
store[LANGUAGE_STORAGE_KEY] = "fr";
vi.stubGlobal("navigator", { languages: ["es-419", "en-US"], language: "es-419" });
const { result } = renderHook(() => useLanguage());
expect(result.current.hasExplicitChoice).toBe(true);
act(() => result.current.clearLanguage());
expect(store[LANGUAGE_STORAGE_KEY]).toBeUndefined();
expect(mockUpdate).toHaveBeenCalledWith({ language: null });
expect(i18nMock.changeLanguage).toHaveBeenCalledWith("es"); // re-detected
expect(result.current.hasExplicitChoice).toBe(false);
});
it("setLanguage marks the choice explicit; clearLanguage unmarks it", () => {
const { result } = renderHook(() => useLanguage());
expect(result.current.hasExplicitChoice).toBe(false);
act(() => result.current.setLanguage("fr"));
expect(result.current.hasExplicitChoice).toBe(true);
act(() => result.current.clearLanguage());
expect(result.current.hasExplicitChoice).toBe(false);
});
it("degrades gracefully when localStorage is unavailable", () => {
vi.stubGlobal("localStorage", {
getItem: () => null,

View File

@@ -1,4 +1,5 @@
import { DEFAULT_LOCALE, isLocale, type Locale, SUPPORTED_LOCALES } from "@fusion/core";
import { normalizeToSupportedLocale } from "@fusion/i18n/config";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { fetchGlobalSettings, updateGlobalSettings } from "../api";
@@ -16,10 +17,26 @@ function readCachedLanguage(): Locale | undefined {
}
}
/** Re-run browser language detection, mirroring the i18next detector's
* navigator step (used when the user clears their explicit choice). */
function detectBrowserLocale(): Locale {
if (!isBrowser) return DEFAULT_LOCALE;
for (const candidate of navigator.languages ?? [navigator.language]) {
const match = normalizeToSupportedLocale(candidate);
if (match) return match;
}
return DEFAULT_LOCALE;
}
export interface UseLanguageReturn {
language: Locale;
supportedLocales: readonly Locale[];
setLanguage: (locale: Locale) => void;
/** Drop the explicit choice everywhere (localStorage + server) and revert
* to runtime auto-detection. */
clearLanguage: () => void;
/** True when the user has explicitly chosen a language (vs auto-detect). */
hasExplicitChoice: boolean;
}
/**
@@ -36,6 +53,9 @@ export function useLanguage(): UseLanguageReturn {
const resolved = i18n.resolvedLanguage ?? i18n.language;
const initial: Locale = isLocale(resolved) ? resolved : DEFAULT_LOCALE;
const [language, setLanguageState] = useState<Locale>(initial);
const [hasExplicitChoice, setHasExplicitChoice] = useState<boolean>(
() => readCachedLanguage() !== undefined,
);
const userSetRef = useRef(false);
// Reflect any i18next language change (including programmatic ones) into state.
@@ -54,12 +74,19 @@ export function useLanguage(): UseLanguageReturn {
useEffect(() => {
if (!isBrowser) return;
const onStorage = (event: StorageEvent) => {
if (
event.key === LANGUAGE_STORAGE_KEY &&
isLocale(event.newValue) &&
event.newValue !== i18n.resolvedLanguage
) {
void i18n.changeLanguage(event.newValue);
if (event.key !== LANGUAGE_STORAGE_KEY) return;
if (isLocale(event.newValue)) {
setHasExplicitChoice(true);
if (event.newValue !== i18n.resolvedLanguage) {
void i18n.changeLanguage(event.newValue);
}
} else if (event.newValue === null) {
// Another tab cleared the choice — revert to auto-detect here too.
setHasExplicitChoice(false);
const detected = detectBrowserLocale();
if (detected !== i18n.resolvedLanguage) {
void i18n.changeLanguage(detected);
}
}
};
window.addEventListener("storage", onStorage);
@@ -94,8 +121,9 @@ export function useLanguage(): UseLanguageReturn {
(locale: Locale) => {
userSetRef.current = true;
setLanguageState(locale);
// changeLanguage re-renders the tree in place and the detector caches the
// choice to localStorage; write it explicitly too in case caching is off.
setHasExplicitChoice(true);
// changeLanguage re-renders the tree in place; the storage key is the
// marker for "explicit user choice", written only here.
void i18n.changeLanguage(locale);
try {
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
@@ -109,5 +137,23 @@ export function useLanguage(): UseLanguageReturn {
[i18n],
);
return { language, supportedLocales: SUPPORTED_LOCALES, setLanguage };
const clearLanguage = useCallback(() => {
userSetRef.current = true;
setHasExplicitChoice(false);
try {
localStorage.removeItem(LANGUAGE_STORAGE_KEY);
} catch {
// localStorage unavailable — server clear below still runs.
}
// `language: null` is null-as-delete at the store boundary: the persisted
// key is removed and every surface reverts to runtime auto-detection.
void updateGlobalSettings({ language: null } as unknown as Parameters<
typeof updateGlobalSettings
>[0]).catch((error) => {
console.warn("[useLanguage] Failed to clear language in global settings", error);
});
void i18n.changeLanguage(detectBrowserLocale());
}, [i18n]);
return { language, supportedLocales: SUPPORTED_LOCALES, setLanguage, clearLanguage, hasExplicitChoice };
}

View File

@@ -1,3 +1,4 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
/**
@@ -11,15 +12,20 @@ export function useLocaleFormat() {
const { i18n } = useTranslation();
const locale = i18n.resolvedLanguage || i18n.language || "en";
return {
locale,
formatDate: (value: number | string | Date, options?: Intl.DateTimeFormatOptions) =>
new Date(value).toLocaleDateString(locale, options),
formatTime: (value: number | string | Date, options?: Intl.DateTimeFormatOptions) =>
new Date(value).toLocaleTimeString(locale, options),
formatDateTime: (value: number | string | Date, options?: Intl.DateTimeFormatOptions) =>
new Date(value).toLocaleString(locale, options),
formatNumber: (value: number, options?: Intl.NumberFormatOptions) =>
value.toLocaleString(locale, options),
};
// Memoized per locale so the formatter identities stay stable across
// renders — consumers can safely put them in hook dependency arrays.
return useMemo(
() => ({
locale,
formatDate: (value: number | string | Date, options?: Intl.DateTimeFormatOptions) =>
new Date(value).toLocaleDateString(locale, options),
formatTime: (value: number | string | Date, options?: Intl.DateTimeFormatOptions) =>
new Date(value).toLocaleTimeString(locale, options),
formatDateTime: (value: number | string | Date, options?: Intl.DateTimeFormatOptions) =>
new Date(value).toLocaleString(locale, options),
formatNumber: (value: number, options?: Intl.NumberFormatOptions) =>
value.toLocaleString(locale, options),
}),
[locale],
);
}

View File

@@ -3,6 +3,8 @@
"appearance": {
"title": "Appearance",
"language": "Language",
"languageAuto": "Auto",
"languageAutoHint": "Follow the browser language",
"languageHint": "Choose the language for the {{brand}} interface."
}
}

View File

@@ -3,6 +3,8 @@
"appearance": {
"title": "Apariencia",
"language": "Idioma",
"languageAuto": "Automático",
"languageAutoHint": "Seguir el idioma del navegador",
"languageHint": "Elige el idioma de la interfaz de {{brand}}."
}
}

View File

@@ -3,6 +3,8 @@
"appearance": {
"title": "Apparence",
"language": "Langue",
"languageAuto": "Auto",
"languageAutoHint": "Suivre la langue du navigateur",
"languageHint": "Choisissez la langue de l'interface {{brand}}."
}
}

View File

@@ -3,6 +3,8 @@
"appearance": {
"title": "外观",
"language": "语言",
"languageAuto": "自动",
"languageAutoHint": "跟随浏览器语言",
"languageHint": "选择 {{brand}} 界面的语言。"
}
}

View File

@@ -3,6 +3,8 @@
"appearance": {
"title": "外觀",
"language": "語言",
"languageAuto": "自動",
"languageAutoHint": "跟隨瀏覽器語言",
"languageHint": "選擇 {{brand}} 介面的語言。"
}
}