feat(desktop): per-model effort/fast presets in the picker

Each model remembers its own reasoning effort / fast mode (localStorage,
like model-visibility): editing a model's effort/fast in the submenu
writes its preset, and selecting a model restores its preset onto the
session (capability-gated, Hermes defaults when unset). Every row shows
its own remembered settings (grayed), and the row label and edit submenu
read the same effective value so they can't disagree.

Presets are desktop-client state only — applyModelPreset() no-ops without
a live session id, so selecting a model can't fall through to the
gateway's persistent agent.reasoning_effort / agent.service_tier writes.
Inactive variant `-fast` edits stay preset-only: toggleFast() records
{ fast } on the base model and only swaps models when the row is active,
and selectFamily() honors a saved variant-fast preset by selecting the
`-fast` sibling id.
This commit is contained in:
Brooklyn Nicholson 2026-06-15 23:37:46 -05:00
parent 989d5d0cb7
commit 0e81d2fb71
5 changed files with 328 additions and 78 deletions

View file

@ -0,0 +1,84 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { DropdownMenu, DropdownMenuContent, DropdownMenuSub, DropdownMenuSubTrigger } from '@/components/ui/dropdown-menu'
import { $modelPresets, getModelPreset } from '@/store/model-presets'
import { $activeSessionId } from '@/store/session'
import { type FastControl, ModelEditSubmenu } from './model-edit-submenu'
// Radix calls these on open; jsdom doesn't implement them.
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
Element.prototype.hasPointerCapture = vi.fn(() => false)
Element.prototype.releasePointerCapture = vi.fn()
})
beforeEach(() => {
$modelPresets.set({})
$activeSessionId.set(null)
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
// Render the submenu inside an open menu/sub so its content (switches) mounts.
function renderSubmenu(opts: { fastControl: FastControl; reasoning: boolean; requestGateway: () => Promise<unknown> }) {
return render(
<DropdownMenu open>
<DropdownMenuContent>
<DropdownMenuSub open>
<DropdownMenuSubTrigger>edit</DropdownMenuSubTrigger>
<ModelEditSubmenu
effort="medium"
fastControl={opts.fastControl}
isActive
model="m1"
onSelectModel={vi.fn()}
provider="p1"
reasoning={opts.reasoning}
requestGateway={opts.requestGateway as never}
/>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
)
}
// Regression: editing the active row before a live session exists must stay
// preset-only — the gateway's config.set falls back to global config when no
// session matches, so it must not be called. (Caught in the second review.)
describe('ModelEditSubmenu no-session guard', () => {
it('param fast: records the preset but skips the gateway without a session', () => {
const requestGateway = vi.fn().mockResolvedValue({})
renderSubmenu({ fastControl: { kind: 'param', on: false }, reasoning: false, requestGateway })
fireEvent.click(screen.getByRole('switch'))
expect(getModelPreset('p1', 'm1').fast).toBe(true)
expect(requestGateway).not.toHaveBeenCalled()
})
it('reasoning: records the preset but skips the gateway without a session', () => {
const requestGateway = vi.fn().mockResolvedValue({})
renderSubmenu({ fastControl: { kind: 'none' }, reasoning: true, requestGateway })
// Thinking starts on (medium); toggling it off routes through patchReasoning.
fireEvent.click(screen.getByRole('switch'))
expect(getModelPreset('p1', 'm1').effort).toBe('none')
expect(requestGateway).not.toHaveBeenCalled()
})
it('param fast: pushes to the gateway once a session is active', async () => {
const requestGateway = vi.fn().mockResolvedValue({})
$activeSessionId.set('sess1')
renderSubmenu({ fastControl: { kind: 'param', on: false }, reasoning: false, requestGateway })
fireEvent.click(screen.getByRole('switch'))
expect(requestGateway).toHaveBeenCalledWith('config.set', { key: 'fast', session_id: 'sess1', value: 'fast' })
})
})

View file

@ -12,13 +12,9 @@ import {
} from '@/components/ui/dropdown-menu'
import { Switch } from '@/components/ui/switch'
import { useI18n } from '@/i18n'
import { setModelPreset } from '@/store/model-presets'
import { notifyError } from '@/store/notifications'
import {
$activeSessionId,
$currentReasoningEffort,
setCurrentFastMode,
setCurrentReasoningEffort
} from '@/store/session'
import { $activeSessionId, setCurrentFastMode, setCurrentReasoningEffort } from '@/store/session'
// Hermes' real reasoning levels (see VALID_REASONING_EFFORTS); `none` is owned
// by the Thinking toggle, not the radio.
@ -76,96 +72,104 @@ export function resolveFastControl(
}
interface ModelEditSubmenuProps {
/** This row's effective reasoning effort (live for the active model, else its
* preset) the submenu shows and edits from this, never the raw session. */
effort: string
/** How fast mode is offered for this model (param toggle vs. variant swap). */
fastControl: FastControl
/** Whether this row's model is the active one. */
isActive: boolean
/** Switch to this model (resolves false on failure). Awaited before applying
* edits when not active so a failed switch doesn't write to the old model. */
onActivate: () => Promise<boolean> | void
/** This row's model id — edits persist as its global preset. */
model: string
/** Switch to a specific model id (used to swap base ⇄ -fast variant). */
onSelectModel: (model: string) => Promise<boolean> | void
/** This row's provider slug — edits persist as its global preset. */
provider: string
/** Whether this model supports reasoning effort. */
reasoning: boolean
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}
export function ModelEditSubmenu({
effort,
fastControl,
isActive,
onActivate,
model,
onSelectModel,
provider,
reasoning,
requestGateway
}: ModelEditSubmenuProps) {
const { t } = useI18n()
const copy = t.shell.modelOptions
// Reactive session state comes straight from the stores rather than being
// drilled through the panel, so editing it re-renders only this submenu.
const activeSessionId = useStore($activeSessionId)
const currentReasoningEffort = useStore($currentReasoningEffort)
const effort = normalizeEffort(currentReasoningEffort)
const thinkingOn = isThinkingEnabled(currentReasoningEffort)
const effortValue = normalizeEffort(effort)
const thinkingOn = isThinkingEnabled(effort)
// Reasoning/fast are session-scoped (they apply to the active model), so
// editing a non-active model first switches to it. Returns false if the
// switch failed, so callers skip applying to the wrong (previous) model.
const ensureActive = async (): Promise<boolean> => {
if (isActive) {
return true
// Editing always records the model's global preset; the active model also gets
// it pushed onto the live session. Non-active edits stay preset-only — they do
// not switch you to that model.
const patchReasoning = async (next: string) => {
setModelPreset(provider, model, { effort: next })
if (!isActive) {
return
}
return (await onActivate()) !== false
}
const patchReasoning = async (next: string, rollback: string) => {
setCurrentReasoningEffort(next)
// Preset-only without a session: `isActive` holds for the global/default
// row pre-session, and the gateway's `config.set` falls back to global
// config when none matches — so don't reach it (preset + optimistic store
// are the whole effect). Same guard in applyModelPreset / toggleFast.
if (!activeSessionId) {
return
}
try {
if (!(await ensureActive())) {
setCurrentReasoningEffort(rollback)
return
}
await requestGateway('config.set', {
key: 'reasoning',
session_id: activeSessionId ?? '',
value: next
})
await requestGateway('config.set', { key: 'reasoning', session_id: activeSessionId, value: next })
} catch (err) {
setCurrentReasoningEffort(rollback)
setCurrentReasoningEffort(effort)
setModelPreset(provider, model, { effort })
notifyError(err, copy.updateFailed)
}
}
const toggleFast = (enabled: boolean) => {
if (fastControl.kind === 'variant') {
// Fast is a separate model id — swap to it (or back to the base).
void onSelectModel(enabled ? fastControl.fastId : fastControl.baseId)
// Fast is a separate model id. Record the choice on the base model's
// preset (selectFamily picks the `-fast` sibling later when set), and
// only swap models now if this is the active row — inactive edits must
// stay preset-only, same as the param path below.
setModelPreset(provider, fastControl.baseId, { fast: enabled })
if (isActive) {
void onSelectModel(enabled ? fastControl.fastId : fastControl.baseId)
}
return
}
if (fastControl.kind === 'param') {
setModelPreset(provider, model, { fast: enabled })
if (!isActive) {
return
}
setCurrentFastMode(enabled)
// Preset-only without a session (see patchReasoning).
if (!activeSessionId) {
return
}
void (async () => {
try {
if (!(await ensureActive())) {
setCurrentFastMode(!enabled)
return
}
await requestGateway('config.set', {
key: 'fast',
session_id: activeSessionId ?? '',
value: enabled ? 'fast' : 'normal'
})
await requestGateway('config.set', { key: 'fast', session_id: activeSessionId, value: enabled ? 'fast' : 'normal' })
} catch (err) {
setCurrentFastMode(!enabled)
setModelPreset(provider, model, { fast: !enabled })
notifyError(err, copy.fastFailed)
}
})()
@ -188,9 +192,7 @@ export function ModelEditSubmenu({
<Switch
checked={thinkingOn}
className="ml-auto"
onCheckedChange={checked =>
void patchReasoning(checked ? effort || 'medium' : 'none', currentReasoningEffort)
}
onCheckedChange={checked => void patchReasoning(checked ? effortValue || 'medium' : 'none')}
size="xs"
/>
</DropdownMenuItem>
@ -205,10 +207,7 @@ export function ModelEditSubmenu({
<>
<DropdownMenuSeparator className="mx-0" />
<DropdownMenuLabel className={dropdownMenuSectionLabel}>{copy.effort}</DropdownMenuLabel>
<DropdownMenuRadioGroup
onValueChange={value => void patchReasoning(value, currentReasoningEffort)}
value={effort}
>
<DropdownMenuRadioGroup onValueChange={value => void patchReasoning(value)} value={effortValue}>
{EFFORT_OPTIONS.map(option => (
<DropdownMenuRadioItem
className={dropdownMenuRow}

View file

@ -20,6 +20,7 @@ import { getGlobalModelOptions } from '@/hermes'
import { useI18n } from '@/i18n'
import { displayModelName, modelDisplayParts, reasoningEffortLabel } from '@/lib/model-status-label'
import { cn } from '@/lib/utils'
import { $modelPresets, applyModelPreset, modelPresetKey } from '@/store/model-presets'
import {
$visibleModels,
collapseModelFamilies,
@ -63,6 +64,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
const currentModel = useStore($currentModel)
const currentProvider = useStore($currentProvider)
const currentReasoningEffort = useStore($currentReasoningEffort)
const modelPresets = useStore($modelPresets)
const visibleModels = useStore($visibleModels)
const modelOptions = useQuery({
@ -87,6 +89,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
: null
const providers = modelOptions.data?.providers
const effectiveVisibleModels = useMemo(
() => effectiveVisibleKeys(visibleModels, providers ?? []),
[visibleModels, providers]
@ -95,6 +98,31 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
const switchTo = (model: string, provider: string) =>
onSelectModel({ model, persistGlobal: !activeSessionId, provider })
// Selecting a model row restores that model's remembered preset onto the
// session (effort/fast), gated by capability. Unset → Hermes defaults.
const selectFamily = async (family: ModelFamily, provider: ModelOptionProvider) => {
const caps = provider.capabilities?.[family.id]
const preset = modelPresets[modelPresetKey(provider.slug, family.id)] ?? {}
// Variant-fast models (no speed param) express "fast" as a separate `-fast`
// id, so honor the saved preset by selecting that sibling. Param-fast is
// applied via applyModelPreset below instead.
const variantFast = !(caps?.fast ?? false) && !!family.fastId
const targetId = variantFast && preset.fast === true ? family.fastId! : family.id
if ((await switchTo(targetId, provider.slug)) === false) {
return
}
await applyModelPreset(
{
effort: (caps?.reasoning ?? true) ? (preset.effort ?? 'medium') : undefined,
fast: (caps?.fast ?? false) ? (preset.fast ?? false) : undefined
},
{ failMessage: t.shell.modelOptions.updateFailed, request: requestGateway, sessionId: activeSessionId }
)
}
const groups = useMemo(
() => groupModels(providers ?? [], search, { model: optionsModel, provider: optionsProvider }, effectiveVisibleModels),
[providers, search, optionsModel, optionsProvider, effectiveVisibleModels]
@ -152,36 +180,36 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
// -fast variant carries the same param support as its base.
const caps = group.provider.capabilities?.[family.id]
// Single source of truth for the active row's fast state — keeps
// the row label in lock-step with the submenu's Fast toggle and
// handles the standalone `-fast` id case.
// Effective settings for this row: live session state when it's
// the active model, otherwise its remembered preset (Hermes
// defaults when unset). Row label AND submenu read from these so
// they never disagree.
const preset = modelPresets[modelPresetKey(group.provider.slug, family.id)] ?? {}
const effEffort = isCurrent ? currentReasoningEffort : preset.effort ?? ''
const effFast = isCurrent ? currentFastMode : preset.fast ?? false
const fastControl = resolveFastControl(
activeId ?? family.id,
group.provider.models ?? [],
caps?.fast ?? false,
currentFastMode
effFast
)
// Grayed text is live session state only. Do not label inactive
// rows as "Fast" just because they have a fast-capable sibling:
// that makes an off Fast toggle look like it is already on.
const meta = isCurrent
? [
fastControl.kind !== 'none' && fastControl.on ? copy.fast : null,
reasoningEffortLabel(currentReasoningEffort) || copy.medium
]
.filter(Boolean)
.join(' ')
: ''
const meta = [
fastControl.kind !== 'none' && fastControl.on ? copy.fast : null,
(caps?.reasoning ?? true) ? reasoningEffortLabel(effEffort) || copy.medium : null
]
.filter(Boolean)
.join(' ')
// Every row is a hover-Edit submenu trigger. Activating it
// (pointer or keyboard) switches to the family's base model;
// the Fast toggle inside swaps to the -fast sibling (or flips
// the speed param). The sub-trigger has no `onSelect`, so wire
// both click and Enter/Space for keyboard parity.
// (pointer or keyboard) switches to the family's base model and
// restores its preset; the Fast toggle inside swaps to the -fast
// sibling (or flips the speed param). The sub-trigger has no
// `onSelect`, so wire both click and Enter/Space for keyboard parity.
const activate = () => {
if (!isCurrent) {
void switchTo(family.id, group.provider.slug)
void selectFamily(family, group.provider)
}
}
@ -204,10 +232,12 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
{isCurrent ? <Codicon className="ml-auto text-foreground" name="check" size="0.75rem" /> : null}
</DropdownMenuSubTrigger>
<ModelEditSubmenu
effort={effEffort}
fastControl={fastControl}
isActive={isCurrent}
onActivate={() => switchTo(family.id, group.provider.slug)}
model={family.id}
onSelectModel={nextModel => switchTo(nextModel, group.provider.slug)}
provider={group.provider.slug}
reasoning={caps?.reasoning ?? true}
requestGateway={requestGateway}
/>

View file

@ -0,0 +1,51 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { $modelPresets, applyModelPreset, getModelPreset, modelPresetKey, setModelPreset } from './model-presets'
describe('model presets', () => {
beforeEach(() => $modelPresets.set({}))
it('round-trips a preset and merges patches without dropping prior fields', () => {
setModelPreset('anthropic', 'claude-opus-4-8', { effort: 'high' })
setModelPreset('anthropic', 'claude-opus-4-8', { fast: true })
expect(getModelPreset('anthropic', 'claude-opus-4-8')).toEqual({ effort: 'high', fast: true })
})
it('returns an empty preset for unknown models', () => {
expect(getModelPreset('x', 'y')).toEqual({})
})
it('keys by provider::model', () => {
expect(modelPresetKey('openai', 'gpt-5.5')).toBe('openai::gpt-5.5')
})
it('pushes only the provided dimensions to the gateway', async () => {
const calls: { method: string; params?: Record<string, unknown> }[] = []
const request = async <T>(method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
return {} as T
}
await applyModelPreset({ effort: 'high' }, { failMessage: 'x', request, sessionId: 's1' })
await applyModelPreset({}, { failMessage: 'x', request, sessionId: 's1' })
expect(calls).toEqual([{ method: 'config.set', params: { key: 'reasoning', session_id: 's1', value: 'high' } }])
})
it('no-ops without a session so selecting a model cannot mutate global config', async () => {
const calls: { method: string; params?: Record<string, unknown> }[] = []
const request = async <T>(method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
return {} as T
}
await applyModelPreset({ effort: 'high', fast: true }, { failMessage: 'x', request, sessionId: null })
expect(calls).toEqual([])
})
})

View file

@ -0,0 +1,86 @@
import { atom } from 'nanostores'
import { persistString, storedString } from '@/lib/storage'
import { notifyError } from './notifications'
import { setCurrentFastMode, setCurrentReasoningEffort } from './session'
const STORAGE_KEY = 'hermes.desktop.model-presets'
/** Per-model reasoning/fast preset, remembered globally across sessions and
* re-applied to the session whenever that model is selected. Unset dimensions
* fall back to the Hermes default (medium effort, no fast). */
export interface ModelPreset {
effort?: string
fast?: boolean
}
type RequestGateway = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
/** Stable `provider::model` key (matches the visibility-store format). */
export const modelPresetKey = (provider: string, model: string): string => `${provider}::${model}`
function load(): Record<string, ModelPreset> {
const raw = storedString(STORAGE_KEY)
if (!raw) {
return {}
}
try {
const parsed = JSON.parse(raw)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, ModelPreset>) : {}
} catch {
return {}
}
}
export const $modelPresets = atom<Record<string, ModelPreset>>(load())
export function getModelPreset(provider: string, model: string): ModelPreset {
return $modelPresets.get()[modelPresetKey(provider, model)] ?? {}
}
/** Merge a partial preset for one model and persist. */
export function setModelPreset(provider: string, model: string, patch: ModelPreset): void {
const key = modelPresetKey(provider, model)
const next = { ...$modelPresets.get(), [key]: { ...$modelPresets.get()[key], ...patch } }
$modelPresets.set(next)
persistString(STORAGE_KEY, JSON.stringify(next))
}
/** Push a model's preset onto the active session (optimistic + gateway).
* `undefined` skips that dimension; values are capability-gated upstream.
* No-ops without a session the gateway's `config.set` reasoning/fast fall
* back to persistent (global/profile) config when none matches, so selecting
* a model must not reach it (else it rewrites `agent.*`, defaults included). */
export async function applyModelPreset(
{ effort, fast }: ModelPreset,
ctx: { failMessage: string; request: RequestGateway; sessionId: null | string }
): Promise<void> {
if (!ctx.sessionId) {
return
}
if (effort !== undefined) {
setCurrentReasoningEffort(effort)
}
if (fast !== undefined) {
setCurrentFastMode(fast)
}
try {
if (effort !== undefined) {
await ctx.request('config.set', { key: 'reasoning', session_id: ctx.sessionId, value: effort })
}
if (fast !== undefined) {
await ctx.request('config.set', { key: 'fast', session_id: ctx.sessionId, value: fast ? 'fast' : 'normal' })
}
} catch (err) {
notifyError(err, ctx.failMessage)
}
}