Skip to content

Commit a766a03

Browse files
authored
fix(mothership): keep a new chat's effort pick changed while its first send is pending (#8662)
* fix(mothership): keep a new chat's effort pick changed while its first send is pending * fix(mothership): keep effort saves per chat and save the pick on dedup adoption * fix(mothership): detach the effort save observer once it settles
1 parent c98e3c7 commit a766a03

3 files changed

Lines changed: 93 additions & 6 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.dom.test.tsx‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2000,6 +2000,59 @@ describe('useChat remount send recovery', () => {
20002000
expect(state.postBodies).toHaveLength(0)
20012001
})
20022002

2003+
it.each([
2004+
{ pendingPick: 'high', kept: 'high', saved: true },
2005+
{ pendingPick: 'low', kept: 'low', saved: false },
2006+
] as const)(
2007+
'keeps a new chat effort picked while its first send is pending ($pendingPick)',
2008+
async ({ pendingPick, kept, saved }) => {
2009+
mockRequestJson.mockClear()
2010+
useMothershipEffortStore.getState().reset()
2011+
useMothershipEffortStore.getState().setNewChatEffort('low')
2012+
const { getResult } = renderUseChat()
2013+
await act(async () => {
2014+
void getResult().sendMessage('Pick while pending')
2015+
})
2016+
await waitFor(() => state.postBodies.length === 1)
2017+
expect(state.postBodies[0]).toMatchObject({ effort: 'low' })
2018+
2019+
useMothershipEffortStore.getState().setNewChatEffort(pendingPick)
2020+
const userMessageId = state.postBodies[0].userMessageId ?? ''
2021+
await act(async () => {
2022+
state.pendingAdmissions.get(userMessageId)?.()
2023+
})
2024+
await waitFor(() => !getResult().isSending)
2025+
2026+
expect(useMothershipEffortStore.getState().chatEfforts[DEDUPED_CHAT_ID]?.effort).toBe(kept)
2027+
const saves = mockRequestJson.mock.calls.filter(
2028+
([contract]) => contract.path === '/api/mothership/chats/[chatId]/effort'
2029+
)
2030+
expect(saves.map(([, input]) => input)).toEqual(
2031+
saved ? [{ params: { chatId: DEDUPED_CHAT_ID }, body: { effort: kept } }] : []
2032+
)
2033+
}
2034+
)
2035+
2036+
it('saves the latest new-chat effort to the chat a deduplicated send names', async () => {
2037+
mockRequestJson.mockClear()
2038+
useMothershipEffortStore.getState().reset()
2039+
useMothershipEffortStore.getState().setNewChatEffort('high')
2040+
state.postBehavior = 'deduped'
2041+
const { getResult } = renderUseChat()
2042+
await act(async () => {
2043+
void getResult().sendMessage('Retry of an admitted send')
2044+
})
2045+
await waitFor(() => state.postBodies.length === 1 && !getResult().isSending)
2046+
2047+
expect(useMothershipEffortStore.getState().chatEfforts[DEDUPED_CHAT_ID]?.effort).toBe('high')
2048+
const saves = mockRequestJson.mock.calls.filter(
2049+
([contract]) => contract.path === '/api/mothership/chats/[chatId]/effort'
2050+
)
2051+
expect(saves.map(([, input]) => input)).toEqual([
2052+
{ params: { chatId: DEDUPED_CHAT_ID }, body: { effort: 'high' } },
2053+
])
2054+
})
2055+
20032056
it('loads the saved transcript once when its own stream completes', async () => {
20042057
const chatId = 'chat-own-completion'
20052058
const history: MothershipChatHistory = {

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

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ import {
115115
fetchMothershipChatHistory,
116116
type MothershipChatHistory,
117117
mothershipChatKeys,
118+
saveMothershipChatEffort,
118119
useMothershipChatHistory,
119120
} from '@/hooks/queries/mothership-chats'
120121
import { fetchWorkflowEnvelope } from '@/hooks/queries/utils/fetch-workflow-envelope'
@@ -3435,6 +3436,18 @@ export function useChat(
34353436
mothershipChatKeys.detail(requestChatId)
34363437
)?.effort)
34373438
: effortStore.newChatEffort
3439+
/* Moves the new-chat pick onto the chat a send opened and saves it there unless it is
3440+
the pick this send's admission stored. A pick changed while the send was pending,
3441+
or a chat an earlier attempt opened with an unknown pick, gets the latest one. */
3442+
const adoptNewChatEffort = (chatId: string, admittedThisSend: boolean) => {
3443+
if (options?.requestMode === 'assistant') return
3444+
const store = useMothershipEffortStore.getState()
3445+
const latestChoice = store.newChatEffort ?? effortChoice
3446+
if (!latestChoice) return
3447+
store.adoptNewChatEffort(chatId, latestChoice)
3448+
if (!admittedThisSend || latestChoice !== effortChoice)
3449+
saveMothershipChatEffort(queryClient, chatId, latestChoice)
3450+
}
34383451
const writeQueuedSendHandoff = (chatId?: string) => {
34393452
if (!queuedSendHandoff) return
34403453
if (!chatId && !queuedSendHandoff.supersededStreamId) return
@@ -3777,8 +3790,7 @@ export function useChat(
37773790
return consumedByTranscript
37783791
}
37793792
if (admittedChatId && !requestChatId) {
3780-
if (effortChoice)
3781-
useMothershipEffortStore.getState().adoptNewChatEffort(admittedChatId, effortChoice)
3793+
adoptNewChatEffort(admittedChatId, true)
37823794
requestChatId = admittedChatId
37833795
streamTargetChatId = admittedChatId
37843796
adoptResolvedChatId(admittedChatId, { replaceHomeHistory: true, invalidateList: true })
@@ -3827,9 +3839,7 @@ export function useChat(
38273839
const conflictChatId =
38283840
typeof errorData.chatId === 'string' ? errorData.chatId : undefined
38293841
if (conflictChatId && !streamTargetChatId) {
3830-
// The retry carries the same pick the first attempt stored on that chat.
3831-
if (effortChoice)
3832-
useMothershipEffortStore.getState().adoptNewChatEffort(conflictChatId, effortChoice)
3842+
adoptNewChatEffort(conflictChatId, false)
38333843
adoptResolvedChatId(conflictChatId, {
38343844
replaceHomeHistory: true,
38353845
invalidateList: true,

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

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import { toError } from '@sim/utils/errors'
22
import { isRecordLike } from '@sim/utils/object'
33
import {
44
keepPreviousData,
5+
MutationObserver,
6+
mutationOptions,
7+
type QueryClient,
58
queryOptions,
69
skipToken,
710
useMutation,
@@ -604,7 +607,28 @@ async function setChatEffort({
604607
*/
605608
export function useSetMothershipChatEffort(chatId: string | undefined) {
606609
const queryClient = useQueryClient()
607-
return useMutation({
610+
return useMutation(chatEffortMutationOptions(queryClient, chatId))
611+
}
612+
613+
/**
614+
* Saves a pick for a chat learned outside render, such as the chat a send just opened. It
615+
* shares the hook's per-chat scope, so it lands in order with picks made in the composer.
616+
*/
617+
export function saveMothershipChatEffort(
618+
queryClient: QueryClient,
619+
chatId: string,
620+
effort: MothershipEffort
621+
): void {
622+
const observer = new MutationObserver(queryClient, chatEffortMutationOptions(queryClient, chatId))
623+
// Detaching once the save settles lets the mutation cache collect it.
624+
observer
625+
.mutate(effort)
626+
.catch(() => undefined)
627+
.finally(() => observer.reset())
628+
}
629+
630+
function chatEffortMutationOptions(queryClient: QueryClient, chatId: string | undefined) {
631+
return mutationOptions({
608632
mutationFn: (effort: MothershipEffort) => {
609633
if (!chatId) throw new Error('A chat effort needs a chat')
610634
return setChatEffort({ chatId, effort })

0 commit comments

Comments
 (0)