Skip to content

Commit 3419c1e

Browse files
committed
fix(mothership): persist @-mentioned resources across send and merge on hydration
1 parent 4de955d commit 3419c1e

1 file changed

Lines changed: 71 additions & 25 deletions

File tree

  • apps/sim/app/workspace/[workspaceId]/home/hooks

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

Lines changed: 71 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ import { sleep } from '@sim/utils/helpers'
55
import { generateId } from '@sim/utils/id'
66
import { useQueryClient } from '@tanstack/react-query'
77
import { usePathname, useRouter } from 'next/navigation'
8+
import { requestJson } from '@/lib/api/client/request'
9+
import {
10+
addMothershipChatResourceContract,
11+
removeMothershipChatResourceContract,
12+
reorderMothershipChatResourcesContract,
13+
} from '@/lib/api/contracts/mothership-tasks'
14+
import { cancelWorkflowExecutionContract } from '@/lib/api/contracts/workflows'
815
import { getMothershipAttachmentPreviewUrl } from '@/lib/copilot/chat/attachment-preview'
916
import { toDisplayMessage } from '@/lib/copilot/chat/display-message'
1017
import { getLiveAssistantMessageId } from '@/lib/copilot/chat/effective-transcript'
@@ -1536,6 +1543,7 @@ export function useChat(
15361543
}, [])
15371544
const resourcesRef = useRef(resources)
15381545
resourcesRef.current = resources
1546+
const pendingPersistResourceKeysRef = useRef<Set<string>>(new Set())
15391547

15401548
// Derive the effective active resource ID — auto-selects the last resource when the stored ID is
15411549
// absent or no longer in the list, avoiding a separate Effect-based state correction loop.
@@ -1962,6 +1970,7 @@ export function useChat(
19621970
setTransportIdle()
19631971
setResources([])
19641972
setActiveResourceId(null)
1973+
pendingPersistResourceKeysRef.current.clear()
19651974
resetEphemeralPreviewState()
19661975
setMessageQueue([])
19671976
clearQueueDispatchState()
@@ -1974,6 +1983,22 @@ export function useChat(
19741983
setTransportIdle,
19751984
])
19761985

1986+
const flushPendingResources = useCallback((chatId: string) => {
1987+
const pendingKeys = pendingPersistResourceKeysRef.current
1988+
if (pendingKeys.size === 0) return
1989+
for (const resource of resourcesRef.current) {
1990+
const key = `${resource.type}:${resource.id}`
1991+
if (!pendingKeys.has(key)) continue
1992+
pendingKeys.delete(key)
1993+
requestJson(addMothershipChatResourceContract, { body: { chatId, resource } }).catch(
1994+
(err) => {
1995+
pendingPersistResourceKeysRef.current.add(key)
1996+
logger.warn('Failed to flush pending resource; will retry on next hydration', err)
1997+
}
1998+
)
1999+
}
2000+
}, [])
2001+
19772002
const adoptResolvedChatId = useCallback(
19782003
(chatId: string, options?: { replaceHomeHistory?: boolean; invalidateList?: boolean }) => {
19792004
const selectedChatId = selectedChatIdRef.current
@@ -1992,8 +2017,9 @@ export function useChat(
19922017
if (options?.invalidateList) {
19932018
queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) })
19942019
}
2020+
flushPendingResources(chatId)
19952021
},
1996-
[queryClient, workspaceId]
2022+
[flushPendingResources, queryClient, workspaceId]
19972023
)
19982024

19992025
const { data: chatHistory } = useChatHistory(resolvedChatId)
@@ -2018,15 +2044,16 @@ export function useChat(
20182044
}
20192045

