Skip to content
Closed
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
5 changes: 5 additions & 0 deletions tests/agent/test_i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,11 @@ def test_normalize_lang_accepts_aliases():
assert i18n._normalize_lang("Turkish") == "tr"
assert i18n._normalize_lang("tr-TR") == "tr"
assert i18n._normalize_lang("türkçe") == "tr"
# Regional Portuguese tags should both land on the shared `pt` catalog
# rather than silently falling back to English (#26665).
assert i18n._normalize_lang("pt-BR") == "pt"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These assertions cover the Python alias map, but the behavior added by this PR is in web/src/i18n/context.tsx. Please add a web-provider test that verifies /api/config display.language: "pt-BR" is applied only when no stored locale exists.

assert i18n._normalize_lang("pt-PT") == "pt"
assert i18n._normalize_lang("brazilian") == "pt"


def test_normalize_lang_unknown_falls_back():
Expand Down
77 changes: 73 additions & 4 deletions web/src/i18n/context.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createContext, useContext, useState, useCallback, type ReactNode } from "react";
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from "react";
import type { Locale, Translations } from "./types";
import { api } from "@/lib/api";
import { en } from "./en";
import { zh } from "./zh";
import { zhHant } from "./zh-hant";
Expand Down Expand Up @@ -62,18 +63,70 @@ export const LOCALE_META: Record<Locale, { name: string; flag: string }> = {
const SUPPORTED_LOCALES = Object.keys(TRANSLATIONS) as Locale[];
const STORAGE_KEY = "hermes-locale";

// Aliases for values users (or config.yaml) may supply that aren't bare
// locale codes — common BCP-47 regional tags ("pt-BR", "zh-CN") plus a few
// English/endonym names. Mirrors agent/i18n.py so the Desktop and CLI
// route the same input to the same catalog.
const LOCALE_ALIASES: Record<string, Locale> = {
"en-us": "en", "en-gb": "en", english: "en",
"zh-cn": "zh", "zh-hans": "zh", "zh-sg": "zh", chinese: "zh",
"zh-tw": "zh-hant", "zh-hk": "zh-hant", "zh-mo": "zh-hant",
"ja-jp": "ja", jp: "ja", japanese: "ja",
"de-de": "de", "de-at": "de", "de-ch": "de", german: "de",
"es-es": "es", "es-mx": "es", "es-ar": "es", spanish: "es",
"fr-fr": "fr", "fr-be": "fr", "fr-ca": "fr", "fr-ch": "fr", french: "fr",
"tr-tr": "tr", turkish: "tr",
"uk-ua": "uk", ua: "uk", ukrainian: "uk",
"af-za": "af", afrikaans: "af",
"ko-kr": "ko", korean: "ko",
"it-it": "it", "it-ch": "it", italian: "it",
"ga-ie": "ga", irish: "ga",
"pt-pt": "pt", "pt-br": "pt", portuguese: "pt", brazilian: "pt",
"ru-ru": "ru", russian: "ru",
"hu-hu": "hu", hungarian: "hu",
};

function isLocale(value: string): value is Locale {
return (SUPPORTED_LOCALES as string[]).includes(value);
}

function getInitialLocale(): Locale {
export function normalizeLocale(value: unknown): Locale | null {
if (typeof value !== "string") return null;
const key = value.trim().toLowerCase();
if (!key) return null;
if (isLocale(key)) return key;
if (key in LOCALE_ALIASES) return LOCALE_ALIASES[key];
const base = key.split("-", 1)[0];
if (isLocale(base)) return base;
return null;
}

function readStoredLocale(): Locale | null {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored && isLocale(stored)) return stored;
return stored ? normalizeLocale(stored) : null;
} catch {
// SSR or privacy mode
return null;
}
}

function getInitialLocale(): Locale {
return readStoredLocale() ?? "en";
}

// Read display.language from config.yaml via /api/config. Used to seed the
// initial UI language for users who set the value in config.yaml directly
// (e.g. CLI setup wizard) without touching the in-browser dropdown. See
// issue #26665.
async function fetchConfigLocale(): Promise<Locale | null> {
try {
const cfg = await api.getConfig();
const display = (cfg as { display?: { language?: unknown } }).display;
return normalizeLocale(display?.language);
} catch {
return null;
}
return "en";
}

interface I18nContextValue {
Expand All @@ -91,6 +144,22 @@ const I18nContext = createContext<I18nContextValue>({
export function I18nProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(getInitialLocale);

// If the user never touched the in-browser dropdown (no localStorage
// entry) honour display.language from config.yaml on startup. An
// explicit dropdown choice — saved to localStorage — always wins.
useEffect(() => {
if (readStoredLocale()) return;
let cancelled = false;
fetchConfigLocale().then((fromConfig) => {
if (!cancelled && fromConfig && !readStoredLocale()) {
setLocaleState(fromConfig);
}
});
return () => {
cancelled = true;
};
}, []);

const setLocale = useCallback((l: Locale) => {
setLocaleState(l);
try {
Expand Down
Loading