fix(desktop): refresh default-derived composer model

This commit is contained in:
Koho Zheng 2026-07-05 06:13:50 +08:00 committed by Brooklyn Nicholson
parent c387be08b9
commit 0f6abc73a8
4 changed files with 69 additions and 4 deletions

View file

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

View file

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

View file

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

View file

@ -8,6 +8,7 @@ import { persistBoolean, persistString, storedBoolean, storedString } from '@/li
import type { SessionInfo, UsageStats } from '@/types/hermes'
type Updater<T> = 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<string>) => {
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<string>) => {
updateAtom($currentReasoningEffort, next)
persistString(COMPOSER_EFFORT_KEY, $currentReasoningEffort.get() || null)