mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Merge sidebar project controls cleanup
This commit is contained in:
commit
765d4244b4
@ -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: [],
|
||||
|
||||
@ -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: [],
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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<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,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<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,
|
||||
})
|
||||
}, [])
|
||||
|
||||
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<string, ProjectOption>()
|
||||
|
||||
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 (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
aria-expanded={open}
|
||||
aria-label={triggerLabel}
|
||||
title={triggerLabel}
|
||||
className={
|
||||
variant === 'embedded'
|
||||
? `inline-flex h-7 w-7 items-center justify-center rounded-[8px] border transition-colors duration-200 ${
|
||||
isAllSelected
|
||||
? 'border-transparent text-[var(--color-text-tertiary)] hover:bg-[var(--color-sidebar-item-hover)] hover:text-[var(--color-text-secondary)]'
|
||||
: 'border-[var(--color-sidebar-item-active-border)] bg-[var(--color-sidebar-item-active)] text-[var(--color-text-primary)] hover:bg-[var(--color-sidebar-item-hover)]'
|
||||
}`
|
||||
: 'inline-flex h-8 max-w-full items-center gap-1.5 rounded-[10px] border border-[var(--color-sidebar-filter-border)] bg-[var(--color-sidebar-filter-bg)] px-2 text-left text-[14px] text-[var(--color-text-primary)] transition-colors duration-200 hover:bg-[var(--color-sidebar-item-hover)]'
|
||||
}
|
||||
>
|
||||
{variant === 'embedded' ? (
|
||||
<span className="relative flex items-center justify-center">
|
||||
<FolderIcon className="h-[14px] w-[14px]" />
|
||||
{!isAllSelected && (
|
||||
<span className="absolute -right-1 -top-1 h-1.5 w-1.5 rounded-full bg-[var(--color-brand)]" />
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<FolderIcon className="h-[14px] w-[14px] text-[var(--color-text-secondary)]" />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-[14px] font-semibold tracking-tight">{label}</span>
|
||||
</span>
|
||||
<span className="flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center text-[var(--color-text-tertiary)] transition-colors">
|
||||
<ChevronIcon open={open} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && dropdownPos && createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="w-[360px] max-w-[calc(100vw-32px)] overflow-hidden rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: Math.min(dropdownPos.left, window.innerWidth - Math.min(360, window.innerWidth - 32) - 16),
|
||||
...(dropdownPos.direction === 'down'
|
||||
? { top: dropdownPos.top }
|
||||
: { bottom: window.innerHeight - dropdownPos.top }),
|
||||
boxShadow: 'var(--shadow-dropdown)',
|
||||
zIndex: 9999,
|
||||
}}
|
||||
>
|
||||
<div className="max-h-[360px] overflow-y-auto p-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={selectAll}
|
||||
className={`flex w-full items-center gap-3 rounded-[12px] px-3 py-2.5 text-left transition-colors ${
|
||||
isAllSelected
|
||||
? 'bg-[var(--color-sidebar-item-active)]'
|
||||
: 'hover:bg-[var(--color-sidebar-item-hover)]'
|
||||
}`}
|
||||
>
|
||||
<FolderIcon className="text-[var(--color-text-secondary)]" />
|
||||
<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-3 my-2 border-t border-[var(--color-border)]" />
|
||||
|
||||
{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-[12px] px-3 py-2.5 text-left transition-colors ${
|
||||
checked
|
||||
? 'bg-[var(--color-sidebar-item-active)]'
|
||||
: 'hover:bg-[var(--color-sidebar-item-hover)]'
|
||||
}`}
|
||||
>
|
||||
{option.isGit ? <GitBranchIcon className="text-[var(--color-text-secondary)]" /> : <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 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"
|
||||
className={`transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function FolderIcon({ className = 'text-[var(--color-text-secondary)]' }: { className?: string }) {
|
||||
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 ${className}`}
|
||||
>
|
||||
<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({ className = 'text-[var(--color-text-secondary)]' }: { className?: string }) {
|
||||
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 ${className}`}
|
||||
>
|
||||
<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="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>
|
||||
)
|
||||
}
|
||||
@ -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: () => <div data-testid="project-filter" />,
|
||||
}))
|
||||
|
||||
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(<Sidebar />)
|
||||
|
||||
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(<Sidebar />)
|
||||
|
||||
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(<Sidebar />)
|
||||
|
||||
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(<Sidebar />)
|
||||
|
||||
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(<Sidebar />)
|
||||
|
||||
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', () => {
|
||||
|
||||
@ -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 ? (
|
||||
<>
|
||||
<div
|
||||
data-testid="sidebar-project-filter-section"
|
||||
data-testid="sidebar-search-controls-section"
|
||||
className="sidebar-section sidebar-section--visible relative z-20 flex-none px-3 pb-2"
|
||||
style={{ overflow: 'visible' }}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex h-9 min-w-0 flex-1 items-center rounded-[14px] border border-[var(--color-sidebar-search-border)] bg-[var(--color-sidebar-search-bg)] pl-1.5 pr-3 transition-colors focus-within:border-[var(--color-border-focus)]">
|
||||
<ProjectFilter variant="embedded" />
|
||||
<span className="mx-2 h-4 w-px bg-[var(--color-border)]/80" aria-hidden="true" />
|
||||
<div className="flex h-9 min-w-0 flex-1 items-center rounded-[14px] border border-[var(--color-sidebar-search-border)] bg-[var(--color-sidebar-search-bg)] pl-3 pr-3 transition-colors focus-within:border-[var(--color-border-focus)]">
|
||||
<span className="pointer-events-none flex shrink-0 items-center text-[var(--color-text-tertiary)]">
|
||||
<SearchIcon />
|
||||
</span>
|
||||
@ -980,23 +967,16 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
))}
|
||||
</div>
|
||||
{(hiddenCount > 0 || sessionsExpanded) && (
|
||||
<div className="mt-2 flex justify-center">
|
||||
<div className="mt-2 flex justify-start px-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleProjectSessionExpansion(project.key)}
|
||||
className="inline-flex items-center justify-center gap-1 rounded-full bg-[var(--color-surface-container-lowest)] px-3 py-1.5 text-[11px] font-medium text-[var(--color-text-tertiary)] shadow-sm transition-colors hover:bg-[var(--color-sidebar-item-hover)] hover:text-[var(--color-text-secondary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
|
||||
className="inline-flex items-center justify-start py-1 text-[13px] font-semibold text-[var(--color-text-tertiary)] opacity-75 transition-[color,opacity] hover:text-[var(--color-text-secondary)] hover:opacity-100 focus-visible:rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
|
||||
aria-expanded={sessionsExpanded}
|
||||
>
|
||||
<span>
|
||||
{sessionsExpanded
|
||||
? t('sidebar.showFewerSessions')
|
||||
: t('sidebar.showMoreSessions', { count: hiddenCount })}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`h-3.5 w-3.5 transition-transform ${sessionsExpanded ? 'rotate-180' : ''}`}
|
||||
strokeWidth={1.8}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{sessionsExpanded
|
||||
? t('sidebar.showFewerSessions')
|
||||
: t('sidebar.showMoreSessions')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@ -1101,7 +1081,6 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
y={projectHeaderMenu.y}
|
||||
organization={projectOrganization}
|
||||
sortBy={projectSortBy}
|
||||
onArchiveAll={requestArchiveAllVisibleSessions}
|
||||
onOpenSubmenu={openProjectHeaderSubmenu}
|
||||
onSetOrganization={updateProjectOrganization}
|
||||
onSetSortBy={updateProjectSortBy}
|
||||
@ -1118,7 +1097,6 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
y={projectHeaderSubmenu.y}
|
||||
organization={projectOrganization}
|
||||
sortBy={projectSortBy}
|
||||
onArchiveAll={requestArchiveAllVisibleSessions}
|
||||
onOpenSubmenu={openProjectHeaderSubmenu}
|
||||
onSetOrganization={updateProjectOrganization}
|
||||
onSetSortBy={updateProjectSortBy}
|
||||
@ -1283,7 +1261,6 @@ function ProjectHeaderMenu({
|
||||
y,
|
||||
organization,
|
||||
sortBy,
|
||||
onArchiveAll,
|
||||
onOpenSubmenu,
|
||||
onSetOrganization,
|
||||
onSetSortBy,
|
||||
@ -1296,7 +1273,6 @@ function ProjectHeaderMenu({
|
||||
y: number
|
||||
organization: SidebarProjectOrganization
|
||||
sortBy: SidebarProjectSortBy
|
||||
onArchiveAll: () => void
|
||||
onOpenSubmenu: (event: React.MouseEvent, type: 'organize' | 'sort') => void
|
||||
onSetOrganization: (organization: SidebarProjectOrganization) => void
|
||||
onSetSortBy: (sortBy: SidebarProjectSortBy) => void
|
||||
@ -1352,10 +1328,6 @@ function ProjectHeaderMenu({
|
||||
|
||||
return (
|
||||
<div role="menu" className={className} style={style} onClick={(event) => event.stopPropagation()}>
|
||||
<HeaderMenuItem icon={<Archive size={18} aria-hidden="true" />} onClick={onArchiveAll}>
|
||||
{t('sidebar.archiveAllChats')}
|
||||
</HeaderMenuItem>
|
||||
<div className="mx-4 my-1.5 border-t border-[var(--color-border)]" />
|
||||
<HeaderMenuItem
|
||||
icon={<Folder size={18} aria-hidden="true" />}
|
||||
trailing
|
||||
|
||||
@ -110,8 +110,6 @@ describe('TabBar', () => {
|
||||
activeSessionId: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
selectedProjects: [],
|
||||
availableProjects: [],
|
||||
isBatchMode: false,
|
||||
selectedSessionIds: new Set(),
|
||||
} as Partial<ReturnType<typeof useSessionStore.getState>>)
|
||||
|
||||
@ -50,8 +50,8 @@ export const en = {
|
||||
'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',
|
||||
|
||||
@ -52,8 +52,8 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'sidebar.restoreProjectToSidebar': '恢复到侧边栏',
|
||||
'sidebar.projectHidden': '已从侧边栏移除 {project}。',
|
||||
'sidebar.newSessionInProject': '在 {project} 中新建会话',
|
||||
'sidebar.showMoreSessions': '查看更多 {count} 个',
|
||||
'sidebar.showFewerSessions': '收起会话',
|
||||
'sidebar.showMoreSessions': '展开显示',
|
||||
'sidebar.showFewerSessions': '折叠显示',
|
||||
'sidebar.expandProject': '展开 {project}',
|
||||
'sidebar.collapseProject': '折叠 {project}',
|
||||
'sidebar.worktree': 'worktree',
|
||||
|
||||
@ -41,8 +41,6 @@ describe('sessionStore', () => {
|
||||
activeSessionId: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
selectedProjects: [],
|
||||
availableProjects: [],
|
||||
})
|
||||
useTabStore.setState({ tabs: [], activeTabId: null })
|
||||
})
|
||||
|
||||
@ -14,8 +14,6 @@ type SessionStore = {
|
||||
activeSessionId: string | null
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
selectedProjects: string[]
|
||||
availableProjects: string[]
|
||||
isBatchMode: boolean
|
||||
selectedSessionIds: Set<string>
|
||||
|
||||
@ -32,7 +30,6 @@ type SessionStore = {
|
||||
renameSession: (id: string, title: string) => Promise<void>
|
||||
updateSessionTitle: (id: string, title: string) => void
|
||||
setActiveSession: (id: string | null) => void
|
||||
setSelectedProjects: (projects: string[]) => void
|
||||
}
|
||||
|
||||
export const useSessionStore = create<SessionStore>((set, get) => ({
|
||||
@ -40,8 +37,6 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
|
||||
activeSessionId: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
selectedProjects: [],
|
||||
availableProjects: [],
|
||||
isBatchMode: false,
|
||||
selectedSessionIds: new Set(),
|
||||
|
||||
@ -64,8 +59,7 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
|
||||
}
|
||||
const sessions = [...byId.values()]
|
||||
syncedSessions = sessions
|
||||
const availableProjects = [...new Set(sessions.map((s) => s.projectRoot || s.projectPath).filter(Boolean))].sort()
|
||||
return { sessions, availableProjects, isLoading: false }
|
||||
return { sessions, isLoading: false }
|
||||
})
|
||||
syncOpenSessionTabTitles(syncedSessions)
|
||||
} catch (err) {
|
||||
@ -167,7 +161,6 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
|
||||
},
|
||||
|
||||
setActiveSession: (id) => set({ activeSessionId: id }),
|
||||
setSelectedProjects: (projects) => set({ selectedProjects: projects }),
|
||||
}))
|
||||
|
||||
function removeIdsFromSet(selected: Set<string>, ids: string[]): Set<string> {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user