mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-17 09:41:58 +00:00
feat(desktop): assignable themes per profile (#42286)
* feat(desktop): assignable themes per profile The desktop skin was a single global preference, so every profile shared one look. Make the theme assignment per profile: picking a theme assigns it to the profile that's currently live, and switching profiles paints that profile's own skin. A profile with no assignment inherits the global default, so single-profile installs and existing setups are unchanged. - themes/context.tsx: per-profile skin record in localStorage; ThemeProvider follows $activeGatewayProfile; boot paint uses the last active profile's theme to avoid a flash on a non-default relaunch; setTheme assigns to the live profile (default profile also seeds the legacy global fallback). - settings/appearance-settings.tsx: caption noting the theme is saved per profile, shown only when more than one profile exists. - i18n: themeProfileNote string across en/zh/zh-hant/ja. - themes/profile-theme.test.ts: resolution + inheritance coverage. * feat(desktop): make light/dark mode per profile too The command palette / theme picker sets skin + mode together on each pick, so leaving mode global meant a profile couldn't actually remember the full look it was given (e.g. "Ember Dark" in one profile would render Ember Light if another profile last flipped the global mode). Mirror the per-profile skin record for light/dark mode: ThemeProvider resolves and applies the active profile's mode on switch, the boot paint uses it, and setMode assigns to the live profile (default profile also seeds the legacy global mode fallback). * refactor(desktop): collapse per-profile skin/mode into one helper Skin and mode were near-identical resolve/assign pairs with hand-rolled try/catch around localStorage. Fold both into a single profilePref<T> factory (resolve + assign, default profile seeds the legacy global) and lean on storedString/persistString for the error-swallowing. Tests go table-driven over both prefs since they share one contract. No behavior change; -89 LOC. * refactor(desktop): treat default profile as the global slot directly "default" isn't a real profile — it is the legacy global value. Stop double-writing (record['default'] + global) on assign; route default straight to the global. resolve is unchanged: a profile with no record entry already falls back to the global, so default reads it for free.
This commit is contained in:
parent
395ed91891
commit
9b1e0d6f70
8 changed files with 170 additions and 49 deletions
|
|
@ -6,6 +6,7 @@ import { useI18n } from '@/i18n'
|
|||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Check, Palette } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
|
||||
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
|
||||
import { useTheme } from '@/themes/context'
|
||||
import { BUILTIN_THEMES } from '@/themes/presets'
|
||||
|
|
@ -57,8 +58,17 @@ export function AppearanceSettings() {
|
|||
const { t, isSavingLocale } = useI18n()
|
||||
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
|
||||
const toolViewMode = useStore($toolViewMode)
|
||||
const profiles = useStore($profiles)
|
||||
const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile))
|
||||
const a = t.settings.appearance
|
||||
|
||||
// Themes save per profile. Surface that only when the user actually has more
|
||||
// than one profile (single-profile installs never see the distinction).
|
||||
const showProfileNote = profiles.length > 1
|
||||
|
||||
const activeProfileName =
|
||||
profiles.find(profile => normalizeProfileKey(profile.name) === activeProfileKey)?.name ?? activeProfileKey
|
||||
|
||||
const modeOptions = MODE_OPTIONS.map(({ id, icon }) => ({ icon, id, label: t.settings.modeOptions[id].label }))
|
||||
|
||||
const toolOptions = [
|
||||
|
|
@ -98,43 +108,50 @@ export function AppearanceSettings() {
|
|||
|
||||
<ListRow
|
||||
below={
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{availableThemes.map(theme => {
|
||||
const active = themeName === theme.name
|
||||
<>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{availableThemes.map(theme => {
|
||||
const active = themeName === theme.name
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
|
||||
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
|
||||
)}
|
||||
key={theme.name}
|
||||
onClick={() => {
|
||||
triggerHaptic('crisp')
|
||||
setTheme(theme.name)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ThemePreview name={theme.name} />
|
||||
<div className="mt-3 flex items-start justify-between gap-3 px-1">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
{theme.label}
|
||||
</div>
|
||||
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{theme.description}
|
||||
</div>
|
||||
</div>
|
||||
{active && (
|
||||
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check className="size-3.5" />
|
||||
</span>
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
|
||||
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
key={theme.name}
|
||||
onClick={() => {
|
||||
triggerHaptic('crisp')
|
||||
setTheme(theme.name)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ThemePreview name={theme.name} />
|
||||
<div className="mt-3 flex items-start justify-between gap-3 px-1">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
{theme.label}
|
||||
</div>
|
||||
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{theme.description}
|
||||
</div>
|
||||
</div>
|
||||
{active && (
|
||||
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{showProfileNote && (
|
||||
<p className="mt-3 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{a.themeProfileNote(activeProfileName)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
description={a.themeDesc}
|
||||
title={a.themeTitle}
|
||||
|
|
|
|||
|
|
@ -292,7 +292,8 @@ export const en: Translations = {
|
|||
technical: 'Technical',
|
||||
technicalDesc: 'Include raw tool args/results and low-level details.',
|
||||
themeTitle: 'Theme',
|
||||
themeDesc: 'Desktop palettes only. The selected mode is applied on top.'
|
||||
themeDesc: 'Desktop palettes only. The selected mode is applied on top.',
|
||||
themeProfileNote: profile => `Saved for the ${profile} profile — each profile keeps its own theme.`
|
||||
},
|
||||
fieldLabels: FIELD_LABELS,
|
||||
fieldDescriptions: FIELD_DESCRIPTIONS,
|
||||
|
|
|
|||
|
|
@ -215,7 +215,8 @@ export const ja = defineLocale({
|
|||
technical: 'テクニカル',
|
||||
technicalDesc: '生のツール引数、結果、低レベルの詳細を含めます。',
|
||||
themeTitle: 'テーマ',
|
||||
themeDesc: 'デスクトップ専用のパレットです。選択したモードの上に適用されます。'
|
||||
themeDesc: 'デスクトップ専用のパレットです。選択したモードの上に適用されます。',
|
||||
themeProfileNote: profile => `「${profile}」プロファイルに保存されます。プロファイルごとに個別のテーマを保持します。`
|
||||
},
|
||||
fieldLabels: defineFieldCopy({
|
||||
model: 'デフォルトモデル',
|
||||
|
|
|
|||
|
|
@ -219,6 +219,7 @@ export interface Translations {
|
|||
technicalDesc: string
|
||||
themeTitle: string
|
||||
themeDesc: string
|
||||
themeProfileNote: (profile: string) => string
|
||||
}
|
||||
fieldLabels: Record<string, string>
|
||||
fieldDescriptions: Record<string, string>
|
||||
|
|
|
|||
|
|
@ -209,7 +209,8 @@ export const zhHant = defineLocale({
|
|||
technical: '技術',
|
||||
technicalDesc: '包含原始工具參數、結果與底層細節。',
|
||||
themeTitle: '主題',
|
||||
themeDesc: '僅限桌面端的調色盤。所選模式會套用在其上。'
|
||||
themeDesc: '僅限桌面端的調色盤。所選模式會套用在其上。',
|
||||
themeProfileNote: profile => `已為「${profile}」設定檔儲存——每個設定檔保留各自的主題。`
|
||||
},
|
||||
fieldLabels: defineFieldCopy({
|
||||
model: '預設模型',
|
||||
|
|
|
|||
|
|
@ -287,7 +287,8 @@ export const zh: Translations = {
|
|||
technical: '技术',
|
||||
technicalDesc: '包含原始工具参数/结果及底层细节。',
|
||||
themeTitle: '主题',
|
||||
themeDesc: '仅桌面端调色板。所选模式叠加其上。'
|
||||
themeDesc: '仅桌面端调色板。所选模式叠加其上。',
|
||||
themeProfileNote: profile => `已为「${profile}」配置文件保存——每个配置文件保留各自的主题。`
|
||||
},
|
||||
fieldLabels: defineFieldCopy({
|
||||
model: '默认模型',
|
||||
|
|
|
|||
|
|
@ -9,15 +9,28 @@
|
|||
* The two are persisted independently. Shift+X toggles light/dark.
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { matchesQuery, useMediaQuery } from '@/hooks/use-media-query'
|
||||
import { persistString, persistStringRecord, storedString, storedStringRecord } from '@/lib/storage'
|
||||
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
|
||||
import { BUILTIN_THEME_LIST, BUILTIN_THEMES, DEFAULT_SKIN_NAME, DEFAULT_TYPOGRAPHY, nousTheme } from './presets'
|
||||
import type { DesktopTheme, DesktopThemeColors } from './types'
|
||||
|
||||
// Legacy global skin (pre per-profile themes). Still the inheritance fallback
|
||||
// for any profile without its own assignment, so single-profile users and old
|
||||
// installs are unaffected.
|
||||
const SKIN_KEY = 'hermes-desktop-theme-v2'
|
||||
const MODE_KEY = 'hermes-desktop-mode-v1'
|
||||
// Per-profile skin + light/dark mode assignments: { [profileKey]: value }. A
|
||||
// profile inherits the global default until it's given its own appearance.
|
||||
const PROFILE_SKINS_KEY = 'hermes-desktop-profile-themes-v1'
|
||||
const PROFILE_MODES_KEY = 'hermes-desktop-profile-modes-v1'
|
||||
// Last active profile, recorded so the boot-time paint can pick that profile's
|
||||
// theme before the gateway reports which profile actually launched.
|
||||
const LAST_PROFILE_KEY = 'hermes-desktop-active-profile-v1'
|
||||
const RETIRED_SKINS = new Set(['nous-light', 'default', 'gold'])
|
||||
|
||||
export type ThemeMode = 'light' | 'dark' | 'system'
|
||||
|
|
@ -27,9 +40,36 @@ const INJECTED_FONT_URLS = new Set<string>()
|
|||
const resolveMode = (mode: ThemeMode, systemDark = matchesQuery('(prefers-color-scheme: dark)')): 'light' | 'dark' =>
|
||||
mode === 'system' ? (systemDark ? 'dark' : 'light') : mode
|
||||
|
||||
const normalizeSkin = (name: string | null | undefined): string =>
|
||||
const normalizeSkin = (name: string | null): string =>
|
||||
name && BUILTIN_THEMES[name] && !RETIRED_SKINS.has(name) ? name : DEFAULT_SKIN_NAME
|
||||
|
||||
const normalizeMode = (value: string | null): ThemeMode =>
|
||||
value === 'light' || value === 'dark' || value === 'system' ? value : 'light'
|
||||
|
||||
// ─── Per-profile appearance persistence ─────────────────────────────────────
|
||||
// Skin and mode are each stored per profile. "default" isn't a real profile —
|
||||
// it *is* the legacy global slot, so it reads/writes the global directly. Named
|
||||
// profiles get their own entry and fall back to that global until assigned, so
|
||||
// unassigned profiles and pre-per-profile installs stay on the global value.
|
||||
const profilePref = <T extends string>(record: string, legacy: string, normalize: (v: string | null) => T) => ({
|
||||
resolve: (profile: string): T => normalize(storedStringRecord(record)[profile] ?? storedString(legacy)),
|
||||
assign: (profile: string, value: T): void => {
|
||||
if (profile === 'default') {
|
||||
persistString(legacy, value)
|
||||
} else {
|
||||
persistStringRecord(record, { ...storedStringRecord(record), [profile]: value })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const skinPref = profilePref(PROFILE_SKINS_KEY, SKIN_KEY, normalizeSkin)
|
||||
export const modePref = profilePref(PROFILE_MODES_KEY, MODE_KEY, normalizeMode)
|
||||
|
||||
// Last active profile — lets the boot paint pick its appearance before the
|
||||
// gateway reports which profile actually launched.
|
||||
const readBootProfileKey = () => normalizeProfileKey(storedString(LAST_PROFILE_KEY))
|
||||
const rememberActiveProfileKey = (profile: string) => persistString(LAST_PROFILE_KEY, profile)
|
||||
|
||||
// ─── Color math (for synthesised light variants of dark-only skins) ────────
|
||||
|
||||
function hexToRgb(hex: string): [number, number, number] | null {
|
||||
|
|
@ -231,12 +271,13 @@ function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') {
|
|||
}
|
||||
}
|
||||
|
||||
// Boot-time paint to avoid a flash before <ThemeProvider> mounts.
|
||||
// Boot-time paint to avoid a flash before <ThemeProvider> mounts. Use the last
|
||||
// active profile's appearance so a non-default profile relaunch paints its own
|
||||
// skin + light/dark mode.
|
||||
if (typeof window !== 'undefined') {
|
||||
const skin = normalizeSkin(window.localStorage.getItem(SKIN_KEY))
|
||||
const mode = (window.localStorage.getItem(MODE_KEY) as ThemeMode) ?? 'light'
|
||||
const resolved = resolveMode(mode)
|
||||
applyTheme(deriveTheme(skin, resolved), resolved)
|
||||
const profile = readBootProfileKey()
|
||||
const resolved = resolveMode(modePref.resolve(profile))
|
||||
applyTheme(deriveTheme(skinPref.resolve(profile), resolved), resolved)
|
||||
}
|
||||
|
||||
// ─── Context ────────────────────────────────────────────────────────────────
|
||||
|
|
@ -264,29 +305,46 @@ const ThemeContext = createContext<ThemeContextValue>({
|
|||
})
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
// Skin + mode are assigned per profile; the active profile drives which
|
||||
// appearance shows. Single-profile users only ever see "default", so their
|
||||
// behavior is unchanged.
|
||||
const profileKey = normalizeProfileKey(useStore($activeGatewayProfile))
|
||||
|
||||
const [themeName, setThemeNameState] = useState(() =>
|
||||
typeof window === 'undefined' ? DEFAULT_SKIN_NAME : normalizeSkin(window.localStorage.getItem(SKIN_KEY))
|
||||
typeof window === 'undefined' ? DEFAULT_SKIN_NAME : skinPref.resolve(readBootProfileKey())
|
||||
)
|
||||
|
||||
const [mode, setModeState] = useState<ThemeMode>(() =>
|
||||
typeof window === 'undefined' ? 'light' : ((window.localStorage.getItem(MODE_KEY) as ThemeMode) ?? 'light')
|
||||
typeof window === 'undefined' ? 'light' : modePref.resolve(readBootProfileKey())
|
||||
)
|
||||
|
||||
// Follow profile switches: paint the profile's assigned skin + mode and
|
||||
// remember it for the next boot's first paint.
|
||||
useEffect(() => {
|
||||
rememberActiveProfileKey(profileKey)
|
||||
setThemeNameState(skinPref.resolve(profileKey))
|
||||
setModeState(modePref.resolve(profileKey))
|
||||
}, [profileKey])
|
||||
|
||||
const systemDark = useMediaQuery('(prefers-color-scheme: dark)')
|
||||
const resolvedMode = resolveMode(mode, systemDark)
|
||||
const activeTheme = useMemo(() => deriveTheme(themeName, resolvedMode), [themeName, resolvedMode])
|
||||
|
||||
useEffect(() => applyTheme(activeTheme, resolvedMode), [activeTheme, resolvedMode])
|
||||
|
||||
// Assign to whichever profile is live right now (read fresh so the callbacks
|
||||
// stay stable across profile switches).
|
||||
const liveProfile = () => normalizeProfileKey($activeGatewayProfile.get())
|
||||
|
||||
const setTheme = useCallback((name: string) => {
|
||||
const next = normalizeSkin(name)
|
||||
setThemeNameState(next)
|
||||
window.localStorage.setItem(SKIN_KEY, next)
|
||||
skinPref.assign(liveProfile(), next)
|
||||
}, [])
|
||||
|
||||
const setMode = useCallback((next: ThemeMode) => {
|
||||
setModeState(next)
|
||||
window.localStorage.setItem(MODE_KEY, next)
|
||||
modePref.assign(liveProfile(), next)
|
||||
}, [])
|
||||
|
||||
// The light/dark toggle (Shift+X by default) is owned by the keybind runtime
|
||||
|
|
|
|||
41
apps/desktop/src/themes/profile-theme.test.ts
Normal file
41
apps/desktop/src/themes/profile-theme.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { modePref, skinPref } from './context'
|
||||
import { DEFAULT_SKIN_NAME } from './presets'
|
||||
|
||||
// Skin and mode share one per-profile contract, so assert it once over both.
|
||||
interface Pref {
|
||||
resolve: (profile: string) => string
|
||||
assign: (profile: string, value: string) => void
|
||||
}
|
||||
|
||||
const cases = [
|
||||
{ name: 'skin', pref: skinPref as unknown as Pref, fallback: DEFAULT_SKIN_NAME, a: 'ember', b: 'midnight', junk: 'nope' },
|
||||
{ name: 'mode', pref: modePref as unknown as Pref, fallback: 'light', a: 'dark', b: 'system', junk: 'dusk' }
|
||||
]
|
||||
|
||||
describe.each(cases)('per-profile $name', ({ pref, fallback, a, b, junk }) => {
|
||||
beforeEach(() => window.localStorage.clear())
|
||||
|
||||
it('falls back to the default when unassigned', () => {
|
||||
expect(pref.resolve('default')).toBe(fallback)
|
||||
expect(pref.resolve('work')).toBe(fallback)
|
||||
})
|
||||
|
||||
it('keeps each profile on its own value', () => {
|
||||
pref.assign('work', a)
|
||||
pref.assign('default', b)
|
||||
expect(pref.resolve('work')).toBe(a)
|
||||
expect(pref.resolve('default')).toBe(b)
|
||||
})
|
||||
|
||||
it('lets unassigned profiles inherit the default profile as the global fallback', () => {
|
||||
pref.assign('default', a)
|
||||
expect(pref.resolve('never-themed')).toBe(a)
|
||||
})
|
||||
|
||||
it('normalizes an unknown stored value back to the default', () => {
|
||||
pref.assign('work', junk)
|
||||
expect(pref.resolve('work')).toBe(fallback)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue