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
79 changes: 79 additions & 0 deletions apps/desktop/src/app/chat/composer/model-pill.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { cleanup, render, screen } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import {
setActiveSessionId,
setCurrentModel,
setCurrentProvider,
setProfileDefaultModel,
setProfileDefaultProvider
} from '@/store/session'

import { ModelPill } from './model-pill'

vi.mock('@/components/ui/tooltip', () => ({
Tip: ({ children }: { children: ReactNode }) => children
}))

vi.mock('@/i18n', () => ({
useI18n: () => ({
t: {
shell: {
statusbar: {
modelNone: 'none',
modelOverrideTitle: (provider: string, model: string, defaultProvider: string, defaultModel: string) =>
`Composer override · ${provider}: ${model}. Default: ${defaultProvider}: ${defaultModel}`,
modelTitle: (provider: string, model: string) => `Model · ${provider}: ${model}`,
openModelPicker: 'Open model picker',
switchModel: 'Switch model',
unknown: 'unknown'
}
}
}
})
}))

const model = {
canSwitch: true,
model: 'deepseek/deepseek-v4-flash',
provider: 'deepseek'
}

describe('ModelPill composer override indicator', () => {
beforeEach(() => {
setActiveSessionId(null)
setCurrentModel('deepseek/deepseek-v4-flash')
setCurrentProvider('deepseek')
setProfileDefaultModel('google/gemma-4-26b-a4b-it:free')
setProfileDefaultProvider('openrouter')
})

afterEach(() => {
cleanup()
setActiveSessionId(null)
setCurrentModel('')
setCurrentProvider('')
setProfileDefaultModel('')
setProfileDefaultProvider('')
})

it('discloses a sticky new-chat selection that differs from the profile default', () => {
const { container } = render(<ModelPill disabled={false} model={model} />)

const button = screen.getByRole('button', { name: /Composer override/ })

expect(button.getAttribute('data-model-override')).toBe('true')
expect(container.querySelector('[data-slot="model-override-indicator"]')).not.toBeNull()
})

it('does not label a live session model as a composer override', () => {
setActiveSessionId('session-1')
const { container } = render(<ModelPill disabled={false} model={model} />)

const button = screen.getByRole('button', { name: 'Open model picker' })

expect(button.getAttribute('data-model-override')).toBeNull()
expect(container.querySelector('[data-slot="model-override-indicator"]')).toBeNull()
})
})
56 changes: 50 additions & 6 deletions apps/desktop/src/app/chat/composer/model-pill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,17 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/compon
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { ChevronDown } from '@/lib/icons'
import { AlertTriangle, ChevronDown } from '@/lib/icons'
import { formatModelStatusLabel } from '@/lib/model-status-label'
import { cn } from '@/lib/utils'
import {
$activeSessionId,
$currentFastMode,
$currentModel,
$currentProvider,
$currentReasoningEffort,
$profileDefaultModel,
$profileDefaultProvider,
setModelPickerOpen
} from '@/store/session'

