fix(app): clean up i18n settings labels

This commit is contained in:
Mohamed Boudra
2026-06-11 13:48:06 +07:00
parent 69f0fa07b4
commit d3a6bf80dd
9 changed files with 190 additions and 24 deletions
+2 -2
View File
@@ -12,12 +12,12 @@ test("Settings language selector switches General labels", async ({ page }) => {
await expect(page.getByText("Default send", { exact: true }).first()).toBeVisible(); await expect(page.getByText("Default send", { exact: true }).first()).toBeVisible();
await page.getByRole("button", { name: "System", exact: true }).click(); 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 expect(page.getByText("默认发送", { exact: true }).first()).toBeVisible();
await page.getByRole("button", { name: "简体中文", exact: true }).click(); 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(); await expect(page.getByText("Default send", { exact: true }).first()).toBeVisible();
}); });
+49 -1
View File
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { LANGUAGE_OPTIONS, parseAppLanguage, resolveSupportedLocale } from "./locales"; import {
LANGUAGE_OPTIONS,
formatLanguageOptionLabel,
parseAppLanguage,
resolveSupportedLocale,
} from "./locales";
describe("parseAppLanguage", () => { describe("parseAppLanguage", () => {
it("accepts system and all UN official language locales", () => { 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", () => { describe("resolveSupportedLocale", () => {
it("respects explicit language choices", () => { it("respects explicit language choices", () => {
expect(resolveSupportedLocale("ar", ["en-US"])).toBe("ar"); expect(resolveSupportedLocale("ar", ["en-US"])).toBe("ar");
@@ -44,11 +87,16 @@ describe("resolveSupportedLocale", () => {
it("maps UN official system locales", () => { it("maps UN official system locales", () => {
expect(resolveSupportedLocale("system", ["ar-EG"])).toBe("ar"); expect(resolveSupportedLocale("system", ["ar-EG"])).toBe("ar");
expect(resolveSupportedLocale("system", ["en-US"])).toBe("en");
expect(resolveSupportedLocale("system", ["es-MX"])).toBe("es"); expect(resolveSupportedLocale("system", ["es-MX"])).toBe("es");
expect(resolveSupportedLocale("system", ["fr-CA"])).toBe("fr"); expect(resolveSupportedLocale("system", ["fr-CA"])).toBe("fr");
expect(resolveSupportedLocale("system", ["ru-RU"])).toBe("ru"); 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", () => { it("maps Chinese system locales to Simplified Chinese", () => {
expect(resolveSupportedLocale("system", ["zh"])).toBe("zh-CN"); expect(resolveSupportedLocale("system", ["zh"])).toBe("zh-CN");
expect(resolveSupportedLocale("system", ["zh-CN"])).toBe("zh-CN"); expect(resolveSupportedLocale("system", ["zh-CN"])).toBe("zh-CN");
+79
View File
@@ -19,6 +19,64 @@ export const LANGUAGE_OPTIONS: LanguageOption[] = [
]; ];
const SUPPORTED_LANGUAGES = new Set<AppLanguage>(["system", "ar", "en", "es", "fr", "ru", "zh-CN"]); const SUPPORTED_LANGUAGES = new Set<AppLanguage>(["system", "ar", "en", "es", "fr", "ru", "zh-CN"]);
const LANGUAGE_NATIVE_NAMES: Record<SupportedLocale, string> = {
ar: "العربية",
en: "English",
es: "Español",
fr: "Français",
ru: "Русский",
"zh-CN": "简体中文",
};
const LANGUAGE_NAMES_BY_LOCALE: Record<SupportedLocale, Record<SupportedLocale, string>> = {
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 { export function parseAppLanguage(value: unknown): AppLanguage | null {
return typeof value === "string" && SUPPORTED_LANGUAGES.has(value as AppLanguage) return typeof value === "string" && SUPPORTED_LANGUAGES.has(value as AppLanguage)
@@ -26,6 +84,24 @@ export function parseAppLanguage(value: unknown): AppLanguage | null {
: 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( export function resolveSupportedLocale(
language: AppLanguage, language: AppLanguage,
systemLocales: readonly string[], systemLocales: readonly string[],
@@ -39,6 +115,9 @@ export function resolveSupportedLocale(
if (normalized === "ar" || normalized.startsWith("ar-")) { if (normalized === "ar" || normalized.startsWith("ar-")) {
return "ar"; return "ar";
} }
if (normalized === "en" || normalized.startsWith("en-")) {
return "en";
}
if (normalized === "es" || normalized.startsWith("es-")) { if (normalized === "es" || normalized.startsWith("es-")) {
return "es"; return "es";
} }
+19
View File
@@ -127,6 +127,25 @@ describe("translation resources", () => {
expect(findInterpolationMismatches(zhCN)).toEqual([]); 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", () => { it("keeps local connection fallback errors translated", () => {
expect(findUntranslatedConnectionErrors()).toEqual([]); expect(findUntranslatedConnectionErrors()).toEqual([]);
}); });
+3 -3
View File
@@ -1082,8 +1082,8 @@ export const ar: TranslationResources = {
favorites: "المفضلة", favorites: "المفضلة",
favoriteModel: "النموذج المفضل", favoriteModel: "النموذج المفضل",
unfavoriteModel: "نموذج غير مفضل", unfavoriteModel: "نموذج غير مفضل",
modelCount: "موديل{{count}}", modelCount: "{{count}} نموذج",
modelCountPlural: "نماذج{{count}}", modelCountPlural: "{{count}} نماذج",
retry: "أعد المحاولة", retry: "أعد المحاولة",
retrying: "جارٍ إعادة المحاولة...", retrying: "جارٍ إعادة المحاولة...",
noMatches: "لا توجد نماذج تطابق بحثك", noMatches: "لا توجد نماذج تطابق بحثك",
@@ -1677,7 +1677,7 @@ export const ar: TranslationResources = {
}, },
models: { models: {
one: "1 نموذج", one: "1 نموذج",
many: "نماذج{{count}}", many: "{{count}} نماذج",
addModel: "إضافة نموذج", addModel: "إضافة نموذج",
addCustomTitle: "إضافة نموذج مخصص", addCustomTitle: "إضافة نموذج مخصص",
modelId: "الموديل ID", modelId: "الموديل ID",
+7 -7
View File
@@ -523,7 +523,7 @@ export const es: TranslationResources = {
}, },
}, },
scripts: { scripts: {
title: "Guiones", title: "Scripts",
actions: { actions: {
run: "Correr", run: "Correr",
view: "Vista", view: "Vista",
@@ -1115,8 +1115,8 @@ export const es: TranslationResources = {
favorites: "Favoritos", favorites: "Favoritos",
favoriteModel: "modelo favorito", favoriteModel: "modelo favorito",
unfavoriteModel: "Modelo no favorito", unfavoriteModel: "Modelo no favorito",
modelCount: "modelo{{count}}", modelCount: "{{count}} modelo",
modelCountPlural: "Modelos{{count}}", modelCountPlural: "{{count}} modelos",
retry: "Rever", retry: "Rever",
retrying: "Reintentando...", retrying: "Reintentando...",
noMatches: "Ningún modelo coincide con tu búsqueda", noMatches: "Ningún modelo coincide con tu búsqueda",
@@ -1408,9 +1408,9 @@ export const es: TranslationResources = {
}, },
}, },
terminalScrollback: { terminalScrollback: {
label: "desplazamiento hacia atrásTerminal", label: "Historial de terminal",
description: "Líneas mantenidas en el búfer de terminal incorporado", 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: { language: {
label: "Idioma", label: "Idioma",
@@ -1716,7 +1716,7 @@ export const es: TranslationResources = {
}, },
models: { models: {
one: "1 modelo", one: "1 modelo",
many: "Modelos{{count}}", many: "{{count}} modelos",
addModel: "Agregar modelo", addModel: "Agregar modelo",
addCustomTitle: "Agregar modelo personalizado", addCustomTitle: "Agregar modelo personalizado",
modelId: "ModeloID", modelId: "ModeloID",
@@ -1785,7 +1785,7 @@ export const es: TranslationResources = {
teardownAccessibility: "Comandos de desmontaje del árbol de trabajo", teardownAccessibility: "Comandos de desmontaje del árbol de trabajo",
}, },
scripts: { scripts: {
title: "Guiones", title: "Scripts",
info: "Servicios de larga duración y comandos únicos que puede iniciar desde cualquier agente en este proyecto", info: "Servicios de larga duración y comandos únicos que puede iniciar desde cualquier agente en este proyecto",
empty: "Aún no hay guiones.", empty: "Aún no hay guiones.",
untitled: "Guión sin título", untitled: "Guión sin título",
+3 -3
View File
@@ -1118,8 +1118,8 @@ export const fr: TranslationResources = {
favorites: "Favoris", favorites: "Favoris",
favoriteModel: "Modèle préféré", favoriteModel: "Modèle préféré",
unfavoriteModel: "Modèle défavorisé", unfavoriteModel: "Modèle défavorisé",
modelCount: "Modèle{{count}}", modelCount: "{{count}} modèle",
modelCountPlural: "Modèles{{count}}", modelCountPlural: "{{count}} modèles",
retry: "Réessayer", retry: "Réessayer",
retrying: "Nouvelle tentative...", retrying: "Nouvelle tentative...",
noMatches: "Aucun modèle ne correspond à votre recherche", noMatches: "Aucun modèle ne correspond à votre recherche",
@@ -1722,7 +1722,7 @@ export const fr: TranslationResources = {
}, },
models: { models: {
one: "1 modèle", one: "1 modèle",
many: "Modèles{{count}}", many: "{{count}} modèles",
addModel: "Ajouter un modèle", addModel: "Ajouter un modèle",
addCustomTitle: "Ajouter un modèle personnalisé", addCustomTitle: "Ajouter un modèle personnalisé",
modelId: "ModèleID", modelId: "ModèleID",
+3 -3
View File
@@ -1107,8 +1107,8 @@ export const ru: TranslationResources = {
favorites: "Избранное", favorites: "Избранное",
favoriteModel: "Любимая модель", favoriteModel: "Любимая модель",
unfavoriteModel: "Нелюбимая модель", unfavoriteModel: "Нелюбимая модель",
modelCount: "Модель{{count}}", modelCount: "{{count}} модель",
modelCountPlural: "Модели{{count}}", modelCountPlural: "{{count}} моделей",
retry: "Повторить попытку", retry: "Повторить попытку",
retrying: "Повторная попытка...", retrying: "Повторная попытка...",
noMatches: "Ни одна модель не соответствует вашему запросу", noMatches: "Ни одна модель не соответствует вашему запросу",
@@ -1709,7 +1709,7 @@ export const ru: TranslationResources = {
}, },
models: { models: {
one: "1 модель", one: "1 модель",
many: "Модели{{count}}", many: "{{count}} моделей",
addModel: "Добавить модель", addModel: "Добавить модель",
addCustomTitle: "Добавить пользовательскую модель", addCustomTitle: "Добавить пользовательскую модель",
modelId: "Модель ID", modelId: "Модель ID",
+25 -5
View File
@@ -94,7 +94,13 @@ import { resolveAppVersion } from "@/utils/app-version";
import { settingsStyles } from "@/styles/settings"; import { settingsStyles } from "@/styles/settings";
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm"; import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context"; 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 { import {
HostConnectionsPage, HostConnectionsPage,
HostAgentsPage, HostAgentsPage,
@@ -226,6 +232,11 @@ function getServiceUrlBehaviorLabel(t: TFunction, value: ServiceUrlBehavior): st
return labels[value]; 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"]; const SERVICE_URL_BEHAVIOR_VALUES: ServiceUrlBehavior[] = ["ask", "in-app", "external"];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -266,17 +277,20 @@ function ServiceUrlBehaviorMenuItem({
interface LanguageMenuItemProps { interface LanguageMenuItemProps {
value: AppLanguage; value: AppLanguage;
activeLocale: SupportedLocale;
selected: boolean; selected: boolean;
onChange: (value: AppLanguage) => void; onChange: (value: AppLanguage) => void;
} }
function LanguageMenuItem({ value, selected, onChange }: LanguageMenuItemProps) { function LanguageMenuItem({ value, activeLocale, selected, onChange }: LanguageMenuItemProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const handleSelect = useCallback(() => { const handleSelect = useCallback(() => {
onChange(value); onChange(value);
}, [onChange, value]); }, [onChange, value]);
const option = LANGUAGE_OPTIONS.find((entry) => entry.value === 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 ( return (
<DropdownMenuItem selected={selected} onSelect={handleSelect}> <DropdownMenuItem selected={selected} onSelect={handleSelect}>
@@ -294,14 +308,19 @@ function GeneralSection({
handleTerminalScrollbackLinesChange, handleTerminalScrollbackLinesChange,
}: GeneralSectionProps) { }: GeneralSectionProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const activeLocale = getActiveLocale(i18n.language);
const iconColor = theme.colors.foregroundMuted; const iconColor = theme.colors.foregroundMuted;
const sendBehaviorOptions = useMemo(() => getSendBehaviorOptions(t), [t]); const sendBehaviorOptions = useMemo(() => getSendBehaviorOptions(t), [t]);
const selectedLanguageOption = LANGUAGE_OPTIONS.find( const selectedLanguageOption = LANGUAGE_OPTIONS.find(
(option) => option.value === settings.language, (option) => option.value === settings.language,
); );
const selectedLanguageLabel = selectedLanguageOption const selectedLanguageLabel = selectedLanguageOption
? t(selectedLanguageOption.labelKey) ? formatLanguageOptionLabel(
selectedLanguageOption,
activeLocale,
t(selectedLanguageOption.labelKey),
)
: settings.language; : settings.language;
const [terminalScrollbackValue, setTerminalScrollbackValue] = useState( const [terminalScrollbackValue, setTerminalScrollbackValue] = useState(
String(settings.terminalScrollbackLines), String(settings.terminalScrollbackLines),
@@ -364,6 +383,7 @@ function GeneralSection({
<LanguageMenuItem <LanguageMenuItem
key={option.value} key={option.value}
value={option.value} value={option.value}
activeLocale={activeLocale}
selected={settings.language === option.value} selected={settings.language === option.value}
onChange={handleLanguageChange} onChange={handleLanguageChange}
/> />