mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
Desktop users can receive new sessions from IM adapters or scheduled tasks while the app stays open. The sidebar now refreshes on mount, visible focus, and a low-frequency visible-only interval, with a manual refresh control and in-flight request dedupe so the fix does not create avoidable polling pressure. Constraint: Sessions can be created outside the desktop process by IM and scheduler entrypoints Rejected: WebSocket push for this patch | broader server contract change than needed for the reported stale list Confidence: high Scope-risk: narrow Directive: Keep session-list refresh visible-only and deduped before lowering intervals or adding more triggers Tested: cd desktop && bunx vitest run src/components/layout/Sidebar.test.tsx src/i18n/index.test.tsx Tested: cd desktop && bun run lint Tested: bun run check:desktop Tested: Browser smoke on isolated desktop backend/frontend with refresh button click Not-tested: Full coverage gate is blocked by unrelated root test port/timeouts; changed lines coverage reported 100% (59/59)
461 lines
15 KiB
TypeScript
461 lines
15 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
|
import '@testing-library/jest-dom'
|
|
|
|
vi.mock('./ProjectFilter', () => ({
|
|
ProjectFilter: () => <div data-testid="project-filter" />,
|
|
}))
|
|
|
|
vi.mock('../../i18n', () => ({
|
|
useTranslation: () => (key: string, params?: Record<string, string | number>) => {
|
|
const translations: Record<string, string> = {
|
|
'sidebar.newSession': 'New Session',
|
|
'sidebar.scheduled': 'Scheduled',
|
|
'sidebar.settings': 'Settings',
|
|
'sidebar.searchPlaceholder': 'Search sessions',
|
|
'sidebar.noSessions': 'No sessions',
|
|
'sidebar.noMatching': 'No matching sessions',
|
|
'sidebar.sessionListFailed': 'Session list failed',
|
|
'sidebar.refreshSessions': 'Refresh sessions',
|
|
'common.retry': 'Retry',
|
|
'common.loading': 'Loading...',
|
|
'common.cancel': 'Cancel',
|
|
'common.delete': 'Delete',
|
|
'common.rename': 'Rename',
|
|
'sidebar.timeGroup.today': 'Today',
|
|
'sidebar.timeGroup.yesterday': 'Yesterday',
|
|
'sidebar.timeGroup.last7days': 'Last 7 Days',
|
|
'sidebar.timeGroup.last30days': 'Last 30 Days',
|
|
'sidebar.timeGroup.older': 'Older',
|
|
'sidebar.missingDir': 'Missing',
|
|
'sidebar.confirmDelete': 'Delete this session? This cannot be undone.',
|
|
'sidebar.batchManage': 'Batch manage',
|
|
'sidebar.batchSelectedCount': '{count} selected',
|
|
'sidebar.batchSelectAll': 'Select all',
|
|
'sidebar.batchDeselectAll': 'Deselect all',
|
|
'sidebar.batchSelectGroup': 'Select {group}',
|
|
'sidebar.batchDeleteSelected': 'Delete selected ({count})',
|
|
'sidebar.batchDeleteConfirm': 'Delete {count} sessions? This cannot be undone.',
|
|
'sidebar.batchDeleteConfirmBody': 'The following sessions will be deleted:',
|
|
'sidebar.batchDeleteMore': '...and {count} more',
|
|
'sidebar.batchExit': 'Cancel batch mode',
|
|
'sidebar.batchDeleteSucceeded': 'Deleted {count} sessions.',
|
|
'sidebar.batchDeleteFailed': '{count} sessions could not be deleted.',
|
|
'sidebar.collapse': 'Collapse sidebar',
|
|
'sidebar.expand': 'Expand sidebar',
|
|
}
|
|
|
|
let text = translations[key] ?? key
|
|
for (const [name, value] of Object.entries(params ?? {})) {
|
|
text = text.replace(new RegExp(`\\{${name}\\}`, 'g'), String(value))
|
|
}
|
|
return text
|
|
},
|
|
}))
|
|
|
|
import { Sidebar } from './Sidebar'
|
|
import { useChatStore } from '../../stores/chatStore'
|
|
import { useSessionStore } from '../../stores/sessionStore'
|
|
import { useTabStore } from '../../stores/tabStore'
|
|
import { useUIStore } from '../../stores/uiStore'
|
|
|
|
describe('Sidebar', () => {
|
|
const connectToSession = vi.fn()
|
|
const disconnectSession = vi.fn()
|
|
const fetchSessions = vi.fn()
|
|
const createSession = vi.fn()
|
|
const deleteSession = vi.fn()
|
|
const deleteSessions = vi.fn()
|
|
const addToast = vi.fn()
|
|
|
|
beforeEach(() => {
|
|
connectToSession.mockReset()
|
|
disconnectSession.mockReset()
|
|
fetchSessions.mockReset()
|
|
createSession.mockReset()
|
|
deleteSession.mockReset()
|
|
deleteSessions.mockReset()
|
|
addToast.mockReset()
|
|
|
|
useTabStore.setState({ tabs: [], activeTabId: null })
|
|
useSessionStore.setState({
|
|
sessions: [],
|
|
activeSessionId: null,
|
|
isLoading: false,
|
|
error: null,
|
|
selectedProjects: [],
|
|
availableProjects: [],
|
|
isBatchMode: false,
|
|
selectedSessionIds: new Set(),
|
|
fetchSessions,
|
|
createSession,
|
|
deleteSession,
|
|
deleteSessions,
|
|
})
|
|
useChatStore.setState({
|
|
connectToSession,
|
|
disconnectSession,
|
|
} as Partial<ReturnType<typeof useChatStore.getState>>)
|
|
useUIStore.setState({
|
|
sidebarOpen: true,
|
|
addToast,
|
|
} as Partial<ReturnType<typeof useUIStore.getState>>)
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
cleanup()
|
|
useTabStore.setState({ tabs: [], activeTabId: null })
|
|
})
|
|
|
|
it('opens a new tab when creating a session from the sidebar', async () => {
|
|
createSession.mockResolvedValue('session-new-1')
|
|
|
|
render(<Sidebar />)
|
|
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByRole('button', { name: 'New Session' }))
|
|
})
|
|
|
|
await waitFor(() => {
|
|
expect(createSession).toHaveBeenCalled()
|
|
expect(connectToSession).toHaveBeenCalledWith('session-new-1')
|
|
})
|
|
|
|
expect(useTabStore.getState().tabs).toEqual([
|
|
{ sessionId: 'session-new-1', title: 'New Session', type: 'session', status: 'idle' },
|
|
])
|
|
expect(useTabStore.getState().activeTabId).toBe('session-new-1')
|
|
expect(screen.getByRole('complementary')).not.toHaveAttribute('data-tauri-drag-region')
|
|
})
|
|
|
|
it('shows a toast when session creation fails', async () => {
|
|
createSession.mockRejectedValue(new Error('boom'))
|
|
|
|
render(<Sidebar />)
|
|
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByRole('button', { name: 'New Session' }))
|
|
})
|
|
|
|
await waitFor(() => {
|
|
expect(addToast).toHaveBeenCalledWith({
|
|
type: 'error',
|
|
message: 'boom',
|
|
})
|
|
})
|
|
|
|
expect(useTabStore.getState().tabs).toEqual([])
|
|
})
|
|
|
|
it('requires confirmation before deleting a session from the sidebar', async () => {
|
|
deleteSession.mockResolvedValue(undefined)
|
|
useSessionStore.setState({
|
|
sessions: [
|
|
{
|
|
id: 'session-1',
|
|
title: 'Open Session',
|
|
createdAt: new Date().toISOString(),
|
|
modifiedAt: new Date().toISOString(),
|
|
messageCount: 1,
|
|
projectPath: '/workspace/project',
|
|
workDir: '/workspace/project',
|
|
workDirExists: true,
|
|
},
|
|
],
|
|
})
|
|
useTabStore.setState({
|
|
tabs: [{ sessionId: 'session-1', title: 'Open Session', type: 'session', status: 'idle' }],
|
|
activeTabId: 'session-1',
|
|
})
|
|
|
|
render(<Sidebar />)
|
|
|
|
fireEvent.contextMenu(screen.getByRole('button', { name: /Open Session/ }))
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
|
|
|
expect(deleteSession).not.toHaveBeenCalled()
|
|
const dialog = screen.getByRole('dialog')
|
|
expect(dialog).toBeInTheDocument()
|
|
expect(screen.getByText('Delete this session? This cannot be undone.')).toBeInTheDocument()
|
|
|
|
await act(async () => {
|
|
fireEvent.click(within(dialog).getByRole('button', { name: 'Delete' }))
|
|
})
|
|
|
|
await waitFor(() => {
|
|
expect(deleteSession).toHaveBeenCalledWith('session-1')
|
|
expect(disconnectSession).toHaveBeenCalledWith('session-1')
|
|
})
|
|
|
|
expect(useTabStore.getState().tabs).toEqual([])
|
|
expect(useTabStore.getState().activeTabId).toBeNull()
|
|
})
|
|
|
|
it('selects and deletes multiple sessions from batch mode', async () => {
|
|
deleteSessions.mockResolvedValue({
|
|
ok: true,
|
|
successes: ['session-1', 'session-2'],
|
|
failures: [],
|
|
})
|
|
const now = new Date().toISOString()
|
|
useSessionStore.setState({
|
|
sessions: [
|
|
{
|
|
id: 'session-1',
|
|
title: 'First Session',
|
|
createdAt: now,
|
|
modifiedAt: now,
|
|
messageCount: 1,
|
|
projectPath: '/workspace/project',
|
|
workDir: '/workspace/project',
|
|
workDirExists: true,
|
|
},
|
|
{
|
|
id: 'session-2',
|
|
title: 'Second Session',
|
|
createdAt: now,
|
|
modifiedAt: now,
|
|
messageCount: 1,
|
|
projectPath: '/workspace/project',
|
|
workDir: '/workspace/project',
|
|
workDirExists: true,
|
|
},
|
|
],
|
|
})
|
|
useTabStore.setState({
|
|
tabs: [
|
|
{ sessionId: 'session-1', title: 'First Session', type: 'session', status: 'idle' },
|
|
{ sessionId: 'session-2', title: 'Second Session', type: 'session', status: 'idle' },
|
|
],
|
|
activeTabId: 'session-1',
|
|
})
|
|
|
|
render(<Sidebar />)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Batch manage' }))
|
|
fireEvent.click(screen.getByRole('button', { name: /First Session/ }))
|
|
fireEvent.click(screen.getByRole('button', { name: /Second Session/ }))
|
|
|
|
expect(screen.getByText('2 selected')).toBeInTheDocument()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Delete selected (2)' }))
|
|
const dialog = screen.getByRole('dialog')
|
|
expect(within(dialog).getByText('Delete 2 sessions? This cannot be undone.')).toBeInTheDocument()
|
|
expect(within(dialog).getByText('First Session')).toBeInTheDocument()
|
|
expect(within(dialog).getByText('Second Session')).toBeInTheDocument()
|
|
|
|
await act(async () => {
|
|
fireEvent.click(within(dialog).getByRole('button', { name: 'Delete' }))
|
|
})
|
|
|
|
await waitFor(() => {
|
|
expect(deleteSessions).toHaveBeenCalledWith(['session-1', 'session-2'])
|
|
expect(disconnectSession).toHaveBeenCalledWith('session-1')
|
|
expect(disconnectSession).toHaveBeenCalledWith('session-2')
|
|
})
|
|
expect(useTabStore.getState().tabs).toEqual([])
|
|
expect(addToast).toHaveBeenCalledWith({
|
|
type: 'success',
|
|
message: 'Deleted 2 sessions.',
|
|
})
|
|
})
|
|
|
|
it('renders batch-selected sessions as separated selected rows', () => {
|
|
const now = new Date().toISOString()
|
|
useSessionStore.setState({
|
|
sessions: [
|
|
{
|
|
id: 'session-1',
|
|
title: 'First Session',
|
|
createdAt: now,
|
|
modifiedAt: now,
|
|
messageCount: 1,
|
|
projectPath: '/workspace/project',
|
|
workDir: '/workspace/project',
|
|
workDirExists: true,
|
|
},
|
|
{
|
|
id: 'session-2',
|
|
title: 'Second Session',
|
|
createdAt: now,
|
|
modifiedAt: now,
|
|
messageCount: 1,
|
|
projectPath: '/workspace/project',
|
|
workDir: '/workspace/project',
|
|
workDirExists: true,
|
|
},
|
|
{
|
|
id: 'session-3',
|
|
title: 'Third Session',
|
|
createdAt: now,
|
|
modifiedAt: now,
|
|
messageCount: 1,
|
|
projectPath: '/workspace/project',
|
|
workDir: '/workspace/project',
|
|
workDirExists: true,
|
|
},
|
|
],
|
|
})
|
|
useTabStore.setState({
|
|
tabs: [{ sessionId: 'session-2', title: 'Second Session', type: 'session', status: 'idle' }],
|
|
activeTabId: 'session-2',
|
|
})
|
|
|
|
render(<Sidebar />)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Batch manage' }))
|
|
fireEvent.click(screen.getByRole('button', { name: /First Session/ }))
|
|
|
|
expect(screen.getByRole('button', { name: /First Session/ }).parentElement).toHaveClass('mb-1.5')
|
|
expect(screen.getByRole('button', { name: /First Session/ })).toHaveClass('sidebar-session-row--selected')
|
|
expect(screen.getByRole('button', { name: /Second Session/ })).toHaveClass('sidebar-session-row--active')
|
|
expect(screen.getByRole('button', { name: /Third Session/ })).toHaveClass('sidebar-session-row--idle')
|
|
})
|
|
|
|
it('collapses into an icon rail and expands back', async () => {
|
|
render(<Sidebar />)
|
|
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' }))
|
|
})
|
|
|
|
expect(useUIStore.getState().sidebarOpen).toBe(false)
|
|
expect(screen.queryByPlaceholderText('Search sessions')).not.toBeInTheDocument()
|
|
expect(screen.getByRole('complementary')).toHaveAttribute('data-state', 'closed')
|
|
expect(screen.getByTestId('sidebar-expand-button')).toHaveClass('sidebar-toggle-button--collapsed')
|
|
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByRole('button', { name: 'Expand sidebar' }))
|
|
})
|
|
|
|
expect(useUIStore.getState().sidebarOpen).toBe(true)
|
|
expect(screen.getByPlaceholderText('Search sessions')).toBeInTheDocument()
|
|
expect(screen.getByRole('complementary')).toHaveAttribute('data-state', 'open')
|
|
})
|
|
|
|
it('keeps the project filter section overflow visible for dropdown menus', () => {
|
|
render(<Sidebar />)
|
|
|
|
expect(screen.getByTestId('sidebar-project-filter-section')).toHaveStyle({ overflow: 'visible' })
|
|
expect(screen.getByTestId('sidebar-project-filter-section')).toHaveClass('relative', 'z-20')
|
|
})
|
|
|
|
it('keeps the session list section in a constrained flex column for scrolling', () => {
|
|
render(<Sidebar />)
|
|
|
|
expect(screen.getByTestId('sidebar-session-list-section')).toHaveClass('flex', 'flex-1', 'min-h-0', 'flex-col')
|
|
})
|
|
|
|
it('keeps mobile navigation focused on chat sessions', async () => {
|
|
const onRequestClose = vi.fn()
|
|
createSession.mockResolvedValue('session-mobile-new')
|
|
useSessionStore.setState({
|
|
sessions: [
|
|
{
|
|
id: 'session-1',
|
|
title: 'Open Session',
|
|
createdAt: new Date().toISOString(),
|
|
modifiedAt: new Date().toISOString(),
|
|
messageCount: 1,
|
|
projectPath: '/workspace/project',
|
|
workDir: '/workspace/project',
|
|
workDirExists: true,
|
|
},
|
|
],
|
|
})
|
|
|
|
render(<Sidebar isMobile onRequestClose={onRequestClose} />)
|
|
|
|
expect(screen.queryByRole('button', { name: 'Scheduled' })).not.toBeInTheDocument()
|
|
expect(screen.queryByRole('button', { name: 'Settings' })).not.toBeInTheDocument()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /Open Session/ }))
|
|
expect(onRequestClose).toHaveBeenCalledTimes(1)
|
|
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByRole('button', { name: 'New Session' }))
|
|
})
|
|
|
|
await waitFor(() => {
|
|
expect(createSession).toHaveBeenCalled()
|
|
})
|
|
expect(onRequestClose).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('shows a loading state instead of an empty session list while initial fetch is pending', () => {
|
|
useSessionStore.setState({ isLoading: true, sessions: [] })
|
|
|
|
render(<Sidebar />)
|
|
|
|
expect(screen.getByText('Loading...')).toBeInTheDocument()
|
|
expect(screen.queryByText('No sessions')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('refreshes sessions manually and through low-frequency visible polling', async () => {
|
|
vi.useFakeTimers()
|
|
|
|
render(<Sidebar />)
|
|
await act(async () => {
|
|
await Promise.resolve()
|
|
})
|
|
|
|
expect(fetchSessions).toHaveBeenCalledTimes(1)
|
|
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByRole('button', { name: 'Refresh sessions' }))
|
|
await Promise.resolve()
|
|
})
|
|
expect(fetchSessions).toHaveBeenCalledTimes(2)
|
|
|
|
await act(async () => {
|
|
window.dispatchEvent(new Event('focus'))
|
|
await Promise.resolve()
|
|
})
|
|
expect(fetchSessions).toHaveBeenCalledTimes(2)
|
|
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(30_000)
|
|
await Promise.resolve()
|
|
})
|
|
expect(fetchSessions).toHaveBeenCalledTimes(3)
|
|
})
|
|
|
|
it('does not poll for session changes while the document is hidden', async () => {
|
|
vi.useFakeTimers()
|
|
const originalVisibility = document.visibilityState
|
|
Object.defineProperty(document, 'visibilityState', {
|
|
configurable: true,
|
|
value: 'hidden',
|
|
})
|
|
|
|
render(<Sidebar />)
|
|
await act(async () => {
|
|
await Promise.resolve()
|
|
})
|
|
expect(fetchSessions).toHaveBeenCalledTimes(1)
|
|
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(30_000)
|
|
await Promise.resolve()
|
|
})
|
|
expect(fetchSessions).toHaveBeenCalledTimes(1)
|
|
|
|
Object.defineProperty(document, 'visibilityState', {
|
|
configurable: true,
|
|
value: 'visible',
|
|
})
|
|
await act(async () => {
|
|
document.dispatchEvent(new Event('visibilitychange'))
|
|
await Promise.resolve()
|
|
})
|
|
expect(fetchSessions).toHaveBeenCalledTimes(2)
|
|
|
|
Object.defineProperty(document, 'visibilityState', {
|
|
configurable: true,
|
|
value: originalVisibility,
|
|
})
|
|
})
|
|
})
|