import { useEffect, useState, useCallback, useMemo, useRef } from 'react' import { Check, ChevronDown, Clock, Folder, FolderOpen, FolderPlus, GitBranch, LoaderCircle, MoreHorizontal, Pin, PinOff, RefreshCw, RotateCcw, SquarePen, X } from 'lucide-react' import { useSessionStore } from '../../stores/sessionStore' import { useUIStore } from '../../stores/uiStore' import { useTranslation, type TranslationKey } from '../../i18n' 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' import { desktopUiPreferencesApi, type SidebarProjectPreferences } from '../../api/desktopUiPreferences' import { getDesktopHost } from '../../lib/desktopHost' import { publicAssetPath } from '../../lib/publicAsset' const desktopHost = getDesktopHost() const isDesktopRuntime = desktopHost.isDesktop const canUseNativeDialogs = desktopHost.capabilities.dialogs 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_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 subtitle: string | null workDir: string | undefined sessions: SessionListItem[] } type SidebarProps = { isMobile?: boolean onRequestClose?: () => void } export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { const t = useTranslation() const sessions = useSessionStore((s) => s.sessions) const isLoading = useSessionStore((s) => s.isLoading) const error = useSessionStore((s) => s.error) const fetchSessions = useSessionStore((s) => s.fetchSessions) const deleteSession = useSessionStore((s) => s.deleteSession) const deleteSessions = useSessionStore((s) => s.deleteSessions) const isBatchMode = useSessionStore((s) => s.isBatchMode) const selectedSessionIds = useSessionStore((s) => s.selectedSessionIds) const enterBatchMode = useSessionStore((s) => s.enterBatchMode) const exitBatchMode = useSessionStore((s) => s.exitBatchMode) const toggleSessionSelected = useSessionStore((s) => s.toggleSessionSelected) const selectSessions = useSessionStore((s) => s.selectSessions) const deselectSessions = useSessionStore((s) => s.deselectSessions) const renameSession = useSessionStore((s) => s.renameSession) const addToast = useUIStore((s) => s.addToast) const sidebarOpen = useUIStore((s) => s.sidebarOpen) const toggleSidebar = useUIStore((s) => s.toggleSidebar) const activeTabId = useTabStore((s) => s.activeTabId) const tabs = useTabStore((s) => s.tabs) const chatSessions = useChatStore((s) => s.sessions) const closeTab = useTabStore((s) => s.closeTab) 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 [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) const [renamingId, setRenamingId] = useState(null) const [renameValue, setRenameValue] = useState('') const [lastSelectedSessionId, setLastSelectedSessionId] = useState(null) const [expandedProjectKeys, setExpandedProjectKeys] = useState>(new Set()) const [collapsedProjectKeys, setCollapsedProjectKeys] = useState>(new Set()) const [projectOrder, setProjectOrder] = useState(() => readStoredProjectOrder()) const [pinnedProjectKeys, setPinnedProjectKeys] = useState>(() => readStoredProjectPins()) const [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) const sidebarPreferenceRevisionRef = useRef(0) const refreshSessionsNow = useSessionListAutoRefresh(fetchSessions) useEffect(() => { if (!contextMenu) return if (!sidebarOpen) setContextMenu(null) // eslint-disable-next-line react-hooks/exhaustive-deps }, [sidebarOpen]) useEffect(() => { 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, projectHeaderMenu, projectHeaderSubmenu]) const filteredSessions = useMemo(() => { let result = sessions if (searchQuery) { const q = searchQuery.toLowerCase() result = result.filter((s) => s.title.toLowerCase().includes(q)) } return result }, [sessions, searchQuery]) const projectGroups = useMemo(() => groupByProject(filteredSessions, projectSortBy), [filteredSessions, projectSortBy]) const orderedProjectGroups = useMemo( () => applyProjectOrder(projectGroups, projectOrder, pinnedProjectKeys, projectOrganization, projectSortBy), [projectGroups, projectOrder, pinnedProjectKeys, projectOrganization, projectSortBy], ) const visibleProjectGroups = useMemo(() => { if (hiddenProjectKeys.size === 0) return orderedProjectGroups return orderedProjectGroups.filter((project) => ( !hiddenProjectKeys.has(project.key) )) }, [hiddenProjectKeys, orderedProjectGroups]) const showInitialLoading = isLoading && sessions.length === 0 const filteredSessionIds = useMemo(() => filteredSessions.map((session) => session.id), [filteredSessions]) const selectedCount = selectedSessionIds.size const sessionsById = useMemo( () => new Map(sessions.map((session) => [session.id, session])), [sessions], ) const runningSessionIds = useMemo(() => { const ids = new Set() for (const tab of tabs) { if (tab.type === 'session' && tab.status === 'running') ids.add(tab.sessionId) } for (const [sessionId, sessionState] of Object.entries(chatSessions)) { if (sessionState.chatState !== 'idle') ids.add(sessionId) } return ids }, [chatSessions, tabs]) const pendingBatchDeleteSessions = useMemo( () => (pendingBatchDeleteSessionIds ?? []) .map((sessionId) => sessionsById.get(sessionId)) .filter((session): session is SessionListItem => Boolean(session)), [pendingBatchDeleteSessionIds, sessionsById], ) const expanded = isMobile ? true : sidebarOpen const closeMobileDrawer = useCallback(() => { if (isMobile) onRequestClose?.() }, [isMobile, onRequestClose]) const applySidebarProjectPreferences = useCallback((preferences: SidebarProjectPreferences) => { setProjectOrder(preferences.projectOrder) setPinnedProjectKeys(new Set(preferences.pinnedProjects)) setHiddenProjectKeys(new Set(preferences.hiddenProjects)) setProjectOrganizationState(preferences.projectOrganization) setProjectSortByState(preferences.projectSortBy) }, []) const persistSidebarProjectPreferences = useCallback((preferences: SidebarProjectPreferences) => { const normalized = normalizeSidebarProjectPreferences(preferences) sidebarPreferenceRevisionRef.current += 1 writeCachedSidebarProjectPreferences(normalized) void desktopUiPreferencesApi.updateSidebarPreferences(normalized).catch(() => undefined) }, []) const restoreHiddenProjectForWorkDir = useCallback((workDir: string | null | undefined) => { if (!workDir) return setHiddenProjectKeys((current) => { const next = new Set([...current].filter((projectKey) => !projectPathMatches(projectKey, workDir))) if (next.size === current.size) return current persistSidebarProjectPreferences(buildSidebarProjectPreferences( projectOrder, pinnedProjectKeys, next, projectOrganization, projectSortBy, )) return next }) }, [persistSidebarProjectPreferences, pinnedProjectKeys, projectOrder, projectOrganization, projectSortBy]) useEffect(() => { let cancelled = false const startRevision = sidebarPreferenceRevisionRef.current void desktopUiPreferencesApi.getPreferences() .then((response) => { if (cancelled || startRevision !== sidebarPreferenceRevisionRef.current) return const localPreferences = readCachedSidebarProjectPreferences() const serverPreferences = normalizeSidebarProjectPreferences(response.preferences.sidebar) const effectivePreferences = response.exists ? serverPreferences : localPreferences applySidebarProjectPreferences(effectivePreferences) writeCachedSidebarProjectPreferences(effectivePreferences) if (!response.exists && hasSidebarProjectPreferences(localPreferences)) { void desktopUiPreferencesApi.updateSidebarPreferences(localPreferences).catch(() => undefined) } }) .catch(() => { // The sidebar remains usable with the local cache if the server is still booting. }) return () => { cancelled = true } }, [applySidebarProjectPreferences]) const handleContextMenu = useCallback((e: React.MouseEvent, id: string) => { e.preventDefault() if (isBatchMode) return setContextMenu({ id, x: e.clientX, y: e.clientY }) }, [isBatchMode]) const handleProjectDragStart = useCallback((event: React.DragEvent, projectKey: string) => { if (isBatchMode) { event.preventDefault() return } suppressProjectClickRef.current = projectKey setDraggingProjectKey(projectKey) event.dataTransfer.effectAllowed = 'move' event.dataTransfer.setData('text/plain', projectKey) }, [isBatchMode]) const handleProjectDragOver = useCallback((event: React.DragEvent, projectKey: string) => { const sourceProjectKey = draggingProjectKey || event.dataTransfer.getData('text/plain') if (!sourceProjectKey || sourceProjectKey === projectKey) return event.preventDefault() event.dataTransfer.dropEffect = 'move' const position = getProjectDropPosition(event) setProjectDropTarget((current) => ( current?.key === projectKey && current.position === position ? current : { key: projectKey, position } )) }, [draggingProjectKey]) const clearProjectDragState = useCallback(() => { setDraggingProjectKey(null) setProjectDropTarget(null) window.setTimeout(() => { suppressProjectClickRef.current = null }, 0) }, []) const handleProjectDrop = useCallback((event: React.DragEvent, targetProjectKey: string) => { event.preventDefault() const sourceProjectKey = draggingProjectKey || event.dataTransfer.getData('text/plain') const dropPosition = projectDropTarget?.key === targetProjectKey ? projectDropTarget.position : getProjectDropPosition(event) if (!sourceProjectKey || sourceProjectKey === targetProjectKey) { clearProjectDragState() return } const nextOrder = moveProjectKey( orderedProjectGroups.map((project) => project.key), sourceProjectKey, targetProjectKey, dropPosition, ) setProjectOrder(nextOrder) persistSidebarProjectPreferences(buildSidebarProjectPreferences(nextOrder, pinnedProjectKeys, hiddenProjectKeys, projectOrganization, projectSortBy)) clearProjectDragState() }, [clearProjectDragState, draggingProjectKey, hiddenProjectKeys, orderedProjectGroups, persistSidebarProjectPreferences, pinnedProjectKeys, projectDropTarget, projectOrganization, projectSortBy]) const createSessionForWorkDir = useCallback(async (workDir?: string) => { try { const sessionId = await useSessionStore.getState().createSession(workDir) restoreHiddenProjectForWorkDir(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, restoreHiddenProjectForWorkDir, 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 (!canUseNativeDialogs) { addToast({ type: 'error', message: t('sidebar.chooseProjectFolderUnavailable'), }) return } try { const selected = await getDesktopHost().dialogs.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) => { const next = new Set(current) if (next.has(projectKey)) { next.delete(projectKey) } else { next.add(projectKey) } persistSidebarProjectPreferences(buildSidebarProjectPreferences(projectOrder, next, hiddenProjectKeys, projectOrganization, projectSortBy)) return next }) }, [hiddenProjectKeys, persistSidebarProjectPreferences, projectOrder, projectOrganization, projectSortBy]) const restoreAllHiddenProjects = useCallback(() => { setProjectHeaderMenu(null) setProjectHeaderSubmenu(null) setHiddenProjectKeys((current) => { if (current.size === 0) return current const next = new Set() persistSidebarProjectPreferences(buildSidebarProjectPreferences( projectOrder, pinnedProjectKeys, next, projectOrganization, projectSortBy, )) return next }) }, [persistSidebarProjectPreferences, pinnedProjectKeys, projectOrder, projectOrganization, projectSortBy]) const toggleHiddenProject = useCallback((project: ProjectGroup) => { const wasHidden = hiddenProjectKeys.has(project.key) setProjectContextMenu(null) setHiddenProjectKeys((current) => { const next = new Set(current) if (next.has(project.key)) { next.delete(project.key) } else { next.add(project.key) } persistSidebarProjectPreferences(buildSidebarProjectPreferences(projectOrder, pinnedProjectKeys, next, projectOrganization, projectSortBy)) return next }) if (!wasHidden) { addToast({ type: 'info', message: t('sidebar.projectHidden', { project: project.title }), }) } }, [addToast, hiddenProjectKeys, persistSidebarProjectPreferences, pinnedProjectKeys, projectOrder, projectOrganization, projectSortBy, t]) 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) }, []) const confirmDelete = useCallback(async () => { if (!pendingDeleteSessionId) return await deleteSession(pendingDeleteSessionId) disconnectSession(pendingDeleteSessionId) closeTab(pendingDeleteSessionId) setPendingDeleteSessionId(null) }, [closeTab, deleteSession, disconnectSession, pendingDeleteSessionId]) const handleBatchSessionClick = useCallback((event: React.MouseEvent, id: string) => { if (event.shiftKey && lastSelectedSessionId) { const start = filteredSessionIds.indexOf(lastSelectedSessionId) const end = filteredSessionIds.indexOf(id) if (start >= 0 && end >= 0) { const [from, to] = start < end ? [start, end] : [end, start] selectSessions(filteredSessionIds.slice(from, to + 1)) setLastSelectedSessionId(id) return } } toggleSessionSelected(id) setLastSelectedSessionId(id) }, [filteredSessionIds, lastSelectedSessionId, selectSessions, toggleSessionSelected]) const handleExitBatchMode = useCallback(() => { exitBatchMode() setLastSelectedSessionId(null) setPendingBatchDeleteSessionIds(null) }, [exitBatchMode]) const requestBatchDelete = useCallback((ids: string[]) => { if (ids.length === 0) return setPendingBatchDeleteSessionIds([...new Set(ids)]) }, []) const confirmBatchDelete = useCallback(async () => { const ids = pendingBatchDeleteSessionIds ?? [] if (ids.length === 0) return setIsBatchDeleting(true) try { const result = await deleteSessions(ids) for (const sessionId of result.successes) { disconnectSession(sessionId) closeTab(sessionId) } if (result.failures.length > 0) { addToast({ type: 'error', message: t('sidebar.batchDeleteFailed', { count: result.failures.length }), }) } else { addToast({ type: 'success', message: t('sidebar.batchDeleteSucceeded', { count: result.successes.length }), }) handleExitBatchMode() } setPendingBatchDeleteSessionIds(null) } catch (error) { addToast({ type: 'error', message: error instanceof Error ? error.message : t('sidebar.batchDeleteFailed', { count: ids.length }), }) } finally { setIsBatchDeleting(false) } }, [addToast, closeTab, deleteSessions, disconnectSession, handleExitBatchMode, pendingBatchDeleteSessionIds, t]) const toggleGroupSelection = useCallback((ids: string[]) => { const allSelected = ids.every((id) => selectedSessionIds.has(id)) if (allSelected) { deselectSessions(ids) } else { selectSessions(ids) } }, [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) setRenameValue(currentTitle) }, []) const handleFinishRename = useCallback(async () => { if (renamingId && renameValue.trim()) { await renameSession(renamingId, renameValue.trim()) } setRenamingId(null) setRenameValue('') }, [renamingId, renameValue, renameSession]) useEffect(() => { if (!isBatchMode) return const handleKeyDown = (event: KeyboardEvent) => { const target = event.target as HTMLElement | null if (target?.closest('input, textarea, [contenteditable="true"]')) return if (event.key === 'Escape') { handleExitBatchMode() } if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'a') { event.preventDefault() selectSessions(filteredSessionIds) } } document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) }, [filteredSessionIds, handleExitBatchMode, isBatchMode, selectSessions]) return ( ) } function useSessionListAutoRefresh(fetchSessions: () => Promise): () => Promise { const inFlightRef = useRef | null>(null) const lastStartedAtRef = useRef(0) const refreshSessions = useCallback((force = false) => { if (inFlightRef.current) return inFlightRef.current const now = Date.now() if (!force && now - lastStartedAtRef.current < SESSION_LIST_FOCUS_REFRESH_MIN_MS) { return Promise.resolve() } lastStartedAtRef.current = now const request = Promise.resolve() .then(() => fetchSessions()) .catch(() => undefined) .finally(() => { if (inFlightRef.current === request) { inFlightRef.current = null } }) inFlightRef.current = request return request }, [fetchSessions]) useEffect(() => { void refreshSessions(true) const refreshIfVisible = () => { if (!isDocumentVisible()) return void refreshSessions() } window.addEventListener('focus', refreshIfVisible) document.addEventListener('visibilitychange', refreshIfVisible) const timer = window.setInterval(() => { if (!isDocumentVisible()) return void refreshSessions(true) }, SESSION_LIST_AUTO_REFRESH_MS) return () => { window.removeEventListener('focus', refreshIfVisible) document.removeEventListener('visibilitychange', refreshIfVisible) window.clearInterval(timer) } }, [refreshSessions]) return useCallback(() => refreshSessions(true), [refreshSessions]) } function isDocumentVisible(): boolean { return typeof document === 'undefined' || document.visibilityState !== 'hidden' } 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, onOpenSubmenu, onSetOrganization, onSetSortBy, onCreateBlank, onUseExistingFolder, onRestoreHiddenProjects, hiddenProjectCount, t, }: { type: SidebarHeaderMenuType x: number y: number organization: SidebarProjectOrganization sortBy: SidebarProjectSortBy onOpenSubmenu: (event: React.MouseEvent, type: 'organize' | 'sort') => void onSetOrganization: (organization: SidebarProjectOrganization) => void onSetSortBy: (sortBy: SidebarProjectSortBy) => void onCreateBlank: () => void onUseExistingFolder: () => void onRestoreHiddenProjects: () => void hiddenProjectCount: number 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()}> {hiddenProjectCount > 0 && ( )}
) } 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) const items = groupsByKey.get(key) ?? [] items.push(session) groupsByKey.set(key, items) } const groups = [...groupsByKey.entries()].map(([key, items]) => { const sortedSessions = [...items].sort((a, b) => compareSessionsByTimestamp(a, b, sortBy)) 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) => 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 compareSessionsByTimestamp(a.sessions[0], b.sessions[0], sortBy) }) } function moveProjectKey( projectKeys: string[], sourceKey: string, targetKey: string, position: 'before' | 'after', ): string[] { const withoutSource = projectKeys.filter((key) => key !== sourceKey) const targetIndex = withoutSource.indexOf(targetKey) if (targetIndex < 0) return projectKeys const insertIndex = position === 'before' ? targetIndex : targetIndex + 1 return [ ...withoutSource.slice(0, insertIndex), sourceKey, ...withoutSource.slice(insertIndex), ] } function getProjectDropPosition(event: React.DragEvent): 'before' | 'after' { const rect = event.currentTarget.getBoundingClientRect() return event.clientY <= rect.top + rect.height / 2 ? 'before' : 'after' } function readStoredProjectOrder(): string[] { if (typeof localStorage === 'undefined') return [] try { const parsed = JSON.parse(localStorage.getItem(PROJECT_ORDER_STORAGE_KEY) ?? '[]') return Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === 'string') : [] } catch { return [] } } function writeStoredProjectOrder(projectOrder: string[]): void { if (typeof localStorage === 'undefined') return try { localStorage.setItem(PROJECT_ORDER_STORAGE_KEY, JSON.stringify(projectOrder)) } catch { // Sidebar ordering is a UI preference; ignore storage failures. } } function readStoredProjectPins(): Set { if (typeof localStorage === 'undefined') return new Set() try { const parsed = JSON.parse(localStorage.getItem(PROJECT_PINNED_STORAGE_KEY) ?? '[]') return new Set(Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === 'string') : []) } catch { return new Set() } } function writeStoredProjectPins(projectKeys: Set): void { if (typeof localStorage === 'undefined') return try { localStorage.setItem(PROJECT_PINNED_STORAGE_KEY, JSON.stringify([...projectKeys])) } catch { // Sidebar pinning is a UI preference; ignore storage failures. } } function readStoredProjectHidden(): Set { if (typeof localStorage === 'undefined') return new Set() try { const parsed = JSON.parse(localStorage.getItem(PROJECT_HIDDEN_STORAGE_KEY) ?? '[]') return new Set(Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === 'string') : []) } catch { return new Set() } } function writeStoredProjectHidden(projectKeys: Set): void { if (typeof localStorage === 'undefined') return try { localStorage.setItem(PROJECT_HIDDEN_STORAGE_KEY, JSON.stringify([...projectKeys])) } catch { // Hidden projects are a local UI preference; ignore storage failures. } } 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, }) } function readCachedSidebarProjectPreferences(): SidebarProjectPreferences { return { projectOrder: readStoredProjectOrder(), pinnedProjects: [...readStoredProjectPins()], hiddenProjects: [...readStoredProjectHidden()], projectOrganization: readStoredProjectOrganization(), projectSortBy: readStoredProjectSortBy(), } } function writeCachedSidebarProjectPreferences(preferences: SidebarProjectPreferences): void { const normalized = normalizeSidebarProjectPreferences(preferences) 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 { return { projectOrder: normalizeProjectKeyList(preferences?.projectOrder), pinnedProjects: normalizeProjectKeyList(preferences?.pinnedProjects), hiddenProjects: normalizeProjectKeyList(preferences?.hiddenProjects), projectOrganization: normalizeProjectOrganization(preferences?.projectOrganization), projectSortBy: normalizeProjectSortBy(preferences?.projectSortBy), } } function normalizeProjectKeyList(values: unknown): string[] { if (!Array.isArray(values)) return [] const seen = new Set() const normalized: string[] = [] for (const value of values) { if (typeof value !== 'string' || value.length === 0 || seen.has(value)) continue seen.add(value) normalized.push(value) } return normalized } function normalizeProjectPathForComparison(value: string): string { const normalized = value.replace(/\\/g, '/').replace(/\/+$/g, '') || value return isWindows ? normalized.toLowerCase() : normalized } function isDriveRootComparisonPath(value: string): boolean { return /^[a-z]:$/i.test(value) } function projectPathMatches(projectKey: string, workDir: string): boolean { const normalizedProjectKey = normalizeProjectPathForComparison(projectKey) const normalizedWorkDir = normalizeProjectPathForComparison(workDir) if (normalizedProjectKey === normalizedWorkDir) return true if (isDriveRootComparisonPath(normalizedProjectKey)) return false return normalizedWorkDir.startsWith(`${normalizedProjectKey}/`) } function hasSidebarProjectPreferences(preferences: SidebarProjectPreferences): boolean { return preferences.projectOrder.length > 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( 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 compareSessionsByTimestamp( a: SessionListItem | undefined, b: SessionListItem | undefined, sortBy: SidebarProjectSortBy, ): number { 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 { if (!pathLike) return 'Unknown project' const normalized = pathLike.replace(/[\\/]+$/, '') const segments = normalized.split(/[\\/]/).filter(Boolean) const last = segments[segments.length - 1] if (last) return last return normalized || 'Unknown project' } function projectSubtitle(projectRoot: string | null | undefined, fallbackKey: string): string | null { if (!projectRoot) return fallbackKey === 'unknown' ? null : fallbackKey return compactProjectPath(projectRoot) } function isWorktreeSession(session: SessionListItem): boolean { if (!session.workDir) return false if (/[\\/]\.claude[\\/]worktrees[\\/]/.test(session.workDir)) return true if (!session.projectRoot || session.workDir === session.projectRoot) return false return !isSameOrChildPath(session.workDir, session.projectRoot) } function isSameOrChildPath(childPath: string, parentPath: string): boolean { const child = normalizePathForCompare(childPath) const parent = normalizePathForCompare(parentPath) return child === parent || child.startsWith(`${parent}/`) } function normalizePathForCompare(pathLike: string): string { return pathLike.replace(/\\/g, '/').replace(/\/+$/, '') } function compactProjectPath(pathLike: string): string { const normalized = normalizePathForCompare(pathLike) const segments = normalized.split('/').filter(Boolean) if (segments.length <= 3) return normalized return `.../${segments.slice(-3, -1).join('/')}` } function domSafeProjectKey(projectKey: string): string { return projectKey.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'unknown' } function positionProjectMenu(clientX: number, clientY: number): React.CSSProperties { if (typeof window === 'undefined') return { left: clientX, top: clientY } const width = 230 const height = 280 return { left: Math.max(8, Math.min(clientX, window.innerWidth - width - 8)), top: Math.max(8, Math.min(clientY, window.innerHeight - height - 8)), } } function ProjectMenuItem({ icon, children, onClick, disabled = false, danger = false, }: { icon: React.ReactNode children: React.ReactNode onClick?: () => void disabled?: boolean danger?: boolean }) { return ( ) } function SessionRowMeta({ isRunning, isWorktree, modifiedAt, t, }: { isRunning: boolean isWorktree: boolean modifiedAt: string t: (key: TranslationKey, params?: Record) => string }) { const relativeTime = formatRelativeTime(modifiedAt, t) const updatedLabel = t('session.lastUpdated', { time: relativeTime }) return ( {isRunning && ( )} {isWorktree && ( )} {relativeTime} ) } function NavItem({ active, collapsed, label, touchFriendly, onClick, icon, children, }: { active: boolean collapsed: boolean label: string touchFriendly?: boolean onClick: () => void icon: React.ReactNode children: React.ReactNode }) { return ( ) } function formatRelativeTime( dateStr: string, t: (key: TranslationKey, params?: Record) => string, ): string { const date = new Date(dateStr) const timestamp = date.getTime() if (!Number.isFinite(timestamp)) return '' const diff = Date.now() - timestamp const min = Math.floor(diff / 60000) if (min < 1) return t('session.timeJustNow') if (min < 60) return t('session.timeMinutes', { n: min }) const hr = Math.floor(min / 60) if (hr < 24) return t('session.timeHours', { n: hr }) const day = Math.floor(hr / 24) if (day < 30) return t('session.timeDays', { n: day }) return new Intl.DateTimeFormat(undefined, { month: 'numeric', day: 'numeric' }).format(date) } function GitHubIcon() { return ( ) } function PlusIcon() { return ( ) } function ClockIcon() { return ( ) } function SearchIcon() { return ( ) } function SidebarToggleIcon({ collapsed }: { collapsed: boolean }) { return ( ) }