Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
52 changes: 30 additions & 22 deletions apps/desktop/src/app/settings/about-settings.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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, unknown>) => 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)
Expand Down Expand Up @@ -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 (
Expand All @@ -95,13 +97,15 @@ export function AboutSettings() {
<div>
<h2 className="text-lg font-semibold tracking-tight">Hermes Desktop</h2>
<p className="mt-1 text-xs text-muted-foreground">
{version?.appVersion ? `Version ${version.appVersion}` : 'Version unavailable'}
{version?.appVersion
? t('settings:about.version', { version: version.appVersion })
: t('settings:about.versionUnavailable')}
</p>
</div>
</div>

<div className="mx-auto mt-4 w-full max-w-2xl">
<SectionHeading icon={RefreshCw} title="Updates" />
<SectionHeading icon={RefreshCw} title={t('settings:about.updates')} />

<div
className={cn(
Expand All @@ -120,8 +124,9 @@ export function AboutSettings() {
<div className="min-w-0">
<p className="font-medium">{statusLine}</p>
<p className="mt-1 text-xs text-muted-foreground">
Last checked {relativeTime(status?.fetchedAt)}
{justChecked && !checking ? ' · just now' : ''}
{t(justChecked && !checking ? 'settings:about.lastCheckedJustNow' : 'settings:about.lastChecked', {
time: relativeTime(status?.fetchedAt, t)
})}
</p>
</div>
</div>
Expand All @@ -134,12 +139,12 @@ export function AboutSettings() {
variant="outline"
>
{checking ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
{checking ? 'Checking…' : 'Check now'}
{checking ? t('settings:about.checking') : t('settings:about.checkNow')}
</Button>

{behind > 0 && supported && !applying && (
<Button onClick={() => openUpdatesWindow()} size="sm">
See what&apos;s new
{t('settings:about.whatsNew')}
</Button>
)}

Expand All @@ -159,16 +164,19 @@ export function AboutSettings() {
target="_blank"
>
<ExternalLink className="size-3" />
Release notes
{t('settings:about.releaseNotes')}
</a>
</Button>
</div>
</div>

<ListRow
description="Hermes checks for updates automatically in the background and lets you know when one is ready."
hint={`Branch ${status?.branch ?? 'unknown'} · Commit ${status?.currentSha?.slice(0, 7) ?? 'unknown'}`}
title="Automatic updates"
description={t('settings:about.automaticUpdatesDescription')}
hint={t('settings:about.branchInfo', {
branch: status?.branch ?? t('common:unknown'),
sha: status?.currentSha?.slice(0, 7) ?? t('common:unknown')
})}
title={t('settings:about.automaticUpdates')}
/>
</div>
</SettingsContent>
Expand Down
113 changes: 88 additions & 25 deletions apps/desktop/src/app/settings/appearance-settings.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
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'
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 }) {
Expand Down Expand Up @@ -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 (
<SettingsContent>
<div className="space-y-5">
<div>
<SectionHeading icon={Palette} title="Appearance" />
<SectionHeading icon={Palette} title={t('settings:appearance.title')} />
<p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
These are desktop-only display preferences. Mode controls brightness; theme controls the accent palette and
chat surface styling.
{t('settings:appearance.description')}
</p>
</div>

<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">Color Mode</div>
<div className="text-sm font-medium">{t('settings:appearance.language.title')}</div>
<div className="mt-1 text-xs text-muted-foreground">
Pick a fixed mode or let Hermes follow your system setting.
{t('settings:appearance.language.description')}
</div>
</div>
<Pill>{prettyName(mode)}</Pill>
<Pill>{LANGUAGE_LABELS[currentLanguage]}</Pill>
</div>
<div className="grid gap-2 sm:grid-cols-3">
{MODE_OPTIONS.map(({ id, label, description, icon: Icon }) => {
{SUPPORTED_LANGUAGES.map(lang => {
const active = currentLanguage === lang

return (
<button
className={cn(
'group rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2.5 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
key={lang}
onClick={() => {
triggerHaptic('crisp')
void i18n.changeLanguage(lang)
}}
type="button"
>
<div className="flex items-start justify-between gap-3">
<div className="text-[length:var(--conversation-text-font-size)] font-medium">
{LANGUAGE_LABELS[lang]}
</div>
{active && (
<span className="grid size-5 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{lang}
</div>
</button>
)
})}
</div>
</section>

<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">{t('settings:appearance.colorMode')}</div>
<div className="mt-1 text-xs text-muted-foreground">
{t('settings:appearance.colorModeDescription')}
</div>
</div>
<Pill>
{t(`settings:appearance.mode.${mode}`)}
</Pill>
</div>
<div className="grid gap-2 sm:grid-cols-3">
{MODE_OPTIONS.map(({ id, labelKey, descriptionKey, icon: Icon }) => {
const active = mode === id

return (
Expand All @@ -104,9 +156,11 @@ export function AppearanceSettings() {
</span>
)}
</div>
<div className="mt-2 text-[length:var(--conversation-text-font-size)] font-medium">{label}</div>
<div className="mt-2 text-[length:var(--conversation-text-font-size)] font-medium">
{t(labelKey)}
</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{description}
{t(descriptionKey)}
</div>
</button>
)
Expand All @@ -117,25 +171,34 @@ export function AppearanceSettings() {
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">Tool Call Display</div>
<div className="text-sm font-medium">{t('settings:appearance.toolCallDisplay')}</div>
<div className="mt-1 text-xs text-muted-foreground">
Product hides raw tool payloads; Technical shows full input/output.
{t('settings:appearance.toolCallDisplayDescription')}
</div>
</div>
<Pill>{toolViewMode === 'technical' ? 'Technical' : 'Product'}</Pill>
<Pill>
{t(
toolViewMode === 'technical'
? 'settings:appearance.toolView.technical'
: 'settings:appearance.toolView.product',
toolViewMode === 'technical' ? 'Technical' : 'Product'
)}
</Pill>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{(
[
{
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 => {
Expand All @@ -155,15 +218,17 @@ export function AppearanceSettings() {
type="button"
>
<div className="flex items-start justify-between gap-3">
<div className="text-[length:var(--conversation-text-font-size)] font-medium">{option.label}</div>
<div className="text-[length:var(--conversation-text-font-size)] font-medium">
{t(`settings:appearance.toolView.${option.key}`, option.fallbackLabel)}
</div>
{active && (
<span className="grid size-5 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{option.description}
{t(`settings:appearance.toolView.${option.key}Description`, option.fallbackDescription)}
</div>
</button>
)
Expand All @@ -174,10 +239,8 @@ export function AppearanceSettings() {
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">Theme</div>
<div className="mt-1 text-xs text-muted-foreground">
Desktop palettes only. The selected mode is applied on top.
</div>
<div className="text-sm font-medium">{t('settings:appearance.theme')}</div>
<div className="mt-1 text-xs text-muted-foreground">{t('settings:appearance.themeDescription')}</div>
</div>
{activeTheme && <Pill>{activeTheme.label}</Pill>}
</div>
Expand Down
Loading