diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 986289325f45..ba71ba4f91ee 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -74,6 +74,8 @@ "cmdk": "^1.1.1", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.2", + "i18next": "^24.2.0", + "i18next-browser-languagedetector": "^8.0.0", "ignore": "^7.0.5", "katex": "^0.16.45", "leva": "^0.10.1", @@ -85,6 +87,7 @@ "react-arborist": "^3.5.0", "react-dom": "^19.2.5", "react-router-dom": "^7.14.2", + "react-i18next": "^15.0.0", "react-shiki": "^0.9.3", "remark-math": "^6.0.0", "shiki": "^4.0.2", diff --git a/apps/desktop/src/app/settings/about-settings.tsx b/apps/desktop/src/app/settings/about-settings.tsx index 991b860624b4..2091145ffe40 100644 --- a/apps/desktop/src/app/settings/about-settings.tsx +++ b/apps/desktop/src/app/settings/about-settings.tsx @@ -1,5 +1,6 @@ import { useStore } from '@nanostores/react' import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' import { CheckCircle2, ExternalLink, Loader2, RefreshCw, Sparkles } from '@/lib/icons' @@ -18,29 +19,30 @@ import { ListRow, SectionHeading, SettingsContent } from './primitives' const RELEASE_NOTES_URL = 'https://github.com/NousResearch/hermes-agent/releases' -function relativeTime(ms: number | undefined) { +function relativeTime(ms: number | undefined, t: (key: string, options?: Record) => string) { if (!ms) { - return 'never' + return t('common:never') } const diff = Date.now() - ms if (diff < 60_000) { - return 'just now' + return t('common:justNow') } if (diff < 3_600_000) { - return `${Math.round(diff / 60_000)} min ago` + return t('common:minutesAgo', { count: Math.round(diff / 60_000) }) } if (diff < 86_400_000) { - return `${Math.round(diff / 3_600_000)} hours ago` + return t('common:hoursAgo', { count: Math.round(diff / 3_600_000) }) } - return `${Math.round(diff / 86_400_000)} days ago` + return t('common:daysAgo', { count: Math.round(diff / 86_400_000) }) } export function AboutSettings() { + const { t } = useTranslation() const version = useStore($desktopVersion) const status = useStore($updateStatus) const apply = useStore($updateApply) @@ -69,21 +71,21 @@ export function AboutSettings() { let statusTone: 'idle' | 'available' | 'error' = 'idle' if (!supported) { - statusLine = status?.message ?? "This build can't update itself from inside the app." + statusLine = status?.message ?? t('settings:about.status.unsupported') statusTone = 'error' } else if (status?.error) { - statusLine = "We couldn't reach the update server." + statusLine = t('settings:about.status.error') statusTone = 'error' } else if (applying) { - statusLine = 'An update is currently installing.' + statusLine = t('settings:about.status.applying') statusTone = 'available' } else if (behind > 0) { - statusLine = `A new update is ready (${behind} change${behind === 1 ? '' : 's'} included).` + statusLine = t('settings:about.status.behind', { count: behind }) statusTone = 'available' } else if (status) { - statusLine = "You're on the latest version." + statusLine = t('settings:about.status.current') } else { - statusLine = 'Tap "Check now" to look for updates.' + statusLine = t('settings:about.status.prompt') } return ( @@ -95,13 +97,15 @@ export function AboutSettings() {

Hermes Desktop

- {version?.appVersion ? `Version ${version.appVersion}` : 'Version unavailable'} + {version?.appVersion + ? t('settings:about.version', { version: version.appVersion }) + : t('settings:about.versionUnavailable')}

- +

{statusLine}

- Last checked {relativeTime(status?.fetchedAt)} - {justChecked && !checking ? ' · just now' : ''} + {t(justChecked && !checking ? 'settings:about.lastCheckedJustNow' : 'settings:about.lastChecked', { + time: relativeTime(status?.fetchedAt, t) + })}

