From 4e969475f2ab5b9d09701c2d3f63101e213daebb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 15 May 2026 16:49:18 +0800 Subject: [PATCH] Improve session project sidebar controls The session sidebar already uses project grouping, so the change adds header-level project actions instead of replacing project row controls. Sorting and organization choices are persisted through the existing desktop UI preferences file and mirrored into local cache for startup continuity. Constraint: Keep the existing project tree, drag ordering, pinning, hiding, and Finder project-row actions intact. Rejected: Replacing project row actions with the header menu | row-level Finder and per-project session actions are still useful and already covered by tests. Confidence: high Scope-risk: moderate Directive: Do not store sidebar organization in global Claude settings; keep it under cc-haha desktop-ui preferences. Tested: cd desktop && bun run test -- src/components/layout/Sidebar.test.tsx Tested: bun test src/server/__tests__/desktop-ui-preferences.test.ts Tested: cd desktop && bun run lint Tested: cd desktop && bun run build --- desktop/src/api/desktopUiPreferences.ts | 2 + .../src/components/layout/Sidebar.test.tsx | 103 +++++ desktop/src/components/layout/Sidebar.tsx | 420 +++++++++++++++++- desktop/src/i18n/locales/en.ts | 13 + desktop/src/i18n/locales/zh.ts | 13 + .../__tests__/desktop-ui-preferences.test.ts | 12 + .../services/desktopUiPreferencesService.ts | 14 + 7 files changed, 556 insertions(+), 21 deletions(-) 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