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' const desktopUiPreferencesApiMock = vi.hoisted(() => ({ getPreferences: vi.fn(), updateSidebarPreferences: vi.fn(), })) vi.mock('../../api/desktopUiPreferences', () => ({ desktopUiPreferencesApi: desktopUiPreferencesApiMock, })) const openTargetStoreMock = vi.hoisted(() => ({ ensureTargets: vi.fn(), openTarget: vi.fn(), targets: [{ id: 'finder', kind: 'file_manager', label: 'Finder', platform: 'darwin' }], })) vi.mock('../../stores/openTargetStore', () => ({ useOpenTargetStore: { getState: () => openTargetStoreMock, }, })) vi.mock('../../i18n', () => ({ useTranslation: () => (key: string, params?: Record) => { const translations: Record = { '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', 'sidebar.projects': 'Projects', 'sidebar.projectMenu': 'Project menu', 'sidebar.newProject': 'New project', 'sidebar.archiveAllChats': 'Archive all chats', 'sidebar.organizeSidebar': 'Organize sidebar', 'sidebar.sortCondition': 'Sort condition', 'sidebar.organizeByProject': 'By project', 'sidebar.organizeByRecentProject': 'Recent projects', 'sidebar.organizeByTime': 'By time', 'sidebar.sortByCreatedAt': 'Created time', 'sidebar.sortByUpdatedAt': 'Updated time', 'sidebar.newBlankProject': 'New blank project', 'sidebar.useExistingFolder': 'Use existing folder', 'sidebar.chooseProjectFolderUnavailable': 'Folder selection is only available in the desktop app.', 'sidebar.projectActions': 'Project actions for {project}', 'sidebar.pinProject': 'Pin Project', 'sidebar.unpinProject': 'Unpin Project', 'sidebar.openInFinder': 'Open in Finder', 'sidebar.openInFinderFailed': 'Could not open the project in Finder.', 'sidebar.openInFinderUnavailable': 'No file manager is available.', 'sidebar.hideProjectFromSidebar': 'Hide from Sidebar', 'sidebar.restoreProjectToSidebar': 'Restore to Sidebar', 'sidebar.restoreHiddenProjects': 'Restore hidden projects ({count})', 'sidebar.projectHidden': '{project} was hidden from the sidebar. Existing sessions were not deleted.', 'sidebar.newSessionInProject': 'New session in {project}', 'sidebar.showMoreSessions': 'Expand display', 'sidebar.showFewerSessions': 'Collapse display', 'sidebar.expandProject': 'Expand {project}', 'sidebar.collapseProject': 'Collapse {project}', 'sidebar.worktree': 'worktree', 'sidebar.sessionRunning': 'Session running', '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', 'session.lastUpdated': 'last updated {time}', 'session.timeJustNow': 'just now', 'session.timeMinutes': '{n}m ago', 'session.timeHours': '{n}h ago', 'session.timeDays': '{n}d ago', } 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' import type { SessionListItem } from '../../types/session' const PROJECT_ORDER_STORAGE_KEY = 'cc-haha-sidebar-project-order' const PROJECT_PINNED_STORAGE_KEY = 'cc-haha-sidebar-pinned-projects' const PROJECT_HIDDEN_STORAGE_KEY = 'cc-haha-sidebar-hidden-projects' const PROJECT_ORGANIZATION_STORAGE_KEY = 'cc-haha-sidebar-project-organization' const PROJECT_SORT_STORAGE_KEY = 'cc-haha-sidebar-project-sort' function makeSession( id: string, title: string, projectRoot: string, modifiedAt: string, ): SessionListItem { return { id, title, createdAt: modifiedAt, modifiedAt, messageCount: 1, projectPath: projectRoot, projectRoot, workDir: projectRoot, workDirExists: true, } } function makeDataTransfer() { const data = new Map() return { effectAllowed: '', dropEffect: '', setData: vi.fn((type: string, value: string) => data.set(type, value)), getData: vi.fn((type: string) => data.get(type) ?? ''), } } function projectGroupNames(): string[] { return screen .getAllByTestId(/^sidebar-project-group-/) .map((group) => group.textContent ?? '') .map((text) => { if (text.includes('alpha')) return 'alpha' if (text.includes('beta')) return 'beta' if (text.includes('gamma')) return 'gamma' return text }) } 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() desktopUiPreferencesApiMock.getPreferences.mockReset() desktopUiPreferencesApiMock.updateSidebarPreferences.mockReset() desktopUiPreferencesApiMock.getPreferences.mockRejectedValue(new Error('server unavailable')) desktopUiPreferencesApiMock.updateSidebarPreferences.mockResolvedValue({ ok: true, preferences: { schemaVersion: 1, sidebar: { projectOrder: [], pinnedProjects: [], hiddenProjects: [], projectOrganization: 'recentProject', projectSortBy: 'updatedAt', }, }, }) openTargetStoreMock.ensureTargets.mockReset() openTargetStoreMock.openTarget.mockReset() openTargetStoreMock.targets = [{ id: 'finder', kind: 'file_manager', label: 'Finder', platform: 'darwin' }] window.localStorage.removeItem(PROJECT_ORDER_STORAGE_KEY) window.localStorage.removeItem(PROJECT_PINNED_STORAGE_KEY) window.localStorage.removeItem(PROJECT_HIDDEN_STORAGE_KEY) window.localStorage.removeItem(PROJECT_ORGANIZATION_STORAGE_KEY) window.localStorage.removeItem(PROJECT_SORT_STORAGE_KEY) useTabStore.setState({ tabs: [], activeTabId: null }) useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null, isBatchMode: false, selectedSessionIds: new Set(), fetchSessions, createSession, deleteSession, deleteSessions, }) useChatStore.setState({ connectToSession, disconnectSession, } as Partial>) useUIStore.setState({ sidebarOpen: true, addToast, } as Partial>) }) afterEach(() => { vi.useRealTimers() cleanup() useTabStore.setState({ tabs: [], activeTabId: null }) window.localStorage.removeItem(PROJECT_ORDER_STORAGE_KEY) window.localStorage.removeItem(PROJECT_PINNED_STORAGE_KEY) window.localStorage.removeItem(PROJECT_HIDDEN_STORAGE_KEY) window.localStorage.removeItem(PROJECT_ORGANIZATION_STORAGE_KEY) window.localStorage.removeItem(PROJECT_SORT_STORAGE_KEY) }) it('opens a new tab when creating a session from the sidebar', async () => { createSession.mockResolvedValue('session-new-1') render() 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-desktop-drag-region') expect(screen.getByTestId('sidebar-title-region')).toHaveAttribute('data-desktop-drag-region') }) it('groups sessions by project and expands overflow rows', () => { const base = new Date('2026-05-15T10:00:00.000Z').getTime() useSessionStore.setState({ sessions: [ ...Array.from({ length: 11 }, (_, index) => ( makeSession( `alpha-${index + 1}`, index === 0 ? 'Alpha newest' : index === 10 ? 'Alpha hidden' : `Alpha ${index + 1}`, '/workspace/alpha', new Date(base - index * 1000).toISOString(), ) )), makeSession('beta-1', 'Beta only', '/workspace/beta', new Date(base - 4000).toISOString()), ], }) render() expect(screen.getByText('Projects')).toBeInTheDocument() expect(screen.getByText('alpha')).toBeInTheDocument() expect(screen.queryByText('/workspace/alpha')).not.toBeInTheDocument() expect(screen.getByText('beta')).toBeInTheDocument() expect(screen.getByRole('button', { name: /Alpha newest/ })).toBeInTheDocument() expect(screen.queryByRole('button', { name: /Alpha hidden/ })).not.toBeInTheDocument() expect(screen.getByTestId('sidebar-project-session-list-workspace-alpha').parentElement).toHaveClass('pl-6') expect(screen.getByRole('button', { name: 'Collapse alpha' })).toHaveAttribute('data-state', 'open') expect(screen.getByTestId('sidebar-project-icon-workspace-alpha')).toHaveAttribute('data-icon-state', 'open') fireEvent.click(screen.getByRole('button', { name: 'Expand display' })) expect(screen.getByRole('button', { name: /Alpha hidden/ })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Collapse display' })).toBeInTheDocument() }) it('reorders project groups by dragging project headers while preserving expanded state', async () => { const base = new Date('2026-05-15T10:00:00.000Z').getTime() useSessionStore.setState({ sessions: [ ...Array.from({ length: 11 }, (_, index) => ( makeSession( `alpha-${index + 1}`, index === 10 ? 'Alpha hidden' : `Alpha ${index + 1}`, '/workspace/alpha', new Date(base - index * 1000).toISOString(), ) )), makeSession('beta-1', 'Beta Session', '/workspace/beta', new Date(base - 20_000).toISOString()), makeSession('gamma-1', 'Gamma Session', '/workspace/gamma', new Date(base - 30_000).toISOString()), ], }) render() fireEvent.click(screen.getByRole('button', { name: 'Expand display' })) expect(screen.getByRole('button', { name: /Alpha hidden/ })).toBeInTheDocument() expect(projectGroupNames().slice(0, 3)).toEqual(['alpha', 'beta', 'gamma']) const dataTransfer = makeDataTransfer() const alphaGroup = screen.getByTestId('sidebar-project-group-workspace-alpha') vi.spyOn(alphaGroup, 'getBoundingClientRect').mockReturnValue({ top: 0, bottom: 100, left: 0, right: 280, width: 280, height: 100, x: 0, y: 0, toJSON: () => ({}), }) fireEvent.dragStart(screen.getByRole('button', { name: 'Collapse gamma' }), { dataTransfer }) fireEvent.dragOver(alphaGroup, { clientY: -10, dataTransfer }) fireEvent.drop(alphaGroup, { clientY: -10, dataTransfer }) await waitFor(() => { expect(projectGroupNames().slice(0, 3)).toEqual(['alpha', 'gamma', 'beta']) }) expect(screen.getByRole('button', { name: /Alpha hidden/ })).toBeInTheDocument() expect(JSON.parse(window.localStorage.getItem(PROJECT_ORDER_STORAGE_KEY) ?? '[]').slice(0, 3)).toEqual([ '/workspace/alpha', '/workspace/gamma', '/workspace/beta', ]) }) it('restores the saved project drag order on render', () => { window.localStorage.setItem(PROJECT_ORDER_STORAGE_KEY, JSON.stringify([ '/workspace/beta', '/workspace/alpha', ])) const now = new Date().toISOString() useSessionStore.setState({ sessions: [ makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', now), makeSession('beta-1', 'Beta Session', '/workspace/beta', now), makeSession('gamma-1', 'Gamma Session', '/workspace/gamma', now), ], }) render() expect(projectGroupNames().slice(0, 3)).toEqual(['beta', 'alpha', 'gamma']) }) it('collapses a project group without removing the project header', () => { const now = new Date().toISOString() useSessionStore.setState({ sessions: [ makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', now), makeSession('beta-1', 'Beta Session', '/workspace/beta', now), ], }) render() fireEvent.click(screen.getByRole('button', { name: 'Collapse alpha' })) expect(screen.getByText('alpha')).toBeInTheDocument() expect(screen.queryByRole('button', { name: /Alpha Session/ })).not.toBeInTheDocument() expect(screen.getByRole('button', { name: /Beta Session/ })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Expand alpha' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Expand alpha' })).toHaveAttribute('data-state', 'closed') expect(screen.getByTestId('sidebar-project-icon-workspace-alpha')).toHaveAttribute('data-icon-state', 'closed') }) it('uses a bounded per-project session scroller for large expanded groups', () => { const base = new Date('2026-05-15T10:00:00.000Z').getTime() useSessionStore.setState({ sessions: Array.from({ length: 14 }, (_, index) => ( makeSession(`alpha-${index + 1}`, `Alpha ${index + 1}`, '/workspace/alpha', new Date(base - index * 1000).toISOString()) )), }) render() const expandButton = screen.getByRole('button', { name: 'Expand display' }) expect(expandButton).toHaveAttribute('aria-expanded', 'false') expect(expandButton.parentElement).toHaveClass('justify-start') expect(expandButton).toHaveClass('text-[var(--color-text-tertiary)]', 'opacity-75') fireEvent.click(expandButton) expect(screen.getByTestId('sidebar-project-session-list-workspace-alpha')).toHaveClass('max-h-[420px]', 'overflow-y-auto') expect(screen.getByRole('button', { name: 'Collapse display' })).toHaveAttribute('aria-expanded', 'true') }) it('creates a new session from the project group context', async () => { createSession.mockResolvedValue('session-alpha-new') useSessionStore.setState({ sessions: [ makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', new Date().toISOString()), ], }) render() await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'New session in alpha' })) }) await waitFor(() => { expect(createSession).toHaveBeenCalledWith('/workspace/alpha') expect(connectToSession).toHaveBeenCalledWith('session-alpha-new') }) }) it('shows project header menus and starts a blank project session', async () => { createSession.mockResolvedValue('session-blank-project') useSessionStore.setState({ sessions: [ makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', new Date().toISOString()), ], }) render() expect(screen.getByTestId('sidebar-projects-header')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'New project' })) expect(screen.getByRole('menuitem', { name: 'New blank project' })).toBeInTheDocument() await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'New blank project' })) }) await waitFor(() => { expect(createSession).toHaveBeenCalledWith(undefined) expect(connectToSession).toHaveBeenCalledWith('session-blank-project') }) }) it('persists project header sort preferences through desktop UI settings', async () => { useSessionStore.setState({ sessions: [ makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', '2026-03-01T00:00:00.000Z'), { ...makeSession('beta-1', 'Beta Session', '/workspace/beta', '2026-02-01T00:00:00.000Z'), createdAt: '2026-04-01T00:00:00.000Z', }, ], }) render() expect(projectGroupNames().slice(0, 2)).toEqual(['alpha', 'beta']) fireEvent.click(screen.getByRole('button', { name: 'Project menu' })) fireEvent.click(screen.getByRole('menuitem', { name: 'Sort condition' })) fireEvent.click(screen.getByRole('menuitem', { name: 'Created time' })) await waitFor(() => { expect(desktopUiPreferencesApiMock.updateSidebarPreferences).toHaveBeenCalledWith({ projectOrder: [], pinnedProjects: [], hiddenProjects: [], projectOrganization: 'recentProject', projectSortBy: 'createdAt', }) expect(projectGroupNames().slice(0, 2)).toEqual(['beta', 'alpha']) }) expect(window.localStorage.getItem(PROJECT_SORT_STORAGE_KEY)).toBe('createdAt') }) it('hides archive-all from the project header menu', () => { const now = new Date().toISOString() useSessionStore.setState({ sessions: [ makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', now), makeSession('beta-1', 'Beta Session', '/workspace/beta', now), ], }) render() fireEvent.click(screen.getByRole('button', { name: 'Project menu' })) expect(screen.queryByRole('menuitem', { name: 'Archive all chats' })).not.toBeInTheDocument() expect(screen.getByRole('menuitem', { name: 'Organize sidebar' })).toBeInTheDocument() expect(screen.getByRole('menuitem', { name: 'Sort condition' })).toBeInTheDocument() expect(screen.queryByRole('dialog')).not.toBeInTheDocument() }) it('keeps project row actions hidden until project hover or focus', () => { useSessionStore.setState({ sessions: [ makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', new Date().toISOString()), ], }) render() const actionButton = screen.getByRole('button', { name: 'Project actions for alpha' }) expect(actionButton.parentElement).toHaveClass('opacity-0') expect(actionButton.parentElement).toHaveClass('group-hover/project:opacity-100') expect(actionButton.parentElement).toHaveClass('group-focus-within/project:opacity-100') }) it('shows the project action menu with pin and Finder actions', async () => { const now = new Date().toISOString() useSessionStore.setState({ sessions: [ makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', now), ], }) render() fireEvent.click(screen.getByRole('button', { name: 'Project actions for alpha' })) expect(screen.getByRole('menuitem', { name: 'Pin Project' })).toBeInTheDocument() expect(screen.getByRole('menuitem', { name: 'Open in Finder' })).toBeInTheDocument() expect(screen.getByRole('menuitem', { name: 'Hide from Sidebar' })).toBeInTheDocument() expect(screen.queryByRole('menuitem', { name: 'Create Permanent Worktree' })).not.toBeInTheDocument() expect(screen.queryByRole('menuitem', { name: 'Rename Project' })).not.toBeInTheDocument() expect(screen.queryByRole('menuitem', { name: 'Archive Conversations' })).not.toBeInTheDocument() await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'Open in Finder' })) }) expect(openTargetStoreMock.ensureTargets).toHaveBeenCalledTimes(1) expect(openTargetStoreMock.openTarget).toHaveBeenCalledWith('finder', '/workspace/alpha') }) it('pins a project above the rest of the project list', async () => { const base = new Date('2026-05-15T10:00:00.000Z').getTime() useSessionStore.setState({ sessions: [ makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', new Date(base).toISOString()), makeSession('beta-1', 'Beta Session', '/workspace/beta', new Date(base - 20_000).toISOString()), ], }) render() expect(projectGroupNames().slice(0, 2)).toEqual(['alpha', 'beta']) fireEvent.click(screen.getByRole('button', { name: 'Project actions for beta' })) fireEvent.click(screen.getByRole('menuitem', { name: 'Pin Project' })) await waitFor(() => { expect(projectGroupNames().slice(0, 2)).toEqual(['beta', 'alpha']) }) expect(JSON.parse(window.localStorage.getItem(PROJECT_PINNED_STORAGE_KEY) ?? '[]')).toEqual(['/workspace/beta']) }) it('removes a project from the sidebar without deleting its sessions', async () => { const base = new Date('2026-05-15T10:00:00.000Z').getTime() useSessionStore.setState({ sessions: [ makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', new Date(base).toISOString()), makeSession('beta-1', 'Beta Session', '/workspace/beta', new Date(base - 20_000).toISOString()), ], }) render() fireEvent.click(screen.getByRole('button', { name: 'Project actions for beta' })) fireEvent.click(screen.getByRole('menuitem', { name: 'Hide from Sidebar' })) await waitFor(() => { expect(screen.queryByText('beta')).not.toBeInTheDocument() }) expect(screen.getByText('alpha')).toBeInTheDocument() expect(deleteSessions).not.toHaveBeenCalled() expect(deleteSession).not.toHaveBeenCalled() expect(JSON.parse(window.localStorage.getItem(PROJECT_HIDDEN_STORAGE_KEY) ?? '[]')).toEqual(['/workspace/beta']) expect(addToast).toHaveBeenCalledWith({ type: 'info', message: 'beta was hidden from the sidebar. Existing sessions were not deleted.', }) }) it('keeps hidden projects out of the sidebar without the removed project filter', () => { window.localStorage.setItem(PROJECT_HIDDEN_STORAGE_KEY, JSON.stringify(['/workspace/beta'])) const now = new Date().toISOString() useSessionStore.setState({ sessions: [ makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', now), makeSession('beta-1', 'Beta Session', '/workspace/beta', now), ], }) render() expect(screen.getByText('alpha')).toBeInTheDocument() expect(screen.queryByText('beta')).not.toBeInTheDocument() expect(screen.queryByTestId('project-filter')).not.toBeInTheDocument() }) it('restores hidden projects from the project header menu', async () => { window.localStorage.setItem(PROJECT_HIDDEN_STORAGE_KEY, JSON.stringify(['/workspace/beta'])) const now = new Date().toISOString() useSessionStore.setState({ sessions: [ makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', now), makeSession('beta-1', 'Beta Session', '/workspace/beta', now), ], }) render() expect(screen.getByText('alpha')).toBeInTheDocument() expect(screen.queryByText('beta')).not.toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Project menu' })) fireEvent.click(screen.getByRole('menuitem', { name: 'Restore hidden projects (1)' })) await waitFor(() => { expect(screen.getByText('beta')).toBeInTheDocument() }) expect(JSON.parse(window.localStorage.getItem(PROJECT_HIDDEN_STORAGE_KEY) ?? '[]')).toEqual([]) expect(desktopUiPreferencesApiMock.updateSidebarPreferences).toHaveBeenCalledWith({ projectOrder: [], pinnedProjects: [], hiddenProjects: [], projectOrganization: 'recentProject', projectSortBy: 'updatedAt', }) }) it('restores a hidden project when a new session is created in that project', async () => { window.localStorage.setItem(PROJECT_HIDDEN_STORAGE_KEY, JSON.stringify(['/workspace/beta'])) createSession.mockResolvedValue('beta-new') const now = new Date().toISOString() useSessionStore.setState({ sessions: [ makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', now), makeSession('beta-1', 'Beta Session', '/workspace/beta', now), ], }) useTabStore.setState({ tabs: [{ sessionId: 'beta-1', title: 'Beta Session', type: 'session', status: 'idle' }], activeTabId: 'beta-1', }) render() expect(screen.queryByText('beta')).not.toBeInTheDocument() await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'New Session' })) }) await waitFor(() => { expect(createSession).toHaveBeenCalledWith('/workspace/beta') expect(screen.getByText('beta')).toBeInTheDocument() }) expect(JSON.parse(window.localStorage.getItem(PROJECT_HIDDEN_STORAGE_KEY) ?? '[]')).toEqual([]) expect(desktopUiPreferencesApiMock.updateSidebarPreferences).toHaveBeenCalledWith({ projectOrder: [], pinnedProjects: [], hiddenProjects: [], projectOrganization: 'recentProject', projectSortBy: 'updatedAt', }) }) it('uses server sidebar preferences across browser and desktop storage contexts', async () => { desktopUiPreferencesApiMock.getPreferences.mockResolvedValueOnce({ exists: true, preferences: { schemaVersion: 1, sidebar: { projectOrder: ['/workspace/beta', '/workspace/alpha'], pinnedProjects: ['/workspace/beta'], hiddenProjects: ['/workspace/alpha'], projectOrganization: 'recentProject', projectSortBy: 'updatedAt', }, }, }) const now = new Date().toISOString() useSessionStore.setState({ sessions: [ makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', now), makeSession('beta-1', 'Beta Session', '/workspace/beta', now), ], }) render() await waitFor(() => { expect(screen.queryByText('alpha')).not.toBeInTheDocument() expect(screen.getByText('beta')).toBeInTheDocument() }) expect(JSON.parse(window.localStorage.getItem(PROJECT_ORDER_STORAGE_KEY) ?? '[]')).toEqual([ '/workspace/beta', '/workspace/alpha', ]) expect(JSON.parse(window.localStorage.getItem(PROJECT_PINNED_STORAGE_KEY) ?? '[]')).toEqual(['/workspace/beta']) expect(JSON.parse(window.localStorage.getItem(PROJECT_HIDDEN_STORAGE_KEY) ?? '[]')).toEqual(['/workspace/alpha']) }) it('migrates cached local sidebar preferences when the server file is missing after update', async () => { desktopUiPreferencesApiMock.getPreferences.mockResolvedValueOnce({ exists: false, preferences: { schemaVersion: 1, sidebar: { projectOrder: [], pinnedProjects: [], hiddenProjects: [], projectOrganization: 'recentProject', projectSortBy: 'updatedAt', }, }, }) window.localStorage.setItem(PROJECT_HIDDEN_STORAGE_KEY, JSON.stringify(['/workspace/beta'])) useSessionStore.setState({ sessions: [ makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', new Date().toISOString()), makeSession('beta-1', 'Beta Session', '/workspace/beta', new Date().toISOString()), ], }) render() await waitFor(() => { expect(desktopUiPreferencesApiMock.updateSidebarPreferences).toHaveBeenCalledWith({ projectOrder: [], pinnedProjects: [], hiddenProjects: ['/workspace/beta'], projectOrganization: 'recentProject', projectSortBy: 'updatedAt', }) }) expect(screen.queryByText('beta')).not.toBeInTheDocument() }) it('ignores corrupt hidden project storage for backward compatibility', () => { window.localStorage.setItem(PROJECT_HIDDEN_STORAGE_KEY, '{bad json') useSessionStore.setState({ sessions: [ makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', new Date().toISOString()), ], }) render() expect(screen.getByText('alpha')).toBeInTheDocument() expect(screen.getByRole('button', { name: /Alpha Session/ })).toBeInTheDocument() }) it('keeps persisted worktree sessions under the source project group', () => { const now = new Date().toISOString() useSessionStore.setState({ sessions: [ makeSession('source-1', 'Source Session', '/workspace/repo', now), { ...makeSession('worktree-1', 'Worktree Session', '/workspace/repo/.claude/worktrees/desktop-main-12345678', now), projectRoot: '/workspace/repo', }, { ...makeSession('subdir-1', 'Subdir Session', '/workspace/repo/packages/app', now), projectRoot: '/workspace/repo', }, ], }) render() expect(screen.getByText('repo')).toBeInTheDocument() expect(screen.getByRole('button', { name: /Source Session/ })).toBeInTheDocument() expect(screen.getByRole('button', { name: /Worktree Session/ })).toBeInTheDocument() expect(screen.getByRole('button', { name: /Subdir Session/ })).toBeInTheDocument() expect(screen.getAllByText('worktree')).toHaveLength(1) }) it('keeps a Windows drive root session separate from sessions in child projects', () => { const now = new Date().toISOString() useSessionStore.setState({ sessions: [ makeSession('drive-root', 'Drive Root Session', 'D:\\', now), makeSession('drive-project', 'Drive Project Session', 'D:\\SomeProject', now), ], }) render() expect(screen.getByText('D:')).toBeInTheDocument() expect(screen.getByText('SomeProject')).toBeInTheDocument() expect(screen.getByRole('button', { name: /Drive Root Session/ })).toBeInTheDocument() expect(screen.getByRole('button', { name: /Drive Project Session/ })).toBeInTheDocument() }) it('does not restore a hidden Windows drive root when creating a child project session', async () => { window.localStorage.setItem(PROJECT_HIDDEN_STORAGE_KEY, JSON.stringify(['D:\\'])) createSession.mockResolvedValue('child-new') const now = new Date().toISOString() useSessionStore.setState({ sessions: [ makeSession('child-1', 'Child Session', 'D:\\workspace\\code\\cc-haha', now), ], }) useTabStore.setState({ tabs: [{ sessionId: 'child-1', title: 'Child Session', type: 'session', status: 'idle' }], activeTabId: 'child-1', }) render() await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'New Session' })) }) await waitFor(() => { expect(createSession).toHaveBeenCalledWith('D:\\workspace\\code\\cc-haha') }) expect(JSON.parse(window.localStorage.getItem(PROJECT_HIDDEN_STORAGE_KEY) ?? '[]')).toEqual(['D:\\']) expect(desktopUiPreferencesApiMock.updateSidebarPreferences).not.toHaveBeenCalled() }) it('right-aligns running status, worktree marker, and update time on session rows', () => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-05-19T12:00:00.000Z')) useSessionStore.setState({ sessions: [ { ...makeSession('running-worktree', 'Running Worktree', '/workspace/repo/.claude/worktrees/desktop-main-12345678', '2026-05-19T07:00:00.000Z'), projectRoot: '/workspace/repo', }, makeSession('idle-source', 'Idle Source', '/workspace/repo', '2026-05-19T11:40:00.000Z'), ], }) useTabStore.setState({ tabs: [ { sessionId: 'running-worktree', title: 'Running Worktree', type: 'session', status: 'running' }, { sessionId: 'idle-source', title: 'Idle Source', type: 'session', status: 'idle' }, ], activeTabId: 'running-worktree', }) render() const runningRow = screen.getByRole('button', { name: /Running Worktree/ }) expect(within(runningRow).getByLabelText('Session running')).toBeInTheDocument() expect(within(runningRow).getByText('worktree')).toHaveClass('sr-only') expect(within(runningRow).getByText('5h ago')).toBeInTheDocument() const idleRow = screen.getByRole('button', { name: /Idle Source/ }) expect(within(idleRow).queryByLabelText('Session running')).not.toBeInTheDocument() expect(within(idleRow).getByText('20m ago')).toBeInTheDocument() }) it('shows a toast when session creation fails', async () => { createSession.mockRejectedValue(new Error('boom')) render() 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() 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() 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() 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-0.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() 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('renders search controls without the removed embedded project filter', () => { render() expect(screen.getByTestId('sidebar-search-controls-section')).toHaveStyle({ overflow: 'visible' }) expect(screen.getByTestId('sidebar-search-controls-section')).toHaveClass('relative', 'z-20') expect(screen.getByPlaceholderText('Search sessions')).toBeInTheDocument() expect(screen.queryByRole('button', { name: /All projects/i })).not.toBeInTheDocument() expect(screen.queryByTestId('project-filter')).not.toBeInTheDocument() }) it('keeps the session list section in a constrained flex column for scrolling', () => { render() expect(screen.getByTestId('sidebar-session-list-section')).toHaveClass('flex', 'flex-1', 'min-h-0', 'flex-col') }) it('keeps the settings dock opaque above the scrolling session list', () => { render() expect(screen.getByTestId('sidebar-settings-dock')).toHaveClass('sidebar-settings-dock') expect(screen.getByTestId('sidebar-settings-dock')).toHaveClass('absolute', 'bottom-0') }) 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() 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() 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() 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() 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, }) }) })