From 3419c1edd4ba0bdd02c2f035477cd8bfbcc01a84 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 13 May 2026 17:22:43 -0700 Subject: [PATCH 1/3] fix(mothership): persist @-mentioned resources across send and merge on hydration --- .../[workspaceId]/home/hooks/use-chat.ts | 96 ++++++++++++++----- 1 file changed, 71 insertions(+), 25 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 680b96d8a17..95b4e070026 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -5,6 +5,13 @@ import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' import { useQueryClient } from '@tanstack/react-query' import { usePathname, useRouter } from 'next/navigation' +import { requestJson } from '@/lib/api/client/request' +import { + addMothershipChatResourceContract, + removeMothershipChatResourceContract, + reorderMothershipChatResourcesContract, +} from '@/lib/api/contracts/mothership-tasks' +import { cancelWorkflowExecutionContract } from '@/lib/api/contracts/workflows' import { getMothershipAttachmentPreviewUrl } from '@/lib/copilot/chat/attachment-preview' import { toDisplayMessage } from '@/lib/copilot/chat/display-message' import { getLiveAssistantMessageId } from '@/lib/copilot/chat/effective-transcript' @@ -1536,6 +1543,7 @@ export function useChat( }, []) const resourcesRef = useRef(resources) resourcesRef.current = resources + const pendingPersistResourceKeysRef = useRef>(new Set()) // Derive the effective active resource ID — auto-selects the last resource when the stored ID is // absent or no longer in the list, avoiding a separate Effect-based state correction loop. @@ -1962,6 +1970,7 @@ export function useChat( setTransportIdle() setResources([]) setActiveResourceId(null) + pendingPersistResourceKeysRef.current.clear() resetEphemeralPreviewState() setMessageQueue([]) clearQueueDispatchState() @@ -1974,6 +1983,22 @@ export function useChat( setTransportIdle, ]) + const flushPendingResources = useCallback((chatId: string) => { + const pendingKeys = pendingPersistResourceKeysRef.current + if (pendingKeys.size === 0) return + for (const resource of resourcesRef.current) { + const key = `${resource.type}:${resource.id}` + if (!pendingKeys.has(key)) continue + pendingKeys.delete(key) + requestJson(addMothershipChatResourceContract, { body: { chatId, resource } }).catch( + (err) => { + pendingPersistResourceKeysRef.current.add(key) + logger.warn('Failed to flush pending resource; will retry on next hydration', err) + } + ) + } + }, []) + const adoptResolvedChatId = useCallback( (chatId: string, options?: { replaceHomeHistory?: boolean; invalidateList?: boolean }) => { const selectedChatId = selectedChatIdRef.current @@ -1992,8 +2017,9 @@ export function useChat( if (options?.invalidateList) { queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) }) } + flushPendingResources(chatId) }, - [queryClient, workspaceId] + [flushPendingResources, queryClient, workspaceId] ) const { data: chatHistory } = useChatHistory(resolvedChatId) @@ -2018,15 +2044,16 @@ export function useChat( } const persistChatId = chatIdRef.current ?? selectedChatIdRef.current + const key = `${resource.type}:${resource.id}` if (persistChatId) { - // boundary-raw-fetch: fire-and-forget side-effect during stream lifecycle; intentionally avoids requestJson's response parsing/throw semantics so a failure here cannot interrupt the active turn - fetch('/api/mothership/chat/resources', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ chatId: persistChatId, resource }), + requestJson(addMothershipChatResourceContract, { + body: { chatId: persistChatId, resource }, }).catch((err) => { - logger.warn('Failed to persist resource', err) + pendingPersistResourceKeysRef.current.add(key) + logger.warn('Failed to persist resource; will retry on next hydration', err) }) + } else { + pendingPersistResourceKeysRef.current.add(key) } return true }, []) @@ -2035,13 +2062,14 @@ export function useChat( setResources((prev) => prev.filter((r) => !(r.type === resourceType && r.id === resourceId))) setActiveResourceId((prev) => (prev === resourceId ? null : prev)) + const key = `${resourceType}:${resourceId}` + const wasPending = pendingPersistResourceKeysRef.current.delete(key) + if (wasPending) return + const persistChatId = chatIdRef.current ?? selectedChatIdRef.current if (persistChatId) { - // boundary-raw-fetch: fire-and-forget side-effect; intentionally avoids requestJson's response parsing/throw semantics so a transient failure cannot interrupt the caller - fetch('/api/mothership/chat/resources', { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ chatId: persistChatId, resourceType, resourceId }), + requestJson(removeMothershipChatResourceContract, { + body: { chatId: persistChatId, resourceType, resourceId }, }).catch((err) => { logger.warn('Failed to persist resource removal', err) }) @@ -2050,6 +2078,18 @@ export function useChat( const reorderResources = useCallback((newOrder: MothershipResource[]) => { setResources(newOrder) + const persistChatId = chatIdRef.current ?? selectedChatIdRef.current + if (!persistChatId) return + const pendingKeys = pendingPersistResourceKeysRef.current + const persistableResources = newOrder.filter( + (r) => r.id !== 'streaming-file' && !pendingKeys.has(`${r.type}:${r.id}`) + ) + if (persistableResources.length === 0) return + requestJson(reorderMothershipChatResourcesContract, { + body: { chatId: persistChatId, resources: persistableResources }, + }).catch((err) => { + logger.warn('Failed to persist resource reorder', err) + }) }, []) const ensureWorkflowToolResource = useCallback( @@ -2179,6 +2219,7 @@ export function useChat( setTransportIdle() setResources([]) setActiveResourceId(null) + pendingPersistResourceKeysRef.current.clear() resetEphemeralPreviewState() setMessageQueue([]) clearQueueDispatchState() @@ -2229,27 +2270,32 @@ export function useChat( const hasPersistedStreamingFile = chatHistory.resources.some((r) => r.id === 'streaming-file') if (hasPersistedStreamingFile) { - // boundary-raw-fetch: fire-and-forget cleanup during chat-history hydration; failures are silently swallowed to keep hydration non-blocking - fetch('/api/mothership/chat/resources', { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + requestJson(removeMothershipChatResourceContract, { + body: { chatId: chatHistory.id, resourceType: 'file', resourceId: 'streaming-file', - }), + }, }).catch(() => {}) } + flushPendingResources(chatHistory.id) + const persistedResources = chatHistory.resources.filter((r) => r.id !== 'streaming-file') - if (persistedResources.length > 0) { + const serverKeys = new Set(persistedResources.map((r) => `${r.type}:${r.id}`)) + const localOnly = resourcesRef.current.filter( + (r) => r.id !== 'streaming-file' && !serverKeys.has(`${r.type}:${r.id}`) + ) + const mergedResources = [...persistedResources, ...localOnly] + + if (mergedResources.length > 0) { const hydratedActiveResourceId = activeResourceIdRef.current && - persistedResources.some((resource) => resource.id === activeResourceIdRef.current) + mergedResources.some((resource) => resource.id === activeResourceIdRef.current) ? activeResourceIdRef.current - : persistedResources[persistedResources.length - 1].id + : mergedResources[mergedResources.length - 1].id activeResourceIdRef.current = hydratedActiveResourceId - setResources(persistedResources) + setResources(mergedResources) setActiveResourceId(hydratedActiveResourceId) for (const resource of persistedResources) { @@ -2373,6 +2419,7 @@ export function useChat( workspaceId, cancelActiveStreamReader, cancelActiveStreamRecovery, + flushPendingResources, queryClient, recoverPendingClientWorkflowTools, seedPreviewSessions, @@ -5003,9 +5050,8 @@ export function useChat( const executionId = execState.getCurrentExecutionId(workflowId) if (executionId) { execState.setCurrentExecutionId(workflowId, null) - // boundary-raw-fetch: fire-and-forget execution cancellation invoked from a stop-generation barrier; failures are silently swallowed so the stop teardown cannot stall on a contract-validation throw - fetch(`/api/workflows/${workflowId}/executions/${executionId}/cancel`, { - method: 'POST', + requestJson(cancelWorkflowExecutionContract, { + params: { id: workflowId, executionId }, }).catch(() => {}) } From 4988c78db3939a0723148389581cad331b342060 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 13 May 2026 17:43:04 -0700 Subject: [PATCH 2/3] fix(mship-resources): handle ADD/DELETE race and reorder during pending flush - Track in-flight ADD promises so DELETE chains off finally(), preventing orphaned server rows when a user removes a resource before its POST resolves - Defer reorder PATCH until pending flush completes; emit with full local order - Clear new refs in reset paths --- .../[workspaceId]/home/hooks/use-chat.ts | 67 +++++++++++++++---- 1 file changed, 55 insertions(+), 12 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 95b4e070026..0ef92a1e9fd 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -1544,6 +1544,8 @@ export function useChat( const resourcesRef = useRef(resources) resourcesRef.current = resources const pendingPersistResourceKeysRef = useRef>(new Set()) + const inFlightResourceAddsRef = useRef>>(new Map()) + const reorderNeededAfterFlushRef = useRef(false) // Derive the effective active resource ID — auto-selects the last resource when the stored ID is // absent or no longer in the list, avoiding a separate Effect-based state correction loop. @@ -1971,6 +1973,8 @@ export function useChat( setResources([]) setActiveResourceId(null) pendingPersistResourceKeysRef.current.clear() + inFlightResourceAddsRef.current.clear() + reorderNeededAfterFlushRef.current = false resetEphemeralPreviewState() setMessageQueue([]) clearQueueDispatchState() @@ -1983,20 +1987,42 @@ export function useChat( setTransportIdle, ]) - const flushPendingResources = useCallback((chatId: string) => { + const flushPendingResources = useCallback(async (chatId: string) => { const pendingKeys = pendingPersistResourceKeysRef.current if (pendingKeys.size === 0) return + const flushPromises: Array> = [] for (const resource of resourcesRef.current) { + if (resource.id === 'streaming-file') continue const key = `${resource.type}:${resource.id}` if (!pendingKeys.has(key)) continue pendingKeys.delete(key) - requestJson(addMothershipChatResourceContract, { body: { chatId, resource } }).catch( - (err) => { + const promise = requestJson(addMothershipChatResourceContract, { + body: { chatId, resource }, + }) + .catch((err) => { pendingPersistResourceKeysRef.current.add(key) logger.warn('Failed to flush pending resource; will retry on next hydration', err) - } - ) + }) + .finally(() => { + inFlightResourceAddsRef.current.delete(key) + }) + inFlightResourceAddsRef.current.set(key, promise) + flushPromises.push(promise) } + if (flushPromises.length === 0) return + await Promise.allSettled(flushPromises) + if (!reorderNeededAfterFlushRef.current) return + reorderNeededAfterFlushRef.current = false + const localOrder = resourcesRef.current.filter( + (r) => + r.id !== 'streaming-file' && !pendingPersistResourceKeysRef.current.has(`${r.type}:${r.id}`) + ) + if (localOrder.length === 0) return + requestJson(reorderMothershipChatResourcesContract, { + body: { chatId, resources: localOrder }, + }).catch((err) => { + logger.warn('Failed to sync resource order after flush', err) + }) }, []) const adoptResolvedChatId = useCallback( @@ -2046,12 +2072,17 @@ export function useChat( const persistChatId = chatIdRef.current ?? selectedChatIdRef.current const key = `${resource.type}:${resource.id}` if (persistChatId) { - requestJson(addMothershipChatResourceContract, { + const promise = requestJson(addMothershipChatResourceContract, { body: { chatId: persistChatId, resource }, - }).catch((err) => { - pendingPersistResourceKeysRef.current.add(key) - logger.warn('Failed to persist resource; will retry on next hydration', err) }) + .catch((err) => { + pendingPersistResourceKeysRef.current.add(key) + logger.warn('Failed to persist resource; will retry on next hydration', err) + }) + .finally(() => { + inFlightResourceAddsRef.current.delete(key) + }) + inFlightResourceAddsRef.current.set(key, promise) } else { pendingPersistResourceKeysRef.current.add(key) } @@ -2064,23 +2095,33 @@ export function useChat( const key = `${resourceType}:${resourceId}` const wasPending = pendingPersistResourceKeysRef.current.delete(key) - if (wasPending) return + const inFlightAdd = inFlightResourceAddsRef.current.get(key) + if (wasPending && !inFlightAdd) return const persistChatId = chatIdRef.current ?? selectedChatIdRef.current - if (persistChatId) { + if (!persistChatId) return + const fireDelete = () => { requestJson(removeMothershipChatResourceContract, { body: { chatId: persistChatId, resourceType, resourceId }, }).catch((err) => { logger.warn('Failed to persist resource removal', err) }) } + if (inFlightAdd) { + inFlightAdd.finally(fireDelete) + } else { + fireDelete() + } }, []) const reorderResources = useCallback((newOrder: MothershipResource[]) => { setResources(newOrder) + const pendingKeys = pendingPersistResourceKeysRef.current + if (pendingKeys.size > 0) { + reorderNeededAfterFlushRef.current = true + } const persistChatId = chatIdRef.current ?? selectedChatIdRef.current if (!persistChatId) return - const pendingKeys = pendingPersistResourceKeysRef.current const persistableResources = newOrder.filter( (r) => r.id !== 'streaming-file' && !pendingKeys.has(`${r.type}:${r.id}`) ) @@ -2220,6 +2261,8 @@ export function useChat( setResources([]) setActiveResourceId(null) pendingPersistResourceKeysRef.current.clear() + inFlightResourceAddsRef.current.clear() + reorderNeededAfterFlushRef.current = false resetEphemeralPreviewState() setMessageQueue([]) clearQueueDispatchState() From f48fa3ad7a401e15780b802ef39b2c9397b49df8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 13 May 2026 18:28:24 -0700 Subject: [PATCH 3/3] fix(mship-resources): defer reorder when ADDs are in-flight on existing chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reorderResources previously only checked pendingPersistResourceKeysRef. When a chatId exists, addResource fires the POST immediately and only tracks the promise in inFlightResourceAddsRef — so a reorder before those ADDs settle shipped a PATCH the server rejected, and the silent catch lost the reorder. Now treat in-flight ADDs like pending ones: defer the PATCH and replay it after Promise.allSettled on the in-flight map. --- .../[workspaceId]/home/hooks/use-chat.ts | 35 +++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 0ef92a1e9fd..2c922ca1d8f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -2116,15 +2116,38 @@ export function useChat( const reorderResources = useCallback((newOrder: MothershipResource[]) => { setResources(newOrder) + const persistChatId = chatIdRef.current ?? selectedChatIdRef.current + if (!persistChatId) return const pendingKeys = pendingPersistResourceKeysRef.current - if (pendingKeys.size > 0) { + const inFlightAdds = inFlightResourceAddsRef.current + const hasUnsyncedAdds = newOrder.some((r) => { + const key = `${r.type}:${r.id}` + return pendingKeys.has(key) || inFlightAdds.has(key) + }) + if (hasUnsyncedAdds) { reorderNeededAfterFlushRef.current = true + if (pendingKeys.size === 0 && inFlightAdds.size > 0) { + Promise.allSettled(Array.from(inFlightAdds.values())).then(() => { + if (!reorderNeededAfterFlushRef.current) return + reorderNeededAfterFlushRef.current = false + const chatId = chatIdRef.current ?? selectedChatIdRef.current + if (!chatId) return + const order = resourcesRef.current.filter( + (r) => + r.id !== 'streaming-file' && + !pendingPersistResourceKeysRef.current.has(`${r.type}:${r.id}`) + ) + if (order.length === 0) return + requestJson(reorderMothershipChatResourcesContract, { + body: { chatId, resources: order }, + }).catch((err) => { + logger.warn('Failed to sync resource order after in-flight ADDs', err) + }) + }) + } + return } - const persistChatId = chatIdRef.current ?? selectedChatIdRef.current - if (!persistChatId) return - const persistableResources = newOrder.filter( - (r) => r.id !== 'streaming-file' && !pendingKeys.has(`${r.type}:${r.id}`) - ) + const persistableResources = newOrder.filter((r) => r.id !== 'streaming-file') if (persistableResources.length === 0) return requestJson(reorderMothershipChatResourcesContract, { body: { chatId: persistChatId, resources: persistableResources },