From 073f60284efdc67c170a8fe49d2cb3aa713cdfb3 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Sat, 16 May 2026 07:45:50 +0700 Subject: [PATCH 1/3] feat(web/i18n): normalize regional locale tags (pt-BR, zh-CN, etc.) Mirrors the alias map in agent/i18n.py so user-supplied values from config.yaml or legacy localStorage entries route to the right catalog instead of silently falling back to English. Refs #26665 --- web/src/i18n/context.tsx | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/web/src/i18n/context.tsx b/web/src/i18n/context.tsx index 7d6fecf5c9bb..f64d8d4fe921 100644 --- a/web/src/i18n/context.tsx +++ b/web/src/i18n/context.tsx @@ -62,14 +62,49 @@ export const LOCALE_META: Record = { 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 = { + "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); } +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 getInitialLocale(): Locale { try { const stored = localStorage.getItem(STORAGE_KEY); - if (stored && isLocale(stored)) return stored; + const normalized = stored ? normalizeLocale(stored) : null; + if (normalized) return normalized; } catch { // SSR or privacy mode } From 909964b6564b583c7564126e06d3a4fc1cc67c8c Mon Sep 17 00:00:00 2001 From: xxxigm Date: Sat, 16 May 2026 07:46:45 +0700 Subject: [PATCH 2/3] fix(web/i18n): seed UI language from config.yaml display.language (#26665) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Desktop only read the locale from localStorage, so users who set `display.language` in `~/.hermes/config.yaml` directly (e.g. via the CLI setup wizard) saw English on every restart even though the value was saved. Fetch `display.language` from `/api/config` on mount and apply it when the user has not explicitly chosen a language via the in-browser dropdown — an explicit dropdown choice still wins. Fixes #26665 --- web/src/i18n/context.tsx | 44 +++++++++++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/web/src/i18n/context.tsx b/web/src/i18n/context.tsx index f64d8d4fe921..dfddce255088 100644 --- a/web/src/i18n/context.tsx +++ b/web/src/i18n/context.tsx @@ -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"; @@ -100,15 +101,32 @@ export function normalizeLocale(value: unknown): Locale | null { return null; } -function getInitialLocale(): Locale { +function readStoredLocale(): Locale | null { try { const stored = localStorage.getItem(STORAGE_KEY); - const normalized = stored ? normalizeLocale(stored) : null; - if (normalized) return normalized; + 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 { + 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 { @@ -126,6 +144,22 @@ const I18nContext = createContext({ export function I18nProvider({ children }: { children: ReactNode }) { const [locale, setLocaleState] = useState(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 { From 6e68ef4ee81bfee452b301947aa7041189e5d24b Mon Sep 17 00:00:00 2001 From: xxxigm Date: Sat, 16 May 2026 07:47:53 +0700 Subject: [PATCH 3/3] test(i18n): pin pt-BR/pt-PT/brazilian to pt catalog (#26665) Adds regression coverage for the specific aliases called out in the bug report so future refactors of the alias map can't silently route Portuguese users back to English. --- tests/agent/test_i18n.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/agent/test_i18n.py b/tests/agent/test_i18n.py index 6c374ebf4872..0c4b4283853a 100644 --- a/tests/agent/test_i18n.py +++ b/tests/agent/test_i18n.py @@ -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" + assert i18n._normalize_lang("pt-PT") == "pt" + assert i18n._normalize_lang("brazilian") == "pt" def test_normalize_lang_unknown_falls_back():