diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index ca7a043a9..6055666fe 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -227,7 +227,6 @@ export function ContribWiring({ children }: { children: ReactNode }) { }) const { refreshCurrentModel, selectModel, updateModelOptionsCache } = useModelControls({ - activeSessionId, queryClient, requestGateway }) diff --git a/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx b/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx index 9b7e08cf0..8085c8cd1 100644 --- a/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx @@ -32,16 +32,13 @@ vi.mock('@/store/notifications', () => ({ type Controls = ReturnType function Harness({ - activeSessionId, onReady, requestGateway }: { - activeSessionId: string | null onReady: (controls: Controls) => void requestGateway: (method: string, params?: Record) => Promise }) { const controls = useModelControls({ - activeSessionId, queryClient: new QueryClient(), requestGateway }) @@ -74,7 +71,6 @@ describe('useModelControls', () => { const { result } = renderHook(() => useModelControls({ - activeSessionId: null, queryClient: new QueryClient(), requestGateway: vi.fn() }) @@ -97,7 +93,6 @@ describe('useModelControls', () => { const { result } = renderHook(() => useModelControls({ - activeSessionId: 'runtime-1', queryClient: new QueryClient(), requestGateway: vi.fn() }) @@ -110,12 +105,11 @@ describe('useModelControls', () => { }) 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) let controls!: Controls - render( - (controls = value)} requestGateway={requestGateway} /> - ) + render( (controls = value)} requestGateway={requestGateway} />) await expect( controls.selectModel({ @@ -133,12 +127,11 @@ describe('useModelControls', () => { }) it('session-scopes MoA preset selections so they cannot persist as the global gateway default', async () => { + $activeSessionId.set('session-1') const requestGateway = vi.fn(async () => ({ key: 'model', value: 'BeastMode' }) as never) let controls!: Controls - render( - (controls = value)} requestGateway={requestGateway} /> - ) + render( (controls = value)} requestGateway={requestGateway} />) await expect( controls.selectModel({ @@ -158,7 +151,7 @@ describe('useModelControls', () => { const requestGateway = vi.fn() let controls!: Controls - render( (controls = value)} requestGateway={requestGateway} />) + render( (controls = value)} requestGateway={requestGateway} />) await expect( controls.selectModel({ @@ -180,7 +173,6 @@ describe('useModelControls', () => { const { result } = renderHook(() => useModelControls({ - activeSessionId: null, queryClient: new QueryClient(), requestGateway: vi.fn() }) diff --git a/apps/desktop/src/app/session/hooks/use-model-controls.ts b/apps/desktop/src/app/session/hooks/use-model-controls.ts index b302a5fa4..3296c9c97 100644 --- a/apps/desktop/src/app/session/hooks/use-model-controls.ts +++ b/apps/desktop/src/app/session/hooks/use-model-controls.ts @@ -13,26 +13,30 @@ interface ModelSelection { } interface ModelControlsOptions { - activeSessionId: string | null queryClient: QueryClient requestGateway: (method: string, params?: Record) => Promise } -export function useModelControls({ activeSessionId, queryClient, requestGateway }: ModelControlsOptions) { +export function useModelControls({ queryClient, requestGateway }: ModelControlsOptions) { const { t } = useI18n() const copy = t.desktop + // All callbacks here read reactive session state from the store (.get()) + // rather than capturing it as a prop. The actions bag in wiring.tsx mutates + // in place to keep a stable identity, so memoized surfaces capture these + // callbacks once and never re-evaluate — a captured prop would be stale + // forever. The store read is always current. const updateModelOptionsCache = useCallback( (provider: string, model: string, includeGlobal: boolean) => { const patch = (prev: ModelOptionsResponse | undefined) => ({ ...(prev ?? {}), provider, model }) - queryClient.setQueryData(['model-options', activeSessionId || 'global'], patch) + queryClient.setQueryData(['model-options', $activeSessionId.get() || 'global'], patch) if (includeGlobal) { queryClient.setQueryData(['model-options', 'global'], patch) } }, - [activeSessionId, queryClient] + [queryClient] ) // Seed the composer's model state from the profile default. `force` reseeds @@ -82,36 +86,38 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway const prevModel = $currentModel.get() const prevProvider = $currentProvider.get() + const liveSessionId = $activeSessionId.get() + setCurrentModel(selection.model) setCurrentProvider(selection.provider) - updateModelOptionsCache(selection.provider, selection.model, !activeSessionId) + updateModelOptionsCache(selection.provider, selection.model, !liveSessionId) // No live session yet: the pick is pure UI state. session.create reads // $currentModel/$currentProvider and applies it as that session's override. - if (!activeSessionId) { + if (!liveSessionId) { return true } try { await requestGateway('config.set', { - session_id: activeSessionId, + session_id: liveSessionId, key: 'model', value: `${selection.model} --provider ${selection.provider} --session` }) - void queryClient.invalidateQueries({ queryKey: ['model-options', activeSessionId] }) + void queryClient.invalidateQueries({ queryKey: ['model-options', liveSessionId] }) return true } catch (err) { setCurrentModel(prevModel) setCurrentProvider(prevProvider) - updateModelOptionsCache(prevProvider, prevModel, !activeSessionId) + updateModelOptionsCache(prevProvider, prevModel, !liveSessionId) notifyError(err, copy.modelSwitchFailed) return false } }, - [activeSessionId, copy.modelSwitchFailed, queryClient, requestGateway, updateModelOptionsCache] + [copy.modelSwitchFailed, queryClient, requestGateway, updateModelOptionsCache] ) return { refreshCurrentModel, selectModel, updateModelOptionsCache }