Merge sidebar project controls cleanup

This commit is contained in:
程序员阿江(Relakkes) 2026-05-15 19:37:15 +08:00
commit 765d4244b4
12 changed files with 43 additions and 556 deletions

View File

@ -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: [],

View File

@ -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: [],

View File

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

View File

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

View File

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

View File

@ -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', () => {

View File

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

View File

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

View File

@ -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',

View File

@ -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',

View File

@ -41,8 +41,6 @@ describe('sessionStore', () => {
activeSessionId: null,
isLoading: false,
error: null,
selectedProjects: [],
availableProjects: [],
})
useTabStore.setState({ tabs: [], activeTabId: null })
})

View File

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