20202046
const persistChatId = chatIdRef.current ?? selectedChatIdRef.current
2047+
const key = `${resource.type}:${resource.id}`
20212048
if (persistChatId) {
2022-
// 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
2023-
fetch('/api/mothership/chat/resources', {
2024-
method: 'POST',
2025-
headers: { 'Content-Type': 'application/json' },
2026-
body: JSON.stringify({ chatId: persistChatId, resource }),
2049+
requestJson(addMothershipChatResourceContract, {
2050+
body: { chatId: persistChatId, resource },
20272051
}).catch((err) => {
2028-
logger.warn('Failed to persist resource', err)
2052+
pendingPersistResourceKeysRef.current.add(key)
2053+
logger.warn('Failed to persist resource; will retry on next hydration', err)
20292054
})
2055+
} else {
2056+
pendingPersistResourceKeysRef.current.add(key)
20302057
}
20312058
return true
20322059
}, [])
@@ -2035,13 +2062,14 @@ export function useChat(
20352062
setResources((prev) => prev.filter((r) => !(r.type === resourceType && r.id === resourceId)))
20362063
setActiveResourceId((prev) => (prev === resourceId ? null : prev))
20372064

2065+
const key = `${resourceType}:${resourceId}`
2066+
const wasPending = pendingPersistResourceKeysRef.current.delete(key)
2067+
if (wasPending) return
2068+
20382069
const persistChatId = chatIdRef.current ?? selectedChatIdRef.current
20392070
if (persistChatId) {
2040-
// boundary-raw-fetch: fire-and-forget side-effect; intentionally avoids requestJson's response parsing/throw semantics so a transient failure cannot interrupt the caller
2041-
fetch('/api/mothership/chat/resources', {
2042-
method: 'DELETE',
2043-
headers: { 'Content-Type': 'application/json' },
2044-
body: JSON.stringify({ chatId: persistChatId, resourceType, resourceId }),
2071+
requestJson(removeMothershipChatResourceContract, {
2072+
body: { chatId: persistChatId, resourceType, resourceId },
20452073
}).catch((err) => {
20462074
logger.warn('Failed to persist resource removal', err)
20472075
})
@@ -2050,6 +2078,18 @@ export function useChat(
20502078

20512079
const reorderResources = useCallback((newOrder: MothershipResource[]) => {
20522080
setResources(newOrder)
2081+
const persistChatId = chatIdRef.current ?? selectedChatIdRef.current
2082+
if (!persistChatId) return
2083+
const pendingKeys = pendingPersistResourceKeysRef.current
2084+
const persistableResources = newOrder.filter(
2085+
(r) => r.id !== 'streaming-file' && !pendingKeys.has(`${r.type}:${r.id}`)
2086+
)
2087+
if (persistableResources.length === 0) return
2088+
requestJson(reorderMothershipChatResourcesContract, {
2089+
body: { chatId: persistChatId, resources: persistableResources },
2090+
}).catch((err) => {
2091+
logger.warn('Failed to persist resource reorder', err)
2092+
})
20532093
}, [])
20542094

20552095
const ensureWorkflowToolResource = useCallback(
@@ -2179,6 +2219,7 @@ export function useChat(
21792219
setTransportIdle()
21802220
setResources([])
21812221
setActiveResourceId(null)
2222+
pendingPersistResourceKeysRef.current.clear()
21822223
resetEphemeralPreviewState()
21832224
setMessageQueue([])
21842225
clearQueueDispatchState()
@@ -2229,27 +2270,32 @@ export function useChat(
22292270

22302271
const hasPersistedStreamingFile = chatHistory.resources.some((r) => r.id === 'streaming-file')
22312272
if (hasPersistedStreamingFile) {
2232-
// boundary-raw-fetch: fire-and-forget cleanup during chat-history hydration; failures are silently swallowed to keep hydration non-blocking
2233-
fetch('/api/mothership/chat/resources', {
2234-
method: 'DELETE',
2235-
headers: { 'Content-Type': 'application/json' },
2236-
body: JSON.stringify({
2273+
requestJson(removeMothershipChatResourceContract, {
2274+
body: {
22372275
chatId: chatHistory.id,
22382276
resourceType: 'file',
22392277
resourceId: 'streaming-file',
2240-
}),
2278+
},
22412279
}).catch(() => {})
22422280
}
22432281

2282+
flushPendingResources(chatHistory.id)
2283+
22442284
const persistedResources = chatHistory.resources.filter((r) => r.id !== 'streaming-file')
2245-
if (persistedResources.length > 0) {
2285+
const serverKeys = new Set(persistedResources.map((r) => `${r.type}:${r.id}`))
2286+
const localOnly = resourcesRef.current.filter(
2287+
(r) => r.id !== 'streaming-file' && !serverKeys.has(`${r.type}:${r.id}`)
2288+
)
2289+
const mergedResources = [...persistedResources, ...localOnly]
2290+
2291+
if (mergedResources.length > 0) {
22462292
const hydratedActiveResourceId =
22472293
activeResourceIdRef.current &&
2248-
persistedResources.some((resource) => resource.id === activeResourceIdRef.current)
2294+
mergedResources.some((resource) => resource.id === activeResourceIdRef.current)
22492295
? activeResourceIdRef.current
2250-
: persistedResources[persistedResources.length - 1].id
2296+
: mergedResources[mergedResources.length - 1].id
22512297
activeResourceIdRef.current = hydratedActiveResourceId
2252-
setResources(persistedResources)
2298+
setResources(mergedResources)
22532299
setActiveResourceId(hydratedActiveResourceId)
22542300

22552301
for (const resource of persistedResources) {
@@ -2373,6 +2419,7 @@ export function useChat(
23732419
workspaceId,
23742420
cancelActiveStreamReader,
23752421
cancelActiveStreamRecovery,
2422+
flushPendingResources,
23762423
queryClient,
23772424
recoverPendingClientWorkflowTools,
23782425
seedPreviewSessions,
@@ -5003,9 +5050,8 @@ export function useChat(
50035050
const executionId = execState.getCurrentExecutionId(workflowId)
50045051
if (executionId) {
50055052
execState.setCurrentExecutionId(workflowId, null)
5006-
// 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
5007-
fetch(`/api/workflows/${workflowId}/executions/${executionId}/cancel`, {
5008-
method: 'POST',
5053+
requestJson(cancelWorkflowExecutionContract, {
5054+
params: { id: workflowId, executionId },
50095055
}).catch(() => {})
50105056
}
50115057

0 commit comments

Comments
 (0)