Skip to content

Commit 81716ff

Browse files
committed
fix(chat): carry attachments through the cross-mount send handoff
The recoverable-abort delivery excluded attachment-bearing sends, so they restored under the dead instance's pending key and were silently lost. The claimable send event now carries fileAttachments end to end (dispatcher, home listener, restore path); only the storage fallback — whose shape cannot hold attachments — still queue-restores them.
1 parent d078e82 commit 81716ff

4 files changed

Lines changed: 36 additions & 21 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/home.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,7 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps)
341341
const detail = (e as CustomEvent<MothershipSendMessageDetail>).detail
342342
if (!detail?.message) return
343343
e.preventDefault()
344-
sendMessage(detail.message, undefined, detail.contexts)
344+
sendMessage(detail.message, detail.fileAttachments, detail.contexts)
345345
}
346346
window.addEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler)
347347
return () => window.removeEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler)

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

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,24 +144,33 @@ describe('useChat mount-settling send recovery', () => {
144144
})
145145

146146
it('delivers an aborted chatless send directly to a live replacement surface', async () => {
147-
const received: string[] = []
147+
const attachment = {
148+
id: 'file-1',
149+
key: 'uploads/file-1',
150+
filename: 'notes.txt',
151+
media_type: 'text/plain',
152+
size: 12,
153+
}
154+
const received: Array<{ message: string; fileAttachments?: unknown[] }> = []
148155
const claim = (event: Event) => {
149-
received.push((event as CustomEvent<{ message: string }>).detail.message)
156+
const detail = (event as CustomEvent<{ message: string; fileAttachments?: unknown[] }>).detail
157+
received.push(detail)
150158
event.preventDefault()
151159
}
152160
window.addEventListener('mothership-send-message', claim)
153161

154162
try {
155163
const { getResult, unmount } = renderUseChat()
156164
await act(async () => {
157-
void getResult().sendMessage('hello from the palette')
165+
void getResult().sendMessage('hello from the palette', [attachment])
158166
})
159167
await waitFor(() => state.postCalls === 1)
160168

161169
unmount()
162170
await waitFor(() => received.length === 1)
163171

164-
expect(received).toEqual(['hello from the palette'])
172+
expect(received[0].message).toBe('hello from the palette')
173+
expect(received[0].fileAttachments).toEqual([attachment])
165174
expect(window.localStorage.getItem('sim_mothership_handoff')).toBeNull()
166175
} finally {
167176
window.removeEventListener('mothership-send-message', claim)

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

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4505,29 +4505,27 @@ export function useChat(
45054505
/* A pending (chatless) surface regenerates its chat key per mount, and
45064506
the cleanup that aborted this send belongs to a full remount — a
45074507
queue restore would orphan the message under the dead instance's
4508-
key. Re-persist it as a one-shot handoff instead: the next mount's
4509-
consumer re-sends it. Chat-bound sends keep the queue restore (their
4510-
key is the stable chat id). Attachment payloads exceed what the
4511-
handoff carries, so they fall back to the queue restore. */
4512-
if (
4513-
recoverableCleanupAbort &&
4514-
dispatchChatKey.startsWith(PENDING_CHAT_KEY_PREFIX) &&
4515-
!msg.fileAttachments?.length
4516-
) {
4517-
/* The settling remount has already run its mount effects by the time
4518-
this microtask executes, so the replacement surface's send listener
4519-
is live — deliver directly. The stored-handoff fallback covers a
4520-
real navigation away, where the next mount's consumer picks it up. */
4521-
if (!sendMothershipMessage(msg.content, msg.contexts)) {
4508+
key. Deliver to the replacement surface instead: its send listener
4509+
is live by the time this microtask executes, and the event carries
4510+
attachments. When nothing claims it (a real navigation away), a
4511+
one-shot handoff covers attachment-less sends for the next mount;
4512+
attachment payloads exceed what the handoff carries and fall back to
4513+
the queue restore. Chat-bound sends always keep the queue restore
4514+
(their key is the stable chat id). */
4515+
if (recoverableCleanupAbort && dispatchChatKey.startsWith(PENDING_CHAT_KEY_PREFIX)) {
4516+
if (sendMothershipMessage(msg.content, msg.contexts, msg.fileAttachments)) {
4517+
return
4518+
}
4519+
if (!msg.fileAttachments?.length) {
45224520
MothershipHandoffStorage.store(
45234521
{
45244522
message: msg.content,
45254523
...(msg.contexts?.length ? { contexts: msg.contexts } : {}),
45264524
},
45274525
workspaceId
45284526
)
4527+
return
45294528
}
4530-
return
45314529
}
45324530
useMothershipQueueStore.getState().insertAt(dispatchChatKey, originalIndex, msg)
45334531
}

apps/sim/lib/mothership/events.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createLogger } from '@sim/logger'
2+
import type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types'
23
import type { ChatContext } from '@/stores/panel'
34

45
const logger = createLogger('MothershipEvents')
@@ -24,6 +25,8 @@ export interface MothershipSendMessageDetail {
2425
message: string
2526
/** Structured contexts to attach — e.g. a `logs` mention tagging a run. */
2627
contexts?: ChatContext[]
28+
/** Already-uploaded attachments riding along with the message. */
29+
fileAttachments?: FileAttachmentForApi[]
2730
}
2831

2932
/**
@@ -35,7 +38,11 @@ export interface MothershipSendMessageDetail {
3538
* was listening — callers that can fall back (e.g. cross-route navigation) use
3639
* this to decide whether to persist a handoff instead.
3740
*/
38-
export function sendMothershipMessage(message: string, contexts?: ChatContext[]): boolean {
41+
export function sendMothershipMessage(
42+
message: string,
43+
contexts?: ChatContext[],
44+
fileAttachments?: FileAttachmentForApi[]
45+
): boolean {
3946
const trimmed = message.trim()
4047
if (!trimmed) {
4148
logger.warn('sendMothershipMessage called with empty message')
@@ -44,6 +51,7 @@ export function sendMothershipMessage(message: string, contexts?: ChatContext[])
4451
const consumed = dispatchClaimable<MothershipSendMessageDetail>(MOTHERSHIP_SEND_MESSAGE_EVENT, {
4552
message: trimmed,
4653
contexts,
54+
fileAttachments,
4755
})
4856
logger.info('Dispatched mothership message event', { messageLength: trimmed.length, consumed })
4957
return consumed

0 commit comments

Comments
 (0)