Skip to content

Commit e84eca6

Browse files
committed
fix(mothership): keep the latest effort pick through refetches, failed saves and abandoned new chats
1 parent e04080d commit e84eca6

7 files changed

Lines changed: 89 additions & 37 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/model-selector.tsx‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use client'
22

3+
import { useEffect } from 'react'
34
import {
45
DropdownMenu,
56
DropdownMenuContent,
@@ -33,10 +34,13 @@ export function ModelSelector() {
3334
const setFastMode = useMothershipEffortStore((state) => state.setFastMode)
3435
const { chatId } = useChatSurface()
3536
const { data: chatHistory } = useMothershipChatHistory(chatId)
37+
const chatPick = useMothershipEffortStore((state) =>
38+
chatId ? state.chatEfforts[chatId] : undefined
39+
)
3640
const newChatEffort = useMothershipEffortStore((state) => state.newChatEffort)
3741
const setNewChatEffort = useMothershipEffortStore((state) => state.setNewChatEffort)
38-
const { mutate: setChatEffort } = useSetMothershipChatEffort()
39-
const effortChoice = chatId ? chatHistory?.effort : newChatEffort
42+
const { mutate: saveChatEffort } = useSetMothershipChatEffort(chatId)
43+
const effortChoice = chatId ? (chatPick ?? chatHistory?.effort) : newChatEffort
4044
const { effort, modelSelection } = resolveMothershipModelSettings(
4145
{ effort: effortChoice ?? undefined, modelSelection: selection },
4246
advanced
@@ -45,9 +49,15 @@ export function ModelSelector() {
4549
? mothershipEffortOptions(modelSelection.model)
4650
: MOTHERSHIP_SIMPLE_EFFORT_OPTIONS
4751
const setEffort = (choice: MothershipEffort) => {
48-
if (chatId) setChatEffort({ chatId, effort: choice })
52+
if (chatId) saveChatEffort(choice)
4953
else setNewChatEffort(choice)
5054
}
55+
56+
useEffect(() => {
57+
if (chatId) return
58+
return () => useMothershipEffortStore.getState().setNewChatEffort(null)
59+
}, [chatId])
60+
5161
const effortLabel = options.find((option) => option.value === effort)?.label ?? effort
5262
const modelLabel =
5363
MOTHERSHIP_MODEL_OPTIONS.find((option) => option.value === modelSelection.model)?.label ??

‎apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts‎

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3396,6 +3396,18 @@ export function useChat(
33963396

33973397
let requestChatId =
33983398
queuedSendHandoff?.chatId ?? selectedChatIdRef.current ?? chatIdRef.current
3399+
// Read before the composer can unmount. Sent only when picked; otherwise the server
3400+
// uses the chat's stored pick or the default.
3401+
const effortStore = useMothershipEffortStore.getState()
3402+
const effortChoice =
3403+
options?.requestMode === 'assistant'
3404+
? undefined
3405+
: requestChatId
3406+
? (effortStore.chatEfforts[requestChatId] ??
3407+
queryClient.getQueryData<MothershipChatHistory>(
3408+
mothershipChatKeys.detail(requestChatId)
3409+
)?.effort)
3410+
: effortStore.newChatEffort
33993411
const writeQueuedSendHandoff = (chatId?: string) => {
34003412
if (!queuedSendHandoff) return
34013413
if (!chatId && !queuedSendHandoff.supersededStreamId) return
@@ -3681,12 +3693,6 @@ export function useChat(
36813693
? {}
36823694
: await getDesktopChatCapabilities(desktopScopeIdRef.current)
36833695

3684-
// Sent only when picked; otherwise the server uses the chat's stored pick or the default.
3685-
const effortChoice = requestChatId
3686-
? queryClient.getQueryData<MothershipChatHistory>(
3687-
mothershipChatKeys.detail(requestChatId)
3688-
)?.effort
3689-
: useMothershipEffortStore.getState().newChatEffort
36903696
const response = await fetch(apiPathRef.current, {
36913697
method: 'POST',
36923698
headers: { 'Content-Type': 'application/json' },
@@ -3734,13 +3740,8 @@ export function useChat(
37343740
return consumedByTranscript
37353741
}
37363742
if (admittedChatId && !requestChatId) {
3737-
if (effortChoice) {
3738-
queryClient.setQueryData<MothershipChatHistory>(
3739-
mothershipChatKeys.detail(admittedChatId),
3740-
(current) => current && { ...current, effort: effortChoice }
3741-
)
3742-
useMothershipEffortStore.getState().setNewChatEffort(null)
3743-
}
3743+
if (effortChoice)
3744+
useMothershipEffortStore.getState().adoptNewChatEffort(admittedChatId, effortChoice)
37443745
requestChatId = admittedChatId
37453746
streamTargetChatId = admittedChatId
37463747
adoptResolvedChatId(admittedChatId, { replaceHomeHistory: true, invalidateList: true })
@@ -3789,6 +3790,8 @@ export function useChat(
37893790
const conflictChatId =
37903791
typeof errorData.chatId === 'string' ? errorData.chatId : undefined
37913792
if (conflictChatId && !streamTargetChatId) {
3793+
if (effortChoice)
3794+
useMothershipEffortStore.getState().adoptNewChatEffort(conflictChatId, effortChoice)
37923795
adoptResolvedChatId(conflictChatId, {
37933796
replaceHomeHistory: true,
37943797
invalidateList: true,

‎apps/sim/hooks/queries/mothership-chats.ts‎

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
} from '@/lib/mothership/request/session/file-preview-session-contract'
3636
import { isStreamBatchEvent, type StreamBatchEvent } from '@/lib/mothership/request/session/types'
3737
import type { MothershipResource } from '@/lib/mothership/resources/types'
38+
import { useMothershipEffortStore } from '@/stores/mothership-effort/store'
3839
import { useMothershipQueueStore } from '@/stores/mothership-queue/store'
3940

4041
export interface MothershipChatMetadata {
@@ -598,27 +599,27 @@ async function setChatEffort({
598599
}
599600

600601
/**
601-
* Records the effort the user picked for a chat, shown immediately. Success needs no
602-
* refetch: the server stores exactly the picked value, and refetching the detail would
603-
* reload the whole transcript, possibly mid-stream.
602+
* Records the effort the user picked for a chat. The pick shows and sends at once from the
603+
* session's pick map; saves for one chat run one at a time so the last pick is the one stored.
604604
*/
605-
export function useSetMothershipChatEffort() {
605+
export function useSetMothershipChatEffort(chatId: string | undefined) {
606606
const queryClient = useQueryClient()
607607
return useMutation({
608-
mutationFn: setChatEffort,
609-
onMutate: async ({ chatId, effort }) => {
610-
const queryKey = mothershipChatKeys.detail(chatId)
611-
const previousEffort = queryClient.getQueryData<MothershipChatHistory>(queryKey)?.effort
612-
queryClient.setQueryData<MothershipChatHistory>(
613-
queryKey,
614-
(current) => current && { ...current, effort }
615-
)
616-
return { previousEffort }
608+
mutationFn: (effort: MothershipEffort) => {
609+
if (!chatId) throw new Error('A chat effort needs a chat')
610+
return setChatEffort({ chatId, effort })
617611
},
618-
onError: (_error, { chatId }, context) => {
612+
scope: { id: `mothership-chat-effort:${chatId ?? ''}` },
613+
onMutate: (effort) => {
614+
if (chatId) useMothershipEffortStore.getState().setChatEffort(chatId, effort)
615+
},
616+
onError: (_error, effort) => {
617+
if (chatId) useMothershipEffortStore.getState().dropChatEffort(chatId, effort)
618+
},
619+
onSuccess: (_data, effort) => {
619620
queryClient.setQueryData<MothershipChatHistory>(
620621
mothershipChatKeys.detail(chatId),
621-
(current) => current && { ...current, effort: context?.previousEffort }
622+
(current) => current && { ...current, effort }
622623
)
623624
},
624625
})

‎apps/sim/lib/mothership/chat/post.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -845,6 +845,7 @@ describe('handleUnifiedChatPost', () => {
845845
{ stored: 'high', sent: undefined, runs: 'high' },
846846
{ stored: 'low', sent: undefined, runs: 'low' },
847847
{ stored: 'high', sent: 'low', runs: 'low' },
848+
{ stored: 'high', sent: 'max', runs: 'xhigh' },
848849
] as const)(
849850
'runs a chat whose stored effort choice is $stored at $runs when the send names $sent',
850851
async ({ stored, sent, runs }) => {
@@ -869,7 +870,7 @@ describe('handleUnifiedChatPost', () => {
869870
expect(buildCopilotRequestPayload).toHaveBeenCalledWith(
870871
expect.objectContaining({ effort: runs })
871872
)
872-
expect(admitTurn.mock.calls[0][0].input.effortChoice).toBe(sent)
873+
expect(admitTurn.mock.calls[0][0].input.effortChoice).toBe(sent && runs)
873874
}
874875
)
875876

‎apps/sim/lib/mothership/chat/post.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1480,7 +1480,8 @@ export async function handleUnifiedChatPost(req: NextRequest) {
14801480
requestMode: body.mode,
14811481
},
14821482
notifyWorkspaceStatus: branch.notifyChatStatus,
1483-
effortChoice,
1483+
// The effort this turn actually runs at, so the stored pick is always one it can use.
1484+
effortChoice: effortChoice && body.effort,
14841485
},
14851486
})
14861487
// Admission committed. A failure to attach this HTTP sink must leave the turn recoverable.

‎apps/sim/stores/mothership-effort/store.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,14 @@ describe('Build reasoning preferences', () => {
3939
modelSelection: { model: 'gpt-6-astra', fastMode: true },
4040
})
4141
})
42+
43+
it('keeps a newer chat pick when an older pick fails to save', () => {
44+
const store = useMothershipEffortStore.getState()
45+
store.setChatEffort('chat-1', 'low')
46+
store.setChatEffort('chat-1', 'high')
47+
store.dropChatEffort('chat-1', 'low')
48+
expect(useMothershipEffortStore.getState().chatEfforts).toEqual({ 'chat-1': 'high' })
49+
store.dropChatEffort('chat-1', 'high')
50+
expect(useMothershipEffortStore.getState().chatEfforts).toEqual({})
51+
})
4252
})

‎apps/sim/stores/mothership-effort/store.ts‎

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { toRecord } from '@sim/utils/object'
1+
import { omit, toRecord } from '@sim/utils/object'
22
import { create } from 'zustand'
33
import { devtools, persist } from 'zustand/middleware'
44
import { type ModelSelection, ModelSelectionSchema } from '@/lib/mothership/generated/protocol'
@@ -13,17 +13,31 @@ interface MothershipEffortState {
1313
setFastMode: (fastMode: boolean) => void
1414
/**
1515
* The effort picked in a composer whose chat does not exist yet. Its first send records
16-
* it on the new chat; existing chats keep their own choice on the chat itself.
16+
* it on the new chat; leaving that composer unsent drops it.
1717
*/
1818
newChatEffort: MothershipEffort | null
1919
setNewChatEffort: (effort: MothershipEffort | null) => void
20+
/**
21+
* Picks made in existing chats this session, by chat id. They win over the chat's loaded
22+
* value, so a detail refetch or a save still in flight never shows or sends an older one.
23+
*/
24+
chatEfforts: Record<string, MothershipEffort>
25+
setChatEffort: (chatId: string, effort: MothershipEffort) => void
26+
/** Drops a pick whose save failed, unless a newer pick replaced it. */
27+
dropChatEffort: (chatId: string, effort: MothershipEffort) => void
28+
/** Moves the new-chat pick onto the chat its first send created. */
29+
adoptNewChatEffort: (chatId: string, effort: MothershipEffort) => void
2030
reset: () => void
2131
}
2232

23-
const initialState = {
33+
const initialState: Pick<
34+
MothershipEffortState,
35+
'modelSelection' | 'newChatEffort' | 'chatEfforts'
36+
> = {
2437
modelSelection: { model: 'gpt-6-astra', fastMode: false },
2538
newChatEffort: null,
26-
} satisfies Pick<MothershipEffortState, 'modelSelection' | 'newChatEffort'>
39+
chatEfforts: {},
40+
}
2741

2842
function withModelSelection(
2943
modelSelection: ModelSelection
@@ -41,6 +55,18 @@ export const useMothershipEffortStore = create<MothershipEffortState>()(
4155
setModel: (model) =>
4256
set((state) => withModelSelection({ model, fastMode: state.modelSelection.fastMode })),
4357
setNewChatEffort: (newChatEffort) => set({ newChatEffort }),
58+
setChatEffort: (chatId, effort) =>
59+
set((state) => ({ chatEfforts: { ...state.chatEfforts, [chatId]: effort } })),
60+
dropChatEffort: (chatId, effort) =>
61+
set((state) => {
62+
if (state.chatEfforts[chatId] !== effort) return state
63+
return { chatEfforts: omit(state.chatEfforts, [chatId]) }
64+
}),
65+
adoptNewChatEffort: (chatId, effort) =>
66+
set((state) => ({
67+
newChatEffort: null,
68+
chatEfforts: { ...state.chatEfforts, [chatId]: effort },
69+
})),
4470
reset: () => set(initialState),
4571
}),
4672
{

0 commit comments

Comments
 (0)