Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/desktop/src/app/desktop-controller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import {
setCurrentBranch,
setCurrentCwd,
setCurrentModel,
setCurrentModelSource,
setCurrentProvider,
setMessages,
setRememberedSessionId
Expand Down Expand Up @@ -1083,6 +1084,7 @@ export function DesktopController() {
onMainModelChanged={(provider, model) => {
setCurrentProvider(provider)
setCurrentModel(model)
setCurrentModelSource('default')
updateModelOptionsCache(provider, model, true)
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
Expand Down
39 changes: 38 additions & 1 deletion apps/desktop/src/app/session/hooks/use-model-controls.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -55,6 +63,7 @@ describe('useModelControls', () => {
beforeEach(() => {
$activeSessionId.set(null)
setCurrentModel('')
setCurrentModelSource('')
setCurrentProvider('')
})

Expand All @@ -63,6 +72,7 @@ describe('useModelControls', () => {
vi.restoreAllMocks()
$activeSessionId.set(null)
setCurrentModel('')
setCurrentModelSource('')
setCurrentProvider('')
})

Expand All @@ -84,6 +94,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 () => {
Expand Down Expand Up @@ -149,6 +160,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()
})
Expand All @@ -171,6 +183,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')
Expand All @@ -179,4 +192,28 @@ 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({
activeSessionId: null,
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')
})
})
21 changes: 18 additions & 3 deletions apps/desktop/src/app/session/hooks/use-model-controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -46,13 +54,13 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
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
}

Expand All @@ -63,6 +71,10 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
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.
}
Expand All @@ -81,9 +93,11 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
// rather than leave the UI showing a model the backend never selected.
const prevModel = $currentModel.get()
const prevProvider = $currentProvider.get()
const prevSource = getCurrentModelSource()

setCurrentModel(selection.model)
setCurrentProvider(selection.provider)
setCurrentModelSource('manual')
updateModelOptionsCache(selection.provider, selection.model, !activeSessionId)

// No live session yet: the pick is pure UI state. session.create reads
Expand All @@ -105,6 +119,7 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
} catch (err) {
setCurrentModel(prevModel)
setCurrentProvider(prevProvider)
setCurrentModelSource(prevSource)
updateModelOptionsCache(prevProvider, prevModel, !activeSessionId)
notifyError(err, copy.modelSwitchFailed)

Expand Down
12 changes: 12 additions & 0 deletions apps/desktop/src/store/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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'

Expand Down Expand Up @@ -319,6 +321,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)
Expand Down