fix(desktop): preserve live model after settings save (#72903)

This commit is contained in:
Gille 2026-07-27 15:52:37 -06:00 committed by GitHub
parent 731aa0ccc9
commit dbc18c6d62
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 105 additions and 9 deletions

View file

@ -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'] })
}}

View file

@ -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)

View file

@ -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 }
}