Restore sidebar project filter metadata and dropdown layering

The sidebar project filter had regressed to lossy projectPath strings, so the
UI showed broken folder-name fragments and the dropdown could not match the
richer project chooser used in session creation. This switches the sidebar back
to recent-project metadata, renders the filter menu through a portal, and keeps
session filtering behavior unchanged while restoring readable labels and paths.

Constraint: Sidebar filtering still needs to target existing session projectPath values without migrating store shape
Constraint: The desktop sidebar dropdown must escape local stacking and overflow constraints to render reliably
Rejected: Keep deriving labels from sanitized projectPath strings | lost repo names and full paths, causing misleading project choices
Rejected: Rebuild sessionStore around recent-project objects in this fix | broader state migration than needed for the regression
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep sidebar project displays aligned with /api/sessions/recent-projects metadata instead of reintroducing string-splitting fallbacks
Tested: cd desktop && bunx vitest run src/components/layout/ProjectFilter.test.tsx src/components/layout/Sidebar.test.tsx
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run build
Not-tested: Manual desktop visual pass of the repaired dropdown in a live Tauri window
This commit is contained in:
程序员阿江(Relakkes) 2026-04-20 23:45:53 +08:00
parent 9fdbfafd4d
commit e78b973e8c
6 changed files with 408 additions and 70 deletions

View File

@ -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) {

View File

@ -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<typeof import('../../api/sessions')>('../../api/sessions')
return {
...actual,
sessionsApi: {
...actual.sessionsApi,
getRecentProjects: getRecentProjectsMock,
},
}
})
vi.mock('../../i18n', () => ({
useTranslation: () => (key: string) => {
const translations: Record<string, string> = {
'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(<ProjectFilter />)
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)
})
})

View File

