From f13463f82a59edc49b2ac6bf184b4cfcd814415c Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:04:25 -0400 Subject: [PATCH 1/4] feat(sessions): type-to-filter and keyboard navigation in the sessions dialog --- .../components/session/SessionList.test.tsx | 271 +++++++++++++++++- .../src/components/session/SessionList.tsx | 218 +++++++++++--- frontend/src/hooks/useOpenCode.test.tsx | 77 +++++ frontend/src/hooks/useOpenCode.ts | 7 + frontend/src/lib/overlayFocus.test.ts | 12 +- frontend/src/lib/overlayFocus.ts | 5 +- frontend/src/pages/SessionDetail.tsx | 13 +- 7 files changed, 549 insertions(+), 54 deletions(-) diff --git a/frontend/src/components/session/SessionList.test.tsx b/frontend/src/components/session/SessionList.test.tsx index f2d20999a..a24412374 100644 --- a/frontend/src/components/session/SessionList.test.tsx +++ b/frontend/src/components/session/SessionList.test.tsx @@ -5,7 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import type { ReactNode } from 'react' import { SessionList } from './SessionList' -const { createSessionMock, deleteSessionMock, sessionsData, createSessionState, fetchNextPageMock, hasNextPageRef, isFetchingNextPageRef, isFetchNextPageErrorRef, useRealSessionsHookRef, lastSessionsHookArgs, sessionPinsData, togglePinMock } = vi.hoisted(() => ({ +const { createSessionMock, deleteSessionMock, sessionsData, createSessionState, fetchNextPageMock, hasNextPageRef, isFetchingNextPageRef, isFetchNextPageErrorRef, isLoadingRef, isPlaceholderDataRef, useRealSessionsHookRef, lastSessionsHookArgs, sessionPinsData, togglePinMock } = vi.hoisted(() => ({ createSessionMock: vi.fn(), deleteSessionMock: vi.fn(), sessionsData: [] as Array<{ id: string; title: string; location: { directory: string }; parentID?: string; time: { updated: number } }>, @@ -14,8 +14,10 @@ const { createSessionMock, deleteSessionMock, sessionsData, createSessionState, hasNextPageRef: { current: false }, isFetchingNextPageRef: { current: false }, isFetchNextPageErrorRef: { current: false }, + isLoadingRef: { current: false }, + isPlaceholderDataRef: { current: false }, useRealSessionsHookRef: { current: false }, - lastSessionsHookArgs: { current: undefined as { directories: string[]; options?: { search?: string; limit?: number } } | undefined }, + lastSessionsHookArgs: { current: undefined as { directories: string[]; options?: { search?: string; limit?: number; keepPreviousResults?: boolean } } | undefined }, sessionPinsData: [] as Array<{ sessionId: string; directory: string; pinnedAt: number }>, togglePinMock: vi.fn(), })) @@ -24,15 +26,19 @@ vi.mock('@/hooks/useOpenCode', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - useSessionsAcrossDirectories: (directories: string[], options?: { search?: string; limit?: number }) => { + useSessionsAcrossDirectories: (directories: string[], options?: { search?: string; limit?: number; keepPreviousResults?: boolean }) => { if (useRealSessionsHookRef.current) { return actual.useSessionsAcrossDirectories(directories, options) } lastSessionsHookArgs.current = { directories, options } - const data = options?.search ? [] : sessionsData + const search = options?.search?.toLowerCase() ?? '' + const data = search + ? sessionsData.filter((session) => session.title.toLowerCase().includes(search)) + : sessionsData return { data, - isLoading: false, + isLoading: isLoadingRef.current, + isPlaceholderData: isPlaceholderDataRef.current, fetchNextPage: fetchNextPageMock, hasNextPage: hasNextPageRef.current, isFetchingNextPage: isFetchingNextPageRef.current, @@ -67,6 +73,8 @@ describe('SessionList', () => { hasNextPageRef.current = false isFetchingNextPageRef.current = false isFetchNextPageErrorRef.current = false + isLoadingRef.current = false + isPlaceholderDataRef.current = false useRealSessionsHookRef.current = false lastSessionsHookArgs.current = undefined sessionPinsData.splice(0, sessionPinsData.length) @@ -551,4 +559,257 @@ describe('SessionList', () => { vi.unstubAllGlobals() } }) + + it('filters rows by title immediately and keeps the search input focused', async () => { + const user = userEvent.setup() + sessionsData.splice(0, sessionsData.length, + { id: 'ses_alpha', title: 'alpha task', location: { directory: '/w/a' }, time: { updated: 2 } }, + { id: 'ses_beta', title: 'beta task', location: { directory: '/w/a' }, time: { updated: 1 } }, + ) + + render( + , + ) + + const searchInput = screen.getByPlaceholderText('Search sessions...') + await user.click(searchInput) + await user.type(searchInput, 'alpha') + + expect(screen.getByText('alpha task')).toBeTruthy() + expect(screen.queryByText('beta task')).toBeNull() + expect(searchInput).toHaveFocus() + }) + + it('highlights the active session by default and the first match after typing', async () => { + const user = userEvent.setup() + sessionsData.splice(0, sessionsData.length, + { id: 'ses_active', title: 'active task', location: { directory: '/w/a' }, time: { updated: 2 } }, + { id: 'ses_other', title: 'other task', location: { directory: '/w/a' }, time: { updated: 1 } }, + ) + + render( + , + ) + + expect(screen.getByRole('option', { selected: true }).textContent).toContain('active task') + + await user.type(screen.getByPlaceholderText('Search sessions...'), 'other') + + expect(screen.getByRole('option', { selected: true }).textContent).toContain('other task') + }) + + it('navigates with arrows and Ctrl+N/Ctrl+P, wrapping around, and selects with Enter', () => { + sessionsData.splice(0, sessionsData.length, + { id: 'ses_1', title: 'one', location: { directory: '/w/a' }, time: { updated: 3 } }, + { id: 'ses_2', title: 'two', location: { directory: '/w/a' }, time: { updated: 2 } }, + { id: 'ses_3', title: 'three', location: { directory: '/w/a' }, time: { updated: 1 } }, + ) + const onSelectSession = vi.fn() + + render( + , + ) + + const searchInput = screen.getByPlaceholderText('Search sessions...') + const highlightedTitle = () => screen.getByRole('option', { selected: true }).textContent ?? '' + + expect(highlightedTitle()).toContain('one') + + fireEvent.keyDown(searchInput, { key: 'ArrowDown' }) + expect(highlightedTitle()).toContain('two') + + fireEvent.keyDown(searchInput, { key: 'ArrowUp' }) + expect(highlightedTitle()).toContain('one') + + fireEvent.keyDown(searchInput, { key: 'ArrowUp' }) + expect(highlightedTitle()).toContain('three') + + fireEvent.keyDown(searchInput, { key: 'n', ctrlKey: true }) + expect(highlightedTitle()).toContain('one') + + fireEvent.keyDown(searchInput, { key: 'p', ctrlKey: true }) + expect(highlightedTitle()).toContain('three') + + fireEvent.keyDown(searchInput, { key: 'Enter' }) + expect(onSelectSession).toHaveBeenCalledWith('ses_3') + }) + + it('clamps PageDown and PageUp navigation to the ends of the list', () => { + sessionsData.splice(0, sessionsData.length, + ...Array.from({ length: 15 }, (_, index) => ({ + id: `ses_${index}`, + title: `session ${index}`, + location: { directory: '/w/a' }, + time: { updated: 15 - index }, + })), + ) + + render( + , + ) + + const searchInput = screen.getByPlaceholderText('Search sessions...') + const highlightedTitle = () => screen.getByRole('option', { selected: true }).textContent ?? '' + + expect(highlightedTitle()).toContain('session 0') + + fireEvent.keyDown(searchInput, { key: 'PageDown' }) + expect(highlightedTitle()).toContain('session 10') + + fireEvent.keyDown(searchInput, { key: 'PageDown' }) + expect(highlightedTitle()).toContain('session 14') + + fireEvent.keyDown(searchInput, { key: 'PageUp' }) + expect(highlightedTitle()).toContain('session 4') + + fireEvent.keyDown(searchInput, { key: 'PageUp' }) + expect(highlightedTitle()).toContain('session 0') + }) + + it('keeps the search input rendered while the first page is loading', () => { + isLoadingRef.current = true + + render( + , + ) + + expect(screen.getByPlaceholderText('Search sessions...')).toBeTruthy() + expect(screen.getByText('Loading sessions...')).toBeTruthy() + }) + + it('keeps previous rows and the search input mounted while a new search query is fetching', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + const initialItems = [ + { id: 'ses_a', projectID: 'proj_1', title: 'alpha session', time: { created: 1, updated: 1 }, location: { directory: '/w/a' } }, + { id: 'ses_b', projectID: 'proj_1', title: 'beta session', time: { created: 2, updated: 2 }, location: { directory: '/w/a' } }, + ] + + let resolveSearch: ((response: Response) => void) | undefined + fetchMock.mockImplementation((input: RequestInfo | URL) => { + const url = String(input) + if (url.includes('search=beta')) { + return new Promise((resolve) => { + resolveSearch = resolve + }) + } + return Promise.resolve(new Response(JSON.stringify({ data: initialItems, cursor: {} }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) + }) + + useRealSessionsHookRef.current = true + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + + try { + render( + , + { wrapper }, + ) + + await waitFor(() => { + expect(screen.getByText('beta session')).toBeTruthy() + }) + + await userEvent.setup().type(screen.getByPlaceholderText('Search sessions...'), 'beta') + + await waitFor(() => { + expect(resolveSearch).toBeDefined() + }) + + expect(screen.getByText('beta session')).toBeTruthy() + expect(screen.getByPlaceholderText('Search sessions...')).toBeTruthy() + expect(screen.queryByText('Loading sessions...')).toBeNull() + + resolveSearch?.(new Response(JSON.stringify({ + data: [{ id: 'ses_b', projectID: 'proj_1', title: 'beta session', time: { created: 2, updated: 2 }, location: { directory: '/w/a' } }], + cursor: {}, + }), { status: 200, headers: { 'Content-Type': 'application/json' } })) + + await waitFor(() => { + expect(screen.queryByText('alpha session')).toBeNull() + }) + expect(screen.getByText('beta session')).toBeTruthy() + } finally { + queryClient.clear() + vi.unstubAllGlobals() + } + }) + + it('shows the searching state while a search is pending and the empty state once it settles', async () => { + const user = userEvent.setup() + sessionsData.splice(0, sessionsData.length, + { id: 'ses_a', title: 'alpha task', location: { directory: '/w/a' }, time: { updated: 1 } }, + ) + + render( + , + ) + + await user.type(screen.getByPlaceholderText('Search sessions...'), 'zzz') + + expect(screen.getByText('Searching sessions...')).toBeTruthy() + expect(screen.queryByText('No sessions found')).toBeNull() + + await waitFor(() => { + expect(screen.getByText('No sessions found')).toBeTruthy() + }) + expect(screen.queryByText('Searching sessions...')).toBeNull() + }) + + it('does not paginate while placeholder data is shown', async () => { + const user = userEvent.setup() + isPlaceholderDataRef.current = true + hasNextPageRef.current = true + sessionsData.splice(0, sessionsData.length, + { id: 'ses_a', title: 'alpha task', location: { directory: '/w/a' }, time: { updated: 1 } }, + ) + + render( + , + ) + + const scrollContainer = screen.getByRole('region', { name: 'Sessions' }) + Object.defineProperty(scrollContainer, 'scrollHeight', { value: 1000, configurable: true }) + Object.defineProperty(scrollContainer, 'clientHeight', { value: 500, configurable: true }) + Object.defineProperty(scrollContainer, 'scrollTop', { value: 300, configurable: true }) + + await user.type(screen.getByPlaceholderText('Search sessions...'), 'zzz') + fireEvent.scroll(scrollContainer) + + expect(screen.getByText('Searching sessions...')).toBeTruthy() + + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(fetchNextPageMock).not.toHaveBeenCalled() + }) }) diff --git a/frontend/src/components/session/SessionList.tsx b/frontend/src/components/session/SessionList.tsx index a1bebb2d4..601ca4cf1 100644 --- a/frontend/src/components/session/SessionList.tsx +++ b/frontend/src/components/session/SessionList.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState, useMemo, useEffect, useRef, type ReactNode } from "react"; +import { useCallback, useState, useMemo, useEffect, useRef, useId, type ReactNode } from "react"; import { useSessionsAcrossDirectories, useDeleteSession, useCreateSession } from "@/hooks/useOpenCode"; import type { DeleteSessionTarget } from "@/hooks/useOpenCode"; import type { Session } from "@/api/types"; @@ -11,6 +11,7 @@ import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Search, Trash2, Pencil, X } from "lucide-react"; +import { useDebouncedValue } from "@/hooks/useDebouncedValue"; interface SessionListProps { directory?: string; @@ -50,7 +51,10 @@ export const SessionList = ({ buildSessionKey(session.location.directory, session.id), []); const [searchQuery, setSearchQuery] = useState(""); - const { data: sessions, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage, isFetchNextPageError } = useSessionsAcrossDirectories(directoriesList, { search: searchQuery, limit: 25 }); + const trimmedSearchQuery = searchQuery.trim(); + const debouncedSearchQuery = useDebouncedValue(trimmedSearchQuery, 150); + const search = trimmedSearchQuery ? debouncedSearchQuery : ''; + const { data: sessions, isLoading, isPlaceholderData, fetchNextPage, hasNextPage, isFetchingNextPage, isFetchNextPageError } = useSessionsAcrossDirectories(directoriesList, { search, limit: 25, keepPreviousResults: true }); const deleteSession = useDeleteSession(directoriesList); const createSession = useCreateSession(sessionCreateDirectory, (newSession) => { onSelectSession(newSession.id); @@ -67,16 +71,52 @@ export const SessionList = ({ const [manageMode, setManageMode] = useState(false); const sessionListRef = useRef(null); - const filteredSessions = useMemo( + const rootSessions = useMemo( () => selectRootSessions(sessions ?? [], { directories: directorySet, keyFn: getSessionSelectionKey }), [sessions, directorySet, getSessionSelectionKey], ); + const filteredSessions = useMemo(() => { + if (!trimmedSearchQuery) return rootSessions; + const needle = trimmedSearchQuery.toLowerCase(); + return rootSessions.filter((session) => (session.title ?? '').toLowerCase().includes(needle)); + }, [rootSessions, trimmedSearchQuery]); + const { pinned: pinnedSessions, today: todaySessions, older: olderSessions } = useMemo( () => partitionSessions(filteredSessions, pinnedKeys, getSessionSelectionKey), [filteredSessions, pinnedKeys, getSessionSelectionKey], ); + const navSessions = useMemo( + () => [...pinnedSessions, ...todaySessions, ...olderSessions], + [pinnedSessions, todaySessions, olderSessions], + ); + + const [highlightedKey, setHighlightedKey] = useState(null); + + useEffect(() => { + setHighlightedKey(null); + }, [trimmedSearchQuery]); + + const highlightedSession = useMemo(() => { + if (navSessions.length === 0) return undefined; + if (highlightedKey) { + const stored = navSessions.find((session) => getSessionSelectionKey(session) === highlightedKey); + if (stored) return stored; + } + if (!trimmedSearchQuery && activeSessionID) { + const active = navSessions.find((session) => session.id === activeSessionID); + if (active) return active; + } + return navSessions[0]; + }, [navSessions, highlightedKey, trimmedSearchQuery, activeSessionID, getSessionSelectionKey]); + + const highlightedSessionKey = highlightedSession ? getSessionSelectionKey(highlightedSession) : null; + const highlightedIndex = highlightedSession ? navSessions.indexOf(highlightedSession) : -1; + const listboxId = useId(); + const keyboardNavigationEnabled = !renderSessions && !manageMode; + const isSearchPending = trimmedSearchQuery !== search || isPlaceholderData; + const handleTogglePin = (session: Session) => { const directory = session.location.directory; const key = getSessionSelectionKey(session); @@ -89,10 +129,10 @@ export const SessionList = ({ const handleSessionsScroll = useCallback((event: React.UIEvent) => { const { scrollTop, scrollHeight, clientHeight } = event.currentTarget; - if (scrollHeight - scrollTop - clientHeight <= 240 && hasNextPage && !isFetchingNextPage && !isFetchNextPageError) { + if (scrollHeight - scrollTop - clientHeight <= 240 && hasNextPage && !isFetchingNextPage && !isFetchNextPageError && !isPlaceholderData) { void fetchNextPage(); } - }, [hasNextPage, isFetchingNextPage, isFetchNextPageError, fetchNextPage]); + }, [hasNextPage, isFetchingNextPage, isFetchNextPageError, isPlaceholderData, fetchNextPage]); useEffect(() => { const sessionList = sessionListRef.current; @@ -101,6 +141,7 @@ export const SessionList = ({ : filteredSessions.length === 0; if ( !isLoading + && !isPlaceholderData && isNearBottom && hasNextPage && !isFetchingNextPage @@ -108,44 +149,72 @@ export const SessionList = ({ ) { void fetchNextPage(); } - }, [isLoading, filteredSessions, hasNextPage, isFetchingNextPage, isFetchNextPageError, fetchNextPage]); - - if (isLoading && !renderSessions) { - return
Loading sessions...
; - } + }, [isLoading, isPlaceholderData, filteredSessions, hasNextPage, isFetchingNextPage, isFetchNextPageError, fetchNextPage]); - if (!sessions || sessions.length === 0) { - if (isFetchNextPageError) { - return ( -
-

Failed to load sessions.

- -
- ); + useEffect(() => { + if (!highlightedSessionKey) return; + const list = sessionListRef.current; + if (!list) return; + const highlighted = list.querySelector('[data-highlighted="true"]'); + if (highlighted && typeof highlighted.scrollIntoView === 'function') { + highlighted.scrollIntoView({ block: 'nearest' }); } - if ((hasNextPage || isFetchingNextPage) && !renderSessions) { - return
Loading sessions...
; + }, [highlightedSessionKey]); + + const handleSearchKeyDown = (event: React.KeyboardEvent) => { + if (!keyboardNavigationEnabled) return; + if (event.nativeEvent.isComposing) return; + if (event.altKey || event.metaKey) return; + + const selectIndex = (index: number) => { + const session = navSessions[index]; + if (!session) return false; + setHighlightedKey(getSessionSelectionKey(session)); + return true; + }; + const moveBy = (delta: number) => { + const count = navSessions.length; + if (count === 0) return false; + return selectIndex(((highlightedIndex + delta) % count + count) % count); + }; + const moveClamped = (delta: number) => { + const count = navSessions.length; + if (count === 0) return false; + return selectIndex(Math.min(Math.max(highlightedIndex + delta, 0), count - 1)); + }; + + if (event.ctrlKey) { + if (event.key.toLowerCase() === 'n') { + if (moveBy(1)) event.preventDefault(); + } else if (event.key.toLowerCase() === 'p') { + if (moveBy(-1)) event.preventDefault(); + } + return; } - if (!searchQuery.trim() && !renderSessions) { - return ( -
- createSession.mutate({ agent: undefined })} - > -
-

No sessions yet

-

- Click here to start a new session -

-
-
-
- ); + + switch (event.key) { + case 'ArrowDown': + if (moveBy(1)) event.preventDefault(); + break; + case 'ArrowUp': + if (moveBy(-1)) event.preventDefault(); + break; + case 'PageDown': + if (moveClamped(10)) event.preventDefault(); + break; + case 'PageUp': + if (moveClamped(-10)) event.preventDefault(); + break; + case 'Enter': + if (highlightedSession) { + event.preventDefault(); + onSelectSession(highlightedSession.id); + } + break; + default: + break; } - } + }; const getDeleteTarget = (session: Session): DeleteSessionTarget => ({ id: session.id, @@ -211,7 +280,7 @@ export const SessionList = ({ const renderSessionCard = (session: (typeof filteredSessions)[number], isPinned: boolean) => { const key = getSessionSelectionKey(session); - return ( + const card = ( handleDelete(session, e)} /> ); + if (!keyboardNavigationEnabled) return card; + const index = navSessions.indexOf(session); + const isHighlighted = highlightedSession === session; + return ( +
+ {card} +
+ ); }; + if (!isLoading && !isPlaceholderData && (!sessions || sessions.length === 0)) { + if (isFetchNextPageError) { + return ( +
+

Failed to load sessions.

+ +
+ ); + } + if ((hasNextPage || isFetchingNextPage) && !renderSessions) { + return
Loading sessions...
; + } + if (!searchQuery.trim() && !renderSessions) { + return ( +
+ createSession.mutate({ agent: undefined })} + > +
+

No sessions yet

+

+ Click here to start a new session +

+
+
+
+ ); + } + } + return (
@@ -266,9 +383,14 @@ export const SessionList = ({ placeholder="Search sessions..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} + onKeyDown={handleSearchKeyDown} className="pl-9 h-9" autoComplete="off" name="session-search" + role={keyboardNavigationEnabled ? 'combobox' : undefined} + aria-expanded={keyboardNavigationEnabled ? true : undefined} + aria-controls={keyboardNavigationEnabled ? listboxId : undefined} + aria-activedescendant={keyboardNavigationEnabled && highlightedIndex >= 0 ? `${listboxId}-option-${highlightedIndex}` : undefined} />
)} - {isFetchingNextPage && ( + {isFetchingNextPage && (sessions?.length ?? 0) > 0 && (
Loading more sessions...
diff --git a/frontend/src/hooks/useOpenCode.test.tsx b/frontend/src/hooks/useOpenCode.test.tsx index e5019ca66..b07be3697 100644 --- a/frontend/src/hooks/useOpenCode.test.tsx +++ b/frontend/src/hooks/useOpenCode.test.tsx @@ -154,6 +154,83 @@ describe('useSessionsAcrossDirectories', () => { expect(queryClient.getQueryState(listKey)?.isInvalidated).toBe(true) }) }) + + it('does not keep previous results across a search change unless requested', async () => { + let resolveSearch: ((value: { items: SessionInfo[] }) => void) | undefined + mocks.listSessionPage.mockImplementation(({ search }: { search?: string }) => { + if (search === 'deploy') { + return new Promise<{ items: SessionInfo[] }>((resolve) => { resolveSearch = resolve }) + } + return Promise.resolve({ items: [sessionInfo('ses_a1', '/w/a')] }) + }) + + const queryClient = createQueryClient() + const { result, rerender } = renderHook( + (props: { search?: string }) => useSessionsAcrossDirectories(['/w/a'], { search: props.search }), + { wrapper: createWrapper(queryClient), initialProps: { search: undefined as string | undefined } }, + ) + + await waitFor(() => { + expect(result.current.data.map((session) => session.id)).toEqual(['ses_a1']) + }) + + rerender({ search: 'deploy' }) + + await waitFor(() => expect(resolveSearch).toBeDefined()) + + expect(result.current.isPlaceholderData).toBe(false) + expect(result.current.isLoading).toBe(true) + expect(result.current.data).toHaveLength(0) + + await act(async () => { + resolveSearch?.({ items: [sessionInfo('ses_b1', '/w/a')] }) + }) + + await waitFor(() => { + expect(result.current.data.map((session) => session.id)).toEqual(['ses_b1']) + }) + }) + + it('keeps previous results across a search change when requested', async () => { + let resolveSearch: ((value: { items: SessionInfo[] }) => void) | undefined + mocks.listSessionPage.mockImplementation(({ search }: { search?: string }) => { + if (search === 'deploy') { + return new Promise<{ items: SessionInfo[] }>((resolve) => { resolveSearch = resolve }) + } + return Promise.resolve({ items: [sessionInfo('ses_a1', '/w/a')] }) + }) + + const queryClient = createQueryClient() + const { result, rerender } = renderHook( + (props: { search?: string; keepPreviousResults?: boolean }) => + useSessionsAcrossDirectories(['/w/a'], { + search: props.search, + keepPreviousResults: props.keepPreviousResults, + }), + { + wrapper: createWrapper(queryClient), + initialProps: { search: undefined as string | undefined, keepPreviousResults: true }, + }, + ) + + await waitFor(() => { + expect(result.current.data.map((session) => session.id)).toEqual(['ses_a1']) + }) + + rerender({ search: 'deploy', keepPreviousResults: true }) + + await waitFor(() => expect(result.current.isPlaceholderData).toBe(true)) + expect(result.current.data.map((session) => session.id)).toEqual(['ses_a1']) + + await act(async () => { + resolveSearch?.({ items: [sessionInfo('ses_b1', '/w/a')] }) + }) + + await waitFor(() => { + expect(result.current.data.map((session) => session.id)).toEqual(['ses_b1']) + }) + expect(result.current.isPlaceholderData).toBe(false) + }) }) describe('useSession', () => { diff --git a/frontend/src/hooks/useOpenCode.ts b/frontend/src/hooks/useOpenCode.ts index 3b5ec6b96..611108a63 100644 --- a/frontend/src/hooks/useOpenCode.ts +++ b/frontend/src/hooks/useOpenCode.ts @@ -41,6 +41,7 @@ const EMPTY_SESSIONS: SessionInfo[] = [] interface UseSessionsAcrossDirectoriesOptions { search?: string limit?: number + keepPreviousResults?: boolean } type SessionPageParam = Record @@ -55,6 +56,7 @@ export const useSessionsAcrossDirectories = ( ); const normalizedSearch = options?.search?.trim() || undefined; const limit = options?.limit ?? SESSION_LIST_PAGE_SIZE; + const keepPreviousResults = options?.keepPreviousResults ?? false; const directoryKey = uniqueDirectories.join('|'); const query = useInfiniteQuery({ @@ -97,6 +99,10 @@ export const useSessionsAcrossDirectories = ( return { items, cursors }; }, initialPageParam: undefined as SessionPageParam | undefined, + placeholderData: keepPreviousResults + ? (previousData, previousQuery) => + previousQuery?.queryKey[2] === directoryKey ? previousData : undefined + : undefined, getNextPageParam: (lastPage) => { if (Object.keys(lastPage.cursors).length > 0) { return lastPage.cursors; @@ -117,6 +123,7 @@ export const useSessionsAcrossDirectories = ( return { data, isLoading: query.isLoading, + isPlaceholderData: query.isPlaceholderData, isError: query.isError, fetchNextPage: query.fetchNextPage, hasNextPage: query.hasNextPage, diff --git a/frontend/src/lib/overlayFocus.test.ts b/frontend/src/lib/overlayFocus.test.ts index 792f88977..91834d26b 100644 --- a/frontend/src/lib/overlayFocus.test.ts +++ b/frontend/src/lib/overlayFocus.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, afterEach } from 'vitest' import { stubMatchMedia } from '@/test/test-utils' -import { getFocusedElement, hasOpenOverlay, restoreOverlayFocus } from './overlayFocus' +import { getFocusedElement, hasOpenOverlay, isFinePointer, restoreOverlayFocus } from './overlayFocus' import { PROMPT_INPUT_SELECTOR, isPromptInput, isTextEntryElement } from './domTargets' function createElement( @@ -26,6 +26,16 @@ describe('getFocusedElement', () => { }) }) +describe('isFinePointer', () => { + it('reflects the fine pointer media query', () => { + stubMatchMedia(true) + expect(isFinePointer()).toBe(true) + + stubMatchMedia(false) + expect(isFinePointer()).toBe(false) + }) +}) + describe('restoreOverlayFocus', () => { it('focuses the chat prompt on desktop when focus was on a button', () => { stubMatchMedia(true) diff --git a/frontend/src/lib/overlayFocus.ts b/frontend/src/lib/overlayFocus.ts index a41da3cf9..fa8b08d26 100644 --- a/frontend/src/lib/overlayFocus.ts +++ b/frontend/src/lib/overlayFocus.ts @@ -12,7 +12,10 @@ export function getFocusedElement(): HTMLElement | null { : null } -function isFinePointer(): boolean { +/** + * Reports whether the current device uses a fine pointer, such as a mouse or trackpad. + */ +export function isFinePointer(): boolean { return ( typeof window !== 'undefined' && typeof window.matchMedia === 'function' && diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx index a68024f55..c0d248cb0 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -10,6 +10,7 @@ import { SquareFill } from "@/components/ui/square-fill"; import { Header } from "@/components/ui/header"; import { SessionList } from "@/components/session/SessionList"; import { getSessionListPath } from '@/lib/navigation' +import { isFinePointer } from '@/lib/overlayFocus' import { FetchError } from '@/api/fetchWrapper' import { FileBrowserSheet } from "@/components/file-browser/FileBrowserSheet"; @@ -829,9 +830,17 @@ export function SessionDetail() { {/* Sessions Dialog */} - + { + if (!isFinePointer()) { + event.preventDefault(); + (event.currentTarget as HTMLElement).focus(); + } + }} + > Sessions -
+
{sessionDirectory && ( Date: Thu, 8 Oct 2026 11:33:45 -0400 Subject: [PATCH 2/4] feat(sessions): TUI-style session picker with scope toggle and delete --- .../components/session/SessionList.test.tsx | 96 --- .../src/components/session/SessionList.tsx | 186 +----- .../session/SessionPickerDialog.test.tsx | 312 +++++++++ .../session/SessionPickerDialog.tsx | 631 ++++++++++++++++++ frontend/src/hooks/useSessionSearch.test.tsx | 112 ++++ frontend/src/hooks/useSessionSearch.ts | 71 ++ frontend/src/hooks/useSidebarCollapsed.ts | 6 +- frontend/src/pages/SessionDetail.tsx | 51 +- .../SessionDetail.assistant-loading.test.tsx | 4 +- .../__tests__/SessionDetail.commands.test.tsx | 2 +- .../SessionDetail.form-prompt.test.tsx | 4 +- .../__tests__/SessionDetail.polling.test.tsx | 2 +- .../SessionDetail.scroll-floating.test.tsx | 4 +- 13 files changed, 1189 insertions(+), 292 deletions(-) create mode 100644 frontend/src/components/session/SessionPickerDialog.test.tsx create mode 100644 frontend/src/components/session/SessionPickerDialog.tsx create mode 100644 frontend/src/hooks/useSessionSearch.test.tsx create mode 100644 frontend/src/hooks/useSessionSearch.ts diff --git a/frontend/src/components/session/SessionList.test.tsx b/frontend/src/components/session/SessionList.test.tsx index a24412374..e445c323d 100644 --- a/frontend/src/components/session/SessionList.test.tsx +++ b/frontend/src/components/session/SessionList.test.tsx @@ -583,102 +583,6 @@ describe('SessionList', () => { expect(searchInput).toHaveFocus() }) - it('highlights the active session by default and the first match after typing', async () => { - const user = userEvent.setup() - sessionsData.splice(0, sessionsData.length, - { id: 'ses_active', title: 'active task', location: { directory: '/w/a' }, time: { updated: 2 } }, - { id: 'ses_other', title: 'other task', location: { directory: '/w/a' }, time: { updated: 1 } }, - ) - - render( - , - ) - - expect(screen.getByRole('option', { selected: true }).textContent).toContain('active task') - - await user.type(screen.getByPlaceholderText('Search sessions...'), 'other') - - expect(screen.getByRole('option', { selected: true }).textContent).toContain('other task') - }) - - it('navigates with arrows and Ctrl+N/Ctrl+P, wrapping around, and selects with Enter', () => { - sessionsData.splice(0, sessionsData.length, - { id: 'ses_1', title: 'one', location: { directory: '/w/a' }, time: { updated: 3 } }, - { id: 'ses_2', title: 'two', location: { directory: '/w/a' }, time: { updated: 2 } }, - { id: 'ses_3', title: 'three', location: { directory: '/w/a' }, time: { updated: 1 } }, - ) - const onSelectSession = vi.fn() - - render( - , - ) - - const searchInput = screen.getByPlaceholderText('Search sessions...') - const highlightedTitle = () => screen.getByRole('option', { selected: true }).textContent ?? '' - - expect(highlightedTitle()).toContain('one') - - fireEvent.keyDown(searchInput, { key: 'ArrowDown' }) - expect(highlightedTitle()).toContain('two') - - fireEvent.keyDown(searchInput, { key: 'ArrowUp' }) - expect(highlightedTitle()).toContain('one') - - fireEvent.keyDown(searchInput, { key: 'ArrowUp' }) - expect(highlightedTitle()).toContain('three') - - fireEvent.keyDown(searchInput, { key: 'n', ctrlKey: true }) - expect(highlightedTitle()).toContain('one') - - fireEvent.keyDown(searchInput, { key: 'p', ctrlKey: true }) - expect(highlightedTitle()).toContain('three') - - fireEvent.keyDown(searchInput, { key: 'Enter' }) - expect(onSelectSession).toHaveBeenCalledWith('ses_3') - }) - - it('clamps PageDown and PageUp navigation to the ends of the list', () => { - sessionsData.splice(0, sessionsData.length, - ...Array.from({ length: 15 }, (_, index) => ({ - id: `ses_${index}`, - title: `session ${index}`, - location: { directory: '/w/a' }, - time: { updated: 15 - index }, - })), - ) - - render( - , - ) - - const searchInput = screen.getByPlaceholderText('Search sessions...') - const highlightedTitle = () => screen.getByRole('option', { selected: true }).textContent ?? '' - - expect(highlightedTitle()).toContain('session 0') - - fireEvent.keyDown(searchInput, { key: 'PageDown' }) - expect(highlightedTitle()).toContain('session 10') - - fireEvent.keyDown(searchInput, { key: 'PageDown' }) - expect(highlightedTitle()).toContain('session 14') - - fireEvent.keyDown(searchInput, { key: 'PageUp' }) - expect(highlightedTitle()).toContain('session 4') - - fireEvent.keyDown(searchInput, { key: 'PageUp' }) - expect(highlightedTitle()).toContain('session 0') - }) - it('keeps the search input rendered while the first page is loading', () => { isLoadingRef.current = true diff --git a/frontend/src/components/session/SessionList.tsx b/frontend/src/components/session/SessionList.tsx index 601ca4cf1..7f3d4fb0e 100644 --- a/frontend/src/components/session/SessionList.tsx +++ b/frontend/src/components/session/SessionList.tsx @@ -1,17 +1,17 @@ -import { useCallback, useState, useMemo, useEffect, useRef, useId, type ReactNode } from "react"; -import { useSessionsAcrossDirectories, useDeleteSession, useCreateSession } from "@/hooks/useOpenCode"; +import { useCallback, useState, useMemo, useEffect, useRef, type ReactNode } from "react"; +import { useDeleteSession, useCreateSession } from "@/hooks/useOpenCode"; import type { DeleteSessionTarget } from "@/hooks/useOpenCode"; import type { Session } from "@/api/types"; import { useSessionPins, useToggleSessionPin } from '@/hooks/useSessionPins'; import { buildSessionKey, buildPinnedSessionKeys } from '@/lib/sessionKey'; -import { partitionSessions, selectRootSessions } from './session-partition'; +import { partitionSessions } from './session-partition'; import { DeleteSessionDialog } from "./DeleteSessionDialog"; import { SessionCard } from "./SessionCard"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Search, Trash2, Pencil, X } from "lucide-react"; -import { useDebouncedValue } from "@/hooks/useDebouncedValue"; +import { useSessionSearch } from "@/hooks/useSessionSearch"; interface SessionListProps { directory?: string; @@ -44,17 +44,26 @@ export const SessionList = ({ const source = directories && directories.length > 0 ? directories : directory ? [directory] : []; return Array.from(new Set(source.filter(Boolean))); }, [directory, directories]); - const directorySet = useMemo(() => new Set(directoriesList), [directoriesList]); const primaryDirectory = directoriesList[0]; const sessionCreateDirectory = createDirectory ?? primaryDirectory; const getSessionSelectionKey = useCallback((session: Session) => buildSessionKey(session.location.directory, session.id), []); - const [searchQuery, setSearchQuery] = useState(""); - const trimmedSearchQuery = searchQuery.trim(); - const debouncedSearchQuery = useDebouncedValue(trimmedSearchQuery, 150); - const search = trimmedSearchQuery ? debouncedSearchQuery : ''; - const { data: sessions, isLoading, isPlaceholderData, fetchNextPage, hasNextPage, isFetchingNextPage, isFetchNextPageError } = useSessionsAcrossDirectories(directoriesList, { search, limit: 25, keepPreviousResults: true }); + const { + query, + setQuery, + trimmedQuery, + sessions, + filteredSessions, + isSearchPending, + isLoading, + isPlaceholderData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isFetchNextPageError, + canFetchNextPage, + } = useSessionSearch(directoriesList); const deleteSession = useDeleteSession(directoriesList); const createSession = useCreateSession(sessionCreateDirectory, (newSession) => { onSelectSession(newSession.id); @@ -71,52 +80,11 @@ export const SessionList = ({ const [manageMode, setManageMode] = useState(false); const sessionListRef = useRef(null); - const rootSessions = useMemo( - () => selectRootSessions(sessions ?? [], { directories: directorySet, keyFn: getSessionSelectionKey }), - [sessions, directorySet, getSessionSelectionKey], - ); - - const filteredSessions = useMemo(() => { - if (!trimmedSearchQuery) return rootSessions; - const needle = trimmedSearchQuery.toLowerCase(); - return rootSessions.filter((session) => (session.title ?? '').toLowerCase().includes(needle)); - }, [rootSessions, trimmedSearchQuery]); - const { pinned: pinnedSessions, today: todaySessions, older: olderSessions } = useMemo( () => partitionSessions(filteredSessions, pinnedKeys, getSessionSelectionKey), [filteredSessions, pinnedKeys, getSessionSelectionKey], ); - const navSessions = useMemo( - () => [...pinnedSessions, ...todaySessions, ...olderSessions], - [pinnedSessions, todaySessions, olderSessions], - ); - - const [highlightedKey, setHighlightedKey] = useState(null); - - useEffect(() => { - setHighlightedKey(null); - }, [trimmedSearchQuery]); - - const highlightedSession = useMemo(() => { - if (navSessions.length === 0) return undefined; - if (highlightedKey) { - const stored = navSessions.find((session) => getSessionSelectionKey(session) === highlightedKey); - if (stored) return stored; - } - if (!trimmedSearchQuery && activeSessionID) { - const active = navSessions.find((session) => session.id === activeSessionID); - if (active) return active; - } - return navSessions[0]; - }, [navSessions, highlightedKey, trimmedSearchQuery, activeSessionID, getSessionSelectionKey]); - - const highlightedSessionKey = highlightedSession ? getSessionSelectionKey(highlightedSession) : null; - const highlightedIndex = highlightedSession ? navSessions.indexOf(highlightedSession) : -1; - const listboxId = useId(); - const keyboardNavigationEnabled = !renderSessions && !manageMode; - const isSearchPending = trimmedSearchQuery !== search || isPlaceholderData; - const handleTogglePin = (session: Session) => { const directory = session.location.directory; const key = getSessionSelectionKey(session); @@ -129,92 +97,20 @@ export const SessionList = ({ const handleSessionsScroll = useCallback((event: React.UIEvent) => { const { scrollTop, scrollHeight, clientHeight } = event.currentTarget; - if (scrollHeight - scrollTop - clientHeight <= 240 && hasNextPage && !isFetchingNextPage && !isFetchNextPageError && !isPlaceholderData) { + if (scrollHeight - scrollTop - clientHeight <= 240 && canFetchNextPage) { void fetchNextPage(); } - }, [hasNextPage, isFetchingNextPage, isFetchNextPageError, isPlaceholderData, fetchNextPage]); + }, [canFetchNextPage, fetchNextPage]); useEffect(() => { const sessionList = sessionListRef.current; const isNearBottom = sessionList ? sessionList.scrollHeight - sessionList.scrollTop - sessionList.clientHeight <= 240 : filteredSessions.length === 0; - if ( - !isLoading - && !isPlaceholderData - && isNearBottom - && hasNextPage - && !isFetchingNextPage - && !isFetchNextPageError - ) { + if (!isLoading && isNearBottom && canFetchNextPage) { void fetchNextPage(); } - }, [isLoading, isPlaceholderData, filteredSessions, hasNextPage, isFetchingNextPage, isFetchNextPageError, fetchNextPage]); - - useEffect(() => { - if (!highlightedSessionKey) return; - const list = sessionListRef.current; - if (!list) return; - const highlighted = list.querySelector('[data-highlighted="true"]'); - if (highlighted && typeof highlighted.scrollIntoView === 'function') { - highlighted.scrollIntoView({ block: 'nearest' }); - } - }, [highlightedSessionKey]); - - const handleSearchKeyDown = (event: React.KeyboardEvent) => { - if (!keyboardNavigationEnabled) return; - if (event.nativeEvent.isComposing) return; - if (event.altKey || event.metaKey) return; - - const selectIndex = (index: number) => { - const session = navSessions[index]; - if (!session) return false; - setHighlightedKey(getSessionSelectionKey(session)); - return true; - }; - const moveBy = (delta: number) => { - const count = navSessions.length; - if (count === 0) return false; - return selectIndex(((highlightedIndex + delta) % count + count) % count); - }; - const moveClamped = (delta: number) => { - const count = navSessions.length; - if (count === 0) return false; - return selectIndex(Math.min(Math.max(highlightedIndex + delta, 0), count - 1)); - }; - - if (event.ctrlKey) { - if (event.key.toLowerCase() === 'n') { - if (moveBy(1)) event.preventDefault(); - } else if (event.key.toLowerCase() === 'p') { - if (moveBy(-1)) event.preventDefault(); - } - return; - } - - switch (event.key) { - case 'ArrowDown': - if (moveBy(1)) event.preventDefault(); - break; - case 'ArrowUp': - if (moveBy(-1)) event.preventDefault(); - break; - case 'PageDown': - if (moveClamped(10)) event.preventDefault(); - break; - case 'PageUp': - if (moveClamped(-10)) event.preventDefault(); - break; - case 'Enter': - if (highlightedSession) { - event.preventDefault(); - onSelectSession(highlightedSession.id); - } - break; - default: - break; - } - }; + }, [isLoading, filteredSessions, canFetchNextPage, fetchNextPage]); const getDeleteTarget = (session: Session): DeleteSessionTarget => ({ id: session.id, @@ -280,7 +176,7 @@ export const SessionList = ({ const renderSessionCard = (session: (typeof filteredSessions)[number], isPinned: boolean) => { const key = getSessionSelectionKey(session); - const card = ( + return ( handleDelete(session, e)} /> ); - if (!keyboardNavigationEnabled) return card; - const index = navSessions.indexOf(session); - const isHighlighted = highlightedSession === session; - return ( -
- {card} -
- ); }; if (!isLoading && !isPlaceholderData && (!sessions || sessions.length === 0)) { @@ -325,7 +206,7 @@ export const SessionList = ({ if ((hasNextPage || isFetchingNextPage) && !renderSessions) { return
Loading sessions...
; } - if (!searchQuery.trim() && !renderSessions) { + if (!trimmedQuery && !renderSessions) { return (
setSearchQuery(e.target.value)} - onKeyDown={handleSearchKeyDown} + value={query} + onChange={(e) => setQuery(e.target.value)} className="pl-9 h-9" autoComplete="off" name="session-search" - role={keyboardNavigationEnabled ? 'combobox' : undefined} - aria-expanded={keyboardNavigationEnabled ? true : undefined} - aria-controls={keyboardNavigationEnabled ? listboxId : undefined} - aria-activedescendant={keyboardNavigationEnabled && highlightedIndex >= 0 ? `${listboxId}-option-${highlightedIndex}` : undefined} />
+ + + + ) + } + + render() + + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'Other' } }) + expect(screen.queryByText('Active session')).not.toBeInTheDocument() + + fireEvent.click(screen.getByText('close-picker')) + fireEvent.click(screen.getByText('open-picker')) + + await waitFor(() => expect(screen.getByRole('combobox')).toHaveValue('')) + expect(screen.getByRole('option', { name: /Active session/ })).toHaveAttribute('aria-selected', 'true') + }) +}) diff --git a/frontend/src/components/session/SessionPickerDialog.tsx b/frontend/src/components/session/SessionPickerDialog.tsx new file mode 100644 index 000000000..d8e63ecf9 --- /dev/null +++ b/frontend/src/components/session/SessionPickerDialog.tsx @@ -0,0 +1,631 @@ +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' +import { format, startOfDay } from 'date-fns' +import { MoreHorizontal, Pin, PinOff, Search } from 'lucide-react' +import type { Repo, Session } from '@/api/types' +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { Input } from '@/components/ui/input' +import { SessionStatusIndicator } from '@/components/ui/session-status-indicator' +import { DeleteSessionDialog } from '@/components/session/DeleteSessionDialog' +import { partitionSessions } from '@/components/session/session-partition' +import { useDeleteSession } from '@/hooks/useOpenCode' +import { useSessionSearch } from '@/hooks/useSessionSearch' +import { useSessionPins, useToggleSessionPin } from '@/hooks/useSessionPins' +import { usePersistentBoolean } from '@/hooks/useSidebarCollapsed' +import { useNavigableRepos } from '@/hooks/useSidebarRepoGroups' +import { useSessionStatusForSession } from '@/stores/sessionStatusStore' +import { buildPinnedSessionKeys, buildSessionKey } from '@/lib/sessionKey' +import { cn, formatShortRelativeTime, getRepoDisplayName } from '@/lib/utils' +import { isFinePointer } from '@/lib/overlayFocus' + +const ALL_PROJECTS_STORAGE_KEY = 'oc:session-picker:all-projects' +const UNTITLED_SESSION_TITLE = 'Untitled Session' +const PAGE_STEP = 10 +const NEAR_BOTTOM_PX = 240 + +interface SessionPickerDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + currentRepo: Repo | undefined + activeSessionID?: string + onSelectSession: (session: Session, repo: Repo) => void + onActiveSessionDeleted: () => void +} + +interface SessionPickerRow { + key: string + session: Session + repo: Repo +} + +interface SessionPickerSection { + id: string + heading: string + rows: SessionPickerRow[] +} + +function buildSections( + sessions: Session[], + pinnedKeys: ReadonlySet, + keyFn: (session: Session) => string, + resolveRepo: (session: Session) => Repo | undefined, +): SessionPickerSection[] { + const { pinned, today, older } = partitionSessions(sessions, pinnedKeys, keyFn) + const toRows = (group: Session[]): SessionPickerRow[] => + group.flatMap((session) => { + const repo = resolveRepo(session) + return repo ? [{ key: keyFn(session), session, repo }] : [] + }) + + const sections: SessionPickerSection[] = [] + const pinnedRows = toRows(pinned) + if (pinnedRows.length > 0) sections.push({ id: 'pinned', heading: 'Pinned', rows: pinnedRows }) + const todayRows = toRows(today) + if (todayRows.length > 0) sections.push({ id: 'today', heading: 'Today', rows: todayRows }) + + const currentYear = new Date().getFullYear() + const byDay = new Map() + for (const row of toRows(older)) { + const day = startOfDay(new Date(row.session.time.updated)).getTime() + const existing = byDay.get(day) + if (existing) existing.push(row) + else byDay.set(day, [row]) + } + for (const [day, rows] of byDay) { + const date = new Date(day) + const heading = + date.getFullYear() === currentYear ? format(date, 'EEE, MMM d') : format(date, 'EEE, MMM d, yyyy') + sections.push({ id: `day-${day}`, heading, rows }) + } + return sections +} + +function optionId(baseId: string, index: number): string { + return `${baseId}-option-${index}` +} + +function SessionPickerGutter({ sessionID, isActive }: { sessionID: string; isActive: boolean }) { + const status = useSessionStatusForSession(sessionID) + if (status.type !== 'idle') { + return ( + + ) + } + if (isActive) { + return + } + return null +} + +interface SessionPickerRowItemProps { + row: SessionPickerRow + optionId: string + isCursor: boolean + isActive: boolean + isPinned: boolean + isPendingDelete: boolean + showRepo: boolean + onOpen: () => void + onHover: () => void + onTogglePin: () => void + onRequestDelete: () => void + registerRef: (node: HTMLDivElement | null) => void +} + +function SessionPickerRowItem({ + row, + optionId, + isCursor, + isActive, + isPinned, + isPendingDelete, + showRepo, + onOpen, + onHover, + onTogglePin, + onRequestDelete, + registerRef, +}: SessionPickerRowItemProps) { + return ( +
{ + if (event.pointerType === 'mouse') onHover() + }} + className={cn( + 'group flex items-center rounded-md text-sm min-h-8 pointer-coarse:min-h-11', + isPendingDelete + ? 'bg-destructive text-destructive-foreground' + : isCursor + ? 'bg-primary text-primary-foreground' + : 'hover:bg-accent', + )} + > +
+ + + + + {isPendingDelete ? 'Press Ctrl+D again to confirm' : row.session.title || UNTITLED_SESSION_TITLE} + + + {showRepo && {getRepoDisplayName(row.repo)}} + {formatShortRelativeTime(new Date(row.session.time.updated))} + +
+
+ + + + + event.stopPropagation()}> + + {isPinned ? : } + {isPinned ? 'Unpin' : 'Pin'} + + Delete + + +
+
+ ) +} + +export function SessionPickerDialog({ + open, + onOpenChange, + currentRepo, + activeSessionID, + onSelectSession, + onActiveSessionDeleted, +}: SessionPickerDialogProps) { + const [allProjects, toggleAllProjects] = usePersistentBoolean(ALL_PROJECTS_STORAGE_KEY, false) + const { repos } = useNavigableRepos(open) + const searchInputRef = useRef(null) + const listRef = useRef(null) + const rowRefs = useRef(new Map()) + const listId = useId() + const optionBaseId = useId() + + const repoByDirectory = useMemo(() => { + const map = new Map() + for (const repo of repos) map.set(repo.fullPath, repo) + if (currentRepo) map.set(currentRepo.fullPath, currentRepo) + return map + }, [repos, currentRepo]) + + const directories = useMemo(() => { + if (!allProjects) return currentRepo ? [currentRepo.fullPath] : [] + const paths = repos.map((repo) => repo.fullPath) + if (currentRepo) paths.push(currentRepo.fullPath) + return Array.from(new Set(paths)) + }, [allProjects, repos, currentRepo]) + + const { + query, + setQuery, + trimmedQuery, + filteredSessions, + isSearchPending, + isLoading, + fetchNextPage, + isFetchingNextPage, + isFetchNextPageError, + canFetchNextPage, + } = useSessionSearch(directories) + + const keyFn = useCallback( + (session: Session) => buildSessionKey(session.location.directory, session.id), + [], + ) + const resolveRepo = useCallback( + (session: Session) => repoByDirectory.get(session.location.directory) ?? currentRepo, + [repoByDirectory, currentRepo], + ) + + const { data: sessionPins } = useSessionPins() + const togglePin = useToggleSessionPin() + const pinnedKeys = useMemo(() => buildPinnedSessionKeys(sessionPins ?? []), [sessionPins]) + const deleteSession = useDeleteSession(directories) + + const [cursorKey, setCursorKey] = useState(null) + const [pendingDeleteKey, setPendingDeleteKey] = useState(null) + const [sessionToDelete, setSessionToDelete] = useState(null) + + const sections = useMemo( + () => buildSections(filteredSessions, pinnedKeys, keyFn, resolveRepo), + [filteredSessions, pinnedKeys, keyFn, resolveRepo], + ) + const rows = useMemo(() => sections.flatMap((section) => section.rows), [sections]) + const rowIndexByKey = useMemo( + () => new Map(rows.map((row, index) => [row.key, index])), + [rows], + ) + + const activeRow = useMemo( + () => rows.find((row) => row.session.id === activeSessionID), + [rows, activeSessionID], + ) + const defaultCursorKey = !trimmedQuery && activeRow ? activeRow.key : rows[0]?.key ?? null + const cursorIndex = useMemo(() => { + if (cursorKey) { + const index = rowIndexByKey.get(cursorKey) + if (index !== undefined) return index + } + return defaultCursorKey ? rowIndexByKey.get(defaultCursorKey) ?? -1 : -1 + }, [cursorKey, rowIndexByKey, defaultCursorKey]) + const cursorRow = cursorIndex >= 0 ? rows[cursorIndex] : undefined + + useEffect(() => { + setCursorKey(null) + setPendingDeleteKey(null) + }, [trimmedQuery, allProjects]) + + useEffect(() => { + if (!open) return + setQuery('') + setCursorKey(null) + setPendingDeleteKey(null) + setSessionToDelete(null) + }, [open, setQuery]) + + const setRowRef = useCallback((key: string, node: HTMLDivElement | null) => { + if (node) rowRefs.current.set(key, node) + else rowRefs.current.delete(key) + }, []) + + useEffect(() => { + if (!cursorRow) return + const node = rowRefs.current.get(cursorRow.key) + if (node && typeof node.scrollIntoView === 'function') { + node.scrollIntoView({ block: 'nearest' }) + } + }, [cursorRow]) + + const moveCursor = useCallback( + (index: number) => { + const row = rows[index] + if (!row) return + setCursorKey(row.key) + setPendingDeleteKey(null) + }, + [rows], + ) + + const openRow = useCallback( + (row: SessionPickerRow | undefined) => { + if (!row) return + onSelectSession(row.session, row.repo) + }, + [onSelectSession], + ) + + const deleteRow = useCallback( + (row: SessionPickerRow | undefined) => { + if (!row) return + if (pendingDeleteKey !== row.key) { + setPendingDeleteKey(row.key) + return + } + void deleteSession + .mutateAsync({ id: row.session.id, directory: row.session.location.directory }) + .then(() => { + if (row.session.id === activeSessionID) onActiveSessionDeleted() + }) + .catch(() => undefined) + .finally(() => { + setPendingDeleteKey(null) + }) + }, + [pendingDeleteKey, deleteSession, activeSessionID, onActiveSessionDeleted], + ) + + const handleSearchKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.nativeEvent.isComposing) return + if (event.metaKey || event.altKey) return + + const moveBy = (delta: number, wrap: boolean) => { + if (rows.length === 0) return + const base = cursorIndex < 0 ? (delta > 0 ? -1 : 0) : cursorIndex + const raw = base + delta + const next = wrap ? ((raw % rows.length) + rows.length) % rows.length : Math.max(0, Math.min(rows.length - 1, raw)) + moveCursor(next) + } + + const key = event.key + const ctrlKey = event.key.toLowerCase() + + if (key === 'ArrowDown' || (event.ctrlKey && ctrlKey === 'n')) { + event.preventDefault() + moveBy(1, true) + return + } + if (key === 'ArrowUp' || (event.ctrlKey && ctrlKey === 'p')) { + event.preventDefault() + moveBy(-1, true) + return + } + if (key === 'PageDown') { + event.preventDefault() + moveBy(PAGE_STEP, false) + return + } + if (key === 'PageUp') { + event.preventDefault() + moveBy(-PAGE_STEP, false) + return + } + if (key === 'Enter') { + if (!cursorRow) return + event.preventDefault() + openRow(cursorRow) + return + } + if (event.ctrlKey && ctrlKey === 'a') { + event.preventDefault() + toggleAllProjects() + return + } + if (event.ctrlKey && ctrlKey === 'd') { + if (!cursorRow) return + event.preventDefault() + deleteRow(cursorRow) + } + }, + [cursorIndex, rows, moveCursor, cursorRow, openRow, toggleAllProjects, deleteRow], + ) + + const handleListScroll = useCallback( + (event: React.UIEvent) => { + const { scrollTop, scrollHeight, clientHeight } = event.currentTarget + if (scrollHeight - scrollTop - clientHeight <= NEAR_BOTTOM_PX && canFetchNextPage) { + void fetchNextPage() + } + }, + [canFetchNextPage, fetchNextPage], + ) + + useEffect(() => { + if (!open) return + const node = listRef.current + const isNearBottom = node + ? node.scrollHeight - node.scrollTop - node.clientHeight <= NEAR_BOTTOM_PX + : rows.length === 0 + if (!isLoading && isNearBottom && canFetchNextPage) { + void fetchNextPage() + } + }, [open, isLoading, rows, canFetchNextPage, fetchNextPage]) + + const handleTogglePin = useCallback( + (row: SessionPickerRow) => { + togglePin.mutate({ + sessionId: row.session.id, + directory: row.session.location.directory, + pinned: !pinnedKeys.has(row.key), + }) + }, + [togglePin, pinnedKeys], + ) + + const confirmDelete = useCallback(() => { + if (!sessionToDelete) return + void deleteSession + .mutateAsync({ + id: sessionToDelete.id, + directory: sessionToDelete.location.directory, + }) + .then(() => { + if (sessionToDelete.id === activeSessionID) onActiveSessionDeleted() + }) + .catch(() => undefined) + .finally(() => { + setSessionToDelete(null) + }) + }, [sessionToDelete, deleteSession, activeSessionID, onActiveSessionDeleted]) + + const renderRow = (row: SessionPickerRow) => { + const index = rowIndexByKey.get(row.key) ?? -1 + return ( + openRow(row)} + onHover={() => moveCursor(index)} + onTogglePin={() => handleTogglePin(row)} + onRequestDelete={() => setSessionToDelete(row.session)} + registerRef={(node) => setRowRef(row.key, node)} + /> + ) + } + + return ( + + onOpenChange(false)} + onOpenAutoFocus={(event) => { + event.preventDefault() + if (isFinePointer()) { + searchInputRef.current?.focus() + } else { + ;(event.currentTarget as HTMLElement).focus() + } + }} + > + + Sessions + {!allProjects && currentRepo && ( + for {getRepoDisplayName(currentRepo)} + )} + + +
+
+
+ + = 0 ? optionId(optionBaseId, cursorIndex) : undefined} + value={query} + onChange={(event) => setQuery(event.target.value)} + onKeyDown={handleSearchKeyDown} + placeholder="Search sessions..." + className="h-9 pl-9" + autoComplete="off" + name="session-picker-search" + /> +
+ +
+ +
+ {rows.length === 0 ? ( +
+ {isLoading + ? 'Loading sessions...' + : isSearchPending + ? 'Searching sessions...' + : trimmedQuery + ? 'No sessions found' + : 'No sessions yet'} +
+ ) : ( + <> + {sections.map((section) => ( +
+
+ {section.heading} +
+ {section.rows.map(renderRow)} +
+ ))} + {isFetchNextPageError && ( +
+

Failed to load more sessions

+ +
+ )} + {isFetchingNextPage && rows.length > 0 && ( +
Loading more sessions...
+ )} + + )} +
+ +
+ ↑↓ move · Enter open · Ctrl+D delete · Ctrl+A {allProjects ? 'this project' : 'all projects'} +
+
+ + { + if (!nextOpen) setSessionToDelete(null) + }} + onConfirm={() => { + void confirmDelete() + }} + onCancel={() => setSessionToDelete(null)} + isDeleting={deleteSession.isPending} + /> +
+
+ ) +} diff --git a/frontend/src/hooks/useSessionSearch.test.tsx b/frontend/src/hooks/useSessionSearch.test.tsx new file mode 100644 index 000000000..d8a4b51d9 --- /dev/null +++ b/frontend/src/hooks/useSessionSearch.test.tsx @@ -0,0 +1,112 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, renderHook } from '@testing-library/react' +import { useSessionSearch } from './useSessionSearch' + +const { sessionsData, lastArgsRef, flagsRef } = vi.hoisted(() => ({ + sessionsData: [] as Array<{ id: string; title: string; location: { directory: string }; parentID?: string; time: { updated: number } }>, + lastArgsRef: { current: undefined as { directories: string[]; options?: { search?: string; limit?: number; keepPreviousResults?: boolean } } | undefined }, + flagsRef: { current: { hasNextPage: false, isFetchingNextPage: false, isFetchNextPageError: false, isPlaceholderData: false } }, +})) + +vi.mock('@/hooks/useOpenCode', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useSessionsAcrossDirectories: (directories: string[], options?: { search?: string; limit?: number; keepPreviousResults?: boolean }) => { + lastArgsRef.current = { directories, options } + const search = options?.search?.toLowerCase() ?? '' + const data = search + ? sessionsData.filter((session) => session.title.toLowerCase().includes(search)) + : sessionsData + return { + data, + isLoading: false, + isPlaceholderData: flagsRef.current.isPlaceholderData, + fetchNextPage: vi.fn(), + hasNextPage: flagsRef.current.hasNextPage, + isFetchingNextPage: flagsRef.current.isFetchingNextPage, + isFetchNextPageError: flagsRef.current.isFetchNextPageError, + } + }, + } +}) + +describe('useSessionSearch', () => { + beforeEach(() => { + sessionsData.splice(0, sessionsData.length, + { id: 'ses_alpha', title: 'alpha task', location: { directory: '/w/a' }, time: { updated: 2 } }, + { id: 'ses_beta', title: 'beta task', location: { directory: '/w/a' }, time: { updated: 1 } }, + ) + lastArgsRef.current = undefined + flagsRef.current = { hasNextPage: false, isFetchingNextPage: false, isFetchNextPageError: false, isPlaceholderData: false } + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('filters sessions locally by title immediately', () => { + const { result } = renderHook(() => useSessionSearch(['/w/a'])) + + act(() => { + result.current.setQuery('alpha') + }) + + expect(result.current.filteredSessions.map((session) => session.title)).toEqual(['alpha task']) + expect(lastArgsRef.current?.options?.search).toBe('') + }) + + it('passes the debounced search to useSessionsAcrossDirectories with keepPreviousResults', () => { + vi.useFakeTimers() + const { result } = renderHook(() => useSessionSearch(['/w/a'])) + + act(() => { + result.current.setQuery('deploy') + }) + act(() => { + vi.advanceTimersByTime(150) + }) + + expect(lastArgsRef.current?.options?.search).toBe('deploy') + expect(lastArgsRef.current?.options?.limit).toBe(25) + expect(lastArgsRef.current?.options?.keepPreviousResults).toBe(true) + }) + + it('reports a pending search before the debounce settles', () => { + vi.useFakeTimers() + const { result } = renderHook(() => useSessionSearch(['/w/a'])) + + act(() => { + result.current.setQuery('deploy') + }) + + expect(result.current.isSearchPending).toBe(true) + + act(() => { + vi.advanceTimersByTime(150) + }) + + expect(result.current.isSearchPending).toBe(false) + }) + + it('exposes canFetchNextPage only when paging is safe', () => { + const { result, rerender } = renderHook(() => useSessionSearch(['/w/a'])) + expect(result.current.canFetchNextPage).toBe(false) + + flagsRef.current = { hasNextPage: true, isFetchingNextPage: false, isFetchNextPageError: false, isPlaceholderData: false } + rerender() + expect(result.current.canFetchNextPage).toBe(true) + + flagsRef.current = { hasNextPage: true, isFetchingNextPage: true, isFetchNextPageError: false, isPlaceholderData: false } + rerender() + expect(result.current.canFetchNextPage).toBe(false) + + flagsRef.current = { hasNextPage: true, isFetchingNextPage: false, isFetchNextPageError: true, isPlaceholderData: false } + rerender() + expect(result.current.canFetchNextPage).toBe(false) + + flagsRef.current = { hasNextPage: true, isFetchingNextPage: false, isFetchNextPageError: false, isPlaceholderData: true } + rerender() + expect(result.current.canFetchNextPage).toBe(false) + }) +}) diff --git a/frontend/src/hooks/useSessionSearch.ts b/frontend/src/hooks/useSessionSearch.ts new file mode 100644 index 000000000..955d699cf --- /dev/null +++ b/frontend/src/hooks/useSessionSearch.ts @@ -0,0 +1,71 @@ +import { useCallback, useMemo, useState } from 'react' +import { useSessionsAcrossDirectories } from '@/hooks/useOpenCode' +import { useDebouncedValue } from '@/hooks/useDebouncedValue' +import { selectRootSessions } from '@/components/session/session-partition' +import { buildSessionKey } from '@/lib/sessionKey' +import type { Session } from '@/api/types' + +const DEFAULT_LIMIT = 25 + +export interface UseSessionSearchOptions { + limit?: number +} + +/** + * Owns the sessions-dialog search state: the raw query, its debounced server + * value, the root-session selection, and the immediate local title filter. + */ +export function useSessionSearch(directories: string[], options: UseSessionSearchOptions = {}) { + const limit = options.limit ?? DEFAULT_LIMIT + const [query, setQuery] = useState('') + const trimmedQuery = query.trim() + const debouncedQuery = useDebouncedValue(trimmedQuery, 150) + const search = trimmedQuery ? debouncedQuery : '' + const { + data: sessions, + isLoading, + isPlaceholderData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isFetchNextPageError, + } = useSessionsAcrossDirectories(directories, { search, limit, keepPreviousResults: true }) + + const directorySet = useMemo(() => new Set(directories), [directories]) + const getSessionSelectionKey = useCallback( + (session: Session) => buildSessionKey(session.location.directory, session.id), + [], + ) + + const rootSessions = useMemo( + () => selectRootSessions(sessions ?? [], { directories: directorySet, keyFn: getSessionSelectionKey }), + [sessions, directorySet, getSessionSelectionKey], + ) + + const filteredSessions = useMemo(() => { + if (!trimmedQuery) return rootSessions + const needle = trimmedQuery.toLowerCase() + return rootSessions.filter((session) => (session.title ?? '').toLowerCase().includes(needle)) + }, [rootSessions, trimmedQuery]) + + const isSearchPending = trimmedQuery !== search || isPlaceholderData + const canFetchNextPage = Boolean( + hasNextPage && !isFetchingNextPage && !isFetchNextPageError && !isPlaceholderData, + ) + + return { + query, + setQuery, + trimmedQuery, + sessions, + filteredSessions, + isSearchPending, + isLoading, + isPlaceholderData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isFetchNextPageError, + canFetchNextPage, + } +} diff --git a/frontend/src/hooks/useSidebarCollapsed.ts b/frontend/src/hooks/useSidebarCollapsed.ts index 3ae6662ae..148868bb9 100644 --- a/frontend/src/hooks/useSidebarCollapsed.ts +++ b/frontend/src/hooks/useSidebarCollapsed.ts @@ -31,7 +31,11 @@ function readStoredBoolean(key: string, fallback: boolean): boolean { } } -function usePersistentBoolean(key: string, fallback: boolean): [boolean, () => void] { +/** + * Persists a boolean preference under `key` in localStorage and returns it with a toggler. + * Falls back to `fallback` when the stored value is missing or malformed. + */ +export function usePersistentBoolean(key: string, fallback: boolean): [boolean, () => void] { const [value, setValue] = useState(() => readStoredBoolean(key, fallback)) const toggle = useCallback(() => { diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx index c0d248cb0..20529d97f 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -8,13 +8,11 @@ import { FloatingTTSButton } from '@/components/message/FloatingTTSButton' import { X, CornerUpLeft } from "lucide-react"; import { SquareFill } from "@/components/ui/square-fill"; import { Header } from "@/components/ui/header"; -import { SessionList } from "@/components/session/SessionList"; -import { getSessionListPath } from '@/lib/navigation' -import { isFinePointer } from '@/lib/overlayFocus' +import { SessionPickerDialog } from "@/components/session/SessionPickerDialog"; +import { getSessionListPath, getSessionPath } from '@/lib/navigation' import { FetchError } from '@/api/fetchWrapper' import { FileBrowserSheet } from "@/components/file-browser/FileBrowserSheet"; -import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { ContextUsageIndicator } from "@/components/session/ContextUsageIndicator"; import { useSession, useInterruptSession, useUpdateSession, useCreateSession } from "@/hooks/useOpenCode"; @@ -828,32 +826,25 @@ export function SessionDetail() { )}
- {/* Sessions Dialog */} - - { - if (!isFinePointer()) { - event.preventDefault(); - (event.currentTarget as HTMLElement).focus(); - } - }} - > - Sessions -
- {sessionDirectory && ( - { - navigate(`/repos/${repoId}/sessions/${sessionID}${sessionRouteSuffix}`) - setSessionsDialogOpen(false) - }} - /> - )} -
-
-
+ {/* Sessions Picker */} + { + if (sessionRepo.id === repoId) { + navigate(`/repos/${repoId}/sessions/${session.id}${sessionRouteSuffix}`) + } else { + navigate(getSessionPath(sessionRepo.id, session.id)) + } + setSessionsDialogOpen(false) + }} + onActiveSessionDeleted={() => { + setSessionsDialogOpen(false) + navigate(getSessionListPath(repoId, isAssistantSession)) + }} + /> {sideQuestion && sessionId && ( ({ vi.mock('@/hooks/useSessionPermissionMode', () => ({ useSessionPermissionMode: vi.fn(() => ({ data: undefined })), })) -vi.mock('@/components/session/SessionList', () => ({ - SessionList: vi.fn(() => null), +vi.mock('@/components/session/SessionPickerDialog', () => ({ + SessionPickerDialog: vi.fn(() => null), })) vi.mock('@/components/session/PermissionModeToggle', () => ({ diff --git a/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx b/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx index 85d20b266..9088fdd9b 100644 --- a/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx @@ -207,7 +207,7 @@ vi.mock('@/components/navigation/ToolSidePanel', () => ({ ToolSidePanel: vi.fn(( vi.mock('@/hooks/useToolPanel', () => ({ useToolPanel: vi.fn(() => ({ activeTool: null, toggleTool: vi.fn(), closePanel: vi.fn() })), })) -vi.mock('@/components/session/SessionList', () => ({ SessionList: vi.fn(() => null) })) +vi.mock('@/components/session/SessionPickerDialog', () => ({ SessionPickerDialog: vi.fn(() => null) })) vi.mock('@/components/file-browser/FileBrowserSheet', () => ({ FileBrowserSheet: vi.fn(() => null) })) vi.mock('@/components/repo/RepoMcpDialog', () => ({ RepoMcpDialog: vi.fn(() => null) })) vi.mock('@/components/repo/RepoActionsDialog', () => ({ RepoActionsDialog: vi.fn(() => null) })) diff --git a/frontend/src/pages/__tests__/SessionDetail.form-prompt.test.tsx b/frontend/src/pages/__tests__/SessionDetail.form-prompt.test.tsx index 287ecce28..c0ade1305 100644 --- a/frontend/src/pages/__tests__/SessionDetail.form-prompt.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.form-prompt.test.tsx @@ -159,8 +159,8 @@ vi.mock('@/hooks/useSessionGoals', () => ({ useResumeSessionGoal: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), useCancelSessionGoal: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), })) -vi.mock('@/components/session/SessionList', () => ({ - SessionList: vi.fn(() => null), +vi.mock('@/components/session/SessionPickerDialog', () => ({ + SessionPickerDialog: vi.fn(() => null), })) vi.mock('@/components/file-browser/FileBrowserSheet', () => ({ diff --git a/frontend/src/pages/__tests__/SessionDetail.polling.test.tsx b/frontend/src/pages/__tests__/SessionDetail.polling.test.tsx index e055c3c7d..9ed44a8cc 100644 --- a/frontend/src/pages/__tests__/SessionDetail.polling.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.polling.test.tsx @@ -137,7 +137,7 @@ vi.mock('@/hooks/useSessionGoals', () => ({ vi.mock('@/hooks/useSessionPermissionMode', () => ({ useSessionPermissionMode: vi.fn(() => ({ data: undefined })), })) -vi.mock('@/components/session/SessionList', () => ({ SessionList: vi.fn(() => null) })) +vi.mock('@/components/session/SessionPickerDialog', () => ({ SessionPickerDialog: vi.fn(() => null) })) vi.mock('@/components/session/PermissionModeToggle', () => ({ PermissionModeToggle: vi.fn(() => null) })) vi.mock('@/components/file-browser/FileBrowserSheet', () => ({ FileBrowserSheet: vi.fn(() => null) })) vi.mock('@/components/repo/RepoMcpDialog', () => ({ RepoMcpDialog: vi.fn(() => null) })) diff --git a/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx b/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx index 9a00bc0d2..7362bed91 100644 --- a/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx @@ -154,8 +154,8 @@ vi.mock('@/hooks/useSessionGoals', () => ({ useResumeSessionGoal: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), useCancelSessionGoal: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), })) -vi.mock('@/components/session/SessionList', () => ({ - SessionList: vi.fn(() => null), +vi.mock('@/components/session/SessionPickerDialog', () => ({ + SessionPickerDialog: vi.fn(() => null), })) vi.mock('@/components/file-browser/FileBrowserSheet', () => ({ From 2d14972fad51bbee698a9b8a31d478d2173f68c4 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:16:59 -0400 Subject: [PATCH 3/4] fix(sessions): address PR #403 review findings - fetch picker sessions only while the dialog is open - list all-projects sessions with one request per page - block infinite-scroll paging while a search is pending - memoize picker rows with stable callbacks - remove deleted sessions from list caches instead of refetching - keep the repo tab when the open session is deleted - reuse useSessionSearch in the sidebar session tree - drop dead activeSessionID/isActive and share delete, session-key and repo-map helpers - move usePersistentBoolean to its own hook --- frontend/src/api/opencode.test.ts | 26 ++ frontend/src/api/opencode.ts | 4 +- .../navigation/DesktopSessionTree.test.tsx | 40 +- .../navigation/DesktopSessionTree.tsx | 98 ++-- .../navigation/sidebar-session-tree.test.ts | 14 + .../navigation/sidebar-session-tree.ts | 10 +- .../src/components/session/SessionCard.tsx | 6 +- .../src/components/session/SessionList.tsx | 26 +- .../session/SessionPickerDialog.test.tsx | 53 ++- .../session/SessionPickerDialog.tsx | 417 ++++++++++-------- frontend/src/hooks/useOpenCode.test.tsx | 77 +++- frontend/src/hooks/useOpenCode.ts | 99 ++++- .../src/hooks/usePersistentBoolean.test.tsx | 86 ++++ frontend/src/hooks/usePersistentBoolean.ts | 48 ++ frontend/src/hooks/useSessionSearch.test.tsx | 53 ++- frontend/src/hooks/useSessionSearch.ts | 21 +- .../src/hooks/useSidebarCollapsed.test.tsx | 55 +-- frontend/src/hooks/useSidebarCollapsed.ts | 49 +- frontend/src/hooks/useSidebarRepoGroups.ts | 6 +- frontend/src/lib/overlayFocus.test.ts | 12 +- frontend/src/lib/overlayFocus.ts | 5 +- frontend/src/lib/sessionKey.test.ts | 11 +- frontend/src/lib/sessionKey.ts | 6 + frontend/src/pages/SessionDetail.tsx | 2 +- 24 files changed, 813 insertions(+), 411 deletions(-) create mode 100644 frontend/src/hooks/usePersistentBoolean.test.tsx create mode 100644 frontend/src/hooks/usePersistentBoolean.ts diff --git a/frontend/src/api/opencode.test.ts b/frontend/src/api/opencode.test.ts index 53026b53c..55db0c975 100644 --- a/frontend/src/api/opencode.test.ts +++ b/frontend/src/api/opencode.test.ts @@ -98,6 +98,32 @@ describe('OpenCode facade', () => { expect(page.nextCursor).toBeUndefined() }) + it('omits the directory when listing sessions across all directories', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ data: [sessionInfo('ses_1', '/repo')], cursor: { next: 'cursor_next' } }), + ) + + const page = await listSessionPage({ limit: 25, order: 'desc' }) + + expect(lastRequest().url).toBe( + 'http://localhost/api/opencode/api/session?limit=25&order=desc&parentID=null', + ) + expect(lastRequest().init.method).toBe('GET') + expect(page.items).toEqual([sessionInfo('ses_1', '/repo')]) + expect(page.nextCursor).toBe('cursor_next') + }) + + it('omits the directory on an all-directories continuation page', async () => { + fetchMock.mockResolvedValue(jsonResponse({ data: [], cursor: {} })) + + await listSessionPage({ cursor: 'cursor_1' }) + + expect(lastRequest().url).toBe( + 'http://localhost/api/opencode/api/session?parentID=null&cursor=cursor_1', + ) + expect(lastRequest().init.method).toBe('GET') + }) + it('reads a single session from the V2 session route', async () => { fetchMock.mockResolvedValue(jsonResponse({ data: sessionInfo('ses_1', '/repo') })) diff --git a/frontend/src/api/opencode.ts b/frontend/src/api/opencode.ts index 163fa95fc..96528a3b9 100644 --- a/frontend/src/api/opencode.ts +++ b/frontend/src/api/opencode.ts @@ -37,7 +37,7 @@ export interface SessionPage { } export interface SessionPageInput { - directory: string + directory?: string limit?: number order?: 'asc' | 'desc' search?: string @@ -93,7 +93,7 @@ function buildPromptFields(input: PromptFields) { export async function listSessionPage(input: SessionPageInput): Promise { const { data, cursor } = await callOpenCode((api) => api.session.list({ - directory: input.directory, + ...(input.directory === undefined ? {} : { directory: input.directory }), parentID: 'null', limit: input.limit, order: input.order, diff --git a/frontend/src/components/navigation/DesktopSessionTree.test.tsx b/frontend/src/components/navigation/DesktopSessionTree.test.tsx index b50eb47d7..22b0cc30f 100644 --- a/frontend/src/components/navigation/DesktopSessionTree.test.tsx +++ b/frontend/src/components/navigation/DesktopSessionTree.test.tsx @@ -34,6 +34,7 @@ const { getSessionMock, sessionPinsData, sessionsHasNextPage, + searchError, } = vi.hoisted(() => ({ listReposMock: vi.fn(), sessionsData: [] as SessionFixture[], @@ -45,6 +46,7 @@ const { getSessionMock: vi.fn(), sessionPinsData: { current: [] as SessionPinFixture[] }, sessionsHasNextPage: { current: false }, + searchError: { current: false }, })) vi.mock('@/api/repos', async (importOriginal) => { @@ -62,12 +64,17 @@ vi.mock('@/hooks/useOpenCode', async (importOriginal) => { return { ...actual, useSessionsAcrossDirectories: (directories: string[], options?: { search?: string; limit?: number }) => { - sessionsHookCalls.push({ directories, options }) + if (directories.length > 0) sessionsHookCalls.push({ directories, options }) const data = options?.search && emptyOnSearch.current ? [] : sessionsData.filter((session) => directories.includes(session.location.directory)) - return { data, isLoading: false, isError: false, hasNextPage: sessionsHasNextPage.current } + return { + data, + isLoading: false, + isError: Boolean(options?.search) && searchError.current, + hasNextPage: sessionsHasNextPage.current, + } }, useCreateSession: (directory?: string) => ({ mutate: (vars?: unknown) => { @@ -161,6 +168,7 @@ describe('DesktopSessionTree', () => { getSessionMock.mockResolvedValue(undefined) sessionPinsData.current = [] sessionsHasNextPage.current = false + searchError.current = false }) it('lists every repo by last access, even inside a repo, with no Recent section', async () => { @@ -302,6 +310,9 @@ describe('DesktopSessionTree', () => { await user.type(input, 'Session') expect(await screen.findByRole('button', { name: /^Session a1/ })).toBeTruthy() + await waitFor(() => + expect(sessionsHookCalls.some((call) => call.options?.search === 'Session')).toBe(true), + ) const searchCall = sessionsHookCalls.find((call) => call.options?.search === 'Session') expect(searchCall?.directories).toEqual(['/repos/a', '/repos/b']) expect(sessionsHookCalls.some((call) => call.options?.search === 'Sess')).toBe(false) @@ -315,6 +326,19 @@ describe('DesktopSessionTree', () => { expect(toggleFor('Alpha')).toBeTruthy() }) + it('filters loaded sessions by title immediately', async () => { + const user = userEvent.setup() + render(, { wrapper: createWrapper(['/']) }) + + const input = await screen.findByLabelText('Search sessions') + await screen.findByText('Alpha') + await user.type(input, 'a1') + + expect(await screen.findByRole('button', { name: /^Session a1/ })).toBeTruthy() + expect(screen.queryByRole('button', { name: /^Session b1/ })).toBeNull() + expect(screen.queryByRole('button', { name: /^Session c1/ })).toBeNull() + }) + it('clears an active search with the clear button', async () => { const user = userEvent.setup() render(, { wrapper: createWrapper(['/']) }) @@ -340,6 +364,7 @@ describe('DesktopSessionTree', () => { expect(await screen.findByRole('button', { name: /^Session b1/ })).toBeTruthy() expect(screen.queryByRole('button', { name: /^Session a1/ })).toBeNull() + await waitFor(() => expect(sessionsHookCalls.some((call) => call.options?.search === 'bet')).toBe(true)) const searchCall = sessionsHookCalls.find((call) => call.options?.search === 'bet') expect(searchCall?.directories).toEqual(['/repos/c', '/repos/a']) }) @@ -369,6 +394,17 @@ describe('DesktopSessionTree', () => { expect(await screen.findByText('No sessions found')).toBeTruthy() }) + it('shows the error state when the session search fails', async () => { + const user = userEvent.setup() + searchError.current = true + render(, { wrapper: createWrapper(['/']) }) + + const input = await screen.findByLabelText('Search sessions') + await user.type(input, 'Session') + + expect(await screen.findByText('Failed to load sessions')).toBeTruthy() + }) + it('creates a session in the repo full path', async () => { const user = userEvent.setup() render(, { wrapper: createWrapper(['/']) }) diff --git a/frontend/src/components/navigation/DesktopSessionTree.tsx b/frontend/src/components/navigation/DesktopSessionTree.tsx index 98753d55d..7b3aa5811 100644 --- a/frontend/src/components/navigation/DesktopSessionTree.tsx +++ b/frontend/src/components/navigation/DesktopSessionTree.tsx @@ -1,9 +1,11 @@ import { useMemo, useState } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import { Search } from 'lucide-react' -import type { Repo } from '@/api/types' +import type { Repo, Session } from '@/api/types' import { Input } from '@/components/ui/input' import { useNavigableRepos, useSidebarRepoGroups } from '@/hooks/useSidebarRepoGroups' +import { useSessionSearch } from '@/hooks/useSessionSearch' +import { useSessionPins } from '@/hooks/useSessionPins' import { NewSessionButton, RepoNavGroup, @@ -12,12 +14,16 @@ import { SessionNavRow, SessionNavStatus, } from '@/components/navigation/RepoSessionNav' -import { getActiveRepoId, isCurrentSessionItem, isRepoReady } from '@/components/navigation/sidebar-session-tree' +import { + SIDEBAR_SESSIONS_PER_REPO, + buildSidebarRepoGroups, + getActiveRepoId, + isCurrentSessionItem, + isRepoReady, +} from '@/components/navigation/sidebar-session-tree' import { getRepoPath } from '@/lib/navigation' import { getRepoDisplayName } from '@/lib/utils' -import { useDebouncedValue } from '@/hooks/useDebouncedValue' - -const SESSION_SEARCH_DEBOUNCE_MS = 300 +import { buildPinnedSessionKeys } from '@/lib/sessionKey' function SessionSearchInput({ value, onChange }: { value: string; onChange: (value: string) => void }) { const clear = () => onChange('') @@ -46,29 +52,37 @@ function SessionSearchInput({ value, onChange }: { value: string; onChange: (val ) } -function SessionSearchResults({ repos, search }: { repos: Repo[]; search: string }) { +interface SessionSearchResultsProps { + nameMatchedRepos: Repo[] + otherRepos: Repo[] + sessions: Session[] + isSearchLoading: boolean + isSearchError: boolean +} + +function SessionSearchResults({ + nameMatchedRepos, + otherRepos, + sessions, + isSearchLoading, + isSearchError, +}: SessionSearchResultsProps) { const navigate = useNavigate() const location = useLocation() - const { nameMatchedRepos, otherRepos } = useMemo(() => { - const query = search.toLowerCase() - const readyRepos = repos.filter(isRepoReady) - const isNameMatch = (repo: Repo) => getRepoDisplayName(repo).toLowerCase().includes(query) - return { - nameMatchedRepos: readyRepos.filter(isNameMatch), - otherRepos: readyRepos.filter((repo) => !isNameMatch(repo)), - } - }, [repos, search]) const nameMatches = useSidebarRepoGroups({ repos: nameMatchedRepos }) - const sessionMatches = useSidebarRepoGroups({ repos: otherRepos, search }) - const isLoading = nameMatches.isLoading || sessionMatches.isLoading - const isError = nameMatches.isError || sessionMatches.isError + const { data: sessionPins } = useSessionPins() + const pinnedKeys = useMemo(() => buildPinnedSessionKeys(sessionPins ?? []), [sessionPins]) + const sessionGroups = useMemo( + () => buildSidebarRepoGroups({ repos: otherRepos, sessions, pinnedKeys, now: Date.now() }), + [otherRepos, sessions, pinnedKeys], + ) const matchingGroups = [ ...nameMatches.groups, - ...sessionMatches.groups.filter((group) => group.items.length > 0), + ...sessionGroups.filter((group) => group.items.length > 0), ] - if (isLoading) return Loading sessions... - if (isError) return Failed to load sessions + if (nameMatches.isLoading || isSearchLoading) return Loading sessions... + if (nameMatches.isError || isSearchError) return Failed to load sessions if (matchingGroups.length === 0) return No sessions found return ( @@ -101,23 +115,53 @@ function SessionSearchResults({ repos, search }: { repos: Repo[]; search: string export function DesktopSessionTree() { const navigate = useNavigate() const location = useLocation() + const { repos, isLoading } = useNavigableRepos() + const readyRepos = useMemo(() => repos.filter(isRepoReady), [repos]) + const [draft, setDraft] = useState('') const trimmedDraft = draft.trim() - const debouncedSearch = useDebouncedValue(trimmedDraft, SESSION_SEARCH_DEBOUNCE_MS) - const search = trimmedDraft ? debouncedSearch : '' - const { repos, isLoading } = useNavigableRepos() + + const { nameMatchedRepos, otherRepos } = useMemo(() => { + const query = trimmedDraft.toLowerCase() + const isNameMatch = (repo: Repo) => getRepoDisplayName(repo).toLowerCase().includes(query) + return { + nameMatchedRepos: readyRepos.filter(isNameMatch), + otherRepos: readyRepos.filter((repo) => !isNameMatch(repo)), + } + }, [readyRepos, trimmedDraft]) + + const otherDirectories = useMemo(() => otherRepos.map((repo) => repo.fullPath), [otherRepos]) + const searchDirectories = useMemo( + () => (trimmedDraft ? otherDirectories : []), + [trimmedDraft, otherDirectories], + ) + const { setQuery, filteredSessions, isLoading: isSearchLoading, isError: isSearchError } = useSessionSearch( + searchDirectories, + { limit: SIDEBAR_SESSIONS_PER_REPO }, + ) + + const handleSearchChange = (value: string) => { + setDraft(value) + setQuery(value) + } return (
- +
{isLoading ? ( Loading repos... - ) : search ? ( - + ) : trimmedDraft ? ( + ) : ( { }) }) +describe('buildRepoByDirectory', () => { + it('indexes each repo by full path', () => { + const repoA = createRepo({ id: 1, fullPath: '/repos/a' }) + const repoB = createRepo({ id: 2, fullPath: '/repos/b' }) + + const byDirectory = buildRepoByDirectory([repoA, repoB]) + + expect(byDirectory.get('/repos/a')).toBe(repoA) + expect(byDirectory.get('/repos/b')).toBe(repoB) + expect(byDirectory.size).toBe(2) + }) +}) + describe('buildSidebarRepoGroups', () => { const repoA = createRepo({ id: 1, fullPath: '/repos/a', name: 'Alpha' }) const repoB = createRepo({ id: 2, fullPath: '/repos/b', name: 'Beta' }) diff --git a/frontend/src/components/navigation/sidebar-session-tree.ts b/frontend/src/components/navigation/sidebar-session-tree.ts index 245187e09..2a1db5a25 100644 --- a/frontend/src/components/navigation/sidebar-session-tree.ts +++ b/frontend/src/components/navigation/sidebar-session-tree.ts @@ -1,7 +1,7 @@ import type { Repo, Session } from '@/api/types' import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils' import { getRepoBranchLabel, getRepoDisplayName } from '@/lib/utils' -import { buildSessionKey } from '@/lib/sessionKey' +import { getSessionKey } from '@/lib/sessionKey' import { getSessionPath, isAssistantPath, parseRepoRoute } from '@/lib/navigation' import { partitionSessions, selectRootSessions } from '@/components/session/session-partition' @@ -46,6 +46,10 @@ export function selectNavigableRepos(repos: Repo[]): Repo[] { ) } +export function buildRepoByDirectory(repos: Repo[]): Map { + return new Map(repos.map((repo) => [repo.fullPath, repo])) +} + export function buildSidebarRepoGroups(input: { repos: Repo[] sessions: Session[] @@ -53,8 +57,8 @@ export function buildSidebarRepoGroups(input: { now: number }): SidebarRepoGroup[] { const { repos, sessions, pinnedKeys, now } = input - const keyFn = (session: Session) => buildSessionKey(session.location.directory, session.id) - const repoByDirectory = new Map(repos.map((repo) => [repo.fullPath, repo])) + const keyFn = getSessionKey + const repoByDirectory = buildRepoByDirectory(repos) const roots = selectRootSessions(sessions, { directories: new Set(repos.map((repo) => repo.fullPath)), diff --git a/frontend/src/components/session/SessionCard.tsx b/frontend/src/components/session/SessionCard.tsx index 16e5e5d13..e8c4ff07d 100644 --- a/frontend/src/components/session/SessionCard.tsx +++ b/frontend/src/components/session/SessionCard.tsx @@ -12,7 +12,6 @@ import { Button } from "@/components/ui/button"; interface SessionCardProps { session: Session; isSelected: boolean; - isActive: boolean; manageMode: boolean; isPinned?: boolean; onTogglePin?: () => void; @@ -24,7 +23,6 @@ interface SessionCardProps { export const SessionCard = ({ session, isSelected, - isActive, manageMode, isPinned, onTogglePin, @@ -71,9 +69,7 @@ export const SessionCard = ({ } ${ isSelected ? "border-primary shadow-lg shadow-primary/30 bg-accent" - : isActive - ? "bg-accent border-border" - : "bg-card border-border hover:bg-accent hover:border-border" + : "bg-card border-border hover:bg-accent hover:border-border" } hover:shadow-lg`} onClick={() => { if (!isOpen) { diff --git a/frontend/src/components/session/SessionList.tsx b/frontend/src/components/session/SessionList.tsx index 7f3d4fb0e..711cab565 100644 --- a/frontend/src/components/session/SessionList.tsx +++ b/frontend/src/components/session/SessionList.tsx @@ -3,7 +3,7 @@ import { useDeleteSession, useCreateSession } from "@/hooks/useOpenCode"; import type { DeleteSessionTarget } from "@/hooks/useOpenCode"; import type { Session } from "@/api/types"; import { useSessionPins, useToggleSessionPin } from '@/hooks/useSessionPins'; -import { buildSessionKey, buildPinnedSessionKeys } from '@/lib/sessionKey'; +import { getSessionKey, buildPinnedSessionKeys } from '@/lib/sessionKey'; import { partitionSessions } from './session-partition'; import { DeleteSessionDialog } from "./DeleteSessionDialog"; import { SessionCard } from "./SessionCard"; @@ -17,7 +17,6 @@ interface SessionListProps { directory?: string; directories?: string[]; createDirectory?: string; - activeSessionID?: string; onSelectSession: (sessionID: string) => void; renderSessions?: (args: SessionListRenderArgs) => ReactNode; } @@ -36,7 +35,6 @@ export const SessionList = ({ directory, directories, createDirectory, - activeSessionID, onSelectSession, renderSessions, }: SessionListProps) => { @@ -46,9 +44,6 @@ export const SessionList = ({ }, [directory, directories]); const primaryDirectory = directoriesList[0]; const sessionCreateDirectory = createDirectory ?? primaryDirectory; - const getSessionSelectionKey = useCallback((session: Session) => - buildSessionKey(session.location.directory, session.id), - []); const { query, setQuery, @@ -81,13 +76,13 @@ export const SessionList = ({ const sessionListRef = useRef(null); const { pinned: pinnedSessions, today: todaySessions, older: olderSessions } = useMemo( - () => partitionSessions(filteredSessions, pinnedKeys, getSessionSelectionKey), - [filteredSessions, pinnedKeys, getSessionSelectionKey], + () => partitionSessions(filteredSessions, pinnedKeys, getSessionKey), + [filteredSessions, pinnedKeys], ); const handleTogglePin = (session: Session) => { const directory = session.location.directory; - const key = getSessionSelectionKey(session); + const key = getSessionKey(session); togglePin.mutate({ sessionId: session.id, directory, pinned: !pinnedKeys.has(key) }); }; @@ -141,7 +136,7 @@ export const SessionList = ({ }; const toggleSessionSelection = (session: Session, selected: boolean) => { - const selectionKey = getSessionSelectionKey(session); + const selectionKey = getSessionKey(session); const newSelected = new Set(selectedSessions); if (selected) { newSelected.add(selectionKey); @@ -153,20 +148,20 @@ export const SessionList = ({ const allVisibleSelected = filteredSessions.length > 0 && - filteredSessions.every((session) => selectedSessions.has(getSessionSelectionKey(session))); + filteredSessions.every((session) => selectedSessions.has(getSessionKey(session))); const toggleSelectAll = () => { if (allVisibleSelected) { setSelectedSessions(new Set()); } else { - setSelectedSessions(new Set(filteredSessions.map(getSessionSelectionKey))); + setSelectedSessions(new Set(filteredSessions.map(getSessionKey))); } }; const handleBulkDelete = () => { if (selectedSessions.size > 0) { const selectedTargets = filteredSessions - .filter((session) => selectedSessions.has(getSessionSelectionKey(session))) + .filter((session) => selectedSessions.has(getSessionKey(session))) .map(getDeleteTarget); if (selectedTargets.length === 0) return; setSessionToDelete(selectedTargets); @@ -175,13 +170,12 @@ export const SessionList = ({ }; const renderSessionCard = (session: (typeof filteredSessions)[number], isPinned: boolean) => { - const key = getSessionSelectionKey(session); + const key = getSessionKey(session); return ( renderSessionCard(session, pinnedKeys.has(getSessionSelectionKey(session))), + renderSessionCard: (session) => renderSessionCard(session, pinnedKeys.has(getSessionKey(session))), }) ) : filteredSessions.length === 0 && !isFetchingNextPage ? (
diff --git a/frontend/src/components/session/SessionPickerDialog.test.tsx b/frontend/src/components/session/SessionPickerDialog.test.tsx index 8b62962d3..86a248a49 100644 --- a/frontend/src/components/session/SessionPickerDialog.test.tsx +++ b/frontend/src/components/session/SessionPickerDialog.test.tsx @@ -1,13 +1,16 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useState } from 'react' import { render, screen, fireEvent, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import type { Repo, Session } from '@/api/types' +import { stubMatchMedia } from '@/test/test-utils' import { SessionPickerDialog } from './SessionPickerDialog' const mocks = vi.hoisted(() => ({ sessionsData: [] as Session[], lastDirectories: [] as string[], + lastAllDirectories: false, + sessionsHook: vi.fn(), fetchNextPage: vi.fn(), deleteSessionMock: vi.fn(), togglePinMock: vi.fn(), @@ -19,8 +22,13 @@ vi.mock('@/hooks/useOpenCode', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - useSessionsAcrossDirectories: (directories: string[], options?: { search?: string }) => { + useSessionsAcrossDirectories: ( + directories: string[], + options?: { search?: string; allDirectories?: boolean }, + ) => { + mocks.sessionsHook() mocks.lastDirectories = directories + mocks.lastAllDirectories = options?.allDirectories ?? false const search = options?.search?.toLowerCase() ?? '' const data = search ? mocks.sessionsData.filter((session) => (session.title ?? '').toLowerCase().includes(search)) @@ -48,11 +56,6 @@ vi.mock('@/hooks/useSidebarRepoGroups', () => ({ useNavigableRepos: () => ({ repos: mocks.repos, isLoading: false, refetch: vi.fn() }), })) -vi.mock('@/lib/overlayFocus', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, isFinePointer: () => true } -}) - vi.mock('@/stores/sessionStatusStore', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useSessionStatusForSession: () => ({ type: 'idle' }) } @@ -104,6 +107,7 @@ const combobox = () => screen.getByRole('combobox') describe('SessionPickerDialog', () => { beforeEach(() => { vi.clearAllMocks() + stubMatchMedia(true) localStorage.clear() mocks.deleteSessionMock.mockResolvedValue(undefined) mocks.sessionPins = [] @@ -111,6 +115,10 @@ describe('SessionPickerDialog', () => { mocks.sessionsData = [] }) + afterEach(() => { + Reflect.deleteProperty(window, 'matchMedia') + }) + it('opens with the active session as the cursor and marks it with a dot', () => { mocks.sessionsData = [ session('ses_active', 'Active session', '/w/a', 3000), @@ -185,12 +193,14 @@ describe('SessionPickerDialog', () => { renderPicker() expect(mocks.lastDirectories).toEqual(['/w/a']) + expect(mocks.lastAllDirectories).toBe(false) expect(screen.getByText('Alpha session')).toBeInTheDocument() expect(screen.queryByText('Beta session')).not.toBeInTheDocument() fireEvent.keyDown(combobox(), { key: 'a', ctrlKey: true }) expect(mocks.lastDirectories).toEqual(['/w/a', '/w/b']) + expect(mocks.lastAllDirectories).toBe(true) expect(localStorage.getItem('oc:session-picker:all-projects')).toBe('true') expect(screen.getByText('Beta session')).toBeInTheDocument() expect(screen.getByText('alpha')).toBeInTheDocument() @@ -198,9 +208,38 @@ describe('SessionPickerDialog', () => { fireEvent.keyDown(combobox(), { key: 'a', ctrlKey: true }) expect(mocks.lastDirectories).toEqual(['/w/a']) + expect(mocks.lastAllDirectories).toBe(false) expect(localStorage.getItem('oc:session-picker:all-projects')).toBe('false') }) + it('does not query sessions while closed and queries after opening', async () => { + mocks.sessionsData = [session('ses_1', 'First', '/w/a', 3000)] + + function Host() { + const [open, setOpen] = useState(false) + return ( + <> + + + + ) + } + + render() + + expect(mocks.sessionsHook).not.toHaveBeenCalled() + + fireEvent.click(screen.getByText('open-picker')) + + await waitFor(() => expect(mocks.sessionsHook).toHaveBeenCalled()) + }) + it('deletes on a second Ctrl+D and clears the pending state on a move', async () => { mocks.sessionsData = [ session('ses_1', 'First', '/w/a', 3000), diff --git a/frontend/src/components/session/SessionPickerDialog.tsx b/frontend/src/components/session/SessionPickerDialog.tsx index d8e63ecf9..37fb490c3 100644 --- a/frontend/src/components/session/SessionPickerDialog.tsx +++ b/frontend/src/components/session/SessionPickerDialog.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' import { format, startOfDay } from 'date-fns' import { MoreHorizontal, Pin, PinOff, Search } from 'lucide-react' import type { Repo, Session } from '@/api/types' @@ -14,15 +14,16 @@ import { Input } from '@/components/ui/input' import { SessionStatusIndicator } from '@/components/ui/session-status-indicator' import { DeleteSessionDialog } from '@/components/session/DeleteSessionDialog' import { partitionSessions } from '@/components/session/session-partition' +import { buildRepoByDirectory } from '@/components/navigation/sidebar-session-tree' import { useDeleteSession } from '@/hooks/useOpenCode' import { useSessionSearch } from '@/hooks/useSessionSearch' import { useSessionPins, useToggleSessionPin } from '@/hooks/useSessionPins' -import { usePersistentBoolean } from '@/hooks/useSidebarCollapsed' +import { usePersistentBoolean } from '@/hooks/usePersistentBoolean' import { useNavigableRepos } from '@/hooks/useSidebarRepoGroups' import { useSessionStatusForSession } from '@/stores/sessionStatusStore' -import { buildPinnedSessionKeys, buildSessionKey } from '@/lib/sessionKey' +import { buildPinnedSessionKeys, getSessionKey } from '@/lib/sessionKey' import { cn, formatShortRelativeTime, getRepoDisplayName } from '@/lib/utils' -import { isFinePointer } from '@/lib/overlayFocus' +import { FINE_POINTER_MEDIA_QUERY, useMediaQuery } from '@/hooks/useMediaQuery' const ALL_PROJECTS_STORAGE_KEY = 'oc:session-picker:all-projects' const UNTITLED_SESSION_TITLE = 'Untitled Session' @@ -109,21 +110,23 @@ function SessionPickerGutter({ sessionID, isActive }: { sessionID: string; isAct interface SessionPickerRowItemProps { row: SessionPickerRow + index: number optionId: string isCursor: boolean isActive: boolean isPinned: boolean isPendingDelete: boolean showRepo: boolean - onOpen: () => void - onHover: () => void - onTogglePin: () => void - onRequestDelete: () => void - registerRef: (node: HTMLDivElement | null) => void + onOpen: (row: SessionPickerRow) => void + onHover: (index: number) => void + onTogglePin: (row: SessionPickerRow) => void + onRequestDelete: (session: Session) => void + registerRef: (key: string, node: HTMLDivElement | null) => void } -function SessionPickerRowItem({ +const SessionPickerRowItem = memo(function SessionPickerRowItem({ row, + index, optionId, isCursor, isActive, @@ -138,10 +141,10 @@ function SessionPickerRowItem({ }: SessionPickerRowItemProps) { return (
registerRef(row.key, node)} role="presentation" onPointerMove={(event) => { - if (event.pointerType === 'mouse') onHover() + if (event.pointerType === 'mouse') onHover(index) }} className={cn( 'group flex items-center rounded-md text-sm min-h-8 pointer-coarse:min-h-11', @@ -156,7 +159,7 @@ function SessionPickerRowItem({ role="option" id={optionId} aria-selected={isCursor} - onClick={onOpen} + onClick={() => onOpen(row)} className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 px-2" > @@ -207,37 +210,46 @@ function SessionPickerRowItem({ event.stopPropagation()}> - + onTogglePin(row)}> {isPinned ? : } {isPinned ? 'Unpin' : 'Pin'} - Delete + onRequestDelete(row.session)}>Delete
) +}) + +interface SessionPickerContentProps { + open: boolean + currentRepo: Repo | undefined + activeSessionID?: string + onSelectSession: (session: Session, repo: Repo) => void + onActiveSessionDeleted: () => void + searchInputRef: React.RefObject + hasFinePointer: boolean } -export function SessionPickerDialog({ +function SessionPickerContent({ open, - onOpenChange, currentRepo, activeSessionID, onSelectSession, onActiveSessionDeleted, -}: SessionPickerDialogProps) { + searchInputRef, + hasFinePointer, +}: SessionPickerContentProps) { const [allProjects, toggleAllProjects] = usePersistentBoolean(ALL_PROJECTS_STORAGE_KEY, false) - const { repos } = useNavigableRepos(open) - const searchInputRef = useRef(null) + const { repos } = useNavigableRepos() const listRef = useRef(null) const rowRefs = useRef(new Map()) const listId = useId() const optionBaseId = useId() const repoByDirectory = useMemo(() => { - const map = new Map() - for (const repo of repos) map.set(repo.fullPath, repo) + const map = buildRepoByDirectory(repos) if (currentRepo) map.set(currentRepo.fullPath, currentRepo) return map }, [repos, currentRepo]) @@ -260,12 +272,8 @@ export function SessionPickerDialog({ isFetchingNextPage, isFetchNextPageError, canFetchNextPage, - } = useSessionSearch(directories) + } = useSessionSearch(directories, { allDirectories: allProjects }) - const keyFn = useCallback( - (session: Session) => buildSessionKey(session.location.directory, session.id), - [], - ) const resolveRepo = useCallback( (session: Session) => repoByDirectory.get(session.location.directory) ?? currentRepo, [repoByDirectory, currentRepo], @@ -281,8 +289,8 @@ export function SessionPickerDialog({ const [sessionToDelete, setSessionToDelete] = useState(null) const sections = useMemo( - () => buildSections(filteredSessions, pinnedKeys, keyFn, resolveRepo), - [filteredSessions, pinnedKeys, keyFn, resolveRepo], + () => buildSections(filteredSessions, pinnedKeys, getSessionKey, resolveRepo), + [filteredSessions, pinnedKeys, resolveRepo], ) const rows = useMemo(() => sections.flatMap((section) => section.rows), [sections]) const rowIndexByKey = useMemo( @@ -309,14 +317,6 @@ export function SessionPickerDialog({ setPendingDeleteKey(null) }, [trimmedQuery, allProjects]) - useEffect(() => { - if (!open) return - setQuery('') - setCursorKey(null) - setPendingDeleteKey(null) - setSessionToDelete(null) - }, [open, setQuery]) - const setRowRef = useCallback((key: string, node: HTMLDivElement | null) => { if (node) rowRefs.current.set(key, node) else rowRefs.current.delete(key) @@ -348,6 +348,17 @@ export function SessionPickerDialog({ [onSelectSession], ) + const performDelete = useCallback( + (session: Session) => + deleteSession + .mutateAsync({ id: session.id, directory: session.location.directory }) + .then(() => { + if (session.id === activeSessionID) onActiveSessionDeleted() + }) + .catch(() => undefined), + [deleteSession, activeSessionID, onActiveSessionDeleted], + ) + const deleteRow = useCallback( (row: SessionPickerRow | undefined) => { if (!row) return @@ -355,17 +366,11 @@ export function SessionPickerDialog({ setPendingDeleteKey(row.key) return } - void deleteSession - .mutateAsync({ id: row.session.id, directory: row.session.location.directory }) - .then(() => { - if (row.session.id === activeSessionID) onActiveSessionDeleted() - }) - .catch(() => undefined) - .finally(() => { - setPendingDeleteKey(null) - }) + void performDelete(row.session).finally(() => { + setPendingDeleteKey(null) + }) }, - [pendingDeleteKey, deleteSession, activeSessionID, onActiveSessionDeleted], + [pendingDeleteKey, performDelete], ) const handleSearchKeyDown = useCallback( @@ -456,42 +461,170 @@ export function SessionPickerDialog({ [togglePin, pinnedKeys], ) + const handleRequestDelete = useCallback((session: Session) => { + setSessionToDelete(session) + }, []) + const confirmDelete = useCallback(() => { if (!sessionToDelete) return - void deleteSession - .mutateAsync({ - id: sessionToDelete.id, - directory: sessionToDelete.location.directory, - }) - .then(() => { - if (sessionToDelete.id === activeSessionID) onActiveSessionDeleted() - }) - .catch(() => undefined) - .finally(() => { - setSessionToDelete(null) - }) - }, [sessionToDelete, deleteSession, activeSessionID, onActiveSessionDeleted]) + void performDelete(sessionToDelete).finally(() => { + setSessionToDelete(null) + }) + }, [sessionToDelete, performDelete]) - const renderRow = (row: SessionPickerRow) => { - const index = rowIndexByKey.get(row.key) ?? -1 - return ( - openRow(row)} - onHover={() => moveCursor(index)} - onTogglePin={() => handleTogglePin(row)} - onRequestDelete={() => setSessionToDelete(row.session)} - registerRef={(node) => setRowRef(row.key, node)} + return ( + <> + + Sessions + {!allProjects && currentRepo && ( + for {getRepoDisplayName(currentRepo)} + )} + + +
+
+
+ + = 0 ? optionId(optionBaseId, cursorIndex) : undefined} + value={query} + onChange={(event) => setQuery(event.target.value)} + onKeyDown={handleSearchKeyDown} + placeholder="Search sessions..." + className="h-9 pl-9" + autoComplete="off" + name="session-picker-search" + /> +
+ +
+ +
+ {rows.length === 0 ? ( +
+ {isLoading + ? 'Loading sessions...' + : isSearchPending + ? 'Searching sessions...' + : trimmedQuery + ? 'No sessions found' + : 'No sessions yet'} +
+ ) : ( + <> + {sections.map((section) => ( +
+
+ {section.heading} +
+ {section.rows.map((row) => { + const index = rowIndexByKey.get(row.key) ?? -1 + return ( + + ) + })} +
+ ))} + {isFetchNextPageError && ( +
+

Failed to load more sessions

+ +
+ )} + {isFetchingNextPage && rows.length > 0 && ( +
Loading more sessions...
+ )} + + )} +
+ +
+ ↑↓ move · Enter open · Ctrl+D delete · Ctrl+A {allProjects ? 'this project' : 'all projects'} +
+
+ + { + if (!nextOpen) setSessionToDelete(null) + }} + onConfirm={() => { + void confirmDelete() + }} + onCancel={() => setSessionToDelete(null)} + isDeleting={deleteSession.isPending} /> - ) - } + + ) +} + +export function SessionPickerDialog({ + open, + onOpenChange, + currentRepo, + activeSessionID, + onSelectSession, + onActiveSessionDeleted, +}: SessionPickerDialogProps) { + const searchInputRef = useRef(null) + const hasFinePointer = useMediaQuery(FINE_POINTER_MEDIA_QUERY) return ( @@ -501,129 +634,21 @@ export function SessionPickerDialog({ onEscapeKeyDown={() => onOpenChange(false)} onOpenAutoFocus={(event) => { event.preventDefault() - if (isFinePointer()) { + if (hasFinePointer) { searchInputRef.current?.focus() } else { ;(event.currentTarget as HTMLElement).focus() } }} > - - Sessions - {!allProjects && currentRepo && ( - for {getRepoDisplayName(currentRepo)} - )} - - -
-
-
- - = 0 ? optionId(optionBaseId, cursorIndex) : undefined} - value={query} - onChange={(event) => setQuery(event.target.value)} - onKeyDown={handleSearchKeyDown} - placeholder="Search sessions..." - className="h-9 pl-9" - autoComplete="off" - name="session-picker-search" - /> -
- -
- -
- {rows.length === 0 ? ( -
- {isLoading - ? 'Loading sessions...' - : isSearchPending - ? 'Searching sessions...' - : trimmedQuery - ? 'No sessions found' - : 'No sessions yet'} -
- ) : ( - <> - {sections.map((section) => ( -
-
- {section.heading} -
- {section.rows.map(renderRow)} -
- ))} - {isFetchNextPageError && ( -
-

Failed to load more sessions

- -
- )} - {isFetchingNextPage && rows.length > 0 && ( -
Loading more sessions...
- )} - - )} -
- -
- ↑↓ move · Enter open · Ctrl+D delete · Ctrl+A {allProjects ? 'this project' : 'all projects'} -
-
- - { - if (!nextOpen) setSessionToDelete(null) - }} - onConfirm={() => { - void confirmDelete() - }} - onCancel={() => setSessionToDelete(null)} - isDeleting={deleteSession.isPending} +
diff --git a/frontend/src/hooks/useOpenCode.test.tsx b/frontend/src/hooks/useOpenCode.test.tsx index b07be3697..68c370a01 100644 --- a/frontend/src/hooks/useOpenCode.test.tsx +++ b/frontend/src/hooks/useOpenCode.test.tsx @@ -134,12 +134,58 @@ describe('useSessionsAcrossDirectories', () => { }) }) - it('deletes each session through the facade and invalidates the session list cache', async () => { + it('issues one call per page across all directories and paginates by cursor', async () => { + mocks.listSessionPage.mockImplementation(async ({ cursor }: { cursor?: string }) => { + if (cursor === 'cursor_all') { + return { items: [sessionInfo('ses_all2', '/w/b', 2000)] } + } + return { items: [sessionInfo('ses_all1', '/w/a')], nextCursor: 'cursor_all' } + }) + + const queryClient = createQueryClient() + const { result } = renderHook( + () => useSessionsAcrossDirectories(['/w/a', '/w/b'], { allDirectories: true }), + { wrapper: createWrapper(queryClient) }, + ) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + + expect(mocks.listSessionPage).toHaveBeenCalledTimes(1) + expect(mocks.listSessionPage).toHaveBeenCalledWith({ + limit: 25, + order: 'desc', + search: undefined, + }) + expect(result.current.data.map((session) => session.id)).toEqual(['ses_all1']) + expect(result.current.hasNextPage).toBe(true) + + await act(async () => { + await result.current.fetchNextPage() + }) + + await waitFor(() => { + expect(result.current.data).toHaveLength(2) + }) + + expect(mocks.listSessionPage).toHaveBeenCalledTimes(2) + expect(mocks.listSessionPage).toHaveBeenLastCalledWith({ cursor: 'cursor_all' }) + expect(result.current.data.map((session) => session.id)).toEqual(['ses_all1', 'ses_all2']) + }) + + it('deletes each session through the facade and removes it from cached lists without invalidating', async () => { mocks.deleteSession.mockResolvedValue(undefined) const queryClient = createQueryClient() - const listKey = ['opencode', 'sessions', '/w/a', { search: undefined, limit: 25 }] - queryClient.setQueryData(listKey, { pages: [{ items: [], cursors: {} }], pageParams: [undefined] }) + const listKey = ['opencode', 'sessions', '/w/a', { search: undefined, limit: 25, allDirectories: false }] + queryClient.setQueryData(listKey, { + pages: [{ + items: [sessionInfo('ses_a1', '/w/a'), sessionInfo('ses_a2', '/w/a')], + nextParam: undefined, + }], + pageParams: [undefined], + }) const { result } = renderHook(() => useDeleteSession(['/w/a']), { wrapper: createWrapper(queryClient), @@ -150,6 +196,31 @@ describe('useSessionsAcrossDirectories', () => { }) expect(mocks.deleteSession).toHaveBeenCalledWith('ses_a1') + const cached = queryClient.getQueryData<{ pages: Array<{ items: SessionInfo[] }> }>(listKey) + expect(cached?.pages[0].items.map((session) => session.id)).toEqual(['ses_a2']) + expect(queryClient.getQueryState(listKey)?.isInvalidated).toBe(false) + }) + + it('invalidates the session list cache when a delete fails', async () => { + mocks.deleteSession.mockRejectedValue(new Error('nope')) + + const queryClient = createQueryClient() + const listKey = ['opencode', 'sessions', '/w/a', { search: undefined, limit: 25, allDirectories: false }] + queryClient.setQueryData(listKey, { + pages: [{ items: [sessionInfo('ses_a1', '/w/a')], nextParam: undefined }], + pageParams: [undefined], + }) + + const { result } = renderHook(() => useDeleteSession(['/w/a']), { + wrapper: createWrapper(queryClient), + }) + + await act(async () => { + await expect( + result.current.mutateAsync({ id: 'ses_a1', directory: '/w/a' }), + ).rejects.toThrow('Failed to delete 1 session(s)') + }) + await waitFor(() => { expect(queryClient.getQueryState(listKey)?.isInvalidated).toBe(true) }) diff --git a/frontend/src/hooks/useOpenCode.ts b/frontend/src/hooks/useOpenCode.ts index 611108a63..79d22ac35 100644 --- a/frontend/src/hooks/useOpenCode.ts +++ b/frontend/src/hooks/useOpenCode.ts @@ -1,4 +1,4 @@ -import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from "@tanstack/react-query"; +import { useQuery, useMutation, useQueryClient, useInfiniteQuery, type InfiniteData } from "@tanstack/react-query"; import { useMemo, useRef, useEffect, useCallback } from "react"; import { createSession, @@ -42,9 +42,19 @@ interface UseSessionsAcrossDirectoriesOptions { search?: string limit?: number keepPreviousResults?: boolean + allDirectories?: boolean } -type SessionPageParam = Record +type SessionDirectoryCursors = Record + +type SessionListPageParam = + | { mode: 'directories'; cursors: SessionDirectoryCursors } + | { mode: 'all'; cursor: string } + +interface SessionListPage { + items: SessionInfo[] + nextParam?: SessionListPageParam +} export const useSessionsAcrossDirectories = ( directories: string[], @@ -57,12 +67,32 @@ export const useSessionsAcrossDirectories = ( const normalizedSearch = options?.search?.trim() || undefined; const limit = options?.limit ?? SESSION_LIST_PAGE_SIZE; const keepPreviousResults = options?.keepPreviousResults ?? false; + const allDirectories = options?.allDirectories ?? false; const directoryKey = uniqueDirectories.join('|'); - const query = useInfiniteQuery({ - queryKey: ['opencode', 'sessions', directoryKey, { search: normalizedSearch, limit }], + const query = useInfiniteQuery< + SessionListPage, + Error, + InfiniteData, + readonly unknown[], + SessionListPageParam | undefined + >({ + queryKey: ['opencode', 'sessions', directoryKey, { search: normalizedSearch, limit, allDirectories }], queryFn: async ({ pageParam }) => { - if (!pageParam) { + if (allDirectories) { + const cursor = pageParam?.mode === 'all' ? pageParam.cursor : undefined; + const page = await listSessionPage( + cursor === undefined + ? { limit, order: 'desc', search: normalizedSearch } + : { cursor }, + ); + return { + items: page.items, + nextParam: page.nextCursor ? { mode: 'all', cursor: page.nextCursor } : undefined, + }; + } + + if (!pageParam || pageParam.mode !== 'directories') { const pages = await Promise.all( uniqueDirectories.map((directory) => listSessionPage({ @@ -73,7 +103,7 @@ export const useSessionsAcrossDirectories = ( }), ), ); - const cursors: SessionPageParam = {}; + const cursors: SessionDirectoryCursors = {}; const items: SessionInfo[] = []; for (let i = 0; i < pages.length; i++) { items.push(...pages[i].items); @@ -81,14 +111,17 @@ export const useSessionsAcrossDirectories = ( cursors[uniqueDirectories[i]] = pages[i].nextCursor!; } } - return { items, cursors }; + return { + items, + nextParam: Object.keys(cursors).length > 0 ? { mode: 'directories', cursors } : undefined, + }; } - const entries = Object.entries(pageParam); + const entries = Object.entries(pageParam.cursors); const pages = await Promise.all( entries.map(([directory, cursor]) => listSessionPage({ directory, cursor })), ); - const cursors: SessionPageParam = {}; + const cursors: SessionDirectoryCursors = {}; const items: SessionInfo[] = []; for (let i = 0; i < pages.length; i++) { items.push(...pages[i].items); @@ -96,19 +129,17 @@ export const useSessionsAcrossDirectories = ( cursors[entries[i][0]] = pages[i].nextCursor!; } } - return { items, cursors }; + return { + items, + nextParam: Object.keys(cursors).length > 0 ? { mode: 'directories', cursors } : undefined, + }; }, - initialPageParam: undefined as SessionPageParam | undefined, + initialPageParam: undefined as SessionListPageParam | undefined, placeholderData: keepPreviousResults ? (previousData, previousQuery) => previousQuery?.queryKey[2] === directoryKey ? previousData : undefined : undefined, - getNextPageParam: (lastPage) => { - if (Object.keys(lastPage.cursors).length > 0) { - return lastPage.cursors; - } - return undefined; - }, + getNextPageParam: (lastPage) => lastPage.nextParam, enabled: uniqueDirectories.length > 0, staleTime: 10000, refetchOnWindowFocus: true, @@ -252,19 +283,43 @@ export const useDeleteSession = (directory?: string | string[]) => { return { deleted: targets.length, results } }, - onSuccess: ({ deleted }) => { + onSuccess: ({ deleted }, variables) => { + removeDeletedSessionsFromListCaches(queryClient, variables); + cleanupSessionPins(queryClient, variables, primaryDirectory); showToast.success(deleted === 1 ? 'Session deleted' : `${deleted} sessions deleted`); }, - onError: () => { - showToast.error('Failed to delete sessions'); - }, - onSettled: (_data, _error, variables) => { + onError: (_error, variables) => { invalidateSessionListCaches(queryClient); cleanupSessionPins(queryClient, variables, primaryDirectory); + showToast.error('Failed to delete sessions'); }, }); }; +const removeDeletedSessionsFromListCaches = ( + queryClient: ReturnType, + variables: DeleteSessionTarget | DeleteSessionTarget[], +) => { + const deletedIDs = new Set( + (Array.isArray(variables) ? variables : [variables]).map(getDeleteSessionTargetId), + ); + if (deletedIDs.size === 0) return; + queryClient.setQueriesData>( + { queryKey: ['opencode', 'sessions'] }, + (current) => { + if (!current) return current; + let changed = false; + const pages = current.pages.map((page) => { + const items = page.items.filter((item) => !deletedIDs.has(item.id)); + if (items.length === page.items.length) return page; + changed = true; + return { ...page, items }; + }); + return changed ? { ...current, pages } : current; + }, + ); +}; + const cleanupSessionPins = ( queryClient: ReturnType, variables: DeleteSessionTarget | DeleteSessionTarget[], diff --git a/frontend/src/hooks/usePersistentBoolean.test.tsx b/frontend/src/hooks/usePersistentBoolean.test.tsx new file mode 100644 index 000000000..1b9283323 --- /dev/null +++ b/frontend/src/hooks/usePersistentBoolean.test.tsx @@ -0,0 +1,86 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { usePersistentBoolean } from './usePersistentBoolean' + +const STORAGE_KEY = 'oc:test:flag' + +const localStorageMock = { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), +} + +describe('usePersistentBoolean', () => { + beforeEach(() => { + vi.clearAllMocks() + Object.defineProperty(global, 'localStorage', { + value: localStorageMock, + writable: true, + }) + }) + + afterEach(() => { + Object.defineProperty(global, 'localStorage', { + value: window.localStorage, + writable: true, + }) + vi.restoreAllMocks() + }) + + it('returns the fallback when no stored value', () => { + localStorageMock.getItem.mockReturnValue(null) + + const { result } = renderHook(() => usePersistentBoolean(STORAGE_KEY, false)) + + expect(localStorageMock.getItem).toHaveBeenCalledWith(STORAGE_KEY) + expect(result.current[0]).toBe(false) + }) + + it('returns a stored true value', () => { + localStorageMock.getItem.mockReturnValue('true') + + const { result } = renderHook(() => usePersistentBoolean(STORAGE_KEY, false)) + + expect(result.current[0]).toBe(true) + }) + + it('toggles the value and persists it to localStorage', () => { + localStorageMock.getItem.mockReturnValue(null) + + const { result } = renderHook(() => usePersistentBoolean(STORAGE_KEY, false)) + + expect(result.current[0]).toBe(false) + + act(() => { + result.current[1]() + }) + + expect(result.current[0]).toBe(true) + expect(localStorageMock.setItem).toHaveBeenCalledWith(STORAGE_KEY, 'true') + }) + + it('returns the fallback when the stored value is malformed JSON', () => { + localStorageMock.getItem.mockReturnValue('not-json{{') + + const { result } = renderHook(() => usePersistentBoolean(STORAGE_KEY, false)) + + expect(result.current[0]).toBe(false) + }) + + it('returns the fallback when the stored value is JSON but not a boolean', () => { + localStorageMock.getItem.mockReturnValue('"some string"') + + const { result } = renderHook(() => usePersistentBoolean(STORAGE_KEY, false)) + + expect(result.current[0]).toBe(false) + }) + + it('honours a true fallback when no value is stored', () => { + localStorageMock.getItem.mockReturnValue(null) + + const { result } = renderHook(() => usePersistentBoolean(STORAGE_KEY, true)) + + expect(result.current[0]).toBe(true) + }) +}) diff --git a/frontend/src/hooks/usePersistentBoolean.ts b/frontend/src/hooks/usePersistentBoolean.ts new file mode 100644 index 000000000..82d3f3d42 --- /dev/null +++ b/frontend/src/hooks/usePersistentBoolean.ts @@ -0,0 +1,48 @@ +import { useState, useCallback } from 'react' + +function readStorage(key: string): string | null { + try { + return localStorage.getItem(key) + } catch { + return null + } +} + +function writeStorage(key: string, value: unknown): void { + try { + localStorage.setItem(key, JSON.stringify(value)) + } catch { + return + } +} + +function readStoredBoolean(key: string, fallback: boolean): boolean { + const stored = readStorage(key) + if (stored === null) { + return fallback + } + try { + const parsed = JSON.parse(stored) + return typeof parsed === 'boolean' ? parsed : fallback + } catch { + return fallback + } +} + +/** + * Persists a boolean preference under `key` in localStorage and returns it with a toggler. + * Falls back to `fallback` when the stored value is missing or malformed. + */ +export function usePersistentBoolean(key: string, fallback: boolean): [boolean, () => void] { + const [value, setValue] = useState(() => readStoredBoolean(key, fallback)) + + const toggle = useCallback(() => { + setValue((prev: boolean) => { + const newValue = !prev + writeStorage(key, newValue) + return newValue + }) + }, [key]) + + return [value, toggle] +} diff --git a/frontend/src/hooks/useSessionSearch.test.tsx b/frontend/src/hooks/useSessionSearch.test.tsx index d8a4b51d9..77b03bda1 100644 --- a/frontend/src/hooks/useSessionSearch.test.tsx +++ b/frontend/src/hooks/useSessionSearch.test.tsx @@ -4,15 +4,15 @@ import { useSessionSearch } from './useSessionSearch' const { sessionsData, lastArgsRef, flagsRef } = vi.hoisted(() => ({ sessionsData: [] as Array<{ id: string; title: string; location: { directory: string }; parentID?: string; time: { updated: number } }>, - lastArgsRef: { current: undefined as { directories: string[]; options?: { search?: string; limit?: number; keepPreviousResults?: boolean } } | undefined }, - flagsRef: { current: { hasNextPage: false, isFetchingNextPage: false, isFetchNextPageError: false, isPlaceholderData: false } }, + lastArgsRef: { current: undefined as { directories: string[]; options?: { search?: string; limit?: number; keepPreviousResults?: boolean; allDirectories?: boolean } } | undefined }, + flagsRef: { current: { hasNextPage: false, isFetchingNextPage: false, isFetchNextPageError: false, isPlaceholderData: false, isError: false } }, })) vi.mock('@/hooks/useOpenCode', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - useSessionsAcrossDirectories: (directories: string[], options?: { search?: string; limit?: number; keepPreviousResults?: boolean }) => { + useSessionsAcrossDirectories: (directories: string[], options?: { search?: string; limit?: number; keepPreviousResults?: boolean; allDirectories?: boolean }) => { lastArgsRef.current = { directories, options } const search = options?.search?.toLowerCase() ?? '' const data = search @@ -21,6 +21,7 @@ vi.mock('@/hooks/useOpenCode', async (importOriginal) => { return { data, isLoading: false, + isError: flagsRef.current.isError, isPlaceholderData: flagsRef.current.isPlaceholderData, fetchNextPage: vi.fn(), hasNextPage: flagsRef.current.hasNextPage, @@ -38,7 +39,7 @@ describe('useSessionSearch', () => { { id: 'ses_beta', title: 'beta task', location: { directory: '/w/a' }, time: { updated: 1 } }, ) lastArgsRef.current = undefined - flagsRef.current = { hasNextPage: false, isFetchingNextPage: false, isFetchNextPageError: false, isPlaceholderData: false } + flagsRef.current = { hasNextPage: false, isFetchingNextPage: false, isFetchNextPageError: false, isPlaceholderData: false, isError: false } }) afterEach(() => { @@ -89,23 +90,59 @@ describe('useSessionSearch', () => { expect(result.current.isSearchPending).toBe(false) }) + it('passes allDirectories through to useSessionsAcrossDirectories', () => { + renderHook(() => useSessionSearch(['/w/a'], { allDirectories: true })) + + expect(lastArgsRef.current?.options?.allDirectories).toBe(true) + }) + + it('passes isError through from useSessionsAcrossDirectories', () => { + flagsRef.current = { hasNextPage: false, isFetchingNextPage: false, isFetchNextPageError: false, isPlaceholderData: false, isError: true } + + const { result } = renderHook(() => useSessionSearch(['/w/a'])) + + expect(result.current.isError).toBe(true) + }) + + it('blocks pagination while a search is pending', () => { + vi.useFakeTimers() + flagsRef.current = { hasNextPage: true, isFetchingNextPage: false, isFetchNextPageError: false, isPlaceholderData: false, isError: false } + const { result } = renderHook(() => useSessionSearch(['/w/a'])) + + expect(result.current.canFetchNextPage).toBe(true) + + act(() => { + result.current.setQuery('deploy') + }) + + expect(result.current.isSearchPending).toBe(true) + expect(result.current.canFetchNextPage).toBe(false) + + act(() => { + vi.advanceTimersByTime(150) + }) + + expect(result.current.isSearchPending).toBe(false) + expect(result.current.canFetchNextPage).toBe(true) + }) + it('exposes canFetchNextPage only when paging is safe', () => { const { result, rerender } = renderHook(() => useSessionSearch(['/w/a'])) expect(result.current.canFetchNextPage).toBe(false) - flagsRef.current = { hasNextPage: true, isFetchingNextPage: false, isFetchNextPageError: false, isPlaceholderData: false } + flagsRef.current = { hasNextPage: true, isFetchingNextPage: false, isFetchNextPageError: false, isPlaceholderData: false, isError: false } rerender() expect(result.current.canFetchNextPage).toBe(true) - flagsRef.current = { hasNextPage: true, isFetchingNextPage: true, isFetchNextPageError: false, isPlaceholderData: false } + flagsRef.current = { hasNextPage: true, isFetchingNextPage: true, isFetchNextPageError: false, isPlaceholderData: false, isError: false } rerender() expect(result.current.canFetchNextPage).toBe(false) - flagsRef.current = { hasNextPage: true, isFetchingNextPage: false, isFetchNextPageError: true, isPlaceholderData: false } + flagsRef.current = { hasNextPage: true, isFetchingNextPage: false, isFetchNextPageError: true, isPlaceholderData: false, isError: false } rerender() expect(result.current.canFetchNextPage).toBe(false) - flagsRef.current = { hasNextPage: true, isFetchingNextPage: false, isFetchNextPageError: false, isPlaceholderData: true } + flagsRef.current = { hasNextPage: true, isFetchingNextPage: false, isFetchNextPageError: false, isPlaceholderData: true, isError: false } rerender() expect(result.current.canFetchNextPage).toBe(false) }) diff --git a/frontend/src/hooks/useSessionSearch.ts b/frontend/src/hooks/useSessionSearch.ts index 955d699cf..0f33b643b 100644 --- a/frontend/src/hooks/useSessionSearch.ts +++ b/frontend/src/hooks/useSessionSearch.ts @@ -1,14 +1,14 @@ -import { useCallback, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import { useSessionsAcrossDirectories } from '@/hooks/useOpenCode' import { useDebouncedValue } from '@/hooks/useDebouncedValue' import { selectRootSessions } from '@/components/session/session-partition' -import { buildSessionKey } from '@/lib/sessionKey' -import type { Session } from '@/api/types' +import { getSessionKey } from '@/lib/sessionKey' const DEFAULT_LIMIT = 25 export interface UseSessionSearchOptions { limit?: number + allDirectories?: boolean } /** @@ -17,6 +17,7 @@ export interface UseSessionSearchOptions { */ export function useSessionSearch(directories: string[], options: UseSessionSearchOptions = {}) { const limit = options.limit ?? DEFAULT_LIMIT + const allDirectories = options.allDirectories ?? false const [query, setQuery] = useState('') const trimmedQuery = query.trim() const debouncedQuery = useDebouncedValue(trimmedQuery, 150) @@ -24,22 +25,19 @@ export function useSessionSearch(directories: string[], options: UseSessionSearc const { data: sessions, isLoading, + isError, isPlaceholderData, fetchNextPage, hasNextPage, isFetchingNextPage, isFetchNextPageError, - } = useSessionsAcrossDirectories(directories, { search, limit, keepPreviousResults: true }) + } = useSessionsAcrossDirectories(directories, { search, limit, keepPreviousResults: true, allDirectories }) const directorySet = useMemo(() => new Set(directories), [directories]) - const getSessionSelectionKey = useCallback( - (session: Session) => buildSessionKey(session.location.directory, session.id), - [], - ) const rootSessions = useMemo( - () => selectRootSessions(sessions ?? [], { directories: directorySet, keyFn: getSessionSelectionKey }), - [sessions, directorySet, getSessionSelectionKey], + () => selectRootSessions(sessions ?? [], { directories: directorySet, keyFn: getSessionKey }), + [sessions, directorySet], ) const filteredSessions = useMemo(() => { @@ -50,7 +48,7 @@ export function useSessionSearch(directories: string[], options: UseSessionSearc const isSearchPending = trimmedQuery !== search || isPlaceholderData const canFetchNextPage = Boolean( - hasNextPage && !isFetchingNextPage && !isFetchNextPageError && !isPlaceholderData, + hasNextPage && !isFetchingNextPage && !isFetchNextPageError && !isSearchPending, ) return { @@ -61,6 +59,7 @@ export function useSessionSearch(directories: string[], options: UseSessionSearc filteredSessions, isSearchPending, isLoading, + isError, isPlaceholderData, fetchNextPage, hasNextPage, diff --git a/frontend/src/hooks/useSidebarCollapsed.test.tsx b/frontend/src/hooks/useSidebarCollapsed.test.tsx index 0aed5cefd..a849f5cb0 100644 --- a/frontend/src/hooks/useSidebarCollapsed.test.tsx +++ b/frontend/src/hooks/useSidebarCollapsed.test.tsx @@ -9,7 +9,7 @@ const localStorageMock = { clear: vi.fn(), } -describe('sidebar collapse hooks', () => { +describe('useSidebarCollapsed', () => { beforeEach(() => { vi.clearAllMocks() Object.defineProperty(global, 'localStorage', { @@ -26,52 +26,27 @@ describe('sidebar collapse hooks', () => { vi.restoreAllMocks() }) - describe('useSidebarCollapsed', () => { - it('returns false by default when no stored value', () => { - localStorageMock.getItem.mockReturnValue(null) + it('reads and persists under the sidebar storage key with a false default', () => { + localStorageMock.getItem.mockReturnValue(null) - const { result } = renderHook(() => useSidebarCollapsed()) + const { result } = renderHook(() => useSidebarCollapsed()) - expect(result.current[0]).toBe(false) - }) - - it('returns stored value from localStorage', () => { - localStorageMock.getItem.mockReturnValue('true') - - const { result } = renderHook(() => useSidebarCollapsed()) - - expect(result.current[0]).toBe(true) - }) - - it('toggles collapsed state and persists to localStorage', () => { - localStorageMock.getItem.mockReturnValue(null) - - const { result } = renderHook(() => useSidebarCollapsed()) + expect(localStorageMock.getItem).toHaveBeenCalledWith('oc:sidebar:collapsed') + expect(result.current[0]).toBe(false) - expect(result.current[0]).toBe(false) - - act(() => { - result.current[1]() - }) - - expect(result.current[0]).toBe(true) - expect(localStorageMock.setItem).toHaveBeenCalledWith('oc:sidebar:collapsed', 'true') + act(() => { + result.current[1]() }) - it('returns false when stored value is malformed JSON', () => { - localStorageMock.getItem.mockReturnValue('not-json{{') - - const { result } = renderHook(() => useSidebarCollapsed()) - - expect(result.current[0]).toBe(false) - }) + expect(result.current[0]).toBe(true) + expect(localStorageMock.setItem).toHaveBeenCalledWith('oc:sidebar:collapsed', 'true') + }) - it('returns false when stored value is JSON but not a boolean', () => { - localStorageMock.getItem.mockReturnValue('"some string"') + it('returns the stored value', () => { + localStorageMock.getItem.mockReturnValue('true') - const { result } = renderHook(() => useSidebarCollapsed()) + const { result } = renderHook(() => useSidebarCollapsed()) - expect(result.current[0]).toBe(false) - }) + expect(result.current[0]).toBe(true) }) }) diff --git a/frontend/src/hooks/useSidebarCollapsed.ts b/frontend/src/hooks/useSidebarCollapsed.ts index 148868bb9..a44ad6a91 100644 --- a/frontend/src/hooks/useSidebarCollapsed.ts +++ b/frontend/src/hooks/useSidebarCollapsed.ts @@ -1,54 +1,7 @@ -import { useState, useCallback } from 'react' +import { usePersistentBoolean } from './usePersistentBoolean' const STORAGE_KEY = 'oc:sidebar:collapsed' -function readStorage(key: string): string | null { - try { - return localStorage.getItem(key) - } catch { - return null - } -} - -function writeStorage(key: string, value: unknown): void { - try { - localStorage.setItem(key, JSON.stringify(value)) - } catch { - return - } -} - -function readStoredBoolean(key: string, fallback: boolean): boolean { - const stored = readStorage(key) - if (stored === null) { - return fallback - } - try { - const parsed = JSON.parse(stored) - return typeof parsed === 'boolean' ? parsed : fallback - } catch { - return fallback - } -} - -/** - * Persists a boolean preference under `key` in localStorage and returns it with a toggler. - * Falls back to `fallback` when the stored value is missing or malformed. - */ -export function usePersistentBoolean(key: string, fallback: boolean): [boolean, () => void] { - const [value, setValue] = useState(() => readStoredBoolean(key, fallback)) - - const toggle = useCallback(() => { - setValue((prev: boolean) => { - const newValue = !prev - writeStorage(key, newValue) - return newValue - }) - }, [key]) - - return [value, toggle] -} - export function useSidebarCollapsed(): [boolean, () => void] { return usePersistentBoolean(STORAGE_KEY, false) } diff --git a/frontend/src/hooks/useSidebarRepoGroups.ts b/frontend/src/hooks/useSidebarRepoGroups.ts index c72e5eb0b..4fbd63991 100644 --- a/frontend/src/hooks/useSidebarRepoGroups.ts +++ b/frontend/src/hooks/useSidebarRepoGroups.ts @@ -3,7 +3,7 @@ import { useQueries, useQuery } from '@tanstack/react-query' import { listRepos } from '@/api/repos' import { sessionQueryOptions, useSessionsAcrossDirectories } from '@/hooks/useOpenCode' import { useSessionPins } from '@/hooks/useSessionPins' -import { buildPinnedSessionKeys, buildSessionKey } from '@/lib/sessionKey' +import { buildPinnedSessionKeys, buildSessionKey, getSessionKey } from '@/lib/sessionKey' import type { Repo, Session } from '@/api/types' import { SIDEBAR_SESSIONS_PER_REPO, @@ -46,9 +46,7 @@ export function useSidebarRepoGroups(input: { const missingPins = useMemo(() => { if (search) return [] const repoDirectories = new Set(directories) - const fetchedKeys = new Set( - sessions.map((session) => buildSessionKey(session.location.directory, session.id)), - ) + const fetchedKeys = new Set(sessions.map(getSessionKey)) return (sessionPins ?? []).filter( (pin) => repoDirectories.has(pin.directory) && diff --git a/frontend/src/lib/overlayFocus.test.ts b/frontend/src/lib/overlayFocus.test.ts index 91834d26b..792f88977 100644 --- a/frontend/src/lib/overlayFocus.test.ts +++ b/frontend/src/lib/overlayFocus.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, afterEach } from 'vitest' import { stubMatchMedia } from '@/test/test-utils' -import { getFocusedElement, hasOpenOverlay, isFinePointer, restoreOverlayFocus } from './overlayFocus' +import { getFocusedElement, hasOpenOverlay, restoreOverlayFocus } from './overlayFocus' import { PROMPT_INPUT_SELECTOR, isPromptInput, isTextEntryElement } from './domTargets' function createElement( @@ -26,16 +26,6 @@ describe('getFocusedElement', () => { }) }) -describe('isFinePointer', () => { - it('reflects the fine pointer media query', () => { - stubMatchMedia(true) - expect(isFinePointer()).toBe(true) - - stubMatchMedia(false) - expect(isFinePointer()).toBe(false) - }) -}) - describe('restoreOverlayFocus', () => { it('focuses the chat prompt on desktop when focus was on a button', () => { stubMatchMedia(true) diff --git a/frontend/src/lib/overlayFocus.ts b/frontend/src/lib/overlayFocus.ts index fa8b08d26..a41da3cf9 100644 --- a/frontend/src/lib/overlayFocus.ts +++ b/frontend/src/lib/overlayFocus.ts @@ -12,10 +12,7 @@ export function getFocusedElement(): HTMLElement | null { : null } -/** - * Reports whether the current device uses a fine pointer, such as a mouse or trackpad. - */ -export function isFinePointer(): boolean { +function isFinePointer(): boolean { return ( typeof window !== 'undefined' && typeof window.matchMedia === 'function' && diff --git a/frontend/src/lib/sessionKey.test.ts b/frontend/src/lib/sessionKey.test.ts index 814c5d322..f9e5c5dce 100644 --- a/frontend/src/lib/sessionKey.test.ts +++ b/frontend/src/lib/sessionKey.test.ts @@ -1,5 +1,14 @@ import { describe, it, expect } from 'vitest' -import { buildPinnedSessionKeys } from './sessionKey' +import { buildPinnedSessionKeys, getSessionKey } from './sessionKey' +import type { Session } from '@/api/types' + +describe('getSessionKey', () => { + it('combines the session directory and id', () => { + const session = { id: 'ses_1', location: { directory: '/w/a' } } as Session + + expect(getSessionKey(session)).toBe('/w/a:ses_1') + }) +}) describe('buildPinnedSessionKeys', () => { it('builds a session key for each pin', () => { diff --git a/frontend/src/lib/sessionKey.ts b/frontend/src/lib/sessionKey.ts index 7704f4ead..1c7718bab 100644 --- a/frontend/src/lib/sessionKey.ts +++ b/frontend/src/lib/sessionKey.ts @@ -1,7 +1,13 @@ +import type { Session } from '@/api/types' + export function buildSessionKey(directory: string | undefined, id: string): string { return `${directory ?? ''}:${id}` } +export function getSessionKey(session: Pick): string { + return buildSessionKey(session.location.directory, session.id) +} + export function buildPinnedSessionKeys( pins: ReadonlyArray<{ directory: string; sessionId: string }>, ): Set { diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx index 20529d97f..9dd33d04a 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -842,7 +842,7 @@ export function SessionDetail() { }} onActiveSessionDeleted={() => { setSessionsDialogOpen(false) - navigate(getSessionListPath(repoId, isAssistantSession)) + handleCloseSession() }} /> From 7bc71d712349a8b1cb247c08e6a104e06ef7fe95 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:11:58 -0400 Subject: [PATCH 4/4] fix(sessions): settle session before revert stage and notify active-session delete eagerly --- frontend/src/api/opencode.test.ts | 24 ++++++++++---- frontend/src/api/opencode.ts | 2 ++ .../session/SessionPickerDialog.test.tsx | 32 +++++++++++++------ .../session/SessionPickerDialog.tsx | 23 +++++++------ frontend/src/pages/SessionDetail.tsx | 2 +- 5 files changed, 54 insertions(+), 29 deletions(-) diff --git a/frontend/src/api/opencode.test.ts b/frontend/src/api/opencode.test.ts index 55db0c975..99d4542ed 100644 --- a/frontend/src/api/opencode.test.ts +++ b/frontend/src/api/opencode.test.ts @@ -555,15 +555,27 @@ describe('OpenCode facade', () => { }) it('stages, commits, and clears a session revert', async () => { - fetchMock.mockResolvedValue(jsonResponse({ data: { messageID: 'msg_1' } })) + fetchMock.mockImplementation((input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith('/interrupt')) return Promise.resolve(jsonResponse({ data: { interrupted: true } })) + if (url.endsWith('/wait')) return Promise.resolve(new Response(null, { status: 204 })) + if (url.endsWith('/revert/stage')) return Promise.resolve(jsonResponse({ data: { messageID: 'msg_1' } })) + return Promise.resolve(new Response(null, { status: 204 })) + }) await stageRevert('ses_1', 'msg_1') - expect(lastRequest().url).toBe('http://localhost/api/opencode/api/session/ses_1/revert/stage') - expect(lastRequest().init.method).toBe('POST') - expect(lastRequest().init.body).toBe(JSON.stringify({ messageID: 'msg_1' })) - - fetchMock.mockResolvedValue(new Response(null, { status: 204 })) + const stageRequests = fetchMock.mock.calls.map(([input, init]) => ({ + url: String(input), + init: init as RequestInit, + })) + expect(stageRequests.map((request) => request.url)).toEqual([ + 'http://localhost/api/opencode/api/session/ses_1/interrupt', + 'http://localhost/api/opencode/api/experimental/session/ses_1/wait', + 'http://localhost/api/opencode/api/session/ses_1/revert/stage', + ]) + expect(stageRequests.map((request) => request.init.method)).toEqual(['POST', 'POST', 'POST']) + expect(stageRequests[2].init.body).toBe(JSON.stringify({ messageID: 'msg_1' })) await commitRevert('ses_1') expect(lastRequest().url).toBe('http://localhost/api/opencode/api/session/ses_1/revert/commit') diff --git a/frontend/src/api/opencode.ts b/frontend/src/api/opencode.ts index 96528a3b9..226939824 100644 --- a/frontend/src/api/opencode.ts +++ b/frontend/src/api/opencode.ts @@ -241,6 +241,8 @@ export async function removeShell(id: string, directory: string): Promise } export async function stageRevert(sessionID: string, messageID: string): Promise { + await interruptSession(sessionID) + await callOpenCode((api) => api.session.wait({ sessionID })) return callOpenCode((api) => api.session.revert.stage({ sessionID, messageID })) } diff --git a/frontend/src/components/session/SessionPickerDialog.test.tsx b/frontend/src/components/session/SessionPickerDialog.test.tsx index 86a248a49..747b29aad 100644 --- a/frontend/src/components/session/SessionPickerDialog.test.tsx +++ b/frontend/src/components/session/SessionPickerDialog.test.tsx @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useState } from 'react' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' import userEvent from '@testing-library/user-event' import type { Repo, Session } from '@/api/types' import { stubMatchMedia } from '@/test/test-utils' @@ -88,18 +88,18 @@ function repo(id: number, name: string, fullPath: string): Repo { function renderPicker(overrides: Partial> = {}) { const onOpenChange = vi.fn() const onSelectSession = vi.fn() - const onActiveSessionDeleted = vi.fn() + const onDeleteActiveSession = vi.fn() render( , ) - return { onOpenChange, onSelectSession, onActiveSessionDeleted } + return { onOpenChange, onSelectSession, onDeleteActiveSession } } const combobox = () => screen.getByRole('combobox') @@ -225,7 +225,7 @@ describe('SessionPickerDialog', () => { onOpenChange={setOpen} currentRepo={mocks.repos[0]} onSelectSession={vi.fn()} - onActiveSessionDeleted={vi.fn()} + onDeleteActiveSession={vi.fn()} /> ) @@ -245,7 +245,7 @@ describe('SessionPickerDialog', () => { session('ses_1', 'First', '/w/a', 3000), session('ses_2', 'Second', '/w/a', 2000), ] - renderPicker() + const { onDeleteActiveSession } = renderPicker() fireEvent.keyDown(combobox(), { key: 'd', ctrlKey: true }) expect(screen.getByText('Press Ctrl+D again to confirm')).toBeInTheDocument() @@ -259,20 +259,32 @@ describe('SessionPickerDialog', () => { fireEvent.keyDown(combobox(), { key: 'd', ctrlKey: true }) expect(mocks.deleteSessionMock).toHaveBeenCalledWith({ id: 'ses_2', directory: '/w/a' }) + expect(onDeleteActiveSession).not.toHaveBeenCalled() await waitFor(() => expect(screen.queryByText('Press Ctrl+D again to confirm')).not.toBeInTheDocument(), ) }) - it('notifies when the active session is deleted', async () => { + it('notifies when the active session is deleted before the delete completes', async () => { mocks.sessionsData = [session('ses_active', 'Active', '/w/a', 3000)] - const { onActiveSessionDeleted } = renderPicker({ activeSessionID: 'ses_active' }) + let resolveDelete: () => void = () => undefined + mocks.deleteSessionMock.mockReturnValue( + new Promise((resolve) => { + resolveDelete = resolve + }), + ) + const { onDeleteActiveSession } = renderPicker({ activeSessionID: 'ses_active' }) fireEvent.keyDown(combobox(), { key: 'd', ctrlKey: true }) fireEvent.keyDown(combobox(), { key: 'd', ctrlKey: true }) - await waitFor(() => expect(onActiveSessionDeleted).toHaveBeenCalled()) + expect(mocks.deleteSessionMock).toHaveBeenCalledWith({ id: 'ses_active', directory: '/w/a' }) + await waitFor(() => expect(onDeleteActiveSession).toHaveBeenCalled()) + + await act(async () => { + resolveDelete() + }) }) it('moves the cursor when a mouse pointer moves over a row', () => { @@ -331,7 +343,7 @@ describe('SessionPickerDialog', () => { currentRepo={mocks.repos[0]} activeSessionID="ses_active" onSelectSession={vi.fn()} - onActiveSessionDeleted={vi.fn()} + onDeleteActiveSession={vi.fn()} /> ) diff --git a/frontend/src/components/session/SessionPickerDialog.tsx b/frontend/src/components/session/SessionPickerDialog.tsx index 37fb490c3..7994aa190 100644 --- a/frontend/src/components/session/SessionPickerDialog.tsx +++ b/frontend/src/components/session/SessionPickerDialog.tsx @@ -36,7 +36,7 @@ interface SessionPickerDialogProps { currentRepo: Repo | undefined activeSessionID?: string onSelectSession: (session: Session, repo: Repo) => void - onActiveSessionDeleted: () => void + onDeleteActiveSession: () => void } interface SessionPickerRow { @@ -227,7 +227,7 @@ interface SessionPickerContentProps { currentRepo: Repo | undefined activeSessionID?: string onSelectSession: (session: Session, repo: Repo) => void - onActiveSessionDeleted: () => void + onDeleteActiveSession: () => void searchInputRef: React.RefObject hasFinePointer: boolean } @@ -237,7 +237,7 @@ function SessionPickerContent({ currentRepo, activeSessionID, onSelectSession, - onActiveSessionDeleted, + onDeleteActiveSession, searchInputRef, hasFinePointer, }: SessionPickerContentProps) { @@ -349,14 +349,13 @@ function SessionPickerContent({ ) const performDelete = useCallback( - (session: Session) => - deleteSession + (session: Session) => { + if (session.id === activeSessionID) onDeleteActiveSession() + return deleteSession .mutateAsync({ id: session.id, directory: session.location.directory }) - .then(() => { - if (session.id === activeSessionID) onActiveSessionDeleted() - }) - .catch(() => undefined), - [deleteSession, activeSessionID, onActiveSessionDeleted], + .catch(() => undefined) + }, + [deleteSession, activeSessionID, onDeleteActiveSession], ) const deleteRow = useCallback( @@ -621,7 +620,7 @@ export function SessionPickerDialog({ currentRepo, activeSessionID, onSelectSession, - onActiveSessionDeleted, + onDeleteActiveSession, }: SessionPickerDialogProps) { const searchInputRef = useRef(null) const hasFinePointer = useMediaQuery(FINE_POINTER_MEDIA_QUERY) @@ -646,7 +645,7 @@ export function SessionPickerDialog({ currentRepo={currentRepo} activeSessionID={activeSessionID} onSelectSession={onSelectSession} - onActiveSessionDeleted={onActiveSessionDeleted} + onDeleteActiveSession={onDeleteActiveSession} searchInputRef={searchInputRef} hasFinePointer={hasFinePointer} /> diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx index 9dd33d04a..9c435772c 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -840,7 +840,7 @@ export function SessionDetail() { } setSessionsDialogOpen(false) }} - onActiveSessionDeleted={() => { + onDeleteActiveSession={() => { setSessionsDialogOpen(false) handleCloseSession() }}