diff --git a/apps/desktop/src/app/shell/model-edit-submenu.test.tsx b/apps/desktop/src/app/shell/model-edit-submenu.test.tsx new file mode 100644 index 00000000000..e2493c60020 --- /dev/null +++ b/apps/desktop/src/app/shell/model-edit-submenu.test.tsx @@ -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 }) { + return render( + + + + edit + + + + + ) +} + +// 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' }) + }) +}) diff --git a/apps/desktop/src/app/shell/model-edit-submenu.tsx b/apps/desktop/src/app/shell/model-edit-submenu.tsx index 6872cca7f5a..881e33cab05 100644 --- a/apps/desktop/src/app/shell/model-edit-submenu.tsx +++ b/apps/desktop/src/app/shell/model-edit-submenu.tsx @@ -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 | 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 | void + /** This row's provider slug — edits persist as its global preset. */ + provider: string /** Whether this model supports reasoning effort. */ reasoning: boolean requestGateway: (method: string, params?: Record) => Promise } 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 => { - 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({ - void patchReasoning(checked ? effort || 'medium' : 'none', currentReasoningEffort) - } + onCheckedChange={checked => void patchReasoning(checked ? effortValue || 'medium' : 'none')} size="xs" /> @@ -205,10 +207,7 @@ export function ModelEditSubmenu({ <> {copy.effort} - void patchReasoning(value, currentReasoningEffort)} - value={effort} - > + void patchReasoning(value)} value={effortValue}> {EFFORT_OPTIONS.map(option => ( 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 ? : null} 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} /> diff --git a/apps/desktop/src/store/model-presets.test.ts b/apps/desktop/src/store/model-presets.test.ts new file mode 100644 index 00000000000..efe49ffa6e5 --- /dev/null +++ b/apps/desktop/src/store/model-presets.test.ts @@ -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 }[] = [] + + const request = async (method: string, params?: Record) => { + 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 }[] = [] + + const request = async (method: string, params?: Record) => { + calls.push({ method, params }) + + return {} as T + } + + await applyModelPreset({ effort: 'high', fast: true }, { failMessage: 'x', request, sessionId: null }) + + expect(calls).toEqual([]) + }) +}) diff --git a/apps/desktop/src/store/model-presets.ts b/apps/desktop/src/store/model-presets.ts new file mode 100644 index 00000000000..9a66a8b0d2c --- /dev/null +++ b/apps/desktop/src/store/model-presets.ts @@ -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 = (method: string, params?: Record) => Promise + +/** Stable `provider::model` key (matches the visibility-store format). */ +export const modelPresetKey = (provider: string, model: string): string => `${provider}::${model}` + +function load(): Record { + 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) : {} + } catch { + return {} + } +} + +export const $modelPresets = atom>(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 { + 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) + } +}