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:
parent
6cd5a2c5f7
commit
659d1123c4
3 changed files with 21 additions and 24 deletions
|
|
@ -227,7 +227,6 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
|||
})
|
||||
|
||||
const { refreshCurrentModel, selectModel, updateModelOptionsCache } = useModelControls({
|
||||
activeSessionId,
|
||||
queryClient,
|
||||
requestGateway
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue