fix(desktop): model picker reverts in existing threads (#65777)

selectModel in use-model-controls captured activeSessionId as a closure
prop, but the actions bag in wiring.tsx mutates in place to keep a stable
identity for memoized surfaces. The modelMenuContent useMemo captures
selectModel once when the gateway first opens (before any session is
active) and never re-evaluates, so clicking a model in an existing thread
goes through a stale closure with activeSessionId=null — the pick is
treated as UI-only, config.set is never sent, and the next session.info
event clobbers the optimistic update back to the session's real model.

Drop the activeSessionId prop entirely. All three callbacks now read
$activeSessionId.get() live from the store, matching the pattern
refreshCurrentModel already followed. This is the correct contract for
the actions bag's in-place mutation: callbacks read live state from the
store, not from captured props.
This commit is contained in:
ethernet 2026-07-16 12:17:41 -04:00 committed by GitHub
parent 6cd5a2c5f7
commit 659d1123c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 21 additions and 24 deletions

View file

@ -227,7 +227,6 @@ export function ContribWiring({ children }: { children: ReactNode }) {
})
const { refreshCurrentModel, selectModel, updateModelOptionsCache } = useModelControls({
activeSessionId,
queryClient,
requestGateway
})

View file

@ -32,16 +32,13 @@ vi.mock('@/store/notifications', () => ({
type Controls = ReturnType<typeof useModelControls>
function Harness({
activeSessionId,
onReady,
requestGateway
}: {
activeSessionId: string | null
onReady: (controls: Controls) => void
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
}) {
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(
<Harness activeSessionId="session-1" onReady={value => (controls = value)} requestGateway={requestGateway} />
)
render(<Harness onReady={value => (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(
<Harness activeSessionId="session-1" onReady={value => (controls = value)} requestGateway={requestGateway} />
)
render(<Harness onReady={value => (controls = value)} requestGateway={requestGateway} />)
await expect(
controls.selectModel({
@ -158,7 +151,7 @@ describe('useModelControls', () => {
const requestGateway = vi.fn()
let controls!: Controls
render(<Harness activeSessionId={null} onReady={value => (controls = value)} requestGateway={requestGateway} />)
render(<Harness onReady={value => (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()
})

View file

@ -13,26 +13,30 @@ interface ModelSelection {
}
interface ModelControlsOptions {
activeSessionId: string | null
queryClient: QueryClient
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
}
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<ModelOptionsResponse>(['model-options', activeSessionId || 'global'], patch)
queryClient.setQueryData<ModelOptionsResponse>(['model-options', $activeSessionId.get() || 'global'], patch)
if (includeGlobal) {
queryClient.setQueryData<ModelOptionsResponse>(['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 }