From efc9004fda5c8fd30f876efdebd8f47aaa1b64ff Mon Sep 17 00:00:00 2001 From: Matvey Rudoy <75200069+mrudybtw@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:13:53 +0300 Subject: [PATCH] feat(web): add Russian locale and detect initial language (#1958) * feat(web): add Russian locale - Add web/src/lib/locales/ru.ts - Register 'ru' in the locale registry, i18n context, language switcher and settings - Format outline timestamps with the day.month convention for 'ru' * feat(web): detect initial locale from Telegram and browser The Telegram Mini App runs in its own web view, so a language chosen in the browser does not carry over and the app always opened in English. Pick the first session locale from an explicit stored choice, then the Telegram user's language_code, then navigator.language, then English. * feat(web): translate the newly localized strings Russian values for the keys added by the hardcoded-string fix (see the separate web i18n PR). Keeping them on this branch so the locale PR is complete when both land. * feat(web): add Russian for the newly localized strings * fix(web): recheck the Telegram language after a slow SDK load * fix(web): keep a manual language choice over late Telegram detection --------- Co-authored-by: mrudy --- web/src/chat/presentation.ts | 7 +- web/src/components/LanguageSwitcher.tsx | 1 + web/src/components/settings/SettingsNav.tsx | 2 +- web/src/hooks/useTelegram.ts | 1 + web/src/lib/i18n-context.test.ts | 140 ++ web/src/lib/i18n-context.tsx | 109 +- web/src/lib/locales/index.ts | 1 + web/src/lib/locales/ru.ts | 1284 +++++++++++++++++++ web/src/routes/settings/general.tsx | 1 + 9 files changed, 1537 insertions(+), 9 deletions(-) create mode 100644 web/src/lib/i18n-context.test.ts create mode 100644 web/src/lib/locales/ru.ts diff --git a/web/src/chat/presentation.ts b/web/src/chat/presentation.ts index 5877bbcf..09b4bf07 100644 --- a/web/src/chat/presentation.ts +++ b/web/src/chat/presentation.ts @@ -45,7 +45,7 @@ export function formatMessageTimestamp(date: Date, now: Date = new Date()): stri export function formatOutlineTimestamp( date: Date, - locale: 'en' | 'zh-CN', + locale: 'en' | 'zh-CN' | 'ru', now: Date = new Date() ): string { const sameDay = date.getFullYear() === now.getFullYear() @@ -67,6 +67,11 @@ export function formatOutlineTimestamp( return `${dateLabel} ${time}` } + if (locale === 'ru') { + const dateLabel = sameYear ? `${day}.${month}` : `${day}.${month}.${year}` + return `${dateLabel} ${time}` + } + const dateLabel = sameYear ? `${month}/${day}` : `${year}/${month}/${day}` return `${dateLabel} ${time}` } diff --git a/web/src/components/LanguageSwitcher.tsx b/web/src/components/LanguageSwitcher.tsx index 45fbbf8e..fa8243c4 100644 --- a/web/src/components/LanguageSwitcher.tsx +++ b/web/src/components/LanguageSwitcher.tsx @@ -4,6 +4,7 @@ import { useTranslation, type Locale } from '@/lib/use-translation' const locales: { value: Locale; label: string }[] = [ { value: 'en', label: 'English' }, { value: 'zh-CN', label: '简体中文' }, + { value: 'ru', label: 'Русский' }, ] function LanguageIcon() { diff --git a/web/src/components/settings/SettingsNav.tsx b/web/src/components/settings/SettingsNav.tsx index 5e4fbea5..d8038d2b 100644 --- a/web/src/components/settings/SettingsNav.tsx +++ b/web/src/components/settings/SettingsNav.tsx @@ -30,7 +30,7 @@ export function SettingsNav(props: { activeId?: string; mobile?: boolean }) { const { composerEnterBehavior } = useComposerEnterBehavior() const summaries: Record = { - general: locale === 'zh-CN' ? '简体中文' : 'English', + general: locale === 'zh-CN' ? '简体中文' : locale === 'ru' ? 'Русский' : 'English', display: `${t(`settings.display.appearance.${appearance}`)} · ${Math.round(fontScale * 100)}%`, chat: t(`settings.chat.enterBehavior.${composerEnterBehavior}`), voice: t('settings.hub.voice.summary'), diff --git a/web/src/hooks/useTelegram.ts b/web/src/hooks/useTelegram.ts index 4d52d088..60c3967e 100644 --- a/web/src/hooks/useTelegram.ts +++ b/web/src/hooks/useTelegram.ts @@ -40,6 +40,7 @@ export type TelegramWebAppUser = { username?: string first_name: string last_name?: string + language_code?: string } export type TelegramWebAppInitDataUnsafe = { diff --git a/web/src/lib/i18n-context.test.ts b/web/src/lib/i18n-context.test.ts new file mode 100644 index 00000000..753b5d2a --- /dev/null +++ b/web/src/lib/i18n-context.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + applyLateTelegramLocale, + detectInitialLocale, + normalizeLocaleTag, + resolveLateTelegramLocale +} from './i18n-context' + +function setTelegramLanguage(code: string | undefined) { + if (code === undefined) { + delete (window as unknown as { Telegram?: unknown }).Telegram + return + } + ;(window as unknown as { Telegram?: unknown }).Telegram = { + WebApp: { initDataUnsafe: { user: { language_code: code } } } + } +} + +beforeEach(() => { + localStorage.clear() + setTelegramLanguage(undefined) +}) + +afterEach(() => { + localStorage.clear() + setTelegramLanguage(undefined) + vi.restoreAllMocks() +}) + +describe('normalizeLocaleTag', () => { + it('maps Russian tags', () => { + expect(normalizeLocaleTag('ru')).toBe('ru') + expect(normalizeLocaleTag('ru-RU')).toBe('ru') + expect(normalizeLocaleTag('RU')).toBe('ru') + expect(normalizeLocaleTag('ru_RU')).toBe('ru') + }) + + it('maps Chinese tags to the simplified locale we ship', () => { + expect(normalizeLocaleTag('zh')).toBe('zh-CN') + expect(normalizeLocaleTag('zh-Hans')).toBe('zh-CN') + expect(normalizeLocaleTag('zh-CN')).toBe('zh-CN') + expect(normalizeLocaleTag('zh_CN')).toBe('zh-CN') + }) + + it('maps English tags', () => { + expect(normalizeLocaleTag('en')).toBe('en') + expect(normalizeLocaleTag('en-US')).toBe('en') + }) + + it('rejects unsupported or empty tags', () => { + expect(normalizeLocaleTag('de')).toBeNull() + expect(normalizeLocaleTag('')).toBeNull() + expect(normalizeLocaleTag(null)).toBeNull() + expect(normalizeLocaleTag(undefined)).toBeNull() + }) +}) + +describe('detectInitialLocale', () => { + it('prefers an explicit stored choice over Telegram', () => { + localStorage.setItem('hapi-lang', 'en') + setTelegramLanguage('ru') + + expect(detectInitialLocale()).toBe('en') + }) + + it('uses the Telegram Mini App language when nothing is stored', () => { + setTelegramLanguage('ru') + + expect(detectInitialLocale()).toBe('ru') + }) + + it('normalizes the Telegram language tag', () => { + setTelegramLanguage('zh-hans') + + expect(detectInitialLocale()).toBe('zh-CN') + }) + + it('falls back to the browser language', () => { + vi.spyOn(navigator, 'language', 'get').mockReturnValue('ru-RU') + + expect(detectInitialLocale()).toBe('ru') + }) + + it('falls back to English for unsupported languages', () => { + setTelegramLanguage('de') + vi.spyOn(navigator, 'language', 'get').mockReturnValue('de-DE') + + expect(detectInitialLocale()).toBe('en') + }) +}) + +describe('resolveLateTelegramLocale', () => { + it('picks up the language when the SDK appears after the timeout', async () => { + vi.useFakeTimers() + setTelegramLanguage(undefined) + + const pending = resolveLateTelegramLocale(500, 5) + await vi.advanceTimersByTimeAsync(1500) + setTelegramLanguage('ru') + await vi.advanceTimersByTimeAsync(500) + + await expect(pending).resolves.toBe('ru') + vi.useRealTimers() + }) + + it('resolves null when the SDK never loads', async () => { + vi.useFakeTimers() + setTelegramLanguage(undefined) + + const pending = resolveLateTelegramLocale(500, 2) + await vi.advanceTimersByTimeAsync(1500) + + await expect(pending).resolves.toBeNull() + vi.useRealTimers() + }) +}) + +describe('applyLateTelegramLocale', () => { + it('applies the detected locale when nothing is stored', () => { + const apply = vi.fn() + + expect(applyLateTelegramLocale('ru', apply)).toBe(true) + expect(apply).toHaveBeenCalledWith('ru') + }) + + it('does not override a manual choice made while the SDK loaded', () => { + localStorage.setItem('hapi-lang', 'en') + const apply = vi.fn() + + expect(applyLateTelegramLocale('ru', apply)).toBe(false) + expect(apply).not.toHaveBeenCalled() + }) + + it('ignores a null detection', () => { + const apply = vi.fn() + + expect(applyLateTelegramLocale(null, apply)).toBe(false) + expect(apply).not.toHaveBeenCalled() + }) +}) diff --git a/web/src/lib/i18n-context.tsx b/web/src/lib/i18n-context.tsx index 99979c56..1f80c7c7 100644 --- a/web/src/lib/i18n-context.tsx +++ b/web/src/lib/i18n-context.tsx @@ -1,7 +1,8 @@ import { createContext, useEffect, useState, useCallback, type ReactNode } from 'react' -import { en, zhCN } from './locales' +import { getTelegramWebApp, isTelegramEnvironment } from '@/hooks/useTelegram' +import { en, zhCN, ru } from './locales' -export type Locale = 'en' | 'zh-CN' +export type Locale = 'en' | 'zh-CN' | 'ru' export type Translations = Record @@ -13,7 +14,54 @@ export type I18nContextValue = { export const I18nContext = createContext(null) -const locales: Record = { en, 'zh-CN': zhCN } +const locales: Record = { en, 'zh-CN': zhCN, ru } + +/** + * Map a BCP-47-ish language tag (`ru`, `ru-RU`, `zh-Hans`, `en-US`) onto a + * supported locale. Returns null for anything we do not ship. + */ +export function normalizeLocaleTag(tag: string | null | undefined): Locale | null { + if (!tag) return null + const lower = tag.trim().toLowerCase().replace(/_/g, '-') + if (!lower) return null + if (lower === 'ru' || lower.startsWith('ru-')) return 'ru' + if (lower === 'zh' || lower.startsWith('zh-')) return 'zh-CN' + if (lower === 'en' || lower.startsWith('en-')) return 'en' + return null +} + +function readStoredLocale(): Locale | null { + try { + return normalizeLocaleTag(localStorage.getItem('hapi-lang')) + } catch { + return null + } +} + +/** + * Pick the locale for a fresh session: an explicit user choice wins, then the + * Telegram Mini App user's language, then the browser language, then English. + * Telegram runs the app in its own web view, so `localStorage` from a normal + * browser visit is not available and the language must be detected here. + */ +export function detectInitialLocale(): Locale { + const stored = readStoredLocale() + if (stored) return stored + + if (typeof window !== 'undefined') { + const telegramLocale = normalizeLocaleTag( + getTelegramWebApp()?.initDataUnsafe?.user?.language_code + ) + if (telegramLocale) return telegramLocale + } + + if (typeof navigator !== 'undefined') { + const browserLocale = normalizeLocaleTag(navigator.language) + if (browserLocale) return browserLocale + } + + return 'en' +} function interpolate(str: string, params?: Record): string { if (!params) return str @@ -23,11 +71,40 @@ function interpolate(str: string, params?: Record): str }) } +/** + * Telegram loads its SDK asynchronously and `loadTelegramSdk` gives up after a + * timeout, so the user's `language_code` can appear after the first render. + * Poll briefly for the WebApp and resolve the language once it shows up. + */ +export async function resolveLateTelegramLocale( + intervalMs = 500, + maxAttempts = 20 +): Promise { + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const telegramLocale = normalizeLocaleTag( + getTelegramWebApp()?.initDataUnsafe?.user?.language_code + ) + if (telegramLocale) return telegramLocale + await new Promise((resolve) => setTimeout(resolve, intervalMs)) + } + return null +} + +/** + * Apply a late-detected Telegram locale unless the user has stored an explicit + * choice in the meantime. Returns true when the locale was applied. + */ +export function applyLateTelegramLocale( + detected: Locale | null, + apply: (locale: Locale) => void +): boolean { + if (!detected || readStoredLocale()) return false + apply(detected) + return true +} + export function I18nProvider({ children }: { children: ReactNode }) { - const [locale, setLocaleState] = useState(() => { - const saved = localStorage.getItem('hapi-lang') - return (saved === 'en' || saved === 'zh-CN') ? saved : 'en' - }) + const [locale, setLocaleState] = useState(() => detectInitialLocale()) const setLocale = useCallback((newLocale: Locale) => { setLocaleState(newLocale) @@ -46,6 +123,24 @@ export function I18nProvider({ children }: { children: ReactNode }) { document.documentElement.lang = locale }, [locale]) + // The Telegram SDK can finish loading after `detectInitialLocale` ran, so + // pick up the Telegram language then — unless the user chose one explicitly. + useEffect(() => { + if (typeof window === 'undefined') return + if (readStoredLocale()) return + if (!isTelegramEnvironment()) return + if (normalizeLocaleTag(getTelegramWebApp()?.initDataUnsafe?.user?.language_code)) return + + let cancelled = false + void resolveLateTelegramLocale().then((detected) => { + if (cancelled) return + applyLateTelegramLocale(detected, setLocaleState) + }) + return () => { + cancelled = true + } + }, []) + return ( {children} diff --git a/web/src/lib/locales/index.ts b/web/src/lib/locales/index.ts index e4668824..6e339e93 100644 --- a/web/src/lib/locales/index.ts +++ b/web/src/lib/locales/index.ts @@ -1,2 +1,3 @@ export { default as en } from './en' export { default as zhCN } from './zh-CN' +export { default as ru } from './ru' diff --git a/web/src/lib/locales/ru.ts b/web/src/lib/locales/ru.ts new file mode 100644 index 00000000..c3d03910 --- /dev/null +++ b/web/src/lib/locales/ru.ts @@ -0,0 +1,1284 @@ +export default { + // Состояния загрузки + 'loading': 'Загрузка…', + 'authorizing': 'Авторизация…', + 'loading.session': 'Загрузка сессии…', + 'loading.git': 'Загрузка статуса Git…', + 'loading.file': 'Загрузка файла…', + 'loading.files': 'Загрузка файлов…', + 'loading.messages': 'Загрузка сообщений…', + 'loading.machines': 'Загрузка машин…', + 'message.copy': 'Копировать', + 'message.copied': 'Скопировано', + 'message.info': 'Сведения о сообщении', + 'message.fork': 'Форк', + 'message.rewind': 'Откат', + 'message.fork.confirmTitle': 'Форк диалога', + 'message.fork.confirmDescription': 'Создать новую сессию с этого места?\nТекущая сессия не будет изменена.', + 'message.fork.confirming': 'Создание форка…', + 'message.rewind.confirmTitle': 'Откат диалога', + 'message.rewind.confirmDescription': 'Откатить эту сессию к этому месту?\nИстория диалога после этого момента будет удалена безвозвратно. Файлы изменены не будут.', + 'message.rewind.confirming': 'Откат…', + 'message.rewind.fallbackTitle': 'Откат недоступен', + 'message.rewind.fallbackDescription': 'HAPI не изменил эту сессию, поскольку её нативная граница Codex неоднозначна.\nСоздать новую сессию до этого места? Текущая сессия не будет изменена.', + 'message.rewind.fallbackFork': 'Форк здесь', + 'message.rewind.fallbackForking': 'Создание форка…', + 'message.shareTurn': 'Поделиться ходом как изображением', + 'shareTurn.title': 'Поделиться ходом как изображением', + 'shareTurn.badge': 'Общий ход', + 'shareTurn.generated': 'Создано в HAPI', + 'shareTurn.cancel': 'Отмена', + 'shareTurn.copy': 'Копировать', + 'shareTurn.copying': 'Копирование…', + 'shareTurn.copied': 'Скопировано', + 'shareTurn.share': 'Поделиться', + 'shareTurn.sharing': 'Отправка…', + 'shareTurn.download': 'Скачать', + 'shareTurn.saving': 'Сохранение…', + + // Вход / Аутентификация + 'login.title': 'HAPI', + 'login.subtitle': 'Vibe Coding — в любое время, в любом месте', + 'login.bind.title': 'Привязать Telegram', + 'login.submit': 'Войти', + 'login.bind.submit': 'Привязать', + 'login.submitting': 'Вход…', + 'login.bind.submitting': 'Привязка…', + 'login.placeholder': 'Токен доступа', + 'login.error.enterToken': 'Введите токен доступа', + 'login.error.bindingUnavailable': 'Привязка недоступна.', + 'login.error.loginUnavailable': 'Вход недоступен.', + 'login.error.bindFailed': 'Не удалось привязать', + 'login.error.authFailed': 'Ошибка аутентификации', + 'login.help': 'Нужна помощь?', + 'login.server.default': '(По умолчанию)', + 'login.server.custom': '(Свой)', + 'login.server.title': 'URL хаба', + 'login.server.description': 'Задайте origin хаба HAPI для API и живых обновлений.', + 'login.server.current': 'Текущий:', + 'login.server.origin': 'Origin хаба', + 'login.server.placeholder': 'https://hapi.example.com', + 'login.server.hint': 'Только http(s). Путь игнорируется.', + 'login.server.required': 'Требуется URL хаба. Укажите его перед входом.', + 'login.server.useSameOrigin': 'Использовать тот же origin', + 'login.server.save': 'Сохранить хаб', + 'login.footer': 'Создано с', + 'login.footer.for': 'для Vibe Coding', + 'login.footer.copyright': '©', + + // Страница сессий + 'sessions.new': 'Новая сессия', + 'sessions.empty.title': 'Сессий пока нет', + 'sessions.empty.hint': 'Запустите сессию в любой папке рабочего пространства или сначала просмотрите дерево.', + 'sessions.empty.startSession': 'Запустить сессию', + 'sessions.empty.browse': 'Просмотр рабочего пространства', + 'sessions.search.placeholder': 'Поиск по названию/пути/агенту/машине/ID…', + 'sessions.search.open': 'Поиск сессий (название, путь, агент, имя машины, ID и другое)', + 'sessions.search.clear': 'Очистить поиск', + 'sessions.search.noResults': 'Нет сессий, соответствующих фильтрам.', + 'sessions.unreadFilter.toggle': 'Только непрочитанные', + 'sessions.markAllRead.button': 'Отметить все как прочитанные ({count})', + 'sessions.markAllRead.title': 'Отметить все как прочитанные?', + 'sessions.markAllRead.description': 'Будут отмечены как прочитанные {count} непрочитанных сессий на этом устройстве.', + 'sessions.markAllRead.confirm': 'Подтвердить', + 'sessions.markAllRead.confirming': 'Отмечаем как прочитанные…', + 'sessions.timeFilter.label': 'Фильтр сессий по последней активности', + 'sessions.timeFilter.pickStart': 'Выбрать дату начала', + 'sessions.timeFilter.pickEnd': 'Выбрать дату окончания', + 'sessions.timeFilter.clear': 'Очистить', + 'sessions.timeFilter.close': 'Закрыть выбор даты', + 'sessions.timeFilter.previousMonth': 'Предыдущий месяц', + 'sessions.timeFilter.nextMonth': 'Следующий месяц', + 'sessions.timeFilter.dayWithActivity': '{date}, есть активность сессии', + 'sessions.group.expand': 'Развернуть {n}', + 'sessions.group.collapse': 'Свернуть {n}', + 'sessions.runningSection': 'В работе', + 'sessions.activeSection': 'Активные сессии', + 'sessions.group.new': 'Новая сессия в этой папке', + 'sessions.machineFilter.label': 'Фильтр сессий по машине', + 'sessions.machineFilter.all': 'Все', + 'codexSync.tooltip': 'Импортировать сессии из Codex в Hapi', + 'codexSync.newSessionAction': 'Импорт истории Codex', + 'codexSync.confirm.title': 'Импорт сессий Codex', + 'codexSync.confirm.description': 'Выберите сессии Codex для импорта в Hapi', + 'piImport.inline.title': 'Импорт истории Pi', + 'piImport.inline.description': 'Необязательно: выберите существующую локальную сессию Pi для импорта и продолжения.', + 'piImport.inline.clear': 'Очистить', + 'piImport.inline.choose': 'Выбрать историю', + 'piImport.dialog.title': 'Импорт сессий Pi', + 'piImport.dialog.description': 'Выберите локальные истории Pi. Один выбор продолжается сразу; несколько выборов импортируются в фоне.', + 'piImport.dialog.concurrentWarning': 'Несколько процессов могут открыть одну и ту же сессию Pi, но избегайте записи из устаревших копий. Перед продолжением в HAPI перезагрузите или закройте любой другой процесс Pi, чтобы избежать непреднамеренного ответвления.', + 'piImport.loading': 'Сканирование сессий Pi…', + 'piImport.empty': 'Не найдено сессий Pi для импорта.', + 'piImport.selectedCount': 'Выбрано: {n}', + 'piImport.clearAll': 'Очистить все', + 'piImport.selectAll': 'Выбрать все', + 'piImport.allDirectories': 'Все каталоги', + 'piImport.search': 'Поиск по названию, запросу, пути, модели или ID', + 'piImport.imported': 'Импортировано', + 'piImport.needsAttention': 'Требует внимания', + 'piImport.messages': 'сообщений', + 'piImport.importing': 'Импорт…', + 'piImport.confirm': 'Импортировать', + 'piImport.success.title': 'Сессии Pi импортированы', + 'piImport.success.body': 'Импортировано сессий Pi: {n}.', + 'piImport.failed.title': 'Не удалось импортировать Pi', + 'piImport.failed.body': 'Не удалось импортировать историю Pi.', + 'piImport.failed.partial': 'Не удалось импортировать сессий: {failed}. Причина: {reason}', + 'piImport.error.diverged': 'Локальная ветка Pi изменилась после предыдущего импорта. Проверьте сессию перед продолжением.', + 'piImport.error.active': 'Остановите активную сессию Hapi Pi перед импортом более новой нативной истории.', + 'piImport.manual.success.title': 'Синхронизация истории Pi завершена', + 'piImport.manual.success.body': 'Синхронизировано новых элементов истории Pi: {n}.', + 'piImport.manual.success.noNewMessages': 'История Pi уже актуальна.', + 'piImport.manual.failed.title': 'Не удалось синхронизировать историю Pi', + // Комментарий: ниже — тексты для списка с множественным выбором в диалоге импорта, состояния загрузки и подсказки кнопки перезапуска клиента Codex в правом верхнем углу. + 'codexSync.confirm.selectAll': 'Выбрать все', + 'codexSync.confirm.clearAll': 'Очистить все', + 'codexSync.confirm.selectedCount': 'Выбрано сессий: {n}', + 'codexSync.confirm.empty': 'Локальные сессии Codex не найдены', + 'codexSync.confirm.current': 'Связано', + 'codexSync.confirm.cwd': 'Рабочий каталог', + 'codexSync.confirm.cwdFilter': 'Рабочий каталог', + 'codexSync.confirm.cwdFilterAll': 'Все рабочие каталоги', + 'codexSync.confirm.emptyForWorkdir': 'Нет сессий Codex в этом рабочем каталоге', + 'codexSync.confirm.confirm': 'Импортировать', + 'codexSync.confirm.confirming': 'Импорт…', + 'codexSync.confirm.loading': 'Загрузка локальных сессий Codex…', + 'codexSync.success.title': 'Импорт завершён', + 'codexSync.success.body': 'Импортировано сессий Codex в Hapi: {n}.', + 'codexSync.manual.success.title': 'Синхронизация Codex завершена', + 'codexSync.manual.success.body': 'Синхронизировано сессий Codex: {n}.', + 'codexSync.manual.failed.title': 'Не удалось синхронизировать сессию Codex', + 'codexSync.error.timeout': 'Время операции истекло', + 'codexSync.error.active': 'Эта сессия ещё активна. Дождитесь её завершения и повторите.', + 'codexSync.failed.title': 'Не удалось импортировать сессии Codex', + 'codexSync.failed.body': 'Не удалось импортировать сессии Codex.', + 'codexSync.failed.bodyWithReason': 'Импорт не удался: {reason}', + 'codexSync.restart.tooltip': 'Перезапустить клиент Codex', + 'codexSync.restart.title': 'Перезапустить клиент Codex', + 'codexSync.restart.description': 'Перезапустить клиент Codex сейчас, чтобы обновить сессии?', + 'codexSync.restart.confirm': 'Перезапустить', + 'codexSync.restart.confirming': 'Перезапуск…', + 'codexSync.restart.failed.title': 'Не удалось перезапустить клиент Codex', + 'codexSync.restart.failed.notFound': 'Не удалось перезапустить клиент Codex: не установлен или не найден', + 'codexSync.restart.failed.body': 'Не удалось выполнить скрипт перезапуска.', + 'codexSync.restart.started.title': 'Перезапуск запущен', + 'codexSync.restart.started.body': 'Клиент Codex перезапускается для обновления сессий.', + 'codexSync.duplicates.confirm.title': 'Обнаружены дубликаты сессий', + 'codexSync.duplicates.confirm.description': 'Обнаружены дубликаты сессий. Объединить их?', + 'codexSync.duplicates.confirm.confirm': 'Объединить', + 'codexSync.duplicates.confirm.confirming': 'Объединение…', + 'codexSync.duplicates.detect.failed.title': 'Не удалось обнаружить дубликаты сессий', + 'codexSync.duplicates.detect.failed.body': 'Не удалось обнаружить дубликаты сессий.', + 'codexSync.duplicates.merge.success.title': 'Дубликаты сессий объединены', + 'codexSync.duplicates.merge.success.body': 'Объединены дубликаты выбранных сессий Codex.', + 'codexSync.duplicates.merge.failed.title': 'Не удалось объединить дубликаты сессий', + 'codexSync.duplicates.merge.failed.body': 'Не удалось объединить дубликаты сессий.', + + // Список сессий + 'session.item.path': 'путь', + 'session.item.agent': 'агент', + 'session.item.model': 'модель', + 'session.item.machine': 'машина', + 'session.modelChange.pending': 'Изменение…', + 'session.modelChange.cursorAutoRestart': 'Авто (для переключения обратно нужен перезапуск)', + 'session.modelChange.pendingTooltip': 'Модель изменится, когда текущая задача завершится.', + 'session.item.worktree': 'рабочее дерево', + 'session.item.pending': 'ожидание', + 'session.item.thinking': 'размышление', + 'session.item.running': 'Выполняется', + 'session.item.active': 'Активна', + 'session.item.idle': 'Простаивает (только keepalive)', + 'session.item.permission': 'Требуется разрешение', + 'session.item.needsInput': 'Нужен ввод', + 'session.item.background': 'Выполняются фоновые задачи', + 'session.item.newActivity': 'Новая активность', + 'session.item.scheduledMessage': 'Ожидает отложенное сообщение', + 'session.item.scheduledMessages': 'Ожидает отложенных сообщений: {count}', + 'session.tooltip.permission.body': 'Одобрить:', + 'session.tooltip.input.body': 'Ответить:', + 'session.tooltip.background.count.one': 'Выполняется 1 задача', + 'session.tooltip.background.count.other': 'Выполняется задач: {count}', + 'session.tooltip.scheduled.body': 'Сработает по наступлении срока.', + 'session.tooltip.scheduled.fires': 'Сработает {when}', + 'session.tooltip.scheduled.next': 'Следующее {when} · ещё +{more}', + 'session.tooltip.moreCount': 'ещё +{count}', + 'session.time.justNow': 'только что', + 'session.time.minutesAgo': '{n} мин назад', + 'session.time.hoursAgo': '{n} ч назад', + 'session.time.daysAgo': '{n} дн назад', + 'session.time.inLessThanMinute': 'через <1 мин', + 'session.time.inMinutes': 'через {n} мин', + 'session.time.inHours': 'через {n} ч', + 'session.time.inDays': 'через {n} дн', + 'session.time.soon': 'скоро', + 'session.time.importedFromCodex.justNow': 'только что импортировано из Codex', + 'session.time.importedFromCodex.minutesAgo': 'импортировано из Codex {n} мин назад', + 'session.time.importedFromCodex.hoursAgo': 'импортировано из Codex {n} ч назад', + 'session.time.importedFromCodex.daysAgo': 'импортировано из Codex {n} дн назад', + 'session.cursorMigration.banner.title': 'Обновление сессии Cursor', + 'session.cursorMigration.banner.body': 'Переключение этого устаревшего чата на более безопасный протокол ACP. Для сессий с длинной историей это занимает 15–20 секунд; ваш диалог возобновится автоматически, а любой черновой текст сохранится.', + 'session.cursorMigration.bannerAmbiguous.title': 'Обновление сессии Cursor требует проверки вручную', + 'session.cursorMigration.bannerAmbiguous.body': 'Этот чат существует на диске в нескольких папках рабочего пространства, либо размер на диске не совпадает с синхронизированной историей, поэтому HAPI отказался переносить его автоматически. Проверьте логи хаба на предмет списка кандидатов (поиск по «[migrator] ambiguous legacy store» или «[migrator] size sanity check refused»), удалите устаревшие папки в ~/.cursor/chats/ и откройте сессию заново.', + + // Неактивная сессия + 'session.inactive.autoResume': 'Эта сессия неактивна. Отправьте сообщение, чтобы возобновить.', + 'session.inactive.cannotResume': 'Эта сессия неактивна и не может быть возобновлена.', + + // Шапка сессии + 'session.title': 'Файлы', + 'session.view.returnToChat': 'Вернуться к диалогу', + 'session.more': 'Дополнительные действия', + 'session.outline.open': 'План диалога', + 'session.outline.close': 'Закрыть план', + 'session.outline.title': 'План', + 'session.outline.loadOlder': 'Загрузить более ранние', + 'session.outline.empty': 'В загруженных сообщениях нет элементов плана', + 'session.outline.searchPlaceholder': 'Поиск по плану', + 'session.outline.searchLabel': 'Поиск элементов плана', + 'session.outline.searchResults': '{matched} из {total} элементов', + 'session.outline.noSearchResults': 'Нет подходящих элементов плана', + + // Действия с сессией + 'session.action.rename': 'Переименовать', + 'session.action.pinProject': 'Закрепить в проекте', + 'session.action.unpinProject': 'Открепить от проекта', + 'session.action.pinGlobal': 'Закрепить глобально', + 'session.action.unpinGlobal': 'Открепить глобально', + 'session.action.pinFailed': 'Не удалось изменить закрепление', + 'sessions.pinnedSection': 'Закреплённые сессии', + 'session.action.export': 'Экспортировать диалог', + 'session.action.markUnread': 'Отметить как непрочитанную', + 'session.action.syncCodex': 'Синхронизировать Codex', + 'session.action.syncPi': 'Синхронизировать историю Pi', + 'session.action.archive': 'Архивировать', + 'session.action.reopen': 'Открыть заново', + 'session.action.reopenCursorChecking': 'Проверка доступности данных чата Cursor на записанной машине.', + 'session.action.reopenCursorMissing': 'Данные чата Cursor больше недоступны на записанной машине.', + 'session.action.reopenCursorCheckFailed': 'Не удалось проверить данные чата Cursor на записанной машине.', + 'session.action.reopenCursorUnverified': 'Не удалось проверить данные чата Cursor (раннер может быть устаревшим). Повторное открытие всё ещё доступно — обновите CLI на той машине, если оно не сработает.', + 'runner.skew.updateRequired': 'ТРЕБУЕТСЯ ОБНОВЛЕНИЕ', + 'runner.skew.banner.title': 'Раннер устарел на {host}', + 'runner.skew.banner.body': 'Этому хабу нужна более новая версия HAPI CLI на {host} (сейчас {version}). Обновите CLI на той машине, затем перезапустите раннер (`systemctl --user restart hapi-runner` или `hapi runner start`). Этот баннер будет отображаться, пока раннер не сообщит о наличии требуемых возможностей.', + 'runner.skew.banner.bodyUnknownVersion': 'Этому хабу нужна более новая версия HAPI CLI на {host}. Обновите CLI на той машине, затем перезапустите раннер (`systemctl --user restart hapi-runner` или `hapi runner start`). Этот баннер будет отображаться, пока раннер не сообщит о наличии требуемых возможностей.', + 'runner.skew.banner.summaryTitle': 'Устаревших раннеров: {count}', + 'runner.skew.banner.summaryBody': 'Обновите HAPI CLI на каждом хосте ниже, затем перезапустите соответствующий раннер. Сверните или отложите этот баннер, чтобы добраться до сессий под ним.', + 'runner.skew.banner.minimized': 'Устаревших раннеров: {count} ({hosts}) — нажмите, чтобы развернуть', + 'runner.skew.banner.minimize': 'Свернуть', + 'runner.skew.banner.dismissTemp': 'Отложить на 1 ч', + 'runner.skew.banner.restart': 'Перезапустить раннер', + 'runner.skew.banner.restartUnavailable': 'Сначала обновите CLI', + 'runner.skew.banner.restartNeedsNewerBinary': 'Перезапуск поможет только после установки более новой версии HAPI CLI на этом хосте. Он не скачивает пакеты.', + 'runner.skew.banner.restartNeedsSupervisor': 'Перезапуск работает только когда раннер под наблюдением (HAPI_RUNNER_SUPERVISED=1 / systemd). Остановка без наблюдателя оставит хост офлайн.', + 'runner.skew.banner.restarting': 'Перезапуск…', + 'runner.skew.banner.binaryUpdatedHint': '(на диске уже более новая версия CLI — раннер скоро перезапустится сам)', + 'runner.skew.banner.upgradeCliFirst': '(на диске ещё нет более новой версии CLI — установите/обновите на этом хосте)', + 'runner.skew.banner.handoffHint': 'После обновления CLI на хосте этот раннер обычно перезапускается сам (передача версии). Перезапуск здесь только останавливает раннер под наблюдением, чтобы systemd/pm2 перезапустил его — никогда не используйте на раннере отдельного ноутбука.', + 'runner.skew.restartFailed': 'Не удалось перезапустить раннер', + 'session.action.delete': 'Удалить', + 'session.action.copy': 'Копировать', + 'session.action.copyReference': 'Копировать ссылку', + + // Диалоги + 'dialog.uri.title': 'Открыть эту ссылку?', + 'dialog.uri.description': 'Эта ссылка использует пользовательскую схему URI. Убедитесь, что доверяете этой ссылке, прежде чем открывать её.', + 'dialog.uri.open': 'Открыть', + 'dialog.uri.alwaysAllow': 'Всегда разрешать {scheme}:', + 'dialog.rename.title': 'Переименовать сессию', + 'dialog.rename.placeholder': 'Название сессии', + 'dialog.rename.save': 'Сохранить', + 'dialog.rename.saving': 'Сохранение…', + 'dialog.rename.error': 'Не удалось переименовать. Попробуйте ещё раз.', + 'dialog.rename.generate': 'Сгенерировать', + 'dialog.rename.generating': 'Генерация…', + 'dialog.rename.generateError': 'Не удалось сгенерировать название. Попробуйте ещё раз.', + 'dialog.archive.title': 'Архивировать сессию', + 'dialog.archive.description': 'Вы уверены, что хотите архивировать «{name}»? Активная сессия будет отключена.', + 'dialog.archive.confirm': 'Архивировать', + 'dialog.archive.confirming': 'Архивирование…', + 'dialog.reopen.errorTitle': 'Не удалось открыть сессию заново', + 'dialog.reopen.dismiss': 'Закрыть', + 'dialog.delete.title': 'Удалить сессию', + 'dialog.delete.description': 'Вы уверены, что хотите удалить «{name}»? Это действие необратимо.', + 'dialog.delete.scratchlist.one': 'Также будет удалена 1 запись блокнота.', + 'dialog.delete.scratchlist.other': 'Также будут удалены записи блокнота: {n}.', + 'dialog.delete.confirm': 'Удалить', + 'dialog.delete.confirming': 'Удаление…', + 'dialog.error.default': 'Операция не удалась. Попробуйте ещё раз.', + + // Экспорт сессии + 'session.export.title': 'Экспорт диалога', + 'session.export.description': 'Выберите формат, затем скачайте весь видимый диалог и записи блокнота.', + 'session.export.format.json': 'JSON', + 'session.export.format.json.description': 'Полные данные без потерь: метаданные сессии, сообщения и записи блокнота (только метаданные вложений).', + 'session.export.format.markdown': 'Markdown', + 'session.export.format.markdown.description': 'Читаемое представление, сгенерированное из тех же данных экспорта.', + 'session.export.download': 'Скачать', + 'session.export.downloadAnyway': 'Скачать в любом случае', + 'session.export.downloading': 'Экспорт…', + 'session.export.warning.title': 'Большой экспорт', + 'session.export.warning.description': 'Этот экспорт содержит {count} сообщений и оценивается в {size}. Это превышает рекомендуемый порог в {limit} сообщений.', + 'session.export.error.noApi': 'Нет соединения с сервером', + 'session.export.error.tooLarge': 'Этот экспорт слишком велик для безопасной подготовки ({count} сообщений, {size}; максимум {maxSize}).', + 'session.export.error.default': 'Не удалось экспортировать диалог', + 'session.export.toast.success.title': 'Диалог экспортирован', + 'session.export.toast.success.body': 'Скачан {filename}', + 'session.export.toast.error.title': 'Экспорт не удался', + + // Диаграммы Mermaid + 'mermaid.openFullscreen': 'Открыть диаграмму на весь экран', + 'mermaid.viewerTitle': 'Диаграмма', + 'mermaid.loading': 'Загрузка диаграммы…', + 'mermaid.renderError': 'Не удалось отобразить диаграмму.', + + // Общие кнопки + 'button.cancel': 'Отмена', + 'button.save': 'Сохранить', + 'button.confirm': 'Подтвердить', + 'button.close': 'Закрыть', + 'button.dismiss': 'Закрыть', + 'button.copy': 'Копировать', + 'button.paste': 'Вставить', + + // Форма новой сессии + 'newSession.title': 'Создать сессию', + 'newSession.machine': 'Машина', + 'newSession.directory': 'Каталог', + 'newSession.placeholder': '/путь/к/проекту', + 'newSession.browse': 'Обзор', + 'newSession.recent': 'Недавние пути', + 'newSession.type': 'Тип сессии', + 'newSession.type.simple': 'Обычная', + 'newSession.type.simple.desc': 'Использовать выбранный каталог как есть', + 'newSession.type.worktree': 'Рабочее дерево', + 'newSession.type.worktree.desc': 'Создать новое рабочее дерево рядом с репозиторием (Cursor использует нативные ~/.cursor/worktrees)', + 'newSession.type.worktree.placeholder': 'feature-x (по умолчанию 1228-xxxx)', + 'newSession.agent': 'Агент', + 'newSession.agentAvailabilityLoading': 'Проверка установленных агентов…', + 'newSession.agentAvailabilityFailed': 'Не удалось проверить установленные агенты.', + 'newSession.runnerUpgradeRequired': 'Перед созданием сессий обновите и перезапустите раннер HAPI на этой машине.', + 'newSession.noAvailableAgents': 'На этой машине не установлены поддерживаемые агенты.', + 'newSession.agentUnavailable': 'Выбранный агент больше недоступен на этой машине. Обновите список и выберите другого агента.', + 'newSession.directoryOutsideWorkspaceRoots': 'Каталог должен находиться внутри одного из корней рабочего пространства этой машины.', + 'newSession.model': 'Модель', + 'newSession.effort': 'Усилия', + 'newSession.model.optional': 'необязательно', + 'newSession.model.default': 'По умолчанию', + 'newSession.model.loadFailed': 'Не удалось загрузить модели', + 'newSession.model.selectVariant': 'Выбрать вариант', + 'newSession.model.cursorUnavailable': 'Моделей Cursor пока нет. Запустите сессию Cursor один раз или используйте Auto.', + 'newSession.agyModel.fetchingModels': 'Получение доступных моделей…', + 'newSession.agyModel.authRequired': 'Требуется аутентификация', + 'newSession.agyModel.authHint': 'Выполните `agy` в терминале, чтобы войти через Google.', + 'newSession.agyModel.retry': 'Повторить', + 'newSession.agyModel.notListed': 'больше не в списке', + 'newSession.agyModel.noModels': 'Нет доступных моделей Antigravity', + 'newSession.opencodeModel.loading': 'Поиск моделей OpenCode…', + 'newSession.opencodeModel.loadFailed': 'Не удалось загрузить модели OpenCode', + 'newSession.opencodeModel.retry': 'Повторить', + 'newSession.opencodeModel.empty': 'Для этого каталога не найдено моделей OpenCode', + 'newSession.opencodeModel.default': 'По умолчанию', + 'newSession.permission.managedTitle': 'Управляется агентом', + 'newSession.permission.managedDesc': 'Этот агент управляет своими разрешениями самостоятельно. Режим YOLO недоступен.', + 'newSession.reasoningEffort': 'Уровень рассуждений', + 'newSession.collaborationMode': 'Режим совместной работы', + 'newSession.copilotAgentMode': 'Режим агента', + 'newSession.fastMode': 'Быстрый режим', + 'newSession.yolo': 'Режим YOLO', + 'newSession.yolo.title': 'Обойти подтверждения и песочницу', + 'newSession.yolo.desc': 'Использует опасные флаги агента при запуске.', + 'newSession.yolo.mapsTo': 'Применяет нативный режим {mode}.', + 'newSession.grokAutoUnavailable': 'недоступно', + 'newSession.grokAutoUnavailableDesc': 'Grok не включил автоматические разрешения для этой учётной записи или сборки CLI.', + 'newSession.create': 'Создать', + 'newSession.creating': 'Создание…', + 'button.retry': 'Повторить', + + 'session.directoryMissingSimple': 'Каталог не существует. При создании сессии он будет создан автоматически.', + 'session.directoryMissingSimpleConfirm': 'Каталог не существует. Нажмите ещё раз, чтобы создать его автоматически.', + 'session.directoryMissingWorktree': 'Сессии с рабочим деревом требуют существующего каталога репозитория.', + 'session.codexModelsLoadFailed': 'Не удалось загрузить модели Codex', + 'session.createAndCreateDirectory': 'Создать и создать каталог', + + // Машина + 'machine.unknown': 'Неизвестная платформа', + 'machine.os.windows': 'Windows', + 'machine.os.linux': 'Linux', + 'machine.os.macos': 'macOS', + 'machine.os.unknown': 'Неизвестная ОС', + 'machine.header.sessionCount': 'Сессий: {n}', + 'machine.health.tooltip.title': 'Ёмкость машины', + 'machine.health.status.healthy': 'В норме — есть место для новых агентов', + 'machine.health.status.elevated': 'Повышенная — новые агенты могут работать медленнее', + 'machine.health.status.high': 'Высокая нагрузка — лучше не запускать здесь новые', + 'machine.health.status.unknown': 'Метрики недоступны', + 'machine.health.metric.cpu': 'ЦП', + 'machine.health.metric.ram': 'ОЗУ', + 'machine.health.tooltip.load': 'Очередь выполнения (1 мин): {value}', + 'machine.health.tooltip.loadShort': 'Нагрузка (1м)', + 'machine.health.tooltip.uptimeShort': 'Аптайм', + 'machine.health.uptimeCompact': 'работает {value}', + 'machine.health.tooltip.hint': 'Обновляется примерно каждые 20 с раннером на этой машине', + 'machine.health.aria.cpu': 'ЦП {n} процентов', + 'machine.health.aria.ram': 'ОЗУ {n} процентов', + 'machine.health.aria.unknown': 'Состояние машины недоступно', + + // Чат + 'chat.placeholder': 'Введите сообщение…', + 'chat.send': 'Отправить', + 'chat.abort': 'Прервать', + 'chat.settings': 'Настройки', + 'chat.terminal': 'Терминал', + 'chat.switchRemote': 'Переключиться в удалённый режим', + 'chat.sendError.fallback': 'Не удалось отправить сообщение. Отредактируйте и попробуйте ещё раз.', + 'chat.sendError.sessionInactive': 'Эта сессия заархивирована. Откройте её заново, чтобы отправить сообщение.', + 'chat.sendError.sessionInactive.action': 'Открыть заново', + + // Обзор Codex + 'codexReview.title': 'Обзор Codex', + 'codexReview.findings': 'Находок: {count}', + 'codexReview.confidence': 'Уверенность {value}', + 'codexReview.location.missing': 'Нет местоположения', + + // Терминал + 'terminal.commandName': 'Команда', + 'terminal.commandMessage': 'Сообщение команды', + 'terminal.commandArgs': 'Аргументы команды', + 'terminal.stdout': 'Stdout', + 'terminal.stderr': 'Stderr', + 'terminal.unsupportedWindows': 'Удалённый терминал недоступен на этом хосте.', + 'terminal.retry': 'Повторить', + 'terminal.inputMode.label': 'Режим ввода терминала', + 'terminal.inputMode.command': 'Команда', + 'terminal.inputMode.direct': 'Прямой', + 'terminal.command.placeholder': 'Введите команду…', + 'terminal.command.inputLabel': 'Ввод команды', + 'terminal.command.run': 'Выполнить команду', + 'terminal.command.shortcuts': 'Основные команды', + 'terminal.direct.pages': 'Страницы прямых клавиш', + 'terminal.direct.page.control': 'Управление', + 'terminal.direct.page.navigation': 'Навигация', + 'terminal.direct.page.shell': 'Оболочка', + 'terminal.direct.page.symbols': 'Символы', + 'terminal.paste.fallbackTitle': 'Вставить ввод', + 'terminal.paste.fallbackDescription': 'Чтение из буфера обмена недоступно. Вставьте текст ниже.', + 'terminal.paste.placeholder': 'Вставьте ввод терминала сюда…', + + // Блок кода + 'code.copy': 'Копировать', + 'code.truncated': 'Предпросмотр обрезан — откройте детали для полного вывода', + 'code.wrap.enable': 'Включить перенос строк', + 'code.wrap.disable': 'Отключить перенос строк', + + // Просмотр изменений + 'diff.title': 'Изменения', + 'diff.view': 'Просмотр', + + // Страница файлов + 'files.page.title': 'Файлы', + 'files.page.refresh': 'Обновить', + 'files.page.refreshFilesystem': 'Обновить вид файловой системы', + 'files.page.searchPlaceholder': 'Поиск файлов', + 'files.projectRoot': 'корень проекта', + 'files.branch.detached': 'отделённый HEAD', + 'files.branch.summary': '{staged} индексировано, {unstaged} не индексировано', + 'files.tab.changes': 'Изменения', + 'files.tab.directories': 'Каталоги', + 'files.search.empty': 'Нет файлов, соответствующих поиску.', + 'files.search.error.failed': 'Не удалось выполнить поиск файлов.', + 'files.search.error.failedWithDetail': 'Не удалось выполнить поиск файлов: {error}', + 'files.changes.section.staged': 'Индексированные изменения ({n})', + 'files.changes.section.unstaged': 'Неиндексированные изменения ({n})', + 'files.changes.empty.unavailable': 'Статус Git недоступен. Используйте «Каталоги» для просмотра всех файлов или поиск.', + 'files.changes.empty.none': 'Изменений не обнаружено. Используйте «Каталоги» для просмотра всех файлов или поиск.', + 'files.changes.error.gitStatusUnavailable': 'Статус Git недоступен.', + 'files.changes.error.gitStatusUnavailableWithDetail': 'Статус Git недоступен: {error}', + 'files.changes.error.unstagedDiffUnavailableWithDetail': 'Неиндексированные изменения недоступны: {error}', + 'files.changes.error.stagedDiffUnavailableWithDetail': 'Индексированные изменения недоступны: {error}', + 'files.directories.empty': 'Пустой каталог.', + 'files.directories.error.listFailed': 'Не удалось получить список каталога.', + 'files.directories.error.listFailedWithDetail': 'Не удалось получить список каталога: {error}', + 'files.directories.download': 'Скачать файл', + 'files.directories.downloadNamed': 'Скачать {name}', + 'files.directories.download.error.title': 'Скачивание не удалось', + 'files.directories.download.error.default': 'Не удалось скачать файл.', + 'files.sort.title': 'Сортировка файлов', + 'files.sort.by': 'Сортировать по', + 'files.sort.name': 'Имя', + 'files.sort.modified': 'Время изменения', + 'files.sort.size': 'Размер файла', + 'files.sort.nameAsc': 'От А до Я', + 'files.sort.nameDesc': 'От Я до А', + 'files.sort.oldest': 'Сначала старые', + 'files.sort.newest': 'Сначала новые', + 'files.sort.smallest': 'Сначала маленькие', + 'files.sort.largest': 'Сначала большие', + + // Контекстное меню файла + 'file.menu.title': 'Действия с файлом', + 'file.menu.copyPath': 'Копировать путь', + 'file.menu.copyAbsolutePath': 'Копировать абсолютный путь', + 'file.menu.addToComposer': 'Добавить в редактор', + + // Страница файла + 'file.page.fallbackName': 'Файл', + 'file.page.unknownPath': 'Неизвестный путь', + 'file.page.copyPath': 'Копировать путь', + 'file.page.copyContent': 'Копировать содержимое файла', + 'file.page.download': 'Скачать файл', + 'file.page.tab.diff': 'Изменения', + 'file.page.tab.file': 'Файл', + 'file.page.tab.source': 'Исходник', + 'file.page.tab.preview': 'Предпросмотр', + 'file.page.missingPath': 'Путь к файлу не указан.', + 'file.page.binary': 'Похоже, это бинарный файл. Его нельзя отобразить.', + 'file.page.imagePreviewAlt': 'Предпросмотр изображения {name}', + 'file.page.empty': 'Файл пуст.', + 'file.page.noChanges': 'Нет изменений для отображения.', + 'file.error.readFailed': 'Не удалось прочитать файл.', + 'file.error.readFailedWithDetail': 'Не удалось прочитать файл: {error}', + 'file.error.diffUnavailable': 'Изменения недоступны.', + 'file.error.diffUnavailableWithDetail': 'Изменения недоступны: {error}', + + // Встроенные медиа + 'media.displayed.image': 'Показано изображение', + 'media.displayed.video': 'Показано видео', + 'media.displayed.audio': 'Показано аудио', + 'media.displayed.file': 'Показан файл', + 'media.displayed.header': '{label}: {fileName}', + 'media.displayed.unavailable': '{label} недоступно. {error}', + + // Карточка инструмента + 'tool.askQuestion': 'Другое', + 'tool.edit': 'Правка', + 'tool.multiedit': 'Мультиправка', + 'tool.write': 'Запись', + 'tool.todo': 'Задачи', + 'tool.confirm': 'Подтвердить', + 'tool.deny': 'Отклонить', + 'tool.abort': 'Прервать', + 'tool.allow': 'Разрешить', + 'tool.allowAll': 'Разрешить все правки', + 'tool.denyAll': 'Запретить все правки', + 'tool.approved': 'Одобрено', + 'tool.rejected': 'Отклонено', + 'tool.cancel': 'Отмена', + 'tool.exitPlan': 'Выйти из режима плана', + 'tool.plan.implement': 'Реализовать план', + 'tool.plan.continue': 'Продолжить планирование', + 'tool.patch': 'Патч', + 'tool.input': 'Ввод', + 'tool.label': 'Инструмент', + 'tool.more': '+ещё {count}', + 'tool.trace': 'Трассировка', + 'tool.trace.callsSuffix': 'вызовов', + 'tool.result': 'Результат', + 'tool.duration': 'Длительность', + 'tool.startedAt': 'Начато', + 'tool.completedAt': 'Завершено', + 'tool.semanticTitle.readFile': 'Чтение файла', + 'tool.semanticTitle.runShell': 'Выполнение команды', + 'tool.semanticTitle.search': 'Поиск', + 'tool.semanticTitle.openUrl': 'Открыть URL', + 'tool.semanticTitle.query': 'Запрос', + 'tool.questionsAnswers': 'Вопросы и ответы', + 'tool.submit': 'Отправить', + 'tool.submitting': 'Отправка…', + 'tool.prev': '← Назад', + 'tool.next': 'Далее →', + 'tool.yes': 'Да', + 'tool.yesForSession': 'Да для сессии', + 'tool.allowForSession': 'Разрешить для сессии', + 'tool.abortLabel': 'Прервать', + 'tool.other': 'Другое', + 'tool.otherDescription': 'Введите свой ответ', + 'tool.question': 'Вопрос', + 'tool.selectOption': 'Выберите хотя бы один вариант или введите ответ.', + 'tool.waitingForApproval': 'Ожидание одобрения…', + 'tool.resolved': 'Разрешено в Codex', + 'tool.canceled': 'Отменено', + 'tool.approvedForSession': 'Одобрено для сессии', + 'tool.aborted': 'Прервано', + 'tool.approvedAllowAllEdits': 'Одобрено: разрешить все правки', + 'tool.requestFailed': 'Запрос не удался', + 'tool.askUserQuestion.fallback': 'Данные AskUserQuestion не в ожидаемом формате. Введите ответ:', + 'tool.askUserQuestion.placeholder': 'Введите ответ…', + 'tool.askUserQuestion.otherPlaceholder': 'Или введите свой ответ…', + 'tool.requestUserInput.textPlaceholder': 'Введите ответ…', + 'tool.requestUserInput.noteLabel': 'Дополнительное примечание (необязательно)', + 'tool.requestUserInput.notePlaceholder': 'Добавить примечание…', + 'tool.requestUserInput.otherLabel': 'Ничего из вышеперечисленного', + 'tool.requestUserInput.otherDescription': 'При желании добавьте детали в примечаниях.', + 'tool.requestUserInput.popupBlocked': 'Не удалось открыть страницу входа. Разрешите всплывающие окна и попробуйте ещё раз.', + 'toolGroup.title': 'Активность инструментов', + 'toolGroup.friendly.inspectTarget': 'Проверить {target}', + 'toolGroup.friendly.searchTarget': 'Найти «{target}»', + 'toolGroup.friendly.runTarget': 'Выполнить {target}', + 'toolGroup.friendly.editTarget': 'Изменить {target}', + 'toolGroup.primary.fileTargets': '{target} +{n}', + 'toolGroup.primary.commandTargets': '{target} +{n}', + 'toolGroup.primary.searchTargets': '{target} +{n}', + 'toolGroup.primary.urlTargets': '{target} +{n}', + 'toolGroup.primary.otherTargets': '{target} +{n}', + 'toolGroup.summary.read': 'Чтение {n}', + 'toolGroup.summary.mutation': 'Правка {n}', + 'toolGroup.summary.command': 'Выполнение {n}', + 'toolGroup.summary.search': 'Поиск {n}', + 'toolGroup.summary.web': 'Веб {n}', + 'toolGroup.summary.other': 'Инструмент {n}', + 'toolGroup.friendly.inspectFiles': 'Проверить файлы проекта', + 'toolGroup.friendly.searchContent': 'Найти в содержимом проекта', + 'toolGroup.friendly.runCommands': 'Выполнить команды проекта', + 'toolGroup.friendly.editFiles': 'Изменить файлы проекта', + 'toolGroup.friendly.openWeb': 'Просмотреть веб-содержимое', + 'toolGroup.friendly.genericCommand': 'Выполнить команду', + 'toolGroup.friendly.genericTool': 'Использовать инструмент', + 'toolGroup.badge.running': '{n} выполняется', + 'toolGroup.badge.pending': '{n} в ожидании', + 'toolGroup.badge.error': '{n} ошибка', + 'toolGroup.badge.fileTargets': '{n} файлов', + 'toolGroup.toolCount': '{n} действий', + 'toolGroup.loadingOlderHistory': 'Загрузка более ранней активности инструментов…', + 'toolGroup.historyUnavailable': 'Более ранняя активность инструментов недоступна.', + 'toolGroup.rowStatus.running': 'Выполняется', + 'toolGroup.rowStatus.pending': 'Ожидание', + 'toolGroup.rowStatus.error': 'Ошибка', + 'toolGroup.codex.exploring': 'Изучение', + 'toolGroup.codex.explored': 'Изучено', + 'toolGroup.codex.read': 'Чтение', + 'toolGroup.codex.list': 'Список', + 'toolGroup.codex.search': 'Поиск', + 'toolGroup.codex.searchIn': '{query} в {path}', + + // Кнопки редактора + 'composer.settings': 'Настройки', + 'composer.terminal': 'Терминал', + 'composer.abort': 'Прервать', + 'composer.switchRemote': 'Переключиться в удалённый режим', + 'composer.attach': 'Прикрепить файл', + 'composer.expand': 'Развернуть редактор сообщений', + 'composer.collapse': 'Свернуть редактор сообщений', + 'composer.dropToAttach': 'Перетащите, чтобы прикрепить', + 'composer.send': 'Отправить', + 'composer.stop': 'Остановить', + 'composer.voice': 'Голосовой ассистент', + 'composer.dictate': 'Диктовка (Ctrl/Cmd+Shift+D)', + 'composer.scheduleSend': 'Отложенная отправка', + 'composer.scheduleRelativeTab': 'Относительно', + 'composer.scheduleSpecificTab': 'Точное время', + 'composer.scheduleSpecificHint': 'Максимум 7 дней. Требуется работающий хаб; CLI наверстает отправку при следующем подключении после этого времени.', + 'composer.scheduleErrorPast': 'Время отправки должно быть в будущем.', + 'composer.scheduleErrorTooFar': 'Максимальное время отправки — 7 дней.', + 'queuedMessages.scheduledFor': 'Запланировано на {time}', + 'queuedMessages.editAlreadyInvoked': 'Сообщение уже отправлено — его нельзя отредактировать', + 'queuedMessages.editBusyNotRestored': 'Доставка всё ещё не завершена — черновик не восстановлен (может ещё быть в пути)', + 'queuedMessages.editCurrentDraftKept': 'Отложенное сообщение отменено — текущий черновик и расписание сохранены.', + 'queuedMessages.steer': 'Доставить в текущий ход сейчас', + 'queuedMessages.steerFailed': 'Не удалось направить — сообщение остаётся в очереди', + 'queuedMessages.steerOutcomeUnknown': 'Исход доставки неизвестен — повторите или явно отмените', + 'queuedMessages.retryOutcome': 'Повторить неопределённую доставку', + 'queuedMessages.retryFailed': 'Повтор не удался', + 'queuedMessages.retryMissing': 'Сообщение больше недоступно', + 'queuedMessages.retryUnavailable': 'Агент всё ещё обрабатывает эту доставку', + 'queuedMessages.steeredBadge': '↳ Направлено', + 'queuedMessages.steeredBadgeTitle': 'Направлено в активный ход', + + // Блокнот (рабочее пространство сессии) + 'scratchlist.title': 'Блокнот', + 'scratchlist.heldLabel': 'отложено — не отправлено', + 'scratchlist.empty': 'пусто', + 'scratchlist.count.one': '1 элемент', + 'scratchlist.count.other': 'Элементов: {n}', + 'scratchlist.attachmentOnly': '(вложение)', + 'scratchlist.emptyHint': 'Откладывайте сюда заметки, черновики и идеи. Ничего не отправляется, пока вы не продвинете запись.', + 'scratchlist.drawerHint': 'Печатайте ниже — кнопка «Отправить» добавляет следующее сообщение в блокнот, а не в чат. Нажмите иконку заметки ещё раз, чтобы выйти.', + 'scratchlist.toggleAriaLabel': 'Панель блокнота', + 'scratchlist.toggleTooltip': 'Блокнот — откладывать заметки и черновики (Ctrl/Cmd+Shift+S)', + 'scratchlist.sendToScratchlist': 'Отправить в блокнот', + 'scratchlist.fueTitle': 'Новое: блокнот', + 'scratchlist.fueBody': 'Откладывайте сюда заметки и черновики без отправки. Пока вы копите записи, кнопка «Отправить» светится янтарным; нажмите иконку (или Ctrl/Cmd+Shift+S) ещё раз, чтобы выйти.', + 'richComposer.fueTitle': 'Новое: @упоминание другой сессии', + 'richComposer.fueBody': 'Введите @, чтобы упомянуть другую сессию. Она станет чипом (наведите для деталей в стиле боковой панели). При отправке сообщение включает полный ID этой сессии, чтобы агент мог вызвать inspect_peer или ping_peer.', + 'scratchlist.addPlaceholder': 'Заметка, черновик или идея — Enter для добавления', + 'scratchlist.addAriaLabel': 'Добавить запись в блокнот', + 'scratchlist.add': 'Добавить', + 'scratchlist.atCap': 'Блокнот достиг лимита в {n} записей. Удалите старую запись, чтобы добавить новую.', + 'scratchlist.confirmDelete': 'Удалить эту запись блокнота? Это действие необратимо.', + 'scratchlist.listAriaLabel': 'Записи блокнота', + 'scratchlist.action.moveUp': 'Переместить запись вверх', + 'scratchlist.action.moveDown': 'Переместить запись вниз', + 'scratchlist.action.promoteToComposer': 'Скопировать в редактор', + 'scratchlist.action.promoteToQueue': 'Отправить в очередь', + 'scratchlist.action.copy': 'Скопировать текст в буфер обмена (не изображения)', + 'scratchlist.action.copied': 'Скопировано!', + 'scratchlist.action.delete': 'Удалить запись', + 'scratchlist.entry.lastSaved': 'Сохранено {time}', + 'scratchlist.entry.lastSavedAriaLabel': 'Запись сохранена {time}', + 'scratchlist.migrationBanner.title': 'Блокнот теперь синхронизируется между устройствами', + 'scratchlist.migrationBanner.body': 'Ваши существующие заметки скопированы в хаб — ничего не потеряно. Теперь изменения в этой сессии будут появляться на каждом используемом вами устройстве.', + 'scratchlist.migrationBanner.dismiss': 'Понятно', + 'fue.newFeatureDot': 'Доступна новая функция', + 'fue.gotIt': 'Понятно', + 'fue.closeAriaLabel': 'Закрыть пояснение', + 'composer.codexSlashUnsupported.title': 'Команда Codex недоступна', + 'composer.codexSlashUnsupported.body': 'Удалённый режим HAPI пока не выполняет встроенные слэш-команды Codex, такие как {command}. Используйте обычный язык или выполните её в локальном TUI Codex.', + + // Голосовой ассистент + 'voice.connecting': 'Подключение...', + 'voice.active': 'Активен', + 'voice.muted': 'Выключен звук', + 'voice.error': 'Ошибка', + 'voice.mute': 'Выключить звук', + 'voice.unmute': 'Включить звук', + 'voice.end': 'Завершить', + 'voice.error.micPermission': 'Доступ к микрофону запрещён', + 'voice.error.network': 'Ошибка сети', + 'voice.error.notInitialized': 'Голосовая сессия не инициализирована', + 'voice.error.startFailed': 'Не удалось запустить голосовую сессию', + 'voice.error.notAllowed': 'Голос недоступен', + 'voice.error.connection': 'Ошибка подключения', + 'voice.dismiss': 'Закрыть', + + // Баннеры + 'offline.title': 'Офлайн', + 'offline.message': 'Вы сейчас офлайн. Некоторые функции могут быть ограничены.', + 'syncing.title': 'Синхронизация…', + 'syncing.message': 'Ваши данные синхронизируются.', + 'reconnecting.message': 'Переподключение...', + 'reconnecting.reason.error': 'ошибка потока', + 'reconnecting.reason.closed': 'поток закрыт', + 'reconnecting.reason.heartbeatTimeout': 'таймаут heartbeat', + 'reconnecting.reason.visibilityRecovery': 'возобновление после фона', + 'reconnecting.reason.connectTimeout': 'таймаут подключения', + 'reconnecting.reason.transportError': 'ошибка потока', + 'pwa.update.title': 'Доступна новая версия', + 'pwa.update.body': 'Перезагрузите, чтобы получить последнюю версию HAPI', + 'pwa.update.reload': 'Перезагрузить', + 'pwa.update.whyToggle': 'Почему нельзя закрыть?', + 'pwa.update.whyBody': 'HAPI не будет перезагружать вкладку автоматически, пока у вас может работать агент, ожидаться разрешение или отправляться сообщение. Запуск старой веб-сборки с текущим сервером может вызвать ошибки синхронизации и неудачные действия. Этот баннер остаётся видимым до перезагрузки, чтобы вы случайно не застряли на устаревшей версии — но вы сами решаете, когда нажать «Перезагрузить», закончив текущие дела.', + + // Отправка заблокирована + 'send.blocked.title': 'Не удаётся отправить сообщение', + 'send.blocked.noConnection': 'Нет соединения с сервером', + 'resume.failed.title': 'Не удалось возобновить', + 'toast.ready.title': 'Готово к вводу', + 'toast.ready.body': '{agent} ожидает в {session}', + 'toast.permission.title': 'Запрос разрешения', + 'toast.input.title': '{agent} нужен ваш ввод', + 'toast.task.completed': 'Задача завершена', + 'toast.task.failed': 'Задача не удалась', + + // Предложение установки + 'install.title': 'Установить HAPI', + 'install.description': 'Добавьте HAPI на главный экран для быстрого доступа.', + 'install.button': 'Установить', + + // Переключатель языка + 'language.title': 'Язык', + 'language.english': 'English', + 'language.chinese': '简体中文', + + // Страница настроек + 'common.back': 'Назад', + 'settings.title': 'Настройки', + 'settings.hub.description': 'Выберите категорию, чтобы настроить HAPI под свой рабочий процесс.', + 'settings.hub.voice.summary': 'Голос, язык и поведение', + 'settings.general.title': 'Общие', + 'settings.storage.title': 'Хранилище', + 'settings.storage.summary': 'Использование базы данных хаба', + 'settings.storage.description': 'Текущий размер базы данных SQLite хаба на диске.', + 'settings.storage.loading': 'Загрузка использования хранилища…', + 'settings.storage.error': 'Не удалось загрузить использование хранилища', + 'settings.storage.total': 'Всего', + 'settings.storage.database': 'База данных', + 'settings.storage.wal': 'Журнал предзаписи', + 'settings.storage.shm': 'Разделяемая память', + 'settings.storage.path': 'Путь', + 'settings.storage.refresh': 'Обновить', + 'settings.storage.refreshing': 'Обновление…', + 'settings.storage.chartTitle': 'Относительная доля', + 'settings.storage.chartEmpty': 'Пока нет данных о размере на диске.', + 'settings.usage.title': 'Расход токенов', + 'settings.usage.summary': 'Панель расхода только по токенам', + 'settings.usage.description': 'Токены, обработанные при управлении агентами через HAPI. Ранее импортированные расшифровки не учитываются; стоимость не входит.', + 'settings.usage.range.label': 'Период', + 'settings.usage.range.7d': '7 дней', + 'settings.usage.range.30d': '30 дней', + 'settings.usage.range.all': 'Всё время', + 'settings.usage.loading': 'Загрузка расхода токенов…', + 'settings.usage.error': 'Не удалось загрузить расход токенов', + 'settings.usage.empty': 'За этот период расход токенов не зафиксирован.', + 'settings.usage.total': 'Обработано токенов (с учётом кэша)', + 'settings.usage.uncached': 'Токены без кэша', + 'settings.usage.input': 'Входные токены (включая кэш)', + 'settings.usage.output': 'Выходные токены', + 'settings.usage.cacheRead': 'Чтение кэша (включено во входные)', + 'settings.usage.cacheCreation': 'Создание кэша (включено)', + 'settings.usage.cacheHitRate': 'Доля попаданий в кэш', + 'settings.usage.requests': 'Запросы', + 'settings.usage.daily.title': 'Динамика по дням', + 'settings.usage.agent.title': 'По агентам', + 'settings.usage.model.title': 'По моделям', + 'settings.usage.bucketDetails': '{requests} запросов · {input} вход · {output} выход', + 'settings.usage.sessions': 'Сессий с расходом: {count}', + 'settings.general.description': 'Язык, сопряжение с приложением-компаньоном и общие предпочтения приложения.', + 'settings.general.sessionSummary.title': 'Сводки статуса сессий', + 'settings.general.sessionSummary.description': 'Выберите, будут ли поддерживаемые агенты формировать сводки статуса и отображаться ли они в чате.', + 'settings.general.sessionSummaryContract': 'Формировать сводки статуса', + 'settings.general.sessionSummaryContract.desc': 'По умолчанию выключено. При включении поддерживаемых агентов просят добавлять после каждого хода завершающую строку AGENT_NOTIFY_SUMMARY для уведомлений и записей о фоновой работе. Применяется к новым/возобновлённым сессиям. (Поддерживаются: Claude, Codex, OpenCode, удалённый Grok; пока не поддерживаются: локальный Grok, Cursor)', + 'settings.general.sessionSummaryInChat': 'Показывать сводки статуса в чате', + 'settings.general.sessionSummaryInChat.desc': 'Влияет только на отображение в чате и скопированном содержимом; не влияет на формирование сводок, уведомления или записи о фоновой работе. При включении показывается строка статуса, при выключении она скрыта. Сохранённые сообщения не изменяются.', + 'settings.language.title': 'Язык', + 'settings.language.label': 'Язык', + 'settings.display.title': 'Отображение', + 'settings.display.description': 'Внешний вид, типографика и настройки списка сессий.', + 'settings.display.typography': 'Типографика', + 'settings.display.sessions': 'Список сессий', + 'settings.display.appearance': 'Оформление', + 'settings.display.appearanceMode': 'Режим оформления', + 'settings.display.appearance.system': 'Как в системе', + 'settings.display.appearance.dark': 'Тёмная', + 'settings.display.appearance.oled': 'OLED-чёрный', + 'settings.display.appearance.light': 'Светлая', + 'settings.display.colorTheme': 'Цветовая тема', + 'settings.display.colorTheme.default': 'По умолчанию', + 'settings.display.colorTheme.notion': 'Notion', + 'settings.display.colorTheme.one': 'One', + 'settings.display.colorTheme.proof': 'Proof', + 'settings.display.colorTheme.raycast': 'Raycast', + 'settings.display.colorTheme.rosePine': 'Rose Pine', + 'settings.display.colorTheme.solarized': 'Solarized', + 'settings.display.colorTheme.vercel': 'Vercel', + 'settings.display.colorTheme.vsCodePlus': 'VS Code Plus', + 'settings.display.colorTheme.xcode': 'Xcode', + 'settings.display.colorTheme.linear': 'Linear', + 'settings.display.colorTheme.lobster': 'Lobster', + 'settings.display.colorTheme.material': 'Material', + 'settings.display.colorTheme.matrix': 'Matrix', + 'settings.display.colorTheme.monokai': 'Monokai', + 'settings.display.colorTheme.nightOwl': 'Night Owl', + 'settings.display.colorTheme.nord': 'Nord', + 'settings.display.themeColors.title': 'Свои цвета', + 'settings.display.themeColors.description': 'Применяется к текущему оформлению. Переключите оформление, чтобы настроить каждое отдельно.', + 'settings.display.themeColors.reset': 'Сбросить', + 'settings.display.themeColors.resetAll': 'Сбросить все', + 'settings.display.themeColors.expand': 'Настроить', + 'settings.display.themeColors.key.background': 'Фон', + 'settings.display.themeColors.key.surface': 'Карточки и поверхности', + 'settings.display.themeColors.key.text': 'Текст', + 'settings.display.themeColors.key.hint': 'Приглушённый текст', + 'settings.display.themeColors.key.accent': 'Акцент и ссылки', + 'settings.display.themeColors.key.border': 'Границы', + 'settings.display.themeColors.key.userBubble': 'Пузырь вашего сообщения', + 'settings.display.fontSize': 'Размер шрифта', + 'settings.display.terminalFontSize': 'Размер шрифта терминала', + 'settings.display.sessionPreviewLimit': 'Сессий до сворачивания', + 'settings.display.sessionPreviewLimit.decrease': 'Показывать меньше сессий до сворачивания', + 'settings.display.sessionPreviewLimit.increase': 'Показывать больше сессий до сворачивания', + 'settings.display.activeSessionsOnly': 'Только активные сессии', + 'settings.display.activeSessionsOnly.desc': 'Скрыть неактивные сессии в боковой панели. Открытая сессия остаётся видимой.', + 'settings.display.pinInProgressSessions': 'Закреплять сессии в работе', + 'settings.display.pinInProgressSessions.desc': 'Перемещать незакреплённые подключённые сессии в разделы над папками проекта: сначала выполняющиеся и ожидающие, затем спокойные активные сессии (выполнение завершено, подключение есть). Глобальные закрепления остаются выше них. Выключено — всё остаётся в группах по каталогам.', + 'settings.display.appBadge': 'Значок непрочитанного на панели задач', + 'settings.display.appBadge.desc': 'Показывать число сессий с новой активностью в установленной PWA Edge или Chrome. Внешний вид определяет браузер хоста; в обычной вкладке браузера это не действует.', + 'settings.display.sessionListStatus': 'Подсказки статуса в списке сессий', + 'settings.display.sessionListStatus.standard': 'Базовые', + 'settings.display.sessionListStatus.detailed': 'Расширенные', + 'settings.display.sessionListStatus.detailedDescription': 'Выберите, какие подсказки статуса показывать в списке сессий. Базовые показывают состояние выполнения; расширенные также показывают подсказки о разрешениях, вводе, фоновых задачах, новой активности и отложенных сообщениях (иконка часов).', + 'settings.display.sessionHeader': 'Шапка сессии', + 'settings.display.sessionHeader.description': 'Выберите, какие детали показывать под названием сессии. На мобильных показывается агент плюс самая приоритетная доступная деталь.', + 'settings.display.sessionHeader.showLabels': 'Показывать подписи полей', + 'settings.display.sessionHeader.agent': 'Агент', + 'settings.display.sessionHeader.model': 'Модель', + 'settings.display.sessionHeader.reasoning': 'Уровень рассуждений', + 'settings.display.sessionHeader.fastMode': 'Быстрый режим', + 'settings.display.sessionHeader.machine': 'Машина', + 'settings.display.sessionHeader.lastActive': 'Время активности', + 'settings.display.sessionHeader.createdAt': 'Время создания', + 'settings.display.sessionHeader.updatedAt': 'Время обновления', + 'settings.display.sessionHeader.worktree': 'Ветка рабочего дерева', + 'settings.display.links': 'Ссылки', + 'settings.display.openExternalLinksInNewTab': 'Открывать внешние ссылки в новой вкладке', + 'settings.display.openExternalLinksInNewTab.desc': 'Выключено (по умолчанию) сохраняет текущее поведение. Включено — открывает внешние ссылки в отдельной вкладке браузера.', + 'session.header.createdAt': 'Создана', + 'session.header.updatedAt': 'Обновлена', + 'settings.chat.title': 'Чат', + 'settings.chat.description': 'Ввод сообщений, карточки инструментов и цвета диалога.', + 'settings.chat.input': 'Ввод', + 'settings.chat.tools': 'Карточки инструментов', + 'settings.chat.colors': 'Цвета диалога', + 'settings.chat.enterBehavior': 'Клавиша Enter', + 'settings.chat.enterBehavior.send': 'Отправить сообщение', + 'settings.chat.enterBehavior.newline': 'Вставить перенос строки', + 'settings.chat.terminalToolDisplay': 'Карточки инструментов терминала', + 'settings.chat.terminalToolDisplay.compact': 'Компактные (только команда)', + 'settings.chat.terminalToolDisplay.detailed': 'Подробные (показывать предпросмотр вывода)', + 'settings.chat.codexExplorationCollapsed': 'Сворачивать изученные группы инструментов по умолчанию', + 'settings.chat.codexExplorationCollapsed.desc': 'Держать активность чтения и поиска Codex свёрнутой, пока вы её не откроете.', + 'settings.chat.reasoningCollapsed': 'Сворачивать рассуждения по умолчанию', + 'settings.chat.reasoningCollapsed.desc': 'Держать сообщения с рассуждениями ИИ свёрнутыми, в том числе во время потокового вывода.', + 'settings.chat.groupedToolBackground': 'Фон сгруппированного использования инструментов', + 'settings.chat.userMessageBackground': 'Фон сообщения пользователя', + 'settings.chat.surfaceColor.default': 'Цвет по умолчанию', + 'settings.chat.surfaceColor.softBlue': 'Мягкий синий', + 'settings.chat.surfaceColor.softGreen': 'Мягкий зелёный', + 'settings.chat.surfaceColor.softYellow': 'Мягкий жёлтый', + 'settings.chat.surfaceColor.custom': 'Свой цвет', + 'settings.chat.composerToolbar.title': 'Настройка панели инструментов редактора', + 'settings.chat.composerToolbar.description': 'Выберите, как расположить действия панели под полем ввода сообщения.', + 'settings.chat.composerToolbar.layout': 'Выравнивание', + 'settings.chat.composerToolbar.order': 'Порядок инструментов', + 'settings.chat.composerToolbar.hidden': 'Скрытые инструменты', + 'settings.chat.composerToolbar.emptyHidden': 'Перетащите сюда инструменты, чтобы скрыть их', + 'settings.chat.composerToolbar.hide': 'Скрыть', + 'settings.chat.composerToolbar.show': 'Показать', + 'settings.chat.composerToolbar.layout.left': 'Слева', + 'settings.chat.composerToolbar.layout.center': 'По центру', + 'settings.chat.composerToolbar.layout.right': 'Справа', + 'settings.chat.composerToolbar.layout.split': 'Раздельно', + 'settings.chat.composerToolbar.dragHint': 'Перетаскивайте действия, чтобы изменить порядок. В раздельной раскладке перетаскивайте между двумя группами.', + 'settings.chat.composerToolbar.previewHint': 'Перетаскивайте реальные иконки панели ниже, чтобы изменить порядок, или перемещайте их в «Скрытые инструменты». Изменения применяются сразу; наведите на иконку, чтобы увидеть её действие.', + 'settings.chat.composerToolbar.reset': 'Сбросить', + 'settings.chat.composerToolbar.leftGroup': 'Левая группа', + 'settings.chat.composerToolbar.rightGroup': 'Правая группа', + 'settings.chat.composerToolbar.emptyGroup': 'Перетащите действия сюда', + 'settings.chat.composerToolbar.moveLeft': 'Переместить в левую группу', + 'settings.chat.composerToolbar.moveRight': 'Переместить в правую группу', + 'settings.chat.composerToolbar.moveEarlier': 'Переместить раньше', + 'settings.chat.composerToolbar.moveLater': 'Переместить позже', + 'settings.chat.composerToolbar.item.attachment': 'Добавить вложение', + 'settings.chat.composerToolbar.item.settings': 'Настройки сессии', + 'settings.chat.composerToolbar.item.expand': 'Развернуть редактор сообщений', + 'settings.chat.composerToolbar.item.model': 'Модель', + 'settings.chat.composerToolbar.item.effort': 'Усилия', + 'settings.chat.composerToolbar.item.terminal': 'Терминал', + 'settings.chat.composerToolbar.item.abort': 'Прервать', + 'settings.chat.composerToolbar.item.switch': 'Переключиться на удалённый', + 'settings.chat.composerToolbar.item.voiceMic': 'Микрофон голоса', + 'settings.chat.composerToolbar.item.scratchlist': 'Блокнот', + 'settings.chat.composerToolbar.item.schedule': 'Отложенная отправка', + 'settings.voice.title': 'Голос', + 'settings.voice.description': 'Выберите диктовку или полный голосовой ассистент.', + 'settings.voice.inputMode.title': 'Режим голоса', + 'settings.voice.inputMode.hint': 'Диктовка только вставляет текст в редактор; она никогда не отправляет автоматически.', + 'settings.voice.inputMode.assistant': 'Голосовой ассистент', + 'settings.voice.inputMode.assistant.hint': 'Двусторонний голосовой разговор', + 'settings.voice.inputMode.dictation': 'Диктовка', + 'settings.voice.inputMode.dictation.hint': 'Только ввод речи в текст', + 'settings.voice.transcriptionProvider': 'Провайдер транскрипции', + 'settings.voice.noTranscriptionProvider': 'На хабе пока нет провайдера транскрипции — добавьте OpenAI, ElevenLabs или Groq ниже (или используйте встроенный в браузер, если доступен).', + 'settings.voice.noVoiceBackend': 'На хабе не настроен бэкенд голосового ассистента — добавьте его ниже.', + 'settings.voice.credentials.hint': 'Выберите OpenAI, ElevenLabs или Groq. Ключи хранятся на хабе; браузер никогда не хранит долгоживущие секреты провайдера.', + 'settings.voice.credentials.assistantHint': 'Добавьте ключ ElevenLabs, Gemini Live или Qwen Realtime. Ключи хранятся на хабе.', + 'settings.voice.credentials.provider': 'Провайдер', + 'settings.voice.credentials.openaiCompatible': 'OpenAI-совместимый / локальный', + 'settings.voice.credentials.geminiLive': 'Gemini Live', + 'settings.voice.credentials.qwenRealtime': 'Qwen Realtime', + 'settings.voice.credentials.apiKey': 'API-ключ', + 'settings.voice.credentials.apiKeyPlaceholder': 'Вставьте API-ключ', + 'settings.voice.credentials.apiKeyReplace': 'Вставьте новый ключ для замены', + 'settings.voice.credentials.baseUrl': 'Базовый URL', + 'settings.voice.credentials.model': 'Модель', + 'settings.voice.credentials.save': 'Сохранить на хабе', + 'settings.voice.credentials.clear': 'Очистить', + 'settings.voice.credentials.saved': 'Сохранено. Список провайдеров обновлён.', + 'settings.voice.credentials.cleared': 'Удалено из настроек хаба.', + 'settings.voice.credentials.saveFailed': 'Не удалось сохранить учётные данные.', + 'settings.voice.credentials.configuredAs': 'Настроено через {source}: {hint}', + 'settings.voice.credentials.source.env': 'окружение', + 'settings.voice.credentials.source.settings': 'Настройки', + 'settings.voice.credentials.envLocked': 'Этот ключ задан окружением хаба и не может быть изменён здесь.', + 'settings.voice.credentials.manage': 'Добавить или управлять провайдерами', + 'settings.voice.credentials.manageHint': 'Добавьте OpenAI, ElevenLabs или Groq, чтобы включить диктовку на хабе.', + 'settings.voice.credentials.manageAssistantHint': 'Вставьте API-ключ, чтобы включить ElevenLabs, Gemini Live или Qwen Realtime на хабе.', + 'settings.voice.transcriptionMode': 'Режим транскрипции', + 'settings.voice.transcriptionMode.standard': 'Стандартный', + 'settings.voice.transcriptionMode.standard.hint': 'Записать, затем транскрибировать (по умолчанию)', + 'settings.voice.transcriptionMode.realtime': 'Реального времени', + 'settings.voice.transcriptionMode.realtime.hint': 'Показывать живой текст во время речи; вставить итог после остановки', + 'settings.voice.voices.description': 'Выберите голос, используемый выбранным бэкендом.', + 'settings.voice.sounds.title': 'Как это звучит', + 'settings.voice.responds.title': 'Как отвечает', + 'settings.voice.connection.title': 'Подключение и провайдер', + 'settings.voice.behaves.title': 'Как себя ведёт', + 'settings.voice.persona.title': 'Персона и инструкции', + 'settings.voice.advanced.section.title': 'Дополнительно', + 'settings.voice.responseLength.label': 'Длина ответа', + 'settings.voice.responseLength.brief': 'Краткий', + 'settings.voice.responseLength.balanced': 'Сбалансированный', + 'settings.voice.responseLength.detailed': 'Подробный', + 'settings.voice.responseLength.brief.hint': 'Отвечать в 1–2 предложениях.', + 'settings.voice.responseLength.balanced.hint': 'По умолчанию — ассистент сам определяет длину по контексту.', + 'settings.voice.responseLength.detailed.hint': 'Давать развёрнутые ответы, когда полезна глубина.', + 'settings.voice.opening.label': 'Начало', + 'settings.voice.opening.greet': 'Поздороваться', + 'settings.voice.opening.brief': 'Ввести в курс', + 'settings.voice.opening.greet.hint': 'Ассистент здоровается и ждёт, пока вы заговорите.', + 'settings.voice.opening.brief.hint': 'Ассистент кратко описывает активность агента при подключении.', + 'settings.voice.group.label': 'Настройки голоса', + 'settings.voice.group.hint': 'Настройки применяются к выбранному голосовому провайдеру или провайдеру транскрипции.', + 'settings.voice.backend': 'Бэкенд голоса', + 'settings.voice.language': 'Язык голоса', + 'settings.voice.autoDetect': 'Автоопределение', + 'settings.voice.voice': 'Голос', + 'settings.voice.voiceDefault': 'По умолчанию', + 'settings.voice.advanced.title': 'Расширенные настройки голоса', + 'settings.voice.advanced.hint': 'Идентичность, характер, фикстуры, ползунки. Значения по умолчанию подходят — открывайте только если хотите перенастроить.', + 'settings.voice.advanced.customizedBadge': 'изменено', + 'settings.voice.fixtures.title': 'Правила платформы (только чтение)', + 'settings.voice.fixtures.hint': 'Поставляются с приложением: инструменты, маршрутизация, правила TTS. Обновляются при обновлении HAPI — здесь не редактируются.', + 'settings.voice.identity.title': 'Идентичность и бренд', + 'settings.voice.identity.hint': 'Кто такой голосовой ассистент. HAPI — это приложение; здесь можно переименовать или ребрендировать персону (например, «надзиратель»). Ограничения провайдера всегда применяются отдельно.', + 'settings.voice.identity.reset': 'Сбросить идентичность', + 'settings.voice.identity.default': 'Встроенная идентичность по умолчанию', + 'settings.voice.identity.customized': 'Своя идентичность', + 'settings.voice.character.promptTitle': 'Характер и стиль речи', + 'settings.voice.character.promptHint': 'Тон и стиль. Текст предустановленной подачи объединяется сюда, когда характер пуст. Компонуется с правилами платформы во время выполнения на всех голосовых бэкендах.', + 'settings.voice.character.reset': 'Сбросить характер', + 'settings.voice.character.appendPreset': 'Добавить предустановленный текст подачи', + 'settings.voice.layers.resetAll': 'Сбросить идентичность и характер', + 'settings.voice.layers.composedNote': 'Промпт во время выполнения = правила платформы + ваша идентичность + характер (все бэкенды).', + 'settings.voice.wireBudget.elevenlabs': 'ElevenLabs отправляет собранные инструкции при подключении; история сессии инициализируется, затем передаётся потоком.', + 'settings.voice.wireBudget.gemini': 'Gemini Live использует те же собранные инструкции через прокси хаба.', + 'settings.voice.wireBudget.qwen': 'Qwen Realtime использует те же собранные инструкции в session.update.', + 'settings.voice.wireBudget.storage': 'Лимит локального хранилища (символов):', + 'settings.voice.wireBudget.elevenlabsLimits': 'Ограничения канала: ~{msg} Б на сообщение WebRTC; бюджет собранного промпта ~{prompt} Б.', + 'settings.voice.wireBudget.promptTruncated': 'Ваш собранный промпт был сокращён, чтобы уложиться в лимит канала ElevenLabs. Сократите идентичность/характер, иначе мы ещё обрежем его при подключении.', + 'settings.voice.wireBudget.composedSize': 'Размер последнего собранного промпта', + 'settings.voice.contextNotice': 'Последняя голосовая сессия', + 'settings.voice.character.title': 'Предустановка стиля речи', + 'settings.voice.character.presetSlidersHint': 'Предустановки настраивают ползунки TTS ElevenLabs и необязательную формулировку подачи (слой характера).', + 'settings.voice.character.preset.label': 'Предустановка', + 'settings.voice.character.preset.balanced': 'Сбалансированная', + 'settings.voice.character.preset.balancedHint': 'Тёплая и сосредоточенная — подходит для повседневных задач.', + 'settings.voice.character.preset.warm': 'Тёплая и выразительная', + 'settings.voice.character.preset.warmHint': 'Более живая подача; аудио-теги ElevenLabs, когда доступны.', + 'settings.voice.character.preset.calm': 'Спокойная', + 'settings.voice.character.preset.calmHint': 'Медленный, размеренный тон для долгих или поздних сессий.', + 'settings.voice.character.preset.direct': 'Прямая', + 'settings.voice.character.preset.directHint': 'Кратко и эффективно — минимум лишнего.', + 'settings.voice.character.preset.custom': 'Своя', + 'settings.voice.character.preset.customHint': 'Вы управляете ползунками и системным промптом выше.', + 'settings.voice.tuning.title': 'Акустическая настройка', + 'settings.voice.tuning.elevenlabs': 'ElevenLabs', + 'settings.voice.tuning.gemini': 'Gemini Live', + 'settings.voice.tuning.stability': 'Стабильность', + 'settings.voice.tuning.stabilityHint': 'Ниже = динамичнее; выше = ровнее.', + 'settings.voice.tuning.expressiveness': 'Выразительность', + 'settings.voice.tuning.expressivenessHint': 'Преувеличение стиля (стиль ElevenLabs).', + 'settings.voice.tuning.speakingRate': 'Скорость речи', + 'settings.voice.tuning.speakingRateHint': '0,7–1,2× (API ElevenLabs).', + 'settings.voice.tuning.similarity': 'Усиление схожести', + 'settings.voice.tuning.similarityHint': 'Более точное совпадение с обученным голосом.', + 'settings.voice.tuning.speakerBoost': 'Усиление голоса', + 'settings.voice.tuning.affectiveDialog': 'Аффективный диалог', + 'settings.voice.tuning.affectiveDialogHint': 'Подстраивать подачу под ваш тон (Gemini v1alpha).', + 'settings.voice.tuning.qwenHint': 'У Qwen здесь нет ползунков TTS — отредактируйте голосовой системный промпт выше; эмоции подстраиваются под вашу речь.', + 'settings.voice.tuning.selectBackend': 'Ползунки для каждого бэкенда появляются при выборе голосового бэкенда.', + 'settings.voice.preview.elevenlabsOnly': 'Предпросмотр голоса доступен только для ElevenLabs', + 'settings.voice.preview.unavailable': 'Предпросмотр недоступен без API-ключа ElevenLabs', + 'settings.voice.preview.play': 'Прослушать голос', + 'settings.voice.preview.stop': 'Остановить предпросмотр', + 'settings.voice.staticCatalogHint': 'Откройте список, чтобы увидеть заметки о характере голоса. Аудиопредпросмотр — только ElevenLabs.', + 'settings.voice.session.label': 'Поведение сессии', + 'settings.voice.proactive': 'Начинать голосовую сессию со сводкой', + 'settings.voice.proactive.description': 'При включении голосовая сессия начинается с озвученной сводки текущей активности агента. При выключении ассистент здоровается и ждёт, пока вы заговорите.', + 'settings.companion.title': 'Компаньон', + 'settings.companion.noToken': 'Для сопряжения нужен исходный токен доступа (CLI_API_TOKEN). Похоже, вы вошли через Telegram или другим способом, который не сохранил его — вставьте токен вручную в приложении-компаньоне.', + 'settings.companion.description': 'Отсканируйте из приложения-компаньона HAPI на Android (телефон или Wear OS), чтобы привязать его к этому хабу. Код сопряжения — это ваш токен доступа; относитесь к нему как к паролю.', + 'settings.companion.showQr': 'Показать QR сопряжения', + 'settings.companion.qrAriaLabel': 'QR-код сопряжения компаньона', + 'settings.companion.copyLink': 'Копировать ссылку', + 'settings.companion.copied': 'Скопировано!', + 'settings.companion.hide': 'Скрыть', + 'settings.machines.title': 'Машины', + 'settings.machines.description': 'Дайте своим машинам собственные имена. Отображаются только машины, которые сейчас онлайн.', + 'settings.machines.section': 'Ваши машины', + 'settings.machines.rename': 'Переименовать {name}', + 'settings.machines.namePlaceholder': 'Использовать имя хоста', + 'settings.machines.empty': 'Нет машин онлайн.', + 'settings.machines.error': 'Не удалось переименовать эту машину.', + 'settings.hub.machines.summary': 'Собственные имена для ваших машин', + 'settings.about.title': 'О приложении', + 'settings.about.description': 'Ссылки и информация о версии HAPI.', + 'settings.about.website': 'Сайт', + 'settings.about.appVersion': 'Версия приложения', + 'settings.about.protocolVersion': 'Версия протокола', + + // Обзор / Рабочее пространство + 'browse.title': 'Обзор', + 'browse.goUp': 'Наверх', + 'browse.empty': 'Подкаталоги не найдены', + 'browse.refresh': 'Обновить', + 'browse.showHidden': 'Показать скрытые', + 'browse.startSession': 'Запустить сессию', + 'browse.nav': 'Обзор', + 'browse.noRootTitle': 'Обзор рабочего пространства выключен', + 'browse.noRootHint': 'Обзор включается по желанию. Перезапустите раннер с одним или несколькими корнями рабочего пространства, чтобы включить навигацию по дереву файлов и ограниченный запуск сессий.', + 'browse.noRootFooter': 'Вы по-прежнему можете создавать сессии на странице «Новая сессия».', + 'browse.noMachinesConnected': 'CLI не подключён. Выполните `hapi runner start --workspace-root /путь/а --workspace-root /путь/б` на машине, чтобы начать.', + + // Разное + 'misc.noMachines': 'Нет доступных машин', + 'misc.machine': 'Машина', + 'misc.collaborationMode': 'Режим совместной работы', + 'misc.copilotAgentMode': 'Режим агента', + 'misc.permissionMode': 'Режим разрешений', + 'misc.model': 'Модель', + 'misc.reasoningEffort': 'Уровень рассуждений', + 'misc.effort': 'Усилия', + 'misc.fastMode': 'Быстрый режим', + 'misc.fastModeStandard': 'Стандартный', + 'misc.fastModeFast': 'Быстрый', + 'misc.variant': 'Вариант', + 'misc.changeModel': 'Сменить модель…', + 'misc.backToModelList': '← Модели', + 'misc.loading': 'Загрузка…', + 'misc.newMessage': 'Новых сообщений: {n}', + 'misc.scrollToBottom': 'Прокрутить вниз', + 'misc.loadingMessages': 'Загрузка сообщений…', + 'misc.pullToLoadOlder': 'Тяните, чтобы загрузить более ранние сообщения', + 'misc.releaseToLoadOlder': 'Отпустите, чтобы загрузить более ранние сообщения', + 'misc.typeMessage': "Введите 'continue', чтобы продолжить...", + 'misc.typeAMessage': 'Введите сообщение...', + 'misc.typeAMessageWithMentions': 'Введите, что должен сделать агент, или @упомяните другую сессию для контекста или передачи', + 'misc.offline': 'офлайн', + 'misc.permissionRequired': 'требуется разрешение', + 'misc.online': 'онлайн', + 'misc.contextDetails': 'Сведения о контексте', + 'misc.contextCache': 'Кэш: {value}', + 'misc.contextUsed': 'Использовано: {value} ({percent}%)', + 'misc.contextUsedTokens': 'Использовано: {value}', + 'misc.contextRemaining': 'Осталось: {value} ({percent}%)', + + // Выбор цели Web Share + 'share.title': 'Поделиться в HAPI', + 'share.subtitle': 'Выберите сессию, к которой это прикрепить.', + 'share.recentSessions': 'Недавние активные сессии', + 'share.newSession': 'Новая сессия', + 'share.discard': 'Отменить', + 'share.loading': 'Загрузка общего содержимого…', + 'share.notFound.title': 'Общее содержимое не найдено', + 'share.notFound.body': 'Эта ссылка истекла или была открыта напрямую без передачи.', + 'share.error.ingest': 'Не удалось прочитать общее содержимое. Попробуйте ещё раз из исходного приложения.', + 'share.error.noId': 'Не передан идентификатор. Откройте эту страницу из системного меню «Поделиться».', + 'share.backToSessions': 'Назад к сессиям', + 'share.preview.text': 'Общий текст', + 'share.preview.empty': '(пустой общий элемент)', + 'share.preview.files': '{n} файлов', + 'share.noActiveSessions': 'Нет активных сессий. Выберите «Новая сессия» ниже.', + 'sessions.refresh.pull': 'Потяните для обновления', + 'sessions.refresh.release': 'Отпустите для обновления', + 'sessions.refresh.refreshing': 'Обновление…', + 'sessions.refresh.failed.title': 'Не удалось обновить', + 'codexSync.newSessionInline.title': 'Импорт истории Codex', + 'codexSync.newSessionInline.description': 'Необязательно: выберите одну локальную сессию Codex, затем выберите модель/настройки и нажмите «Создать» для импорта.', + 'codexSync.newSessionInline.clear': 'Очистить выбранную историю', + 'codexSync.newSessionInline.choose': 'Выбрать историю', + 'codexSync.confirm.search': 'Поиск', + 'codexSync.confirm.searchPlaceholder': 'Поиск по названию, сообщению, каталогу, ID сессии', + 'codexSync.confirm.useSelected': 'Использовать выбранное', + 'codexSync.confirm.imported': 'Импортировано', + 'codexSync.confirm.original': 'Исходная', + 'codexSync.confirm.fork': 'Форк', + 'codexSync.confirm.archiveAction': 'Архивировать в Codex', + 'session.action.syncCodexDisabledActive': 'Остановите или заархивируйте выполняющуюся сессию перед синхронизацией истории Codex.', + 'codexSync.manual.success.noNewMessages': 'Нет новых сообщений Codex.', + 'session.status.title': 'Статус сессии', + 'session.status.goal': 'Цель', + 'session.status.tasks': 'Задачи', + 'session.status.subagents': 'Подагенты', + 'session.status.terminals': 'Фоновые терминалы', + 'session.status.terminalsUnavailable': 'Выполняется: {count} · точная команда недоступна', + 'session.status.terminalsPossible': 'Возможные команды', + 'session.status.goal.active': 'Активна', + 'session.status.goal.paused': 'Приостановлена', + 'session.status.goal.budgetLimited': 'Ограничена', + 'session.status.goal.usageLimited': 'Ограничена по расходу', + 'session.status.goal.blocked': 'Заблокирована', + 'session.status.goal.complete': 'Завершена', + 'session.status.subagent.running': 'Выполняется', + 'session.status.subagent.waiting': 'Ожидание', + 'session.status.subagent.error': 'Ошибка', + 'share.searchResults': 'Подходящие сессии', + 'share.noSearchResults': 'Нет сессий, соответствующих поиску.', + 'share.searchForMore': 'Ещё {n} активных сессий — воспользуйтесь поиском, чтобы найти их.', + 'session.summary.label': 'Сводка сессии', + 'session.summary.ariaLabel': 'Сводка статуса сессии', + 'session.summary.status.done': 'Готово', + 'session.summary.status.blocked': 'Заблокирована', + 'session.summary.status.needsReview': 'Требует проверки', + 'session.summary.status.needsDecision': 'Требует решения', + 'session.summary.status.failed': 'Не удалась', + 'session.summary.status.stalled': 'Застопорилась', + + // Install prompt (iOS add-to-home-screen guide) + 'install.close': 'Закрыть', + 'install.ios.step1.before': 'Нажмите', + 'install.ios.step1.after': '«Поделиться» на панели', + 'install.ios.step2.before': 'Прокрутите вниз и нажмите', + 'install.ios.step2.after': 'На экран «Домой»', + 'install.ios.step3.before': 'Нажмите', + 'install.ios.step3.emphasis': 'Добавить', + 'install.ios.step3.after': 'в правом верхнем углу', + + // Queued messages bar + 'queuedMessages.title': 'Сообщения в очереди', + 'queuedMessages.status': 'В очереди', + 'queuedMessages.editLabel': 'Изменить сообщение в очереди', + 'queuedMessages.cancelLabel': 'Отменить сообщение в очереди', + 'queuedMessages.pendingInvocation.one': '{n} сообщение в очереди ожидает вызова', + 'queuedMessages.pendingInvocation.other': '{n} сообщений в очереди ожидают вызова', + + // Image viewer / zoomable lightbox + 'image.clickToZoom': 'Нажмите, чтобы увеличить', + 'image.previous': 'Предыдущее изображение', + 'image.next': 'Следующее изображение', + 'image.zoomIn': 'Увеличить', + 'image.zoomOut': 'Уменьшить', + 'image.resetZoom': 'Сбросить масштаб', + 'image.fitToScreen': 'По размеру экрана', + + // Chat chrome and status + 'chat.contextCompacted': 'Контекст сжат', + 'chat.reasoning': 'Рассуждения', + 'chat.queuedStatus': 'В очереди', + 'chat.sendingStatus': 'Отправка', + 'chat.terminalOutput': 'Вывод терминала', + 'chat.removeAttachment': 'Убрать вложение', + 'chat.uploadFailed': 'Ошибка загрузки', + + // Tool cards + 'tool.output': 'Вывод', + 'tool.prompt': 'Промпт', + 'tool.draft': 'Черновик', + 'tool.rawJson': 'Исходный JSON', + 'tool.fileContent': 'Содержимое файла', + + // Team panel + 'team.members': 'Участники', + 'team.tasks': 'Задачи', + 'team.recentMessages': 'Недавние сообщения', + + // Terminal / app shell + 'terminal.inactiveSession': 'Сессия неактивна. Терминал недоступен.', + 'app.sessionUnavailable': 'Сессия недоступна', + + // Tool result states + 'tool.state.waitingForPermission': 'Ожидание разрешения…', + 'tool.state.running': 'Выполняется…', + 'tool.state.noOutput': '(нет вывода)', + 'tool.result.done': 'Готово', + 'tool.result.agentLaunched': 'Агент запущен', + 'tool.result.timedOut': 'Тайм-аут', + 'tool.result.noStatus': 'Нет статуса', + 'tool.result.skillLoaded': 'Навык загружен', + 'tool.result.skillLoadedNamed': 'Навык «{name}» загружен', + 'tool.result.fieldName': 'Имя', + 'tool.result.fieldTask': 'Задача', + 'tool.result.fieldId': 'ID', + 'tool.result.agentCount': 'Агентов: {count}', + + // Session shell + 'session.notFound.returning': 'Сессия не найдена. Возвращаемся к списку…', +} as const diff --git a/web/src/routes/settings/general.tsx b/web/src/routes/settings/general.tsx index 17f6cbe2..da7c5f6a 100644 --- a/web/src/routes/settings/general.tsx +++ b/web/src/routes/settings/general.tsx @@ -8,6 +8,7 @@ import { queryKeys } from '@/lib/query-keys' const locales: ReadonlyArray<{ value: Locale; label: string }> = [ { value: 'en', label: 'English' }, { value: 'zh-CN', label: '简体中文' }, + { value: 'ru', label: 'Русский' }, ] function getNamespace(token: string | null): string | null {