diff --git a/desktop/src/__tests__/agentsSettings.test.tsx b/desktop/src/__tests__/agentsSettings.test.tsx index dd035ecb..e1c3b417 100644 --- a/desktop/src/__tests__/agentsSettings.test.tsx +++ b/desktop/src/__tests__/agentsSettings.test.tsx @@ -165,15 +165,12 @@ describe('Settings > Agents tab', () => { activeSessionId: 'session-1', isLoading: false, error: null, - selectedProjects: [], - availableProjects: [], fetchSessions: noopFetch, createSession: vi.fn(), deleteSession: vi.fn(), renameSession: vi.fn(), updateSessionTitle: vi.fn(), setActiveSession: vi.fn(), - setSelectedProjects: vi.fn(), }) useAgentStore.setState({ activeAgents: [], diff --git a/desktop/src/__tests__/mcpSettings.test.tsx b/desktop/src/__tests__/mcpSettings.test.tsx index 418dd5c3..a1f9bf20 100644 --- a/desktop/src/__tests__/mcpSettings.test.tsx +++ b/desktop/src/__tests__/mcpSettings.test.tsx @@ -26,15 +26,12 @@ describe('McpSettings', () => { activeSessionId: 'session-1', isLoading: false, error: null, - selectedProjects: [], - availableProjects: [], fetchSessions: vi.fn(), createSession: vi.fn(), deleteSession: vi.fn(), renameSession: vi.fn(), updateSessionTitle: vi.fn(), setActiveSession: vi.fn(), - setSelectedProjects: vi.fn(), }) useMcpStore.setState({ servers: [], diff --git a/desktop/src/__tests__/skillsSettings.test.tsx b/desktop/src/__tests__/skillsSettings.test.tsx index 2b3d21bb..0ecefea1 100644 --- a/desktop/src/__tests__/skillsSettings.test.tsx +++ b/desktop/src/__tests__/skillsSettings.test.tsx @@ -82,8 +82,6 @@ describe('Settings > Skills tab', () => { activeSessionId: 'session-1', isLoading: false, error: null, - selectedProjects: [], - availableProjects: ['/workspace/project'], }) useTabStore.setState({ tabs: [], activeTabId: null }) useUIStore.setState({ pendingSettingsTab: null }) diff --git a/desktop/src/components/layout/ProjectFilter.test.tsx b/desktop/src/components/layout/ProjectFilter.test.tsx deleted file mode 100644 index 1a861490..00000000 --- a/desktop/src/components/layout/ProjectFilter.test.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import '@testing-library/jest-dom' - -const { getRecentProjectsMock } = vi.hoisted(() => ({ - getRecentProjectsMock: vi.fn(), -})) - -vi.mock('../../api/sessions', async () => { - const actual = await vi.importActual('../../api/sessions') - return { - ...actual, - sessionsApi: { - ...actual.sessionsApi, - getRecentProjects: getRecentProjectsMock, - }, - } -}) - -vi.mock('../../i18n', () => ({ - useTranslation: () => (key: string) => { - const translations: Record = { - 'sidebar.allProjects': 'All projects', - 'sidebar.other': 'Other', - 'sidebar.noSessions': 'No sessions', - 'common.loading': 'Loading', - } - - return translations[key] ?? key - }, -})) - -import { useSessionStore } from '../../stores/sessionStore' -import { ProjectFilter } from './ProjectFilter' - -describe('ProjectFilter', () => { - beforeEach(() => { - getRecentProjectsMock.mockReset() - useSessionStore.setState({ - sessions: [], - activeSessionId: null, - isLoading: false, - error: null, - selectedProjects: [], - availableProjects: [ - 'Users-nanmi-workspace-myself_code-OpenCutSkill', - 'Users-nanmi-workspace-myself_code-claude-code-haha', - ], - }) - }) - - it('renders recent project metadata instead of bare fallback folder names', async () => { - getRecentProjectsMock.mockResolvedValue({ - projects: [ - { - projectPath: 'Users-nanmi-workspace-myself_code-claude-code-haha', - realPath: '/Users/nanmi/workspace/myself_code/claude-code-haha', - projectName: 'claude-code-haha', - isGit: true, - repoName: 'NanmiCoder/cc-haha', - branch: 'main', - modifiedAt: '2026-04-20T10:00:00.000Z', - sessionCount: 4, - }, - { - projectPath: 'Users-nanmi-workspace-myself_code-OpenCutSkill', - realPath: '/Users/nanmi/workspace/myself_code/OpenCutSkill', - projectName: 'OpenCutSkill', - isGit: true, - repoName: 'NanmiCoder/OpenCutSkill', - branch: 'main', - modifiedAt: '2026-04-20T09:00:00.000Z', - sessionCount: 2, - }, - ], - }) - - render() - - fireEvent.click(screen.getByRole('button', { name: /All projects/i })) - - await waitFor(() => { - expect(screen.getByText('NanmiCoder/cc-haha')).toBeInTheDocument() - expect(screen.getByText('/Users/nanmi/workspace/myself_code/claude-code-haha')).toBeInTheDocument() - expect(screen.getByText('NanmiCoder/OpenCutSkill')).toBeInTheDocument() - }) - - fireEvent.click(screen.getByRole('button', { name: /NanmiCoder\/cc-haha/i })) - - await waitFor(() => { - expect(useSessionStore.getState().selectedProjects).toEqual(['Users-nanmi-workspace-myself_code-claude-code-haha']) - }) - - expect(screen.getAllByRole('button', { name: /NanmiCoder\/cc-haha/i })).toHaveLength(2) - }) -}) diff --git a/desktop/src/components/layout/ProjectFilter.tsx b/desktop/src/components/layout/ProjectFilter.tsx deleted file mode 100644 index 9900b7b9..00000000 --- a/desktop/src/components/layout/ProjectFilter.tsx +++ /dev/null @@ -1,369 +0,0 @@ -import { useState, useRef, useEffect, useMemo, useCallback } from 'react' -import { createPortal } from 'react-dom' -import { sessionsApi, type RecentProject } from '../../api/sessions' -import { useSessionStore } from '../../stores/sessionStore' -import { useTranslation } from '../../i18n' - -type DropdownPos = { - top: number - left: number - direction: 'up' | 'down' -} - -type ProjectOption = { - projectPath: string - title: string - subtitle: string | null - isGit: boolean - branch: string | null - modifiedAt?: string -} - -let cachedProjects: RecentProject[] | null = null -let cacheTimestamp = 0 -const CACHE_TTL = 30_000 - -export function ProjectFilter({ variant = 'default' }: { variant?: 'default' | 'embedded' }) { - const t = useTranslation() - const { availableProjects, selectedProjects, setSelectedProjects } = useSessionStore() - const [open, setOpen] = useState(false) - const [projects, setProjects] = useState([]) - const [loading, setLoading] = useState(false) - const [dropdownPos, setDropdownPos] = useState(null) - const ref = useRef(null) - const triggerRef = useRef(null) - const dropdownRef = useRef(null) - - const updateDropdownPos = useCallback(() => { - if (!triggerRef.current) return - const rect = triggerRef.current.getBoundingClientRect() - const dropdownHeight = 420 - const spaceAbove = rect.top - const spaceBelow = window.innerHeight - rect.bottom - const direction = spaceBelow >= dropdownHeight || spaceBelow >= spaceAbove ? 'down' : 'up' - - setDropdownPos({ - top: direction === 'down' ? rect.bottom + 8 : rect.top - 8, - left: rect.left, - direction, - }) - }, []) - - useEffect(() => { - if (!open) return - - const handleClick = (e: MouseEvent) => { - const target = e.target as Node - if (ref.current?.contains(target)) return - if (dropdownRef.current?.contains(target)) return - setOpen(false) - } - - document.addEventListener('mousedown', handleClick) - return () => document.removeEventListener('mousedown', handleClick) - }, [open]) - - useEffect(() => { - if (!open) return - updateDropdownPos() - window.addEventListener('scroll', updateDropdownPos, true) - window.addEventListener('resize', updateDropdownPos) - return () => { - window.removeEventListener('scroll', updateDropdownPos, true) - window.removeEventListener('resize', updateDropdownPos) - } - }, [open, updateDropdownPos]) - - useEffect(() => { - if (!open) return - if (cachedProjects && Date.now() - cacheTimestamp < CACHE_TTL) { - setProjects(cachedProjects) - return - } - - setLoading(true) - sessionsApi.getRecentProjects(200) - .then(({ projects: nextProjects }) => { - cachedProjects = nextProjects - cacheTimestamp = Date.now() - setProjects(nextProjects) - }) - .catch(() => setProjects([])) - .finally(() => setLoading(false)) - }, [open]) - - const isAllSelected = selectedProjects.length === 0 - - const options = useMemo(() => { - const availableSet = new Set(availableProjects) - const optionsByPath = new Map() - - for (const project of projects) { - const optionPath = availableSet.has(project.realPath) ? project.realPath : project.projectPath - if (!availableSet.has(optionPath)) continue - optionsByPath.set(optionPath, { - projectPath: optionPath, - title: project.repoName || project.projectName, - subtitle: project.realPath, - isGit: project.isGit, - branch: project.branch, - modifiedAt: project.modifiedAt, - }) - } - - for (const projectPath of availableProjects) { - if (optionsByPath.has(projectPath)) continue - optionsByPath.set(projectPath, { - projectPath, - title: fallbackProjectTitle(projectPath, t('sidebar.other')), - subtitle: null, - isGit: false, - branch: null, - }) - } - - return [...optionsByPath.values()].sort(compareProjectOptions) - }, [availableProjects, projects, t]) - - const optionByPath = useMemo( - () => new Map(options.map((option) => [option.projectPath, option])), - [options], - ) - - const label = isAllSelected - ? t('sidebar.allProjects') - : selectedProjects.length === 1 - ? optionByPath.get(selectedProjects[0]!)?.title || fallbackProjectTitle(selectedProjects[0]!, t('sidebar.other')) - : `${selectedProjects.length} projects` - const triggerLabel = isAllSelected ? t('sidebar.allProjects') : label - - const toggleProject = (projectPath: string) => { - if (isAllSelected) { - setSelectedProjects([projectPath]) - return - } - - if (selectedProjects.includes(projectPath)) { - const next = selectedProjects.filter((path) => path !== projectPath) - setSelectedProjects(next.length === 0 ? [] : next) - return - } - - const next = [...selectedProjects, projectPath] - setSelectedProjects(next.length >= availableProjects.length ? [] : next) - } - - const selectAll = () => setSelectedProjects([]) - - return ( -
- - - {open && dropdownPos && createPortal( -
-
- - -
- - {loading ? ( -
{t('common.loading')}
- ) : options.length === 0 ? ( -
{t('sidebar.noSessions')}
- ) : ( - options.map((option) => { - const checked = !isAllSelected && selectedProjects.includes(option.projectPath) - return ( - - ) - }) - )} -
-
, - document.body, - )} -
- ) -} - -function compareProjectOptions(a: ProjectOption, b: ProjectOption) { - if (a.modifiedAt && b.modifiedAt && a.modifiedAt !== b.modifiedAt) { - return b.modifiedAt.localeCompare(a.modifiedAt) - } - if (a.modifiedAt && !b.modifiedAt) return -1 - if (!a.modifiedAt && b.modifiedAt) return 1 - return a.title.localeCompare(b.title) -} - -function fallbackProjectTitle(projectPath: string, fallback: string) { - if (!projectPath || projectPath === '_unknown') return fallback - if (projectPath.includes('/')) { - return projectPath.split('/').filter(Boolean).pop() || fallback - } - - const segments = projectPath.split('-').filter(Boolean) - return segments[segments.length - 1] || projectPath || fallback -} - -function ChevronIcon({ open }: { open: boolean }) { - return ( - - - - ) -} - -function FolderIcon({ className = 'text-[var(--color-text-secondary)]' }: { className?: string }) { - return ( - - - - ) -} - -function GitBranchIcon({ className = 'text-[var(--color-text-secondary)]' }: { className?: string }) { - return ( - - - - - - - ) -} - -function CheckIcon() { - return ( - - - - ) -} diff --git a/desktop/src/components/layout/Sidebar.test.tsx b/desktop/src/components/layout/Sidebar.test.tsx index 4c6f9e6f..473c41a1 100644 --- a/desktop/src/components/layout/Sidebar.test.tsx +++ b/desktop/src/components/layout/Sidebar.test.tsx @@ -2,10 +2,6 @@ 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: () =>
, -})) - const desktopUiPreferencesApiMock = vi.hoisted(() => ({ getPreferences: vi.fn(), updateSidebarPreferences: vi.fn(), @@ -62,8 +58,8 @@ vi.mock('../../i18n', () => ({ 'sidebar.restoreProjectToSidebar': 'Restore to Sidebar', 'sidebar.projectHidden': '{project} was removed from the sidebar.', 'sidebar.newSessionInProject': 'New session in {project}', - 'sidebar.showMoreSessions': 'Show {count} more', - 'sidebar.showFewerSessions': 'Show fewer', + 'sidebar.showMoreSessions': 'Expand display', + 'sidebar.showFewerSessions': 'Collapse display', 'sidebar.expandProject': 'Expand {project}', 'sidebar.collapseProject': 'Collapse {project}', 'sidebar.worktree': 'worktree', @@ -205,8 +201,6 @@ describe('Sidebar', () => { activeSessionId: null, isLoading: false, error: null, - selectedProjects: [], - availableProjects: [], isBatchMode: false, selectedSessionIds: new Set(), fetchSessions, @@ -282,10 +276,10 @@ describe('Sidebar', () => { expect(screen.queryByRole('button', { name: /Alpha hidden/ })).not.toBeInTheDocument() expect(screen.getByRole('button', { name: /Alpha newest/ }).closest('[class*="pl-0"]')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: 'Show 5 more' })) + fireEvent.click(screen.getByRole('button', { name: 'Expand display' })) expect(screen.getByRole('button', { name: /Alpha hidden/ })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Show fewer' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Collapse display' })).toBeInTheDocument() }) it('reorders project groups by dragging project headers while preserving expanded state', async () => { @@ -307,7 +301,7 @@ describe('Sidebar', () => { render() - fireEvent.click(screen.getByRole('button', { name: 'Show 5 more' })) + 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']) @@ -388,9 +382,15 @@ describe('Sidebar', () => { render() - fireEvent.click(screen.getByRole('button', { name: 'Show 8 more' })) + 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 () => { @@ -469,7 +469,7 @@ describe('Sidebar', () => { expect(window.localStorage.getItem(PROJECT_SORT_STORAGE_KEY)).toBe('createdAt') }) - it('uses the project header menu to request archiving all visible sessions', () => { + it('hides archive-all from the project header menu', () => { const now = new Date().toISOString() useSessionStore.setState({ sessions: [ @@ -481,12 +481,11 @@ describe('Sidebar', () => { render() fireEvent.click(screen.getByRole('button', { name: 'Project menu' })) - fireEvent.click(screen.getByRole('menuitem', { name: 'Archive all chats' })) - const dialog = screen.getByRole('dialog') - expect(within(dialog).getByText('Delete 2 sessions? This cannot be undone.')).toBeInTheDocument() - expect(within(dialog).getByText('Alpha Session')).toBeInTheDocument() - expect(within(dialog).getByText('Beta Session')).toBeInTheDocument() + 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', () => { @@ -580,11 +579,10 @@ describe('Sidebar', () => { }) }) - it('restores hidden project state from localStorage and shows selected hidden projects for recovery', () => { + 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({ - selectedProjects: ['/workspace/beta'], sessions: [ makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', now), makeSession('beta-1', 'Beta Session', '/workspace/beta', now), @@ -593,11 +591,9 @@ describe('Sidebar', () => { render() - expect(screen.queryByText('alpha')).not.toBeInTheDocument() - expect(screen.getByText('beta')).toBeInTheDocument() - - fireEvent.click(screen.getByRole('button', { name: 'Project actions for beta' })) - expect(screen.getByRole('menuitem', { name: 'Restore to Sidebar' })).toBeInTheDocument() + expect(screen.getByText('alpha')).toBeInTheDocument() + expect(screen.queryByText('beta')).not.toBeInTheDocument() + expect(screen.queryByTestId('project-filter')).not.toBeInTheDocument() }) it('uses server sidebar preferences across browser and desktop storage contexts', async () => { @@ -917,11 +913,14 @@ describe('Sidebar', () => { expect(screen.getByRole('complementary')).toHaveAttribute('data-state', 'open') }) - it('keeps the project filter section overflow visible for dropdown menus', () => { + it('renders search controls without the removed embedded project filter', () => { render() - expect(screen.getByTestId('sidebar-project-filter-section')).toHaveStyle({ overflow: 'visible' }) - expect(screen.getByTestId('sidebar-project-filter-section')).toHaveClass('relative', 'z-20') + 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', () => { diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index da5b8ae0..a4593ddf 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -1,9 +1,8 @@ import { useEffect, useState, useCallback, useMemo, useRef } from 'react' -import { Archive, Check, ChevronDown, Clock, Folder, FolderOpen, FolderPlus, MoreHorizontal, Pin, PinOff, RefreshCw, RotateCcw, SquarePen, X } from 'lucide-react' +import { Check, ChevronDown, Clock, Folder, FolderOpen, FolderPlus, MoreHorizontal, Pin, PinOff, RefreshCw, RotateCcw, SquarePen, X } from 'lucide-react' import { useSessionStore } from '../../stores/sessionStore' import { useUIStore } from '../../stores/uiStore' import { useTranslation } from '../../i18n' -import { ProjectFilter } from './ProjectFilter' import { ConfirmDialog } from '../shared/ConfirmDialog' import type { SessionListItem } from '../../types/session' import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID } from '../../stores/tabStore' @@ -43,7 +42,6 @@ type SidebarProps = { export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { const t = useTranslation() const sessions = useSessionStore((s) => s.sessions) - const selectedProjects = useSessionStore((s) => s.selectedProjects) const isLoading = useSessionStore((s) => s.isLoading) const error = useSessionStore((s) => s.error) const fetchSessions = useSessionStore((s) => s.fetchSessions) @@ -107,15 +105,12 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { const filteredSessions = useMemo(() => { let result = sessions - if (selectedProjects.length > 0) { - result = result.filter((s) => selectedProjects.includes(getSessionProjectKey(s))) - } if (searchQuery) { const q = searchQuery.toLowerCase() result = result.filter((s) => s.title.toLowerCase().includes(q)) } return result - }, [sessions, selectedProjects, searchQuery]) + }, [sessions, searchQuery]) const projectGroups = useMemo(() => groupByProject(filteredSessions, projectSortBy), [filteredSessions, projectSortBy]) const orderedProjectGroups = useMemo( @@ -125,9 +120,9 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { const visibleProjectGroups = useMemo(() => { if (hiddenProjectKeys.size === 0) return orderedProjectGroups return orderedProjectGroups.filter((project) => ( - !hiddenProjectKeys.has(project.key) || selectedProjects.includes(project.key) + !hiddenProjectKeys.has(project.key) )) - }, [hiddenProjectKeys, orderedProjectGroups, selectedProjects]) + }, [hiddenProjectKeys, orderedProjectGroups]) const showInitialLoading = isLoading && sessions.length === 0 const filteredSessionIds = useMemo(() => filteredSessions.map((session) => session.id), [filteredSessions]) const selectedCount = selectedSessionIds.size @@ -444,12 +439,6 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { setPendingBatchDeleteSessionIds([...new Set(ids)]) }, []) - const requestArchiveAllVisibleSessions = useCallback(() => { - setProjectHeaderMenu(null) - setProjectHeaderSubmenu(null) - requestBatchDelete(filteredSessionIds) - }, [filteredSessionIds, requestBatchDelete]) - const confirmBatchDelete = useCallback(async () => { const ids = pendingBatchDeleteSessionIds ?? [] if (ids.length === 0) return @@ -668,14 +657,12 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { {expanded ? ( <>
-
- -