mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
fix: reduce risky sidebar controls to project organization
The sidebar now groups conversations by project, so the embedded project picker in the search box duplicated the main navigation model and hid state inside a compact icon. Remove that picker and its store state, leaving search as plain text filtering and keeping the project header menu focused on organization and sorting. The archive-all entry was also hidden because a broad destructive action does not belong in the lightweight project menu. Constraint: Project grouping is now the primary project navigation surface. Rejected: Keep the embedded project picker hidden in place | it would leave dead filtering state and a stale recovery path. Rejected: Keep archive-all in the header menu | broad destructive actions are too risky for this surface. Confidence: high Scope-risk: moderate Directive: Do not reintroduce broad session deletion or hidden project filters into the sidebar header without a dedicated reviewed management flow. Tested: cd desktop && bun run test src/components/layout/Sidebar.test.tsx src/stores/sessionStore.test.ts src/components/layout/TabBar.test.tsx Tested: cd desktop && bun run lint Tested: cd desktop && bun run build
This commit is contained in:
parent
4e969475f2
commit
ba1ab98c2e
@ -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