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

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 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();
});

View File

@@ -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");

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 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 {
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";
}

View File

@@ -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([]);
});

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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 (
<DropdownMenuItem selected={selected} onSelect={handleSelect}>
@@ -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({
<LanguageMenuItem
key={option.value}
value={option.value}
activeLocale={activeLocale}
selected={settings.language === option.value}
onChange={handleLanguageChange}
/>