@@ -134,12 +139,12 @@ export function AboutSettings() { variant="outline" > {checking ? : } - {checking ? 'Checking…' : 'Check now'} + {checking ? t('settings:about.checking') : t('settings:about.checkNow')} {behind > 0 && supported && !applying && ( )} @@ -159,16 +164,19 @@ export function AboutSettings() { target="_blank" > - Release notes + {t('settings:about.releaseNotes')} diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx index c35ec3417e56..c56fe0c097f3 100644 --- a/apps/desktop/src/app/settings/appearance-settings.tsx +++ b/apps/desktop/src/app/settings/appearance-settings.tsx @@ -1,6 +1,8 @@ import { useStore } from '@nanostores/react' +import { useTranslation } from 'react-i18next' import { triggerHaptic } from '@/lib/haptics' +import { LANGUAGE_LABELS, SUPPORTED_LANGUAGES, type SupportedLanguage } from '@/lib/i18n' import { Check, Palette } from '@/lib/icons' import { cn } from '@/lib/utils' import { $toolViewMode, setToolViewMode } from '@/store/tool-view' @@ -8,7 +10,6 @@ import { useTheme } from '@/themes/context' import { BUILTIN_THEMES } from '@/themes/presets' import { MODE_OPTIONS } from './constants' -import { prettyName } from './helpers' import { Pill, SectionHeading, SettingsContent } from './primitives' function ThemePreview({ name }: { name: string }) { @@ -52,33 +53,84 @@ function ThemePreview({ name }: { name: string }) { } export function AppearanceSettings() { + const { t, i18n } = useTranslation() const { themeName, mode, availableThemes, setTheme, setMode } = useTheme() const toolViewMode = useStore($toolViewMode) const activeTheme = availableThemes.find(t => t.name === themeName) + const currentLanguage: SupportedLanguage = + SUPPORTED_LANGUAGES.find(lang => lang === (i18n.resolvedLanguage ?? i18n.language)) ?? 'en' + return (
- +

- These are desktop-only display preferences. Mode controls brightness; theme controls the accent palette and - chat surface styling. + {t('settings:appearance.description')}

-
Color Mode
+
{t('settings:appearance.language.title')}
- Pick a fixed mode or let Hermes follow your system setting. + {t('settings:appearance.language.description')}
- {prettyName(mode)} + {LANGUAGE_LABELS[currentLanguage]}
- {MODE_OPTIONS.map(({ id, label, description, icon: Icon }) => { + {SUPPORTED_LANGUAGES.map(lang => { + const active = currentLanguage === lang + + return ( + + ) + })} +
+
+ +
+
+
+
{t('settings:appearance.colorMode')}
+
+ {t('settings:appearance.colorModeDescription')} +
+
+ + {t(`settings:appearance.mode.${mode}`)} + +
+
+ {MODE_OPTIONS.map(({ id, labelKey, descriptionKey, icon: Icon }) => { const active = mode === id return ( @@ -104,9 +156,11 @@ export function AppearanceSettings() { )}
-
{label}
+
+ {t(labelKey)} +
- {description} + {t(descriptionKey)}
) @@ -117,25 +171,34 @@ export function AppearanceSettings() {
-
Tool Call Display
+
{t('settings:appearance.toolCallDisplay')}
- Product hides raw tool payloads; Technical shows full input/output. + {t('settings:appearance.toolCallDisplayDescription')}
- {toolViewMode === 'technical' ? 'Technical' : 'Product'} + + {t( + toolViewMode === 'technical' + ? 'settings:appearance.toolView.technical' + : 'settings:appearance.toolView.product', + toolViewMode === 'technical' ? 'Technical' : 'Product' + )} +
{( [ { - id: 'product', - label: 'Product', - description: 'Human-friendly tool activity with concise summaries.' + id: 'product' as const, + key: 'product', + fallbackLabel: 'Product', + fallbackDescription: 'Human-friendly tool activity with concise summaries.' }, { - id: 'technical', - label: 'Technical', - description: 'Include raw tool args/results and low-level details.' + id: 'technical' as const, + key: 'technical', + fallbackLabel: 'Technical', + fallbackDescription: 'Include raw tool args/results and low-level details.' } ] as const ).map(option => { @@ -155,7 +218,9 @@ export function AppearanceSettings() { type="button" >
-
{option.label}
+
+ {t(`settings:appearance.toolView.${option.key}`, option.fallbackLabel)} +
{active && ( @@ -163,7 +228,7 @@ export function AppearanceSettings() { )}
- {option.description} + {t(`settings:appearance.toolView.${option.key}Description`, option.fallbackDescription)}
) @@ -174,10 +239,8 @@ export function AppearanceSettings() {
-
Theme
-
- Desktop palettes only. The selected mode is applied on top. -
+
{t('settings:appearance.theme')}
+
{t('settings:appearance.themeDescription')}
{activeTheme && {activeTheme.label}}
diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx index 3969c2c09b51..97defa0f179b 100644 --- a/apps/desktop/src/app/settings/config-settings.tsx +++ b/apps/desktop/src/app/settings/config-settings.tsx @@ -1,5 +1,6 @@ import type { ChangeEvent, ReactNode } from 'react' import { useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' import { Input } from '@/components/ui/input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' @@ -37,9 +38,12 @@ function ConfigField({ optionLabels?: Record onChange: (value: unknown) => void }) { - const label = FIELD_LABELS[schemaKey] ?? prettyName(schemaKey.split('.').pop() ?? schemaKey) + const { t } = useTranslation() + const labelKey = FIELD_LABELS[schemaKey] + const label = labelKey ? t(labelKey) : prettyName(schemaKey.split('.').pop() ?? schemaKey) const normalize = (v: string) => v.toLowerCase().replace(/[^a-z0-9]+/g, '') - const rawDescription = (FIELD_DESCRIPTIONS[schemaKey] ?? schema.description ?? '').trim() + const descriptionKey = FIELD_DESCRIPTIONS[schemaKey] + const rawDescription = (descriptionKey ? t(descriptionKey) : (schema.description ?? '')).trim() const normalizedDesc = normalize(rawDescription) const description = @@ -54,7 +58,7 @@ function ConfigField({ if (schema.type === 'boolean') { return row(
- {value ? 'On' : 'Off'} + {value ? t('settings:config.on') : t('settings:config.off')}
) @@ -77,8 +81,8 @@ function ConfigField({ {option ? (optionLabels?.[option] ?? prettyName(option)) : schemaKey === 'display.personality' - ? 'None' - : '(none)'} + ? t('common:none') + : t('common:noneParens')} ))} @@ -98,7 +102,7 @@ function ConfigField({ onChange(n) } }} - placeholder="Not set" + placeholder={t('common:notSet')} type="number" value={value === undefined || value === null ? '' : String(value)} /> @@ -117,7 +121,7 @@ function ConfigField({ .filter(Boolean) ) } - placeholder="comma-separated values" + placeholder={t('settings:config.commaSeparated')} value={Array.isArray(value) ? value.join(', ') : String(value ?? '')} /> ) @@ -134,7 +138,7 @@ function ConfigField({ /* keep last valid */ } }} - placeholder="Not set" + placeholder={t('common:notSet')} spellCheck={false} value={JSON.stringify(value, null, 2)} />, @@ -149,14 +153,14 @@ function ConfigField({