Group desktop sessions by project root

The desktop sidebar needed a project-first navigation model that keeps worktree sessions attached to their source project instead of scattering them as separate paths. The UI now renders sessions under project groups with project-level actions and persisted ordering, while the server exposes a logical project root for worktree transcripts.

Constraint: Existing memory directory tree work must remain separate from the session sidebar behavior.

Rejected: Group by raw transcript projectPath | worktree paths fragment the same repository into multiple sidebar projects.

Confidence: high

Scope-risk: moderate

Directive: Keep future sidebar grouping keyed by projectRoot before projectPath so isolated worktrees stay under their source repository.

Tested: cd desktop && bun run test -- src/components/layout/Sidebar.test.tsx

Tested: bun test src/server/__tests__/sessions.test.ts

Tested: git diff --check
This commit is contained in:
程序员阿江(Relakkes) 2026-05-15 16:01:36 +08:00
parent b98e77cd39
commit 0e78439f9d
10 changed files with 1075 additions and 170 deletions

View File

@ -99,9 +99,10 @@ export function ProjectFilter({ variant = 'default' }: { variant?: 'default' | '
const optionsByPath = new Map<string, ProjectOption>()
for (const project of projects) {
if (!availableSet.has(project.projectPath)) continue
optionsByPath.set(project.projectPath, {
projectPath: project.projectPath,
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,

View File

@ -6,6 +6,18 @@ vi.mock('./ProjectFilter', () => ({
ProjectFilter: () => <div data-testid="project-filter" />,
}))
const openTargetStoreMock = vi.hoisted(() => ({
ensureTargets: vi.fn(),
openTarget: vi.fn(),
targets: [{ id: 'finder', kind: 'file_manager', label: 'Finder', platform: 'darwin' }],
}))
vi.mock('../../stores/openTargetStore', () => ({
useOpenTargetStore: {
getState: () => openTargetStoreMock,
},
}))
vi.mock('../../i18n', () => ({
useTranslation: () => (key: string, params?: Record<string, string | number>) => {
const translations: Record<string, string> = {
@ -17,6 +29,23 @@ vi.mock('../../i18n', () => ({
'sidebar.noMatching': 'No matching sessions',
'sidebar.sessionListFailed': 'Session list failed',
'sidebar.refreshSessions': 'Refresh sessions',
'sidebar.projects': 'Projects',
'sidebar.projectActions': 'Project actions for {project}',
'sidebar.pinProject': 'Pin Project',
'sidebar.unpinProject': 'Unpin Project',
'sidebar.openInFinder': 'Open in Finder',
'sidebar.openInFinderFailed': 'Could not open the project in Finder.',
'sidebar.openInFinderUnavailable': 'No file manager is available.',
'sidebar.createPermanentWorktree': 'Create Permanent Worktree',
'sidebar.renameProject': 'Rename Project',
'sidebar.archiveSessions': 'Archive Conversations',
'sidebar.removeProject': 'Remove',
'sidebar.newSessionInProject': 'New session in {project}',
'sidebar.showMoreSessions': 'Show {count} more',
'sidebar.showFewerSessions': 'Show fewer',
'sidebar.expandProject': 'Expand {project}',
'sidebar.collapseProject': 'Collapse {project}',
'sidebar.worktree': 'worktree',
'common.retry': 'Retry',
'common.loading': 'Loading...',
'common.cancel': 'Cancel',
@ -58,6 +87,51 @@ import { useChatStore } from '../../stores/chatStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useTabStore } from '../../stores/tabStore'
import { useUIStore } from '../../stores/uiStore'
import type { SessionListItem } from '../../types/session'
const PROJECT_ORDER_STORAGE_KEY = 'cc-haha-sidebar-project-order'
const PROJECT_PINNED_STORAGE_KEY = 'cc-haha-sidebar-pinned-projects'
function makeSession(
id: string,
title: string,
projectRoot: string,
modifiedAt: string,
): SessionListItem {
return {
id,
title,
createdAt: modifiedAt,
modifiedAt,
messageCount: 1,
projectPath: projectRoot,
projectRoot,
workDir: projectRoot,
workDirExists: true,
}
}
function makeDataTransfer() {
const data = new Map<string, string>()
return {
effectAllowed: '',
dropEffect: '',
setData: vi.fn((type: string, value: string) => data.set(type, value)),
getData: vi.fn((type: string) => data.get(type) ?? ''),
}
}
function projectGroupNames(): string[] {
return screen
.getAllByTestId(/^sidebar-project-group-/)
.map((group) => group.textContent ?? '')
.map((text) => {
if (text.includes('alpha')) return 'alpha'
if (text.includes('beta')) return 'beta'
if (text.includes('gamma')) return 'gamma'
return text
})
}
describe('Sidebar', () => {
const connectToSession = vi.fn()
@ -76,6 +150,11 @@ describe('Sidebar', () => {
deleteSession.mockReset()
deleteSessions.mockReset()
addToast.mockReset()
openTargetStoreMock.ensureTargets.mockReset()
openTargetStoreMock.openTarget.mockReset()
openTargetStoreMock.targets = [{ id: 'finder', kind: 'file_manager', label: 'Finder', platform: 'darwin' }]
window.localStorage.removeItem(PROJECT_ORDER_STORAGE_KEY)
window.localStorage.removeItem(PROJECT_PINNED_STORAGE_KEY)
useTabStore.setState({ tabs: [], activeTabId: null })
useSessionStore.setState({
@ -106,6 +185,8 @@ describe('Sidebar', () => {
vi.useRealTimers()
cleanup()
useTabStore.setState({ tabs: [], activeTabId: null })
window.localStorage.removeItem(PROJECT_ORDER_STORAGE_KEY)
window.localStorage.removeItem(PROJECT_PINNED_STORAGE_KEY)
})
it('opens a new tab when creating a session from the sidebar', async () => {
@ -129,6 +210,252 @@ describe('Sidebar', () => {
expect(screen.getByRole('complementary')).not.toHaveAttribute('data-tauri-drag-region')
})
it('groups sessions by project and expands overflow rows', () => {
const base = new Date('2026-05-15T10:00:00.000Z').getTime()
useSessionStore.setState({
sessions: [
...Array.from({ length: 11 }, (_, index) => (
makeSession(
`alpha-${index + 1}`,
index === 0 ? 'Alpha newest' : index === 10 ? 'Alpha hidden' : `Alpha ${index + 1}`,
'/workspace/alpha',
new Date(base - index * 1000).toISOString(),
)
)),
makeSession('beta-1', 'Beta only', '/workspace/beta', new Date(base - 4000).toISOString()),
],
})
render(<Sidebar />)
expect(screen.getByText('Projects')).toBeInTheDocument()
expect(screen.getByText('alpha')).toBeInTheDocument()
expect(screen.queryByText('/workspace/alpha')).not.toBeInTheDocument()
expect(screen.getByText('beta')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Alpha newest/ })).toBeInTheDocument()
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' }))
expect(screen.getByRole('button', { name: /Alpha hidden/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Show fewer' })).toBeInTheDocument()
})
it('reorders project groups by dragging project headers while preserving expanded state', async () => {
const base = new Date('2026-05-15T10:00:00.000Z').getTime()
useSessionStore.setState({
sessions: [
...Array.from({ length: 11 }, (_, index) => (
makeSession(
`alpha-${index + 1}`,
index === 10 ? 'Alpha hidden' : `Alpha ${index + 1}`,
'/workspace/alpha',
new Date(base - index * 1000).toISOString(),
)
)),
makeSession('beta-1', 'Beta Session', '/workspace/beta', new Date(base - 20_000).toISOString()),
makeSession('gamma-1', 'Gamma Session', '/workspace/gamma', new Date(base - 30_000).toISOString()),
],
})
render(<Sidebar />)
fireEvent.click(screen.getByRole('button', { name: 'Show 5 more' }))
expect(screen.getByRole('button', { name: /Alpha hidden/ })).toBeInTheDocument()
expect(projectGroupNames().slice(0, 3)).toEqual(['alpha', 'beta', 'gamma'])
const dataTransfer = makeDataTransfer()
const alphaGroup = screen.getByTestId('sidebar-project-group-workspace-alpha')
vi.spyOn(alphaGroup, 'getBoundingClientRect').mockReturnValue({
top: 0,
bottom: 100,
left: 0,
right: 280,
width: 280,
height: 100,
x: 0,
y: 0,
toJSON: () => ({}),
})
fireEvent.dragStart(screen.getByRole('button', { name: 'Collapse gamma' }), { dataTransfer })
fireEvent.dragOver(alphaGroup, { clientY: -10, dataTransfer })
fireEvent.drop(alphaGroup, { clientY: -10, dataTransfer })
await waitFor(() => {
expect(projectGroupNames().slice(0, 3)).toEqual(['alpha', 'gamma', 'beta'])
})
expect(screen.getByRole('button', { name: /Alpha hidden/ })).toBeInTheDocument()
expect(JSON.parse(window.localStorage.getItem(PROJECT_ORDER_STORAGE_KEY) ?? '[]').slice(0, 3)).toEqual([
'/workspace/alpha',
'/workspace/gamma',
'/workspace/beta',
])
})
it('restores the saved project drag order on render', () => {
window.localStorage.setItem(PROJECT_ORDER_STORAGE_KEY, JSON.stringify([
'/workspace/beta',
'/workspace/alpha',
]))
const now = new Date().toISOString()
useSessionStore.setState({
sessions: [
makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', now),
makeSession('beta-1', 'Beta Session', '/workspace/beta', now),
makeSession('gamma-1', 'Gamma Session', '/workspace/gamma', now),
],
})
render(<Sidebar />)
expect(projectGroupNames().slice(0, 3)).toEqual(['beta', 'alpha', 'gamma'])
})
it('collapses a project group without removing the project header', () => {
const now = new Date().toISOString()
useSessionStore.setState({
sessions: [
makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', now),
makeSession('beta-1', 'Beta Session', '/workspace/beta', now),
],
})
render(<Sidebar />)
fireEvent.click(screen.getByRole('button', { name: 'Collapse alpha' }))
expect(screen.getByText('alpha')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Alpha Session/ })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /Beta Session/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Expand alpha' })).toBeInTheDocument()
})
it('uses a bounded per-project session scroller for large expanded groups', () => {
const base = new Date('2026-05-15T10:00:00.000Z').getTime()
useSessionStore.setState({
sessions: Array.from({ length: 14 }, (_, index) => (
makeSession(`alpha-${index + 1}`, `Alpha ${index + 1}`, '/workspace/alpha', new Date(base - index * 1000).toISOString())
)),
})
render(<Sidebar />)
fireEvent.click(screen.getByRole('button', { name: 'Show 8 more' }))
expect(screen.getByTestId('sidebar-project-session-list-workspace-alpha')).toHaveClass('max-h-[420px]', 'overflow-y-auto')
})
it('creates a new session from the project group context', async () => {
createSession.mockResolvedValue('session-alpha-new')
useSessionStore.setState({
sessions: [
makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', new Date().toISOString()),
],
})
render(<Sidebar />)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'New session in alpha' }))
})
await waitFor(() => {
expect(createSession).toHaveBeenCalledWith('/workspace/alpha')
expect(connectToSession).toHaveBeenCalledWith('session-alpha-new')
})
})
it('keeps project row actions hidden until project hover or focus', () => {
useSessionStore.setState({
sessions: [
makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', new Date().toISOString()),
],
})
render(<Sidebar />)
const actionButton = screen.getByRole('button', { name: 'Project actions for alpha' })
expect(actionButton.parentElement).toHaveClass('opacity-0')
expect(actionButton.parentElement).toHaveClass('group-hover/project:opacity-100')
expect(actionButton.parentElement).toHaveClass('group-focus-within/project:opacity-100')
})
it('shows the project action menu with pin and Finder actions', async () => {
const now = new Date().toISOString()
useSessionStore.setState({
sessions: [
makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', now),
],
})
render(<Sidebar />)
fireEvent.click(screen.getByRole('button', { name: 'Project actions for alpha' }))
expect(screen.getByRole('menuitem', { name: 'Pin Project' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'Open in Finder' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'Create Permanent Worktree' })).toBeDisabled()
expect(screen.getByRole('menuitem', { name: 'Rename Project' })).toBeDisabled()
expect(screen.getByRole('menuitem', { name: 'Archive Conversations' })).toBeDisabled()
expect(screen.getByRole('menuitem', { name: 'Remove' })).toBeDisabled()
await act(async () => {
fireEvent.click(screen.getByRole('menuitem', { name: 'Open in Finder' }))
})
expect(openTargetStoreMock.ensureTargets).toHaveBeenCalledTimes(1)
expect(openTargetStoreMock.openTarget).toHaveBeenCalledWith('finder', '/workspace/alpha')
})
it('pins a project above the rest of the project list', async () => {
const base = new Date('2026-05-15T10:00:00.000Z').getTime()
useSessionStore.setState({
sessions: [
makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', new Date(base).toISOString()),
makeSession('beta-1', 'Beta Session', '/workspace/beta', new Date(base - 20_000).toISOString()),
],
})
render(<Sidebar />)
expect(projectGroupNames().slice(0, 2)).toEqual(['alpha', 'beta'])
fireEvent.click(screen.getByRole('button', { name: 'Project actions for beta' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Pin Project' }))
await waitFor(() => {
expect(projectGroupNames().slice(0, 2)).toEqual(['beta', 'alpha'])
})
expect(JSON.parse(window.localStorage.getItem(PROJECT_PINNED_STORAGE_KEY) ?? '[]')).toEqual(['/workspace/beta'])
})
it('keeps persisted worktree sessions under the source project group', () => {
const now = new Date().toISOString()
useSessionStore.setState({
sessions: [
makeSession('source-1', 'Source Session', '/workspace/repo', now),
{
...makeSession('worktree-1', 'Worktree Session', '/workspace/repo/.claude/worktrees/desktop-main-12345678', now),
projectRoot: '/workspace/repo',
},
{
...makeSession('subdir-1', 'Subdir Session', '/workspace/repo/packages/app', now),
projectRoot: '/workspace/repo',
},
],
})
render(<Sidebar />)
expect(screen.getByText('repo')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Source Session/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Worktree Session/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Subdir Session/ })).toBeInTheDocument()
expect(screen.getAllByText('worktree')).toHaveLength(1)
})
it('shows a toast when session creation fails', async () => {
createSession.mockRejectedValue(new Error('boom'))
@ -308,7 +635,7 @@ describe('Sidebar', () => {
fireEvent.click(screen.getByRole('button', { name: 'Batch manage' }))
fireEvent.click(screen.getByRole('button', { name: /First Session/ }))
expect(screen.getByRole('button', { name: /First Session/ }).parentElement).toHaveClass('mb-1.5')
expect(screen.getByRole('button', { name: /First Session/ }).parentElement).toHaveClass('mb-0.5')
expect(screen.getByRole('button', { name: /First Session/ })).toHaveClass('sidebar-session-row--selected')
expect(screen.getByRole('button', { name: /Second Session/ })).toHaveClass('sidebar-session-row--active')
expect(screen.getByRole('button', { name: /Third Session/ })).toHaveClass('sidebar-session-row--idle')

View File

@ -1,5 +1,5 @@
import { useEffect, useState, useCallback, useMemo, useRef } from 'react'
import { RefreshCw } from 'lucide-react'
import { Archive, ChevronDown, Folder, FolderOpen, GitBranch, MoreHorizontal, Pencil, Pin, PinOff, RefreshCw, SquarePen, X } from 'lucide-react'
import { useSessionStore } from '../../stores/sessionStore'
import { useUIStore } from '../../stores/uiStore'
import { useTranslation } from '../../i18n'
@ -8,15 +8,24 @@ import { ConfirmDialog } from '../shared/ConfirmDialog'
import type { SessionListItem } from '../../types/session'
import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID } from '../../stores/tabStore'
import { useChatStore } from '../../stores/chatStore'
import { useOpenTargetStore } from '../../stores/openTargetStore'
const isTauri = typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
const isWindows = typeof navigator !== 'undefined' && /Win/.test(navigator.platform)
const SESSION_LIST_AUTO_REFRESH_MS = 30_000
const SESSION_LIST_FOCUS_REFRESH_MIN_MS = 5_000
const PROJECT_ORDER_STORAGE_KEY = 'cc-haha-sidebar-project-order'
const PROJECT_PINNED_STORAGE_KEY = 'cc-haha-sidebar-pinned-projects'
const PROJECT_GROUP_VISIBLE_COUNT = 6
const PROJECT_GROUP_SCROLL_COUNT = 12
type TimeGroup = 'today' | 'yesterday' | 'last7days' | 'last30days' | 'older'
const TIME_GROUP_ORDER: TimeGroup[] = ['today', 'yesterday', 'last7days', 'last30days', 'older']
type ProjectGroup = {
key: string
title: string
subtitle: string | null
workDir: string | undefined
sessions: SessionListItem[]
}
type SidebarProps = {
isMobile?: boolean
@ -48,12 +57,20 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
const disconnectSession = useChatStore((s) => s.disconnectSession)
const [searchQuery, setSearchQuery] = useState('')
const [contextMenu, setContextMenu] = useState<{ id: string; x: number; y: number } | null>(null)
const [projectContextMenu, setProjectContextMenu] = useState<{ key: string; x: number; y: number } | null>(null)
const [pendingDeleteSessionId, setPendingDeleteSessionId] = useState<string | null>(null)
const [pendingBatchDeleteSessionIds, setPendingBatchDeleteSessionIds] = useState<string[] | null>(null)
const [isBatchDeleting, setIsBatchDeleting] = useState(false)
const [renamingId, setRenamingId] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('')
const [lastSelectedSessionId, setLastSelectedSessionId] = useState<string | null>(null)
const [expandedProjectKeys, setExpandedProjectKeys] = useState<Set<string>>(new Set())
const [collapsedProjectKeys, setCollapsedProjectKeys] = useState<Set<string>>(new Set())
const [projectOrder, setProjectOrder] = useState<string[]>(() => readStoredProjectOrder())
const [pinnedProjectKeys, setPinnedProjectKeys] = useState<Set<string>>(() => readStoredProjectPins())
const [draggingProjectKey, setDraggingProjectKey] = useState<string | null>(null)
const [projectDropTarget, setProjectDropTarget] = useState<{ key: string; position: 'before' | 'after' } | null>(null)
const suppressProjectClickRef = useRef<string | null>(null)
const refreshSessionsNow = useSessionListAutoRefresh(fetchSessions)
useEffect(() => {
@ -63,16 +80,19 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
}, [sidebarOpen])
useEffect(() => {
if (!contextMenu) return
const close = () => setContextMenu(null)
if (!contextMenu && !projectContextMenu) return
const close = () => {
setContextMenu(null)
setProjectContextMenu(null)
}
document.addEventListener('click', close)
return () => document.removeEventListener('click', close)
}, [contextMenu])
}, [contextMenu, projectContextMenu])
const filteredSessions = useMemo(() => {
let result = sessions
if (selectedProjects.length > 0) {
result = result.filter((s) => selectedProjects.includes(s.projectPath))
result = result.filter((s) => selectedProjects.includes(getSessionProjectKey(s)))
}
if (searchQuery) {
const q = searchQuery.toLowerCase()
@ -81,7 +101,11 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
return result
}, [sessions, selectedProjects, searchQuery])
const timeGroups = useMemo(() => groupByTime(filteredSessions), [filteredSessions])
const projectGroups = useMemo(() => groupByProject(filteredSessions), [filteredSessions])
const orderedProjectGroups = useMemo(
() => applyProjectOrder(projectGroups, projectOrder, pinnedProjectKeys),
[projectGroups, projectOrder, pinnedProjectKeys],
)
const showInitialLoading = isLoading && sessions.length === 0
const filteredSessionIds = useMemo(() => filteredSessions.map((session) => session.id), [filteredSessions])
const selectedCount = selectedSessionIds.size
@ -95,6 +119,10 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
.filter((session): session is SessionListItem => Boolean(session)),
[pendingBatchDeleteSessionIds, sessionsById],
)
const expanded = isMobile ? true : sidebarOpen
const closeMobileDrawer = useCallback(() => {
if (isMobile) onRequestClose?.()
}, [isMobile, onRequestClose])
const handleContextMenu = useCallback((e: React.MouseEvent, id: string) => {
e.preventDefault()
@ -102,6 +130,111 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
setContextMenu({ id, x: e.clientX, y: e.clientY })
}, [isBatchMode])
const handleProjectDragStart = useCallback((event: React.DragEvent, projectKey: string) => {
if (isBatchMode) {
event.preventDefault()
return
}
suppressProjectClickRef.current = projectKey
setDraggingProjectKey(projectKey)
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', projectKey)
}, [isBatchMode])
const handleProjectDragOver = useCallback((event: React.DragEvent<HTMLElement>, projectKey: string) => {
const sourceProjectKey = draggingProjectKey || event.dataTransfer.getData('text/plain')
if (!sourceProjectKey || sourceProjectKey === projectKey) return
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
const position = getProjectDropPosition(event)
setProjectDropTarget((current) => (
current?.key === projectKey && current.position === position
? current
: { key: projectKey, position }
))
}, [draggingProjectKey])
const clearProjectDragState = useCallback(() => {
setDraggingProjectKey(null)
setProjectDropTarget(null)
window.setTimeout(() => {
suppressProjectClickRef.current = null
}, 0)
}, [])
const handleProjectDrop = useCallback((event: React.DragEvent<HTMLElement>, targetProjectKey: string) => {
event.preventDefault()
const sourceProjectKey = draggingProjectKey || event.dataTransfer.getData('text/plain')
const dropPosition = projectDropTarget?.key === targetProjectKey
? projectDropTarget.position
: getProjectDropPosition(event)
if (!sourceProjectKey || sourceProjectKey === targetProjectKey) {
clearProjectDragState()
return
}
const nextOrder = moveProjectKey(
orderedProjectGroups.map((project) => project.key),
sourceProjectKey,
targetProjectKey,
dropPosition,
)
setProjectOrder(nextOrder)
writeStoredProjectOrder(nextOrder)
clearProjectDragState()
}, [clearProjectDragState, draggingProjectKey, orderedProjectGroups, projectDropTarget])
const createSessionForWorkDir = useCallback(async (workDir?: string) => {
try {
const sessionId = await useSessionStore.getState().createSession(workDir)
useTabStore.getState().openTab(sessionId, t('sidebar.newSession'))
useChatStore.getState().connectToSession(sessionId)
closeMobileDrawer()
} catch (error) {
addToast({
type: 'error',
message: error instanceof Error ? error.message : t('sidebar.sessionListFailed'),
})
}
}, [addToast, closeMobileDrawer, t])
const togglePinnedProject = useCallback((projectKey: string) => {
setProjectContextMenu(null)
setPinnedProjectKeys((current) => {
const next = new Set(current)
if (next.has(projectKey)) {
next.delete(projectKey)
} else {
next.add(projectKey)
}
writeStoredProjectPins(next)
return next
})
}, [])
const openProjectInFinder = useCallback(async (project: ProjectGroup) => {
setProjectContextMenu(null)
try {
if (!project.workDir) {
throw new Error(t('sidebar.openInFinderUnavailable'))
}
const store = useOpenTargetStore.getState()
await store.ensureTargets()
const latest = useOpenTargetStore.getState()
const target = latest.targets.find((item) => item.id === 'finder')
?? latest.targets.find((item) => item.kind === 'file_manager')
if (!target) {
throw new Error(t('sidebar.openInFinderUnavailable'))
}
await latest.openTarget(target.id, project.workDir)
} catch (error) {
addToast({
type: 'error',
message: error instanceof Error ? error.message : t('sidebar.openInFinderFailed'),
})
}
}, [addToast, t])
const handleDelete = useCallback((id: string) => {
setContextMenu(null)
setPendingDeleteSessionId(id)
@ -186,6 +319,34 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
}
}, [deselectSessions, selectSessions, selectedSessionIds])
const toggleProjectCollapsed = useCallback((projectKey: string) => {
if (suppressProjectClickRef.current === projectKey) {
suppressProjectClickRef.current = null
return
}
setCollapsedProjectKeys((current) => {
const next = new Set(current)
if (next.has(projectKey)) {
next.delete(projectKey)
} else {
next.add(projectKey)
}
return next
})
}, [])
const toggleProjectSessionExpansion = useCallback((projectKey: string) => {
setExpandedProjectKeys((current) => {
const next = new Set(current)
if (next.has(projectKey)) {
next.delete(projectKey)
} else {
next.add(projectKey)
}
return next
})
}, [])
const handleStartRename = useCallback((id: string, currentTitle: string) => {
setContextMenu(null)
setRenamingId(id)
@ -218,19 +379,6 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
startDraggingRef.current?.()
}, [])
const expanded = isMobile ? true : sidebarOpen
const closeMobileDrawer = useCallback(() => {
if (isMobile) onRequestClose?.()
}, [isMobile, onRequestClose])
const timeGroupLabels: Record<TimeGroup, string> = {
today: t('sidebar.timeGroup.today'),
yesterday: t('sidebar.timeGroup.yesterday'),
last7days: t('sidebar.timeGroup.last7days'),
last30days: t('sidebar.timeGroup.last30days'),
older: t('sidebar.timeGroup.older'),
}
useEffect(() => {
if (!isBatchMode) return
@ -314,23 +462,12 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
collapsed={!expanded}
label={t('sidebar.newSession')}
touchFriendly={isMobile}
onClick={async () => {
try {
const currentTabId = useTabStore.getState().activeTabId
const currentSession = currentTabId
? useSessionStore.getState().sessions.find((s) => s.id === currentTabId)
: null
const workDir = currentSession?.workDir || undefined
const sessionId = await useSessionStore.getState().createSession(workDir)
useTabStore.getState().openTab(sessionId, t('sidebar.newSession'))
useChatStore.getState().connectToSession(sessionId)
closeMobileDrawer()
} catch (error) {
addToast({
type: 'error',
message: error instanceof Error ? error.message : t('sidebar.sessionListFailed'),
})
}
onClick={() => {
const currentTabId = useTabStore.getState().activeTabId
const currentSession = currentTabId
? useSessionStore.getState().sessions.find((s) => s.id === currentTabId)
: null
void createSessionForWorkDir(currentSession?.workDir || currentSession?.projectRoot || undefined)
}}
icon={<PlusIcon />}
>
@ -452,7 +589,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
</div>
</div>
)}
<div className="sidebar-scroll-area min-h-0 flex-1 overflow-y-auto px-3">
<div className="sidebar-scroll-area min-h-0 flex-1 overflow-y-auto px-3 pb-20">
{error && (
<div className="mx-1 mt-2 rounded-[var(--radius-md)] border border-[var(--color-error)]/20 bg-[var(--color-error)]/5 px-3 py-2">
<div className="text-xs font-medium text-[var(--color-error)]">{t('sidebar.sessionListFailed')}</div>
@ -474,115 +611,222 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
{searchQuery ? t('sidebar.noMatching') : t('sidebar.noSessions')}
</div>
)}
{TIME_GROUP_ORDER.map((group) => {
const items = timeGroups.get(group)
if (!items || items.length === 0) return null
const groupIds = items.map((session) => session.id)
{orderedProjectGroups.length > 0 && (
<div className="px-1.5 pb-2 pt-1 text-[12px] font-semibold tracking-normal text-[var(--color-text-primary)]">
{t('sidebar.projects')}
</div>
)}
{orderedProjectGroups.map((project) => {
const projectCollapsed = collapsedProjectKeys.has(project.key)
const sessionsExpanded = expandedProjectKeys.has(project.key)
const visibleItems = projectCollapsed
? []
: getVisibleProjectSessions(project.sessions, sessionsExpanded, activeTabId)
const hiddenCount = project.sessions.length - visibleItems.length
const groupIds = project.sessions.map((session) => session.id)
const groupSelectedCount = groupIds.filter((id) => selectedSessionIds.has(id)).length
const hasInternalScroll = sessionsExpanded && project.sessions.length > PROJECT_GROUP_SCROLL_COUNT
const isProjectDragging = draggingProjectKey === project.key
const isProjectPinned = pinnedProjectKeys.has(project.key)
const dropBefore = projectDropTarget?.key === project.key && projectDropTarget.position === 'before'
const dropAfter = projectDropTarget?.key === project.key && projectDropTarget.position === 'after'
return (
<div key={group} className="mb-1">
<div className="flex items-center justify-between px-2 pb-1 pt-4">
<div className="text-[11px] font-semibold tracking-wide text-[var(--color-text-tertiary)]">
{timeGroupLabels[group]}
</div>
{isBatchMode && (
<button
type="button"
onClick={() => toggleGroupSelection(groupIds)}
className={`rounded-md px-1.5 py-0.5 text-[11px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${
groupSelectedCount > 0
? 'text-[var(--color-brand)] hover:bg-[var(--color-brand)]/10'
: 'text-[var(--color-text-tertiary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-secondary)]'
}`}
aria-label={t('sidebar.batchSelectGroup', { group: timeGroupLabels[group] })}
>
{groupSelectedCount === groupIds.length
? t('sidebar.batchDeselectAll')
: t('sidebar.batchSelectAll')}
</button>
)}
</div>
{items.map((session) => (
<div key={session.id} className="relative mb-1.5 last:mb-0">
{renamingId === session.id ? (
<input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={handleFinishRename}
onKeyDown={(e) => {
if (e.key === 'Enter') handleFinishRename()
if (e.key === 'Escape') {
setRenamingId(null)
setRenameValue('')
}
}}
className="ml-1 w-full rounded-[var(--radius-md)] border border-[var(--color-border-focus)] bg-[var(--color-surface)] px-3 py-2 text-sm text-[var(--color-text-primary)] outline-none"
/>
) : (
<section
key={project.key}
data-testid={`sidebar-project-group-${domSafeProjectKey(project.key)}`}
onDragOver={(event) => handleProjectDragOver(event, project.key)}
onDrop={(event) => handleProjectDrop(event, project.key)}
onDragLeave={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setProjectDropTarget((current) => current?.key === project.key ? null : current)
}
}}
className={`group/project relative mb-3.5 transition-opacity ${isProjectDragging ? 'opacity-50' : ''}`}
>
{dropBefore && (
<div className="pointer-events-none absolute -top-1 left-1 right-1 z-10 h-0.5 rounded-full bg-[var(--color-brand)]" />
)}
<div className="flex items-center gap-1">
<button
type="button"
draggable={!isBatchMode}
onDragStart={(event) => handleProjectDragStart(event, project.key)}
onDragEnd={clearProjectDragState}
onClick={() => toggleProjectCollapsed(project.key)}
className="flex min-w-0 flex-1 cursor-grab items-center gap-2 rounded-xl px-1.5 py-2 text-left transition-colors active:cursor-grabbing hover:bg-[var(--color-sidebar-item-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
aria-expanded={!projectCollapsed}
aria-label={t(projectCollapsed ? 'sidebar.expandProject' : 'sidebar.collapseProject', { project: project.title })}
title={project.subtitle || project.title}
>
<span className="flex h-5 w-5 flex-shrink-0 items-center justify-center text-[var(--color-text-primary)]">
<Folder className="h-[18px] w-[18px]" strokeWidth={1.9} aria-hidden="true" />
</span>
<span className="min-w-0 flex-1 truncate text-[13px] font-semibold leading-5 text-[var(--color-text-primary)]">
{project.title}
</span>
{isProjectPinned && (
<Pin className="h-3.5 w-3.5 flex-shrink-0 text-[var(--color-text-tertiary)]" strokeWidth={1.8} aria-hidden="true" />
)}
</button>
<div className="flex flex-shrink-0 items-center gap-1">
{isBatchMode && (
<button
onClick={(event) => {
if (isBatchMode) {
handleBatchSessionClick(event, session.id)
return
}
useTabStore.getState().openTab(session.id, session.title)
useChatStore.getState().connectToSession(session.id)
closeMobileDrawer()
}}
onContextMenu={(e) => handleContextMenu(e, session.id)}
className={`
group w-full rounded-[12px] border px-3 ${isMobile ? 'py-3' : 'py-2'} text-left text-sm transition-[background,border-color,box-shadow,filter,color] duration-200
${selectedSessionIds.has(session.id)
? 'sidebar-session-row--selected border-[var(--color-sidebar-item-active-border)] bg-[var(--color-sidebar-item-active)] text-[var(--color-text-primary)] shadow-[inset_0_1px_0_rgba(255,255,255,0.72),0_3px_10px_rgba(143,72,47,0.07)] hover:brightness-[0.995]'
: session.id === activeTabId
? 'sidebar-session-row--active border-transparent bg-[var(--color-sidebar-item-active)] text-[var(--color-text-primary)]'
: 'sidebar-session-row--idle border-transparent text-[var(--color-text-secondary)] hover:bg-[var(--color-sidebar-item-hover)]'
}
`}
aria-pressed={isBatchMode ? selectedSessionIds.has(session.id) : undefined}
type="button"
onClick={() => toggleGroupSelection(groupIds)}
className={`rounded-md px-1.5 py-1 text-[11px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${
groupSelectedCount > 0
? 'text-[var(--color-brand)] hover:bg-[var(--color-brand)]/10'
: 'text-[var(--color-text-tertiary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-secondary)]'
}`}
aria-label={t('sidebar.batchSelectGroup', { group: project.title })}
>
<span className="flex items-center gap-2.5">
{isBatchMode ? (
<span
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-[5px] border transition-colors ${
selectedSessionIds.has(session.id)
? 'border-[var(--color-brand)] bg-[var(--color-brand)] text-white'
: 'border-[var(--color-border)] bg-[var(--color-surface)]'
}`}
aria-hidden="true"
>
{selectedSessionIds.has(session.id) && (
<span className="material-symbols-outlined text-[12px]">check</span>
)}
</span>
) : (
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: session.id === activeTabId ? 'var(--color-brand)' : 'var(--color-text-tertiary)',
opacity: session.id === activeTabId ? 1 : 0.5,
}}
/>
)}
<span className="flex-1 truncate font-medium tracking-[-0.01em]">{session.title || 'Untitled'}</span>
{!session.workDirExists && (
<span
className="flex-shrink-0 text-[10px] text-[var(--color-warning)]"
title={session.workDir ?? ''}
>
{t('sidebar.missingDir')}
</span>
)}
<span className="flex-shrink-0 text-[10px] text-[var(--color-text-tertiary)] opacity-0 transition-opacity group-hover:opacity-100">
{formatRelativeTime(session.modifiedAt)}
</span>
</span>
{groupSelectedCount === groupIds.length
? t('sidebar.batchDeselectAll')
: t('sidebar.batchSelectAll')}
</button>
)}
{!isBatchMode && (
<div className="pointer-events-none flex items-center gap-0.5 opacity-0 transition-opacity duration-150 group-hover/project:pointer-events-auto group-hover/project:opacity-100 group-focus-within/project:pointer-events-auto group-focus-within/project:opacity-100">
<button
type="button"
onClick={(event) => {
event.stopPropagation()
setContextMenu(null)
setProjectContextMenu({ key: project.key, x: event.clientX, y: event.clientY })
}}
className="flex h-7 w-7 items-center justify-center rounded-lg text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-sidebar-item-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
aria-label={t('sidebar.projectActions', { project: project.title })}
title={t('sidebar.projectActions', { project: project.title })}
>
<MoreHorizontal className="h-[17px] w-[17px]" strokeWidth={2} aria-hidden="true" />
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation()
void createSessionForWorkDir(project.workDir)
}}
className="flex h-7 w-7 items-center justify-center rounded-lg text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-sidebar-item-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
aria-label={t('sidebar.newSessionInProject', { project: project.title })}
title={t('sidebar.newSessionInProject', { project: project.title })}
>
<SquarePen className="h-[16px] w-[16px]" strokeWidth={2} aria-hidden="true" />
</button>
</div>
)}
</div>
))}
</div>
</div>
{!projectCollapsed && (
<div className="mt-0.5 pl-0">
<div
className={hasInternalScroll ? 'max-h-[420px] overflow-y-auto pr-1' : undefined}
data-testid={`sidebar-project-session-list-${domSafeProjectKey(project.key)}`}
>
{visibleItems.map((session) => (
<div key={session.id} className="relative mb-0.5 last:mb-0">
{renamingId === session.id ? (
<input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={handleFinishRename}
onKeyDown={(e) => {
if (e.key === 'Enter') handleFinishRename()
if (e.key === 'Escape') {
setRenamingId(null)
setRenameValue('')
}
}}
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border-focus)] bg-[var(--color-surface)] px-3 py-2 text-sm text-[var(--color-text-primary)] outline-none"
/>
) : (
<button
onClick={(event) => {
if (isBatchMode) {
handleBatchSessionClick(event, session.id)
return
}
useTabStore.getState().openTab(session.id, session.title)
useChatStore.getState().connectToSession(session.id)
closeMobileDrawer()
}}
onContextMenu={(e) => handleContextMenu(e, session.id)}
className={`
group/session w-full rounded-lg px-2.5 ${isMobile ? 'py-3' : 'py-1.5'} text-left text-[13px] transition-[background,filter,color] duration-200
${selectedSessionIds.has(session.id)
? 'sidebar-session-row--selected bg-[var(--color-sidebar-item-active)] text-[var(--color-text-primary)]'
: session.id === activeTabId
? 'sidebar-session-row--active bg-[var(--color-sidebar-item-active)] text-[var(--color-text-primary)]'
: 'sidebar-session-row--idle text-[var(--color-text-secondary)] hover:bg-[var(--color-sidebar-item-hover)] hover:text-[var(--color-text-primary)]'
}
`}
aria-pressed={isBatchMode ? selectedSessionIds.has(session.id) : undefined}
>
<span className="flex min-w-0 items-center gap-2">
{isBatchMode ? (
<span
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-[5px] border transition-colors ${
selectedSessionIds.has(session.id)
? 'border-[var(--color-brand)] bg-[var(--color-brand)] text-white'
: 'border-[var(--color-border)] bg-[var(--color-surface)]'
}`}
aria-hidden="true"
>
{selectedSessionIds.has(session.id) && (
<span className="material-symbols-outlined text-[12px]">check</span>
)}
</span>
) : null}
<span className="min-w-0 flex-1 truncate font-medium tracking-normal">{session.title || 'Untitled'}</span>
{!session.workDirExists && (
<span
className="flex-shrink-0 text-[10px] text-[var(--color-warning)]"
title={session.workDir ?? ''}
>
{t('sidebar.missingDir')}
</span>
)}
{isWorktreeSession(session) && (
<span className="flex-shrink-0 rounded bg-[var(--color-sidebar-item-hover)] px-1 py-0.5 text-[9px] font-medium uppercase tracking-wide text-[var(--color-text-tertiary)]">
{t('sidebar.worktree')}
</span>
)}
<span className="flex-shrink-0 text-[10px] text-[var(--color-text-tertiary)] opacity-0 transition-opacity group-hover/session:opacity-100">
{formatRelativeTime(session.modifiedAt)}
</span>
</span>
</button>
)}
</div>
))}
</div>
{(hiddenCount > 0 || sessionsExpanded) && (
<div className="mt-2 flex justify-center">
<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)]"
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"
/>
</button>
</div>
)}
</div>
)}
{dropAfter && (
<div className="pointer-events-none absolute -bottom-1 left-1 right-1 z-10 h-0.5 rounded-full bg-[var(--color-brand)]" />
)}
</section>
)
})}
</div>
@ -636,6 +880,45 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
</div>
)}
{projectContextMenu && (() => {
const project = orderedProjectGroups.find((group) => group.key === projectContextMenu.key)
if (!project) return null
const pinned = pinnedProjectKeys.has(project.key)
return (
<div
role="menu"
className="fixed z-50 min-w-[230px] overflow-hidden rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-2 shadow-[var(--shadow-dropdown)]"
style={positionProjectMenu(projectContextMenu.x, projectContextMenu.y)}
onClick={(event) => event.stopPropagation()}
>
<ProjectMenuItem
icon={pinned ? <PinOff size={18} aria-hidden="true" /> : <Pin size={18} aria-hidden="true" />}
onClick={() => togglePinnedProject(project.key)}
>
{t(pinned ? 'sidebar.unpinProject' : 'sidebar.pinProject')}
</ProjectMenuItem>
<ProjectMenuItem
icon={<FolderOpen size={18} aria-hidden="true" />}
onClick={() => void openProjectInFinder(project)}
>
{t('sidebar.openInFinder')}
</ProjectMenuItem>
<ProjectMenuItem icon={<GitBranch size={18} aria-hidden="true" />} disabled>
{t('sidebar.createPermanentWorktree')}
</ProjectMenuItem>
<ProjectMenuItem icon={<Pencil size={18} aria-hidden="true" />} disabled>
{t('sidebar.renameProject')}
</ProjectMenuItem>
<ProjectMenuItem icon={<Archive size={18} aria-hidden="true" />} disabled>
{t('sidebar.archiveSessions')}
</ProjectMenuItem>
<ProjectMenuItem icon={<X size={18} aria-hidden="true" />} disabled danger>
{t('sidebar.removeProject')}
</ProjectMenuItem>
</div>
)
})()}
<ConfirmDialog
open={pendingDeleteSessionId !== null}
onClose={() => setPendingDeleteSessionId(null)}
@ -740,28 +1023,218 @@ function isDocumentVisible(): boolean {
return typeof document === 'undefined' || document.visibilityState !== 'hidden'
}
function groupByTime(sessions: SessionListItem[]): Map<TimeGroup, SessionListItem[]> {
const groups = new Map<TimeGroup, SessionListItem[]>()
const now = new Date()
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
const startOfYesterday = startOfToday - 86400000
const sevenDaysAgo = startOfToday - 7 * 86400000
const thirtyDaysAgo = startOfToday - 30 * 86400000
function groupByProject(sessions: SessionListItem[]): ProjectGroup[] {
const groupsByKey = new Map<string, SessionListItem[]>()
for (const session of sessions) {
const ts = new Date(session.modifiedAt).getTime()
let group: TimeGroup
if (ts >= startOfToday) group = 'today'
else if (ts >= startOfYesterday) group = 'yesterday'
else if (ts >= sevenDaysAgo) group = 'last7days'
else if (ts >= thirtyDaysAgo) group = 'last30days'
else group = 'older'
if (!groups.has(group)) groups.set(group, [])
groups.get(group)!.push(session)
const key = getSessionProjectKey(session)
const items = groupsByKey.get(key) ?? []
items.push(session)
groupsByKey.set(key, items)
}
return groups
const groups = [...groupsByKey.entries()].map(([key, items]) => {
const sortedSessions = [...items].sort(compareSessionsByModifiedAt)
const newest = sortedSessions[0]
const projectRoot = newest?.projectRoot || newest?.workDir || key
return {
key,
title: projectTitle(projectRoot),
subtitle: projectSubtitle(projectRoot, key),
workDir: projectRoot || newest?.workDir || undefined,
sessions: sortedSessions,
}
})
return groups.sort((a, b) => compareSessionsByModifiedAt(a.sessions[0], b.sessions[0]))
}
function applyProjectOrder(
groups: ProjectGroup[],
projectOrder: string[],
pinnedProjectKeys: Set<string>,
): ProjectGroup[] {
const orderIndex = new Map(projectOrder.map((key, index) => [key, index]))
return [...groups].sort((a, b) => {
const aPinned = pinnedProjectKeys.has(a.key)
const bPinned = pinnedProjectKeys.has(b.key)
if (aPinned !== bPinned) return aPinned ? -1 : 1
const aIndex = orderIndex.get(a.key)
const bIndex = orderIndex.get(b.key)
if (aIndex !== undefined && bIndex !== undefined) return aIndex - bIndex
if (aIndex !== undefined) return -1
if (bIndex !== undefined) return 1
return compareSessionsByModifiedAt(a.sessions[0], b.sessions[0])
})
}
function moveProjectKey(
projectKeys: string[],
sourceKey: string,
targetKey: string,
position: 'before' | 'after',
): string[] {
const withoutSource = projectKeys.filter((key) => key !== sourceKey)
const targetIndex = withoutSource.indexOf(targetKey)
if (targetIndex < 0) return projectKeys
const insertIndex = position === 'before' ? targetIndex : targetIndex + 1
return [
...withoutSource.slice(0, insertIndex),
sourceKey,
...withoutSource.slice(insertIndex),
]
}
function getProjectDropPosition(event: React.DragEvent<HTMLElement>): 'before' | 'after' {
const rect = event.currentTarget.getBoundingClientRect()
return event.clientY <= rect.top + rect.height / 2 ? 'before' : 'after'
}
function readStoredProjectOrder(): string[] {
if (typeof localStorage === 'undefined') return []
try {
const parsed = JSON.parse(localStorage.getItem(PROJECT_ORDER_STORAGE_KEY) ?? '[]')
return Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === 'string') : []
} catch {
return []
}
}
function writeStoredProjectOrder(projectOrder: string[]): void {
if (typeof localStorage === 'undefined') return
try {
localStorage.setItem(PROJECT_ORDER_STORAGE_KEY, JSON.stringify(projectOrder))
} catch {
// Sidebar ordering is a UI preference; ignore storage failures.
}
}
function readStoredProjectPins(): Set<string> {
if (typeof localStorage === 'undefined') return new Set()
try {
const parsed = JSON.parse(localStorage.getItem(PROJECT_PINNED_STORAGE_KEY) ?? '[]')
return new Set(Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === 'string') : [])
} catch {
return new Set()
}
}
function writeStoredProjectPins(projectKeys: Set<string>): void {
if (typeof localStorage === 'undefined') return
try {
localStorage.setItem(PROJECT_PINNED_STORAGE_KEY, JSON.stringify([...projectKeys]))
} catch {
// Sidebar pinning is a UI preference; ignore storage failures.
}
}
function getVisibleProjectSessions(
sessions: SessionListItem[],
expanded: boolean,
activeSessionId: string | null,
): SessionListItem[] {
if (expanded || sessions.length <= PROJECT_GROUP_VISIBLE_COUNT) return sessions
const visible = sessions.slice(0, PROJECT_GROUP_VISIBLE_COUNT)
if (!activeSessionId || visible.some((session) => session.id === activeSessionId)) return visible
const activeSession = sessions.find((session) => session.id === activeSessionId)
return activeSession ? [...visible, activeSession] : visible
}
function getSessionProjectKey(session: SessionListItem): string {
return session.projectRoot || session.workDir || session.projectPath || 'unknown'
}
function compareSessionsByModifiedAt(
a: SessionListItem | undefined,
b: SessionListItem | undefined,
): number {
return new Date(b?.modifiedAt ?? 0).getTime() - new Date(a?.modifiedAt ?? 0).getTime()
}
function projectTitle(pathLike: string | null | undefined): string {
if (!pathLike) return 'Unknown project'
const normalized = pathLike.replace(/[\\/]+$/, '')
const segments = normalized.split(/[\\/]/).filter(Boolean)
const last = segments[segments.length - 1]
if (last) return last
return normalized || 'Unknown project'
}
function projectSubtitle(projectRoot: string | null | undefined, fallbackKey: string): string | null {
if (!projectRoot) return fallbackKey === 'unknown' ? null : fallbackKey
return compactProjectPath(projectRoot)
}
function isWorktreeSession(session: SessionListItem): boolean {
if (!session.workDir) return false
if (/[\\/]\.claude[\\/]worktrees[\\/]/.test(session.workDir)) return true
if (!session.projectRoot || session.workDir === session.projectRoot) return false
return !isSameOrChildPath(session.workDir, session.projectRoot)
}
function isSameOrChildPath(childPath: string, parentPath: string): boolean {
const child = normalizePathForCompare(childPath)
const parent = normalizePathForCompare(parentPath)
return child === parent || child.startsWith(`${parent}/`)
}
function normalizePathForCompare(pathLike: string): string {
return pathLike.replace(/\\/g, '/').replace(/\/+$/, '')
}
function compactProjectPath(pathLike: string): string {
const normalized = normalizePathForCompare(pathLike)
const segments = normalized.split('/').filter(Boolean)
if (segments.length <= 3) return normalized
return `.../${segments.slice(-3, -1).join('/')}`
}
function domSafeProjectKey(projectKey: string): string {
return projectKey.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'unknown'
}
function positionProjectMenu(clientX: number, clientY: number): React.CSSProperties {
if (typeof window === 'undefined') return { left: clientX, top: clientY }
const width = 230
const height = 280
return {
left: Math.max(8, Math.min(clientX, window.innerWidth - width - 8)),
top: Math.max(8, Math.min(clientY, window.innerHeight - height - 8)),
}
}
function ProjectMenuItem({
icon,
children,
onClick,
disabled = false,
danger = false,
}: {
icon: React.ReactNode
children: React.ReactNode
onClick?: () => void
disabled?: boolean
danger?: boolean
}) {
return (
<button
type="button"
role="menuitem"
disabled={disabled}
onClick={disabled ? undefined : onClick}
className={`flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:bg-[var(--color-surface-hover)] disabled:cursor-default disabled:opacity-45 ${
danger
? 'text-[var(--color-error)] enabled:hover:bg-[var(--color-error)]/10'
: 'text-[var(--color-text-primary)] enabled:hover:bg-[var(--color-surface-hover)]'
}`}
>
<span className="flex h-5 w-5 shrink-0 items-center justify-center text-current">
{icon}
</span>
<span className="min-w-0 truncate">{children}</span>
</button>
)
}
function NavItem({

View File

@ -26,6 +26,23 @@ export const en = {
'sidebar.noMatching': 'No matching sessions',
'sidebar.sessionListFailed': 'Session list failed to load',
'sidebar.refreshSessions': 'Refresh sessions',
'sidebar.projects': 'Projects',
'sidebar.projectActions': 'Project actions for {project}',
'sidebar.pinProject': 'Pin Project',
'sidebar.unpinProject': 'Unpin Project',
'sidebar.openInFinder': 'Open in Finder',
'sidebar.openInFinderFailed': 'Could not open the project in Finder.',
'sidebar.openInFinderUnavailable': 'No file manager is available.',
'sidebar.createPermanentWorktree': 'Create Permanent Worktree',
'sidebar.renameProject': 'Rename Project',
'sidebar.archiveSessions': 'Archive Conversations',
'sidebar.removeProject': 'Remove',
'sidebar.newSessionInProject': 'New session in {project}',
'sidebar.showMoreSessions': 'Show {count} more',
'sidebar.showFewerSessions': 'Show fewer',
'sidebar.expandProject': 'Expand {project}',
'sidebar.collapseProject': 'Collapse {project}',
'sidebar.worktree': 'worktree',
'sidebar.missingDir': 'missing dir',
'sidebar.confirmDelete': 'Delete this session? This cannot be undone.',
'sidebar.batchManage': 'Batch manage',

View File

@ -28,6 +28,23 @@ export const zh: Record<TranslationKey, string> = {
'sidebar.noMatching': '没有匹配的会话',
'sidebar.sessionListFailed': '会话列表加载失败',
'sidebar.refreshSessions': '刷新会话列表',
'sidebar.projects': '项目',
'sidebar.projectActions': '{project} 项目操作',
'sidebar.pinProject': '置顶项目',
'sidebar.unpinProject': '取消置顶项目',
'sidebar.openInFinder': '在“访达”中打开',
'sidebar.openInFinderFailed': '无法在访达中打开项目。',
'sidebar.openInFinderUnavailable': '没有可用的文件管理器。',
'sidebar.createPermanentWorktree': '创建永久工作树',
'sidebar.renameProject': '重命名项目',
'sidebar.archiveSessions': '归档对话',
'sidebar.removeProject': '移除',
'sidebar.newSessionInProject': '在 {project} 中新建会话',
'sidebar.showMoreSessions': '查看更多 {count} 个',
'sidebar.showFewerSessions': '收起会话',
'sidebar.expandProject': '展开 {project}',
'sidebar.collapseProject': '折叠 {project}',
'sidebar.worktree': 'worktree',
'sidebar.missingDir': '目录缺失',
'sidebar.confirmDelete': '确定要删除这个会话吗?此操作不可撤销。',
'sidebar.batchManage': '批量管理',

View File

@ -64,7 +64,7 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
}
const sessions = [...byId.values()]
syncedSessions = sessions
const availableProjects = [...new Set(sessions.map((s) => s.projectPath).filter(Boolean))].sort()
const availableProjects = [...new Set(sessions.map((s) => s.projectRoot || s.projectPath).filter(Boolean))].sort()
return { sessions, availableProjects, isLoading: false }
})
syncOpenSessionTabTitles(syncedSessions)
@ -87,6 +87,7 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
messageCount: 0,
projectPath: '',
workDir: resolvedWorkDir ?? workDir ?? null,
projectRoot: resolvedWorkDir ?? workDir ?? null,
workDirExists: true,
}

View File

@ -7,6 +7,7 @@ export type SessionListItem = {
modifiedAt: string
messageCount: number
projectPath: string
projectRoot?: string | null
workDir: string | null
workDirExists: boolean
}

View File

@ -431,6 +431,32 @@ describe('SessionService', () => {
expect(session.title).toBe('Hello Claude')
expect(session.messageCount).toBe(2) // 1 user + 1 assistant
expect(session.projectPath).toBe('-tmp-testproject')
expect(session.projectRoot).toBe('/tmp/test')
})
it('should expose the source project root for persisted worktree sessions', async () => {
const sourceWorkDir = path.join(tmpDir, 'source-repo')
const worktreePath = path.join(sourceWorkDir, '.claude', 'worktrees', 'desktop-main-12345678')
await fs.mkdir(worktreePath, { recursive: true })
const sessionId = 'bbbbbbbb-bbbb-cccc-dddd-eeeeeeeeeeee'
await writeSessionFile(sanitizePath(worktreePath), sessionId, [
makeSnapshotEntry(),
makeSessionMetaEntry(worktreePath),
makeWorktreeStateEntry(sessionId, worktreePath, {
originalCwd: sourceWorkDir,
}),
makeUserEntry('Hello from worktree'),
])
const result = await service.listSessions()
expect(result.sessions).toHaveLength(1)
expect(result.sessions[0]).toMatchObject({
id: sessionId,
projectPath: sanitizePath(worktreePath),
projectRoot: await fs.realpath(sourceWorkDir),
workDir: worktreePath,
})
})
it('should paginate results with limit and offset', async () => {

View File

@ -784,21 +784,21 @@ async function getRecentProjects(url: URL): Promise<Response> {
const { sessions } = await sessionService.listSessions({ limit: 200 })
const validSessions = sessions.filter((session) => session.workDirExists && session.workDir)
// First pass: resolve realPath for each session and group by realPath to dedup
// First pass: group by logical project root so worktrees stay under the same project.
const realPathMap = new Map<string, { projectPath: string; modifiedAt: string; sessionCount: number; sessionId: string }>()
for (const s of validSessions) {
let realPath: string
try {
const workDir = await sessionService.getSessionWorkDir(s.id)
realPath = workDir || sessionService.desanitizePath(s.projectPath)
realPath = s.projectRoot || workDir || sessionService.desanitizePath(s.projectPath)
} catch {
realPath = sessionService.desanitizePath(s.projectPath)
realPath = s.projectRoot || sessionService.desanitizePath(s.projectPath)
}
const existing = realPathMap.get(realPath)
if (!existing || s.modifiedAt > existing.modifiedAt) {
realPathMap.set(realPath, {
projectPath: s.projectPath,
projectPath: realPath,
modifiedAt: s.modifiedAt,
sessionCount: (existing?.sessionCount ?? 0) + 1,
sessionId: s.id,

View File

@ -11,6 +11,7 @@ import * as os from 'node:os'
import { ApiError } from '../middleware/errorHandler.js'
import { sanitizePath as sanitizePortablePath } from '../../utils/sessionStoragePortable.js'
import type { FileHistorySnapshot } from '../../utils/fileHistory.js'
import { findCanonicalGitRoot } from '../../utils/git.js'
import { calculateUSDCost, MODEL_COSTS } from '../../utils/modelCost.js'
import {
calculateCurrentContextTokenTotal,
@ -37,6 +38,7 @@ export type SessionListItem = {
modifiedAt: string
messageCount: number
projectPath: string
projectRoot: string | null
workDir: string | null
workDirExists: boolean
}
@ -327,6 +329,42 @@ export class SessionService {
return undefined
}
private async resolveProjectRootFromEntries(
entries: RawEntry[],
workDir: string | null,
fallbackProjectDir?: string,
): Promise<string | null> {
const worktreeSession = this.resolveWorktreeSessionFromEntries(entries)
const repository = this.resolveRepositoryFromEntries(entries)
const candidate = worktreeSession?.originalCwd ||
repository?.repoRoot ||
workDir ||
(fallbackProjectDir ? this.desanitizePath(fallbackProjectDir) : null)
if (!candidate) return null
const canonicalCandidate = await this.canonicalizeProjectPath(candidate)
const gitRoot = findCanonicalGitRoot(canonicalCandidate)
if (gitRoot) return gitRoot
if (workDir) {
const marker = `${path.sep}.claude${path.sep}worktrees${path.sep}`
const markerIndex = canonicalCandidate.indexOf(marker)
if (markerIndex > 0) return canonicalCandidate.slice(0, markerIndex)
}
return canonicalCandidate
}
private async canonicalizeProjectPath(projectPath: string): Promise<string> {
try {
return (await fs.realpath(projectPath)).normalize('NFC')
} catch {
return projectPath.normalize('NFC')
}
}
private countTranscriptMessages(entries: RawEntry[]): number {
return entries.filter((entry) =>
!entry.isMeta &&
@ -1179,6 +1217,7 @@ export class SessionService {
try {
const entries = await this.readJsonlFile(filePath)
const workDir = this.resolveWorkDirFromEntries(entries, projectDir)
const projectRoot = await this.resolveProjectRootFromEntries(entries, workDir, projectDir)
const workDirExists = await this.pathExists(workDir)
// Count transcript messages only (user + assistant)
@ -1204,6 +1243,7 @@ export class SessionService {
modifiedAt: stat.mtime.toISOString(),
messageCount,
projectPath: projectDir,
projectRoot,
workDir,
workDirExists,
}
@ -1234,6 +1274,7 @@ export class SessionService {
)
const title = this.extractTitle(entries)
const workDir = this.resolveWorkDirFromEntries(entries, projectDir)
const projectRoot = await this.resolveProjectRootFromEntries(entries, workDir, projectDir)
const workDirExists = await this.pathExists(workDir)
let createdAt = stat.birthtime.toISOString()
@ -1251,6 +1292,7 @@ export class SessionService {
modifiedAt: stat.mtime.toISOString(),
messageCount: messages.length,
projectPath: projectDir,
projectRoot,
workDir,
workDirExists,
messages,