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:
@@ -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. |
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
"language": "Language",
|
||||
"languageAuto": "Auto",
|
||||
"languageAutoHint": "Follow the browser language",
|
||||
"languageHint": "Choose the language for the {{brand}} interface."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
"appearance": {
|
||||
"title": "外观",
|
||||
"language": "语言",
|
||||
"languageAuto": "自动",
|
||||
"languageAutoHint": "跟随浏览器语言",
|
||||
"languageHint": "选择 {{brand}} 界面的语言。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
"appearance": {
|
||||
"title": "外觀",
|
||||
"language": "語言",
|
||||
"languageAuto": "自動",
|
||||
"languageAutoHint": "跟隨瀏覽器語言",
|
||||
"languageHint": "選擇 {{brand}} 介面的語言。"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user