@@ -66,6 +66,7 @@ import {
6666} from '@/lib/copilot/request/session/file-preview-session-contract'
6767import type { StreamBatchEvent } from '@/lib/copilot/request/session/types'
6868import { canDisplayResource } from '@/lib/copilot/resources/availability'
69+ import { ResourcePersistenceQueue } from '@/lib/copilot/resources/client-persistence-queue'
6970import {
7071 BROWSER_SESSION_RESOURCE_ID ,
7172 isAddressableResource ,
@@ -1384,9 +1385,27 @@ export function useChat(
13841385 * make the tabs disappear for the desktop app too.
13851386 */
13861387 const undisplayableResourcesRef = useRef < MothershipResource [ ] > ( [ ] )
1387- const pendingPersistResourceKeysRef = useRef < Set < string > > ( new Set ( ) )
1388- const pendingClearViewIdKeysRef = useRef < Set < string > > ( new Set ( ) )
1389- const inFlightResourceAddsRef = useRef < Map < string , Promise < unknown > > > ( new Map ( ) )
1388+ const resourcePersistenceQueueRef = useRef < ResourcePersistenceQueue | null > ( null )
1389+ if ( ! resourcePersistenceQueueRef . current ) {
1390+ resourcePersistenceQueueRef . current = new ResourcePersistenceQueue ( {
1391+ persist : ( chatId , update ) => {
1392+ const { clearViewId, ...resource } = update
1393+ return requestJson ( addMothershipChatResourceContract , {
1394+ body : {
1395+ chatId,
1396+ resource,
1397+ ...( clearViewId === true ? { clearViewId : true as const } : { } ) ,
1398+ } ,
1399+ } )
1400+ } ,
1401+ onError : ( error ) => {
1402+ logger . warn ( 'Failed to persist resource; will retry on next hydration' , error )
1403+ } ,
1404+ } )
1405+ }
1406+ const resourcePersistenceQueue = resourcePersistenceQueueRef . current
1407+ const pendingPersistResourceKeysRef = useRef ( resourcePersistenceQueue . pendingKeys )
1408+ const inFlightResourceAddsRef = useRef ( resourcePersistenceQueue . inFlight )
13901409 const reorderNeededAfterFlushRef = useRef ( false )
13911410
13921411 // Derive the effective active resource ID for rendering without writing a
@@ -1644,9 +1663,7 @@ export function useChat(
16441663 // Pending view pins belong to the chat whose stream issued them.
16451664 useTableViewPinStore . getState ( ) . reset ( )
16461665 undisplayableResourcesRef . current = [ ]
1647- pendingPersistResourceKeysRef . current . clear ( )
1648- pendingClearViewIdKeysRef . current . clear ( )
1649- inFlightResourceAddsRef . current . clear ( )
1666+ resourcePersistenceQueue . clear ( )
16501667 reorderNeededAfterFlushRef . current = false
16511668 resetEphemeralPreviewState ( )
16521669 // Editing binds to this hook's composer — release it before rotating chatKey.
@@ -1674,56 +1691,29 @@ export function useChat(
16741691 workspaceId ,
16751692 ] )
16761693
1677- const flushPendingResources = useCallback ( async ( chatId : string ) => {
1678- const pendingKeys = pendingPersistResourceKeysRef . current
1679- if ( pendingKeys . size === 0 ) return
1680- const flushPromises : Array < Promise < unknown > > = [ ]
1681- for ( const resource of resourcesRef . current ) {
1682- if ( resource . id === 'streaming-file' ) continue
1683- const key = `${ resource . type } :${ resource . id } `
1684- if ( ! pendingKeys . has ( key ) ) continue
1685- pendingKeys . delete ( key )
1686- const shouldClearViewId = pendingClearViewIdKeysRef . current . has ( key )
1687- const promise = requestJson ( addMothershipChatResourceContract , {
1688- body : {
1689- chatId,
1690- resource,
1691- ...( shouldClearViewId ? { clearViewId : true as const } : { } ) ,
1692- } ,
1694+ const flushPendingResources = useCallback (
1695+ async ( chatId : string ) => {
1696+ if ( pendingPersistResourceKeysRef . current . size === 0 ) return
1697+ await resourcePersistenceQueue . flush ( chatId )
1698+ if ( ! reorderNeededAfterFlushRef . current ) return
1699+ reorderNeededAfterFlushRef . current = false
1700+ const localOrder = [
1701+ ...resourcesRef . current . filter (
1702+ ( r ) =>
1703+ r . id !== 'streaming-file' &&
1704+ ! pendingPersistResourceKeysRef . current . has ( `${ r . type } :${ r . id } ` )
1705+ ) ,
1706+ ...undisplayableResourcesRef . current ,
1707+ ]
1708+ if ( localOrder . length === 0 ) return
1709+ requestJson ( reorderMothershipChatResourcesContract , {
1710+ body : { chatId, resources : localOrder } ,
1711+ } ) . catch ( ( err ) => {
1712+ logger . warn ( 'Failed to sync resource order after flush' , err )
16931713 } )
1694- . then ( ( result ) => {
1695- if ( shouldClearViewId ) pendingClearViewIdKeysRef . current . delete ( key )
1696- return result
1697- } )
1698- . catch ( ( err ) => {
1699- pendingPersistResourceKeysRef . current . add ( key )
1700- logger . warn ( 'Failed to flush pending resource; will retry on next hydration' , err )
1701- } )
1702- . finally ( ( ) => {
1703- inFlightResourceAddsRef . current . delete ( key )
1704- } )
1705- inFlightResourceAddsRef . current . set ( key , promise )
1706- flushPromises . push ( promise )
1707- }
1708- if ( flushPromises . length === 0 ) return
1709- await Promise . allSettled ( flushPromises )
1710- if ( ! reorderNeededAfterFlushRef . current ) return
1711- reorderNeededAfterFlushRef . current = false
1712- const localOrder = [
1713- ...resourcesRef . current . filter (
1714- ( r ) =>
1715- r . id !== 'streaming-file' &&
1716- ! pendingPersistResourceKeysRef . current . has ( `${ r . type } :${ r . id } ` )
1717- ) ,
1718- ...undisplayableResourcesRef . current ,
1719- ]
1720- if ( localOrder . length === 0 ) return
1721- requestJson ( reorderMothershipChatResourcesContract , {
1722- body : { chatId, resources : localOrder } ,
1723- } ) . catch ( ( err ) => {
1724- logger . warn ( 'Failed to sync resource order after flush' , err )
1725- } )
1726- } , [ ] )
1714+ } ,
1715+ [ resourcePersistenceQueue ]
1716+ )
17271717
17281718 const adoptResolvedChatId = useCallback (
17291719 ( chatId : string , options ?: { replaceHomeHistory ?: boolean ; invalidateList ?: boolean } ) => {
@@ -1811,113 +1801,76 @@ export function useChat(
18111801 const source = chatHistory ?. messages . map ( toDisplayMessage ) ?? pendingMessages
18121802 return source . map ( ( m ) => restoreRevealedSimKeysForMessage ( m , revealedSimKeysRef . current ) )
18131803 } , [ chatHistory , pendingMessages ] )
1814- const addResource = useCallback ( ( resourceUpdate : MothershipResourceUpdate ) : boolean => {
1815- // The single fan-in for tab creation, so the invariant lives here.
1816- if ( ! isAddressableResource ( resourceUpdate ) ) {
1817- logger . warn ( 'Ignored a resource with no id' , {
1818- type : resourceUpdate . type ,
1819- title : resourceUpdate . title ,
1820- } )
1821- return false
1822- }
1823- const existing = resourcesRef . current . find (
1824- ( r ) => r . type === resourceUpdate . type && r . id === resourceUpdate . id
1825- )
1826- const resource = mergeChatResource ( existing , resourceUpdate )
1827- if ( existing && resource === existing ) {
1828- return false
1829- }
1830-
1831- setResources ( ( prev ) => {
1832- const current = prev . find ( ( r ) => r . type === resource . type && r . id === resource . id )
1833- if ( ! current ) return [ ...prev , resource ]
1834- const merged = mergeChatResource ( current , resourceUpdate )
1835- return merged === current
1836- ? prev
1837- : prev . map ( ( r ) => ( r . type === resource . type && r . id === resource . id ? merged : r ) )
1838- } )
1839- // Synthetic result/preview panels are in-memory only. The browser tab
1840- // metadata is persisted even though its live page remains desktop-owned.
1841- if ( isEphemeralResource ( resource ) ) {
1842- return true
1843- }
1804+ const addResource = useCallback (
1805+ ( resourceUpdate : MothershipResourceUpdate ) : boolean => {
1806+ // The single fan-in for tab creation, so the invariant lives here.
1807+ if ( ! isAddressableResource ( resourceUpdate ) ) {
1808+ logger . warn ( 'Ignored a resource with no id' , {
1809+ type : resourceUpdate . type ,
1810+ title : resourceUpdate . title ,
1811+ } )
1812+ return false
1813+ }
1814+ const existing = resourcesRef . current . find (
1815+ ( r ) => r . type === resourceUpdate . type && r . id === resourceUpdate . id
1816+ )
1817+ const resource = mergeChatResource ( existing , resourceUpdate )
1818+ if ( existing && resource === existing ) {
1819+ return false
1820+ }
18441821
1845- const persistChatId = chatIdRef . current ?? selectedChatIdRef . current
1846- const key = `${ resource . type } :${ resource . id } `
1847- const shouldClearViewId = resourceUpdate . clearViewId === true
1848- if ( shouldClearViewId ) {
1849- pendingClearViewIdKeysRef . current . add ( key )
1850- } else if ( resourceUpdate . viewId !== undefined ) {
1851- pendingClearViewIdKeysRef . current . delete ( key )
1852- }
1853- // `resourcesRef` is written during render, so adds of the same resource in
1854- // one tick all read the pre-render list and all pass the check above. State
1855- // converges (the updater is idempotent) but each fired its own POST — 5-6
1856- // per resource in production.
1857- const alreadyPersisting =
1858- inFlightResourceAddsRef . current . has ( key ) || pendingPersistResourceKeysRef . current . has ( key )
1859- if ( alreadyPersisting ) {
1860- pendingPersistResourceKeysRef . current . add ( key )
1861- return existing === undefined
1862- }
1863- if ( persistChatId ) {
1864- const promise = requestJson ( addMothershipChatResourceContract , {
1865- body : {
1866- chatId : persistChatId ,
1867- resource,
1868- ...( shouldClearViewId ? { clearViewId : true as const } : { } ) ,
1869- } ,
1822+ setResources ( ( prev ) => {
1823+ const current = prev . find ( ( r ) => r . type === resource . type && r . id === resource . id )
1824+ if ( ! current ) return [ ...prev , resource ]
1825+ const merged = mergeChatResource ( current , resourceUpdate )
1826+ return merged === current
1827+ ? prev
1828+ : prev . map ( ( r ) => ( r . type === resource . type && r . id === resource . id ? merged : r ) )
18701829 } )
1871- . then ( ( result ) => {
1872- if ( shouldClearViewId ) pendingClearViewIdKeysRef . current . delete ( key )
1873- return result
1874- } )
1875- . catch ( ( err ) => {
1876- pendingPersistResourceKeysRef . current . add ( key )
1877- logger . warn ( 'Failed to persist resource; will retry on next hydration' , err )
1878- } )
1879- . finally ( ( ) => {
1880- inFlightResourceAddsRef . current . delete ( key )
1881- } )
1882- inFlightResourceAddsRef . current . set ( key , promise )
1883- } else {
1884- pendingPersistResourceKeysRef . current . add ( key )
1885- }
1886- return existing === undefined
1887- } , [ ] )
1830+ // Synthetic result/preview panels are in-memory only. The browser tab
1831+ // metadata is persisted even though its live page remains desktop-owned.
1832+ if ( isEphemeralResource ( resource ) ) {
1833+ return true
1834+ }
18881835
1889- const removeResource = useCallback ( ( resourceType : MothershipResourceType , resourceId : string ) => {
1890- setResources ( ( prev ) => prev . filter ( ( r ) => ! ( r . type === resourceType && r . id === resourceId ) ) )
1891- setActiveResourceId ( ( prev ) => ( prev === resourceId ? null : prev ) )
1836+ const persistChatId = chatIdRef . current ?? selectedChatIdRef . current
1837+ resourcePersistenceQueue . enqueue ( resourceUpdate , persistChatId , existing )
1838+ return existing === undefined
1839+ } ,
1840+ [ resourcePersistenceQueue ]
1841+ )
18921842
1893- // Ephemeral panels were never persisted; nothing to delete server-side.
1894- if ( isEphemeralResource ( { type : resourceType , id : resourceId , title : '' } ) ) return
1843+ const removeResource = useCallback (
1844+ ( resourceType : MothershipResourceType , resourceId : string ) => {
1845+ setResources ( ( prev ) => prev . filter ( ( r ) => ! ( r . type === resourceType && r . id === resourceId ) ) )
1846+ setActiveResourceId ( ( prev ) => ( prev === resourceId ? null : prev ) )
18951847
1896- const key = `${ resourceType } :${ resourceId } `
1897- pendingClearViewIdKeysRef . current . delete ( key )
1898- const wasPending = pendingPersistResourceKeysRef . current . delete ( key )
1899- const inFlightAdd = inFlightResourceAddsRef . current . get ( key )
1900- if ( wasPending && ! inFlightAdd ) return
1848+ // Ephemeral panels were never persisted; nothing to delete server-side.
1849+ if ( isEphemeralResource ( { type : resourceType , id : resourceId , title : '' } ) ) return
19011850
1902- const persistChatId = chatIdRef . current ?? selectedChatIdRef . current
1903- if ( ! persistChatId ) return
1904- const fireDelete = ( ) => {
1905- requestJson ( removeMothershipChatResourceContract , {
1906- body : { chatId : persistChatId , resourceType, resourceId } ,
1907- } ) . catch ( ( err ) => {
1908- logger . warn ( 'Failed to persist resource removal' , err )
1909- } )
1910- }
1911- if ( inFlightAdd ) {
1912- // Drop the entry now, not when the add settles: an add being deleted must
1913- // not suppress a fresh add of the same resource. The chained delete keeps
1914- // its own reference to the promise.
1915- inFlightResourceAddsRef . current . delete ( key )
1916- inFlightAdd . finally ( fireDelete )
1917- } else {
1918- fireDelete ( )
1919- }
1920- } , [ ] )
1851+ const { inFlight : inFlightAdd , wasPending } = resourcePersistenceQueue . remove (
1852+ resourceType ,
1853+ resourceId
1854+ )
1855+ if ( wasPending && ! inFlightAdd ) return
1856+
1857+ const persistChatId = chatIdRef . current ?? selectedChatIdRef . current
1858+ if ( ! persistChatId ) return
1859+ const fireDelete = ( ) => {
1860+ requestJson ( removeMothershipChatResourceContract , {
1861+ body : { chatId : persistChatId , resourceType, resourceId } ,
1862+ } ) . catch ( ( err ) => {
1863+ logger . warn ( 'Failed to persist resource removal' , err )
1864+ } )
1865+ }
1866+ if ( inFlightAdd ) {
1867+ inFlightAdd . finally ( fireDelete )
1868+ } else {
1869+ fireDelete ( )
1870+ }
1871+ } ,
1872+ [ resourcePersistenceQueue ]
1873+ )
19211874
19221875 /**
19231876 * Drops hydrated workflow tabs whose workflow no longer exists, so an old
@@ -2280,11 +2233,7 @@ export function useChat(
22802233 const streamOwnerId = chatIdRef . current
22812234 const pendingTurn = activeTurnRef . current
22822235 const pendingStreamId = streamIdRef . current ?? pendingTurn ?. userMessageId
2283- const pendingResources = resourcesRef . current . filter (
2284- ( resource ) =>
2285- ! isEphemeralResource ( resource ) &&
2286- pendingPersistResourceKeysRef . current . has ( `${ resource . type } :${ resource . id } ` )
2287- )
2236+ const pendingResources = resourcePersistenceQueue . getPendingUpdates ( )
22882237 const navigatedToDifferentChat =
22892238 sendingRef . current &&
22902239 initialChatId !== streamOwnerId &&
@@ -2382,9 +2331,7 @@ export function useChat(
23822331 setResources ( [ ] )
23832332 setActiveResourceId ( null )
23842333 useTableViewPinStore . getState ( ) . reset ( )
2385- pendingPersistResourceKeysRef . current . clear ( )
2386- pendingClearViewIdKeysRef . current . clear ( )
2387- inFlightResourceAddsRef . current . clear ( )
2334+ resourcePersistenceQueue . clear ( )
23882335 reorderNeededAfterFlushRef . current = false
23892336 resetEphemeralPreviewState ( )
23902337 // Rotate the bucket key; the previous chat's queue stays in the store.
0 commit comments