@@ -78,6 +78,18 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
7878 }
7979 formatter : FormatterStatus [ ]
8080 vcs : VcsInfo | undefined
81+ messageOlderCursor : {
82+ [ sessionID : string ] : string | null
83+ }
84+ messageNewerCursor : {
85+ [ sessionID : string ] : string | null
86+ }
87+ messageOlderLoading : {
88+ [ sessionID : string ] : boolean
89+ }
90+ messageNewerLoading : {
91+ [ sessionID : string ] : boolean
92+ }
8193 } > ( {
8294 provider_next : {
8395 all : [ ] ,
@@ -105,6 +117,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
105117 mcp_resource : { } ,
106118 formatter : [ ] ,
107119 vcs : undefined ,
120+ messageOlderCursor : { } ,
121+ messageNewerCursor : { } ,
122+ messageOlderLoading : { } ,
123+ messageNewerLoading : { } ,
108124 } )
109125
110126 const event = useEvent ( )
@@ -251,19 +267,33 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
251267 }
252268
253269 case "message.updated" : {
254- const messages = store . message [ event . properties . info . sessionID ]
270+ const sessionID = event . properties . info . sessionID
271+ const messages = store . message [ sessionID ]
255272 if ( ! messages ) {
256- setStore ( "message" , event . properties . info . sessionID , [ event . properties . info ] )
273+ setStore ( "message" , sessionID , [ event . properties . info ] )
257274 break
258275 }
259276 const result = Binary . search ( messages , event . properties . info . id , ( m ) => m . id )
260277 if ( result . found ) {
261- setStore ( "message" , event . properties . info . sessionID , result . index , reconcile ( event . properties . info ) )
278+ setStore ( "message" , sessionID , result . index , reconcile ( event . properties . info ) )
262279 break
263280 }
281+ // If the bottom of the window has been evicted (messageNewerCursor
282+ // is set), drop messages that arrive past our visible tail. They
283+ // will be loaded on demand when the user scrolls back down.
284+ if ( store . messageNewerCursor [ sessionID ] ) {
285+ const last = messages [ messages . length - 1 ]
286+ if ( last ) {
287+ const incoming = event . properties . info
288+ const isPastTail =
289+ incoming . time . created > last . time . created ||
290+ ( incoming . time . created === last . time . created && incoming . id > last . id )
291+ if ( isPastTail ) break
292+ }
293+ }
264294 setStore (
265295 "message" ,
266- event . properties . info . sessionID ,
296+ sessionID ,
267297 produce ( ( draft ) => {
268298 draft . splice ( result . index , 0 , event . properties . info )
269299 } ) ,
@@ -285,19 +315,30 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
285315 break
286316 }
287317 case "message.part.updated" : {
288- const parts = store . part [ event . properties . part . messageID ]
318+ const sessionID = event . properties . part . sessionID
319+ const messageID = event . properties . part . messageID
320+ const parts = store . part [ messageID ]
321+ // If the parent message isn't in our window AND the window's
322+ // bottom has been evicted, drop the part - it would otherwise
323+ // be orphaned in store.part with no message to attach to.
324+ const inWindow = ( ( ) => {
325+ const messages = store . message [ sessionID ]
326+ if ( ! messages ) return true
327+ return Binary . search ( messages , messageID , ( m ) => m . id ) . found
328+ } ) ( )
289329 if ( ! parts ) {
290- setStore ( "part" , event . properties . part . messageID , [ event . properties . part ] )
330+ if ( ! inWindow && store . messageNewerCursor [ sessionID ] ) break
331+ setStore ( "part" , messageID , [ event . properties . part ] )
291332 break
292333 }
293334 const result = Binary . search ( parts , event . properties . part . id , ( p ) => p . id )
294335 if ( result . found ) {
295- setStore ( "part" , event . properties . part . messageID , result . index , reconcile ( event . properties . part ) )
336+ setStore ( "part" , messageID , result . index , reconcile ( event . properties . part ) )
296337 break
297338 }
298339 setStore (
299340 "part" ,
300- event . properties . part . messageID ,
341+ messageID ,
301342 produce ( ( draft ) => {
302343 draft . splice ( result . index , 0 , event . properties . part )
303344 } ) ,
@@ -503,10 +544,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
503544 if ( fullSyncedSessions . has ( sessionID ) ) return
504545 const [ session , messages , todo , diff ] = await Promise . all ( [
505546 sdk . client . session . get ( { sessionID } , { throwOnError : true } ) ,
506- sdk . client . session . messages ( { sessionID } ) ,
547+ sdk . client . session . messages ( { sessionID, limit : INITIAL_PAGE_SIZE } ) ,
507548 sdk . client . session . todo ( { sessionID } ) ,
508549 sdk . client . session . diff ( { sessionID } ) ,
509550 ] )
551+ const olderCursor = ( messages . response ?. headers . get ( "X-Next-Cursor" ) as string | null | undefined ) ?? null
510552 setStore (
511553 produce ( ( draft ) => {
512554 const match = Binary . search ( draft . session , sessionID , ( s ) => s . id )
@@ -520,13 +562,129 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
520562 }
521563 draft . message [ sessionID ] = infos
522564 draft . session_diff [ sessionID ] = diff . data ?? [ ]
565+ draft . messageOlderCursor [ sessionID ] = olderCursor
566+ draft . messageNewerCursor [ sessionID ] = null
567+ } ) ,
568+ )
569+ if ( ! olderCursor ) fullSyncedSessions . add ( sessionID )
570+ } ,
571+ async loadOlderMessages ( sessionID : string ) {
572+ const cursor = store . messageOlderCursor [ sessionID ]
573+ if ( ! cursor || store . messageOlderLoading [ sessionID ] ) return
574+ setStore ( "messageOlderLoading" , sessionID , true )
575+ try {
576+ const res = await sdk . client . session . messages ( { sessionID, limit : PAGE_SIZE , before : cursor } )
577+ const nextCursor = ( res . response ?. headers . get ( "X-Next-Cursor" ) as string | null | undefined ) ?? null
578+ setStore (
579+ produce ( ( draft ) => {
580+ const existing = draft . message [ sessionID ] ?? [ ]
581+ const prepend : Message [ ] = [ ]
582+ for ( const m of res . data ?? [ ] ) {
583+ draft . part [ m . info . id ] = m . parts
584+ prepend . push ( m . info )
585+ }
586+ draft . message [ sessionID ] = [ ...prepend , ...existing ]
587+ draft . messageOlderCursor [ sessionID ] = nextCursor
588+ } ) ,
589+ )
590+ if ( ! nextCursor && ! store . messageNewerCursor [ sessionID ] ) fullSyncedSessions . add ( sessionID )
591+ } finally {
592+ setStore ( "messageOlderLoading" , sessionID , false )
593+ }
594+ } ,
595+ async loadNewerMessages ( sessionID : string ) {
596+ const cursor = store . messageNewerCursor [ sessionID ]
597+ if ( ! cursor || store . messageNewerLoading [ sessionID ] ) return
598+ setStore ( "messageNewerLoading" , sessionID , true )
599+ try {
600+ const res = await sdk . client . session . messages ( { sessionID, limit : PAGE_SIZE , after : cursor } )
601+ const nextCursor = ( res . response ?. headers . get ( "X-Next-Cursor" ) as string | null | undefined ) ?? null
602+ setStore (
603+ produce ( ( draft ) => {
604+ const existing = draft . message [ sessionID ] ?? [ ]
605+ const append : Message [ ] = [ ]
606+ for ( const m of res . data ?? [ ] ) {
607+ draft . part [ m . info . id ] = m . parts
608+ append . push ( m . info )
609+ }
610+ draft . message [ sessionID ] = [ ...existing , ...append ]
611+ draft . messageNewerCursor [ sessionID ] = nextCursor
612+ } ) ,
613+ )
614+ if ( ! nextCursor && ! store . messageOlderCursor [ sessionID ] ) fullSyncedSessions . add ( sessionID )
615+ } finally {
616+ setStore ( "messageNewerLoading" , sessionID , false )
617+ }
618+ } ,
619+ trimNewerMessages ( sessionID : string , cap : number ) {
620+ const messages = store . message [ sessionID ]
621+ if ( ! messages || messages . length <= cap ) return
622+ // Find the largest "safe" prefix length we can keep without
623+ // discarding a message that's still in flight (assistants
624+ // currently streaming) - those need to remain pinned so live
625+ // events can update them.
626+ let target = cap
627+ while ( target < messages . length ) {
628+ const tail = messages . slice ( target )
629+ const hasInflight = tail . some (
630+ ( m ) => m . role === "assistant" && ! m . time ?. completed ,
631+ )
632+ if ( ! hasInflight ) break
633+ target ++
634+ }
635+ if ( target >= messages . length ) return
636+ const evicted = messages . slice ( target )
637+ const newLast = messages [ target - 1 ]
638+ if ( ! newLast ) return
639+ const cursorVal = encodeMessageCursor ( { id : newLast . id , time : newLast . time . created } )
640+ setStore (
641+ produce ( ( draft ) => {
642+ const arr = draft . message [ sessionID ]
643+ for ( const ev of evicted ) delete draft . part [ ev . id ]
644+ arr . length = target
645+ draft . messageNewerCursor [ sessionID ] = cursorVal
523646 } ) ,
524647 )
525- fullSyncedSessions . add ( sessionID )
648+ fullSyncedSessions . delete ( sessionID )
649+ } ,
650+ trimOlderMessages ( sessionID : string , cap : number ) {
651+ const messages = store . message [ sessionID ]
652+ if ( ! messages || messages . length <= cap ) return
653+ const drop = messages . length - cap
654+ const evicted = messages . slice ( 0 , drop )
655+ const newFirst = messages [ drop ]
656+ if ( ! newFirst ) return
657+ const cursorVal = encodeMessageCursor ( { id : newFirst . id , time : newFirst . time . created } )
658+ setStore (
659+ produce ( ( draft ) => {
660+ const arr = draft . message [ sessionID ]
661+ for ( const ev of evicted ) delete draft . part [ ev . id ]
662+ arr . splice ( 0 , drop )
663+ draft . messageOlderCursor [ sessionID ] = cursorVal
664+ } ) ,
665+ )
666+ fullSyncedSessions . delete ( sessionID )
667+ } ,
668+ async loadAllMessages ( sessionID : string ) {
669+ // Page through both directions until exhausted. Used by the
670+ // Timeline dialog so it can render every prompt in the session.
671+ while ( store . messageOlderCursor [ sessionID ] ) {
672+ await result . session . loadOlderMessages ( sessionID )
673+ }
674+ while ( store . messageNewerCursor [ sessionID ] ) {
675+ await result . session . loadNewerMessages ( sessionID )
676+ }
526677 } ,
527678 } ,
528679 bootstrap,
529680 }
530681 return result
531682 } ,
532683} )
684+
685+ const INITIAL_PAGE_SIZE = 100
686+ const PAGE_SIZE = 50
687+
688+ function encodeMessageCursor ( input : { id : string ; time : number } ) : string {
689+ return Buffer . from ( JSON . stringify ( input ) ) . toString ( "base64url" )
690+ }
0 commit comments