@@ -28,6 +28,12 @@ import { useArgs } from "./args"
2828import { batch , onMount } from "solid-js"
2929import { Log } from "@/util/log"
3030import type { Path } from "@opencode-ai/sdk"
31+ import { parseLinkHeader } from "@/util/link-header"
32+
33+ /** Maximum messages kept in memory per session */
34+ const MAX_LOADED_MESSAGES = 500
35+ /** Chunk size for eviction when limit exceeded */
36+ const EVICTION_CHUNK_SIZE = 50
3137
3238export const { use : useSync , provider : SyncProvider } = createSimpleContext ( {
3339 name : "Sync" ,
@@ -48,6 +54,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
4854 }
4955 config : Config
5056 session : Session [ ]
57+ message_page : {
58+ [ sessionID : string ] : {
59+ hasOlder : boolean
60+ hasNewer : boolean
61+ loading : boolean
62+ loadingDirection ?: "older" | "newer"
63+ error ?: string
64+ }
65+ }
5166 session_status : {
5267 [ sessionID : string ] : SessionStatus
5368 }
@@ -89,6 +104,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
89104 provider : [ ] ,
90105 provider_default : { } ,
91106 session : [ ] ,
107+ message_page : { } ,
92108 session_status : { } ,
93109 session_diff : { } ,
94110 todo : { } ,
@@ -226,22 +242,26 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
226242 }
227243
228244 case "message.updated" : {
229- const messages = store . message [ event . properties . info . sessionID ]
245+ const sessionID = event . properties . info . sessionID
246+ const page = store . message_page [ sessionID ]
247+ const messages = store . message [ sessionID ]
230248 if ( ! messages ) {
231- setStore ( "message" , event . properties . info . sessionID , [ event . properties . info ] )
249+ setStore ( "message" , sessionID , [ event . properties . info ] )
232250 break
233251 }
234252 const result = Binary . search ( messages , event . properties . info . id , ( m ) => m . id )
235253 if ( result . found ) {
236- setStore ( "message" , event . properties . info . sessionID , result . index , reconcile ( event . properties . info ) )
254+ setStore ( "message" , sessionID , result . index , reconcile ( event . properties . info ) )
255+ break
256+ }
257+ if ( page ?. hasNewer ) {
237258 break
238259 }
239260 setStore (
240261 "message" ,
241- event . properties . info . sessionID ,
262+ sessionID ,
242263 produce ( ( draft ) => {
243264 draft . splice ( result . index , 0 , event . properties . info )
244- if ( draft . length > 100 ) draft . shift ( )
245265 } ) ,
246266 )
247267 break
@@ -261,6 +281,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
261281 break
262282 }
263283 case "message.part.updated" : {
284+ const sessionID = event . properties . part . sessionID
285+ const page = store . message_page [ sessionID ]
286+ const messages = store . message [ sessionID ]
287+ const messageExists = messages ?. some ( ( m ) => m . id === event . properties . part . messageID )
288+ if ( page ?. hasNewer && ! messageExists ) {
289+ break
290+ }
264291 const parts = store . part [ event . properties . part . messageID ]
265292 if ( ! parts ) {
266293 setStore ( "part" , event . properties . part . messageID , [ event . properties . part ] )
@@ -371,6 +398,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
371398 } )
372399
373400 const fullSyncedSessions = new Set < string > ( )
401+ const loadingGuard = new Set < string > ( )
374402 const result = {
375403 data : store ,
376404 set : setStore ,
@@ -404,6 +432,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
404432 sdk . client . session . todo ( { sessionID } ) ,
405433 sdk . client . session . diff ( { sessionID } ) ,
406434 ] )
435+ const link = messages . response . headers . get ( "link" ) ?? ""
436+ const hasOlder = parseLinkHeader ( link ) . prev !== undefined
407437 setStore (
408438 produce ( ( draft ) => {
409439 const match = Binary . search ( draft . session , sessionID , ( s ) => s . id )
@@ -415,10 +445,261 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
415445 draft . part [ message . info . id ] = message . parts
416446 }
417447 draft . session_diff [ sessionID ] = diff . data ?? [ ]
448+ draft . message_page [ sessionID ] = { hasOlder, hasNewer : false , loading : false , error : undefined }
418449 } ) ,
419450 )
420451 fullSyncedSessions . add ( sessionID )
421452 } ,
453+ async loadOlder ( sessionID : string ) {
454+ const page = store . message_page [ sessionID ]
455+ if ( page ?. loading || ! page ?. hasOlder ) return
456+ const messages = store . message [ sessionID ] ?? [ ]
457+ const oldest = messages . at ( 0 )
458+ if ( ! oldest ) return
459+ if ( loadingGuard . has ( sessionID ) ) return
460+ loadingGuard . add ( sessionID )
461+ try {
462+ setStore ( "message_page" , sessionID , { ...page , loading : true , loadingDirection : "older" , error : undefined } )
463+
464+ const res = await sdk . client . session . messages (
465+ { sessionID, before : oldest . id , limit : 100 } ,
466+ { throwOnError : true } ,
467+ )
468+ const link = res . response . headers . get ( "link" ) ?? ""
469+ const hasOlder = parseLinkHeader ( link ) . prev !== undefined
470+ setStore (
471+ produce ( ( draft ) => {
472+ const existing = draft . message [ sessionID ] ?? [ ]
473+ for ( const msg of res . data ?? [ ] ) {
474+ const match = Binary . search ( existing , msg . info . id , ( m ) => m . id )
475+ if ( ! match . found ) {
476+ existing . splice ( match . index , 0 , msg . info )
477+ draft . part [ msg . info . id ] = msg . parts
478+ }
479+ }
480+ if ( existing . length > MAX_LOADED_MESSAGES + EVICTION_CHUNK_SIZE ) {
481+ const evicted = existing . splice ( - ( existing . length - MAX_LOADED_MESSAGES ) )
482+ for ( const msg of evicted ) delete draft . part [ msg . id ]
483+ draft . message_page [ sessionID ] = { hasOlder, hasNewer : true , loading : false , error : undefined }
484+ } else {
485+ draft . message_page [ sessionID ] = {
486+ hasOlder,
487+ hasNewer : draft . message_page [ sessionID ] ?. hasNewer ?? false ,
488+ loading : false ,
489+ error : undefined ,
490+ }
491+ }
492+ } ) ,
493+ )
494+ } catch ( e ) {
495+ const page = store . message_page [ sessionID ]
496+ setStore ( "message_page" , sessionID , {
497+ hasOlder : page ?. hasOlder ?? false ,
498+ hasNewer : page ?. hasNewer ?? false ,
499+ loading : false ,
500+ error : e instanceof Error ? e . message : String ( e ) ,
501+ } )
502+ } finally {
503+ loadingGuard . delete ( sessionID )
504+ }
505+ } ,
506+ async loadNewer ( sessionID : string ) {
507+ const page = store . message_page [ sessionID ]
508+ if ( page ?. loading || ! page ?. hasNewer ) return
509+ const messages = store . message [ sessionID ] ?? [ ]
510+ const newest = messages . at ( - 1 )
511+ if ( ! newest ) return
512+ if ( loadingGuard . has ( sessionID ) ) return
513+ loadingGuard . add ( sessionID )
514+ try {
515+ setStore ( "message_page" , sessionID , { ...page , loading : true , loadingDirection : "newer" , error : undefined } )
516+
517+ const res = await sdk . client . session . messages (
518+ { sessionID, after : newest . id , limit : 100 } ,
519+ { throwOnError : true } ,
520+ )
521+ const link = res . response . headers . get ( "link" ) ?? ""
522+ const hasNewer = parseLinkHeader ( link ) . next !== undefined
523+ setStore (
524+ produce ( ( draft ) => {
525+ const existing = draft . message [ sessionID ] ?? [ ]
526+ for ( const msg of res . data ?? [ ] ) {
527+ const match = Binary . search ( existing , msg . info . id , ( m ) => m . id )
528+ if ( ! match . found ) {
529+ existing . splice ( match . index , 0 , msg . info )
530+ draft . part [ msg . info . id ] = msg . parts
531+ }
532+ }
533+ if ( existing . length > MAX_LOADED_MESSAGES + EVICTION_CHUNK_SIZE ) {
534+ const evicted = existing . splice ( 0 , existing . length - MAX_LOADED_MESSAGES )
535+ for ( const msg of evicted ) delete draft . part [ msg . id ]
536+ draft . message_page [ sessionID ] = { hasOlder : true , hasNewer, loading : false , error : undefined }
537+ } else {
538+ draft . message_page [ sessionID ] = {
539+ hasOlder : draft . message_page [ sessionID ] ?. hasOlder ?? false ,
540+ hasNewer,
541+ loading : false ,
542+ error : undefined ,
543+ }
544+ }
545+ } ) ,
546+ )
547+ } catch ( e ) {
548+ const page = store . message_page [ sessionID ]
549+ setStore ( "message_page" , sessionID , {
550+ hasOlder : page ?. hasOlder ?? false ,
551+ hasNewer : page ?. hasNewer ?? false ,
552+ loading : false ,
553+ error : e instanceof Error ? e . message : String ( e ) ,
554+ } )
555+ } finally {
556+ loadingGuard . delete ( sessionID )
557+ }
558+ } ,
559+ async jumpToLatest ( sessionID : string ) {
560+ const page = store . message_page [ sessionID ]
561+ if ( page ?. loading || ! page ?. hasNewer ) return
562+ if ( loadingGuard . has ( sessionID ) ) return
563+ loadingGuard . add ( sessionID )
564+
565+ try {
566+ // Check for revert state
567+ const session = store . session . find ( ( s ) => s . id === sessionID )
568+ const revertMessageID = session ?. revert ?. messageID
569+
570+ setStore ( "message_page" , sessionID , {
571+ ...page ,
572+ loading : true ,
573+ loadingDirection : "newer" ,
574+ error : undefined ,
575+ } )
576+
577+ // Fetch newest page (no cursor = newest)
578+ const res = await sdk . client . session . messages ( { sessionID, limit : 100 } , { throwOnError : true } )
579+
580+ let messages = res . data ?? [ ]
581+ const link = res . response . headers . get ( "link" ) ?? ""
582+ const hasOlder = parseLinkHeader ( link ) . prev !== undefined
583+
584+ // Revert-aware: If in revert state and marker not in results, fetch it
585+ if ( revertMessageID && ! messages . some ( ( m ) => m . info . id === revertMessageID ) ) {
586+ try {
587+ const revertResult = await sdk . client . session . message (
588+ { sessionID, messageID : revertMessageID } ,
589+ { throwOnError : true } ,
590+ )
591+ if ( revertResult . data ) {
592+ // Prepend revert message (it's older than newest page)
593+ messages = [ revertResult . data , ...messages ]
594+ }
595+ } catch ( e ) {
596+ // Revert message may have been deleted, continue without it
597+ Log . Default . info ( "Revert marker fetch failed (may be deleted)" , {
598+ messageID : revertMessageID ,
599+ error : e ,
600+ } )
601+ }
602+ }
603+
604+ setStore (
605+ produce ( ( draft ) => {
606+ // Clean up parts only for messages not in new results
607+ const oldMessages = draft . message [ sessionID ] ?? [ ]
608+ const newIds = new Set ( messages . map ( ( m ) => m . info . id ) )
609+ for ( const msg of oldMessages ) {
610+ if ( ! newIds . has ( msg . id ) ) {
611+ delete draft . part [ msg . id ]
612+ }
613+ }
614+
615+ // Store new messages
616+ draft . message [ sessionID ] = messages . map ( ( m ) => m . info )
617+ for ( const msg of messages ) {
618+ draft . part [ msg . info . id ] = msg . parts
619+ }
620+ draft . message_page [ sessionID ] = {
621+ hasOlder,
622+ hasNewer : false ,
623+ loading : false ,
624+ error : undefined ,
625+ }
626+ } ) ,
627+ )
628+ } catch ( e ) {
629+ setStore (
630+ produce ( ( draft ) => {
631+ const p = draft . message_page [ sessionID ]
632+ if ( p ) {
633+ p . loading = false
634+ p . error = e instanceof Error ? e . message : String ( e )
635+ }
636+ } ) ,
637+ )
638+ } finally {
639+ loadingGuard . delete ( sessionID )
640+ }
641+ } ,
642+ async jumpToOldest ( sessionID : string ) {
643+ const page = store . message_page [ sessionID ]
644+ if ( page ?. loading || ! page ?. hasOlder ) return
645+ if ( loadingGuard . has ( sessionID ) ) return
646+ loadingGuard . add ( sessionID )
647+
648+ try {
649+ setStore ( "message_page" , sessionID , {
650+ ...page ,
651+ loading : true ,
652+ loadingDirection : "older" ,
653+ error : undefined ,
654+ } )
655+
656+ const res = await sdk . client . session . messages (
657+ { sessionID, oldest : true , limit : 100 } ,
658+ { throwOnError : true } ,
659+ )
660+
661+ const messages = res . data ?? [ ]
662+ const link = res . response . headers . get ( "link" ) ?? ""
663+ const hasNewer = parseLinkHeader ( link ) . next !== undefined
664+
665+ setStore (
666+ produce ( ( draft ) => {
667+ // Clean up parts only for messages not in new results
668+ const oldMessages = draft . message [ sessionID ] ?? [ ]
669+ const newIds = new Set ( messages . map ( ( m ) => m . info . id ) )
670+ for ( const msg of oldMessages ) {
671+ if ( ! newIds . has ( msg . id ) ) {
672+ delete draft . part [ msg . id ]
673+ }
674+ }
675+
676+ // Store new messages
677+ draft . message [ sessionID ] = messages . map ( ( m ) => m . info )
678+ for ( const msg of messages ) {
679+ draft . part [ msg . info . id ] = msg . parts
680+ }
681+ draft . message_page [ sessionID ] = {
682+ hasOlder : false ,
683+ hasNewer,
684+ loading : false ,
685+ error : undefined ,
686+ }
687+ } ) ,
688+ )
689+ } catch ( e ) {
690+ setStore (
691+ produce ( ( draft ) => {
692+ const p = draft . message_page [ sessionID ]
693+ if ( p ) {
694+ p . loading = false
695+ p . error = e instanceof Error ? e . message : String ( e )
696+ }
697+ } ) ,
698+ )
699+ } finally {
700+ loadingGuard . delete ( sessionID )
701+ }
702+ } ,
422703 } ,
423704 bootstrap,
424705 }
0 commit comments