@@ -78,6 +78,7 @@ import { QuestionPrompt } from "./question"
7878import { DialogExportOptions } from "../../ui/dialog-export-options"
7979import { formatTranscript } from "../../util/transcript"
8080import { UI } from "@/cli/ui.ts"
81+ import { edgeHints , olderScrollTarget , queueBoundaryLoad } from "@tui/util/pagination"
8182
8283addDefaultParsers ( parsers . parsers )
8384
@@ -124,6 +125,7 @@ export function Session() {
124125 . toSorted ( ( a , b ) => ( a . id < b . id ? - 1 : a . id > b . id ? 1 : 0 ) )
125126 } )
126127 const messages = createMemo ( ( ) => sync . data . message [ route . sessionID ] ?? [ ] )
128+ const paging = createMemo ( ( ) => sync . data . message_page [ route . sessionID ] )
127129 const permissions = createMemo ( ( ) => {
128130 if ( session ( ) ?. parentID ) return [ ]
129131 return children ( ) . flatMap ( ( x ) => sync . data . permission [ x . id ] ?? [ ] )
@@ -133,6 +135,67 @@ export function Session() {
133135 return children ( ) . flatMap ( ( x ) => sync . data . question [ x . id ] ?? [ ] )
134136 } )
135137
138+ const LOAD_MORE_THRESHOLD = 5
139+
140+ const loadOlder = ( ) => {
141+ const page = paging ( )
142+ if ( ! page ?. hasOlder || page . loading || ! scroll ) return
143+ if ( scroll . scrollTop > LOAD_MORE_THRESHOLD ) return
144+
145+ const anchor = ( ( ) => {
146+ const scrollTop = scroll . scrollTop
147+ const children = scroll . getChildren ( )
148+ for ( const child of children ) {
149+ if ( ! child . id ) continue
150+ if ( child . y + child . height > scrollTop ) {
151+ return { id : child . id , offset : scrollTop - child . y }
152+ }
153+ }
154+ return undefined
155+ } ) ( )
156+
157+ const height = scroll . scrollHeight
158+ const scrollTop = scroll . scrollTop
159+ sync . session . loadOlder ( route . sessionID ) . then ( ( ) => {
160+ queueMicrotask ( ( ) => {
161+ requestAnimationFrame ( ( ) => {
162+ if ( ! scroll || scroll . isDestroyed ) return
163+ const nextTop = olderScrollTarget ( scroll . getChildren ( ) , scroll . scrollHeight , height , scrollTop , anchor )
164+ if ( nextTop !== undefined ) scroll . scrollTo ( nextTop )
165+ refreshEdges ( )
166+ } )
167+ } )
168+ } )
169+ }
170+
171+ const loadNewer = ( ) => {
172+ const page = paging ( )
173+ if ( ! page ?. hasNewer || page . loading || ! scroll ) return
174+ const bottomDistance = scroll . scrollHeight - scroll . scrollTop - scroll . viewport . height
175+ if ( bottomDistance > LOAD_MORE_THRESHOLD ) return
176+ sync . session . loadNewer ( route . sessionID ) . then ( ( ) => {
177+ queueMicrotask ( ( ) => {
178+ requestAnimationFrame ( ( ) => {
179+ refreshEdges ( )
180+ } )
181+ } )
182+ } )
183+ }
184+
185+ const refreshEdges = ( ) => {
186+ if ( ! scroll || scroll . isDestroyed ) return
187+ const edges = edgeHints ( scroll . scrollTop , scroll . scrollHeight , scroll . viewport . height , HINT_THRESHOLD )
188+ setNearTop ( edges . nearTop )
189+ setNearBottom ( edges . nearBottom )
190+ }
191+
192+ const scrollMove = ( delta : number ) => {
193+ if ( ! scroll || scroll . isDestroyed ) return
194+ scroll . scrollBy ( delta )
195+ refreshEdges ( )
196+ queueBoundaryLoad ( delta , loadOlder , loadNewer )
197+ }
198+
136199 const pending = createMemo ( ( ) => {
137200 return messages ( ) . findLast ( ( x ) => x . role === "assistant" && ! x . time . completed ) ?. id
138201 } )
@@ -154,6 +217,9 @@ export function Session() {
154217 const [ diffWrapMode ] = kv . signal < "word" | "none" > ( "diff_wrap_mode" , "word" )
155218 const [ animationsEnabled , setAnimationsEnabled ] = kv . signal ( "animations_enabled" , true )
156219 const [ showGenericToolOutput , setShowGenericToolOutput ] = kv . signal ( "generic_tool_output_visibility" , false )
220+ const [ nearTop , setNearTop ] = createSignal ( false )
221+ const [ nearBottom , setNearBottom ] = createSignal ( false )
222+ const HINT_THRESHOLD = 20
157223
158224 const wide = createMemo ( ( ) => dimensions ( ) . width > 120 )
159225 const sidebarVisible = createMemo ( ( ) => {
@@ -181,7 +247,9 @@ export function Session() {
181247 await sync . session
182248 . sync ( route . sessionID )
183249 . then ( ( ) => {
184- if ( scroll ) scroll . scrollBy ( 100_000 )
250+ if ( ! scroll || scroll . isDestroyed ) return
251+ scroll . scrollBy ( 100_000 )
252+ refreshEdges ( )
185253 } )
186254 . catch ( ( e ) => {
187255 console . error ( e )
@@ -193,6 +261,16 @@ export function Session() {
193261 } )
194262 } )
195263
264+ createEffect ( ( ) => {
265+ if ( ! scroll || scroll . isDestroyed ) return
266+ messages ( )
267+ queueMicrotask ( ( ) => {
268+ requestAnimationFrame ( ( ) => {
269+ refreshEdges ( )
270+ } )
271+ } )
272+ } )
273+
196274 const toast = useToast ( )
197275 const sdk = useSDK ( )
198276
@@ -258,7 +336,7 @@ export function Session() {
258336 const findNextVisibleMessage = ( direction : "next" | "prev" ) : string | null => {
259337 const children = scroll . getChildren ( )
260338 const messagesList = messages ( )
261- const scrollTop = scroll . y
339+ const scrollTop = scroll . scrollTop
262340
263341 // Get visible messages sorted by position, filtering for valid non-synthetic, non-ignored content
264342 const visibleMessages = children
@@ -290,20 +368,26 @@ export function Session() {
290368 const targetID = findNextVisibleMessage ( direction )
291369
292370 if ( ! targetID ) {
293- scroll . scrollBy ( direction === "next" ? scroll . height : - scroll . height )
371+ scrollMove ( direction === "next" ? scroll . height : - scroll . height )
294372 dialog . clear ( )
295373 return
296374 }
297375
298376 const child = scroll . getChildren ( ) . find ( ( c ) => c . id === targetID )
299- if ( child ) scroll . scrollBy ( child . y - scroll . y - 1 )
377+ if ( child ) {
378+ scroll . scrollBy ( child . y - scroll . scrollTop - 1 )
379+ refreshEdges ( )
380+ }
300381 dialog . clear ( )
301382 }
302383
303384 function toBottom ( ) {
304385 setTimeout ( ( ) => {
305386 if ( ! scroll || scroll . isDestroyed ) return
306387 scroll . scrollTo ( scroll . scrollHeight )
388+ requestAnimationFrame ( ( ) => {
389+ refreshEdges ( )
390+ } )
307391 } , 50 )
308392 }
309393
@@ -381,7 +465,10 @@ export function Session() {
381465 const child = scroll . getChildren ( ) . find ( ( child ) => {
382466 return child . id === messageID
383467 } )
384- if ( child ) scroll . scrollBy ( child . y - scroll . y - 1 )
468+ if ( child ) {
469+ scroll . scrollBy ( child . y - scroll . scrollTop - 1 )
470+ refreshEdges ( )
471+ }
385472 } }
386473 sessionID = { route . sessionID }
387474 setPrompt = { ( promptInfo ) => prompt . set ( promptInfo ) }
@@ -404,7 +491,10 @@ export function Session() {
404491 const child = scroll . getChildren ( ) . find ( ( child ) => {
405492 return child . id === messageID
406493 } )
407- if ( child ) scroll . scrollBy ( child . y - scroll . y - 1 )
494+ if ( child ) {
495+ scroll . scrollBy ( child . y - scroll . scrollTop - 1 )
496+ refreshEdges ( )
497+ }
408498 } }
409499 sessionID = { route . sessionID }
410500 />
@@ -618,7 +708,7 @@ export function Session() {
618708 category : "Session" ,
619709 hidden : true ,
620710 onSelect : ( dialog ) => {
621- scroll . scrollBy ( - scroll . height / 2 )
711+ scrollMove ( - scroll . height / 2 )
622712 dialog . clear ( )
623713 } ,
624714 } ,
@@ -629,7 +719,7 @@ export function Session() {
629719 category : "Session" ,
630720 hidden : true ,
631721 onSelect : ( dialog ) => {
632- scroll . scrollBy ( scroll . height / 2 )
722+ scrollMove ( scroll . height / 2 )
633723 dialog . clear ( )
634724 } ,
635725 } ,
@@ -640,7 +730,7 @@ export function Session() {
640730 category : "Session" ,
641731 disabled : true ,
642732 onSelect : ( dialog ) => {
643- scroll . scrollBy ( - 1 )
733+ scrollMove ( - 1 )
644734 dialog . clear ( )
645735 } ,
646736 } ,
@@ -651,7 +741,7 @@ export function Session() {
651741 category : "Session" ,
652742 disabled : true ,
653743 onSelect : ( dialog ) => {
654- scroll . scrollBy ( 1 )
744+ scrollMove ( 1 )
655745 dialog . clear ( )
656746 } ,
657747 } ,
@@ -662,7 +752,7 @@ export function Session() {
662752 category : "Session" ,
663753 hidden : true ,
664754 onSelect : ( dialog ) => {
665- scroll . scrollBy ( - scroll . height / 4 )
755+ scrollMove ( - scroll . height / 4 )
666756 dialog . clear ( )
667757 } ,
668758 } ,
@@ -673,7 +763,7 @@ export function Session() {
673763 category : "Session" ,
674764 hidden : true ,
675765 onSelect : ( dialog ) => {
676- scroll . scrollBy ( scroll . height / 4 )
766+ scrollMove ( scroll . height / 4 )
677767 dialog . clear ( )
678768 } ,
679769 } ,
@@ -684,7 +774,23 @@ export function Session() {
684774 category : "Session" ,
685775 hidden : true ,
686776 onSelect : ( dialog ) => {
687- scroll . scrollTo ( 0 )
777+ const page = paging ( )
778+ if ( page ?. hasOlder && ! page . loading ) {
779+ sync . session . jumpToOldest ( route . sessionID ) . then ( ( ) => {
780+ requestAnimationFrame ( ( ) => {
781+ if ( ! scroll || scroll . isDestroyed ) return
782+ scroll . scrollTo ( 0 )
783+ refreshEdges ( )
784+ } )
785+ } )
786+ } else {
787+ if ( ! scroll || scroll . isDestroyed ) {
788+ dialog . clear ( )
789+ return
790+ }
791+ scroll . scrollTo ( 0 )
792+ refreshEdges ( )
793+ }
688794 dialog . clear ( )
689795 } ,
690796 } ,
@@ -695,7 +801,23 @@ export function Session() {
695801 category : "Session" ,
696802 hidden : true ,
697803 onSelect : ( dialog ) => {
698- scroll . scrollTo ( scroll . scrollHeight )
804+ const page = paging ( )
805+ if ( page ?. hasNewer && ! page . loading ) {
806+ sync . session . jumpToLatest ( route . sessionID ) . then ( ( ) => {
807+ requestAnimationFrame ( ( ) => {
808+ if ( ! scroll || scroll . isDestroyed ) return
809+ scroll . scrollTo ( scroll . scrollHeight )
810+ refreshEdges ( )
811+ } )
812+ } )
813+ } else {
814+ if ( ! scroll || scroll . isDestroyed ) {
815+ dialog . clear ( )
816+ return
817+ }
818+ scroll . scrollTo ( scroll . scrollHeight )
819+ refreshEdges ( )
820+ }
699821 dialog . clear ( )
700822 } ,
701823 } ,
@@ -725,7 +847,10 @@ export function Session() {
725847 const child = scroll . getChildren ( ) . find ( ( child ) => {
726848 return child . id === message . id
727849 } )
728- if ( child ) scroll . scrollBy ( child . y - scroll . y - 1 )
850+ if ( child ) {
851+ scroll . scrollBy ( child . y - scroll . scrollTop - 1 )
852+ refreshEdges ( )
853+ }
729854 break
730855 }
731856 }
@@ -996,8 +1121,45 @@ export function Session() {
9961121 < Show when = { showHeader ( ) && ( ! sidebarVisible ( ) || ! wide ( ) ) } >
9971122 < Header />
9981123 </ Show >
1124+ < Show when = { paging ( ) ?. loading && paging ( ) ?. loadingDirection === "older" } >
1125+ < box flexShrink = { 0 } paddingLeft = { 1 } >
1126+ < text fg = { theme . textMuted } > Loading older messages...</ text >
1127+ </ box >
1128+ </ Show >
1129+ < Show when = { ! paging ( ) ?. loading && paging ( ) ?. hasOlder && nearTop ( ) } >
1130+ < box flexShrink = { 0 } paddingLeft = { 1 } >
1131+ < text fg = { theme . textMuted } > (scroll up for more)</ text >
1132+ </ box >
1133+ </ Show >
1134+ < Show when = { paging ( ) ?. error } >
1135+ < box flexShrink = { 0 } paddingLeft = { 1 } >
1136+ < text fg = { theme . error } > Failed to load: { paging ( ) ?. error } </ text >
1137+ < text fg = { theme . textMuted } > (scroll to retry)</ text >
1138+ </ box >
1139+ </ Show >
9991140 < scrollbox
10001141 ref = { ( r ) => ( scroll = r ) }
1142+ onMouseScroll = { ( ) => {
1143+ refreshEdges ( )
1144+ loadOlder ( )
1145+ loadNewer ( )
1146+ } }
1147+ onKeyDown = { ( e ) => {
1148+ // Standard scroll triggers incremental load
1149+ if ( [ "up" , "pageup" , "home" ] . includes ( e . name ) ) {
1150+ setTimeout ( ( ) => {
1151+ refreshEdges ( )
1152+ loadOlder ( )
1153+ } , 0 )
1154+ }
1155+ if ( [ "down" , "pagedown" , "end" ] . includes ( e . name ) ) {
1156+ setTimeout ( ( ) => {
1157+ refreshEdges ( )
1158+ loadNewer ( )
1159+ } , 0 )
1160+ }
1161+ } }
1162+ viewportCulling = { true }
10011163 viewportOptions = { {
10021164 paddingRight : showScrollbar ( ) ? 1 : 0 ,
10031165 } }
@@ -1110,6 +1272,16 @@ export function Session() {
11101272 ) }
11111273 </ For >
11121274 </ scrollbox >
1275+ < Show when = { paging ( ) ?. loading && paging ( ) ?. loadingDirection === "newer" } >
1276+ < box flexShrink = { 0 } paddingLeft = { 1 } >
1277+ < text fg = { theme . textMuted } > Loading newer messages...</ text >
1278+ </ box >
1279+ </ Show >
1280+ < Show when = { ! paging ( ) ?. loading && paging ( ) ?. hasNewer && nearBottom ( ) } >
1281+ < box flexShrink = { 0 } paddingLeft = { 1 } >
1282+ < text fg = { theme . textMuted } > (scroll down for more)</ text >
1283+ </ box >
1284+ </ Show >
11131285 < box flexShrink = { 0 } >
11141286 < Show when = { permissions ( ) . length > 0 } >
11151287 < PermissionPrompt request = { permissions ( ) [ 0 ] } />
0 commit comments