Files
hapi/web/src/components/SessionList.directory-action.test.tsx
T
weishuandGitHub da6f4cc5b0 fix(web): keep session preview fold while searching (#1183)
Searching the session list forced every directory group to expand all
sessions (expanded: isFiltering) and hid the Show more button, so the
user's per-group preview fold was ignored during filtering. Stop
overriding the preview state while filtering and keep the Show more /
Show less control available.

Fixes #1068
2026-07-27 12:57:51 +08:00

381 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
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'
import { ToastProvider } from '@/lib/toast-context'
import { SessionList } from './SessionList'
afterEach(() => {
cleanup()
localStorage.removeItem('hapi-session-preview-limit')
})
function makeSession(overrides: Partial<SessionSummary> & { id: string }): SessionSummary {
return {
active: false,
thinking: false,
activeAt: 0,
updatedAt: 0,
metadata: null,
todoProgress: null,
pendingRequestsCount: 0,
pendingRequestKinds: [],
pendingRequests: [],
backgroundTaskCount: 0,
futureScheduledMessageCount: 0,
nextScheduledAt: null,
model: null,
effort: null,
...overrides
}
}
function renderWithProviders(children: ReactNode) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
}
})
return render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<I18nProvider>
{children}
</I18nProvider>
</ToastProvider>
</QueryClientProvider>
)
}
describe('SessionList directory action', () => {
it('starts a new session with the project machine and directory', () => {
const onNewSessionInDirectory = vi.fn()
const session = makeSession({
id: 'session-1',
updatedAt: Date.now(),
metadata: {
path: '/home/ubuntu',
machineId: 'machine-1',
name: 'Greeting',
flavor: 'codex',
}
})
renderWithProviders(
<SessionList
sessions={[session]}
selectedSessionId={null}
onSelect={vi.fn()}
onNewSession={vi.fn()}
onNewSessionInDirectory={onNewSessionInDirectory}
onRefresh={vi.fn()}
isLoading={false}
renderHeader={false}
api={null}
machineLabelsById={{ 'machine-1': 'Mint' }}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'New session in this directory' }))
expect(onNewSessionInDirectory).toHaveBeenCalledWith({
machineId: 'machine-1',
directory: '/home/ubuntu',
})
})
it('hides the directory action for sessions without path metadata', () => {
renderWithProviders(
<SessionList
sessions={[makeSession({ id: 'session-without-path' })]}
selectedSessionId={null}
onSelect={vi.fn()}
onNewSession={vi.fn()}
onNewSessionInDirectory={vi.fn()}
onRefresh={vi.fn()}
isLoading={false}
renderHeader={false}
api={null}
/>
)
expect(screen.queryByRole('button', { name: 'New session in this directory' })).toBeNull()
})
})
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' }))
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` })
expect(emptyDate).toHaveClass('text-[var(--app-hint)]')
expect(activeDate).toHaveClass('text-[var(--app-fg)]')
expect(activeDate).toHaveAttribute('title', `${new Date(2026, 6, 18).toLocaleDateString()}, has session activity`)
fireEvent.click(emptyDate)
fireEvent.click(activeDate)
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()}, has session activity` }))
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],
['closed', false]
] as const)('offers conversation export for a %s session', (_label, active) => {
const session = makeSession({
id: `session-${active ? 'running' : 'closed'}`,
active,
updatedAt: Date.now(),
metadata: {
path: '/home/ubuntu',
machineId: 'machine-1',
name: active ? 'Running session' : 'Closed session',
flavor: 'codex'
}
})
renderWithProviders(
<SessionList
sessions={[session]}
selectedSessionId={null}
onSelect={vi.fn()}
onNewSession={vi.fn()}
onRefresh={vi.fn()}
isLoading={false}
renderHeader={false}
api={null}
/>
)
fireEvent.contextMenu(screen.getByRole('button', { name: new RegExp(active ? 'Running session' : 'Closed session') }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Export conversation' }))
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Export conversation' })).toBeInTheDocument()
})
})
describe('SessionList collapse behavior', () => {
function renderSessionList(sessions: SessionSummary[], selectedSessionId: string | null = 'session-running') {
return (
<QueryClientProvider client={new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
}
})}>
<I18nProvider>
<SessionList
sessions={sessions}
selectedSessionId={selectedSessionId}
onSelect={vi.fn()}
onNewSession={vi.fn()}
onRefresh={vi.fn()}
isLoading={false}
renderHeader={false}
api={null}
/>
</I18nProvider>
</QueryClientProvider>
)
}
function getProjectPanel(): Element {
const header = screen.getByTitle('/work/hapi')
const panel = header.nextElementSibling
if (!panel) {
throw new Error('Expected project collapse panel')
}
return panel
}
it('keeps a selected running path collapsed across live session-list refreshes', async () => {
const baseSessions = [
makeSession({
id: 'session-running',
active: true,
thinking: true,
pendingRequestsCount: 1,
updatedAt: 100,
metadata: { path: '/work/hapi', name: 'Running task', flavor: 'codex' },
}),
makeSession({
id: 'session-old',
updatedAt: 50,
metadata: { path: '/work/hapi', name: 'Older task', flavor: 'codex' },
})
]
const { rerender } = render(renderSessionList(baseSessions))
expect(getProjectPanel().getAttribute('data-open')).toBe('true')
fireEvent.click(screen.getByTitle('/work/hapi'))
expect(getProjectPanel().getAttribute('data-open')).toBeNull()
rerender(renderSessionList([
{
...baseSessions[0]!,
pendingRequestsCount: 2,
updatedAt: 200,
},
baseSessions[1]!
]))
await waitFor(() => {
expect(getProjectPanel().getAttribute('data-open')).toBeNull()
})
})
it('auto-expands the path again when the selected session changes', async () => {
const sessions = [
makeSession({
id: 'session-running',
active: true,
thinking: true,
updatedAt: 100,
metadata: { path: '/work/hapi', name: 'Running task', flavor: 'codex' },
}),
makeSession({
id: 'session-next',
updatedAt: 90,
metadata: { path: '/work/hapi', name: 'Next task', flavor: 'codex' },
})
]
const { rerender } = render(renderSessionList(sessions))
fireEvent.click(screen.getByTitle('/work/hapi'))
expect(getProjectPanel().getAttribute('data-open')).toBeNull()
rerender(renderSessionList(sessions, 'session-next'))
await waitFor(() => {
expect(getProjectPanel().getAttribute('data-open')).toBe('true')
})
})
it('keeps the previous selected path open when selection moves', async () => {
const sessions = [
makeSession({
id: 'session-first',
updatedAt: 100,
metadata: { path: '/work/first', name: 'First task', flavor: 'codex' },
}),
makeSession({
id: 'session-second',
updatedAt: 90,
metadata: { path: '/work/second', name: 'Second task', flavor: 'codex' },
})
]
const { rerender } = render(renderSessionList(sessions, 'session-first'))
const firstPanel = screen.getByTitle('/work/first').nextElementSibling
expect(firstPanel?.getAttribute('data-open')).toBe('true')
rerender(renderSessionList(sessions, 'session-second'))
await waitFor(() => {
expect(firstPanel?.getAttribute('data-open')).toBe('true')
})
})
it('keeps the configured session preview fold while searching', () => {
localStorage.setItem('hapi-session-preview-limit', '2')
const sessions = Array.from({ length: 4 }, (_, index) => makeSession({
id: `matching-${index + 1}`,
updatedAt: 100 - index,
metadata: {
path: '/work/hapi',
name: `Matching task ${index + 1}`,
flavor: 'codex',
},
}))
render(renderSessionList(sessions, null))
fireEvent.change(screen.getByPlaceholderText('Search sessions…'), {
target: { value: 'Matching task' },
})
expect(screen.getByRole('button', { name: /Matching task 1/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Matching task 2/ })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Matching task 3/ })).toBeNull()
expect(screen.queryByRole('button', { name: /Matching task 4/ })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Show 2 more' }))
expect(screen.getByRole('button', { name: /Matching task 3/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Matching task 4/ })).toBeInTheDocument()
})
})