From d3a6bf80dde9aaf667f297dd95f9379bf4b23cde Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 11 Jun 2026 13:48:06 +0700 Subject: [PATCH] fix(app): clean up i18n settings labels --- packages/app/e2e/settings-i18n.spec.ts | 4 +- packages/app/src/i18n/locales.test.ts | 50 ++++++++++++- packages/app/src/i18n/locales.ts | 79 ++++++++++++++++++++ packages/app/src/i18n/resources.test.ts | 19 +++++ packages/app/src/i18n/resources/ar.ts | 6 +- packages/app/src/i18n/resources/es.ts | 14 ++-- packages/app/src/i18n/resources/fr.ts | 6 +- packages/app/src/i18n/resources/ru.ts | 6 +- packages/app/src/screens/settings-screen.tsx | 30 ++++++-- 9 files changed, 190 insertions(+), 24 deletions(-) diff --git a/packages/app/e2e/settings-i18n.spec.ts b/packages/app/e2e/settings-i18n.spec.ts index 765d4c333..556c82f56 100644 --- a/packages/app/e2e/settings-i18n.spec.ts +++ b/packages/app/e2e/settings-i18n.spec.ts @@ -12,12 +12,12 @@ test("Settings language selector switches General labels", async ({ page }) => { await expect(page.getByText("Default send", { exact: true }).first()).toBeVisible(); await page.getByRole("button", { name: "System", exact: true }).click(); - await page.getByRole("button", { name: "Simplified Chinese", exact: true }).click(); + await page.getByRole("button", { name: "简体中文 - Simplified Chinese", exact: true }).click(); await expect(page.getByText("默认发送", { exact: true }).first()).toBeVisible(); await page.getByRole("button", { name: "简体中文", exact: true }).click(); - await page.getByRole("button", { name: "English", exact: true }).click(); + await page.getByRole("button", { name: "English - 英语", exact: true }).click(); await expect(page.getByText("Default send", { exact: true }).first()).toBeVisible(); }); diff --git a/packages/app/src/i18n/locales.test.ts b/packages/app/src/i18n/locales.test.ts index e10270091..e6c4fd357 100644 --- a/packages/app/src/i18n/locales.test.ts +++ b/packages/app/src/i18n/locales.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { LANGUAGE_OPTIONS, parseAppLanguage, resolveSupportedLocale } from "./locales"; +import { + LANGUAGE_OPTIONS, + formatLanguageOptionLabel, + parseAppLanguage, + resolveSupportedLocale, +} from "./locales"; describe("parseAppLanguage", () => { it("accepts system and all UN official language locales", () => { @@ -32,6 +37,44 @@ describe("parseAppLanguage", () => { }); }); +describe("formatLanguageOptionLabel", () => { + it("shows the native language name and English name in English UI", () => { + const arabic = LANGUAGE_OPTIONS.find((option) => option.value === "ar"); + const spanish = LANGUAGE_OPTIONS.find((option) => option.value === "es"); + const chinese = LANGUAGE_OPTIONS.find((option) => option.value === "zh-CN"); + + expect([ + formatLanguageOptionLabel(arabic!, "en", "System"), + formatLanguageOptionLabel(spanish!, "en", "System"), + formatLanguageOptionLabel(chinese!, "en", "System"), + ]).toEqual(["العربية - Arabic", "Español - Spanish", "简体中文 - Simplified Chinese"]); + }); + + it("shows the native language name and Chinese name in Chinese UI", () => { + const arabic = LANGUAGE_OPTIONS.find((option) => option.value === "ar"); + const english = LANGUAGE_OPTIONS.find((option) => option.value === "en"); + const spanish = LANGUAGE_OPTIONS.find((option) => option.value === "es"); + + expect([ + formatLanguageOptionLabel(arabic!, "zh-CN", "系统"), + formatLanguageOptionLabel(english!, "zh-CN", "系统"), + formatLanguageOptionLabel(spanish!, "zh-CN", "系统"), + ]).toEqual(["العربية - 阿拉伯语", "English - 英语", "Español - 西班牙语"]); + }); + + it("uses a single label when both language names match", () => { + const english = LANGUAGE_OPTIONS.find((option) => option.value === "en"); + + expect(formatLanguageOptionLabel(english!, "en", "System")).toBe("English"); + }); + + it("uses the active-language name for System", () => { + const system = LANGUAGE_OPTIONS.find((option) => option.value === "system"); + + expect(formatLanguageOptionLabel(system!, "zh-CN", "系统")).toBe("系统"); + }); +}); + describe("resolveSupportedLocale", () => { it("respects explicit language choices", () => { expect(resolveSupportedLocale("ar", ["en-US"])).toBe("ar"); @@ -44,11 +87,16 @@ describe("resolveSupportedLocale", () => { it("maps UN official system locales", () => { expect(resolveSupportedLocale("system", ["ar-EG"])).toBe("ar"); + expect(resolveSupportedLocale("system", ["en-US"])).toBe("en"); expect(resolveSupportedLocale("system", ["es-MX"])).toBe("es"); expect(resolveSupportedLocale("system", ["fr-CA"])).toBe("fr"); expect(resolveSupportedLocale("system", ["ru-RU"])).toBe("ru"); }); + it("keeps English when Spanish is a secondary system language", () => { + expect(resolveSupportedLocale("system", ["en-US", "es-GB"])).toBe("en"); + }); + it("maps Chinese system locales to Simplified Chinese", () => { expect(resolveSupportedLocale("system", ["zh"])).toBe("zh-CN"); expect(resolveSupportedLocale("system", ["zh-CN"])).toBe("zh-CN"); diff --git a/packages/app/src/i18n/locales.ts b/packages/app/src/i18n/locales.ts index 30689fee8..decf04ee2 100644 --- a/packages/app/src/i18n/locales.ts +++ b/packages/app/src/i18n/locales.ts @@ -19,6 +19,64 @@ export const LANGUAGE_OPTIONS: LanguageOption[] = [ ]; const SUPPORTED_LANGUAGES = new Set(["system", "ar", "en", "es", "fr", "ru", "zh-CN"]); +const LANGUAGE_NATIVE_NAMES: Record = { + ar: "العربية", + en: "English", + es: "Español", + fr: "Français", + ru: "Русский", + "zh-CN": "简体中文", +}; +const LANGUAGE_NAMES_BY_LOCALE: Record> = { + ar: { + ar: "العربية", + en: "الإنجليزية", + es: "الإسبانية", + fr: "الفرنسية", + ru: "الروسية", + "zh-CN": "الصينية المبسطة", + }, + en: { + ar: "Arabic", + en: "English", + es: "Spanish", + fr: "French", + ru: "Russian", + "zh-CN": "Simplified Chinese", + }, + es: { + ar: "árabe", + en: "inglés", + es: "español", + fr: "francés", + ru: "ruso", + "zh-CN": "chino simplificado", + }, + fr: { + ar: "arabe", + en: "anglais", + es: "espagnol", + fr: "français", + ru: "russe", + "zh-CN": "chinois simplifié", + }, + ru: { + ar: "арабский", + en: "английский", + es: "испанский", + fr: "французский", + ru: "русский", + "zh-CN": "упрощенный китайский", + }, + "zh-CN": { + ar: "阿拉伯语", + en: "英语", + es: "西班牙语", + fr: "法语", + ru: "俄语", + "zh-CN": "简体中文", + }, +}; export function parseAppLanguage(value: unknown): AppLanguage | null { return typeof value === "string" && SUPPORTED_LANGUAGES.has(value as AppLanguage) @@ -26,6 +84,24 @@ export function parseAppLanguage(value: unknown): AppLanguage | null { : null; } +export function formatLanguageOptionLabel( + option: LanguageOption, + activeLocale: SupportedLocale, + systemLabel: string, +): string { + if (option.value === "system") { + return systemLabel; + } + + const nativeName = LANGUAGE_NATIVE_NAMES[option.value]; + const activeLanguageName = LANGUAGE_NAMES_BY_LOCALE[activeLocale][option.value]; + if (nativeName === activeLanguageName) { + return nativeName; + } + + return `${nativeName} - ${activeLanguageName}`; +} + export function resolveSupportedLocale( language: AppLanguage, systemLocales: readonly string[], @@ -39,6 +115,9 @@ export function resolveSupportedLocale( if (normalized === "ar" || normalized.startsWith("ar-")) { return "ar"; } + if (normalized === "en" || normalized.startsWith("en-")) { + return "en"; + } if (normalized === "es" || normalized.startsWith("es-")) { return "es"; } diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index cf510c474..8f6f24526 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -127,6 +127,25 @@ describe("translation resources", () => { expect(findInterpolationMismatches(zhCN)).toEqual([]); }); + it("keeps reported Spanish settings and scripts labels clean", () => { + expect(es.workspace.scripts.title).toBe("Scripts"); + expect(es.settings.general.terminalScrollback.label).toBe("Historial de terminal"); + expect(es.settings.project.scripts.title).toBe("Scripts"); + }); + + it("keeps model count labels spaced around the count", () => { + expect(ar.modelSelector.modelCountPlural).toBe("{{count}} نماذج"); + expect(es.modelSelector.modelCountPlural).toBe("{{count}} modelos"); + expect(fr.modelSelector.modelCountPlural).toBe("{{count}} modèles"); + expect(ru.modelSelector.modelCountPlural).toBe("{{count}} моделей"); + expect(zhCN.modelSelector.modelCountPlural).toBe("{{count}} 个模型"); + expect(ar.settings.providers.models.many).toBe("{{count}} نماذج"); + expect(es.settings.providers.models.many).toBe("{{count}} modelos"); + expect(fr.settings.providers.models.many).toBe("{{count}} modèles"); + expect(ru.settings.providers.models.many).toBe("{{count}} моделей"); + expect(zhCN.settings.providers.models.many).toBe("{{count}} 个 Model"); + }); + it("keeps local connection fallback errors translated", () => { expect(findUntranslatedConnectionErrors()).toEqual([]); }); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 28e5ec410..0480b043f 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1082,8 +1082,8 @@ export const ar: TranslationResources = { favorites: "المفضلة", favoriteModel: "النموذج المفضل", unfavoriteModel: "نموذج غير مفضل", - modelCount: "موديل{{count}}", - modelCountPlural: "نماذج{{count}}", + modelCount: "{{count}} نموذج", + modelCountPlural: "{{count}} نماذج", retry: "أعد المحاولة", retrying: "جارٍ إعادة المحاولة...", noMatches: "لا توجد نماذج تطابق بحثك", @@ -1677,7 +1677,7 @@ export const ar: TranslationResources = { }, models: { one: "1 نموذج", - many: "نماذج{{count}}", + many: "{{count}} نماذج", addModel: "إضافة نموذج", addCustomTitle: "إضافة نموذج مخصص", modelId: "الموديل ID", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 175a67a30..6f7b09aa2 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -523,7 +523,7 @@ export const es: TranslationResources = { }, }, scripts: { - title: "Guiones", + title: "Scripts", actions: { run: "Correr", view: "Vista", @@ -1115,8 +1115,8 @@ export const es: TranslationResources = { favorites: "Favoritos", favoriteModel: "modelo favorito", unfavoriteModel: "Modelo no favorito", - modelCount: "modelo{{count}}", - modelCountPlural: "Modelos{{count}}", + modelCount: "{{count}} modelo", + modelCountPlural: "{{count}} modelos", retry: "Rever", retrying: "Reintentando...", noMatches: "Ningún modelo coincide con tu búsqueda", @@ -1408,9 +1408,9 @@ export const es: TranslationResources = { }, }, terminalScrollback: { - label: "desplazamiento hacia atrásTerminal", + label: "Historial de terminal", description: "Líneas mantenidas en el búfer de terminal incorporado", - accessibilityLabel: "Líneas de desplazamiento hacia atrásTerminal", + accessibilityLabel: "Líneas del historial de terminal", }, language: { label: "Idioma", @@ -1716,7 +1716,7 @@ export const es: TranslationResources = { }, models: { one: "1 modelo", - many: "Modelos{{count}}", + many: "{{count}} modelos", addModel: "Agregar modelo", addCustomTitle: "Agregar modelo personalizado", modelId: "ModeloID", @@ -1785,7 +1785,7 @@ export const es: TranslationResources = { teardownAccessibility: "Comandos de desmontaje del árbol de trabajo", }, scripts: { - title: "Guiones", + title: "Scripts", info: "Servicios de larga duración y comandos únicos que puede iniciar desde cualquier agente en este proyecto", empty: "Aún no hay guiones.", untitled: "Guión sin título", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 62c01f83f..de23eaae3 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1118,8 +1118,8 @@ export const fr: TranslationResources = { favorites: "Favoris", favoriteModel: "Modèle préféré", unfavoriteModel: "Modèle défavorisé", - modelCount: "Modèle{{count}}", - modelCountPlural: "Modèles{{count}}", + modelCount: "{{count}} modèle", + modelCountPlural: "{{count}} modèles", retry: "Réessayer", retrying: "Nouvelle tentative...", noMatches: "Aucun modèle ne correspond à votre recherche", @@ -1722,7 +1722,7 @@ export const fr: TranslationResources = { }, models: { one: "1 modèle", - many: "Modèles{{count}}", + many: "{{count}} modèles", addModel: "Ajouter un modèle", addCustomTitle: "Ajouter un modèle personnalisé", modelId: "ModèleID", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 525708d18..61487037e 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1107,8 +1107,8 @@ export const ru: TranslationResources = { favorites: "Избранное", favoriteModel: "Любимая модель", unfavoriteModel: "Нелюбимая модель", - modelCount: "Модель{{count}}", - modelCountPlural: "Модели{{count}}", + modelCount: "{{count}} модель", + modelCountPlural: "{{count}} моделей", retry: "Повторить попытку", retrying: "Повторная попытка...", noMatches: "Ни одна модель не соответствует вашему запросу", @@ -1709,7 +1709,7 @@ export const ru: TranslationResources = { }, models: { one: "1 модель", - many: "Модели{{count}}", + many: "{{count}} моделей", addModel: "Добавить модель", addCustomTitle: "Добавить пользовательскую модель", modelId: "Модель ID", diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index a8781a91f..08aa23619 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -94,7 +94,13 @@ import { resolveAppVersion } from "@/utils/app-version"; import { settingsStyles } from "@/styles/settings"; import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm"; import { useVoiceAudioEngineOptional } from "@/contexts/voice-context"; -import { LANGUAGE_OPTIONS, type AppLanguage } from "@/i18n/locales"; +import { + LANGUAGE_OPTIONS, + formatLanguageOptionLabel, + parseAppLanguage, + type AppLanguage, + type SupportedLocale, +} from "@/i18n/locales"; import { HostConnectionsPage, HostAgentsPage, @@ -226,6 +232,11 @@ function getServiceUrlBehaviorLabel(t: TFunction, value: ServiceUrlBehavior): st return labels[value]; } +function getActiveLocale(language: string | undefined): SupportedLocale { + const parsed = parseAppLanguage(language); + return parsed && parsed !== "system" ? parsed : "en"; +} + const SERVICE_URL_BEHAVIOR_VALUES: ServiceUrlBehavior[] = ["ask", "in-app", "external"]; // --------------------------------------------------------------------------- @@ -266,17 +277,20 @@ function ServiceUrlBehaviorMenuItem({ interface LanguageMenuItemProps { value: AppLanguage; + activeLocale: SupportedLocale; selected: boolean; onChange: (value: AppLanguage) => void; } -function LanguageMenuItem({ value, selected, onChange }: LanguageMenuItemProps) { +function LanguageMenuItem({ value, activeLocale, selected, onChange }: LanguageMenuItemProps) { const { t } = useTranslation(); const handleSelect = useCallback(() => { onChange(value); }, [onChange, value]); const option = LANGUAGE_OPTIONS.find((entry) => entry.value === value); - const label = option ? t(option.labelKey) : value; + const label = option + ? formatLanguageOptionLabel(option, activeLocale, t(option.labelKey)) + : value; return ( @@ -294,14 +308,19 @@ function GeneralSection({ handleTerminalScrollbackLinesChange, }: GeneralSectionProps) { const { theme } = useUnistyles(); - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const activeLocale = getActiveLocale(i18n.language); const iconColor = theme.colors.foregroundMuted; const sendBehaviorOptions = useMemo(() => getSendBehaviorOptions(t), [t]); const selectedLanguageOption = LANGUAGE_OPTIONS.find( (option) => option.value === settings.language, ); const selectedLanguageLabel = selectedLanguageOption - ? t(selectedLanguageOption.labelKey) + ? formatLanguageOptionLabel( + selectedLanguageOption, + activeLocale, + t(selectedLanguageOption.labelKey), + ) : settings.language; const [terminalScrollbackValue, setTerminalScrollbackValue] = useState( String(settings.terminalScrollbackLines), @@ -364,6 +383,7 @@ function GeneralSection({