@@ -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,9 @@ export function useChat(
15361543 } , [ ] )
15371544 const resourcesRef = useRef ( resources )
15381545 resourcesRef . current = resources
1546+ const pendingPersistResourceKeysRef = useRef < Set < string > > ( new Set ( ) )
1547+ const inFlightResourceAddsRef = useRef < Map < string , Promise < unknown > > > ( new Map ( ) )
1548+ const reorderNeededAfterFlushRef = useRef ( false )
15391549
15401550 // Derive the effective active resource ID — auto-selects the last resource when the stored ID is
15411551 // absent or no longer in the list, avoiding a separate Effect-based state correction loop.
@@ -1962,6 +1972,9 @@ export function useChat(
19621972 setTransportIdle ( )
19631973 setResources ( [ ] )
19641974 setActiveResourceId ( null )
1975+ pendingPersistResourceKeysRef . current . clear ( )
1976+ inFlightResourceAddsRef . current . clear ( )
1977+ reorderNeededAfterFlushRef . current = false
19651978 resetEphemeralPreviewState ( )
19661979 setMessageQueue ( [ ] )
19671980 clearQueueDispatchState ( )
@@ -1974,6 +1987,44 @@ export function useChat(
19741987 setTransportIdle ,
19751988 ] )
19761989
1990+ const flushPendingResources = useCallback ( async ( chatId : string ) => {
1991+ const pendingKeys = pendingPersistResourceKeysRef . current
1992+ if ( pendingKeys . size === 0 ) return
1993+ const flushPromises : Array < Promise < unknown > > = [ ]
1994+ for ( const resource of resourcesRef . current ) {
1995+ if ( resource . id === 'streaming-file' ) continue
1996+ const key = `${ resource . type } :${ resource . id } `
1997+ if ( ! pendingKeys . has ( key ) ) continue
1998+ pendingKeys . delete ( key )
1999+ const promise = requestJson ( addMothershipChatResourceContract , {
2000+ body : { chatId, resource } ,
2001+ } )
2002+ . catch ( ( err ) => {
2003+ pendingPersistResourceKeysRef . current . add ( key )
2004+ logger . warn ( 'Failed to flush pending resource; will retry on next hydration' , err )
2005+ } )
2006+ . finally ( ( ) => {
2007+ inFlightResourceAddsRef . current . delete ( key )
2008+ } )
2009+ inFlightResourceAddsRef . current . set ( key , promise )
2010+ flushPromises . push ( promise )
2011+ }
2012+ if ( flushPromises . length === 0 ) return
2013+ await Promise . allSettled ( flushPromises )
2014+ if ( ! reorderNeededAfterFlushRef . current ) return
2015+ reorderNeededAfterFlushRef . current = false
2016+ const localOrder = resourcesRef . current . filter (
2017+ ( r ) =>
2018+ r . id !== 'streaming-file' && ! pendingPersistResourceKeysRef . current . has ( `${ r . type } :${ r . id } ` )
2019+ )
2020+ if ( localOrder . length === 0 ) return
2021+ requestJson ( reorderMothershipChatResourcesContract , {
2022+ body : { chatId, resources : localOrder } ,
2023+ } ) . catch ( ( err ) => {
2024+ logger . warn ( 'Failed to sync resource order after flush' , err )
2025+ } )
2026+ } , [ ] )
2027+
19772028 const adoptResolvedChatId = useCallback (
19782029 ( chatId : string , options ?: { replaceHomeHistory ?: boolean ; invalidateList ?: boolean } ) => {
19792030 const selectedChatId = selectedChatIdRef . current
@@ -1992,8 +2043,9 @@ export function useChat(
19922043 if ( options ?. invalidateList ) {
19932044 queryClient . invalidateQueries ( { queryKey : taskKeys . list ( workspaceId ) } )
19942045 }
2046+ flushPendingResources ( chatId )
19952047 } ,
1996- [ queryClient , workspaceId ]
2048+ [ flushPendingResources , queryClient , workspaceId ]
19972049 )
19982050
19992051 const { data : chatHistory } = useChatHistory ( resolvedChatId )
@@ -2018,15 +2070,21 @@ export function useChat(
20182070 }
20192071
20202072 const persistChatId = chatIdRef . current ?? selectedChatIdRef . current
2073+ const key = `${ resource . type } :${ resource . id } `
20212074 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 } ) ,
2027- } ) . catch ( ( err ) => {
2028- logger . warn ( 'Failed to persist resource' , err )
2075+ const promise = requestJson ( addMothershipChatResourceContract , {
2076+ body : { chatId : persistChatId , resource } ,
20292077 } )
2078+ . catch ( ( err ) => {
2079+ pendingPersistResourceKeysRef . current . add ( key )
2080+ logger . warn ( 'Failed to persist resource; will retry on next hydration' , err )
2081+ } )
2082+ . finally ( ( ) => {
2083+ inFlightResourceAddsRef . current . delete ( key )
2084+ } )
2085+ inFlightResourceAddsRef . current . set ( key , promise )
2086+ } else {
2087+ pendingPersistResourceKeysRef . current . add ( key )
20302088 }
20312089 return true
20322090 } , [ ] )
@@ -2035,21 +2093,67 @@ export function useChat(
20352093 setResources ( ( prev ) => prev . filter ( ( r ) => ! ( r . type === resourceType && r . id === resourceId ) ) )
20362094 setActiveResourceId ( ( prev ) => ( prev === resourceId ? null : prev ) )
20372095
2096+ const key = `${ resourceType } :${ resourceId } `
2097+ const wasPending = pendingPersistResourceKeysRef . current . delete ( key )
2098+ const inFlightAdd = inFlightResourceAddsRef . current . get ( key )
2099+ if ( wasPending && ! inFlightAdd ) return
2100+
20382101 const persistChatId = chatIdRef . current ?? selectedChatIdRef . current
2039- 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 } ) ,
2102+ if ( ! persistChatId ) return
2103+ const fireDelete = ( ) => {
2104+ requestJson ( removeMothershipChatResourceContract , {
2105+ body : { chatId : persistChatId , resourceType, resourceId } ,
20452106 } ) . catch ( ( err ) => {
20462107 logger . warn ( 'Failed to persist resource removal' , err )
20472108 } )
20482109 }
2110+ if ( inFlightAdd ) {
2111+ inFlightAdd . finally ( fireDelete )
2112+ } else {
2113+ fireDelete ( )
2114+ }
20492115 } , [ ] )
20502116
20512117 const reorderResources = useCallback ( ( newOrder : MothershipResource [ ] ) => {
20522118 setResources ( newOrder )
2119+ const persistChatId = chatIdRef . current ?? selectedChatIdRef . current
2120+ if ( ! persistChatId ) return
2121+ const pendingKeys = pendingPersistResourceKeysRef . current
2122+ const inFlightAdds = inFlightResourceAddsRef . current
2123+ const hasUnsyncedAdds = newOrder . some ( ( r ) => {
2124+ const key = `${ r . type } :${ r . id } `
2125+ return pendingKeys . has ( key ) || inFlightAdds . has ( key )
2126+ } )
2127+ if ( hasUnsyncedAdds ) {
2128+ reorderNeededAfterFlushRef . current = true
2129+ if ( pendingKeys . size === 0 && inFlightAdds . size > 0 ) {
2130+ Promise . allSettled ( Array . from ( inFlightAdds . values ( ) ) ) . then ( ( ) => {
2131+ if ( ! reorderNeededAfterFlushRef . current ) return
2132+ reorderNeededAfterFlushRef . current = false
2133+ const chatId = chatIdRef . current ?? selectedChatIdRef . current
2134+ if ( ! chatId ) return
2135+ const order = resourcesRef . current . filter (
2136+ ( r ) =>
2137+ r . id !== 'streaming-file' &&
2138+ ! pendingPersistResourceKeysRef . current . has ( `${ r . type } :${ r . id } ` )
2139+ )
2140+ if ( order . length === 0 ) return
2141+ requestJson ( reorderMothershipChatResourcesContract , {
2142+ body : { chatId, resources : order } ,
2143+ } ) . catch ( ( err ) => {
2144+ logger . warn ( 'Failed to sync resource order after in-flight ADDs' , err )
2145+ } )
2146+ } )
2147+ }
2148+ return
2149+ }
2150+ const persistableResources = newOrder . filter ( ( r ) => r . id !== 'streaming-file' )
2151+ if ( persistableResources . length === 0 ) return
2152+ requestJson ( reorderMothershipChatResourcesContract , {
2153+ body : { chatId : persistChatId , resources : persistableResources } ,
2154+ } ) . catch ( ( err ) => {
2155+ logger . warn ( 'Failed to persist resource reorder' , err )
2156+ } )
20532157 } , [ ] )
20542158
20552159 const ensureWorkflowToolResource = useCallback (
@@ -2179,6 +2283,9 @@ export function useChat(
21792283 setTransportIdle ( )
21802284 setResources ( [ ] )
21812285 setActiveResourceId ( null )
2286+ pendingPersistResourceKeysRef . current . clear ( )
2287+ inFlightResourceAddsRef . current . clear ( )
2288+ reorderNeededAfterFlushRef . current = false
21822289 resetEphemeralPreviewState ( )
21832290 setMessageQueue ( [ ] )
21842291 clearQueueDispatchState ( )
@@ -2229,27 +2336,32 @@ export function useChat(
22292336
22302337 const hasPersistedStreamingFile = chatHistory . resources . some ( ( r ) => r . id === 'streaming-file' )
22312338 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 ( {
2339+ requestJson ( removeMothershipChatResourceContract , {
2340+ body : {
22372341 chatId : chatHistory . id ,
22382342 resourceType : 'file' ,
22392343 resourceId : 'streaming-file' ,
2240- } ) ,
2344+ } ,
22412345 } ) . catch ( ( ) => { } )
22422346 }
22432347
2348+ flushPendingResources ( chatHistory . id )
2349+
22442350 const persistedResources = chatHistory . resources . filter ( ( r ) => r . id !== 'streaming-file' )
2245- if ( persistedResources . length > 0 ) {
2351+ const serverKeys = new Set ( persistedResources . map ( ( r ) => `${ r . type } :${ r . id } ` ) )
2352+ const localOnly = resourcesRef . current . filter (
2353+ ( r ) => r . id !== 'streaming-file' && ! serverKeys . has ( `${ r . type } :${ r . id } ` )
2354+ )
2355+ const mergedResources = [ ...persistedResources , ...localOnly ]
2356+
2357+ if ( mergedResources . length > 0 ) {
22462358 const hydratedActiveResourceId =
22472359 activeResourceIdRef . current &&
2248- persistedResources . some ( ( resource ) => resource . id === activeResourceIdRef . current )
2360+ mergedResources . some ( ( resource ) => resource . id === activeResourceIdRef . current )
22492361 ? activeResourceIdRef . current
2250- : persistedResources [ persistedResources . length - 1 ] . id
2362+ : mergedResources [ mergedResources . length - 1 ] . id
22512363 activeResourceIdRef . current = hydratedActiveResourceId
2252- setResources ( persistedResources )
2364+ setResources ( mergedResources )
22532365 setActiveResourceId ( hydratedActiveResourceId )
22542366
22552367 for ( const resource of persistedResources ) {
@@ -2373,6 +2485,7 @@ export function useChat(
23732485 workspaceId ,
23742486 cancelActiveStreamReader ,
23752487 cancelActiveStreamRecovery ,
2488+ flushPendingResources ,
23762489 queryClient ,
23772490 recoverPendingClientWorkflowTools ,
23782491 seedPreviewSessions ,
@@ -5003,9 +5116,8 @@ export function useChat(
50035116 const executionId = execState . getCurrentExecutionId ( workflowId )
50045117 if ( executionId ) {
50055118 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' ,
5119+ requestJson ( cancelWorkflowExecutionContract , {
5120+ params : { id : workflowId , executionId } ,
50095121 } ) . catch ( ( ) => { } )
50105122 }
50115123
0 commit comments