@@ -81,6 +81,7 @@ import { formatTranscript } from "../../util/transcript"
8181import { UI } from "@/cli/ui.ts"
8282import { useTuiConfig } from "../../context/tui-config"
8383import { DialogSessionTree } from "./dialog-session-tree"
84+ import { edgeHints , olderScrollTarget , queueBoundaryLoad } from "@tui/util/pagination"
8485
8586addDefaultParsers ( parsers . parsers )
8687
@@ -164,13 +165,75 @@ export function Session() {
164165 }
165166 return ids
166167 } )
168+ const paging = createMemo ( ( ) => sync . data . message_page [ route . sessionID ] )
167169 const permissions = createMemo ( ( ) => {
168170 return descendants ( ) . flatMap ( ( id ) => sync . data . permission [ id ] ?? [ ] )
169171 } )
170172 const questions = createMemo ( ( ) => {
171173 return descendants ( ) . flatMap ( ( id ) => sync . data . question [ id ] ?? [ ] )
172174 } )
173175
176+ const LOAD_MORE_THRESHOLD = 5
177+
178+ const loadOlder = ( ) => {
179+ const page = paging ( )
180+ if ( ! page ?. hasOlder || page . loading || ! scroll ) return
181+ if ( scroll . scrollTop > LOAD_MORE_THRESHOLD ) return
182+
183+ const anchor = ( ( ) => {
184+ const scrollTop = scroll . scrollTop
185+ const children = scroll . getChildren ( )
186+ for ( const child of children ) {
187+ if ( ! child . id ) continue
188+ if ( child . y + child . height > scrollTop ) {
189+ return { id : child . id , offset : scrollTop - child . y }
190+ }
191+ }
192+ return undefined
193+ } ) ( )
194+
195+ const height = scroll . scrollHeight
196+ const scrollTop = scroll . scrollTop
197+ sync . session . loadOlder ( route . sessionID ) . then ( ( ) => {
198+ queueMicrotask ( ( ) => {
199+ requestAnimationFrame ( ( ) => {
200+ if ( ! scroll || scroll . isDestroyed ) return
201+ const nextTop = olderScrollTarget ( scroll . getChildren ( ) , scroll . scrollHeight , height , scrollTop , anchor )
202+ if ( nextTop !== undefined ) scroll . scrollTo ( nextTop )
203+ refreshEdges ( )
204+ } )
205+ } )
206+ } )
207+ }
208+
209+ const loadNewer = ( ) => {
210+ const page = paging ( )
211+ if ( ! page ?. hasNewer || page . loading || ! scroll ) return
212+ const bottomDistance = scroll . scrollHeight - scroll . scrollTop - scroll . viewport . height
213+ if ( bottomDistance > LOAD_MORE_THRESHOLD ) return
214+ sync . session . loadNewer ( route . sessionID ) . then ( ( ) => {
215+ queueMicrotask ( ( ) => {
216+ requestAnimationFrame ( ( ) => {
217+ refreshEdges ( )
218+ } )
219+ } )
220+ } )
221+ }
222+
223+ const refreshEdges = ( ) => {
224+ if ( ! scroll || scroll . isDestroyed ) return
225+ const edges = edgeHints ( scroll . scrollTop , scroll . scrollHeight , scroll . viewport . height , HINT_THRESHOLD )
226+ setNearTop ( edges . nearTop )
227+ setNearBottom ( edges . nearBottom )
228+ }
229+
230+ const scrollMove = ( delta : number ) => {
231+ if ( ! scroll || scroll . isDestroyed ) return
232+ scroll . scrollBy ( delta )
233+ refreshEdges ( )
234+ queueBoundaryLoad ( delta , loadOlder , loadNewer )
235+ }
236+
174237 const pending = createMemo ( ( ) => {
175238 return messages ( ) . findLast ( ( x ) => x . role === "assistant" && ! x . time . completed ) ?. id
176239 } )
@@ -192,6 +255,9 @@ export function Session() {
192255 const [ diffWrapMode ] = kv . signal < "word" | "none" > ( "diff_wrap_mode" , "word" )
193256 const [ animationsEnabled , setAnimationsEnabled ] = kv . signal ( "animations_enabled" , true )
194257 const [ showGenericToolOutput , setShowGenericToolOutput ] = kv . signal ( "generic_tool_output_visibility" , false )
258+ const [ nearTop , setNearTop ] = createSignal ( false )
259+ const [ nearBottom , setNearBottom ] = createSignal ( false )
260+ const HINT_THRESHOLD = 20
195261
196262 const wide = createMemo ( ( ) => dimensions ( ) . width > 120 )
197263 const sidebarVisible = createMemo ( ( ) => {
@@ -219,7 +285,9 @@ export function Session() {
219285 await sync . session
220286 . sync ( route . sessionID )
221287 . then ( ( ) => {
222- if ( scroll ) scroll . scrollBy ( 100_000 )
288+ if ( ! scroll || scroll . isDestroyed ) return
289+ scroll . scrollBy ( 100_000 )
290+ refreshEdges ( )
223291 } )
224292 . catch ( ( e ) => {
225293 console . error ( e )
@@ -232,6 +300,16 @@ export function Session() {
232300 sync . session . syncTree ( route . sessionID ) . catch ( ( ) => { } )
233301 } )
234302
303+ createEffect ( ( ) => {
304+ if ( ! scroll || scroll . isDestroyed ) return
305+ messages ( )
306+ queueMicrotask ( ( ) => {
307+ requestAnimationFrame ( ( ) => {
308+ refreshEdges ( )
309+ } )
310+ } )
311+ } )
312+
235313 const toast = useToast ( )
236314 const sdk = useSDK ( )
237315
@@ -299,7 +377,7 @@ export function Session() {
299377 const findNextVisibleMessage = ( direction : "next" | "prev" ) : string | null => {
300378 const children = scroll . getChildren ( )
301379 const messagesList = messages ( )
302- const scrollTop = scroll . y
380+ const scrollTop = scroll . scrollTop
303381
304382 // Get visible messages sorted by position, filtering for valid non-synthetic, non-ignored content
305383 const visibleMessages = children
@@ -331,20 +409,26 @@ export function Session() {
331409 const targetID = findNextVisibleMessage ( direction )
332410
333411 if ( ! targetID ) {
334- scroll . scrollBy ( direction === "next" ? scroll . height : - scroll . height )
412+ scrollMove ( direction === "next" ? scroll . height : - scroll . height )
335413 dialog . clear ( )
336414 return
337415 }
338416
339417 const child = scroll . getChildren ( ) . find ( ( c ) => c . id === targetID )
340- if ( child ) scroll . scrollBy ( child . y - scroll . y - 1 )
418+ if ( child ) {
419+ scroll . scrollBy ( child . y - scroll . scrollTop - 1 )
420+ refreshEdges ( )
421+ }
341422 dialog . clear ( )
342423 }
343424
344425 function toBottom ( ) {
345426 setTimeout ( ( ) => {
346427 if ( ! scroll || scroll . isDestroyed ) return
347428 scroll . scrollTo ( scroll . scrollHeight )
429+ requestAnimationFrame ( ( ) => {
430+ refreshEdges ( )
431+ } )
348432 } , 50 )
349433 }
350434
@@ -454,7 +538,10 @@ export function Session() {
454538 const child = scroll . getChildren ( ) . find ( ( child ) => {
455539 return child . id === messageID
456540 } )
457- if ( child ) scroll . scrollBy ( child . y - scroll . y - 1 )
541+ if ( child ) {
542+ scroll . scrollBy ( child . y - scroll . scrollTop - 1 )
543+ refreshEdges ( )
544+ }
458545 } }
459546 sessionID = { route . sessionID }
460547 setPrompt = { ( promptInfo ) => prompt . set ( promptInfo ) }
@@ -477,7 +564,10 @@ export function Session() {
477564 const child = scroll . getChildren ( ) . find ( ( child ) => {
478565 return child . id === messageID
479566 } )
480- if ( child ) scroll . scrollBy ( child . y - scroll . y - 1 )
567+ if ( child ) {
568+ scroll . scrollBy ( child . y - scroll . scrollTop - 1 )
569+ refreshEdges ( )
570+ }
481571 } }
482572 sessionID = { route . sessionID }
483573 />
@@ -691,7 +781,7 @@ export function Session() {
691781 category : "Session" ,
692782 hidden : true ,
693783 onSelect : ( dialog ) => {
694- scroll . scrollBy ( - scroll . height / 2 )
784+ scrollMove ( - scroll . height / 2 )
695785 dialog . clear ( )
696786 } ,
697787 } ,
@@ -702,7 +792,7 @@ export function Session() {
702792 category : "Session" ,
703793 hidden : true ,
704794 onSelect : ( dialog ) => {
705- scroll . scrollBy ( scroll . height / 2 )
795+ scrollMove ( scroll . height / 2 )
706796 dialog . clear ( )
707797 } ,
708798 } ,
@@ -713,7 +803,7 @@ export function Session() {
713803 category : "Session" ,
714804 disabled : true ,
715805 onSelect : ( dialog ) => {
716- scroll . scrollBy ( - 1 )
806+ scrollMove ( - 1 )
717807 dialog . clear ( )
718808 } ,
719809 } ,
@@ -724,7 +814,7 @@ export function Session() {
724814 category : "Session" ,
725815 disabled : true ,
726816 onSelect : ( dialog ) => {
727- scroll . scrollBy ( 1 )
817+ scrollMove ( 1 )
728818 dialog . clear ( )
729819 } ,
730820 } ,
@@ -735,7 +825,7 @@ export function Session() {
735825 category : "Session" ,
736826 hidden : true ,
737827 onSelect : ( dialog ) => {
738- scroll . scrollBy ( - scroll . height / 4 )
828+ scrollMove ( - scroll . height / 4 )
739829 dialog . clear ( )
740830 } ,
741831 } ,
@@ -746,7 +836,7 @@ export function Session() {
746836 category : "Session" ,
747837 hidden : true ,
748838 onSelect : ( dialog ) => {
749- scroll . scrollBy ( scroll . height / 4 )
839+ scrollMove ( scroll . height / 4 )
750840 dialog . clear ( )
751841 } ,
752842 } ,
@@ -757,7 +847,23 @@ export function Session() {
757847 category : "Session" ,
758848 hidden : true ,
759849 onSelect : ( dialog ) => {
760- scroll . scrollTo ( 0 )
850+ const page = paging ( )
851+ if ( page ?. hasOlder && ! page . loading ) {
852+ sync . session . jumpToOldest ( route . sessionID ) . then ( ( ) => {
853+ requestAnimationFrame ( ( ) => {
854+ if ( ! scroll || scroll . isDestroyed ) return
855+ scroll . scrollTo ( 0 )
856+ refreshEdges ( )
857+ } )
858+ } )
859+ } else {
860+ if ( ! scroll || scroll . isDestroyed ) {
861+ dialog . clear ( )
862+ return
863+ }
864+ scroll . scrollTo ( 0 )
865+ refreshEdges ( )
866+ }
761867 dialog . clear ( )
762868 } ,
763869 } ,
@@ -768,7 +874,23 @@ export function Session() {
768874 category : "Session" ,
769875 hidden : true ,
770876 onSelect : ( dialog ) => {
771- scroll . scrollTo ( scroll . scrollHeight )
877+ const page = paging ( )
878+ if ( page ?. hasNewer && ! page . loading ) {
879+ sync . session . jumpToLatest ( route . sessionID ) . then ( ( ) => {
880+ requestAnimationFrame ( ( ) => {
881+ if ( ! scroll || scroll . isDestroyed ) return
882+ scroll . scrollTo ( scroll . scrollHeight )
883+ refreshEdges ( )
884+ } )
885+ } )
886+ } else {
887+ if ( ! scroll || scroll . isDestroyed ) {
888+ dialog . clear ( )
889+ return
890+ }
891+ scroll . scrollTo ( scroll . scrollHeight )
892+ refreshEdges ( )
893+ }
772894 dialog . clear ( )
773895 } ,
774896 } ,
@@ -798,7 +920,10 @@ export function Session() {
798920 const child = scroll . getChildren ( ) . find ( ( child ) => {
799921 return child . id === message . id
800922 } )
801- if ( child ) scroll . scrollBy ( child . y - scroll . y - 1 )
923+ if ( child ) {
924+ scroll . scrollBy ( child . y - scroll . scrollTop - 1 )
925+ refreshEdges ( )
926+ }
802927 break
803928 }
804929 }
@@ -1098,8 +1223,45 @@ export function Session() {
10981223 < Show when = { showHeader ( ) && ( ! sidebarVisible ( ) || ! wide ( ) ) } >
10991224 < Header />
11001225 </ Show >
1226+ < Show when = { paging ( ) ?. loading && paging ( ) ?. loadingDirection === "older" } >
1227+ < box flexShrink = { 0 } paddingLeft = { 1 } >
1228+ < text fg = { theme . textMuted } > Loading older messages...</ text >
1229+ </ box >
1230+ </ Show >
1231+ < Show when = { ! paging ( ) ?. loading && paging ( ) ?. hasOlder && nearTop ( ) } >
1232+ < box flexShrink = { 0 } paddingLeft = { 1 } >
1233+ < text fg = { theme . textMuted } > (scroll up for more)</ text >
1234+ </ box >
1235+ </ Show >
1236+ < Show when = { paging ( ) ?. error } >
1237+ < box flexShrink = { 0 } paddingLeft = { 1 } >
1238+ < text fg = { theme . error } > Failed to load: { paging ( ) ?. error } </ text >
1239+ < text fg = { theme . textMuted } > (scroll to retry)</ text >
1240+ </ box >
1241+ </ Show >
11011242 < scrollbox
11021243 ref = { ( r ) => ( scroll = r ) }
1244+ onMouseScroll = { ( ) => {
1245+ refreshEdges ( )
1246+ loadOlder ( )
1247+ loadNewer ( )
1248+ } }
1249+ onKeyDown = { ( e ) => {
1250+ // Standard scroll triggers incremental load
1251+ if ( [ "up" , "pageup" , "home" ] . includes ( e . name ) ) {
1252+ setTimeout ( ( ) => {
1253+ refreshEdges ( )
1254+ loadOlder ( )
1255+ } , 0 )
1256+ }
1257+ if ( [ "down" , "pagedown" , "end" ] . includes ( e . name ) ) {
1258+ setTimeout ( ( ) => {
1259+ refreshEdges ( )
1260+ loadNewer ( )
1261+ } , 0 )
1262+ }
1263+ } }
1264+ viewportCulling = { true }
11031265 viewportOptions = { {
11041266 paddingRight : showScrollbar ( ) ? 1 : 0 ,
11051267 } }
@@ -1212,6 +1374,16 @@ export function Session() {
12121374 ) }
12131375 </ For >
12141376 </ scrollbox >
1377+ < Show when = { paging ( ) ?. loading && paging ( ) ?. loadingDirection === "newer" } >
1378+ < box flexShrink = { 0 } paddingLeft = { 1 } >
1379+ < text fg = { theme . textMuted } > Loading newer messages...</ text >
1380+ </ box >
1381+ </ Show >
1382+ < Show when = { ! paging ( ) ?. loading && paging ( ) ?. hasNewer && nearBottom ( ) } >
1383+ < box flexShrink = { 0 } paddingLeft = { 1 } >
1384+ < text fg = { theme . textMuted } > (scroll down for more)</ text >
1385+ </ box >
1386+ </ Show >
12151387 < box flexShrink = { 0 } >
12161388 < Show when = { permissions ( ) . length > 0 } >
12171389 < PermissionPrompt request = { permissions ( ) [ 0 ] } />
0 commit comments