@ -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<RecentProject[]>([])
const [loading, setLoading] = useState(false)
const [dropdownPos, setDropdownPos] = useState<DropdownPos | null>(null)
const ref = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const dropdownRef = useRef<HTMLDivElement>(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<string, ProjectOption>()
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 (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-1 px-2 py-1 text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors rounded-[var(--radius-md)] hover:bg-[var(--color-surface-hover)]"
ref={triggerRef}
type="button"
onClick={() => setOpen((current) => !current)}
aria-expanded={open}
className="flex items-center gap-1.5 rounded-[var(--radius-lg)] bg-[var(--color-surface-container-low)] px-3 py-2 text-sm font-medium text-[var(--color-text-primary)] shadow-[0_1px_0_rgba(255,255,255,0.6)] transition-colors hover:bg-[var(--color-surface-hover)]"
>
<span className="truncate max-w-[140px]">{label}</span>
<span className="truncate max-w-[180px]">{label}</span>
<ChevronIcon open={open} />
</button>
{open && (
{open && dropdownPos && createPortal(
<div
className="absolute left-0 top-full mt-1 z-50 min-w-[200px] max-h-[300px] overflow-y-auto bg-[var(--color-surface)] border border-[var(--color-border)] rounded-[var(--radius-md)] py-1"
style={{ boxShadow: 'var(--shadow-dropdown)' }}
ref={dropdownRef}
className="w-[380px] max-w-[calc(100vw-32px)] overflow-hidden rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]"
style={{
position: 'fixed',
left: Math.min(dropdownPos.left, window.innerWidth - Math.min(380, window.innerWidth - 32) - 16),
...(dropdownPos.direction === 'down'
? { top: dropdownPos.top }
: { bottom: window.innerHeight - dropdownPos.top }),
boxShadow: 'var(--shadow-dropdown)',
zIndex: 9999,
}}
>
{/* All projects */}
<button
onClick={selectAll}
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-sm text-left hover:bg-[var(--color-surface-hover)] transition-colors"
>
<FolderIcon />
<span className="flex-1 text-[var(--color-text-primary)]">{t('sidebar.allProjects')}</span>
{isAllSelected && <CheckIcon />}
</button>
<div className="max-h-[360px] overflow-y-auto p-2">
<button
type="button"
onClick={selectAll}
className="flex w-full items-center gap-3 rounded-[16px] px-4 py-3 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
>
<FolderIcon />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-[var(--color-text-primary)]">{t('sidebar.allProjects')}</div>
</div>
{isAllSelected && <CheckIcon />}
</button>
<div className="mx-2 my-1 border-t border-[var(--color-border)]" />
<div className="mx-3 my-2 border-t border-[var(--color-border)]" />
{/* Individual projects */}
{availableProjects.map((path) => {
const checked = !isAllSelected && selectedProjects.includes(path)
return (
<button
key={path}
onClick={() => toggleProject(path)}
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-sm text-left hover:bg-[var(--color-surface-hover)] transition-colors"
>
<FolderIcon />
<span className="flex-1 truncate text-[var(--color-text-primary)]">{getDisplayName(path, t('sidebar.other'))}</span>
{checked && <CheckIcon />}
</button>
)
})}
</div>
{loading ? (
<div className="px-4 py-6 text-center text-xs text-[var(--color-text-tertiary)]">{t('common.loading')}</div>
) : options.length === 0 ? (
<div className="px-4 py-6 text-center text-xs text-[var(--color-text-tertiary)]">{t('sidebar.noSessions')}</div>
) : (
options.map((option) => {
const checked = !isAllSelected && selectedProjects.includes(option.projectPath)
return (
<button
key={option.projectPath}
type="button"
onClick={() => toggleProject(option.projectPath)}
className={`flex w-full items-center gap-3 rounded-[16px] px-4 py-3 text-left transition-colors hover:bg-[var(--color-surface-hover)] ${
checked ? 'bg-[var(--color-surface-selected)]' : ''
}`}
>
{option.isGit ? <GitBranchIcon /> : <FolderIcon />}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-[var(--color-text-primary)]">{option.title}</div>
{option.subtitle && (
<div className="truncate pt-0.5 text-[11px] text-[var(--color-text-tertiary)] font-[var(--font-mono)]">
{option.subtitle}
</div>
)}
</div>
<div className="flex flex-shrink-0 items-center gap-1.5">
{option.branch && (
<span className="max-w-[88px] truncate text-[10px] text-[var(--color-text-tertiary)]">
{option.branch}
</span>
)}
{checked && <CheckIcon />}
</div>
</button>
)
})
)}
</div>
</div>,
document.body,
)}
</div>
)
}
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 (
<svg
width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={`transition-transform ${open ? 'rotate-180' : ''}`}
>
<polyline points="6 9 12 15 18 9" />
@ -111,15 +279,56 @@ function ChevronIcon({ open }: { open: boolean }) {
function FolderIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-[var(--color-text-tertiary)] flex-shrink-0">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
className="flex-shrink-0 text-[var(--color-text-secondary)]"
>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
)
}
function GitBranchIcon() {
return (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
className="flex-shrink-0 text-[var(--color-text-secondary)]"
>
<circle cx="18" cy="18" r="3" />
<circle cx="6" cy="6" r="3" />
<path d="M13 6h3a2 2 0 0 1 2 2v7" />
<line x1="6" y1="9" x2="6" y2="21" />
</svg>
)
}
function CheckIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--color-brand)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="var(--color-brand)"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
className="flex-shrink-0"
>
<polyline points="20 6 9 17 4 12" />
</svg>
)

View File

@ -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(<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')
})
})

View File

@ -198,7 +198,11 @@ export function Sidebar() {
{sidebarOpen ? (
<>
<div className="sidebar-section sidebar-section--visible flex-none px-3 pb-1">
<div
data-testid="sidebar-project-filter-section"
className="sidebar-section sidebar-section--visible relative z-20 flex-none px-3 pb-1"
style={{ overflow: 'visible' }}
>
<div className="flex items-center justify-between">
<ProjectFilter />
</div>
@ -215,8 +219,11 @@ export function Sidebar() {
/>
</div>
<div className="sidebar-section sidebar-section--visible flex-1 min-h-0">
<div className="flex-1 overflow-y-auto px-3">
<div
data-testid="sidebar-session-list-section"
className="sidebar-section sidebar-section--visible flex flex-1 min-h-0 flex-col"
>
<div className="min-h-0 flex-1 overflow-y-auto px-3">
{error && (
<div className="mx-1 mt-2 rounded-[var(--radius-md)] border border-[var(--color-error)]/20 bg-[var(--color-error)]/5 px-3 py-2">
<div className="text-xs font-medium text-[var(--color-error)]">{t('sidebar.sessionListFailed')}</div>

View File

@ -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<Response>
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<Response> {
async function getRecentProjects(url: URL): Promise<Response> {
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<Response> {
// 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) })
}