From 43d1088ba2c3a901c6aa98ccffc0e8abafffcec9 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Jul 2026 20:51:18 -0500 Subject: [PATCH] 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)