1- import { Component , For , Show , createSignal , createMemo , createEffect , JSX , onCleanup } from "solid-js"
1+ import { Component , Show , createSignal , createMemo , createEffect , JSX , on , onCleanup } from "solid-js"
2+ import { Virtualizer , type VirtualizerHandle } from "virtua/solid"
23import type { SessionStatus } from "../types/session"
34import type { SessionThread } from "../stores/session-state"
45import { getRetrySeconds , getSessionIdleFadeClass , getSessionRetry , getSessionStatus , shouldShowSessionStatus } from "../stores/session-status"
@@ -32,7 +33,7 @@ import {
3233 isSessionSearchLoading ,
3334} from "../stores/sessions"
3435import { getGitRepoStatus , getWorktreeSlugForParentSession } from "../stores/worktrees"
35- import { collectSessionThreadIds , findSessionThread , sortSessionIdsDeepestFirst } from "../stores/session-tree"
36+ import { collectSessionThreadIds , findSessionThread , flattenVisibleSessionThreads , sortSessionIdsDeepestFirst } from "../stores/session-tree"
3637import { getLogger } from "../lib/logger"
3738import { copyToClipboard } from "../lib/clipboard"
3839import { useConfig } from "../stores/preferences"
@@ -69,6 +70,9 @@ const SessionList: Component<SessionListProps> = (props) => {
6970 const [ selectedSessionIds , setSelectedSessionIds ] = createSignal < Set < string > > ( new Set ( ) )
7071 const [ reloadingSessionIds , setReloadingSessionIds ] = createSignal < Set < string > > ( new Set ( ) )
7172 const [ now , setNow ] = createSignal ( Date . now ( ) )
73+ const [ listEl , setListEl ] = createSignal < HTMLDivElement > ( )
74+ const [ virtualizerHandle , setVirtualizerHandle ] = createSignal < VirtualizerHandle > ( )
75+ const [ focusedSessionId , setFocusedSessionId ] = createSignal < string > ( )
7276
7377 createEffect ( ( ) => {
7478 if ( typeof window === "undefined" ) return
@@ -107,7 +111,7 @@ const SessionList: Component<SessionListProps> = (props) => {
107111 } )
108112 }
109113 } ,
110- { root : el . parentElement ?? null , rootMargin : "0px 0px 200px 0px" }
114+ { root : listEl ( ) ?? null , rootMargin : "0px 0px 200px 0px" }
111115 )
112116
113117 observer . observe ( el )
@@ -189,6 +193,29 @@ const SessionList: Component<SessionListProps> = (props) => {
189193 return result
190194 } )
191195
196+ const visibleProjection = createMemo ( ( ) => {
197+ const expandAll = Boolean ( normalizedQuery ( ) )
198+ const rows = flattenVisibleSessionThreads (
199+ filteredThreads ( ) ,
200+ ( sessionId ) => expandAll || isSessionExpanded ( props . instanceId , sessionId ) ,
201+ )
202+ const ids : string [ ] = [ ]
203+ const rowsById = new Map < string , ( typeof rows ) [ number ] > ( )
204+ const indexById = new Map < string , number > ( )
205+ rows . forEach ( ( row , index ) => {
206+ ids . push ( row . sessionId )
207+ rowsById . set ( row . sessionId , row )
208+ indexById . set ( row . sessionId , index )
209+ } )
210+ return { ids, rowsById, indexById }
211+ } )
212+ const keptMountedIndexes = createMemo ( ( ) => {
213+ const sessionId = focusedSessionId ( )
214+ if ( ! sessionId ) return undefined
215+ const index = visibleProjection ( ) . indexById . get ( sessionId )
216+ return index === undefined ? undefined : [ index ]
217+ } )
218+
192219 const allMatchingSessionIds = createMemo < string [ ] > ( ( ) => {
193220 const ids : string [ ] = [ ]
194221 const collectIds = ( threads : SessionThread [ ] ) => {
@@ -472,6 +499,7 @@ const SessionList: Component<SessionListProps> = (props) => {
472499 isLastChild : boolean
473500 hasChildren : boolean
474501 expanded ?: boolean
502+ isLastRow : boolean
475503 onToggleExpand ?: ( ) => void
476504 } > = ( rowProps ) => {
477505 const sessionId = ( ) => rowProps . session . id
@@ -574,7 +602,7 @@ const SessionList: Component<SessionListProps> = (props) => {
574602 }
575603
576604 return (
577- < div class = " session-list-item group" >
605+ < div class = { ` session-list-item group ${ rowProps . isLastRow ? "session-list-item-last" : "" } ` } >
578606 < button
579607 class = { `session-item-base ${ isChild ( ) ? "session-item-nested" : "" } ${ isChild ( ) && rowProps . isLastChild ? "session-item-child-last" : "" } ${ isChild ( ) ? "session-item-border-assistant session-item-kind-assistant" : "session-item-border-user session-item-kind-user" } ${ isActive ( ) ? "session-item-active" : "session-item-inactive" } ` }
580608 style = { nestedStyle ( ) }
@@ -718,40 +746,6 @@ const SessionList: Component<SessionListProps> = (props) => {
718746 )
719747 }
720748
721- // Recursive component for rendering a thread and its children
722- const SessionThreadRow : Component < {
723- thread : SessionThread
724- depth ?: number
725- isLastChild ?: boolean
726- } > = ( rowProps ) => {
727- const depth = ( ) => rowProps . depth ?? 0
728- const expanded = ( ) => normalizedQuery ( ) ? true : isSessionExpanded ( props . instanceId , rowProps . thread . session . id )
729-
730- return (
731- < >
732- < SessionRow
733- session = { rowProps . thread . session }
734- depth = { depth ( ) }
735- hasChildren = { rowProps . thread . hasChildren }
736- expanded = { expanded ( ) }
737- onToggleExpand = { ( ) => toggleSessionExpanded ( props . instanceId , rowProps . thread . session . id ) }
738- isLastChild = { Boolean ( rowProps . isLastChild ) }
739- />
740- < Show when = { expanded ( ) && rowProps . thread . children . length > 0 } >
741- < For each = { rowProps . thread . children } >
742- { ( childThread , index ) => (
743- < SessionThreadRow
744- thread = { childThread }
745- depth = { depth ( ) + 1 }
746- isLastChild = { index ( ) === rowProps . thread . children . length - 1 }
747- />
748- ) }
749- </ For >
750- </ Show >
751- </ >
752- )
753- }
754-
755749 createEffect ( ( ) => {
756750 const activeId = props . activeSessionId
757751 if ( ! activeId || activeId === "info" ) return
@@ -760,52 +754,24 @@ const SessionList: Component<SessionListProps> = (props) => {
760754 ensureSessionAncestorsExpanded ( props . instanceId , activeId )
761755 } )
762756
763- const listEl = createSignal < HTMLElement | null > ( null )
764-
765- const escapeCss = ( value : string ) => {
766- if ( typeof CSS !== "undefined" && typeof ( CSS as any ) . escape === "function" ) {
767- return ( CSS as any ) . escape ( value )
768- }
769- return value . replace ( / \\ / g, "\\\\" ) . replace ( / " / g, "\\\"" )
770- }
771-
772- const scrollActiveIntoView = ( sessionId : string ) => {
773- const root = listEl [ 0 ] ( )
774- if ( ! root ) return
775-
776- const selector = `[data-session-id="${ escapeCss ( sessionId ) } "]`
777-
778- const scrollNow = ( ) => {
779- const target = root . querySelector ( selector ) as HTMLElement | null
780- if ( ! target ) return
781- target . scrollIntoView ( { block : "nearest" , inline : "nearest" } )
782- }
783-
784- if ( typeof requestAnimationFrame === "undefined" ) {
785- scrollNow ( )
786- return
787- }
788-
789- // Wait a couple frames so expand/collapse DOM settles.
790- let raf1 = 0
791- let raf2 = 0
792- raf1 = requestAnimationFrame ( ( ) => {
793- raf2 = requestAnimationFrame ( ( ) => {
794- scrollNow ( )
795- } )
796- } )
797-
798- onCleanup ( ( ) => {
799- if ( raf1 ) cancelAnimationFrame ( raf1 )
800- if ( raf2 ) cancelAnimationFrame ( raf2 )
801- } )
802- }
757+ createEffect ( on (
758+ ( ) => [ props . activeSessionId , virtualizerHandle ( ) ] as const ,
759+ ( [ activeId , handle ] ) => {
760+ if ( ! activeId || activeId === "info" || ! handle ) return
761+
762+ const scroll = ( ) => {
763+ const index = visibleProjection ( ) . indexById . get ( activeId )
764+ if ( index !== undefined ) handle . scrollToIndex ( index , { align : "nearest" } )
765+ }
766+ if ( typeof requestAnimationFrame === "undefined" ) {
767+ scroll ( )
768+ return
769+ }
803770
804- createEffect ( ( ) => {
805- const activeId = props . activeSessionId
806- if ( ! activeId || activeId === "info" ) return
807- scrollActiveIntoView ( activeId )
808- } )
771+ const frame = requestAnimationFrame ( scroll )
772+ onCleanup ( ( ) => cancelAnimationFrame ( frame ) )
773+ } ,
774+ ) )
809775
810776 return (
811777 < div
@@ -883,7 +849,21 @@ const SessionList: Component<SessionListProps> = (props) => {
883849 </ div >
884850 </ Show >
885851
886- < div class = "session-list flex-1 overflow-y-auto" ref = { ( el ) => listEl [ 1 ] ( el ) } >
852+ < div
853+ class = "session-list flex-1 overflow-y-auto"
854+ ref = { setListEl }
855+ onFocusIn = { ( event ) => {
856+ const target = event . target
857+ if ( ! ( target instanceof Element ) ) return
858+ const row = target . closest < HTMLElement > ( "[data-session-id]" )
859+ setFocusedSessionId ( row ?. dataset . sessionId )
860+ } }
861+ onFocusOut = { ( event ) => {
862+ const nextTarget = event . relatedTarget
863+ if ( nextTarget instanceof Node && listEl ( ) ?. contains ( nextTarget ) ) return
864+ setFocusedSessionId ( undefined )
865+ } }
866+ >
887867
888868 < Show when = { sessionListError ( ) } >
889869 { ( error ) => (
@@ -897,23 +877,42 @@ const SessionList: Component<SessionListProps> = (props) => {
897877 ) }
898878 </ Show >
899879
900- < Show when = { ! sessionListError ( ) && isFetchingSessions ( ) && filteredThreads ( ) . length === 0 } >
880+ < Show when = { ! sessionListError ( ) && isFetchingSessions ( ) && visibleProjection ( ) . ids . length === 0 } >
901881 < div class = "flex items-center justify-center p-4 text-xs text-muted" role = "status" >
902882 < span class = "animate-pulse" > { t ( "sessionList.loading.initial" ) } </ span >
903883 </ div >
904884 </ Show >
905885
906- < Show when = { filteredThreads ( ) . length > 0 } >
907- < div class = "session-section" >
908- < For each = { filteredThreads ( ) } >
909- { ( thread , index ) => (
910- < SessionThreadRow
911- thread = { thread }
912- depth = { 0 }
913- isLastChild = { index ( ) === filteredThreads ( ) . length - 1 }
914- />
915- ) }
916- </ For >
886+ < Show when = { visibleProjection ( ) . ids . length > 0 || hasMore ( ) || isFetchingSessions ( ) } >
887+ < div class = "session-section" >
888+ < Show when = { visibleProjection ( ) . ids . length > 0 } >
889+ < Virtualizer
890+ ref = { setVirtualizerHandle }
891+ data = { visibleProjection ( ) . ids }
892+ scrollRef = { listEl ( ) }
893+ bufferSize = { 400 }
894+ keepMounted = { keptMountedIndexes ( ) }
895+ >
896+ { ( sessionId , index ) => {
897+ const row = createMemo ( ( ) => visibleProjection ( ) . rowsById . get ( sessionId ) )
898+ return (
899+ < Show when = { row ( ) } >
900+ { ( current ) => (
901+ < SessionRow
902+ session = { current ( ) . thread . session }
903+ depth = { current ( ) . depth }
904+ hasChildren = { current ( ) . hasChildren }
905+ expanded = { current ( ) . expanded }
906+ onToggleExpand = { ( ) => toggleSessionExpanded ( props . instanceId , sessionId ) }
907+ isLastChild = { current ( ) . isLastChild }
908+ isLastRow = { index ( ) === visibleProjection ( ) . ids . length - 1 && ! hasMore ( ) && ! isFetchingSessions ( ) }
909+ />
910+ ) }
911+ </ Show >
912+ )
913+ } }
914+ </ Virtualizer >
915+ </ Show >
917916 < Show when = { hasMore ( ) || isFetchingSessions ( ) } >
918917 < div
919918 ref = { ( el ) => setSentinelEl ( el ) }
0 commit comments