diff --git a/desktop/src/api/desktopUiPreferences.ts b/desktop/src/api/desktopUiPreferences.ts index 2e2ebc76..4692c491 100644 --- a/desktop/src/api/desktopUiPreferences.ts +++ b/desktop/src/api/desktopUiPreferences.ts @@ -4,6 +4,8 @@ export type SidebarProjectPreferences = { projectOrder: string[] pinnedProjects: string[] hiddenProjects: string[] + projectOrganization: 'project' | 'recentProject' | 'time' + projectSortBy: 'createdAt' | 'updatedAt' } export type DesktopUiPreferences = { diff --git a/desktop/src/components/layout/Sidebar.test.tsx b/desktop/src/components/layout/Sidebar.test.tsx index 5926b8a6..4c6f9e6f 100644 --- a/desktop/src/components/layout/Sidebar.test.tsx +++ b/desktop/src/components/layout/Sidebar.test.tsx @@ -39,6 +39,19 @@ vi.mock('../../i18n', () => ({ 'sidebar.sessionListFailed': 'Session list failed', 'sidebar.refreshSessions': 'Refresh sessions', 'sidebar.projects': 'Projects', + 'sidebar.projectMenu': 'Project menu', + 'sidebar.newProject': 'New project', + 'sidebar.archiveAllChats': 'Archive all chats', + 'sidebar.organizeSidebar': 'Organize sidebar', + 'sidebar.sortCondition': 'Sort condition', + 'sidebar.organizeByProject': 'By project', + 'sidebar.organizeByRecentProject': 'Recent projects', + 'sidebar.organizeByTime': 'By time', + 'sidebar.sortByCreatedAt': 'Created time', + 'sidebar.sortByUpdatedAt': 'Updated time', + 'sidebar.newBlankProject': 'New blank project', + 'sidebar.useExistingFolder': 'Use existing folder', + 'sidebar.chooseProjectFolderUnavailable': 'Folder selection is only available in the desktop app.', 'sidebar.projectActions': 'Project actions for {project}', 'sidebar.pinProject': 'Pin Project', 'sidebar.unpinProject': 'Unpin Project', @@ -100,6 +113,8 @@ 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' const PROJECT_HIDDEN_STORAGE_KEY = 'cc-haha-sidebar-hidden-projects' +const PROJECT_ORGANIZATION_STORAGE_KEY = 'cc-haha-sidebar-project-organization' +const PROJECT_SORT_STORAGE_KEY = 'cc-haha-sidebar-project-sort' function makeSession( id: string, @@ -170,6 +185,8 @@ describe('Sidebar', () => { projectOrder: [], pinnedProjects: [], hiddenProjects: [], + projectOrganization: 'recentProject', + projectSortBy: 'updatedAt', }, }, }) @@ -179,6 +196,8 @@ describe('Sidebar', () => { window.localStorage.removeItem(PROJECT_ORDER_STORAGE_KEY) window.localStorage.removeItem(PROJECT_PINNED_STORAGE_KEY) window.localStorage.removeItem(PROJECT_HIDDEN_STORAGE_KEY) + window.localStorage.removeItem(PROJECT_ORGANIZATION_STORAGE_KEY) + window.localStorage.removeItem(PROJECT_SORT_STORAGE_KEY) useTabStore.setState({ tabs: [], activeTabId: null }) useSessionStore.setState({ @@ -212,6 +231,8 @@ describe('Sidebar', () => { window.localStorage.removeItem(PROJECT_ORDER_STORAGE_KEY) window.localStorage.removeItem(PROJECT_PINNED_STORAGE_KEY) window.localStorage.removeItem(PROJECT_HIDDEN_STORAGE_KEY) + window.localStorage.removeItem(PROJECT_ORGANIZATION_STORAGE_KEY) + window.localStorage.removeItem(PROJECT_SORT_STORAGE_KEY) }) it('opens a new tab when creating a session from the sidebar', async () => { @@ -392,6 +413,82 @@ describe('Sidebar', () => { }) }) + it('shows project header menus and starts a blank project session', async () => { + createSession.mockResolvedValue('session-blank-project') + useSessionStore.setState({ + sessions: [ + makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', new Date().toISOString()), + ], + }) + + render() + + expect(screen.getByTestId('sidebar-projects-header')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'New project' })) + expect(screen.getByRole('menuitem', { name: 'New blank project' })).toBeInTheDocument() + + await act(async () => { + fireEvent.click(screen.getByRole('menuitem', { name: 'New blank project' })) + }) + + await waitFor(() => { + expect(createSession).toHaveBeenCalledWith(undefined) + expect(connectToSession).toHaveBeenCalledWith('session-blank-project') + }) + }) + + it('persists project header sort preferences through desktop UI settings', async () => { + useSessionStore.setState({ + sessions: [ + makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', '2026-03-01T00:00:00.000Z'), + { + ...makeSession('beta-1', 'Beta Session', '/workspace/beta', '2026-02-01T00:00:00.000Z'), + createdAt: '2026-04-01T00:00:00.000Z', + }, + ], + }) + + render() + + expect(projectGroupNames().slice(0, 2)).toEqual(['alpha', 'beta']) + + fireEvent.click(screen.getByRole('button', { name: 'Project menu' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Sort condition' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Created time' })) + + await waitFor(() => { + expect(desktopUiPreferencesApiMock.updateSidebarPreferences).toHaveBeenCalledWith({ + projectOrder: [], + pinnedProjects: [], + hiddenProjects: [], + projectOrganization: 'recentProject', + projectSortBy: 'createdAt', + }) + expect(projectGroupNames().slice(0, 2)).toEqual(['beta', 'alpha']) + }) + expect(window.localStorage.getItem(PROJECT_SORT_STORAGE_KEY)).toBe('createdAt') + }) + + it('uses the project header menu to request archiving all visible sessions', () => { + 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: 'Project menu' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Archive all chats' })) + + const dialog = screen.getByRole('dialog') + expect(within(dialog).getByText('Delete 2 sessions? This cannot be undone.')).toBeInTheDocument() + expect(within(dialog).getByText('Alpha Session')).toBeInTheDocument() + expect(within(dialog).getByText('Beta Session')).toBeInTheDocument() + }) + it('keeps project row actions hidden until project hover or focus', () => { useSessionStore.setState({ sessions: [ @@ -512,6 +609,8 @@ describe('Sidebar', () => { projectOrder: ['/workspace/beta', '/workspace/alpha'], pinnedProjects: ['/workspace/beta'], hiddenProjects: ['/workspace/alpha'], + projectOrganization: 'recentProject', + projectSortBy: 'updatedAt', }, }, }) @@ -546,6 +645,8 @@ describe('Sidebar', () => { projectOrder: [], pinnedProjects: [], hiddenProjects: [], + projectOrganization: 'recentProject', + projectSortBy: 'updatedAt', }, }, }) @@ -564,6 +665,8 @@ describe('Sidebar', () => { projectOrder: [], pinnedProjects: [], hiddenProjects: ['/workspace/beta'], + projectOrganization: 'recentProject', + projectSortBy: 'updatedAt', }) }) expect(screen.queryByText('beta')).not.toBeInTheDocument() diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index a3b60a05..da5b8ae0 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 { ChevronDown, Folder, FolderOpen, MoreHorizontal, Pin, PinOff, RefreshCw, RotateCcw, SquarePen, X } from 'lucide-react' +import { Archive, Check, ChevronDown, Clock, Folder, FolderOpen, FolderPlus, MoreHorizontal, Pin, PinOff, RefreshCw, RotateCcw, SquarePen, X } from 'lucide-react' import { useSessionStore } from '../../stores/sessionStore' import { useUIStore } from '../../stores/uiStore' import { useTranslation } from '../../i18n' @@ -18,9 +18,15 @@ 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_HIDDEN_STORAGE_KEY = 'cc-haha-sidebar-hidden-projects' +const PROJECT_ORGANIZATION_STORAGE_KEY = 'cc-haha-sidebar-project-organization' +const PROJECT_SORT_STORAGE_KEY = 'cc-haha-sidebar-project-sort' const PROJECT_GROUP_VISIBLE_COUNT = 6 const PROJECT_GROUP_SCROLL_COUNT = 12 +type SidebarProjectOrganization = 'project' | 'recentProject' | 'time' +type SidebarProjectSortBy = 'createdAt' | 'updatedAt' +type SidebarHeaderMenuType = 'main' | 'organize' | 'sort' | 'create' + type ProjectGroup = { key: string title: string @@ -60,6 +66,8 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { 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 [projectHeaderMenu, setProjectHeaderMenu] = useState<{ type: SidebarHeaderMenuType; x: number; y: number } | null>(null) + const [projectHeaderSubmenu, setProjectHeaderSubmenu] = useState<{ type: 'organize' | 'sort'; x: number; y: number } | null>(null) const [pendingDeleteSessionId, setPendingDeleteSessionId] = useState(null) const [pendingBatchDeleteSessionIds, setPendingBatchDeleteSessionIds] = useState(null) const [isBatchDeleting, setIsBatchDeleting] = useState(false) @@ -71,6 +79,8 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { const [projectOrder, setProjectOrder] = useState(() => readStoredProjectOrder()) const [pinnedProjectKeys, setPinnedProjectKeys] = useState>(() => readStoredProjectPins()) const [hiddenProjectKeys, setHiddenProjectKeys] = useState>(() => readStoredProjectHidden()) + const [projectOrganization, setProjectOrganizationState] = useState(() => readStoredProjectOrganization()) + const [projectSortBy, setProjectSortByState] = useState(() => readStoredProjectSortBy()) const [draggingProjectKey, setDraggingProjectKey] = useState(null) const [projectDropTarget, setProjectDropTarget] = useState<{ key: string; position: 'before' | 'after' } | null>(null) const suppressProjectClickRef = useRef(null) @@ -84,14 +94,16 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { }, [sidebarOpen]) useEffect(() => { - if (!contextMenu && !projectContextMenu) return + if (!contextMenu && !projectContextMenu && !projectHeaderMenu && !projectHeaderSubmenu) return const close = () => { setContextMenu(null) setProjectContextMenu(null) + setProjectHeaderMenu(null) + setProjectHeaderSubmenu(null) } document.addEventListener('click', close) return () => document.removeEventListener('click', close) - }, [contextMenu, projectContextMenu]) + }, [contextMenu, projectContextMenu, projectHeaderMenu, projectHeaderSubmenu]) const filteredSessions = useMemo(() => { let result = sessions @@ -105,10 +117,10 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { return result }, [sessions, selectedProjects, searchQuery]) - const projectGroups = useMemo(() => groupByProject(filteredSessions), [filteredSessions]) + const projectGroups = useMemo(() => groupByProject(filteredSessions, projectSortBy), [filteredSessions, projectSortBy]) const orderedProjectGroups = useMemo( - () => applyProjectOrder(projectGroups, projectOrder, pinnedProjectKeys), - [projectGroups, projectOrder, pinnedProjectKeys], + () => applyProjectOrder(projectGroups, projectOrder, pinnedProjectKeys, projectOrganization, projectSortBy), + [projectGroups, projectOrder, pinnedProjectKeys, projectOrganization, projectSortBy], ) const visibleProjectGroups = useMemo(() => { if (hiddenProjectKeys.size === 0) return orderedProjectGroups @@ -138,6 +150,8 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { setProjectOrder(preferences.projectOrder) setPinnedProjectKeys(new Set(preferences.pinnedProjects)) setHiddenProjectKeys(new Set(preferences.hiddenProjects)) + setProjectOrganizationState(preferences.projectOrganization) + setProjectSortByState(preferences.projectSortBy) }, []) const persistSidebarProjectPreferences = useCallback((preferences: SidebarProjectPreferences) => { @@ -231,9 +245,9 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { dropPosition, ) setProjectOrder(nextOrder) - persistSidebarProjectPreferences(buildSidebarProjectPreferences(nextOrder, pinnedProjectKeys, hiddenProjectKeys)) + persistSidebarProjectPreferences(buildSidebarProjectPreferences(nextOrder, pinnedProjectKeys, hiddenProjectKeys, projectOrganization, projectSortBy)) clearProjectDragState() - }, [clearProjectDragState, draggingProjectKey, hiddenProjectKeys, orderedProjectGroups, persistSidebarProjectPreferences, pinnedProjectKeys, projectDropTarget]) + }, [clearProjectDragState, draggingProjectKey, hiddenProjectKeys, orderedProjectGroups, persistSidebarProjectPreferences, pinnedProjectKeys, projectDropTarget, projectOrganization, projectSortBy]) const createSessionForWorkDir = useCallback(async (workDir?: string) => { try { @@ -249,6 +263,89 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { } }, [addToast, closeMobileDrawer, t]) + const openProjectHeaderMenu = useCallback((event: React.MouseEvent, type: SidebarHeaderMenuType) => { + event.stopPropagation() + const rect = event.currentTarget.getBoundingClientRect() + const width = type === 'create' ? 250 : 270 + setProjectContextMenu(null) + setContextMenu(null) + setProjectHeaderSubmenu(null) + setProjectHeaderMenu({ + type, + x: Math.max(10, Math.min(rect.right - width, window.innerWidth - width - 10)), + y: rect.bottom + 8, + }) + }, []) + + const openProjectHeaderSubmenu = useCallback((event: React.MouseEvent, type: 'organize' | 'sort') => { + event.stopPropagation() + const rect = event.currentTarget.getBoundingClientRect() + const width = type === 'sort' ? 230 : 260 + setProjectHeaderSubmenu({ + type, + x: Math.max(10, Math.min(rect.right + 8, window.innerWidth - width - 10)), + y: Math.max(10, Math.min(rect.top - 8, window.innerHeight - 170)), + }) + }, []) + + const updateProjectOrganization = useCallback((organization: SidebarProjectOrganization) => { + setProjectHeaderMenu(null) + setProjectHeaderSubmenu(null) + setProjectOrganizationState(organization) + const nextOrder = organization === 'project' || organization === 'time' ? [] : projectOrder + if (nextOrder !== projectOrder) setProjectOrder(nextOrder) + persistSidebarProjectPreferences(buildSidebarProjectPreferences( + nextOrder, + pinnedProjectKeys, + hiddenProjectKeys, + organization, + projectSortBy, + )) + }, [hiddenProjectKeys, persistSidebarProjectPreferences, pinnedProjectKeys, projectOrder, projectSortBy]) + + const updateProjectSortBy = useCallback((sortBy: SidebarProjectSortBy) => { + setProjectHeaderMenu(null) + setProjectHeaderSubmenu(null) + setProjectSortByState(sortBy) + const nextOrder: string[] = [] + setProjectOrder(nextOrder) + persistSidebarProjectPreferences(buildSidebarProjectPreferences( + nextOrder, + pinnedProjectKeys, + hiddenProjectKeys, + projectOrganization, + sortBy, + )) + }, [hiddenProjectKeys, persistSidebarProjectPreferences, pinnedProjectKeys, projectOrganization]) + + const createSessionFromExistingFolder = useCallback(async () => { + setProjectHeaderMenu(null) + setProjectHeaderSubmenu(null) + if (!isTauri) { + addToast({ + type: 'error', + message: t('sidebar.chooseProjectFolderUnavailable'), + }) + return + } + try { + const { open } = await import('@tauri-apps/plugin-dialog') + const selected = await open({ + directory: true, + multiple: false, + title: t('sidebar.useExistingFolder'), + }) + if (typeof selected === 'string' && selected.trim()) { + await createSessionForWorkDir(selected) + } + } catch (error) { + addToast({ + type: 'error', + message: error instanceof Error ? error.message : t('sidebar.sessionListFailed'), + }) + } + }, [addToast, createSessionForWorkDir, t]) + const togglePinnedProject = useCallback((projectKey: string) => { setProjectContextMenu(null) setPinnedProjectKeys((current) => { @@ -258,10 +355,10 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { } else { next.add(projectKey) } - persistSidebarProjectPreferences(buildSidebarProjectPreferences(projectOrder, next, hiddenProjectKeys)) + persistSidebarProjectPreferences(buildSidebarProjectPreferences(projectOrder, next, hiddenProjectKeys, projectOrganization, projectSortBy)) return next }) - }, [hiddenProjectKeys, persistSidebarProjectPreferences, projectOrder]) + }, [hiddenProjectKeys, persistSidebarProjectPreferences, projectOrder, projectOrganization, projectSortBy]) const toggleHiddenProject = useCallback((project: ProjectGroup) => { const wasHidden = hiddenProjectKeys.has(project.key) @@ -273,7 +370,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { } else { next.add(project.key) } - persistSidebarProjectPreferences(buildSidebarProjectPreferences(projectOrder, pinnedProjectKeys, next)) + persistSidebarProjectPreferences(buildSidebarProjectPreferences(projectOrder, pinnedProjectKeys, next, projectOrganization, projectSortBy)) return next }) if (!wasHidden) { @@ -282,7 +379,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { message: t('sidebar.projectHidden', { project: project.title }), }) } - }, [addToast, hiddenProjectKeys, persistSidebarProjectPreferences, pinnedProjectKeys, projectOrder, t]) + }, [addToast, hiddenProjectKeys, persistSidebarProjectPreferences, pinnedProjectKeys, projectOrder, projectOrganization, projectSortBy, t]) const openProjectInFinder = useCallback(async (project: ProjectGroup) => { setProjectContextMenu(null) @@ -347,6 +444,12 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { setPendingBatchDeleteSessionIds([...new Set(ids)]) }, []) + const requestArchiveAllVisibleSessions = useCallback(() => { + setProjectHeaderMenu(null) + setProjectHeaderSubmenu(null) + requestBatchDelete(filteredSessionIds) + }, [filteredSessionIds, requestBatchDelete]) + const confirmBatchDelete = useCallback(async () => { const ids = pendingBatchDeleteSessionIds ?? [] if (ids.length === 0) return @@ -684,9 +787,13 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { )} {visibleProjectGroups.length > 0 && ( -
- {t('sidebar.projects')} -
+ openProjectHeaderMenu(event, 'main')} + onOpenCreate={(event) => openProjectHeaderMenu(event, 'create')} + /> )} {visibleProjectGroups.map((project) => { const projectCollapsed = collapsedProjectKeys.has(project.key) @@ -987,6 +1094,40 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { ) })()} + {projectHeaderMenu && ( + void createSessionForWorkDir()} + onUseExistingFolder={() => void createSessionFromExistingFolder()} + t={t} + /> + )} + + {projectHeaderSubmenu && ( + void createSessionForWorkDir()} + onUseExistingFolder={() => void createSessionFromExistingFolder()} + t={t} + /> + )} + setPendingDeleteSessionId(null)} @@ -1091,7 +1232,186 @@ function isDocumentVisible(): boolean { return typeof document === 'undefined' || document.visibilityState !== 'hidden' } -function groupByProject(sessions: SessionListItem[]): ProjectGroup[] { +function ProjectHeaderActions({ + title, + menuLabel, + createLabel, + onOpenMenu, + onOpenCreate, +}: { + title: string + menuLabel: string + createLabel: string + onOpenMenu: (event: React.MouseEvent) => void + onOpenCreate: (event: React.MouseEvent) => void +}) { + return ( +
+
+ {title} +
+
+ + +
+
+ ) +} + +function ProjectHeaderMenu({ + type, + x, + y, + organization, + sortBy, + onArchiveAll, + onOpenSubmenu, + onSetOrganization, + onSetSortBy, + onCreateBlank, + onUseExistingFolder, + t, +}: { + type: SidebarHeaderMenuType + x: number + y: number + organization: SidebarProjectOrganization + sortBy: SidebarProjectSortBy + onArchiveAll: () => void + onOpenSubmenu: (event: React.MouseEvent, type: 'organize' | 'sort') => void + onSetOrganization: (organization: SidebarProjectOrganization) => void + onSetSortBy: (sortBy: SidebarProjectSortBy) => void + onCreateBlank: () => void + onUseExistingFolder: () => void + t: ReturnType +}) { + const width = type === 'sort' ? 230 : type === 'create' ? 250 : 270 + const style: React.CSSProperties = { left: x, top: y, width, boxShadow: 'var(--shadow-dropdown)' } + const className = 'fixed z-50 overflow-hidden rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-2 shadow-[var(--shadow-dropdown)]' + + if (type === 'create') { + return ( +
event.stopPropagation()}> + + +
+ ) + } + + if (type === 'organize') { + return ( +
event.stopPropagation()}> + + + +
+ ) + } + + if (type === 'sort') { + return ( +
event.stopPropagation()}> + + +
+ ) + } + + return ( +
event.stopPropagation()}> + +
+ + +
+ ) +} + +function HeaderMenuItem({ + icon, + children, + onClick, + onMouseEnter, + checked = false, + trailing = false, +}: { + icon: React.ReactNode + children: React.ReactNode + onClick: (event: React.MouseEvent) => void + onMouseEnter?: (event: React.MouseEvent) => void + checked?: boolean + trailing?: boolean +}) { + return ( + + ) +} + +function groupByProject(sessions: SessionListItem[], sortBy: SidebarProjectSortBy): ProjectGroup[] { const groupsByKey = new Map() for (const session of sessions) { const key = getSessionProjectKey(session) @@ -1101,7 +1421,7 @@ function groupByProject(sessions: SessionListItem[]): ProjectGroup[] { } const groups = [...groupsByKey.entries()].map(([key, items]) => { - const sortedSessions = [...items].sort(compareSessionsByModifiedAt) + const sortedSessions = [...items].sort((a, b) => compareSessionsByTimestamp(a, b, sortBy)) const newest = sortedSessions[0] const projectRoot = newest?.projectRoot || newest?.workDir || key return { @@ -1113,25 +1433,28 @@ function groupByProject(sessions: SessionListItem[]): ProjectGroup[] { } }) - return groups.sort((a, b) => compareSessionsByModifiedAt(a.sessions[0], b.sessions[0])) + return groups.sort((a, b) => compareSessionsByTimestamp(a.sessions[0], b.sessions[0], sortBy)) } function applyProjectOrder( groups: ProjectGroup[], projectOrder: string[], pinnedProjectKeys: Set, + organization: SidebarProjectOrganization, + sortBy: SidebarProjectSortBy, ): 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 + if (organization === 'project') return a.title.localeCompare(b.title) 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]) + return compareSessionsByTimestamp(a.sessions[0], b.sessions[0], sortBy) }) } @@ -1214,15 +1537,47 @@ function writeStoredProjectHidden(projectKeys: Set): void { } } +function readStoredProjectOrganization(): SidebarProjectOrganization { + if (typeof localStorage === 'undefined') return 'recentProject' + return normalizeProjectOrganization(localStorage.getItem(PROJECT_ORGANIZATION_STORAGE_KEY)) +} + +function writeStoredProjectOrganization(organization: SidebarProjectOrganization): void { + if (typeof localStorage === 'undefined') return + try { + localStorage.setItem(PROJECT_ORGANIZATION_STORAGE_KEY, organization) + } catch { + // Sidebar organization is a UI preference; ignore storage failures. + } +} + +function readStoredProjectSortBy(): SidebarProjectSortBy { + if (typeof localStorage === 'undefined') return 'updatedAt' + return normalizeProjectSortBy(localStorage.getItem(PROJECT_SORT_STORAGE_KEY)) +} + +function writeStoredProjectSortBy(sortBy: SidebarProjectSortBy): void { + if (typeof localStorage === 'undefined') return + try { + localStorage.setItem(PROJECT_SORT_STORAGE_KEY, sortBy) + } catch { + // Sidebar sorting is a UI preference; ignore storage failures. + } +} + function buildSidebarProjectPreferences( projectOrder: string[], pinnedProjectKeys: Set, hiddenProjectKeys: Set, + projectOrganization: SidebarProjectOrganization, + projectSortBy: SidebarProjectSortBy, ): SidebarProjectPreferences { return normalizeSidebarProjectPreferences({ projectOrder, pinnedProjects: [...pinnedProjectKeys], hiddenProjects: [...hiddenProjectKeys], + projectOrganization, + projectSortBy, }) } @@ -1231,6 +1586,8 @@ function readCachedSidebarProjectPreferences(): SidebarProjectPreferences { projectOrder: readStoredProjectOrder(), pinnedProjects: [...readStoredProjectPins()], hiddenProjects: [...readStoredProjectHidden()], + projectOrganization: readStoredProjectOrganization(), + projectSortBy: readStoredProjectSortBy(), } } @@ -1239,6 +1596,8 @@ function writeCachedSidebarProjectPreferences(preferences: SidebarProjectPrefere writeStoredProjectOrder(normalized.projectOrder) writeStoredProjectPins(new Set(normalized.pinnedProjects)) writeStoredProjectHidden(new Set(normalized.hiddenProjects)) + writeStoredProjectOrganization(normalized.projectOrganization) + writeStoredProjectSortBy(normalized.projectSortBy) } function normalizeSidebarProjectPreferences(preferences: Partial | undefined): SidebarProjectPreferences { @@ -1246,6 +1605,8 @@ function normalizeSidebarProjectPreferences(preferences: Partial 0 || preferences.pinnedProjects.length > 0 || preferences.hiddenProjects.length > 0 + || preferences.projectOrganization !== 'recentProject' + || preferences.projectSortBy !== 'updatedAt' +} + +function normalizeProjectOrganization(value: unknown): SidebarProjectOrganization { + return value === 'project' || value === 'recentProject' || value === 'time' ? value : 'recentProject' +} + +function normalizeProjectSortBy(value: unknown): SidebarProjectSortBy { + return value === 'createdAt' || value === 'updatedAt' ? value : 'updatedAt' } function getVisibleProjectSessions( @@ -1287,11 +1658,18 @@ function getSessionProjectKey(session: SessionListItem): string { return session.projectRoot || session.workDir || session.projectPath || 'unknown' } -function compareSessionsByModifiedAt( +function compareSessionsByTimestamp( a: SessionListItem | undefined, b: SessionListItem | undefined, + sortBy: SidebarProjectSortBy, ): number { - return new Date(b?.modifiedAt ?? 0).getTime() - new Date(a?.modifiedAt ?? 0).getTime() + return getSessionTimestamp(b, sortBy) - getSessionTimestamp(a, sortBy) +} + +function getSessionTimestamp(session: SessionListItem | undefined, sortBy: SidebarProjectSortBy): number { + const value = sortBy === 'createdAt' ? session?.createdAt : session?.modifiedAt + const timestamp = new Date(value ?? 0).getTime() + return Number.isFinite(timestamp) ? timestamp : 0 } function projectTitle(pathLike: string | null | undefined): string { diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 102dbcdd..f8df0118 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -27,6 +27,19 @@ export const en = { 'sidebar.sessionListFailed': 'Session list failed to load', 'sidebar.refreshSessions': 'Refresh sessions', 'sidebar.projects': 'Projects', + 'sidebar.projectMenu': 'Project menu', + 'sidebar.newProject': 'New project', + 'sidebar.archiveAllChats': 'Archive all chats', + 'sidebar.organizeSidebar': 'Organize sidebar', + 'sidebar.sortCondition': 'Sort condition', + 'sidebar.organizeByProject': 'By project', + 'sidebar.organizeByRecentProject': 'Recent projects', + 'sidebar.organizeByTime': 'By time', + 'sidebar.sortByCreatedAt': 'Created time', + 'sidebar.sortByUpdatedAt': 'Updated time', + 'sidebar.newBlankProject': 'New blank project', + 'sidebar.useExistingFolder': 'Use existing folder', + 'sidebar.chooseProjectFolderUnavailable': 'Folder selection is only available in the desktop app.', 'sidebar.projectActions': 'Project actions for {project}', 'sidebar.pinProject': 'Pin Project', 'sidebar.unpinProject': 'Unpin Project', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 2135c3b4..3cfcc286 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -29,6 +29,19 @@ export const zh: Record = { 'sidebar.sessionListFailed': '会话列表加载失败', 'sidebar.refreshSessions': '刷新会话列表', 'sidebar.projects': '项目', + 'sidebar.projectMenu': '项目菜单', + 'sidebar.newProject': '新建项目', + 'sidebar.archiveAllChats': '归档所有聊天', + 'sidebar.organizeSidebar': '整理侧边栏', + 'sidebar.sortCondition': '排序条件', + 'sidebar.organizeByProject': '按项目', + 'sidebar.organizeByRecentProject': '近期项目', + 'sidebar.organizeByTime': '按时间顺序', + 'sidebar.sortByCreatedAt': '创建时间', + 'sidebar.sortByUpdatedAt': '更新时间', + 'sidebar.newBlankProject': '新建空白项目', + 'sidebar.useExistingFolder': '使用现有文件夹', + 'sidebar.chooseProjectFolderUnavailable': '选择文件夹仅在桌面端可用。', 'sidebar.projectActions': '{project} 项目操作', 'sidebar.pinProject': '置顶项目', 'sidebar.unpinProject': '取消置顶项目', diff --git a/src/server/__tests__/desktop-ui-preferences.test.ts b/src/server/__tests__/desktop-ui-preferences.test.ts index 92c4c1bf..cb0af3c1 100644 --- a/src/server/__tests__/desktop-ui-preferences.test.ts +++ b/src/server/__tests__/desktop-ui-preferences.test.ts @@ -60,6 +60,8 @@ describe('DesktopUiPreferencesService', () => { projectOrder: [], pinnedProjects: [], hiddenProjects: [], + projectOrganization: 'recentProject', + projectSortBy: 'updatedAt', }, }) }) @@ -95,6 +97,8 @@ describe('DesktopUiPreferencesService', () => { projectOrder: ['/workspace/alpha', '/workspace/beta'], pinnedProjects: ['/workspace/beta'], hiddenProjects: ['/workspace/gamma'], + projectOrganization: 'recentProject', + projectSortBy: 'updatedAt', }, }) expect(after).toEqual({ @@ -104,6 +108,8 @@ describe('DesktopUiPreferencesService', () => { projectOrder: ['/workspace/gamma'], pinnedProjects: [], hiddenProjects: ['/workspace/beta'], + projectOrganization: 'recentProject', + projectSortBy: 'updatedAt', }, }) expect(await readDesktopUiFile()).toEqual(after) @@ -132,6 +138,8 @@ describe('desktop UI preferences API', () => { projectOrder: ['/workspace/beta', '/workspace/alpha'], pinnedProjects: ['/workspace/beta'], hiddenProjects: ['/workspace/old'], + projectOrganization: 'project', + projectSortBy: 'createdAt', }) const putRes = await handleDesktopUiApi(putReq.req, putReq.url, putReq.segments) @@ -146,6 +154,8 @@ describe('desktop UI preferences API', () => { projectOrder: ['/workspace/beta', '/workspace/alpha'], pinnedProjects: ['/workspace/beta'], hiddenProjects: ['/workspace/old'], + projectOrganization: 'project', + projectSortBy: 'createdAt', }, }, }) @@ -163,6 +173,8 @@ describe('desktop UI preferences API', () => { projectOrder: ['/workspace/beta', '/workspace/alpha'], pinnedProjects: ['/workspace/beta'], hiddenProjects: ['/workspace/old'], + projectOrganization: 'project', + projectSortBy: 'createdAt', }, }, }) diff --git a/src/server/services/desktopUiPreferencesService.ts b/src/server/services/desktopUiPreferencesService.ts index d86f212a..6091e686 100644 --- a/src/server/services/desktopUiPreferencesService.ts +++ b/src/server/services/desktopUiPreferencesService.ts @@ -13,6 +13,8 @@ export type SidebarProjectPreferences = { projectOrder: string[] pinnedProjects: string[] hiddenProjects: string[] + projectOrganization: 'project' | 'recentProject' | 'time' + projectSortBy: 'createdAt' | 'updatedAt' } export type DesktopUiPreferences = { @@ -30,6 +32,8 @@ const DEFAULT_SIDEBAR_PROJECT_PREFERENCES: SidebarProjectPreferences = { projectOrder: [], pinnedProjects: [], hiddenProjects: [], + projectOrganization: 'recentProject', + projectSortBy: 'updatedAt', } function defaultPreferences(): DesktopUiPreferences { @@ -64,9 +68,19 @@ export function normalizeSidebarProjectPreferences(value: unknown): SidebarProje projectOrder: normalizeStringArray(record.projectOrder), pinnedProjects: normalizeStringArray(record.pinnedProjects), hiddenProjects: normalizeStringArray(record.hiddenProjects), + projectOrganization: normalizeProjectOrganization(record.projectOrganization), + projectSortBy: normalizeProjectSortBy(record.projectSortBy), } } +function normalizeProjectOrganization(value: unknown): SidebarProjectPreferences['projectOrganization'] { + return value === 'project' || value === 'recentProject' || value === 'time' ? value : 'recentProject' +} + +function normalizeProjectSortBy(value: unknown): SidebarProjectPreferences['projectSortBy'] { + return value === 'createdAt' || value === 'updatedAt' ? value : 'updatedAt' +} + function normalizeDesktopUiPreferences(value: unknown): DesktopUiPreferences | null { if (!value || typeof value !== 'object' || Array.isArray(value)) { return null