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:
程序员阿江(Relakkes) 2026-05-15 19:36:52 +08:00
parent 4e969475f2
commit ba1ab98c2e
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> {