mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
b98e77cd39
commit
0e78439f9d
@ -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,
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '批量管理',
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ export type SessionListItem = {
|
||||
modifiedAt: string
|
||||
messageCount: number
|
||||
projectPath: string
|
||||
projectRoot?: string | null
|
||||
workDir: string | null
|
||||
workDirExists: boolean
|
||||
}
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user