feat(web-shell): add Russian locale for the Goal card and Goals dialog

The web-shell i18n shipped only en and zh-CN and collapsed every other
browser locale (e.g. ru-RU) to en in normalizeLanguage, so a Russian
user saw the Goal card and the Goals dialog in English around a
Russian objective.

Add a partial ru catalog for the goal.*/goals.* keys with Russian
pluralisation (ход/хода/ходов, токен/токена/токенов,
активная цель/активные цели/активных целей). getTranslator already
falls back per key to EN (messages[key] ?? EN[key] ?? key), and the
partial catalog relies on that for every other key — the behaviour this
locale has been running with as an installer patch.

- WEB_SHELL_LANGUAGES / LANGUAGE_LABELS: add 'ru' / 'Русский [ru]' to
  the settings picker
- normalizeLanguage: accept ru / ru-ru / russian / русский
- languageSettingToWebShellLanguage: accept the same spellings for the
  daemon's general.language setting
- WebShellProps.language / WebShellTranscriptProps.language: accept the
  'ru' and 'ru-ru' raw values
- surfaces with their own WebShellLanguage-keyed copy tables
  (StandaloneAuth, RootErrorFallback, conversation search, desktop
  relay) reuse the EN copy for ru, same partial-catalog policy
- re-measure the transcript entry size budget for the catalog
  (+4,649 to 1,325,015; ceiling raised to 1,328,000)

Part of #13391
This commit is contained in:
Сергей Панченко committed 2026-10-08 16:43:24 +03:00
1 parent f8605526f6
commit c5ff652b59
9 files changed
+326 -69

No files matched your search

