diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 5014cad924..8afcc85427 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -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. | diff --git a/packages/cli/src/commands/__tests__/settings.test.ts b/packages/cli/src/commands/__tests__/settings.test.ts index a6e8bb1880..1de05dd11e 100644 --- a/packages/cli/src/commands/__tests__/settings.test.ts +++ b/packages/cli/src/commands/__tests__/settings.test.ts @@ -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).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).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 })); diff --git a/packages/cli/src/commands/settings.ts b/packages/cli/src/commands/settings.ts index c8bdc331ae..84fea3e418 100644 --- a/packages/cli/src/commands/settings.ts +++ b/packages/cli/src/commands/settings.ts @@ -66,7 +66,8 @@ const ENUM_SETTINGS: Record = { 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); + 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) { diff --git a/packages/cli/src/i18n/__tests__/i18n.test.tsx b/packages/cli/src/i18n/__tests__/i18n.test.tsx index 05f2f42541..43d86297c3 100644 --- a/packages/cli/src/i18n/__tests__/i18n.test.tsx +++ b/packages/cli/src/i18n/__tests__/i18n.test.tsx @@ -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"); diff --git a/packages/core/src/__tests__/settings-precedence.test.ts b/packages/core/src/__tests__/settings-precedence.test.ts index 8df5c5e82c..f33fcacc67 100644 --- a/packages/core/src/__tests__/settings-precedence.test.ts +++ b/packages/core/src/__tests__/settings-precedence.test.ts @@ -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(); + }); }); diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index a7d43b6589..9281ef2de3 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -3255,12 +3255,17 @@ export class TaskStore extends EventEmitter { // 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)["language"]); - if (validatedLanguage === undefined) { - delete (globalPatch as Record)["language"]; - } else { - globalPatch.language = validatedLanguage; + const rawLanguage = (globalPatch as Record)["language"]; + if (rawLanguage !== null) { + const validatedLanguage = validateLocale(rawLanguage); + if (validatedLanguage === undefined) { + delete (globalPatch as Record)["language"]; + } else { + globalPatch.language = validatedLanguage; + } } } diff --git a/packages/dashboard/app/components/LanguageSelector.tsx b/packages/dashboard/app/components/LanguageSelector.tsx index e8ca1b68d1..167b9b4e38 100644 --- a/packages/dashboard/app/components/LanguageSelector.tsx +++ b/packages/dashboard/app/components/LanguageSelector.tsx @@ -15,7 +15,8 @@ const ENDONYMS: Record = { /** 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). */}
+ {supportedLocales.map((locale) => (