From 58f5a05655c58b747cb1b6d4ce96e1586e16c049 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Jul 2026 20:37:06 -0500 Subject: [PATCH 1/4] fix(desktop): publish the profile default reasoning effort to a store The composer only seeded effort from agent.reasoning_effort when it was about to reseed the whole selection, which a sticky manual model pick skips. The configured value was read and discarded, leaving every other surface with no way to resolve the profile default. Mirror it into $defaultReasoningEffort on every config load, independent of the composer reseed, so the manual pick stays sticky without hiding the default from the surfaces that need it. --- .../session/hooks/use-hermes-config.test.ts | 26 ++++++++++++++++++- .../app/session/hooks/use-hermes-config.ts | 7 +++++ apps/desktop/src/store/session.ts | 12 +++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/app/session/hooks/use-hermes-config.test.ts b/apps/desktop/src/app/session/hooks/use-hermes-config.test.ts index 23e0f5a74c81..a5ec2060bf52 100644 --- a/apps/desktop/src/app/session/hooks/use-hermes-config.test.ts +++ b/apps/desktop/src/app/session/hooks/use-hermes-config.test.ts @@ -8,11 +8,13 @@ import { $currentCwd, $currentFastMode, $currentReasoningEffort, + $defaultReasoningEffort, markComposerSelectionManual, setCurrentCwd, setCurrentFastMode, setCurrentModelSource, - setCurrentReasoningEffort + setCurrentReasoningEffort, + setDefaultReasoningEffort } from '@/store/session' import { useHermesConfig } from './use-hermes-config' @@ -44,9 +46,31 @@ describe('useHermesConfig refreshHermesConfig', () => { setCurrentFastMode(false) setCurrentModelSource('') setCurrentReasoningEffort('') + setDefaultReasoningEffort('') persistString(WORKSPACE_CWD_KEY, null) }) + // Regression: the composer keeps a manual model pick sticky, which skips the + // composer reseed. The profile default must still be published, because the + // model picker resolves "the default effort" from it when applying a model's + // preset — otherwise selecting a model silently downgrades a configured + // `agent.reasoning_effort: high` to Hermes' built-in medium. + it('publishes the profile default effort even when a manual pick blocks the composer reseed', async () => { + setCurrentModelSource('manual') + setCurrentReasoningEffort('low') + + mockConfig({ agent: { reasoning_effort: 'high' } }) + const { result } = renderHook(() => useHermesConfig({ activeSessionIdRef: { current: null } })) + + await act(async () => { + await result.current.refreshHermesConfig() + }) + + expect($defaultReasoningEffort.get()).toBe('high') + // The manual pick itself is still respected. + expect($currentReasoningEffort.get()).toBe('low') + }) + it('does not let terminal.cwd replace an inactive selected workspace', async () => { setCurrentCwd('/Users/example/repo/.worktrees/feature') diff --git a/apps/desktop/src/app/session/hooks/use-hermes-config.ts b/apps/desktop/src/app/session/hooks/use-hermes-config.ts index 5d78fb1c6c51..06f5a1393671 100644 --- a/apps/desktop/src/app/session/hooks/use-hermes-config.ts +++ b/apps/desktop/src/app/session/hooks/use-hermes-config.ts @@ -11,6 +11,7 @@ import { setCurrentPersonality, setCurrentReasoningEffort, setCurrentServiceTier, + setDefaultReasoningEffort, setIntroPersonality } from '@/store/session' import { applyAutoSpeakFromConfig } from '@/store/voice-prefs' @@ -83,6 +84,12 @@ export function useHermesConfig({ activeSessionIdRef }: HermesConfigOptions) { const reasoning = normalizeConfigEffort(config.agent?.reasoning_effort) const tier = (config.agent?.service_tier ?? '').trim() + // Publish the profile default regardless of whether the composer is + // reseeded below: picker rows and preset application resolve "the + // default" from here, so a manual model pick must not leave them + // rendering/applying Hermes' built-in medium over the user's config. + setDefaultReasoningEffort(reasoning) + const shouldSeedComposer = !activeSessionIdRef.current && getComposerSelectionGeneration() === selectionGeneration && diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index 0044a7b34a6f..f5a1a3aad4a4 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -454,6 +454,18 @@ export const setCurrentReasoningEffort = (next: Updater) => { persistString(COMPOSER_EFFORT_KEY, $currentReasoningEffort.get() || null) } +// Hermes' built-in effort when neither the surface nor the profile config +// specifies one (see VALID_REASONING_EFFORTS / parse_reasoning_effort). +export const DEFAULT_REASONING_EFFORT = 'medium' + +// The profile's `agent.reasoning_effort`, mirrored from config so surfaces that +// need to render or apply "the default" resolve the user's configured level +// instead of assuming DEFAULT_REASONING_EFFORT. Empty until config loads, and +// re-seeded on every profile switch by useHermesConfig. +export const $defaultReasoningEffort = atom('') + +export const setDefaultReasoningEffort = (next: string) => updateAtom($defaultReasoningEffort, next) + export const setCurrentServiceTier = (next: Updater) => updateAtom($currentServiceTier, next) export const setCurrentFastMode = (next: Updater) => { From e2122f22b89bd186a5458943af16275fe2fa973e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Jul 2026 20:37:09 -0500 Subject: [PATCH 2/4] fix(desktop): stop the model picker overriding the configured effort Selecting a model applied 'preset.effort ?? medium', so a user running agent.reasoning_effort: high was silently downgraded to medium on every model switch and every new chat that inherited the pick. The Thinking toggle and the effort radio group defaulted to the same literal. Resolve all of them from the profile default instead, falling back to DEFAULT_REASONING_EFFORT only when config has not loaded. --- .../src/app/shell/model-edit-submenu.tsx | 27 ++++++++++++------- .../src/app/shell/model-menu-panel.tsx | 6 +++-- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/app/shell/model-edit-submenu.tsx b/apps/desktop/src/app/shell/model-edit-submenu.tsx index 742ed304b65b..ab141d71744b 100644 --- a/apps/desktop/src/app/shell/model-edit-submenu.tsx +++ b/apps/desktop/src/app/shell/model-edit-submenu.tsx @@ -16,7 +16,13 @@ import { useI18n } from '@/i18n' import { normalize } from '@/lib/text' import { setModelPreset } from '@/store/model-presets' import { notifyError } from '@/store/notifications' -import { markComposerSelectionManual, setCurrentFastMode, setCurrentReasoningEffort } from '@/store/session' +import { + $defaultReasoningEffort, + DEFAULT_REASONING_EFFORT, + markComposerSelectionManual, + setCurrentFastMode, + setCurrentReasoningEffort +} from '@/store/session' import { sessionTileDelegate } from '@/store/session-states' // Hermes' real reasoning levels (see VALID_REASONING_EFFORTS); `none` is owned @@ -111,8 +117,9 @@ export function ModelEditSubmenu({ const activeSessionId = useStore(view.$runtimeId) const touchesPrimary = view.kind === 'primary' - const effortValue = normalizeEffort(effort) - const thinkingOn = isThinkingEnabled(effort) + const defaultEffort = useStore($defaultReasoningEffort) || DEFAULT_REASONING_EFFORT + const effortValue = normalizeEffort(effort, defaultEffort) + const thinkingOn = isThinkingEnabled(effort, defaultEffort) // Editing always records the model's global preset (keyed by provider::model, // not per-surface — a tile edit re-applies to that model everywhere); the @@ -224,7 +231,7 @@ export function ModelEditSubmenu({ void patchReasoning(checked ? effortValue || 'medium' : 'none')} + onCheckedChange={checked => void patchReasoning(checked ? effortValue || defaultEffort : 'none')} size="xs" /> @@ -259,18 +266,18 @@ export function ModelEditSubmenu({ ) } -function isThinkingEnabled(effort: string): boolean { - // Empty = Hermes default (medium) = on; only an explicit "none" is off. - return normalize(effort || 'medium') !== 'none' +function isThinkingEnabled(effort: string, fallback: string): boolean { + // Empty = the profile default = on; only an explicit "none" is off. + return normalize(effort || fallback) !== 'none' } -function normalizeEffort(effort: string): string { - const value = normalize(effort || 'medium') +function normalizeEffort(effort: string, fallback: string): string { + const value = normalize(effort || fallback) // Thinking off → no effort selected in the radio group. if (value === 'none') { return '' } - return EFFORT_OPTIONS.some(option => option.value === value) ? value : 'medium' + return EFFORT_OPTIONS.some(option => option.value === value) ? value : DEFAULT_REASONING_EFFORT } diff --git a/apps/desktop/src/app/shell/model-menu-panel.tsx b/apps/desktop/src/app/shell/model-menu-panel.tsx index 9c3c60e5cdaf..6affa652c84e 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.tsx @@ -39,6 +39,7 @@ import { setModelVisibilityOpen } from '@/store/model-visibility' import { $collapsedProviders, toggleCollapsedProvider } from '@/store/provider-collapse' +import { $defaultReasoningEffort, DEFAULT_REASONING_EFFORT } from '@/store/session' import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes' import { ModelEditSubmenu, resolveFastControl } from './model-edit-submenu' @@ -84,6 +85,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re const currentProvider = useStore(view.$provider) const currentReasoningEffort = useStore(view.$reasoningEffort) const modelPresets = useStore($modelPresets) + const defaultEffort = useStore($defaultReasoningEffort) || DEFAULT_REASONING_EFFORT const visibleModels = useStore($visibleModels) const collapsedProviders = useStore($collapsedProviders) @@ -181,7 +183,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re await applyModelPreset( { - effort: (caps?.reasoning ?? true) ? (preset.effort ?? 'medium') : undefined, + effort: (caps?.reasoning ?? true) ? (preset.effort ?? defaultEffort) : undefined, fast: (caps?.fast ?? false) ? (preset.fast ?? false) : undefined }, { @@ -300,7 +302,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re const meta = [ fastControl.kind !== 'none' && fastControl.on ? copy.fast : null, - (caps?.reasoning ?? true) ? reasoningEffortLabel(effEffort) || copy.medium : null + (caps?.reasoning ?? true) ? reasoningEffortLabel(effEffort || defaultEffort) : null ] .filter(Boolean) .join(' ') From be1edef5ce93a55ce94cb4843cfa50b408205358 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Jul 2026 20:37:13 -0500 Subject: [PATCH 3/4] fix(desktop): show the real effort on the composer pill The pill hardcoded 'Med' whenever the surface had no explicit effort, so a profile configured for high advertised a level the agent would not use. Take the profile default as a fallback and drop the now-unused shell.modelMenu.medium key across the locales. --- apps/desktop/src/app/chat/composer/model-pill.tsx | 7 +++++-- apps/desktop/src/i18n/ar.ts | 3 +-- apps/desktop/src/i18n/en.ts | 3 +-- apps/desktop/src/i18n/ja.ts | 3 +-- apps/desktop/src/i18n/types.ts | 1 - apps/desktop/src/i18n/zh-hant.ts | 3 +-- apps/desktop/src/i18n/zh.ts | 3 +-- apps/desktop/src/lib/model-status-label.test.ts | 9 ++++++++- apps/desktop/src/lib/model-status-label.ts | 12 +++++++----- 9 files changed, 25 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/model-pill.tsx b/apps/desktop/src/app/chat/composer/model-pill.tsx index b5a6e976805b..2a26db4094c9 100644 --- a/apps/desktop/src/app/chat/composer/model-pill.tsx +++ b/apps/desktop/src/app/chat/composer/model-pill.tsx @@ -11,7 +11,7 @@ import { useI18n } from '@/i18n' import { ChevronDown } from '@/lib/icons' import { formatModelStatusLabel } from '@/lib/model-status-label' import { cn } from '@/lib/utils' -import { $currentModelSource, setModelPickerOpen } from '@/store/session' +import { $currentModelSource, $defaultReasoningEffort, setModelPickerOpen } from '@/store/session' import type { ChatBarState } from './types' @@ -48,6 +48,7 @@ export function ModelPill({ const fastMode = useStore(view.$fast) const reasoningEffort = useStore(view.$reasoningEffort) const modelSource = useStore($currentModelSource) + const defaultEffort = useStore($defaultReasoningEffort) const runtimeId = useStore(view.$runtimeId) const [open, setOpen] = useState(false) @@ -68,7 +69,9 @@ export function ModelPill({ ) : ( <> {currentModel.trim() ? ( - {formatModelStatusLabel(currentModel, { fastMode, reasoningEffort })} + + {formatModelStatusLabel(currentModel, { defaultEffort, fastMode, reasoningEffort })} + ) : ( )} diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 2d61c399998f..01f018d65936 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -1986,8 +1986,7 @@ export const ar = defineLocale({ noModels: 'لا توجد نماذج', editModels: 'تحرير النماذج', refreshModels: 'تحديث النماذج', - fast: 'سريع', - medium: 'متوسط' + fast: 'سريع' }, modelOptions: { noOptions: 'لا توجد خيارات لهذا النموذج', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 12d15e9657c4..25a6fa82fb61 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -2306,8 +2306,7 @@ export const en: Translations = { noModels: 'No models found', editModels: 'Edit Models…', refreshModels: 'Refresh Models', - fast: 'Fast', - medium: 'Med' + fast: 'Fast' }, modelOptions: { noOptions: 'No options for this model', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 90d65ad5901a..75cfe0feaf8a 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -2178,8 +2178,7 @@ export const ja = defineLocale({ noModels: 'モデルが見つかりません', editModels: 'モデルを編集…', refreshModels: 'モデルを更新', - fast: '高速', - medium: '中' + fast: '高速' }, modelOptions: { noOptions: 'このモデルにはオプションがありません', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 41417a4cf338..928493ec05a4 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1915,7 +1915,6 @@ export interface Translations { editModels: string refreshModels: string fast: string - medium: string } modelOptions: { noOptions: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 2772852eee51..90240c358a0e 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -2105,8 +2105,7 @@ export const zhHant = defineLocale({ noModels: '找不到模型', editModels: '編輯模型…', refreshModels: '重新整理模型', - fast: '快速', - medium: '中' + fast: '快速' }, modelOptions: { noOptions: '此模型沒有可用選項', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 68cbbb77ad4a..f9f4c92a7748 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2483,8 +2483,7 @@ export const zh: Translations = { noModels: '未找到模型', editModels: '编辑模型…', refreshModels: '刷新模型', - fast: '快速', - medium: '中' + fast: '快速' }, modelOptions: { noOptions: '此模型没有可用选项', diff --git a/apps/desktop/src/lib/model-status-label.test.ts b/apps/desktop/src/lib/model-status-label.test.ts index cc499c86d565..c0a1f867c22e 100644 --- a/apps/desktop/src/lib/model-status-label.test.ts +++ b/apps/desktop/src/lib/model-status-label.test.ts @@ -34,9 +34,16 @@ describe('model-status-label', () => { ) }) - it('always surfaces the effort (default medium) so the level is visible', () => { + it('falls back to the profile default effort, then to medium', () => { expect(formatModelStatusLabel('openai/gpt-5.5', { reasoningEffort: 'medium' })).toBe('GPT-5.5 · Med') expect(formatModelStatusLabel('openai/gpt-5.5')).toBe('GPT-5.5 · Med') + // No session-level effort → the configured profile default is advertised, + // not Hermes' built-in medium. + expect(formatModelStatusLabel('openai/gpt-5.5', { defaultEffort: 'high' })).toBe('GPT-5.5 · High') + // An explicit session effort still wins over the profile default. + expect(formatModelStatusLabel('openai/gpt-5.5', { defaultEffort: 'high', reasoningEffort: 'low' })).toBe( + 'GPT-5.5 · Low' + ) }) it('returns just the placeholder name when there is no model', () => { diff --git a/apps/desktop/src/lib/model-status-label.ts b/apps/desktop/src/lib/model-status-label.ts index a1b28b8a4c58..1c6a7850dc12 100644 --- a/apps/desktop/src/lib/model-status-label.ts +++ b/apps/desktop/src/lib/model-status-label.ts @@ -99,10 +99,12 @@ export function displayModelName(model: string): string { return modelDisplayParts(model).name } -/** Status bar trigger label — model name plus the live session state (effort/fast). */ +/** Status bar trigger label — model name plus the live session state (effort/fast). + * `defaultEffort` is the profile's configured level, used when the surface has + * no explicit effort so the label never advertises a default the agent won't use. */ export function formatModelStatusLabel( model: string, - options?: { fastMode?: boolean; reasoningEffort?: string } + options?: { defaultEffort?: string; fastMode?: boolean; reasoningEffort?: string } ): string { const name = displayModelName(model) @@ -118,9 +120,9 @@ export function formatModelStatusLabel( parts.push('Fast') } - // Always surface the effort (empty = Hermes default of medium) so the - // current reasoning level is visible at a glance, not just when non-default. - parts.push(reasoningEffortLabel(options?.reasoningEffort ?? '') || 'Med') + // Always surface the effort so the current reasoning level is visible at a + // glance, not just when non-default. + parts.push(reasoningEffortLabel(options?.reasoningEffort || options?.defaultEffort || 'medium')) return `${name} · ${parts.join(' ')}` } From 43d1088ba2c3a901c6aa98ccffc0e8abafffcec9 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Jul 2026 20:51:18 -0500 Subject: [PATCH 4/4] refactor(desktop): give reasoning effort one owner The seven effort levels were enumerated four times in four shapes: a labels map, a radio-option list, a settings value array, and an enum list for the config field. Each carried its own hardcoded medium fallback, which is how the picker drifted from the configured default in the first place. Collapse them into lib/reasoning-effort, which owns the scale, the labels, and the resolve/fallback rules. Net -80/+26 in source. Dropping the effortLabelKey cast also means the i18n keys are now type-checked against the canonical list instead of asserted into place. --- apps/desktop/src/app/settings/constants.ts | 4 +- .../src/app/settings/model-settings.tsx | 14 ++--- .../src/app/shell/model-edit-submenu.tsx | 45 +++++----------- .../src/app/shell/model-menu-panel.tsx | 10 ++-- .../src/lib/model-status-label.test.ts | 8 +-- apps/desktop/src/lib/model-status-label.ts | 25 +-------- apps/desktop/src/lib/reasoning-effort.test.ts | 53 ++++++++++++++++++ apps/desktop/src/lib/reasoning-effort.ts | 54 +++++++++++++++++++ apps/desktop/src/store/session.ts | 8 +-- 9 files changed, 135 insertions(+), 86 deletions(-) create mode 100644 apps/desktop/src/lib/reasoning-effort.test.ts create mode 100644 apps/desktop/src/lib/reasoning-effort.ts diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts index 2ecdfb1e7e44..f041b2992470 100644 --- a/apps/desktop/src/app/settings/constants.ts +++ b/apps/desktop/src/app/settings/constants.ts @@ -11,6 +11,7 @@ import { Sun, Wrench } from '@/lib/icons' +import { REASONING_EFFORTS } from '@/lib/reasoning-effort' import type { ThemeMode } from '@/themes/context' import { defineFieldCopy } from './field-copy' @@ -246,7 +247,8 @@ export const ENUM_OPTIONS: Record = { 'approvals.mode': ['manual', 'smart', 'off'], 'code_execution.mode': ['project', 'strict'], 'context.engine': ['compressor', 'default', 'custom'], - 'delegation.reasoning_effort': ['', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'], + // '' = inherit the agent's own effort; the rest is the shared scale. + 'delegation.reasoning_effort': ['', ...REASONING_EFFORTS], // NOTE: memory.provider is intentionally NOT listed here. Its options are // discovery-driven and served by the backend config schema (merged // per-request in web_server._schema_with_dynamic_provider_options), so diff --git a/apps/desktop/src/app/settings/model-settings.tsx b/apps/desktop/src/app/settings/model-settings.tsx index 9cc9838b9d94..3b1b8997ab06 100644 --- a/apps/desktop/src/app/settings/model-settings.tsx +++ b/apps/desktop/src/app/settings/model-settings.tsx @@ -25,6 +25,7 @@ import type { } from '@/hermes' import { useI18n } from '@/i18n' import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons' +import { DEFAULT_REASONING_EFFORT, REASONING_EFFORT_VALUES } from '@/lib/reasoning-effort' import { cn } from '@/lib/utils' import { notifyError } from '@/store/notifications' import { startManualLocalEndpoint, startManualOnboarding, startManualProviderOAuth } from '@/store/onboarding' @@ -81,10 +82,6 @@ export function ModelSettingsSkeleton() { ) } -// Hermes' reasoning levels (VALID_REASONING_EFFORTS); `none` = thinking off. -// Empty config = Hermes default (medium), shown as Medium. -const EFFORT_VALUES = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'] as const - // agent.service_tier stores "fast"/"priority"/"on" for fast; anything else is // normal (mirrors tui_gateway _load_service_tier). const isFastTier = (tier: unknown): boolean => @@ -94,9 +91,6 @@ const isFastTier = (tier: unknown): boolean => .toLowerCase() ) -// Reuse the composer's effort labels. -const effortLabelKey = (v: string) => v as 'high' | 'low' | 'max' | 'medium' | 'minimal' | 'ultra' | 'xhigh' - // A provider row is "ready" to pick a model from when it reports models. The // backend now surfaces the full `hermes model` universe (every canonical // provider), so unconfigured providers come back with `authenticated:false` @@ -515,7 +509,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { .trim() .toLowerCase() - const effortValue = rawEffort === 'false' || rawEffort === 'disabled' ? 'none' : rawEffort || 'medium' + const effortValue = rawEffort === 'false' || rawEffort === 'disabled' ? 'none' : rawEffort || DEFAULT_REASONING_EFFORT const fastOn = isFastTier(getNested(config ?? {}, 'agent.service_tier')) @@ -822,9 +816,9 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { - {EFFORT_VALUES.map(value => ( + {REASONING_EFFORT_VALUES.map(value => ( - {value === 'none' ? m.reasoningOff : t.shell.modelOptions[effortLabelKey(value)]} + {value === 'none' ? m.reasoningOff : t.shell.modelOptions[value]} ))} diff --git a/apps/desktop/src/app/shell/model-edit-submenu.tsx b/apps/desktop/src/app/shell/model-edit-submenu.tsx index ab141d71744b..53c419937ffc 100644 --- a/apps/desktop/src/app/shell/model-edit-submenu.tsx +++ b/apps/desktop/src/app/shell/model-edit-submenu.tsx @@ -13,29 +13,24 @@ import { } from '@/components/ui/dropdown-menu' import { Switch } from '@/components/ui/switch' import { useI18n } from '@/i18n' -import { normalize } from '@/lib/text' +import { + DEFAULT_REASONING_EFFORT, + isThinkingEnabled, + REASONING_EFFORTS, + resolveReasoningEffort +} from '@/lib/reasoning-effort' import { setModelPreset } from '@/store/model-presets' import { notifyError } from '@/store/notifications' import { $defaultReasoningEffort, - DEFAULT_REASONING_EFFORT, markComposerSelectionManual, setCurrentFastMode, setCurrentReasoningEffort } from '@/store/session' import { sessionTileDelegate } from '@/store/session-states' -// Hermes' real reasoning levels (see VALID_REASONING_EFFORTS); `none` is owned +// Hermes' real reasoning levels live in lib/reasoning-effort; `none` is owned // by the Thinking toggle, not the radio. -const EFFORT_OPTIONS = [ - { value: 'minimal', labelKey: 'minimal' }, - { value: 'low', labelKey: 'low' }, - { value: 'medium', labelKey: 'medium' }, - { value: 'high', labelKey: 'high' }, - { value: 'xhigh', labelKey: 'xhigh' }, - { value: 'max', labelKey: 'max' }, - { value: 'ultra', labelKey: 'ultra' } -] as const /** How "fast" is achieved for a given model — two different mechanisms: * - `param`: the Anthropic/OpenAI `speed=fast` request parameter. @@ -118,7 +113,7 @@ export function ModelEditSubmenu({ const touchesPrimary = view.kind === 'primary' const defaultEffort = useStore($defaultReasoningEffort) || DEFAULT_REASONING_EFFORT - const effortValue = normalizeEffort(effort, defaultEffort) + const effortValue = resolveReasoningEffort(effort, defaultEffort) const thinkingOn = isThinkingEnabled(effort, defaultEffort) // Editing always records the model's global preset (keyed by provider::model, @@ -247,14 +242,14 @@ export function ModelEditSubmenu({ {copy.effort} void patchReasoning(value)} value={effortValue}> - {EFFORT_OPTIONS.map(option => ( + {REASONING_EFFORTS.map(value => ( event.preventDefault()} - value={option.value} + value={value} > - {copy[option.labelKey]} + {copy[value]} ))} @@ -265,19 +260,3 @@ export function ModelEditSubmenu({ ) } - -function isThinkingEnabled(effort: string, fallback: string): boolean { - // Empty = the profile default = on; only an explicit "none" is off. - return normalize(effort || fallback) !== 'none' -} - -function normalizeEffort(effort: string, fallback: string): string { - const value = normalize(effort || fallback) - - // Thinking off → no effort selected in the radio group. - if (value === 'none') { - return '' - } - - return EFFORT_OPTIONS.some(option => option.value === value) ? value : DEFAULT_REASONING_EFFORT -} diff --git a/apps/desktop/src/app/shell/model-menu-panel.tsx b/apps/desktop/src/app/shell/model-menu-panel.tsx index 6affa652c84e..c7fa0246cf72 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.tsx @@ -20,12 +20,8 @@ import type { HermesGateway } from '@/hermes' import { useI18n } from '@/i18n' import { ChevronDown, ChevronRight } from '@/lib/icons' import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options' -import { - currentPickerSelection, - displayModelName, - modelDisplayParts, - reasoningEffortLabel -} from '@/lib/model-status-label' +import { currentPickerSelection, displayModelName, modelDisplayParts } from '@/lib/model-status-label' +import { DEFAULT_REASONING_EFFORT, reasoningEffortLabel } from '@/lib/reasoning-effort' import { normalize } from '@/lib/text' import { cn } from '@/lib/utils' import { $modelPresets, applyModelPreset, modelPresetKey } from '@/store/model-presets' @@ -39,7 +35,7 @@ import { setModelVisibilityOpen } from '@/store/model-visibility' import { $collapsedProviders, toggleCollapsedProvider } from '@/store/provider-collapse' -import { $defaultReasoningEffort, DEFAULT_REASONING_EFFORT } from '@/store/session' +import { $defaultReasoningEffort } from '@/store/session' import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes' import { ModelEditSubmenu, resolveFastControl } from './model-edit-submenu' diff --git a/apps/desktop/src/lib/model-status-label.test.ts b/apps/desktop/src/lib/model-status-label.test.ts index c0a1f867c22e..f21c1f5f1d34 100644 --- a/apps/desktop/src/lib/model-status-label.test.ts +++ b/apps/desktop/src/lib/model-status-label.test.ts @@ -1,11 +1,7 @@ import { describe, expect, it } from 'vitest' -import { - currentPickerSelection, - displayModelName, - formatModelStatusLabel, - reasoningEffortLabel -} from './model-status-label' +import { currentPickerSelection, displayModelName, formatModelStatusLabel } from './model-status-label' +import { reasoningEffortLabel } from './reasoning-effort' describe('model-status-label', () => { it('formats display names consistently', () => { diff --git a/apps/desktop/src/lib/model-status-label.ts b/apps/desktop/src/lib/model-status-label.ts index 1c6a7850dc12..0a1f14818504 100644 --- a/apps/desktop/src/lib/model-status-label.ts +++ b/apps/desktop/src/lib/model-status-label.ts @@ -1,25 +1,4 @@ -import { normalize } from '@/lib/text' - -const REASONING_LABELS: Record = { - none: 'Off', - minimal: 'Min', - low: 'Low', - medium: 'Med', - high: 'High', - xhigh: 'XHigh', - max: 'Max', - ultra: 'Ultra' -} - -export function reasoningEffortLabel(effort: string): string { - const key = normalize(effort) - - if (!key) { - return '' - } - - return REASONING_LABELS[key] ?? effort -} +import { DEFAULT_REASONING_EFFORT, reasoningEffortLabel } from '@/lib/reasoning-effort' /** Which model/provider a picker should mark "current". With a live session the * gateway's `model.options` is authoritative; pre-session there is no server @@ -122,7 +101,7 @@ export function formatModelStatusLabel( // Always surface the effort so the current reasoning level is visible at a // glance, not just when non-default. - parts.push(reasoningEffortLabel(options?.reasoningEffort || options?.defaultEffort || 'medium')) + parts.push(reasoningEffortLabel(options?.reasoningEffort || options?.defaultEffort || DEFAULT_REASONING_EFFORT)) return `${name} · ${parts.join(' ')}` } diff --git a/apps/desktop/src/lib/reasoning-effort.test.ts b/apps/desktop/src/lib/reasoning-effort.test.ts new file mode 100644 index 000000000000..bc61b91fb9b3 --- /dev/null +++ b/apps/desktop/src/lib/reasoning-effort.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' + +import { + DEFAULT_REASONING_EFFORT, + isReasoningEffort, + isThinkingEnabled, + REASONING_EFFORT_VALUES, + REASONING_EFFORTS, + reasoningEffortLabel, + resolveReasoningEffort +} from './reasoning-effort' + +describe('reasoning-effort', () => { + it('keeps the scale ascending and `none` off it', () => { + expect(REASONING_EFFORTS).not.toContain('none') + expect(REASONING_EFFORT_VALUES[0]).toBe('none') + expect(REASONING_EFFORT_VALUES).toHaveLength(REASONING_EFFORTS.length + 1) + }) + + it('labels every level it claims to support', () => { + for (const effort of REASONING_EFFORT_VALUES) { + expect(reasoningEffortLabel(effort)).not.toBe('') + } + + expect(reasoningEffortLabel('')).toBe('') + // Unknown values pass through rather than silently reading as a real level. + expect(reasoningEffortLabel('bogus')).toBe('bogus') + }) + + it('recognizes only real scale levels', () => { + expect(isReasoningEffort(DEFAULT_REASONING_EFFORT)).toBe(true) + expect(isReasoningEffort('HIGH')).toBe(true) + expect(isReasoningEffort('none')).toBe(false) + expect(isReasoningEffort('bogus')).toBe(false) + }) + + it('treats empty as inherit and only `none` as off', () => { + expect(isThinkingEnabled('none')).toBe(false) + expect(isThinkingEnabled('high')).toBe(true) + // Empty inherits the fallback, so an off fallback reads as off. + expect(isThinkingEnabled('', 'none')).toBe(false) + expect(isThinkingEnabled('', 'high')).toBe(true) + }) + + it('resolves a scale value: inherit, off, or clamp', () => { + expect(resolveReasoningEffort('high')).toBe('high') + // Empty inherits the profile default rather than snapping to medium. + expect(resolveReasoningEffort('', 'ultra')).toBe('ultra') + // Off selects nothing on the scale. + expect(resolveReasoningEffort('none')).toBe('') + expect(resolveReasoningEffort('bogus')).toBe(DEFAULT_REASONING_EFFORT) + }) +}) diff --git a/apps/desktop/src/lib/reasoning-effort.ts b/apps/desktop/src/lib/reasoning-effort.ts new file mode 100644 index 000000000000..5d3e17afd050 --- /dev/null +++ b/apps/desktop/src/lib/reasoning-effort.ts @@ -0,0 +1,54 @@ +import { normalize } from '@/lib/text' + +/** Hermes' reasoning levels, in ascending order — mirrors the backend's + * VALID_REASONING_EFFORTS (hermes_constants.py). `none` is not a level: it's + * thinking disabled, owned by the Thinking toggle rather than the scale. */ +export const REASONING_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'] as const + +export type ReasoningEffort = (typeof REASONING_EFFORTS)[number] + +/** The scale plus the off state — the full set a config value may hold. */ +export const REASONING_EFFORT_VALUES = ['none', ...REASONING_EFFORTS] as const + +/** Hermes' built-in level when neither the surface nor the profile config + * specifies one (mirrors the backend's own fallback). */ +export const DEFAULT_REASONING_EFFORT: ReasoningEffort = 'medium' + +/** Compact labels for chrome where space is tight (pill, picker rows). Menus + * and settings use the translated `shell.modelOptions` strings instead. */ +const SHORT_LABELS: Record = { + none: 'Off', + minimal: 'Min', + low: 'Low', + medium: 'Med', + high: 'High', + xhigh: 'XHigh', + max: 'Max', + ultra: 'Ultra' +} + +export function reasoningEffortLabel(effort: string): string { + const key = normalize(effort) + + return key ? (SHORT_LABELS[key] ?? effort) : '' +} + +export const isReasoningEffort = (value: string): value is ReasoningEffort => + REASONING_EFFORTS.includes(normalize(value) as ReasoningEffort) + +/** Thinking is on unless a level explicitly says otherwise; an empty value + * means "inherit", so it resolves through `fallback` first. */ +export const isThinkingEnabled = (effort: string, fallback: string = DEFAULT_REASONING_EFFORT): boolean => + normalize(effort || fallback) !== 'none' + +/** The level a scale control should show. Empty inherits `fallback`; `none` + * (thinking off) selects nothing; anything unrecognized clamps to the default. */ +export function resolveReasoningEffort(effort: string, fallback: string = DEFAULT_REASONING_EFFORT): string { + const value = normalize(effort || fallback) + + if (value === 'none') { + return '' + } + + return isReasoningEffort(value) ? value : DEFAULT_REASONING_EFFORT +} diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index f5a1a3aad4a4..31ea5ff832a2 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -454,14 +454,10 @@ export const setCurrentReasoningEffort = (next: Updater) => { persistString(COMPOSER_EFFORT_KEY, $currentReasoningEffort.get() || null) } -// Hermes' built-in effort when neither the surface nor the profile config -// specifies one (see VALID_REASONING_EFFORTS / parse_reasoning_effort). -export const DEFAULT_REASONING_EFFORT = 'medium' - // The profile's `agent.reasoning_effort`, mirrored from config so surfaces that // need to render or apply "the default" resolve the user's configured level -// instead of assuming DEFAULT_REASONING_EFFORT. Empty until config loads, and -// re-seeded on every profile switch by useHermesConfig. +// instead of assuming DEFAULT_REASONING_EFFORT (lib/reasoning-effort). Empty +// until config loads, and re-seeded on every profile switch by useHermesConfig. export const $defaultReasoningEffort = atom('') export const setDefaultReasoningEffort = (next: string) => updateAtom($defaultReasoningEffort, next)