Skip to content

Commit f6b8df7

Browse files
feat(sessions): type-to-filter and keyboard navigation in the sessions dialog
1 parent b06de60 commit f6b8df7

7 files changed

Lines changed: 549 additions & 54 deletions

File tree

‎frontend/src/components/session/SessionList.test.tsx‎

Lines changed: 266 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
55
import type { ReactNode } from 'react'
66
import { 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

Comments
 (0)