@@ -5,7 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
55import type { ReactNode } from 'react'
66import { SessionList } from './SessionList'
77
8- const { createSessionMock, deleteSessionMock, sessionsData, createSessionState, fetchNextPageMock, hasNextPageRef, isFetchingNextPageRef, isFetchNextPageErrorRef, useRealSessionsHookRef, lastSessionsHookArgs, sessionPinsData, togglePinMock } = vi . hoisted ( ( ) => ( {
8+ const { createSessionMock, deleteSessionMock, sessionsData, createSessionState, fetchNextPageMock, hasNextPageRef, isFetchingNextPageRef, isFetchNextPageErrorRef, isLoadingRef , isPlaceholderDataRef , useRealSessionsHookRef, lastSessionsHookArgs, sessionPinsData, togglePinMock } = vi . hoisted ( ( ) => ( {
99 createSessionMock : vi . fn ( ) ,
1010 deleteSessionMock : vi . fn ( ) ,
1111 sessionsData : [ ] as Array < { id : string ; title : string ; location : { directory : string } ; parentID ?: string ; time : { updated : number } } > ,
@@ -14,8 +14,10 @@ const { createSessionMock, deleteSessionMock, sessionsData, createSessionState,
1414 hasNextPageRef : { current : false } ,
1515 isFetchingNextPageRef : { current : false } ,
1616 isFetchNextPageErrorRef : { current : false } ,
17+ isLoadingRef : { current : false } ,
18+ isPlaceholderDataRef : { current : false } ,
1719 useRealSessionsHookRef : { current : false } ,
18- lastSessionsHookArgs : { current : undefined as { directories : string [ ] ; options ?: { search ?: string ; limit ?: number } } | undefined } ,
20+ lastSessionsHookArgs : { current : undefined as { directories : string [ ] ; options ?: { search ?: string ; limit ?: number ; keepPreviousResults ?: boolean } } | undefined } ,
1921 sessionPinsData : [ ] as Array < { sessionId : string ; directory : string ; pinnedAt : number } > ,
2022 togglePinMock : vi . fn ( ) ,
2123} ) )
@@ -24,15 +26,19 @@ vi.mock('@/hooks/useOpenCode', async (importOriginal) => {
2426 const actual = await importOriginal < typeof import ( '@/hooks/useOpenCode' ) > ( )
2527 return {
2628 ...actual ,
27- useSessionsAcrossDirectories : ( directories : string [ ] , options ?: { search ?: string ; limit ?: number } ) => {
29+ useSessionsAcrossDirectories : ( directories : string [ ] , options ?: { search ?: string ; limit ?: number ; keepPreviousResults ?: boolean } ) => {
2830 if ( useRealSessionsHookRef . current ) {
2931 return actual . useSessionsAcrossDirectories ( directories , options )
3032 }
3133 lastSessionsHookArgs . current = { directories, options }
32- const data = options ?. search ? [ ] : sessionsData
34+ const search = options ?. search ?. toLowerCase ( ) ?? ''
35+ const data = search
36+ ? sessionsData . filter ( ( session ) => session . title . toLowerCase ( ) . includes ( search ) )
37+ : sessionsData
3338 return {
3439 data,
35- isLoading : false ,
40+ isLoading : isLoadingRef . current ,
41+ isPlaceholderData : isPlaceholderDataRef . current ,
3642 fetchNextPage : fetchNextPageMock ,
3743 hasNextPage : hasNextPageRef . current ,
3844 isFetchingNextPage : isFetchingNextPageRef . current ,
@@ -67,6 +73,8 @@ describe('SessionList', () => {
6773 hasNextPageRef . current = false
6874 isFetchingNextPageRef . current = false
6975 isFetchNextPageErrorRef . current = false
76+ isLoadingRef . current = false
77+ isPlaceholderDataRef . current = false
7078 useRealSessionsHookRef . current = false
7179 lastSessionsHookArgs . current = undefined
7280 sessionPinsData . splice ( 0 , sessionPinsData . length )
@@ -551,4 +559,257 @@ describe('SessionList', () => {
551559 vi . unstubAllGlobals ( )
552560 }
553561 } )
562+
563+ it ( 'filters rows by title immediately and keeps the search input focused' , async ( ) => {
564+ const user = userEvent . setup ( )
565+ sessionsData . splice ( 0 , sessionsData . length ,
566+ { id : 'ses_alpha' , title : 'alpha task' , location : { directory : '/w/a' } , time : { updated : 2 } } ,
567+ { id : 'ses_beta' , title : 'beta task' , location : { directory : '/w/a' } , time : { updated : 1 } } ,
568+ )
569+
570+ render (
571+ < SessionList
572+ directories = { [ '/w/a' ] }
573+ onSelectSession = { vi . fn ( ) }
574+ /> ,
575+ )
576+
577+ const searchInput = screen . getByPlaceholderText ( 'Search sessions...' )
578+ await user . click ( searchInput )
579+ await user . type ( searchInput , 'alpha' )
580+
581+ expect ( screen . getByText ( 'alpha task' ) ) . toBeTruthy ( )
582+ expect ( screen . queryByText ( 'beta task' ) ) . toBeNull ( )
583+ expect ( searchInput ) . toHaveFocus ( )
584+ } )
585+
586+ it ( 'highlights the active session by default and the first match after typing' , async ( ) => {
587+ const user = userEvent . setup ( )
588+ sessionsData . splice ( 0 , sessionsData . length ,
589+ { id : 'ses_active' , title : 'active task' , location : { directory : '/w/a' } , time : { updated : 2 } } ,
590+ { id : 'ses_other' , title : 'other task' , location : { directory : '/w/a' } , time : { updated : 1 } } ,
591+ )
592+
593+ render (
594+ < SessionList
595+ directories = { [ '/w/a' ] }
596+ activeSessionID = "ses_active"
597+ onSelectSession = { vi . fn ( ) }
598+ /> ,
599+ )
600+
601+ expect ( screen . getByRole ( 'option' , { selected : true } ) . textContent ) . toContain ( 'active task' )
602+
603+ await user . type ( screen . getByPlaceholderText ( 'Search sessions...' ) , 'other' )
604+
605+ expect ( screen . getByRole ( 'option' , { selected : true } ) . textContent ) . toContain ( 'other task' )
606+ } )
607+
608+ it ( 'navigates with arrows and Ctrl+N/Ctrl+P, wrapping around, and selects with Enter' , ( ) => {
609+ sessionsData . splice ( 0 , sessionsData . length ,
610+ { id : 'ses_1' , title : 'one' , location : { directory : '/w/a' } , time : { updated : 3 } } ,
611+ { id : 'ses_2' , title : 'two' , location : { directory : '/w/a' } , time : { updated : 2 } } ,
612+ { id : 'ses_3' , title : 'three' , location : { directory : '/w/a' } , time : { updated : 1 } } ,
613+ )
614+ const onSelectSession = vi . fn ( )
615+
616+ render (
617+ < SessionList
618+ directories = { [ '/w/a' ] }
619+ onSelectSession = { onSelectSession }
620+ /> ,
621+ )
622+
623+ const searchInput = screen . getByPlaceholderText ( 'Search sessions...' )
624+ const highlightedTitle = ( ) => screen . getByRole ( 'option' , { selected : true } ) . textContent ?? ''
625+
626+ expect ( highlightedTitle ( ) ) . toContain ( 'one' )
627+
628+ fireEvent . keyDown ( searchInput , { key : 'ArrowDown' } )
629+ expect ( highlightedTitle ( ) ) . toContain ( 'two' )
630+
631+ fireEvent . keyDown ( searchInput , { key : 'ArrowUp' } )
632+ expect ( highlightedTitle ( ) ) . toContain ( 'one' )
633+
634+ fireEvent . keyDown ( searchInput , { key : 'ArrowUp' } )
635+ expect ( highlightedTitle ( ) ) . toContain ( 'three' )
636+
637+ fireEvent . keyDown ( searchInput , { key : 'n' , ctrlKey : true } )
638+ expect ( highlightedTitle ( ) ) . toContain ( 'one' )
639+
640+ fireEvent . keyDown ( searchInput , { key : 'p' , ctrlKey : true } )
641+ expect ( highlightedTitle ( ) ) . toContain ( 'three' )
642+
643+ fireEvent . keyDown ( searchInput , { key : 'Enter' } )
644+ expect ( onSelectSession ) . toHaveBeenCalledWith ( 'ses_3' )
645+ } )
646+
647+ it ( 'clamps PageDown and PageUp navigation to the ends of the list' , ( ) => {
648+ sessionsData . splice ( 0 , sessionsData . length ,
649+ ...Array . from ( { length : 15 } , ( _ , index ) => ( {
650+ id : `ses_${ index } ` ,
651+ title : `session ${ index } ` ,
652+ location : { directory : '/w/a' } ,
653+ time : { updated : 15 - index } ,
654+ } ) ) ,
655+ )
656+
657+ render (
658+ < SessionList
659+ directories = { [ '/w/a' ] }
660+ onSelectSession = { vi . fn ( ) }
661+ /> ,
662+ )
663+
664+ const searchInput = screen . getByPlaceholderText ( 'Search sessions...' )
665+ const highlightedTitle = ( ) => screen . getByRole ( 'option' , { selected : true } ) . textContent ?? ''
666+
667+ expect ( highlightedTitle ( ) ) . toContain ( 'session 0' )
668+
669+ fireEvent . keyDown ( searchInput , { key : 'PageDown' } )
670+ expect ( highlightedTitle ( ) ) . toContain ( 'session 10' )
671+
672+ fireEvent . keyDown ( searchInput , { key : 'PageDown' } )
673+ expect ( highlightedTitle ( ) ) . toContain ( 'session 14' )
674+
675+ fireEvent . keyDown ( searchInput , { key : 'PageUp' } )
676+ expect ( highlightedTitle ( ) ) . toContain ( 'session 4' )
677+
678+ fireEvent . keyDown ( searchInput , { key : 'PageUp' } )
679+ expect ( highlightedTitle ( ) ) . toContain ( 'session 0' )
680+ } )
681+
682+ it ( 'keeps the search input rendered while the first page is loading' , ( ) => {
683+ isLoadingRef . current = true
684+
685+ render (
686+ < SessionList
687+ directories = { [ '/w/a' ] }
688+ onSelectSession = { vi . fn ( ) }
689+ /> ,
690+ )
691+
692+ expect ( screen . getByPlaceholderText ( 'Search sessions...' ) ) . toBeTruthy ( )
693+ expect ( screen . getByText ( 'Loading sessions...' ) ) . toBeTruthy ( )
694+ } )
695+
696+ it ( 'keeps previous rows and the search input mounted while a new search query is fetching' , async ( ) => {
697+ const fetchMock = vi . fn ( )
698+ vi . stubGlobal ( 'fetch' , fetchMock )
699+
700+ const initialItems = [
701+ { id : 'ses_a' , projectID : 'proj_1' , title : 'alpha session' , time : { created : 1 , updated : 1 } , location : { directory : '/w/a' } } ,
702+ { id : 'ses_b' , projectID : 'proj_1' , title : 'beta session' , time : { created : 2 , updated : 2 } , location : { directory : '/w/a' } } ,
703+ ]
704+
705+ let resolveSearch : ( ( response : Response ) => void ) | undefined
706+ fetchMock . mockImplementation ( ( input : RequestInfo | URL ) => {
707+ const url = String ( input )
708+ if ( url . includes ( 'search=beta' ) ) {
709+ return new Promise < Response > ( ( resolve ) => {
710+ resolveSearch = resolve
711+ } )
712+ }
713+ return Promise . resolve ( new Response ( JSON . stringify ( { data : initialItems , cursor : { } } ) , {
714+ status : 200 ,
715+ headers : { 'Content-Type' : 'application/json' } ,
716+ } ) )
717+ } )
718+
719+ useRealSessionsHookRef . current = true
720+ const queryClient = new QueryClient ( { defaultOptions : { queries : { retry : false } , mutations : { retry : false } } } )
721+ const wrapper = ( { children } : { children : ReactNode } ) => (
722+ < QueryClientProvider client = { queryClient } > { children } </ QueryClientProvider >
723+ )
724+
725+ try {
726+ render (
727+ < SessionList
728+ directories = { [ '/w/a' ] }
729+ onSelectSession = { vi . fn ( ) }
730+ /> ,
731+ { wrapper } ,
732+ )
733+
734+ await waitFor ( ( ) => {
735+ expect ( screen . getByText ( 'beta session' ) ) . toBeTruthy ( )
736+ } )
737+
738+ await userEvent . setup ( ) . type ( screen . getByPlaceholderText ( 'Search sessions...' ) , 'beta' )
739+
740+ await waitFor ( ( ) => {
741+ expect ( resolveSearch ) . toBeDefined ( )
742+ } )
743+
744+ expect ( screen . getByText ( 'beta session' ) ) . toBeTruthy ( )
745+ expect ( screen . getByPlaceholderText ( 'Search sessions...' ) ) . toBeTruthy ( )
746+ expect ( screen . queryByText ( 'Loading sessions...' ) ) . toBeNull ( )
747+
748+ resolveSearch ?.( new Response ( JSON . stringify ( {
749+ data : [ { id : 'ses_b' , projectID : 'proj_1' , title : 'beta session' , time : { created : 2 , updated : 2 } , location : { directory : '/w/a' } } ] ,
750+ cursor : { } ,
751+ } ) , { status : 200 , headers : { 'Content-Type' : 'application/json' } } ) )
752+
753+ await waitFor ( ( ) => {
754+ expect ( screen . queryByText ( 'alpha session' ) ) . toBeNull ( )
755+ } )
756+ expect ( screen . getByText ( 'beta session' ) ) . toBeTruthy ( )
757+ } finally {
758+ queryClient . clear ( )
759+ vi . unstubAllGlobals ( )
760+ }
761+ } )
762+
763+ it ( 'shows the searching state while a search is pending and the empty state once it settles' , async ( ) => {
764+ const user = userEvent . setup ( )
765+ sessionsData . splice ( 0 , sessionsData . length ,
766+ { id : 'ses_a' , title : 'alpha task' , location : { directory : '/w/a' } , time : { updated : 1 } } ,
767+ )
768+
769+ render (
770+ < SessionList
771+ directories = { [ '/w/a' ] }
772+ onSelectSession = { vi . fn ( ) }
773+ /> ,
774+ )
775+
776+ await user . type ( screen . getByPlaceholderText ( 'Search sessions...' ) , 'zzz' )
777+
778+ expect ( screen . getByText ( 'Searching sessions...' ) ) . toBeTruthy ( )
779+ expect ( screen . queryByText ( 'No sessions found' ) ) . toBeNull ( )
780+
781+ await waitFor ( ( ) => {
782+ expect ( screen . getByText ( 'No sessions found' ) ) . toBeTruthy ( )
783+ } )
784+ expect ( screen . queryByText ( 'Searching sessions...' ) ) . toBeNull ( )
785+ } )
786+
787+ it ( 'does not paginate while placeholder data is shown' , async ( ) => {
788+ const user = userEvent . setup ( )
789+ isPlaceholderDataRef . current = true
790+ hasNextPageRef . current = true
791+ sessionsData . splice ( 0 , sessionsData . length ,
792+ { id : 'ses_a' , title : 'alpha task' , location : { directory : '/w/a' } , time : { updated : 1 } } ,
793+ )
794+
795+ render (
796+ < SessionList
797+ directories = { [ '/w/a' ] }
798+ onSelectSession = { vi . fn ( ) }
799+ /> ,
800+ )
801+
802+ const scrollContainer = screen . getByRole ( 'region' , { name : 'Sessions' } )
803+ Object . defineProperty ( scrollContainer , 'scrollHeight' , { value : 1000 , configurable : true } )
804+ Object . defineProperty ( scrollContainer , 'clientHeight' , { value : 500 , configurable : true } )
805+ Object . defineProperty ( scrollContainer , 'scrollTop' , { value : 300 , configurable : true } )
806+
807+ await user . type ( screen . getByPlaceholderText ( 'Search sessions...' ) , 'zzz' )
808+ fireEvent . scroll ( scrollContainer )
809+
810+ expect ( screen . getByText ( 'Searching sessions...' ) ) . toBeTruthy ( )
811+
812+ await new Promise ( ( resolve ) => setTimeout ( resolve , 20 ) )
813+ expect ( fetchNextPageMock ) . not . toHaveBeenCalled ( )
814+ } )
554815} )
0 commit comments