From 0e78439f9d21b82b9912da3efdc924de837ceb0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 15 May 2026 16:01:36 +0800 Subject: [PATCH] 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 --- .../src/components/layout/ProjectFilter.tsx | 7 +- .../src/components/layout/Sidebar.test.tsx | 329 +++++++- desktop/src/components/layout/Sidebar.tsx | 795 ++++++++++++++---- desktop/src/i18n/locales/en.ts | 17 + desktop/src/i18n/locales/zh.ts | 17 + desktop/src/stores/sessionStore.ts | 3 +- desktop/src/types/session.ts | 1 + src/server/__tests__/sessions.test.ts | 26 + src/server/api/sessions.ts | 8 +- src/server/services/sessionService.ts | 42 + 10 files changed, 1075 insertions(+), 170 deletions(-) diff --git a/desktop/src/components/layout/ProjectFilter.tsx b/desktop/src/components/layout/ProjectFilter.tsx index 3a370929..9900b7b9 100644 --- a/desktop/src/components/layout/ProjectFilter.tsx +++ b/desktop/src/components/layout/ProjectFilter.tsx @@ -99,9 +99,10 @@ export function ProjectFilter({ variant = 'default' }: { variant?: 'default' | ' const optionsByPath = new Map() 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, diff --git a/desktop/src/components/layout/Sidebar.test.tsx b/desktop/src/components/layout/Sidebar.test.tsx index 085bb9f8..919d6357 100644 --- a/desktop/src/components/layout/Sidebar.test.tsx +++ b/desktop/src/components/layout/Sidebar.test.tsx @@ -6,6 +6,18 @@ vi.mock('./ProjectFilter', () => ({ ProjectFilter: () =>
, })) +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) => { const translations: Record = { @@ -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() + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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') diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index 8186e370..2f4e8903 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -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(null) const [pendingBatchDeleteSessionIds, setPendingBatchDeleteSessionIds] = useState(null) const [isBatchDeleting, setIsBatchDeleting] = useState(false) const [renamingId, setRenamingId] = useState(null) const [renameValue, setRenameValue] = useState('') const [lastSelectedSessionId, setLastSelectedSessionId] = useState(null) + const [expandedProjectKeys, setExpandedProjectKeys] = useState>(new Set()) + const [collapsedProjectKeys, setCollapsedProjectKeys] = useState>(new Set()) + const [projectOrder, setProjectOrder] = useState(() => readStoredProjectOrder()) + const [pinnedProjectKeys, setPinnedProjectKeys] = useState>(() => readStoredProjectPins()) + const [draggingProjectKey, setDraggingProjectKey] = useState(null) + const [projectDropTarget, setProjectDropTarget] = useState<{ key: string; position: 'before' | 'after' } | null>(null) + const suppressProjectClickRef = useRef(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, 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, 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 = { - 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={} > @@ -452,7 +589,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
)} -
+
{error && (
{t('sidebar.sessionListFailed')}
@@ -474,115 +611,222 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { {searchQuery ? t('sidebar.noMatching') : t('sidebar.noSessions')}
)} - {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 && ( +
+ {t('sidebar.projects')} +
+ )} + {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 ( -
-
-
- {timeGroupLabels[group]} -
- {isBatchMode && ( - - )} -
- {items.map((session) => ( -
- {renamingId === session.id ? ( - 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" - /> - ) : ( +
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 && ( +
+ )} +
+ +
+ {isBatchMode && ( )} + {!isBatchMode && ( +
+ + +
+ )}
- ))} -
+
+ {!projectCollapsed && ( +
+
+ {visibleItems.map((session) => ( +
+ {renamingId === session.id ? ( + 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" + /> + ) : ( + + )} +
+ ))} +
+ {(hiddenCount > 0 || sessionsExpanded) && ( +
+ +
+ )} +
+ )} + {dropAfter && ( +
+ )} +
) })}
@@ -636,6 +880,45 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
)} + {projectContextMenu && (() => { + const project = orderedProjectGroups.find((group) => group.key === projectContextMenu.key) + if (!project) return null + const pinned = pinnedProjectKeys.has(project.key) + return ( +
event.stopPropagation()} + > + + + + + + +
+ ) + })()} + setPendingDeleteSessionId(null)} @@ -740,28 +1023,218 @@ function isDocumentVisible(): boolean { return typeof document === 'undefined' || document.visibilityState !== 'hidden' } -function groupByTime(sessions: SessionListItem[]): Map { - const groups = new Map() - 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() 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, +): 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): '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 { + 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): 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 ( + + ) } function NavItem({ diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index d44bee6b..c301f3e6 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -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', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 0ec2d754..259f7a22 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -28,6 +28,23 @@ export const zh: Record = { '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': '批量管理', diff --git a/desktop/src/stores/sessionStore.ts b/desktop/src/stores/sessionStore.ts index ed7aa214..cd327bc4 100644 --- a/desktop/src/stores/sessionStore.ts +++ b/desktop/src/stores/sessionStore.ts @@ -64,7 +64,7 @@ export const useSessionStore = create((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((set, get) => ({ messageCount: 0, projectPath: '', workDir: resolvedWorkDir ?? workDir ?? null, + projectRoot: resolvedWorkDir ?? workDir ?? null, workDirExists: true, } diff --git a/desktop/src/types/session.ts b/desktop/src/types/session.ts index 96a7e609..0d7940a6 100644 --- a/desktop/src/types/session.ts +++ b/desktop/src/types/session.ts @@ -7,6 +7,7 @@ export type SessionListItem = { modifiedAt: string messageCount: number projectPath: string + projectRoot?: string | null workDir: string | null workDirExists: boolean } diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index 788f8985..f8bfb1d0 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -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 () => { diff --git a/src/server/api/sessions.ts b/src/server/api/sessions.ts index 50ac2a4d..c8b8e48b 100644 --- a/src/server/api/sessions.ts +++ b/src/server/api/sessions.ts @@ -784,21 +784,21 @@ async function getRecentProjects(url: URL): Promise { 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() 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, diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index 98b0f23e..9e28dfdf 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -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 { + 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 { + 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,