diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index e2acd1601f41..0474fd01eea6 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -51,6 +51,7 @@ import { setCurrentBranch, setCurrentCwd, setCurrentModel, + setCurrentModelSource, setCurrentProvider, setMessages } from '@/store/session' @@ -898,6 +899,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { onMainModelChanged={(provider, model) => { setCurrentProvider(provider) setCurrentModel(model) + setCurrentModelSource('default') updateModelOptionsCache(provider, model, true) void refreshCurrentModel() void queryClient.invalidateQueries({ queryKey: ['model-options'] }) diff --git a/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx b/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx index 8085c8cd136b..2c8c4e1f6fbd 100644 --- a/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx @@ -3,7 +3,15 @@ import { cleanup, render, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getGlobalModelInfo } from '@/hermes' -import { $activeSessionId, $currentModel, $currentProvider, setCurrentModel, setCurrentProvider } from '@/store/session' +import { + $activeSessionId, + $currentModel, + $currentProvider, + getCurrentModelSource, + setCurrentModel, + setCurrentModelSource, + setCurrentProvider +} from '@/store/session' import { useModelControls } from './use-model-controls' @@ -52,6 +60,7 @@ describe('useModelControls', () => { beforeEach(() => { $activeSessionId.set(null) setCurrentModel('') + setCurrentModelSource('') setCurrentProvider('') }) @@ -60,6 +69,7 @@ describe('useModelControls', () => { vi.restoreAllMocks() $activeSessionId.set(null) setCurrentModel('') + setCurrentModelSource('') setCurrentProvider('') }) @@ -80,6 +90,7 @@ describe('useModelControls', () => { expect($currentModel.get()).toBe('openai/gpt-5.5') expect($currentProvider.get()).toBe('openai-codex') + expect(getCurrentModelSource()).toBe('default') }) it('does not clobber the active session footer state with global model info', async () => { @@ -164,6 +175,7 @@ describe('useModelControls', () => { // the gateway or the profile default here. expect($currentModel.get()).toBe('claude-sonnet-4.6') expect($currentProvider.get()).toBe('anthropic') + expect(getCurrentModelSource()).toBe('manual') expect(requestGateway).not.toHaveBeenCalled() expect(setGlobalModel).not.toHaveBeenCalled() }) @@ -185,6 +197,7 @@ describe('useModelControls', () => { // A user pick must survive the lifecycle refreshes that fire on boot / fresh // draft / session events. setCurrentModel('anthropic/claude-sonnet-4.6') + setCurrentModelSource('manual') setCurrentProvider('anthropic') await result.current.refreshCurrentModel() expect($currentModel.get()).toBe('anthropic/claude-sonnet-4.6') @@ -193,4 +206,27 @@ describe('useModelControls', () => { await result.current.refreshCurrentModel(true) expect($currentModel.get()).toBe('openai/gpt-5.5') }) + + it('refreshes legacy/default-derived composer state from the profile default', async () => { + setCurrentModel('openai/gpt-5.5') + setCurrentProvider('nous') + setCurrentModelSource('') + vi.mocked(getGlobalModelInfo).mockResolvedValue({ model: 'gpt-5.5', provider: 'openai-codex' }) + + const { result } = renderHook(() => + useModelControls({ + queryClient: new QueryClient(), + requestGateway: vi.fn() + }) + ) + + expect(getCurrentModelSource()).toBe('') + + await result.current.refreshCurrentModel() + + expect(getGlobalModelInfo).toHaveBeenCalled() + expect($currentModel.get()).toBe('gpt-5.5') + expect($currentProvider.get()).toBe('openai-codex') + expect(getCurrentModelSource()).toBe('default') + }) }) diff --git a/apps/desktop/src/app/session/hooks/use-model-controls.ts b/apps/desktop/src/app/session/hooks/use-model-controls.ts index 3296c9c97a27..5f0d351b6ec9 100644 --- a/apps/desktop/src/app/session/hooks/use-model-controls.ts +++ b/apps/desktop/src/app/session/hooks/use-model-controls.ts @@ -4,7 +4,15 @@ import { useCallback } from 'react' import { getGlobalModelInfo } from '@/hermes' import { useI18n } from '@/i18n' import { notifyError } from '@/store/notifications' -import { $activeSessionId, $currentModel, $currentProvider, setCurrentModel, setCurrentProvider } from '@/store/session' +import { + $activeSessionId, + $currentModel, + $currentProvider, + getCurrentModelSource, + setCurrentModel, + setCurrentModelSource, + setCurrentProvider +} from '@/store/session' import type { ModelOptionsResponse } from '@/types/hermes' interface ModelSelection { @@ -50,13 +58,13 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO return } - if (!force && $currentModel.get()) { + if (!force && $currentModel.get() && getCurrentModelSource() === 'manual') { return } const result = await getGlobalModelInfo() - if ($activeSessionId.get() || (!force && $currentModel.get())) { + if ($activeSessionId.get() || (!force && $currentModel.get() && getCurrentModelSource() === 'manual')) { return } @@ -67,6 +75,10 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO if (typeof result.provider === 'string') { setCurrentProvider(result.provider) } + + if (typeof result.model === 'string' || typeof result.provider === 'string') { + setCurrentModelSource('default') + } } catch { // The delayed session.info event still updates this once the agent is ready. } @@ -85,11 +97,13 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO // rather than leave the UI showing a model the backend never selected. const prevModel = $currentModel.get() const prevProvider = $currentProvider.get() + const prevSource = getCurrentModelSource() const liveSessionId = $activeSessionId.get() setCurrentModel(selection.model) setCurrentProvider(selection.provider) + setCurrentModelSource('manual') updateModelOptionsCache(selection.provider, selection.model, !liveSessionId) // No live session yet: the pick is pure UI state. session.create reads @@ -111,6 +125,7 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO } catch (err) { setCurrentModel(prevModel) setCurrentProvider(prevProvider) + setCurrentModelSource(prevSource) updateModelOptionsCache(prevProvider, prevModel, !liveSessionId) notifyError(err, copy.modelSwitchFailed) diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index b36704b03244..78c66618e58b 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -8,6 +8,7 @@ import { persistBoolean, persistString, storedBoolean, storedString } from '@/li import type { SessionInfo, UsageStats } from '@/types/hermes' type Updater = T | ((current: T) => T) +export type ComposerModelSource = '' | 'default' | 'manual' const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd' @@ -18,6 +19,7 @@ const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd' // that profile's default, while within a profile new chats keep your last pick. const COMPOSER_MODEL_KEY = 'hermes.desktop.composer.model' const COMPOSER_PROVIDER_KEY = 'hermes.desktop.composer.provider' +const COMPOSER_MODEL_SOURCE_KEY = 'hermes.desktop.composer.model-source' const COMPOSER_EFFORT_KEY = 'hermes.desktop.composer.reasoning-effort' const COMPOSER_FAST_KEY = 'hermes.desktop.composer.fast' @@ -347,6 +349,16 @@ export const setCurrentProvider = (next: Updater) => { persistString(COMPOSER_PROVIDER_KEY, $currentProvider.get() || null) } +export const getCurrentModelSource = (): ComposerModelSource => { + const source = storedString(COMPOSER_MODEL_SOURCE_KEY) + + return source === 'default' || source === 'manual' ? source : '' +} + +export const setCurrentModelSource = (source: ComposerModelSource) => { + persistString(COMPOSER_MODEL_SOURCE_KEY, source || null) +} + export const setCurrentReasoningEffort = (next: Updater) => { updateAtom($currentReasoningEffort, next) persistString(COMPOSER_EFFORT_KEY, $currentReasoningEffort.get() || null)