From dbc18c6d62c02c664c94978120df972d01ca0fe7 Mon Sep 17 00:00:00 2001 From: Gille <4317663+helix4u@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:52:37 -0600 Subject: [PATCH] fix(desktop): preserve live model after settings save (#72903) --- apps/desktop/src/app/contrib/wiring.tsx | 10 +-- .../session/hooks/use-model-controls.test.tsx | 79 +++++++++++++++++++ .../app/session/hooks/use-model-controls.ts | 25 +++++- 3 files changed, 105 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 0cbda7af36f..5984f9363ad 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -51,9 +51,6 @@ import { sessionPinId, setAwaitingResponse, setBusy, - setCurrentModel, - setCurrentModelSource, - setCurrentProvider, setMessages } from '@/store/session' import { focusedSessionNeedsRoute, focusOpenSession } from '@/store/session-states' @@ -269,7 +266,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { const { refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds } = useHermesConfig({ activeSessionIdRef }) - const { refreshCurrentModel, selectModel, updateModelOptionsCache } = useModelControls({ + const { applySavedMainModel, refreshCurrentModel, selectModel } = useModelControls({ queryClient, requestGateway }) @@ -1000,10 +997,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { void queryClient.invalidateQueries({ queryKey: ['model-options'] }) }} onMainModelChanged={(provider, model) => { - setCurrentProvider(provider) - setCurrentModel(model) - setCurrentModelSource('default') - updateModelOptionsCache($activeSessionId.get(), provider, model, true) + applySavedMainModel(provider, model) 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 bf4ad80eb47..cd539da5a8a 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 @@ -140,6 +140,85 @@ describe('useModelControls', () => { expect($currentProvider.get()).toBe('deepseek') }) + it('keeps a live session authoritative when Settings saves a new profile default', async () => { + const queryClient = new QueryClient() + $activeSessionId.set('runtime-1') + setCurrentModel('tencent/hy3:free') + setCurrentProvider('nous') + setCurrentModelSource('manual') + queryClient.setQueryData(modelOptionsQueryKey('default'), { + model: 'tencent/hy3:free', + provider: 'nous', + providers: [] + }) + queryClient.setQueryData(modelOptionsQueryKey('default', 'runtime-1'), { + model: 'tencent/hy3:free', + provider: 'nous', + providers: [] + }) + vi.mocked(getGlobalModelInfo).mockResolvedValue({ + model: 'poolside/laguna-xs-2.1:free', + provider: 'nous' + }) + + const { result } = renderHook(() => + useModelControls({ + queryClient, + requestGateway: vi.fn() + }) + ) + + result.current.applySavedMainModel('nous', 'poolside/laguna-xs-2.1:free') + await result.current.refreshCurrentModel() + + // Settings changes the profile default, not the active session. The footer + // and its session-scoped picker cache must keep showing the live runtime. + expect($currentModel.get()).toBe('tencent/hy3:free') + expect($currentProvider.get()).toBe('nous') + expect(queryClient.getQueryData(modelOptionsQueryKey('default', 'runtime-1'))).toMatchObject({ + model: 'tencent/hy3:free', + provider: 'nous' + }) + + // The global cache reflects the save, and the next fresh draft may reseed + // from that default instead of preserving the old session's model. + expect(getCurrentModelSource()).toBe('default') + expect(queryClient.getQueryData(modelOptionsQueryKey('default'))).toMatchObject({ + model: 'poolside/laguna-xs-2.1:free', + provider: 'nous' + }) + + $activeSessionId.set(null) + await result.current.refreshCurrentModel() + + expect($currentModel.get()).toBe('poolside/laguna-xs-2.1:free') + expect($currentProvider.get()).toBe('nous') + }) + + it('paints a saved profile default immediately when no session is active', () => { + const queryClient = new QueryClient() + setCurrentModel('tencent/hy3:free') + setCurrentProvider('nous') + setCurrentModelSource('manual') + + const { result } = renderHook(() => + useModelControls({ + queryClient, + requestGateway: vi.fn() + }) + ) + + result.current.applySavedMainModel('nous', 'poolside/laguna-xs-2.1:free') + + expect($currentModel.get()).toBe('poolside/laguna-xs-2.1:free') + expect($currentProvider.get()).toBe('nous') + expect(getCurrentModelSource()).toBe('default') + expect(queryClient.getQueryData(modelOptionsQueryKey('default'))).toMatchObject({ + model: 'poolside/laguna-xs-2.1:free', + provider: 'nous' + }) + }) + it('routes active-session picker changes through config.set with an explicit session-scoped provider', async () => { $activeSessionId.set('session-1') const requestGateway = vi.fn(async () => ({ key: 'model', value: 'claude-sonnet-4.6' }) as never) 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 b9c27aa22a1..1ccba715ce2 100644 --- a/apps/desktop/src/app/session/hooks/use-model-controls.ts +++ b/apps/desktop/src/app/session/hooks/use-model-controls.ts @@ -55,6 +55,29 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO [queryClient] ) + // Settings → Model writes the profile default, which the backend applies to + // new sessions only. Keep a live session's renderer state and session-scoped + // model-options cache authoritative instead of briefly painting the saved + // default as if the active agent had switched. Marking the composer as + // default-derived still lets the next fresh draft reseed from profile config. + const applySavedMainModel = useCallback( + (provider: string, model: string) => { + const liveSessionId = $activeSessionId.get() + + setCurrentModelSource('default') + + if (!liveSessionId) { + setCurrentProvider(provider) + setCurrentModel(model) + } + + // A null session id is the profile-global model-options key. Never patch + // the live session key here: only config.set --session may change it. + updateModelOptionsCache(null, provider, model, false) + }, + [updateModelOptionsCache] + ) + // Seed the composer's model state from the profile default. `force` reseeds // for a profile swap (the new profile has its own default); otherwise this // only fills an EMPTY selection so a user's pick (plain UI state in @@ -220,5 +243,5 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO [copy.modelSwitchFailed, queryClient, requestGateway, updateModelOptionsCache] ) - return { refreshCurrentModel, selectModel, updateModelOptionsCache } + return { applySavedMainModel, refreshCurrentModel, selectModel } }