@@ -98,6 +98,13 @@ addDefaultParsers(parsers.parsers)
9898// Exclude temporary bottom space when measuring the real transcript height.
9999const NAVIGATION_SLACK_ID = "session-navigation-slack"
100100
101+ // Tail-first transcript mounting: rows mounted with the session, then backfill cadence.
102+ // The tail comfortably overfills a tall viewport; backfill drains a 200-message transcript
103+ // in a few hundred milliseconds without a perceptible pause.
104+ const TRANSCRIPT_TAIL_ROWS = 40
105+ const TRANSCRIPT_BACKFILL_CHUNK = 60
106+ const TRANSCRIPT_BACKFILL_DELAY = 120
107+
101108const context = createContext < {
102109 width : number
103110 sessionID : string
@@ -296,6 +303,49 @@ export function Session() {
296303 r . set ( route . prompt )
297304 }
298305
306+ /** Runs after layout has settled (two frames), unless the transcript was torn down. */
307+ const afterLayout = ( continuation : ( ) => void ) => {
308+ requestAnimationFrame ( ( ) => {
309+ requestAnimationFrame ( ( ) => {
310+ if ( ! scroll || scroll . isDestroyed ) return
311+ continuation ( )
312+ } )
313+ } )
314+ }
315+
316+ // Tail-first transcript mounting: only the newest rows mount when the session opens, and the
317+ // rest backfill in chunks shortly after, so switching to a long session costs the visible tail
318+ // instead of the whole transcript. Until backfill pins the count, the hidden span derives from
319+ // the row count, so it needs no effect ordering; the clamp keeps at least a tail visible when a
320+ // re-reduce shrinks the transcript. Streaming appends land at the end of the visible slice.
321+ const [ hiddenRows , setHiddenRows ] = createSignal < number > ( )
322+ const hidden = createMemo ( ( ) => Math . max ( 0 , Math . min ( hiddenRows ( ) ?? Infinity , rows . length - TRANSCRIPT_TAIL_ROWS ) ) )
323+ const visibleRows = createMemo ( ( ) => ( hidden ( ) === 0 ? rows : rows . slice ( hidden ( ) ) ) )
324+ createEffect ( ( ) => {
325+ const current = hidden ( )
326+ if ( current === 0 ) return
327+ // Until the first chunk pins hiddenRows, appends change hidden() and reset this timer, so
328+ // backfill waits for a pause in streaming before starting. Once pinned, it drains on a fixed
329+ // cadence undisturbed by appends.
330+ const timer = setTimeout ( ( ) => {
331+ const before = scroll && ! scroll . isDestroyed ? scroll . scrollHeight : undefined
332+ const viewportBottom = before === undefined ? 0 : scroll . scrollTop + scroll . viewport . height
333+ setHiddenRows ( Math . max ( 0 , current - TRANSCRIPT_BACKFILL_CHUNK ) )
334+ if ( before === undefined ) return
335+ // Sticky scroll holds bottom-anchored readers through the mount; compensation is only for
336+ // readers who have scrolled up.
337+ if ( viewportBottom >= before - 1 ) return
338+ afterLayout ( ( ) => scroll . scrollBy ( scroll . scrollHeight - before ) )
339+ } , TRANSCRIPT_BACKFILL_DELAY )
340+ onCleanup ( ( ) => clearTimeout ( timer ) )
341+ } )
342+ /** Message navigation needs the full transcript mounted before walking or jumping. */
343+ const ensureAllRows = ( continuation : ( ) => void ) => {
344+ if ( hidden ( ) === 0 ) return continuation ( )
345+ setHiddenRows ( 0 )
346+ afterLayout ( continuation )
347+ }
348+
299349 createEffect ( ( ) => {
300350 const current = prompt ( )
301351 if ( sent || ! current || ! synced ( ) || ! local . model . ready ) return
@@ -322,41 +372,36 @@ export function Session() {
322372 currentSlack : scroll . getRenderable ( NAVIGATION_SLACK_ID ) ?. height ?? 0 ,
323373 } ) ,
324374 )
325- requestAnimationFrame ( ( ) => {
326- requestAnimationFrame ( ( ) => {
327- if ( scroll . isDestroyed || navigationMessage ( ) !== messageID ) return
328- scroll . scrollTo ( top )
329- } )
375+ afterLayout ( ( ) => {
376+ if ( navigationMessage ( ) !== messageID ) return
377+ scroll . scrollTo ( top )
330378 } )
331379 }
332380
333- const scrollToMessage = ( direction : "next" | "prev" , dialog : ReturnType < typeof useDialog > , userOnly = false ) => {
334- const target = findMessageBoundary ( {
335- direction,
336- children : scroll . getChildren ( ) ,
337- messages : messages ( ) ,
338- scrollTop : scroll . scrollTop ,
339- viewportY : scroll . viewport . y ,
340- currentID : navigationMessage ( ) ,
341- userOnly,
342- } )
381+ const scrollToMessage = ( direction : "next" | "prev" , dialog : ReturnType < typeof useDialog > , userOnly = false ) =>
382+ ensureAllRows ( ( ) => {
383+ const target = findMessageBoundary ( {
384+ direction,
385+ children : scroll . getChildren ( ) ,
386+ messages : messages ( ) ,
387+ scrollTop : scroll . scrollTop ,
388+ viewportY : scroll . viewport . y ,
389+ currentID : navigationMessage ( ) ,
390+ userOnly,
391+ } )
343392
344- if ( ! target ) {
393+ if ( target ) alignMessage ( target . id , target . top )
345394 dialog . clear ( )
346- return
347- }
348-
349- alignMessage ( target . id , target . top )
350- dialog . clear ( )
351- }
395+ } )
352396
353- const jumpToMessage = ( messageID : string ) => {
354- const child = scroll . getRenderable ( messageID )
355- if ( ! child ) return
356- const y = scroll . scrollTop + child . y - scroll . viewport . y
357- const message = data . session . message . get ( route . sessionID , messageID )
358- alignMessage ( messageID , Math . max ( 0 , y - ( message ?. type === "assistant" ? 1 : 0 ) ) )
359- }
397+ const jumpToMessage = ( messageID : string ) =>
398+ ensureAllRows ( ( ) => {
399+ const child = scroll . getRenderable ( messageID )
400+ if ( ! child ) return
401+ const y = scroll . scrollTop + child . y - scroll . viewport . y
402+ const message = data . session . message . get ( route . sessionID , messageID )
403+ alignMessage ( messageID , Math . max ( 0 , y - ( message ?. type === "assistant" ? 1 : 0 ) ) )
404+ } )
360405
361406 function toBottom ( ) {
362407 clearMessageNavigation ( )
@@ -932,12 +977,12 @@ export function Session() {
932977 flexGrow = { 1 }
933978 scrollAcceleration = { scrollAcceleration ( ) }
934979 >
935- < For each = { rows } >
980+ < For each = { visibleRows ( ) } >
936981 { ( row , index ) => (
937982 < SessionRowView
938983 row = { row }
939984 message = { ( messageID ) => data . session . message . get ( route . sessionID , messageID ) }
940- boundaryID = { boundaries ( ) [ index ( ) ] }
985+ boundaryID = { boundaries ( ) [ index ( ) + hidden ( ) ] }
941986 />
942987 ) }
943988 </ For >
0 commit comments