diff --git a/desktop/src/api/sessions.ts b/desktop/src/api/sessions.ts index 4faa99aa..c1a21c3d 100644 --- a/desktop/src/api/sessions.ts +++ b/desktop/src/api/sessions.ts @@ -42,8 +42,9 @@ export const sessionsApi = { return api.patch<{ ok: true }>(`/api/sessions/${sessionId}`, { title }) }, - getRecentProjects() { - return api.get<{ projects: RecentProject[] }>('/api/sessions/recent-projects') + getRecentProjects(limit?: number) { + const query = typeof limit === 'number' ? `?limit=${limit}` : '' + return api.get<{ projects: RecentProject[] }>(`/api/sessions/recent-projects${query}`) }, getGitInfo(sessionId: string) { diff --git a/desktop/src/components/layout/ProjectFilter.test.tsx b/desktop/src/components/layout/ProjectFilter.test.tsx new file mode 100644 index 00000000..1a861490 --- /dev/null +++ b/desktop/src/components/layout/ProjectFilter.test.tsx @@ -0,0 +1,96 @@ +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 index 406524f3..f94fc0c2 100644 --- a/desktop/src/components/layout/ProjectFilter.tsx +++ b/desktop/src/components/layout/ProjectFilter.tsx @@ -1,44 +1,154 @@ -import { useState, useRef, useEffect } from 'react' +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() { 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, + }) + }, []) - // Close dropdown on click outside useEffect(() => { if (!open) return - const close = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) + + 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', close) - return () => document.removeEventListener('mousedown', close) + + 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) { + if (!availableSet.has(project.projectPath)) continue + optionsByPath.set(project.projectPath, { + projectPath: project.projectPath, + 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 - ? getDisplayName(selectedProjects[0]!, t('sidebar.other')) + ? optionByPath.get(selectedProjects[0]!)?.title || fallbackProjectTitle(selectedProjects[0]!, t('sidebar.other')) : `${selectedProjects.length} projects` - const toggleProject = (path: string) => { + const toggleProject = (projectPath: string) => { if (isAllSelected) { - // Switch from "all" to "only this one" - setSelectedProjects([path]) - } else if (selectedProjects.includes(path)) { - const next = selectedProjects.filter((p) => p !== path) - // If nothing left, revert to all - setSelectedProjects(next.length === 0 ? [] : next) - } else { - const next = [...selectedProjects, path] - // If all are selected individually, revert to "all" - setSelectedProjects(next.length >= availableProjects.length ? [] : next) + 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([]) @@ -46,62 +156,120 @@ export function ProjectFilter() { return (
- {open && ( + {open && dropdownPos && createPortal(
- {/* All projects */} - +
+ -
+
- {/* Individual projects */} - {availableProjects.map((path) => { - const checked = !isAllSelected && selectedProjects.includes(path) - return ( - - ) - })} -
+ {loading ? ( +
{t('common.loading')}
+ ) : options.length === 0 ? ( +
{t('sidebar.noSessions')}
+ ) : ( + options.map((option) => { + const checked = !isAllSelected && selectedProjects.includes(option.projectPath) + return ( + + ) + }) + )} +
+
, + document.body, )}
) } -function getDisplayName(sanitizedPath: string, fallback: string = 'Other'): string { - if (!sanitizedPath || sanitizedPath === '_unknown') return fallback - const segments = sanitizedPath.split('-').filter(Boolean) - return segments[segments.length - 1] || fallback +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 ( @@ -111,15 +279,56 @@ function ChevronIcon({ open }: { open: boolean }) { function FolderIcon() { return ( - + ) } +function GitBranchIcon() { + return ( + + + + + + + ) +} + function CheckIcon() { return ( - + ) diff --git a/desktop/src/components/layout/Sidebar.test.tsx b/desktop/src/components/layout/Sidebar.test.tsx index 48bd620a..c98ef8fe 100644 --- a/desktop/src/components/layout/Sidebar.test.tsx +++ b/desktop/src/components/layout/Sidebar.test.tsx @@ -179,4 +179,17 @@ describe('Sidebar', () => { 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() + + 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() + + expect(screen.getByTestId('sidebar-session-list-section')).toHaveClass('flex', 'flex-1', 'min-h-0', 'flex-col') + }) }) diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index 3599f2b2..98ca2a70 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -198,7 +198,11 @@ export function Sidebar() { {sidebarOpen ? ( <> -
+
@@ -215,8 +219,11 @@ export function Sidebar() { />
-
-
+
+
{error && (
{t('sidebar.sessionListFailed')}
diff --git a/src/server/api/sessions.ts b/src/server/api/sessions.ts index 4210c5fb..beffa881 100644 --- a/src/server/api/sessions.ts +++ b/src/server/api/sessions.ts @@ -47,7 +47,7 @@ export async function handleSessionsApi( // Special collection route: /api/sessions/recent-projects if (sessionId === 'recent-projects' && req.method === 'GET') { - return await getRecentProjects() + return await getRecentProjects(url) } // ----------------------------------------------------------------------- @@ -268,14 +268,27 @@ async function patchSession(req: Request, sessionId: string): Promise return Response.json({ ok: true }) } +type RecentProjectEntry = { + projectPath: string + realPath: string + projectName: string + isGit: boolean + repoName: string | null + branch: string | null + modifiedAt: string + sessionCount: number +} + // In-memory cache for recent projects (TTL: 30s) -let recentProjectsCache: { data: Response; timestamp: number } | null = null +let recentProjectsCache: { projects: RecentProjectEntry[]; timestamp: number } | null = null const RECENT_PROJECTS_CACHE_TTL = 30_000 -async function getRecentProjects(): Promise { +async function getRecentProjects(url: URL): Promise { + const limit = Math.min(Math.max(parseInt(url.searchParams.get('limit') || '10', 10) || 10, 1), 500) + // Return cached response if fresh if (recentProjectsCache && Date.now() - recentProjectsCache.timestamp < RECENT_PROJECTS_CACHE_TTL) { - return recentProjectsCache.data.clone() + return Response.json({ projects: recentProjectsCache.projects.slice(0, limit) }) } const { sessions } = await sessionService.listSessions({ limit: 200 }) @@ -356,7 +369,6 @@ async function getRecentProjects(): Promise { // Sort by most recent projects.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt)) - const response = Response.json({ projects: projects.slice(0, 10) }) - recentProjectsCache = { data: response.clone(), timestamp: Date.now() } - return response + recentProjectsCache = { projects, timestamp: Date.now() } + return Response.json({ projects: projects.slice(0, limit) }) }