+1 -1
View File
@@ -1340,7 +1340,7 @@ export interface WebShellProps {
*/
onThemeResolved?: (theme: WebShellTheme) => void;
/** UI language for the web-shell. Defaults to `?language=` or browser language. */
language?: 'en' | 'zh-CN' | 'zh' | 'zh-cn';
language?: 'en' | 'zh-CN' | 'zh' | 'zh-cn' | 'ru' | 'ru-ru';
/** Called when `/language ui` changes the web-shell UI language. */
onLanguageChange?: (language: WebShellLanguage) => void;
/**
@@ -404,13 +404,14 @@ describe('build artifact — transcript entry (#11031)', () => {
// main grew the same entry in parallel — 1,309,207 at 1aba19c878, plus
// 3,033 for table fullscreen and Shadow DOM keyboard handling, reaching
// 1,312,240. Merging the two measures 1,320,366 in CI at d4304811. The
// ceiling leaves a small margin around that intentional UI;
// ru goal/goals catalog (#13391) adds 4,649 to 1,325,015. The ceiling
// leaves a small margin around that intentional UI;
// re-measure and lower it if the entry gets leaner.
const js = readTranscriptBundle().replace(
/^const __qwenWebShellCss=[^\n]*\n/,
'',
);
expect(js.length).toBeLessThan(1_323_000);
expect(js.length).toBeLessThan(1_328_000);
});
it('carries no Live Voice strings and looks none up', () => {
@@ -141,7 +141,12 @@ type CopyKey = keyof (typeof COPY)['en'];
function useDesktopRelayCopy(): (key: CopyKey) => string {
const { language } = useI18n();
return useCallback((key: CopyKey) => COPY[language][key], [language]);
// No ru copy for the desktop relay yet — same partial-catalog policy as the
// ru table in i18n.tsx (#13391): ru reuses the EN copy.
return useCallback(
(key: CopyKey) => COPY[language === 'ru' ? 'en' : language][key],
[language],
);
}
export interface DesktopRelayStatus {
@@ -24,19 +24,24 @@ interface FallbackCopy {
// This surface renders OUTSIDE the in-app I18nProvider (the boundary wraps the
// whole App, which owns that provider), so it cannot call useI18n. It carries
// its own minimal copy instead of pulling the full translation table.
const EN_COPY: FallbackCopy = {
title: 'Something went wrong',
body: 'An unexpected error occurred and this content could not be displayed.',
retry: 'Try again',
reload: 'Reload page',
};
const COPY: Record<WebShellLanguage, FallbackCopy> = {
en: {
title: 'Something went wrong',
body: 'An unexpected error occurred and this content could not be displayed.',
retry: 'Try again',
reload: 'Reload page',
},
en: EN_COPY,
'zh-CN': {
title: '出了点问题',
body: '发生意外错误,无法显示此内容。',
retry: '重试',
reload: '重新加载',
},
// Not translated yet — same partial-catalog policy as the ru table in
// i18n.tsx (#13391): ru reuses the EN copy.
ru: EN_COPY,
};
// The boundary wraps the whole App, so this surface renders when the themed
@@ -75,41 +75,43 @@ interface AuthCopy {
// This gate renders before the app (and therefore before its I18nProvider), so
// it carries its own copy table instead of calling useI18n.
const EN_COPY: AuthCopy = {
pairingFailed:
'Pairing failed or the QR code expired. Scan a fresh QR code, or enter the daemon token.',
heading: 'Connect to Qwen Code',
connecting: 'Connecting…',
starting: 'Daemon is starting…',
unreachable: 'Cannot reach the daemon. Retrying…',
remoteUnreachable:
'Cannot reach the daemon. Retrying… Check the address, network, HTTPS certificate, and --allow-origin for this page origin.',
notReady: 'Daemon is not ready. Retrying…',
startFailed: 'Daemon failed to start.',
invalidToken:
'Invalid or expired token. Enter the token from the daemon terminal.',
enterToken: 'Enter the bearer token from the daemon terminal.',
policyBlocked:
'Access blocked by the daemon Origin or Host policy. Open its direct address, or check --allow-origin for cross-origin access.',
invalidAddress: 'Invalid daemon address. Enter an HTTP or HTTPS origin.',
addressChanged:
'Connection paused. Select Connect to use the entered address.',
confirmTarget:
'This page points to a daemon this browser has not connected to before. Connect only if you trust it.',
remoteHint:
'The token is sent to the address shown above. Enter only a token issued by that daemon.',
switchUnavailable:
'Browser storage is unavailable, so the token could not be carried to that daemon.',
addressLabel: 'Daemon address',
tokenLabel: 'Bearer token (optional)',
connect: 'Connect',
local: 'Return to local workspaces',
remoteAddCancel: 'Cancel adding workspace',
connectionAddCancel: 'Cancel adding connection',
retry: 'Retry',
hint: 'This token grants full access to the daemon. Only enter it on a page you opened from the daemon terminal or its QR code.',
};
const COPY: Record<WebShellLanguage, AuthCopy> = {
en: {
pairingFailed:
'Pairing failed or the QR code expired. Scan a fresh QR code, or enter the daemon token.',
heading: 'Connect to Qwen Code',
connecting: 'Connecting…',
starting: 'Daemon is starting…',
unreachable: 'Cannot reach the daemon. Retrying…',
remoteUnreachable:
'Cannot reach the daemon. Retrying… Check the address, network, HTTPS certificate, and --allow-origin for this page origin.',
notReady: 'Daemon is not ready. Retrying…',
startFailed: 'Daemon failed to start.',
invalidToken:
'Invalid or expired token. Enter the token from the daemon terminal.',
enterToken: 'Enter the bearer token from the daemon terminal.',
policyBlocked:
'Access blocked by the daemon Origin or Host policy. Open its direct address, or check --allow-origin for cross-origin access.',
invalidAddress: 'Invalid daemon address. Enter an HTTP or HTTPS origin.',
addressChanged:
'Connection paused. Select Connect to use the entered address.',
confirmTarget:
'This page points to a daemon this browser has not connected to before. Connect only if you trust it.',
remoteHint:
'The token is sent to the address shown above. Enter only a token issued by that daemon.',
switchUnavailable:
'Browser storage is unavailable, so the token could not be carried to that daemon.',
addressLabel: 'Daemon address',
tokenLabel: 'Bearer token (optional)',
connect: 'Connect',
local: 'Return to local workspaces',
remoteAddCancel: 'Cancel adding workspace',
connectionAddCancel: 'Cancel adding connection',
retry: 'Retry',
hint: 'This token grants full access to the daemon. Only enter it on a page you opened from the daemon terminal or its QR code.',
},
en: EN_COPY,
'zh-CN': {
pairingFailed:
'配对失败或二维码已过期。请扫描新的二维码,或输入 daemon 令牌。',
@@ -140,6 +142,9 @@ const COPY: Record<WebShellLanguage, AuthCopy> = {
retry: '重试',
hint: '该令牌拥有守护进程的完整访问权限。请仅在从守护进程终端或其二维码打开的页面中输入。',
},
// Not translated yet — same partial-catalog policy as the ru table in
// i18n.tsx (#13391): ru reuses the EN copy.
ru: EN_COPY,
};
/** `Retry-After` in milliseconds (delta-seconds or HTTP-date), or undefined. */
@@ -61,7 +61,7 @@ export interface WebShellTranscriptProps {
blocks: readonly DaemonTranscriptBlock[];
renderMode?: 'readonly' | 'document';
theme?: WebShellTheme;
language?: 'en' | 'zh-CN' | 'zh' | 'zh-cn';
language?: 'en' | 'zh-CN' | 'zh' | 'zh-cn' | 'ru' | 'ru-ru';
className?: string;
style?: CSSProperties;
chatMaxWidth?: number;
@@ -10,29 +10,31 @@ import { useI18n, type WebShellLanguage } from '../i18n';
type Message = string | ((vars?: Record<string, string | number>) => string);
// 搜索文案只随交互组件加载,避免进入只读 HTML 导出的共享翻译包。
const EN_MESSAGES: Record<string, Message> = {
'chat.searchConversation': 'Search this conversation',
'chat.searchConversationPlaceholder': 'Search messages and code…',
'chat.searchConversationHint': 'Enter a keyword to find earlier content.',
'chat.searchingConversation': 'Searching conversation history…',
'chat.searchLoadedOnly':
'Only loaded messages are available. Update Qwen Code to search all history.',
'chat.searchResultPosition': (vars) =>
`${vars?.['current']} / ${vars?.['total']} results`,
'chat.searchNoResults': 'No matching messages.',
'chat.searchPrevious': 'Previous result',
'chat.searchNext': 'Next result',
'chat.searchResults': 'Search results',
'chat.searchResultsLimited': (vars) =>
`Showing the first ${vars?.['count']} results. Refine your search for more specific matches.`,
'chat.searchFailed':
'Could not search all history. Results may be incomplete.',
'chat.searchLocateFailed':
'This result could not be located. Search again to refresh it.',
'chat.searchUser': 'You',
'chat.searchAssistant': 'Assistant',
};
const messages: Record<WebShellLanguage, Record<string, Message>> = {
en: {
'chat.searchConversation': 'Search this conversation',
'chat.searchConversationPlaceholder': 'Search messages and code…',
'chat.searchConversationHint': 'Enter a keyword to find earlier content.',
'chat.searchingConversation': 'Searching conversation history…',
'chat.searchLoadedOnly':
'Only loaded messages are available. Update Qwen Code to search all history.',
'chat.searchResultPosition': (vars) =>
`${vars?.['current']} / ${vars?.['total']} results`,
'chat.searchNoResults': 'No matching messages.',
'chat.searchPrevious': 'Previous result',
'chat.searchNext': 'Next result',
'chat.searchResults': 'Search results',
'chat.searchResultsLimited': (vars) =>
`Showing the first ${vars?.['count']} results. Refine your search for more specific matches.`,
'chat.searchFailed':
'Could not search all history. Results may be incomplete.',
'chat.searchLocateFailed':
'This result could not be located. Search again to refresh it.',
'chat.searchUser': 'You',
'chat.searchAssistant': 'Assistant',
},
en: EN_MESSAGES,
'zh-CN': {
'chat.searchConversation': '搜索当前会话',
'chat.searchConversationPlaceholder': '搜索消息和代码…',
@@ -53,6 +55,9 @@ const messages: Record<WebShellLanguage, Record<string, Message>> = {
'chat.searchUser': '你',
'chat.searchAssistant': '助手',
},
// Not translated yet — same partial-catalog policy as the ru table in
// i18n.tsx (#13391): ru reuses the EN copy.
ru: EN_MESSAGES,
};
export function useConversationSearchI18n() {
+107 -1
View File
@@ -1,5 +1,11 @@
import { describe, expect, it } from 'vitest';
import { getTranslator } from './i18n';
import {
WEB_SHELL_LANGUAGES,
getTranslator,
languageLabel,
languageSettingToWebShellLanguage,
normalizeLanguage,
} from './i18n';
// getTranslator returns the raw key when EN has no entry. SettingsMessage
// translateSettingText then substitutes the settingsSchema.ts description,
@@ -52,3 +58,103 @@ describe('web-shell i18n catalog', () => {
}
});
});
// #13391: ru ships a PARTIAL catalog covering the Goal card and the Goals
// dialog only; every other key must fall back to EN in getTranslator. These
// pins guard the normalizer branches, the pluralisation and that fallback.
describe('web-shell ru locale', () => {
it('normalizes ru spellings to ru and leaves the en/zh mappings untouched', () => {
for (const value of [
'ru',
'ru-ru',
'russian',
'русский',
'RU',
' RU-RU ',
]) {
expect(normalizeLanguage(value)).toBe('ru');
}
expect(normalizeLanguage('en')).toBe('en');
expect(normalizeLanguage('EN-US')).toBe('en');
expect(normalizeLanguage('zh')).toBe('zh-CN');
expect(normalizeLanguage('zh-cn')).toBe('zh-CN');
expect(normalizeLanguage('zh_cn')).toBe('zh-CN');
// Unrelated locales still collapse to en (fr, ja, … are not shipped).
expect(normalizeLanguage('ru-anything-else')).toBe('en');
expect(normalizeLanguage('fr-FR')).toBe('en');
expect(normalizeLanguage(undefined)).toBe('en');
});
it('maps daemon language settings to ru, underscore form included', () => {
for (const value of ['ru', 'ru-ru', 'ru_ru', 'russian', 'русский', 'RU']) {
expect(languageSettingToWebShellLanguage(value)).toBe('ru');
}
expect(languageSettingToWebShellLanguage('zh')).toBe('zh-CN');
expect(languageSettingToWebShellLanguage('en')).toBe('en');
expect(languageSettingToWebShellLanguage('fr')).toBeUndefined();
});
it('lists ru in the picker with the "Русский [ru]" label', () => {
expect(WEB_SHELL_LANGUAGES).toEqual(['en', 'zh-CN', 'ru']);
expect(languageLabel('ru')).toBe('Русский [ru]');
});
it('pluralises turn counters the Russian way', () => {
const t = getTranslator('ru');
expect(t('goal.turns', { count: 1 })).toBe('1 ход');
expect(t('goal.turns', { count: 2 })).toBe('2 хода');
expect(t('goal.turns', { count: 5 })).toBe('5 ходов');
expect(t('goal.turns', { count: 11 })).toBe('11 ходов');
expect(t('goal.turns', { count: 21 })).toBe('21 ход');
// The budget side pluralises on the budget, not the used count.
expect(t('goal.turnsOfBudget', { count: 1, budget: 1 })).toBe('1 / 1 ход');
expect(t('goal.turnsOfBudget', { count: 1, budget: 2 })).toBe('1 / 2 хода');
expect(t('goal.turnsOfBudget', { count: 3, budget: 11 })).toBe(
'3 / 11 ходов',
);
});
it('pluralises token spend the Russian way', () => {
const t = getTranslator('ru');
expect(t('goal.tokens', { used: 1 })).toBe('1 токен');
expect(t('goal.tokens', { used: 3 })).toBe('3 токена');
expect(t('goal.tokens', { used: 5 })).toBe('5 токенов');
expect(t('goal.tokens', { used: 11 })).toBe('11 токенов');
expect(t('goal.tokens', { used: 21 })).toBe('21 токен');
});
it('pluralises the goals counter the Russian way', () => {
const t = getTranslator('ru');
expect(t('goals.count', { count: 1 })).toBe('1 активная цель');
expect(t('goals.count', { count: 2 })).toBe('2 активные цели');
expect(t('goals.count', { count: 5 })).toBe('5 активных целей');
expect(t('goals.count', { count: 11 })).toBe('11 активных целей');
expect(t('goals.count', { count: 21 })).toBe('21 активная цель');
});
it('renders the Goal card from the ru catalog, not from EN', () => {
const t = getTranslator('ru');
const en = getTranslator('en');
expect(t('goal.blocked')).toBe('Цель заблокирована');
expect(t('goal.lastCheck')).toBe('Последняя проверка');
expect(t('goal.status.blocked')).toBe('Заблокирована');
expect(t('goals.title')).toBe('Цели');
expect(t('goals.empty')).toBe(
'Нет активных целей. Задайте: /goal <условие>.',
);
expect(t('goal.blocked')).not.toBe(en('goal.blocked'));
});
it('falls back to EN for keys the partial ru catalog lacks', () => {
const t = getTranslator('ru');
const en = getTranslator('en');
// Plain and parameterised EN values both resolve, not the raw key.
expect(t('welcome.prompt')).toBe(en('welcome.prompt'));
expect(t('welcome.prompt')).not.toBe('welcome.prompt');
expect(t('capacityChoice.capacity', { used: 1, limit: 2 })).toBe(
en('capacityChoice.capacity', { used: 1, limit: 2 }),
);
// Unknown in every catalog: the key itself, as in all locales.
expect(t('no.such.key')).toBe('no.such.key');
});
});
+131 -1
View File
@@ -15,7 +15,7 @@ import {
SETTINGS_MESSAGES_ZH,
} from './settings/messages.js';
export const WEB_SHELL_LANGUAGES = ['en', 'zh-CN'] as const;
export const WEB_SHELL_LANGUAGES = ['en', 'zh-CN', 'ru'] as const;
export type WebShellLanguage = (typeof WEB_SHELL_LANGUAGES)[number];
@@ -32,6 +32,26 @@ function plural(count: string | number | undefined, noun: string): string {
return `${safe} ${noun}${safe === 1 ? '' : 's'}`;
}
/**
* Russian plural pick: 1 → `one`, 2–4 → `few`, otherwise → `many`, with the
* 11–14 exception per decade (ход/ходов, токен/токенов, цель/целей).
*/
function ruPlural(
count: string | number | undefined,
one: string,
few: string,
many: string,
): string {
const value = typeof count === 'number' ? count : Number(count ?? 0);
const safe = Number.isFinite(value) ? value : 0;
const last = safe % 10;
const lastTwo = safe % 100;
if (lastTwo >= 11 && lastTwo <= 14) return many;
if (last === 1) return one;
if (last >= 2 && last <= 4) return few;
return many;
}
const EN: Messages = {
'capacityChoice.persistenceUnconfirmed':
'Saving the last interrupted turn could not be confirmed.',
@@ -8299,14 +8319,108 @@ const ZH: Messages = {
...SETTINGS_MESSAGES_ZH,
};
/**
* Russian catalog — intentionally partial: it covers the Goal card and the
* Goals dialog (#13391). `getTranslator` falls back to EN for every other
* key (`messages[key] ?? EN[key] ?? key`), which is the behaviour this locale
* has been running with since it was first introduced as an installer patch.
*/
const RU: Messages = {
'goal.aborted': 'Цель прервана',
'goal.blocked': 'Цель заблокирована',
'goal.usageLimited': 'Лимит использования цели',
'goal.paused': 'Цель на паузе',
'goal.achieved': 'Цель достигнута',
'goal.check': 'Проверка цели',
'goal.cleared': 'Цель снята',
'goal.failed': 'Цель не достигнута',
'goal.judge': 'Оценка',
'goal.label': 'Цель',
'goal.lastCheck': 'Последняя проверка',
'goal.notYetMet': 'условие не выполнено',
'goal.set': 'Цель задана',
'goal.statusActive': '/goal активен',
'goal.status.active': 'В работе',
'goal.status.paused': 'Пауза',
'goal.status.blocked': 'Заблокирована',
'goal.status.usage_limited': 'Лимит исчерпан',
'goal.status.complete': 'Выполнена',
'goal.activity.idle': 'Ожидание',
'goal.activity.running': 'Работает',
'goal.activity.verifying': 'Проверка',
'goal.edit': 'Изменить цель',
'goal.pause': 'Поставить на паузу',
'goal.resume': 'Продолжить',
'goal.turn': (v) =>
`${v?.count ?? 0} ${ruPlural(v?.count, 'ход', 'хода', 'ходов')}`,
'goal.turnLabel': (v) => `ход ${v?.count ?? 0}`,
'goal.turns': (v) =>
`${v?.count ?? 0} ${ruPlural(v?.count, 'ход', 'хода', 'ходов')}`,
'goal.turnsOfBudget': (v) =>
`${v?.count ?? 0} / ${v?.budget ?? 0} ${ruPlural(v?.budget, 'ход', 'хода', 'ходов')}`,
'goal.activeOfBudget': (v) => `${v?.used ?? ''} / ${v?.budget ?? ''}`,
'goal.tokens': (v) =>
`${v?.used ?? 0} ${ruPlural(v?.used, 'токен', 'токена', 'токенов')}`,
'goal.tokensOfBudget': (v) =>
`${v?.used ?? 0} / ${v?.budget ?? 0} ${ruPlural(v?.budget, 'токен', 'токена', 'токенов')}`,
'goals.title': 'Цели',
'goals.subtitle':
'Цель удерживает сессию в работе, пока условие не выполнено. Здесь видны только загруженные сейчас сессии — цель двигается, пока её сессия работает.',
'goals.loading': 'Загрузка целей…',
'goals.count': (v) =>
`${v?.count ?? 0} ${ruPlural(v?.count, 'активная цель', 'активные цели', 'активных целей')}`,
'goals.empty': 'Нет активных целей. Задайте: /goal <условие>.',
'goals.refresh': 'Обновить',
'goals.new': 'Новая цель',
'goals.newHint':
'Цель запускается в новой сессии и работает, пока условие не выполняется. Жёсткий лимит по числу ходов или времени задаётся в чате: /config model.goalMaxTurns=20 или /config model.goalMaxActiveMinutes=30 (применяется после перезапуска демона, к целям, созданным позже).',
'goals.condition': 'Условие',
'goals.conditionPlaceholder':
'напр. `npm test` завершается с кодом 0 и `npm run lint` без предупреждений (вставить вывод); не менять тестовые файлы; как указание модели — остановиться как blocked после 20 ходов',
'goals.cancel': 'Отмена',
'goals.create': 'Задать цель',
'goals.creating': 'Запуск…',
'goals.saving': 'Сохранение…',
'goals.save': 'Сохранить',
'goals.edit': 'Изменить цель',
'goals.objective': 'Целевое условие',
'goals.clear': 'Снять цель',
'goals.clearConfirm': (v) => `Снять цель "${v?.condition ?? ''}"?`,
'goals.running': 'Работает',
'goals.idle': 'Ожидание',
'goals.dropped': (v) =>
`не удалось достичь ${ruPlural(v?.count, 'сеанс', 'сеанса', 'сеансов')} — цели, которые они выполняли, отсутствуют в этом списке.`,
'goals.notYetEvaluated': 'ещё не оценено',
'goals.openSessionHint': 'Открыть сессию этой цели',
'goals.error.emptyCondition': 'Введите условие для цели.',
'goals.error.clearKeyword': (v) =>
`"${v?.word ?? ''}" снимает цель, а не задаёт её. Опишите условие, к которому нужно работать.`,
'goals.error.createFailed': 'Не удалось запустить цель',
'goals.error.saveFailed': 'Не удалось сохранить цель',
'goals.error.goalUnavailable': 'Цель больше недоступна.',
'goals.error.requiresObjective': (v) =>
`/goal ${v?.keyword ?? 'set'} требует целевого условия.`,
'goals.error.invalidCommand': 'Некорректная команда /goal',
'goals.error.goalsUnavailable': 'Панель целей недоступна в этом интерфейсе.',
'goals.error.attachmentsUnsupported': 'Уберите вложения перед /goal.',
'goals.error.editFailed': 'Не удалось изменить цель',
'goals.error.pauseFailed': 'Не удалось поставить цель на паузу',
'goals.error.resumeFailed': 'Не удалось продолжить цель',
'goals.error.clearFailed': 'Не удалось снять цель',
'goals.error.controlBusy':
'Другое управление целью ещё выполняется. Повторите, когда оно завершится.',
};
const MESSAGES: Record<WebShellLanguage, Messages> = {
en: EN,
'zh-CN': ZH,
ru: RU,
};
const LANGUAGE_LABELS: Record<WebShellLanguage, string> = {
en: 'English [en]',
'zh-CN': '中文 [zh-CN]',
ru: 'Русский [ru]',
};
const Context = createContext<{
@@ -8325,6 +8439,14 @@ export function normalizeLanguage(
if (normalized === 'zh' || normalized === 'zh-cn' || normalized === 'zh_cn') {
return 'zh-CN';
}
if (
normalized === 'ru' ||
normalized === 'ru-ru' ||
normalized === 'russian' ||
normalized === 'русский'
) {
return 'ru';
}
return 'en';
}
@@ -8347,6 +8469,14 @@ export function languageSettingToWebShellLanguage(
) {
return 'zh-CN';
}
if (
normalized === 'ru' ||
normalized === 'ru-ru' ||
normalized === 'russian' ||
normalized === 'русский'
) {
return 'ru';
}
if (
normalized === 'en' ||
normalized === 'en-us' ||