diff --git a/web/src/components/SessionList.directory-action.test.tsx b/web/src/components/SessionList.directory-action.test.tsx index 3cb60c7d..9633c583 100644 --- a/web/src/components/SessionList.directory-action.test.tsx +++ b/web/src/components/SessionList.directory-action.test.tsx @@ -120,9 +120,11 @@ describe('SessionList directory action', () => { const listContent = projectHeader.parentElement?.parentElement expect(listContent).not.toHaveClass('pt-1') + fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) const searchInput = screen.getByPlaceholderText(/Search sessions/) - const searchWrapper = searchInput.parentElement?.parentElement - expect(searchWrapper).toHaveClass('pb-1') + const headerRow = searchInput.parentElement?.parentElement + expect(headerRow).toHaveClass('px-2') + expect(headerRow).toHaveClass('py-1') }) it('hides the directory action for sessions without path metadata', () => { @@ -182,6 +184,7 @@ describe('SessionList time filter', () => { expect(screen.getByRole('button', { name: /Recent session/ })).toBeInTheDocument() expect(screen.getByRole('button', { name: /Old session/ })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) fireEvent.click(screen.getByRole('button', { name: 'Filter sessions by last activity' })) const emptyDate = screen.getByRole('button', { name: new Date(2026, 6, 17).toLocaleDateString() }) const activeDate = screen.getByRole('button', { name: `${new Date(2026, 6, 18).toLocaleDateString()}, has session activity` }) @@ -215,6 +218,7 @@ describe('SessionList time filter', () => { /> ) + fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) fireEvent.click(screen.getByRole('button', { name: 'Filter sessions by last activity' })) const today = screen.getByRole('button', { name: new Date(2026, 6, 18).toLocaleDateString() }) const anotherDay = screen.getByRole('button', { name: new Date(2026, 6, 17).toLocaleDateString() }) @@ -244,6 +248,7 @@ describe('SessionList time filter', () => { /> ) + fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) const filterButton = screen.getByRole('button', { name: 'Filter sessions by last activity' }) fireEvent.click(filterButton) const startDate = screen.getByRole('button', { name: new Date(2026, 6, 1).toLocaleDateString() }) @@ -256,6 +261,41 @@ describe('SessionList time filter', () => { expect(filterButton).toHaveAttribute('aria-expanded', 'false') expect(filterButton).toHaveAttribute('title', '2026-07-01 – 2026-07-18') }) + + it('returns focus to the search input after clearing the date range', () => { + const session = makeSession({ + id: 'session-1', + updatedAt: Date.now(), + metadata: { path: '/work/hapi', name: 'Session' } + }) + + renderWithProviders( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) + const input = screen.getByPlaceholderText('Search sessions…') + const filterButton = screen.getByRole('button', { name: 'Filter sessions by last activity' }) + fireEvent.click(filterButton) + fireEvent.click(screen.getByRole('button', { name: new Date(2026, 6, 1).toLocaleDateString() })) + fireEvent.click(screen.getByRole('button', { name: `${new Date(2026, 6, 18).toLocaleDateString()}, has session activity` })) + + // The footer Clear button unmounts with the range; focus must not drop to body. + fireEvent.click(filterButton) + fireEvent.click(screen.getByRole('button', { name: 'Clear' })) + + expect(input).toHaveFocus() + expect(filterButton).toHaveAttribute('title', 'Filter sessions by last activity') + }) }) describe('SessionList action menu parity', () => { @@ -432,6 +472,7 @@ describe('SessionList collapse behavior', () => { })) render(renderSessionList(sessions, null)) + fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) fireEvent.change(screen.getByPlaceholderText('Search sessions…'), { target: { value: 'Matching task' }, }) @@ -547,3 +588,124 @@ describe('SessionList collapse behavior', () => { expect(screen.getByRole('button', { name: 'Expand 1' })).toBeInTheDocument() }) }) + +describe('SessionList search toggle', () => { + it('expands on icon click and keeps filtering after collapsing on blur', () => { + const sessions = [ + makeSession({ + id: 'session-match', + updatedAt: 100, + metadata: { path: '/work/hapi', name: 'Matching task', flavor: 'codex' }, + }), + makeSession({ + id: 'session-other', + updatedAt: 90, + metadata: { path: '/work/hapi', name: 'Other task', flavor: 'codex' }, + }), + ] + + renderWithProviders( + + ) + + // Collapsed by default: only the toggle icon is rendered. + expect(screen.queryByPlaceholderText('Search sessions…')).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) + const input = screen.getByPlaceholderText('Search sessions…') + expect(input).toHaveFocus() + + fireEvent.change(input, { target: { value: 'Matching' } }) + expect(screen.getByRole('button', { name: /Matching task/ })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Other task/ })).toBeNull() + + // Blur collapses back to the icon; the query stays applied. + fireEvent.blur(input) + expect(screen.queryByPlaceholderText('Search sessions…')).toBeNull() + expect(screen.getByRole('button', { name: 'Search sessions' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Matching task/ })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Other task/ })).toBeNull() + }) + + it('stays expanded with focus on the input after clearing the query', () => { + renderWithProviders( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) + const input = screen.getByPlaceholderText('Search sessions…') + fireEvent.change(input, { target: { value: 'Task' } }) + + // The clear button unmounts itself; focus must return to the input so a + // later outside click still collapses the search via the wrapper blur. + fireEvent.click(screen.getByRole('button', { name: 'Clear search' })) + + expect(input).toHaveFocus() + expect(input).toHaveValue('') + expect(screen.getByPlaceholderText('Search sessions…')).toBeInTheDocument() + }) + + it('keeps header actions visible when sessions become empty while search is expanded', () => { + const renderList = (sessions: SessionSummary[]) => ( + + + + Refresh} + api={null} + /> + + + + ) + const { rerender } = render(renderList([ + makeSession({ + id: 'session-1', + updatedAt: 100, + metadata: { path: '/work/hapi', name: 'Task', flavor: 'codex' }, + }), + ])) + + fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) + expect(screen.queryByRole('button', { name: 'Refresh' })).toBeNull() + + rerender(renderList([])) + + expect(screen.getByRole('button', { name: 'Refresh' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Search sessions' })).toBeNull() + }) +}) diff --git a/web/src/components/SessionList.machine-filter.test.tsx b/web/src/components/SessionList.machine-filter.test.tsx index 4ab63ec1..da2bccb8 100644 --- a/web/src/components/SessionList.machine-filter.test.tsx +++ b/web/src/components/SessionList.machine-filter.test.tsx @@ -139,6 +139,7 @@ describe('SessionList machine filter', () => { }) ]) + fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) fireEvent.change(screen.getByPlaceholderText('Search sessions…'), { target: { value: 'alpha' } }) fireEvent.click(screen.getByRole('button', { name: /Teemo \(1\)/ })) diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index eb790e61..28645f7e 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -537,6 +537,7 @@ function SessionDateRangePicker(props: { end: string sessionActivityDates: ReadonlySet onChange: (start: string, end: string) => void + onClear: () => void onClose: () => void }) { const { t } = useTranslation() @@ -628,7 +629,7 @@ function SessionDateRangePicker(props: { : `${props.start} – ${props.end}`} {props.start ? ( - ) : null} @@ -644,61 +645,112 @@ function SessionListSearch(props: { customEnd: string sessionActivityDates: ReadonlySet onDateRangeChange: (start: string, end: string) => void + expanded: boolean + onExpandedChange: (expanded: boolean) => void }) { const { t } = useTranslation() const [datePickerOpen, setDatePickerOpen] = useState(false) + const inputRef = useRef(null) const hasDateRange = Boolean(props.customStart && props.customEnd) + const hasActiveFilters = props.value.length > 0 || hasDateRange + + useEffect(() => { + if (props.expanded) { + inputRef.current?.focus() + } else { + setDatePickerOpen(false) + } + }, [props.expanded]) + + if (!props.expanded) { + return ( + + ) + } + return ( -
-
-
- -
- props.onChange(event.target.value)} - placeholder={t('sessions.search.placeholder')} - className="w-full appearance-none rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] py-1.5 pl-8 pr-16 text-sm text-[var(--app-fg)] outline-none transition-colors placeholder:text-[var(--app-hint)] focus:border-[var(--app-link)] [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden" - /> - {props.value ? ( - +
{ + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + props.onExpandedChange(false) + } + }} + > +
+ +
+ props.onChange(event.target.value)} + placeholder={t('sessions.search.placeholder')} + className="w-full appearance-none rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] py-1.5 pl-8 pr-16 text-sm text-[var(--app-fg)] outline-none transition-colors placeholder:text-[var(--app-hint)] focus:border-[var(--app-link)] [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden" + /> + {props.value ? ( + + ) : null} +
+ + {datePickerOpen ? ( + <> + - {datePickerOpen ? ( - <> -
) @@ -896,6 +948,7 @@ export function SessionList(props: { onRefresh: () => void isLoading: boolean renderHeader?: boolean + headerActions?: React.ReactNode api: ApiClient | null machineLabelsById?: Record machinesById?: Record @@ -909,6 +962,7 @@ export function SessionList(props: { const { machineFilter, setMachineFilter } = useSessionListMachineFilter() const showDetailedStatus = sessionListStatusMode === 'detailed' const [searchQuery, setSearchQuery] = useState('') + const [searchExpanded, setSearchExpanded] = useState(false) const [customStart, setCustomStart] = useState('') const [customEnd, setCustomEnd] = useState('') const [, setCodexImportedSessionsVersion] = useState(0) @@ -1121,36 +1175,55 @@ export function SessionList(props: { }) }, [allGroups]) + // The search control unmounts when the list empties; reset the expansion so + // it cannot suppress header actions (or re-expand on its own when sessions + // return) while no search control is rendered. + const showSearch = props.sessions.length > 0 + useEffect(() => { + if (!showSearch) setSearchExpanded(false) + }, [showSearch]) + + const showHeaderRow = showSearch || renderHeader || Boolean(props.headerActions) + return (
- {renderHeader ? ( -
- + {showHeaderRow ? ( +
+ {showSearch ? ( + { + setCustomStart(start) + setCustomEnd(end) + }} + expanded={searchExpanded} + onExpandedChange={setSearchExpanded} + /> + ) : null} + {!(showSearch && searchExpanded) ? ( + <> +
+ {renderHeader ? ( + + ) : null} + {props.headerActions} + + ) : null}
) : null} - {props.sessions.length > 0 ? ( - { - setCustomStart(start) - setCustomEnd(end) - }} - /> - ) : null} - {props.sessions.length === 0 && ( -
-
-
- - - - -
-
-
- -
+
{error ? (
{error}
@@ -292,6 +250,45 @@ function SessionsPage() { onRefresh={handleRefresh} isLoading={isLoading} renderHeader={false} + headerActions={( +
+ + + + +
+ )} api={api} machineLabelsById={machineLabelsById} machinesById={machinesById}