mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
9fdbfafd4d
commit
e78b973e8c
@ -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) {
|
||||
|
||||
96
desktop/src/components/layout/ProjectFilter.test.tsx
Normal file
96
desktop/src/components/layout/ProjectFilter.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) })
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user