Expand Down Expand Up @@ -44,20 +47,44 @@ export function ModelPill({
const currentProvider = useStore($currentProvider)
const fastMode = useStore($currentFastMode)
const reasoningEffort = useStore($currentReasoningEffort)
const activeSessionId = useStore($activeSessionId)
const profileDefaultModel = useStore($profileDefaultModel)
const profileDefaultProvider = useStore($profileDefaultProvider)
const [open, setOpen] = useState(false)

const normalizedCurrentModel = currentModel.trim().toLowerCase()
const normalizedDefaultModel = profileDefaultModel.trim().toLowerCase()
const normalizedCurrentProvider = currentProvider.trim().toLowerCase()
const normalizedDefaultProvider = profileDefaultProvider.trim().toLowerCase()

const providerDiffers =
!!normalizedCurrentProvider &&
!!normalizedDefaultProvider &&
normalizedCurrentProvider !== normalizedDefaultProvider

const hasComposerOverride =
!activeSessionId &&
!!normalizedCurrentModel &&
!!normalizedDefaultModel &&
(normalizedCurrentModel !== normalizedDefaultModel || providerDiffers)

// The model resolves a beat after the gateway/session comes up. Rather than
// flash a literal "No model", show a quiet loader (inherits the pill text
// color at half opacity) until a model lands.
const overrideIcon = hasComposerOverride ? (
<AlertTriangle aria-hidden="true" className="size-3 shrink-0 text-amber-500" data-slot="model-override-indicator" />
) : null

const label = compact ? (
<ChevronDown className="size-3.5 shrink-0 opacity-70" />
(overrideIcon ?? <ChevronDown className="size-3.5 shrink-0 opacity-70" />)
) : (
<>
{currentModel.trim() ? (
<span className="truncate">{formatModelStatusLabel(currentModel, { fastMode, reasoningEffort })}</span>
) : (
<GlyphSpinner className="opacity-50" spinner="braille" />
)}
{overrideIcon}
<ChevronDown className="size-2.5 shrink-0 opacity-50" />
</>
)
Expand All @@ -71,14 +98,24 @@ export function ModelPill({
)
: PILL

const title = currentProvider ? copy.modelTitle(currentProvider, currentModel || copy.modelNone) : copy.switchModel
const title = hasComposerOverride
? copy.modelOverrideTitle(
currentProvider || copy.unknown,
currentModel,
profileDefaultProvider || copy.unknown,
profileDefaultModel
)
: currentProvider
? copy.modelTitle(currentProvider, currentModel || copy.modelNone)
: copy.switchModel

if (!model.modelMenuContent) {
return (
<Tip label={copy.openModelPicker} side="top">
<Tip label={hasComposerOverride ? title : copy.openModelPicker} side="top">
<Button
aria-label={copy.openModelPicker}
aria-label={hasComposerOverride ? title : copy.openModelPicker}
className={pillClass}
data-model-override={hasComposerOverride ? 'true' : undefined}
disabled={disabled}
onClick={() => setModelPickerOpen(true)}
type="button"
Expand All @@ -94,7 +131,14 @@ export function ModelPill({
<DropdownMenu onOpenChange={setOpen} open={open}>
<Tip label={title} side="top">
<DropdownMenuTrigger asChild>
<Button aria-label={title} className={pillClass} disabled={disabled} type="button" variant="ghost">
<Button
aria-label={title}
className={pillClass}
data-model-override={hasComposerOverride ? 'true' : undefined}
disabled={disabled}
type="button"
variant="ghost"
>
{label}
</Button>
</DropdownMenuTrigger>
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/app/desktop-controller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ import {
setCurrentModel,
setCurrentProvider,
setMessages,
setProfileDefaultModel,
setProfileDefaultProvider,
setRememberedSessionId
} from '../store/session'
import { onSessionsChanged } from '../store/session-sync'
Expand Down Expand Up @@ -1081,6 +1083,8 @@ export function DesktopController() {
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
}}
onMainModelChanged={(provider, model) => {
setProfileDefaultProvider(provider)
setProfileDefaultModel(model)
setCurrentProvider(provider)
setCurrentModel(model)
updateModelOptionsCache(provider, model, true)
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,17 @@ 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,
$profileDefaultModel,
$profileDefaultProvider,
setCurrentModel,
setCurrentProvider,
setProfileDefaultModel,
setProfileDefaultProvider
} from '@/store/session'

import { useModelControls } from './use-model-controls'

Expand Down Expand Up @@ -56,6 +66,8 @@ describe('useModelControls', () => {
$activeSessionId.set(null)
setCurrentModel('')
setCurrentProvider('')
setProfileDefaultModel('')
setProfileDefaultProvider('')
})

afterEach(() => {
Expand All @@ -64,6 +76,8 @@ describe('useModelControls', () => {
$activeSessionId.set(null)
setCurrentModel('')
setCurrentProvider('')
setProfileDefaultModel('')
setProfileDefaultProvider('')
})

it('applies the global model when there is no active runtime session', async () => {
Expand Down Expand Up @@ -179,4 +193,27 @@ describe('useModelControls', () => {
await result.current.refreshCurrentModel(true)
expect($currentModel.get()).toBe('openai/gpt-5.5')
})

it('reads the global default even when a sticky composer pick must survive', async () => {
setCurrentModel('deepseek/deepseek-v4-flash')
setCurrentProvider('deepseek')
vi.mocked(getGlobalModelInfo).mockResolvedValue({ model: 'google/gemma-4-26b-a4b-it:free', provider: 'openrouter' })
vi.mocked(getGlobalModelInfo).mockClear()

const { result } = renderHook(() =>
useModelControls({
activeSessionId: null,
queryClient: new QueryClient(),
requestGateway: vi.fn()
})
)

await result.current.refreshCurrentModel()

expect(getGlobalModelInfo).toHaveBeenCalledOnce()
expect($currentModel.get()).toBe('deepseek/deepseek-v4-flash')
expect($currentProvider.get()).toBe('deepseek')
expect($profileDefaultModel.get()).toBe('google/gemma-4-26b-a4b-it:free')
expect($profileDefaultProvider.get()).toBe('openrouter')
})
})
33 changes: 24 additions & 9 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,
setCurrentModel,
setCurrentProvider,
setProfileDefaultModel,
setProfileDefaultProvider
} from '@/store/session'
import type { ModelOptionsResponse } from '@/types/hermes'

interface ModelSelection {
Expand Down Expand Up @@ -35,24 +43,31 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
[activeSessionId, queryClient]
)

// 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
// $currentModel) survives the lifecycle refreshes that fire on boot / fresh
// draft / session events. A live session owns the footer, so skip entirely.
// Track the profile default even when a sticky composer pick survives. The
// composer uses that mirror to disclose overrides; `force` still reseeds the
// actual selection for a profile swap. A live session owns the footer, so skip
// entirely rather than mixing global config into session state.
const refreshCurrentModel = useCallback(async (force = false) => {
try {
if ($activeSessionId.get()) {
return
}

if (!force && $currentModel.get()) {
const result = await getGlobalModelInfo()

if ($activeSessionId.get()) {
return
}

const result = await getGlobalModelInfo()
if (typeof result.model === 'string') {
setProfileDefaultModel(result.model)
}

if ($activeSessionId.get() || (!force && $currentModel.get())) {
if (typeof result.provider === 'string') {
setProfileDefaultProvider(result.provider)
}

if (!force && $currentModel.get()) {
return
}

Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2115,6 +2115,8 @@ export const en: Translations = {
switchModel: 'Switch model',
openModelPicker: 'Open model picker',
modelTitle: (provider, model) => `Model · ${provider}: ${model}`,
modelOverrideTitle: (provider, model, defaultProvider, defaultModel) =>
`Composer override · ${provider}: ${model}. Default: ${defaultProvider}: ${defaultModel}`,
providerModelTitle: (provider, model) => `${provider} · ${model}`
}
},
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2060,6 +2060,8 @@ export const ja = defineLocale({
switchModel: 'モデルを切り替え',
openModelPicker: 'モデルピッカーを開く',
modelTitle: (provider, model) => `モデル · ${provider}: ${model}`,
modelOverrideTitle: (provider, model, defaultProvider, defaultModel) =>
`作成欄の上書き · ${provider}: ${model}。デフォルト: ${defaultProvider}: ${defaultModel}`,
providerModelTitle: (provider, model) => `${provider} · ${model}`
}
},
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/i18n/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1751,6 +1751,7 @@ export interface Translations {
switchModel: string
openModelPicker: string
modelTitle: (provider: string, model: string) => string
modelOverrideTitle: (provider: string, model: string, defaultProvider: string, defaultModel: string) => string
providerModelTitle: (provider: string, model: string) => string
}
}
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/i18n/zh-hant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1998,6 +1998,8 @@ export const zhHant = defineLocale({
switchModel: '切換模型',
openModelPicker: '開啟模型選擇器',
modelTitle: (provider, model) => `模型 · ${provider}:${model}`,
modelOverrideTitle: (provider, model, defaultProvider, defaultModel) =>
`輸入框模型覆寫 · ${provider}:${model}。預設:${defaultProvider}:${defaultModel}`,
providerModelTitle: (provider, model) => `${provider} · ${model}`
}
},
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2282,6 +2282,8 @@ export const zh: Translations = {
switchModel: '切换模型',
openModelPicker: '打开模型选择器',
modelTitle: (provider, model) => `模型 · ${provider}: ${model}`,
modelOverrideTitle: (provider, model, defaultProvider, defaultModel) =>
`输入框模型覆盖 · ${provider}: ${model}。默认: ${defaultProvider}: ${defaultModel}`,
providerModelTitle: (provider, model) => `${provider} · ${model}`
}
},
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/src/store/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,11 @@ export const $resumeFailedSessionId = atom<string | null>(null)
export const $resumeExhaustedSessionId = atom<string | null>(null)
export const $currentModel = atom(storedString(COMPOSER_MODEL_KEY) ?? '')
export const $currentProvider = atom(storedString(COMPOSER_PROVIDER_KEY) ?? '')
// Memory-only profile defaults used to distinguish an intentional composer
// override from the model configured in Settings. The override remains sticky;
// these mirrors make that difference visible instead of silently billable.
export const $profileDefaultModel = atom('')
export const $profileDefaultProvider = atom('')
export const $currentReasoningEffort = atom(storedString(COMPOSER_EFFORT_KEY) ?? '')
export const $currentServiceTier = atom('')
export const $currentFastMode = atom(storedBoolean(COMPOSER_FAST_KEY, false))
Expand Down Expand Up @@ -319,6 +324,9 @@ export const setCurrentProvider = (next: Updater<string>) => {
persistString(COMPOSER_PROVIDER_KEY, $currentProvider.get() || null)
}

export const setProfileDefaultModel = (next: Updater<string>) => updateAtom($profileDefaultModel, next)
export const setProfileDefaultProvider = (next: Updater<string>) => updateAtom($profileDefaultProvider, next)

export const setCurrentReasoningEffort = (next: Updater<string>) => {
updateAtom($currentReasoningEffort, next)
persistString(COMPOSER_EFFORT_KEY, $currentReasoningEffort.get() || null)
Expand Down
Loading