From 2623a51b0bfc1e0a669a567b10a07e5e989468e3 Mon Sep 17 00:00:00 2001 From: Ananovo Date: Sun, 19 Jul 2026 14:15:24 +0800 Subject: [PATCH] feat(web): filter sessions by last activity (#1083) * feat(web): filter sessions by last activity * test(web): make session date filter tests deterministic --- .../SessionList.directory-action.test.tsx | 79 +++++- web/src/components/SessionList.test.ts | 19 ++ web/src/components/SessionList.tsx | 259 +++++++++++++++--- web/src/lib/locales/en.ts | 9 +- web/src/lib/locales/zh-CN.ts | 9 +- 5 files changed, 338 insertions(+), 37 deletions(-) diff --git a/web/src/components/SessionList.directory-action.test.tsx b/web/src/components/SessionList.directory-action.test.tsx index 0a572966..cc5be45f 100644 --- a/web/src/components/SessionList.directory-action.test.tsx +++ b/web/src/components/SessionList.directory-action.test.tsx @@ -1,6 +1,6 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ReactNode } from 'react' import type { SessionSummary } from '@/types/api' import { I18nProvider } from '@/lib/i18n-context' @@ -104,6 +104,83 @@ describe('SessionList directory action', () => { }) }) +describe('SessionList time filter', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 6, 18, 12)) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('filters after selecting a start and end date', () => { + const recent = makeSession({ + id: 'recent', + updatedAt: Date.now(), + metadata: { path: '/work/recent', name: 'Recent session' } + }) + const old = makeSession({ + id: 'old', + updatedAt: new Date(2020, 0, 1).getTime(), + metadata: { path: '/work/old', name: 'Old session' } + }) + + renderWithProviders( + + ) + + expect(screen.getByRole('button', { name: /Recent session/ })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Old session/ })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Filter sessions by last activity' })) + fireEvent.click(screen.getByRole('button', { name: new Date(2026, 6, 17).toLocaleDateString() })) + fireEvent.click(screen.getByRole('button', { name: new Date(2026, 6, 18).toLocaleDateString() })) + + expect(screen.getByRole('button', { name: /Recent session/ })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Old session/ })).toBeNull() + }) + + it('uses the first calendar click as start and the second as end', () => { + const session = makeSession({ + id: 'session-1', + updatedAt: Date.now(), + metadata: { path: '/work/hapi', name: 'Session' } + }) + + renderWithProviders( + + ) + + 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() })) + expect(screen.getByText('Select end date')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: new Date(2026, 6, 18).toLocaleDateString() })) + + expect(filterButton).toHaveAttribute('aria-expanded', 'false') + expect(filterButton).toHaveAttribute('title', '2026-07-01 – 2026-07-18') + }) +}) + describe('SessionList action menu parity', () => { it.each([ ['running', true], diff --git a/web/src/components/SessionList.test.ts b/web/src/components/SessionList.test.ts index 06582f6a..f3354ff8 100644 --- a/web/src/components/SessionList.test.ts +++ b/web/src/components/SessionList.test.ts @@ -4,6 +4,7 @@ import { deduplicateSessionsByAgentId, expandSelectedSessionCollapseOverrides, filterActiveSessionsOnly, + getSessionTimeRange, getNextSessionVisibleCount, getSessionDedupKey, getWorktreeSessionLabel, @@ -12,6 +13,7 @@ import { normalizeSearch, prepareSidebarSessions, sessionMatchesQuery, + sessionMatchesTimeRange, shouldShowSessionInSidebar } from './SessionList' @@ -308,6 +310,23 @@ describe('session list search helpers', () => { }) }) +describe('session list time filter helpers', () => { + it('treats the selected end date as inclusive in local time', () => { + const range = getSessionTimeRange('2026-07-01', '2026-07-18') + expect(range).toEqual({ + start: new Date(2026, 6, 1).getTime(), + end: new Date(2026, 6, 19).getTime() + }) + expect(sessionMatchesTimeRange(makeSession({ id: 'inside', updatedAt: new Date(2026, 6, 18, 23, 59).getTime() }), range)).toBe(true) + expect(sessionMatchesTimeRange(makeSession({ id: 'outside', updatedAt: new Date(2026, 6, 19).getTime() }), range)).toBe(false) + }) + + it('does not filter until both dates are selected', () => { + expect(getSessionTimeRange('', '')).toBeNull() + expect(getSessionTimeRange('2026-07-01', '')).toBeNull() + }) +}) + describe('getVisibleSessionPreview', () => { it('keeps selected and pending sessions inside the collapsed preview without promoting them', () => { const sessions = Array.from({ length: 6 }, (_, index) => makeSession({ diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index c296c489..b9fa3081 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -39,6 +39,37 @@ type SessionGroup = { hasActiveSession: boolean } +export type SessionTimeRange = { + start: number | null + end: number | null +} + +function parseLocalDate(value: string): Date | null { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value) + if (!match) return null + const year = Number(match[1]) + const month = Number(match[2]) + const day = Number(match[3]) + const date = new Date(year, month - 1, day) + if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) return null + return date +} + +export function getSessionTimeRange(start: string, end: string): SessionTimeRange | null { + const startDate = parseLocalDate(start) + const endDate = parseLocalDate(end) + if (!startDate || !endDate) return null + if (endDate) endDate.setDate(endDate.getDate() + 1) + return { start: startDate.getTime(), end: endDate.getTime() } +} + +export function sessionMatchesTimeRange(session: SessionSummary, range: SessionTimeRange | null): boolean { + if (!range) return true + if (range.start !== null && session.updatedAt < range.start) return false + if (range.end !== null && session.updatedAt >= range.end) return false + return true +} + function SessionsEmptyState(props: { onNewSession: () => void onBrowse?: () => void @@ -523,33 +554,178 @@ export function getVisibleSessionPreview( return visible } +function CalendarIcon(props: { className?: string }) { + return ( + + + + + ) +} + +function formatDateValue(date: Date): string { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +function SessionDateRangePicker(props: { + start: string + end: string + onChange: (start: string, end: string) => void + onClose: () => void +}) { + const { t } = useTranslation() + const initialDate = parseLocalDate(props.start) ?? new Date() + const [visibleMonth, setVisibleMonth] = useState(() => new Date(initialDate.getFullYear(), initialDate.getMonth(), 1)) + const firstWeekday = new Date(visibleMonth.getFullYear(), visibleMonth.getMonth(), 1).getDay() + const daysInMonth = new Date(visibleMonth.getFullYear(), visibleMonth.getMonth() + 1, 0).getDate() + const weekdays = Array.from({ length: 7 }, (_, day) => ( + new Intl.DateTimeFormat(undefined, { weekday: 'narrow' }).format(new Date(2026, 5, 7 + day)) + )) + + const selectDate = (value: string) => { + if (!props.start || props.end) { + props.onChange(value, '') + return + } + props.onChange(value < props.start ? value : props.start, value < props.start ? props.start : value) + props.onClose() + } + + return ( +
+
+ +
+ {visibleMonth.toLocaleDateString(undefined, { year: 'numeric', month: 'long' })} +
+ +
+
+ {weekdays.map((weekday, index) =>
{weekday}
)} +
+
+ {Array.from({ length: firstWeekday }, (_, index) =>
)} + {Array.from({ length: daysInMonth }, (_, index) => { + const date = new Date(visibleMonth.getFullYear(), visibleMonth.getMonth(), index + 1) + const value = formatDateValue(date) + const isEndpoint = value === props.start || value === props.end + const isInRange = Boolean(props.start && props.end && value > props.start && value < props.end) + return ( + + ) + })} +
+
+ + {!props.start + ? t('sessions.timeFilter.pickStart') + : !props.end + ? t('sessions.timeFilter.pickEnd') + : `${props.start} – ${props.end}`} + + {props.start ? ( + + ) : null} +
+
+ ) +} + function SessionListSearch(props: { value: string onChange: (value: string) => void + customStart: string + customEnd: string + onDateRangeChange: (start: string, end: string) => void }) { const { t } = useTranslation() + const [datePickerOpen, setDatePickerOpen] = useState(false) + const hasDateRange = Boolean(props.customStart && props.customEnd) 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-8 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 ? ( + <> +
- 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-8 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}
) } @@ -836,9 +1012,12 @@ export function SessionList(props: { const { showActiveSessionsOnly } = useShowActiveSessionsOnly() const showDetailedStatus = sessionListStatusMode === 'detailed' const [searchQuery, setSearchQuery] = useState('') + const [customStart, setCustomStart] = useState('') + const [customEnd, setCustomEnd] = useState('') const [, setCodexImportedSessionsVersion] = useState(0) const normalizedQuery = normalizeSearch(searchQuery) - const isSearching = normalizedQuery.length > 0 + const timeRange = getSessionTimeRange(customStart, customEnd) + const isFiltering = normalizedQuery.length > 0 || timeRange !== null useEffect(() => { // 中文注释:监听导入标记变化,让列表在“导入完成”或“用户已在 Hapi 中继续会话”后立即刷新时间文案。 @@ -865,14 +1044,17 @@ export function SessionList(props: { [props.sessions, selectedSessionId, showActiveSessionsOnly] ) const visibleSessions = useMemo( - () => isSearching - ? allSessions.filter(session => sessionMatchesQuery( - session, - normalizedQuery, - resolveMachineLabel(session.metadata?.machineId ?? null) + () => isFiltering + ? allSessions.filter(session => ( + sessionMatchesTimeRange(session, timeRange) + && sessionMatchesQuery( + session, + normalizedQuery, + resolveMachineLabel(session.metadata?.machineId ?? null) + ) )) : allSessions, - [allSessions, isSearching, normalizedQuery, machineLabelsById] // eslint-disable-line react-hooks/exhaustive-deps + [allSessions, isFiltering, normalizedQuery, timeRange?.start, timeRange?.end, machineLabelsById] // eslint-disable-line react-hooks/exhaustive-deps ) const allGroups = useMemo( () => groupSessionsByDirectory(allSessions), @@ -887,7 +1069,7 @@ export function SessionList(props: { ) const autoExpandedSelectedSessionKeyRef = useRef(null) const isGroupCollapsed = (group: SessionGroup): boolean => { - if (isSearching) return false + if (isFiltering) return false const override = collapseOverrides.get(group.key) if (override !== undefined) return override const hasSelectedSession = selectedSessionId @@ -936,7 +1118,7 @@ export function SessionList(props: { return getVisibleSessionPreview( group.sessions, { - expanded: isSearching, + expanded: isFiltering, selectedSessionId, limit: getGroupVisibleCount(group) } @@ -949,7 +1131,7 @@ export function SessionList(props: { ) const isMachineCollapsed = (mg: MachineGroup): boolean => { - if (isSearching) return false + if (isFiltering) return false const key = `machine::${mg.machineId ?? UNKNOWN_MACHINE_ID}` const override = collapseOverrides.get(key) if (override !== undefined) return override @@ -1035,7 +1217,7 @@ export function SessionList(props: { {renderHeader ? (
- {isSearching + {isFiltering ? t('sessions.search.count', { n: visibleSessions.length, total: allSessions.length }) : t('sessions.count', { n: allSessions.length, m: allGroups.length })}
@@ -1051,7 +1233,16 @@ export function SessionList(props: { ) : null} {props.sessions.length > 0 ? ( - + { + setCustomStart(start) + setCustomEnd(end) + }} + /> ) : null} {props.sessions.length === 0 && ( @@ -1061,7 +1252,7 @@ export function SessionList(props: { /> )} - {props.sessions.length > 0 && isSearching && visibleSessions.length === 0 ? ( + {props.sessions.length > 0 && isFiltering && visibleSessions.length === 0 ? (
{t('sessions.search.noResults')}
@@ -1146,7 +1337,7 @@ export function SessionList(props: { showDetailedStatus={showDetailedStatus} /> ))} - {!isSearching && group.sessions.length > sessionPreviewLimit && (hiddenSessionCount > 0 || canCollapseSessions) ? ( + {!isFiltering && group.sessions.length > sessionPreviewLimit && (hiddenSessionCount > 0 || canCollapseSessions) ? (