Skip to content

Commit 999b5eb

Browse files
committed
fix(mothership): keep a chatless surface's queue when it remounts
A follow-up queued on the new-chat surface waits until the first message's chat is known. If the surface remounted first (for example after that message's Stop failed), the queue stayed under the dead mount's key, neither sent nor shown. The unmount now marks that queue held for its surface, and the next mount of the surface adopts it like other held sends.
1 parent e9a6a3f commit 999b5eb

5 files changed

Lines changed: 95 additions & 0 deletions

File tree

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

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2052,6 +2052,57 @@ describe('useChat remount send recovery', () => {
20522052
expect(state.abortBodies[0]).not.toHaveProperty('chatId')
20532053
})
20542054

2055+
/**
2056+
* A follow-up sent on the new-chat surface while the first message's Stop is
2057+
* pending waits in that surface's queue until the first message's chat is
2058+
* known. If the Stop fails and the surface remounts first, the queue key dies
2059+
* with the mount: the next new-chat surface must show the follow-up (and may
2060+
* send it), not leave it under the dead key, neither sent nor visible.
2061+
*/
2062+
it('keeps a follow-up queued behind a failed chatless Stop when the surface remounts', async () => {
2063+
vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => {
2064+
const url = String(input)
2065+
if (url === '/api/mothership/chat' && init?.method === 'POST') {
2066+
state.postBodies.push(JSON.parse(String(init.body)))
2067+
return new Promise<Response>((_, reject) => {
2068+
init.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true })
2069+
})
2070+
}
2071+
if (url.includes('/api/copilot/chat/abort')) {
2072+
state.abortBodies.push(JSON.parse(String(init?.body)))
2073+
return Response.json({ error: 'Internal error' }, { status: 500 })
2074+
}
2075+
if (url.includes('/api/mothership/chat/stream')) {
2076+
return Response.json({ error: 'Internal error' }, { status: 500 })
2077+
}
2078+
return fetchStub(input, init)
2079+
})
2080+
const first = renderUseChat()
2081+
await act(async () => {
2082+
void first.getResult().sendMessage('inspect the workspace')
2083+
})
2084+
await waitFor(() => state.postBodies.length === 1)
2085+
await act(async () => {
2086+
void first
2087+
.getResult()
2088+
.stopGeneration()
2089+
.catch(() => {})
2090+
void first.getResult().sendMessage('Sent while the Stop was failing')
2091+
await sleep(1_000)
2092+
})
2093+
first.unmount()
2094+
2095+
const second = renderUseChat()
2096+
await waitFor(
2097+
() =>
2098+
second
2099+
.getResult()
2100+
.messageQueue.some((message) => message.content === 'Sent while the Stop was failing') ||
2101+
state.postBodies.some((body) => body.message === 'Sent while the Stop was failing'),
2102+
4_000
2103+
)
2104+
})
2105+
20552106
it('stopping a chat preserves an unrelated manual workflow execution', async () => {
20562107
const executionStore = useExecutionStore.getState()
20572108
executionStore.setIsExecuting('manual-workflow', true)

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -770,6 +770,8 @@ export function useChat(
770770
const onlineEventsRef = useRef(0)
771771
/** Identifies this chatless surface across mounts, for the sends it holds. */
772772
const heldSendSurface = `${scopeKey}:${options?.workflowId ?? 'home'}`
773+
const heldSendSurfaceRef = useRef(heldSendSurface)
774+
heldSendSurfaceRef.current = heldSendSurface
773775
const onToolResultRef = useRef(options?.onToolResult)
774776
onToolResultRef.current = options?.onToolResult
775777
const onTitleUpdateRef = useRef(options?.onTitleUpdate)
@@ -5317,6 +5319,14 @@ export function useChat(
53175319
sendingRef.current = false
53185320
// Release the editing slot — the composer it binds to is unmounting.
53195321
useMothershipQueueStore.getState().setEditing(chatKeyRef.current, null)
5322+
/* A chatless mount's queue key dies with it. Messages still queued there
5323+
(behind a first message whose chat is not known yet, or a Stop that
5324+
failed) go to the next mount of this surface, as held sends do. */
5325+
if (chatKeyRef.current.startsWith(PENDING_CHAT_KEY_PREFIX)) {
5326+
useMothershipQueueStore
5327+
.getState()
5328+
.holdForSurface(chatKeyRef.current, heldSendSurfaceRef.current)
5329+
}
53205330
}
53215331
}, [
53225332
cancelActiveStreamRecovery,

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,21 @@ describe('useMothershipQueueStore', () => {
7575
})
7676
})
7777

78+
describe('holdForSurface', () => {
79+
it("hands a dead chatless mount's queue to the next mount of its surface only", () => {
80+
useMothershipQueueStore.getState().enqueue('pending::dead', message('m1'))
81+
useMothershipQueueStore.getState().holdForSurface('pending::dead', 'ws-1:home')
82+
83+
useMothershipQueueStore.getState().adoptHeldSends('pending::other', 'ws-1:workflow-1')
84+
expect(useMothershipQueueStore.getState().queues['pending::other']).toBeUndefined()
85+
86+
useMothershipQueueStore.getState().adoptHeldSends('pending::next', 'ws-1:home')
87+
const state = useMothershipQueueStore.getState()
88+
expect(state.queues['pending::next']?.map((m) => m.id)).toEqual(['m1'])
89+
expect(state.queues['pending::dead']).toBeUndefined()
90+
})
91+
})
92+
7893
describe('migrate', () => {
7994
it('merges into an existing destination bucket instead of overwriting', () => {
8095
useMothershipQueueStore.getState().enqueue('chat-X', message('existing-1'))

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,19 @@ export const useMothershipQueueStore = create<MothershipQueueState>()(
205205
}
206206
}),
207207

208+
holdForSurface: (chatKey, surface) =>
209+
set((state) => {
210+
const queue = state.queues[chatKey]
211+
if (!queue?.some((message) => message.heldSurface !== surface)) return state
212+
return {
213+
queues: setQueueForChat(
214+
state.queues,
215+
chatKey,
216+
queue.map((message) => ({ ...message, heldSurface: surface }))
217+
),
218+
}
219+
}),
220+
208221
clearChat: (chatKey) =>
209222
set((state) => ({
210223
queues: omitKey(state.queues, chatKey),

‎apps/sim/stores/mothership-queue/types.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ export interface MothershipQueueState {
6969
releaseHeldUntilOnline: () => void
7070
/** Moves the sends a dead chatless mount of `surface` held onto `toKey`. */
7171
adoptHeldSends: (toKey: string, surface: string) => void
72+
/**
73+
* Marks everything queued under a chatless mount's key as held for its
74+
* `surface`, as that mount goes away: the key dies with it, and the next
75+
* mount of the surface adopts the messages instead of losing them.
76+
*/
77+
holdForSurface: (chatKey: string, surface: string) => void
7278
clearChat: (chatKey: string) => void
7379
/**
7480
* Lifts the delete an operation saw when it began (`cleared[chatKey]` read

0 commit comments

Comments
 (0)