@@ -5,6 +5,13 @@ import { sleep } from '@sim/utils/helpers'
55import { generateId } from '@sim/utils/id'
66import { useQueryClient } from '@tanstack/react-query'
77import { 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'
815import { getMothershipAttachmentPreviewUrl } from '@/lib/copilot/chat/attachment-preview'
916import { toDisplayMessage } from '@/lib/copilot/chat/display-message'
1017import { 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