@@ -81,6 +81,7 @@ import { DialogExportOptions } from "../../ui/dialog-export-options"
8181import { formatTranscript } from "../../util/transcript"
8282import { UI } from "@/cli/ui.ts"
8383import { useTuiConfig } from "../../context/tui-config"
84+ import { edgeHints , olderScrollTarget , queueBoundaryLoad } from "@tui/util/pagination"
8485
8586addDefaultParsers ( parsers . parsers )
8687
@@ -129,6 +130,7 @@ export function Session() {
129130 . toSorted ( ( a , b ) => ( a . id < b . id ? - 1 : a . id > b . id ? 1 : 0 ) )
130131 } )
131132 const messages = createMemo ( ( ) => sync . data . message [ route . sessionID ] ?? [ ] )
133+ const paging = createMemo ( ( ) => sync . data . message_page [ route . sessionID ] )
132134 const permissions = createMemo ( ( ) => {
133135 if ( session ( ) ?. parentID ) return [ ]
134136 return children ( ) . flatMap ( ( x ) => sync . data . permission [ x . id ] ?? [ ] )
@@ -138,6 +140,67 @@ export function Session() {
138140 return children ( ) . flatMap ( ( x ) => sync . data . question [ x . id ] ?? [ ] )
139141 } )
140142
143+ const LOAD_MORE_THRESHOLD = 5
144+
145+ const loadOlder = ( ) => {
146+ const page = paging ( )
147+ if ( ! page ?. hasOlder || page . loading || ! scroll ) return
148+ if ( scroll . scrollTop > LOAD_MORE_THRESHOLD ) return
149+
150+ const anchor = ( ( ) => {
151+ const scrollTop = scroll . scrollTop
152+ const children = scroll . getChildren ( )
153+ for ( const child of children ) {
154+ if ( ! child . id ) continue
155+ if ( child . y + child . height > scrollTop ) {
156+ return { id : child . id , offset : scrollTop - child . y }
157+ }
158+ }
159+ return undefined
160+ } ) ( )
161+
162+ const height = scroll . scrollHeight
163+ const scrollTop = scroll . scrollTop
164+ sync . session . loadOlder ( route . sessionID ) . then ( ( ) => {
165+ queueMicrotask ( ( ) => {
166+ requestAnimationFrame ( ( ) => {
167+ if ( ! scroll || scroll . isDestroyed ) return
168+ const nextTop = olderScrollTarget ( scroll . getChildren ( ) , scroll . scrollHeight , height , scrollTop , anchor )
169+ if ( nextTop !== undefined ) scroll . scrollTo ( nextTop )
170+ refreshEdges ( )
171+ } )
172+ } )
173+ } )
174+ }
175+
176+ const loadNewer = ( ) => {
177+ const page = paging ( )
178+ if ( ! page ?. hasNewer || page . loading || ! scroll ) return
179+ const bottomDistance = scroll . scrollHeight - scroll . scrollTop - scroll . viewport . height
180+ if ( bottomDistance > LOAD_MORE_THRESHOLD ) return
181+ sync . session . loadNewer ( route . sessionID ) . then ( ( ) => {
182+ queueMicrotask ( ( ) => {
183+ requestAnimationFrame ( ( ) => {
184+ refreshEdges ( )
185+ } )
186+ } )
187+ } )
188+ }
189+
190+ const refreshEdges = ( ) => {
191+ if ( ! scroll || scroll . isDestroyed ) return
192+ const edges = edgeHints ( scroll . scrollTop , scroll . scrollHeight , scroll . viewport . height , HINT_THRESHOLD )
193+ setNearTop ( edges . nearTop )
194+ setNearBottom ( edges . nearBottom )
195+ }
196+
197+ const scrollMove = ( delta : number ) => {
198+ if ( ! scroll || scroll . isDestroyed ) return
199+ scroll . scrollBy ( delta )
200+ refreshEdges ( )
201+ queueBoundaryLoad ( delta , loadOlder , loadNewer )
202+ }
203+
141204 const pending = createMemo ( ( ) => {
142205 return messages ( ) . findLast ( ( x ) => x . role === "assistant" && ! x . time . completed ) ?. id
143206 } )
@@ -159,6 +222,9 @@ export function Session() {
159222 const [ diffWrapMode ] = kv . signal < "word" | "none" > ( "diff_wrap_mode" , "word" )
160223 const [ animationsEnabled , setAnimationsEnabled ] = kv . signal ( "animations_enabled" , true )
161224 const [ showGenericToolOutput , setShowGenericToolOutput ] = kv . signal ( "generic_tool_output_visibility" , false )
225+ const [ nearTop , setNearTop ] = createSignal ( false )
226+ const [ nearBottom , setNearBottom ] = createSignal ( false )
227+ const HINT_THRESHOLD = 20
162228
163229 const wide = createMemo ( ( ) => dimensions ( ) . width > 120 )
164230 const sidebarVisible = createMemo ( ( ) => {
@@ -192,7 +258,9 @@ export function Session() {
192258 await sync . session
193259 . sync ( route . sessionID )
194260 . then ( ( ) => {
195- if ( scroll ) scroll . scrollBy ( 100_000 )
261+ if ( ! scroll || scroll . isDestroyed ) return
262+ scroll . scrollBy ( 100_000 )
263+ refreshEdges ( )
196264 } )
197265 . catch ( ( e ) => {
198266 console . error ( e )
@@ -204,6 +272,16 @@ export function Session() {
204272 } )
205273 } )
206274
275+ createEffect ( ( ) => {
276+ if ( ! scroll || scroll . isDestroyed ) return
277+ messages ( )
278+ queueMicrotask ( ( ) => {
279+ requestAnimationFrame ( ( ) => {
280+ refreshEdges ( )
281+ } )
282+ } )
283+ } )
284+
207285 const toast = useToast ( )
208286 const sdk = useSDK ( )
209287
@@ -270,7 +348,7 @@ export function Session() {
270348 const findNextVisibleMessage = ( direction : "next" | "prev" ) : string | null => {
271349 const children = scroll . getChildren ( )
272350 const messagesList = messages ( )
273- const scrollTop = scroll . y
351+ const scrollTop = scroll . scrollTop
274352
275353 // Get visible messages sorted by position, filtering for valid non-synthetic, non-ignored content
276354 const visibleMessages = children
@@ -302,20 +380,26 @@ export function Session() {
302380 const targetID = findNextVisibleMessage ( direction )
303381
304382 if ( ! targetID ) {
305- scroll . scrollBy ( direction === "next" ? scroll . height : - scroll . height )
383+ scrollMove ( direction === "next" ? scroll . height : - scroll . height )
306384 dialog . clear ( )
307385 return
308386 }
309387
310388 const child = scroll . getChildren ( ) . find ( ( c ) => c . id === targetID )
311- if ( child ) scroll . scrollBy ( child . y - scroll . y - 1 )
389+ if ( child ) {
390+ scroll . scrollBy ( child . y - scroll . scrollTop - 1 )
391+ refreshEdges ( )
392+ }
312393 dialog . clear ( )
313394 }
314395
315396 function toBottom ( ) {
316397 setTimeout ( ( ) => {
317398 if ( ! scroll || scroll . isDestroyed ) return
318399 scroll . scrollTo ( scroll . scrollHeight )
400+ requestAnimationFrame ( ( ) => {
401+ refreshEdges ( )
402+ } )
319403 } , 50 )
320404 }
321405
@@ -419,7 +503,10 @@ export function Session() {
419503 const child = scroll . getChildren ( ) . find ( ( child ) => {
420504 return child . id === messageID
421505 } )
422- if ( child ) scroll . scrollBy ( child . y - scroll . y - 1 )
506+ if ( child ) {
507+ scroll . scrollBy ( child . y - scroll . scrollTop - 1 )
508+ refreshEdges ( )
509+ }
423510 } }
424511 sessionID = { route . sessionID }
425512 setPrompt = { ( promptInfo ) => prompt . set ( promptInfo ) }
@@ -442,7 +529,10 @@ export function Session() {
442529 const child = scroll . getChildren ( ) . find ( ( child ) => {
443530 return child . id === messageID
444531 } )
445- if ( child ) scroll . scrollBy ( child . y - scroll . y - 1 )
532+ if ( child ) {
533+ scroll . scrollBy ( child . y - scroll . scrollTop - 1 )
534+ refreshEdges ( )
535+ }
446536 } }
447537 sessionID = { route . sessionID }
448538 />
@@ -661,7 +751,7 @@ export function Session() {
661751 category : "Session" ,
662752 hidden : true ,
663753 onSelect : ( dialog ) => {
664- scroll . scrollBy ( - scroll . height / 2 )
754+ scrollMove ( - scroll . height / 2 )
665755 dialog . clear ( )
666756 } ,
667757 } ,
@@ -672,7 +762,7 @@ export function Session() {
672762 category : "Session" ,
673763 hidden : true ,
674764 onSelect : ( dialog ) => {
675- scroll . scrollBy ( scroll . height / 2 )
765+ scrollMove ( scroll . height / 2 )
676766 dialog . clear ( )
677767 } ,
678768 } ,
@@ -683,7 +773,7 @@ export function Session() {
683773 category : "Session" ,
684774 disabled : true ,
685775 onSelect : ( dialog ) => {
686- scroll . scrollBy ( - 1 )
776+ scrollMove ( - 1 )
687777 dialog . clear ( )
688778 } ,
689779 } ,
@@ -694,7 +784,7 @@ export function Session() {
694784 category : "Session" ,
695785 disabled : true ,
696786 onSelect : ( dialog ) => {
697- scroll . scrollBy ( 1 )
787+ scrollMove ( 1 )
698788 dialog . clear ( )
699789 } ,
700790 } ,
@@ -705,7 +795,7 @@ export function Session() {
705795 category : "Session" ,
706796 hidden : true ,
707797 onSelect : ( dialog ) => {
708- scroll . scrollBy ( - scroll . height / 4 )
798+ scrollMove ( - scroll . height / 4 )
709799 dialog . clear ( )
710800 } ,
711801 } ,
@@ -716,7 +806,7 @@ export function Session() {
716806 category : "Session" ,
717807 hidden : true ,
718808 onSelect : ( dialog ) => {
719- scroll . scrollBy ( scroll . height / 4 )
809+ scrollMove ( scroll . height / 4 )
720810 dialog . clear ( )
721811 } ,
722812 } ,
@@ -727,7 +817,23 @@ export function Session() {
727817 category : "Session" ,
728818 hidden : true ,
729819 onSelect : ( dialog ) => {
730- scroll . scrollTo ( 0 )
820+ const page = paging ( )
821+ if ( page ?. hasOlder && ! page . loading ) {
822+ sync . session . jumpToOldest ( route . sessionID ) . then ( ( ) => {
823+ requestAnimationFrame ( ( ) => {
824+ if ( ! scroll || scroll . isDestroyed ) return
825+ scroll . scrollTo ( 0 )
826+ refreshEdges ( )
827+ } )
828+ } )
829+ } else {
830+ if ( ! scroll || scroll . isDestroyed ) {
831+ dialog . clear ( )
832+ return
833+ }
834+ scroll . scrollTo ( 0 )
835+ refreshEdges ( )
836+ }
731837 dialog . clear ( )
732838 } ,
733839 } ,
@@ -738,7 +844,23 @@ export function Session() {
738844 category : "Session" ,
739845 hidden : true ,
740846 onSelect : ( dialog ) => {
741- scroll . scrollTo ( scroll . scrollHeight )
847+ const page = paging ( )
848+ if ( page ?. hasNewer && ! page . loading ) {
849+ sync . session . jumpToLatest ( route . sessionID ) . then ( ( ) => {
850+ requestAnimationFrame ( ( ) => {
851+ if ( ! scroll || scroll . isDestroyed ) return
852+ scroll . scrollTo ( scroll . scrollHeight )
853+ refreshEdges ( )
854+ } )
855+ } )
856+ } else {
857+ if ( ! scroll || scroll . isDestroyed ) {
858+ dialog . clear ( )
859+ return
860+ }
861+ scroll . scrollTo ( scroll . scrollHeight )
862+ refreshEdges ( )
863+ }
742864 dialog . clear ( )
743865 } ,
744866 } ,
@@ -768,7 +890,10 @@ export function Session() {
768890 const child = scroll . getChildren ( ) . find ( ( child ) => {
769891 return child . id === message . id
770892 } )
771- if ( child ) scroll . scrollBy ( child . y - scroll . y - 1 )
893+ if ( child ) {
894+ scroll . scrollBy ( child . y - scroll . scrollTop - 1 )
895+ refreshEdges ( )
896+ }
772897 break
773898 }
774899 }
@@ -1051,8 +1176,45 @@ export function Session() {
10511176 < Show when = { showHeader ( ) && ( ! sidebarVisible ( ) || ! wide ( ) ) } >
10521177 < Header />
10531178 </ Show >
1179+ < Show when = { paging ( ) ?. loading && paging ( ) ?. loadingDirection === "older" } >
1180+ < box flexShrink = { 0 } paddingLeft = { 1 } >
1181+ < text fg = { theme . textMuted } > Loading older messages...</ text >
1182+ </ box >
1183+ </ Show >
1184+ < Show when = { ! paging ( ) ?. loading && paging ( ) ?. hasOlder && nearTop ( ) } >
1185+ < box flexShrink = { 0 } paddingLeft = { 1 } >
1186+ < text fg = { theme . textMuted } > (scroll up for more)</ text >
1187+ </ box >
1188+ </ Show >
1189+ < Show when = { paging ( ) ?. error } >
1190+ < box flexShrink = { 0 } paddingLeft = { 1 } >
1191+ < text fg = { theme . error } > Failed to load: { paging ( ) ?. error } </ text >
1192+ < text fg = { theme . textMuted } > (scroll to retry)</ text >
1193+ </ box >
1194+ </ Show >
10541195 < scrollbox
10551196 ref = { ( r ) => ( scroll = r ) }
1197+ onMouseScroll = { ( ) => {
1198+ refreshEdges ( )
1199+ loadOlder ( )
1200+ loadNewer ( )
1201+ } }
1202+ onKeyDown = { ( e ) => {
1203+ // Standard scroll triggers incremental load
1204+ if ( [ "up" , "pageup" , "home" ] . includes ( e . name ) ) {
1205+ setTimeout ( ( ) => {
1206+ refreshEdges ( )
1207+ loadOlder ( )
1208+ } , 0 )
1209+ }
1210+ if ( [ "down" , "pagedown" , "end" ] . includes ( e . name ) ) {
1211+ setTimeout ( ( ) => {
1212+ refreshEdges ( )
1213+ loadNewer ( )
1214+ } , 0 )
1215+ }
1216+ } }
1217+ viewportCulling = { true }
10561218 viewportOptions = { {
10571219 paddingRight : showScrollbar ( ) ? 1 : 0 ,
10581220 } }
@@ -1165,6 +1327,16 @@ export function Session() {
11651327 ) }
11661328 </ For >
11671329 </ scrollbox >
1330+ < Show when = { paging ( ) ?. loading && paging ( ) ?. loadingDirection === "newer" } >
1331+ < box flexShrink = { 0 } paddingLeft = { 1 } >
1332+ < text fg = { theme . textMuted } > Loading newer messages...</ text >
1333+ </ box >
1334+ </ Show >
1335+ < Show when = { ! paging ( ) ?. loading && paging ( ) ?. hasNewer && nearBottom ( ) } >
1336+ < box flexShrink = { 0 } paddingLeft = { 1 } >
1337+ < text fg = { theme . textMuted } > (scroll down for more)</ text >
1338+ </ box >
1339+ </ Show >
11681340 < box flexShrink = { 0 } >
11691341 < Show when = { permissions ( ) . length > 0 } >
11701342 < PermissionPrompt request = { permissions ( ) [ 0 ] } />
0 commit comments