From 3f73a5f6ef04157166f9875ecbc264d1b625068d Mon Sep 17 00:00:00 2001 From: weishu Date: Fri, 31 Jul 2026 19:49:11 +0800 Subject: [PATCH] feat(web): collapse session search into the sidebar toolbar row Move the session-list search out of its dedicated full-width row and into the sidebar toolbar, so the sidebar gets one compact header row instead of two stacked rows. Behavior: - Collapsed by default: a search icon sits at the left of the toolbar row, on the same line as the existing tool icons (codex import, refresh, browse, settings, new session). - Clicking the icon expands the input to fill the entire row width (tool icons hide) and focuses the input; when focus leaves the search container the input collapses back to the icon. - Query and date range stay applied while collapsed; the icon shows an indicator dot whenever any filter is active. - The date-range picker stays inside the expanded input. Implementation: - SessionList gains a headerActions prop and renders a single header row: [collapsible search] [spacer] [optional renderHeader plus] [headerActions]. - SessionsPage drops its own toolbar wrapper and passes its five buttons through headerActions instead. - The safe-area top inset moves to the router container wrapping both the error banner and the list, so iOS PWA error text no longer sits beneath the status bar/notch. Focus handling (review follow-ups): - Controls that unmount themselves on click (clear-query X, picker backdrop, range-end selection, picker footer Clear) silently moved focus to , leaving the search expanded but no longer collapsible via blur. Each now returns focus to the input; the picker footer Clear is wired through a dedicated onClear prop. - Header actions can no longer be suppressed by a stale expanded state: they render whenever the search control itself is absent (e.g. the session list empties via SSE), and the expansion resets when the list becomes empty. i18n: add sessions.search.open (en / zh-CN). Tests: adapt existing search/date-filter tests to expand the search before interacting; add regressions for collapse-on-blur query persistence, focus restore after clearing the query and after clearing the date range, and header actions surviving an emptied session list. --- .../SessionList.directory-action.test.tsx | 166 ++++++++++++- .../SessionList.machine-filter.test.tsx | 1 + web/src/components/SessionList.tsx | 221 ++++++++++++------ web/src/lib/locales/en.ts | 1 + web/src/lib/locales/zh-CN.ts | 1 + web/src/router.tsx | 83 ++++--- 6 files changed, 354 insertions(+), 119 deletions(-) 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}