diff --git a/apps/desktop/src/app/chat/composer/model-pill.test.tsx b/apps/desktop/src/app/chat/composer/model-pill.test.tsx new file mode 100644 index 000000000000..165441dbd462 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/model-pill.test.tsx @@ -0,0 +1,76 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' + +import type { ChatBarState } from '@/app/chat/composer/types' +import { + $activeSessionId, + $currentModel, + setCurrentModel, + setCurrentModelSource +} from '@/store/session' + +import { ModelPill } from './model-pill' + +const modelState = (over: Partial = {}): ChatBarState['model'] => ({ + canSwitch: true, + model: 'gpt-6', + provider: 'openai', + ...over +}) + +afterEach(() => { + cleanup() + $activeSessionId.set(null) + setCurrentModel('') + setCurrentModelSource('') +}) + +// #62055: a manual composer pick is sticky and silently overrides the +// Settings → Model default for every NEW chat. The pill must say so. +describe('ModelPill pinned-override badge', () => { + it('shows the pin dot on a draft running a manual pick', () => { + setCurrentModel('deepseek/deepseek-v4-flash') + setCurrentModelSource('manual') + $activeSessionId.set(null) + + render() + + expect(screen.getByTestId('model-pinned-dot')).toBeTruthy() + }) + + it('stays quiet when the composer reflects the profile default', () => { + setCurrentModel('google/gemma-4-26b-a4b-it:free') + setCurrentModelSource('default') + $activeSessionId.set(null) + + render() + + expect(screen.queryByTestId('model-pinned-dot')).toBeNull() + }) + + it('stays quiet on a live session (footer shows that session, not the pin)', () => { + setCurrentModel('deepseek/deepseek-v4-flash') + setCurrentModelSource('manual') + $activeSessionId.set('live-1') + + render() + + expect(screen.queryByTestId('model-pinned-dot')).toBeNull() + }) + + it('is exercised in both render paths', () => { + setCurrentModel('deepseek/deepseek-v4-flash') + setCurrentModelSource('manual') + $activeSessionId.set(null) + + // Fallback (no live menu) path. + const { unmount } = render() + expect(screen.getByTestId('model-pinned-dot')).toBeTruthy() + unmount() + + // Live-menu (dropdown) path. + render( })} />) + expect(screen.getByTestId('model-pinned-dot')).toBeTruthy() + expect($currentModel.get()).toBe('deepseek/deepseek-v4-flash') + }) +}) diff --git a/apps/desktop/src/app/chat/composer/model-pill.tsx b/apps/desktop/src/app/chat/composer/model-pill.tsx index afaca08c9c8c..e232616ceda5 100644 --- a/apps/desktop/src/app/chat/composer/model-pill.tsx +++ b/apps/desktop/src/app/chat/composer/model-pill.tsx @@ -11,8 +11,10 @@ import { ChevronDown } from '@/lib/icons' import { formatModelStatusLabel } from '@/lib/model-status-label' import { cn } from '@/lib/utils' import { + $activeSessionId, $currentFastMode, $currentModel, + $currentModelSource, $currentProvider, $currentReasoningEffort, setModelPickerOpen @@ -44,8 +46,17 @@ export function ModelPill({ const currentProvider = useStore($currentProvider) const fastMode = useStore($currentFastMode) const reasoningEffort = useStore($currentReasoningEffort) + const modelSource = useStore($currentModelSource) + const activeSessionId = useStore($activeSessionId) const [open, setOpen] = useState(false) + // The composer pick is sticky: a manual selection is pinned and every NEW + // chat uses it instead of the Settings → Model default — silently, which has + // cost users real money on a forgotten paid-model pick (#62055). Surface the + // pin whenever a draft (no live session) is running on a manual override. A + // live session's footer reflects that session's model, so no badge there. + const pinnedOverride = !activeSessionId && modelSource === 'manual' && Boolean(currentModel.trim()) + // 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. @@ -58,6 +69,14 @@ export function ModelPill({ ) : ( )} + {pinnedOverride && ( + + )} ) @@ -71,11 +90,12 @@ export function ModelPill({ ) : PILL - const title = currentProvider ? copy.modelTitle(currentProvider, currentModel || copy.modelNone) : copy.switchModel + const baseTitle = currentProvider ? copy.modelTitle(currentProvider, currentModel || copy.modelNone) : copy.switchModel + const title = pinnedOverride ? `${baseTitle} — ${copy.modelPinned}` : baseTitle if (!model.modelMenuContent) { return ( - +