Skip to content

Commit cd23af8

Browse files
committed
fix(chat): hand an aborted chatless send to the next mount
The mount-settling cycle is a full remount — the pending chat key is regenerated per instance, so restoring the aborted send into the dead instance's queue orphaned it (verified live). A chatless send now re-persists as a one-shot MothershipHandoffStorage handoff the next mount's consumer re-sends; chat-bound sends keep the queue restore.
1 parent b840a3a commit cd23af8

2 files changed

Lines changed: 35 additions & 6 deletions

File tree

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

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ vi.mock('@/lib/api/client/request', async (importOriginal) => ({
3030
requestJson: mockRequestJson,
3131
}))
3232

33+
import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage'
3334
import { useChat } from '@/app/workspace/[workspaceId]/home/hooks/use-chat'
3435
import { useMothershipQueueStore } from '@/stores/mothership-queue/store'
3536

@@ -130,6 +131,7 @@ describe('useChat mount-settling send recovery', () => {
130131
mockRequestJson.mockResolvedValue({ chats: [] })
131132
useMothershipQueueStore.setState({ queues: {}, editing: {} })
132133
window.sessionStorage.clear()
134+
window.localStorage.clear()
133135
})
134136

135137
afterEach(() => {
@@ -141,7 +143,7 @@ describe('useChat mount-settling send recovery', () => {
141143
vi.clearAllMocks()
142144
})
143145

144-
it('restores a send the unmount cleanup aborted before the server received it', async () => {
146+
it('re-persists an aborted chatless send as a handoff for the next mount', async () => {
145147
const { getResult, unmount } = renderUseChat()
146148

147149
await act(async () => {
@@ -152,12 +154,16 @@ describe('useChat mount-settling send recovery', () => {
152154
// The dispatch claimed the queue head when the optimistic send applied.
153155
expect(allQueuedMessages()).toHaveLength(0)
154156

155-
// The cleanup abort (same code path a Suspense hide/reveal runs during
156-
// mount-settling) fires while the POST is still awaiting the server.
157+
// The cleanup abort (the same code path the mount-settling remount runs)
158+
// fires while the POST is still awaiting the server. A chatless surface
159+
// regenerates its queue key per mount, so recovery re-persists the send
160+
// as a one-shot handoff for the next mount's consumer instead of
161+
// restoring the dead instance's queue.
157162
unmount()
158-
await waitFor(() => allQueuedMessages().length === 1)
163+
await waitFor(() => window.localStorage.getItem('sim_mothership_handoff') !== null)
159164

160-
expect(allQueuedMessages()[0].content).toBe('hello from the palette')
165+
expect(allQueuedMessages()).toHaveLength(0)
166+
expect(MothershipHandoffStorage.consume('ws-1')?.message).toBe('hello from the palette')
161167
})
162168

163169
it('does not re-queue a send the server already received', async () => {
@@ -175,5 +181,6 @@ describe('useChat mount-settling send recovery', () => {
175181
})
176182

177183
expect(allQueuedMessages()).toHaveLength(0)
184+
expect(MothershipHandoffStorage.consume('ws-1')).toBeNull()
178185
})
179186
})

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

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ import { executeTerminalToolOnClient } from '@/lib/copilot/tools/client/terminal
8282
import { setCurrentChatTraceparent } from '@/lib/copilot/tools/client/trace-context'
8383
import { isUserLocalVfsToolCall } from '@/lib/copilot/tools/local-filesystem'
8484
import { isWorkflowToolName } from '@/lib/copilot/tools/workflow-tools'
85+
import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage'
8586
import { readSSELines } from '@/lib/core/utils/sse'
8687
import { getDesktopBridge, getDesktopChatCapabilities } from '@/lib/desktop'
8788
import {
@@ -4505,6 +4506,27 @@ export function useChat(
45054506
if (userRemovedDuringDispatchRef.current.delete(msg.id)) {
45064507
return
45074508
}
4509+
/* A pending (chatless) surface regenerates its chat key per mount, and
4510+
the cleanup that aborted this send belongs to a full remount — a
4511+
queue restore would orphan the message under the dead instance's
4512+
key. Re-persist it as a one-shot handoff instead: the next mount's
4513+
consumer re-sends it. Chat-bound sends keep the queue restore (their
4514+
key is the stable chat id). Attachment payloads exceed what the
4515+
handoff carries, so they fall back to the queue restore. */
4516+
if (
4517+
restorableCleanupAbortRef.current &&
4518+
dispatchChatKey.startsWith(PENDING_CHAT_KEY_PREFIX) &&
4519+
!msg.fileAttachments?.length
4520+
) {
4521+
MothershipHandoffStorage.store(
4522+
{
4523+
message: msg.content,
4524+
...(msg.contexts?.length ? { contexts: msg.contexts } : {}),
4525+
},
4526+
workspaceId
4527+
)
4528+
return
4529+
}
45084530
useMothershipQueueStore.getState().insertAt(dispatchChatKey, originalIndex, msg)
45094531
}
45104532

@@ -4545,7 +4567,7 @@ export function useChat(
45454567
userRemovedDuringDispatchRef.current.delete(msg.id)
45464568
}
45474569
},
4548-
[startSendMessage]
4570+
[startSendMessage, workspaceId]
45494571
)
45504572

45514573
const runQueueDispatchLoop = useCallback(async () => {

0 commit comments

Comments
 (0)