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) ? (