feat(web): filter sessions by last activity (#1083)

* feat(web): filter sessions by last activity

* test(web): make session date filter tests deterministic
This commit is contained in:
Ananovo
2026-07-19 14:15:24 +08:00
committed by GitHub
parent 64834467e3
commit 2623a51b0b
5 changed files with 338 additions and 37 deletions
@@ -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(
<SessionList
sessions={[recent, old]}
selectedSessionId={null}
onSelect={vi.fn()}
onNewSession={vi.fn()}
onRefresh={vi.fn()}
isLoading={false}
renderHeader={false}
api={null}
/>
)
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(
<SessionList
sessions={[session]}
selectedSessionId={null}
onSelect={vi.fn()}
onNewSession={vi.fn()}
onRefresh={vi.fn()}
isLoading={false}
renderHeader={false}
api={null}
/>
)
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],
+19
View File
@@ -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({
+225 -34
View File
@@ -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 (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={props.className}>
<rect x="3" y="5" width="18" height="16" rx="2" />
<path d="M16 3v4M8 3v4M3 10h18" />
</svg>
)
}
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 (
<div className="absolute right-0 top-full z-30 mt-2 w-72 rounded-xl border border-[var(--app-border)] bg-[var(--app-bg)] p-3 shadow-xl">
<div className="mb-2 flex items-center justify-between">
<button
type="button"
onClick={() => setVisibleMonth(new Date(visibleMonth.getFullYear(), visibleMonth.getMonth() - 1, 1))}
className="rounded-lg p-1.5 text-[var(--app-hint)] hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)]"
aria-label={t('sessions.timeFilter.previousMonth')}
>
<span aria-hidden="true"></span>
</button>
<div className="text-sm font-medium">
{visibleMonth.toLocaleDateString(undefined, { year: 'numeric', month: 'long' })}
</div>
<button
type="button"
onClick={() => setVisibleMonth(new Date(visibleMonth.getFullYear(), visibleMonth.getMonth() + 1, 1))}
className="rounded-lg p-1.5 text-[var(--app-hint)] hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)]"
aria-label={t('sessions.timeFilter.nextMonth')}
>
<span aria-hidden="true"></span>
</button>
</div>
<div className="mb-1 grid grid-cols-7 text-center text-[10px] text-[var(--app-hint)]">
{weekdays.map((weekday, index) => <div key={`${weekday}-${index}`} className="py-1">{weekday}</div>)}
</div>
<div className="grid grid-cols-7 gap-0.5">
{Array.from({ length: firstWeekday }, (_, index) => <div key={`blank-${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 (
<button
key={value}
type="button"
onClick={() => selectDate(value)}
aria-label={date.toLocaleDateString()}
className={cn(
'h-8 rounded-lg text-xs transition-colors',
isEndpoint && 'bg-[var(--app-link)] text-white',
isInRange && 'bg-[var(--app-link)]/15 text-[var(--app-link)]',
!isEndpoint && !isInRange && 'hover:bg-[var(--app-subtle-bg)]'
)}
>
{index + 1}
</button>
)
})}
</div>
<div className="mt-2 flex items-center justify-between border-t border-[var(--app-divider)] pt-2 text-xs">
<span className="text-[var(--app-hint)]">
{!props.start
? t('sessions.timeFilter.pickStart')
: !props.end
? t('sessions.timeFilter.pickEnd')
: `${props.start} ${props.end}`}
</span>
{props.start ? (
<button type="button" onClick={() => props.onChange('', '')} className="text-[var(--app-link)]">
{t('sessions.timeFilter.clear')}
</button>
) : null}
</div>
</div>
)
}
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 (
<div className="relative px-3 pb-2">
<div className="pointer-events-none absolute inset-y-0 left-5 flex items-center pb-2 text-[var(--app-hint)]">
<SearchIcon className="h-3.5 w-3.5" />
<div className="px-3 pb-2">
<div className="flex items-center gap-2">
<div className="relative min-w-0 flex-1">
<div className="pointer-events-none absolute inset-y-0 left-2 flex items-center text-[var(--app-hint)]">
<SearchIcon className="h-3.5 w-3.5" />
</div>
<input
type="search"
value={props.value}
onChange={(event) => 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 ? (
<button
type="button"
onClick={() => props.onChange('')}
className="absolute inset-y-0 right-2 flex items-center rounded p-0.5 text-[var(--app-hint)] hover:text-[var(--app-fg)]"
title={t('sessions.search.clear')}
>
<XIcon className="h-3.5 w-3.5" />
</button>
) : null}
</div>
<div className="relative shrink-0">
<button
type="button"
onClick={() => setDatePickerOpen(open => !open)}
className={cn(
'relative rounded-lg p-2 transition-colors hover:bg-[var(--app-subtle-bg)]',
hasDateRange ? 'text-[var(--app-link)]' : 'text-[var(--app-hint)]'
)}
title={hasDateRange ? `${props.customStart} ${props.customEnd}` : t('sessions.timeFilter.label')}
aria-label={t('sessions.timeFilter.label')}
aria-expanded={datePickerOpen}
>
<CalendarIcon className="h-5 w-5" />
{hasDateRange ? <span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-[var(--app-link)]" /> : null}
</button>
{datePickerOpen ? (
<>
<button type="button" aria-label={t('sessions.timeFilter.close')} className="fixed inset-0 z-20 cursor-default" onClick={() => setDatePickerOpen(false)} />
<SessionDateRangePicker
start={props.customStart}
end={props.customEnd}
onChange={props.onDateRangeChange}
onClose={() => setDatePickerOpen(false)}
/>
</>
) : null}
</div>
</div>
<input
type="search"
value={props.value}
onChange={(event) => 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 ? (
<button
type="button"
onClick={() => props.onChange('')}
className="absolute inset-y-0 right-5 flex items-center pb-2 rounded p-0.5 text-[var(--app-hint)] hover:text-[var(--app-fg)]"
title={t('sessions.search.clear')}
>
<XIcon className="h-3.5 w-3.5" />
</button>
) : null}
</div>
)
}
@@ -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<string | null>(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 ? (
<div className="flex items-center justify-between px-3 py-1">
<div className="text-xs text-[var(--app-hint)]">
{isSearching
{isFiltering
? t('sessions.search.count', { n: visibleSessions.length, total: allSessions.length })
: t('sessions.count', { n: allSessions.length, m: allGroups.length })}
</div>
@@ -1051,7 +1233,16 @@ export function SessionList(props: {
) : null}
{props.sessions.length > 0 ? (
<SessionListSearch value={searchQuery} onChange={setSearchQuery} />
<SessionListSearch
value={searchQuery}
onChange={setSearchQuery}
customStart={customStart}
customEnd={customEnd}
onDateRangeChange={(start, end) => {
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 ? (
<div className="px-4 py-8 text-center text-sm text-[var(--app-hint)]">
{t('sessions.search.noResults')}
</div>
@@ -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) ? (
<button
type="button"
onClick={() => hiddenSessionCount > 0
+8 -1
View File
@@ -52,7 +52,14 @@ export default {
'sessions.search.placeholder': 'Search sessions…',
'sessions.search.clear': 'Clear search',
'sessions.search.count': '{n} of {total} sessions',
'sessions.search.noResults': 'No sessions match your search.',
'sessions.search.noResults': 'No sessions match your filters.',
'sessions.timeFilter.label': 'Filter sessions by last activity',
'sessions.timeFilter.pickStart': 'Select start date',
'sessions.timeFilter.pickEnd': 'Select end date',
'sessions.timeFilter.clear': 'Clear',
'sessions.timeFilter.close': 'Close date picker',
'sessions.timeFilter.previousMonth': 'Previous month',
'sessions.timeFilter.nextMonth': 'Next month',
'sessions.group.showMore': 'Show {n} more',
'sessions.group.showLess': 'Show less',
'sessions.group.new': 'New session in this directory',
+8 -1
View File
@@ -52,7 +52,14 @@ export default {
'sessions.search.placeholder': '搜索会话…',
'sessions.search.clear': '清除搜索',
'sessions.search.count': '{n} / {total} 个会话',
'sessions.search.noResults': '没有匹配的会话。',
'sessions.search.noResults': '没有符合筛选条件的会话。',
'sessions.timeFilter.label': '按最后活动时间筛选会话',
'sessions.timeFilter.pickStart': '选择开始日期',
'sessions.timeFilter.pickEnd': '选择结束日期',
'sessions.timeFilter.clear': '清除',
'sessions.timeFilter.close': '关闭日期选择器',
'sessions.timeFilter.previousMonth': '上个月',
'sessions.timeFilter.nextMonth': '下个月',
'sessions.group.showMore': '再显示 {n} 个',
'sessions.group.showLess': '收起',
'sessions.group.new': '在此目录新建会话',