mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -1,6 +1,6 @@
|
|||||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
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 { ReactNode } from 'react'
|
||||||
import type { SessionSummary } from '@/types/api'
|
import type { SessionSummary } from '@/types/api'
|
||||||
import { I18nProvider } from '@/lib/i18n-context'
|
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', () => {
|
describe('SessionList action menu parity', () => {
|
||||||
it.each([
|
it.each([
|
||||||
['running', true],
|
['running', true],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
deduplicateSessionsByAgentId,
|
deduplicateSessionsByAgentId,
|
||||||
expandSelectedSessionCollapseOverrides,
|
expandSelectedSessionCollapseOverrides,
|
||||||
filterActiveSessionsOnly,
|
filterActiveSessionsOnly,
|
||||||
|
getSessionTimeRange,
|
||||||
getNextSessionVisibleCount,
|
getNextSessionVisibleCount,
|
||||||
getSessionDedupKey,
|
getSessionDedupKey,
|
||||||
getWorktreeSessionLabel,
|
getWorktreeSessionLabel,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
normalizeSearch,
|
normalizeSearch,
|
||||||
prepareSidebarSessions,
|
prepareSidebarSessions,
|
||||||
sessionMatchesQuery,
|
sessionMatchesQuery,
|
||||||
|
sessionMatchesTimeRange,
|
||||||
shouldShowSessionInSidebar
|
shouldShowSessionInSidebar
|
||||||
} from './SessionList'
|
} 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', () => {
|
describe('getVisibleSessionPreview', () => {
|
||||||
it('keeps selected and pending sessions inside the collapsed preview without promoting them', () => {
|
it('keeps selected and pending sessions inside the collapsed preview without promoting them', () => {
|
||||||
const sessions = Array.from({ length: 6 }, (_, index) => makeSession({
|
const sessions = Array.from({ length: 6 }, (_, index) => makeSession({
|
||||||
|
|||||||
@@ -39,6 +39,37 @@ type SessionGroup = {
|
|||||||
hasActiveSession: boolean
|
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: {
|
function SessionsEmptyState(props: {
|
||||||
onNewSession: () => void
|
onNewSession: () => void
|
||||||
onBrowse?: () => void
|
onBrowse?: () => void
|
||||||
@@ -523,33 +554,178 @@ export function getVisibleSessionPreview(
|
|||||||
return visible
|
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: {
|
function SessionListSearch(props: {
|
||||||
value: string
|
value: string
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
|
customStart: string
|
||||||
|
customEnd: string
|
||||||
|
onDateRangeChange: (start: string, end: string) => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const [datePickerOpen, setDatePickerOpen] = useState(false)
|
||||||
|
const hasDateRange = Boolean(props.customStart && props.customEnd)
|
||||||
return (
|
return (
|
||||||
<div className="relative px-3 pb-2">
|
<div className="px-3 pb-2">
|
||||||
<div className="pointer-events-none absolute inset-y-0 left-5 flex items-center pb-2 text-[var(--app-hint)]">
|
<div className="flex items-center gap-2">
|
||||||
<SearchIcon className="h-3.5 w-3.5" />
|
<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>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -836,9 +1012,12 @@ export function SessionList(props: {
|
|||||||
const { showActiveSessionsOnly } = useShowActiveSessionsOnly()
|
const { showActiveSessionsOnly } = useShowActiveSessionsOnly()
|
||||||
const showDetailedStatus = sessionListStatusMode === 'detailed'
|
const showDetailedStatus = sessionListStatusMode === 'detailed'
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [customStart, setCustomStart] = useState('')
|
||||||
|
const [customEnd, setCustomEnd] = useState('')
|
||||||
const [, setCodexImportedSessionsVersion] = useState(0)
|
const [, setCodexImportedSessionsVersion] = useState(0)
|
||||||
const normalizedQuery = normalizeSearch(searchQuery)
|
const normalizedQuery = normalizeSearch(searchQuery)
|
||||||
const isSearching = normalizedQuery.length > 0
|
const timeRange = getSessionTimeRange(customStart, customEnd)
|
||||||
|
const isFiltering = normalizedQuery.length > 0 || timeRange !== null
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 中文注释:监听导入标记变化,让列表在“导入完成”或“用户已在 Hapi 中继续会话”后立即刷新时间文案。
|
// 中文注释:监听导入标记变化,让列表在“导入完成”或“用户已在 Hapi 中继续会话”后立即刷新时间文案。
|
||||||
@@ -865,14 +1044,17 @@ export function SessionList(props: {
|
|||||||
[props.sessions, selectedSessionId, showActiveSessionsOnly]
|
[props.sessions, selectedSessionId, showActiveSessionsOnly]
|
||||||
)
|
)
|
||||||
const visibleSessions = useMemo(
|
const visibleSessions = useMemo(
|
||||||
() => isSearching
|
() => isFiltering
|
||||||
? allSessions.filter(session => sessionMatchesQuery(
|
? allSessions.filter(session => (
|
||||||
session,
|
sessionMatchesTimeRange(session, timeRange)
|
||||||
normalizedQuery,
|
&& sessionMatchesQuery(
|
||||||
resolveMachineLabel(session.metadata?.machineId ?? null)
|
session,
|
||||||
|
normalizedQuery,
|
||||||
|
resolveMachineLabel(session.metadata?.machineId ?? null)
|
||||||
|
)
|
||||||
))
|
))
|
||||||
: allSessions,
|
: 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(
|
const allGroups = useMemo(
|
||||||
() => groupSessionsByDirectory(allSessions),
|
() => groupSessionsByDirectory(allSessions),
|
||||||
@@ -887,7 +1069,7 @@ export function SessionList(props: {
|
|||||||
)
|
)
|
||||||
const autoExpandedSelectedSessionKeyRef = useRef<string | null>(null)
|
const autoExpandedSelectedSessionKeyRef = useRef<string | null>(null)
|
||||||
const isGroupCollapsed = (group: SessionGroup): boolean => {
|
const isGroupCollapsed = (group: SessionGroup): boolean => {
|
||||||
if (isSearching) return false
|
if (isFiltering) return false
|
||||||
const override = collapseOverrides.get(group.key)
|
const override = collapseOverrides.get(group.key)
|
||||||
if (override !== undefined) return override
|
if (override !== undefined) return override
|
||||||
const hasSelectedSession = selectedSessionId
|
const hasSelectedSession = selectedSessionId
|
||||||
@@ -936,7 +1118,7 @@ export function SessionList(props: {
|
|||||||
return getVisibleSessionPreview(
|
return getVisibleSessionPreview(
|
||||||
group.sessions,
|
group.sessions,
|
||||||
{
|
{
|
||||||
expanded: isSearching,
|
expanded: isFiltering,
|
||||||
selectedSessionId,
|
selectedSessionId,
|
||||||
limit: getGroupVisibleCount(group)
|
limit: getGroupVisibleCount(group)
|
||||||
}
|
}
|
||||||
@@ -949,7 +1131,7 @@ export function SessionList(props: {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const isMachineCollapsed = (mg: MachineGroup): boolean => {
|
const isMachineCollapsed = (mg: MachineGroup): boolean => {
|
||||||
if (isSearching) return false
|
if (isFiltering) return false
|
||||||
const key = `machine::${mg.machineId ?? UNKNOWN_MACHINE_ID}`
|
const key = `machine::${mg.machineId ?? UNKNOWN_MACHINE_ID}`
|
||||||
const override = collapseOverrides.get(key)
|
const override = collapseOverrides.get(key)
|
||||||
if (override !== undefined) return override
|
if (override !== undefined) return override
|
||||||
@@ -1035,7 +1217,7 @@ export function SessionList(props: {
|
|||||||
{renderHeader ? (
|
{renderHeader ? (
|
||||||
<div className="flex items-center justify-between px-3 py-1">
|
<div className="flex items-center justify-between px-3 py-1">
|
||||||
<div className="text-xs text-[var(--app-hint)]">
|
<div className="text-xs text-[var(--app-hint)]">
|
||||||
{isSearching
|
{isFiltering
|
||||||
? t('sessions.search.count', { n: visibleSessions.length, total: allSessions.length })
|
? t('sessions.search.count', { n: visibleSessions.length, total: allSessions.length })
|
||||||
: t('sessions.count', { n: allSessions.length, m: allGroups.length })}
|
: t('sessions.count', { n: allSessions.length, m: allGroups.length })}
|
||||||
</div>
|
</div>
|
||||||
@@ -1051,7 +1233,16 @@ export function SessionList(props: {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{props.sessions.length > 0 ? (
|
{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}
|
) : null}
|
||||||
|
|
||||||
{props.sessions.length === 0 && (
|
{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)]">
|
<div className="px-4 py-8 text-center text-sm text-[var(--app-hint)]">
|
||||||
{t('sessions.search.noResults')}
|
{t('sessions.search.noResults')}
|
||||||
</div>
|
</div>
|
||||||
@@ -1146,7 +1337,7 @@ export function SessionList(props: {
|
|||||||
showDetailedStatus={showDetailedStatus}
|
showDetailedStatus={showDetailedStatus}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{!isSearching && group.sessions.length > sessionPreviewLimit && (hiddenSessionCount > 0 || canCollapseSessions) ? (
|
{!isFiltering && group.sessions.length > sessionPreviewLimit && (hiddenSessionCount > 0 || canCollapseSessions) ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => hiddenSessionCount > 0
|
onClick={() => hiddenSessionCount > 0
|
||||||
|
|||||||
@@ -52,7 +52,14 @@ export default {
|
|||||||
'sessions.search.placeholder': 'Search sessions…',
|
'sessions.search.placeholder': 'Search sessions…',
|
||||||
'sessions.search.clear': 'Clear search',
|
'sessions.search.clear': 'Clear search',
|
||||||
'sessions.search.count': '{n} of {total} sessions',
|
'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.showMore': 'Show {n} more',
|
||||||
'sessions.group.showLess': 'Show less',
|
'sessions.group.showLess': 'Show less',
|
||||||
'sessions.group.new': 'New session in this directory',
|
'sessions.group.new': 'New session in this directory',
|
||||||
|
|||||||
@@ -52,7 +52,14 @@ export default {
|
|||||||
'sessions.search.placeholder': '搜索会话…',
|
'sessions.search.placeholder': '搜索会话…',
|
||||||
'sessions.search.clear': '清除搜索',
|
'sessions.search.clear': '清除搜索',
|
||||||
'sessions.search.count': '{n} / {total} 个会话',
|
'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.showMore': '再显示 {n} 个',
|
||||||
'sessions.group.showLess': '收起',
|
'sessions.group.showLess': '收起',
|
||||||
'sessions.group.new': '在此目录新建会话',
|
'sessions.group.new': '在此目录新建会话',
|
||||||
|
|||||||
Reference in New Issue
Block a user