import React from 'react'; import type { Session } from '@opencode-ai/sdk/v2'; import { RiLayoutLeftLine } from '@remixicon/react'; import { toast } from '@/components/ui'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell, startDesktopWindowDrag } from '@/lib/desktop'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { sessionEvents } from '@/lib/sessionEvents'; import { formatDirectoryName, cn } from '@/lib/utils'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSidebarSessions, useAllSessionStatuses } from '@/sync/sync-context'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useSync } from '@/sync/use-sync'; import { useSessionPrefetch } from './sidebar/hooks/useSessionPrefetch'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useUIStore } from '@/stores/useUIStore'; import { getSafeStorage } from '@/stores/utils/safeStorage'; import { useGitStore, useGitAllBranches, useGitRepoStatusMap } from '@/stores/useGitStore'; import { useDeviceInfo } from '@/lib/device'; import { isVSCodeRuntime } from '@/lib/desktop'; import { NewWorktreeDialog } from './NewWorktreeDialog'; import { ScheduledTasksDialog } from './ScheduledTasksDialog'; import { ProjectNotesTodoPanel } from './ProjectNotesTodoPanel'; import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore'; import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import { useArchivedAutoFolders } from './sidebar/hooks/useArchivedAutoFolders'; import { useSessionSidebarSections } from './sidebar/hooks/useSessionSidebarSections'; import { useProjectSessionSelection } from './sidebar/hooks/useProjectSessionSelection'; import { useGroupOrdering } from './sidebar/hooks/useGroupOrdering'; import { useSessionGrouping } from './sidebar/hooks/useSessionGrouping'; import { useSessionSearchEffects } from './sidebar/hooks/useSessionSearchEffects'; import { useDirectoryStatusProbe } from './sidebar/hooks/useDirectoryStatusProbe'; import { useSessionActions } from './sidebar/hooks/useSessionActions'; import { useSidebarPersistence } from './sidebar/hooks/useSidebarPersistence'; import { useProjectRepoStatus } from './sidebar/hooks/useProjectRepoStatus'; import { useProjectSessionLists } from './sidebar/hooks/useProjectSessionLists'; import { useSessionFolderCleanup } from './sidebar/hooks/useSessionFolderCleanup'; import { useStickyProjectHeaders } from './sidebar/hooks/useStickyProjectHeaders'; import { getGitHubPrStatusKey, usePrVisualSummaryByKeys, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore'; import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog'; import { UpdateDialog } from '@/components/ui/UpdateDialog'; import { SessionGroupSection } from './sidebar/SessionGroupSection'; import { SidebarHeader } from './sidebar/SidebarHeader'; import { SidebarActivitySections } from './sidebar/SidebarActivitySections'; import { SidebarFooter } from './sidebar/SidebarFooter'; import { SidebarProjectsList } from './sidebar/SidebarProjectsList'; import { SessionNodeItem } from './sidebar/SessionNodeItem'; import { useUpdateStore } from '@/stores/useUpdateStore'; import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager'; import type { WorktreeMetadata } from '@/types/worktree'; import type { SortableDragHandleProps } from './sidebar/sortableItems'; import { FolderDeleteConfirmDialog, SessionDeleteConfirmDialog, type DeleteFolderConfirmState, type DeleteSessionConfirmState, } from './sidebar/ConfirmDialogs'; import { type SessionGroup, type SessionNode } from './sidebar/types'; import { type ActiveNowEntry, addActiveNowSession, deriveActiveNowSessions, persistActiveNowEntries, pruneActiveNowEntries, readActiveNowEntries, } from './sidebar/activitySections'; import { compareSessionsByPinnedAndTime, formatProjectLabel, normalizePath, } from './sidebar/utils'; import { refreshGlobalSessions, resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents'; const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse'; const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder'; const GROUP_COLLAPSE_STORAGE_KEY = 'oc.sessions.groupCollapse'; const PROJECT_ACTIVE_SESSION_STORAGE_KEY = 'oc.sessions.activeSessionByProject'; const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents'; const SESSION_PINNED_STORAGE_KEY = 'oc.sessions.pinned'; type PrVisualState = 'draft' | 'open' | 'blocked' | 'merged' | 'closed'; type PrIndicator = { visualState: PrVisualState; number: number; url: string | null; state: 'open' | 'closed' | 'merged'; draft: boolean; title: string | null; base: string | null; head: string | null; checks: { state: 'success' | 'failure' | 'pending' | 'unknown'; total: number; success: number; failure: number; pending: number; } | null; canMerge: boolean | null; mergeableState: string | null; repo: { owner: string; repo: string; } | null; }; interface SessionSidebarProps { mobileVariant?: boolean; onSessionSelected?: (sessionId: string) => void; allowReselect?: boolean; hideDirectoryControls?: boolean; showOnlyMainWorkspace?: boolean; } type SessionStatusActivityBridgeProps = { safeStorage: Storage; setActiveNowEntries: React.Dispatch>; }; const SessionStatusActivityBridge: React.FC = ({ safeStorage, setActiveNowEntries, }) => { const globalSessionStatuses = useAllSessionStatuses(); const sessionStatus = React.useMemo( () => new Map(Object.entries(globalSessionStatuses)), [globalSessionStatuses], ); React.useEffect(() => { const nextStreamingIds = new Set(); sessionStatus.forEach((status, sessionId) => { if (status?.type === 'busy' || status?.type === 'retry') { nextStreamingIds.add(sessionId); } }); if (nextStreamingIds.size > 0) { setActiveNowEntries((prev) => { const next = Array.from(nextStreamingIds).reduce((entries, sessionId) => addActiveNowSession(entries, sessionId), prev); if (next === prev) { return prev; } persistActiveNowEntries(safeStorage, next); return next; }); } }, [sessionStatus, safeStorage, setActiveNowEntries]); return null; }; export const SessionSidebar: React.FC = ({ mobileVariant = false, onSessionSelected, allowReselect = false, hideDirectoryControls = false, showOnlyMainWorkspace = false, }) => { const [isSessionSearchOpen, setIsSessionSearchOpen] = React.useState(false); const [sessionSearchQuery, setSessionSearchQuery] = React.useState(''); const sessionSearchContainerRef = React.useRef(null); const sessionSearchInputRef = React.useRef(null); const [editingId, setEditingId] = React.useState(null); const [editTitle, setEditTitle] = React.useState(''); const [editingProjectDialogId, setEditingProjectDialogId] = React.useState(null); const [expandedParents, setExpandedParents] = React.useState>(new Set()); const [directoryStatus, setDirectoryStatus] = React.useState>( () => new Map(), ); const safeStorage = React.useMemo(() => getSafeStorage(), []); const [activeNowEntries, setActiveNowEntries] = React.useState(() => readActiveNowEntries(safeStorage)); const [collapsedProjects, setCollapsedProjects] = React.useState>(new Set()); const [projectRepoStatus, setProjectRepoStatus] = React.useState>(new Map()); const [expandedSessionGroups, setExpandedSessionGroups] = React.useState>(new Set()); const [newWorktreeDialogOpen, setNewWorktreeDialogOpen] = React.useState(false); const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false); const [projectNotesPanelOpen, setProjectNotesPanelOpen] = React.useState(false); const [openSidebarMenuKey, setOpenSidebarMenuKey] = React.useState(null); const [renamingFolderId, setRenamingFolderId] = React.useState(null); const [renameFolderDraft, setRenameFolderDraft] = React.useState(''); const [deleteSessionConfirm, setDeleteSessionConfirm] = React.useState(null); const [deleteFolderConfirm, setDeleteFolderConfirm] = React.useState(null); const [pinnedSessionIds, setPinnedSessionIds] = React.useState>(() => { try { const raw = getSafeStorage().getItem(SESSION_PINNED_STORAGE_KEY); if (!raw) { return new Set(); } const parsed = JSON.parse(raw) as string[]; return new Set(Array.isArray(parsed) ? parsed.filter((item) => typeof item === 'string') : []); } catch { return new Set(); } }); const [collapsedGroups, setCollapsedGroups] = React.useState>(() => { try { const raw = getSafeStorage().getItem(GROUP_COLLAPSE_STORAGE_KEY); if (!raw) { return new Set(); } const parsed = JSON.parse(raw) as string[]; return new Set(Array.isArray(parsed) ? parsed.filter((item) => typeof item === 'string') : []); } catch { return new Set(); } }); const [groupOrderByProject, setGroupOrderByProject] = React.useState>(() => { try { const raw = getSafeStorage().getItem(GROUP_ORDER_STORAGE_KEY); if (!raw) { return new Map(); } const parsed = JSON.parse(raw) as Record; const next = new Map(); Object.entries(parsed).forEach(([projectId, order]) => { if (Array.isArray(order)) { next.set(projectId, order.filter((item) => typeof item === 'string')); } }); return next; } catch { return new Map(); } }); const [activeSessionByProject, setActiveSessionByProject] = React.useState>(() => { try { const raw = getSafeStorage().getItem(PROJECT_ACTIVE_SESSION_STORAGE_KEY); if (!raw) { return new Map(); } const parsed = JSON.parse(raw) as Record; const next = new Map(); Object.entries(parsed).forEach(([projectId, sessionId]) => { if (typeof sessionId === 'string' && sessionId.length > 0) { next.set(projectId, sessionId); } }); return next; } catch { return new Map(); } }); const [projectRootBranches, setProjectRootBranches] = React.useState>(new Map()); const projectHeaderSentinelRefs = React.useRef>(new Map()); const ignoreIntersectionUntil = React.useRef(0); const homeDirectory = useDirectoryStore((state) => state.homeDirectory); const currentDirectory = useDirectoryStore((state) => state.currentDirectory); const setDirectory = useDirectoryStore((state) => state.setDirectory); const projects = useProjectsStore((state) => state.projects); const activeProjectId = useProjectsStore((state) => state.activeProjectId); const addProject = useProjectsStore((state) => state.addProject); const removeProject = useProjectsStore((state) => state.removeProject); const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly); const updateProjectMeta = useProjectsStore((state) => state.updateProjectMeta); const reorderProjects = useProjectsStore((state) => state.reorderProjects); const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); const openContextPanelTab = useUIStore((state) => state.openContextPanelTab); const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); const toggleHelpDialog = useUIStore((state) => state.toggleHelpDialog); const setAboutDialogOpen = useUIStore((state) => state.setAboutDialogOpen); const deviceInfo = useDeviceInfo(); const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen); const setScheduledTasksDialogOpen = useUIStore((state) => state.setScheduledTasksDialogOpen); const toggleSidebar = useUIStore((state) => state.toggleSidebar); const openMultiRunLauncher = useUIStore((state) => state.openMultiRunLauncher); const notifyOnSubtasks = useUIStore((state) => state.notifyOnSubtasks); const showDeletionDialog = useUIStore((state) => state.showDeletionDialog); const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog); const debouncedSessionSearchQuery = useDebouncedValue(sessionSearchQuery, 120); const normalizedSessionSearchQuery = React.useMemo( () => debouncedSessionSearchQuery.trim().toLowerCase(), [debouncedSessionSearchQuery], ); const hasSessionSearchQuery = normalizedSessionSearchQuery.length > 0; // Session Folders store const collapsedFolderIds = useSessionFoldersStore((state) => state.collapsedFolderIds); const foldersMap = useSessionFoldersStore((state) => state.foldersMap); const getFoldersForScope = useSessionFoldersStore((state) => state.getFoldersForScope); const createFolder = useSessionFoldersStore((state) => state.createFolder); const renameFolder = useSessionFoldersStore((state) => state.renameFolder); const deleteFolder = useSessionFoldersStore((state) => state.deleteFolder); const addSessionToFolder = useSessionFoldersStore((state) => state.addSessionToFolder); const removeSessionFromFolder = useSessionFoldersStore((state) => state.removeSessionFromFolder); const toggleFolderCollapse = useSessionFoldersStore((state) => state.toggleFolderCollapse); const cleanupSessions = useSessionFoldersStore((state) => state.cleanupSessions); const getSessionFolderId = useSessionFoldersStore((state) => state.getSessionFolderId); useSessionSearchEffects({ isSessionSearchOpen, setIsSessionSearchOpen, sessionSearchInputRef, sessionSearchContainerRef, }); const gitBranches = useGitAllBranches(); const sync = useSync(); const syncSessions = useSidebarSessions(); const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions); const archivedSessions = useGlobalSessionsStore((state) => state.archivedSessions); const hasLoadedGlobalSessions = useGlobalSessionsStore((state) => state.hasLoaded); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open)); const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); const updateSessionTitle = useSessionUIStore((state) => state.updateSessionTitle); const shareSession = useSessionUIStore((state) => state.shareSession); const unshareSession = useSessionUIStore((state) => state.unshareSession); // sessionAttentionStates removed — now using notification-store directly in SessionNodeItem const worktreeMetadata = useSessionUIStore((state) => state.worktreeMetadata); const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); const updateStore = useUpdateStore(); const sessions = React.useMemo(() => { if (!hasLoadedGlobalSessions) { return syncSessions; } if (syncSessions.length === 0) { return globalActiveSessions; } const syncedById = new Map(syncSessions.map((session) => [session.id, session])); const merged = globalActiveSessions.map((session) => syncedById.get(session.id) ?? session); const seenIds = new Set(merged.map((session) => session.id)); syncSessions.forEach((session) => { if (seenIds.has(session.id)) { return; } const sessionDirectory = resolveGlobalSessionDirectory(session); if (sessionDirectory && sessionDirectory === currentDirectory) { merged.push(session); } }); return merged; }, [currentDirectory, globalActiveSessions, hasLoadedGlobalSessions, syncSessions]); const syncSessionStructureSignature = React.useMemo( () => syncSessions .map((session) => { const directory = normalizePath((session as Session & { directory?: string | null }).directory ?? null) ?? ''; return `${session.id}:${session.title ?? ''}:${session.time?.archived ? 1 : 0}:${directory}`; }) .join('|'), [syncSessions], ); const syncSessionsSnapshotRef = React.useRef(syncSessions); React.useEffect(() => { syncSessionsSnapshotRef.current = syncSessions; }, [syncSessionStructureSignature, syncSessions]); React.useEffect(() => { let cancelled = false; const discoverWorktrees = async () => { const projectEntries = useProjectsStore.getState().projects; if (projectEntries.length === 0) return; const worktreesByProject = new Map(); const allWorktrees: WorktreeMetadata[] = []; await Promise.all( projectEntries.map(async (project) => { const projectPath = normalizePath(project.path); if (!projectPath) return; try { // Use store-cached isGitRepo when available; fall back to direct check for initial worktree discovery const cachedIsGitRepo = useGitStore.getState().directories.get(projectPath)?.isGitRepo; const isGitRepo = cachedIsGitRepo ?? await import('@/lib/gitApi').then(m => m.checkIsGitRepository(projectPath)); if (!isGitRepo) return; const worktrees = await listProjectWorktrees({ id: project.id, path: projectPath }); if (cancelled || worktrees.length === 0) return; worktreesByProject.set(projectPath, worktrees); allWorktrees.push(...worktrees); } catch { // ignore discovery errors } }), ); if (cancelled) return; useSessionUIStore.setState({ availableWorktrees: allWorktrees, availableWorktreesByProject: worktreesByProject, }); }; void refreshGlobalSessions(syncSessionsSnapshotRef.current); void discoverWorktrees(); return () => { cancelled = true; }; }, [currentDirectory, syncSessionStructureSignature]); React.useEffect(() => { let refreshTimeout: ReturnType | null = null; const unsubscribe = subscribeOpenchamberEvents((event) => { if (event.type !== 'scheduled-task-ran') { return; } if (refreshTimeout) { clearTimeout(refreshTimeout); } refreshTimeout = setTimeout(() => { void refreshGlobalSessions(syncSessionsSnapshotRef.current); }, 500); }); return () => { if (refreshTimeout) { clearTimeout(refreshTimeout); } unsubscribe(); }; }, []); const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []); const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []); const [isDesktopWindowFullscreen, setIsDesktopWindowFullscreen] = React.useState(false); const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); const isMacPlatform = React.useMemo(() => { if (typeof navigator === 'undefined') { return false; } return /Macintosh|Mac OS X/.test(navigator.userAgent || ''); }, []); const showDesktopSidebarChrome = !mobileVariant && !isVSCode; const desktopSidebarTopPaddingClass = isDesktopShellRuntime && isMacPlatform && !isDesktopWindowFullscreen ? 'pl-[5.5rem]' : 'pl-3'; const desktopSidebarToggleButtonClass = 'app-region-no-drag inline-flex h-8 w-8 items-center justify-center rounded-md typography-ui-label font-medium text-foreground transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50'; React.useEffect(() => { if (!isDesktopShellRuntime || !isMacPlatform) { setIsDesktopWindowFullscreen(false); return; } let disposed = false; let unlistenResize: (() => void) | null = null; const syncFullscreenState = async () => { try { const { getCurrentWindow } = await import('@tauri-apps/api/window'); const currentWindow = getCurrentWindow(); const fullscreen = await currentWindow.isFullscreen(); if (!disposed) { setIsDesktopWindowFullscreen(fullscreen); } } catch { if (!disposed) { setIsDesktopWindowFullscreen(false); } } }; const attach = async () => { try { const { getCurrentWindow } = await import('@tauri-apps/api/window'); const currentWindow = getCurrentWindow(); unlistenResize = await currentWindow.onResized(() => { void syncFullscreenState(); }); } catch { // Ignore listener setup failures; fallback state remains false. } }; void syncFullscreenState(); void attach(); return () => { disposed = true; if (unlistenResize) { unlistenResize(); } }; }, [isDesktopShellRuntime, isMacPlatform]); const handleDesktopSidebarDragStart = React.useCallback(async (event: React.MouseEvent) => { const target = event.target as HTMLElement; if (target.closest('.app-region-no-drag')) { return; } if (target.closest('button, a, input, select, textarea')) { return; } if (event.button !== 0) { return; } if (!isDesktopShellRuntime) { return; } await startDesktopWindowDrag(); }, [isDesktopShellRuntime]); const { buildGroupSearchText, filterSessionNodesForSearch, buildGroupedSessions, } = useSessionGrouping({ homeDirectory, worktreeMetadata, pinnedSessionIds, gitBranches, isVSCode, }); const { scheduleCollapsedProjectsPersist } = useSidebarPersistence({ isVSCode, safeStorage, keys: { sessionExpanded: SESSION_EXPANDED_STORAGE_KEY, projectCollapse: PROJECT_COLLAPSE_STORAGE_KEY, sessionPinned: SESSION_PINNED_STORAGE_KEY, groupOrder: GROUP_ORDER_STORAGE_KEY, projectActiveSession: PROJECT_ACTIVE_SESSION_STORAGE_KEY, groupCollapse: GROUP_COLLAPSE_STORAGE_KEY, }, sessions, pinnedSessionIds, setPinnedSessionIds, groupOrderByProject, activeSessionByProject, collapsedGroups, setExpandedParents, setCollapsedProjects, }); const togglePinnedSession = React.useCallback((sessionId: string) => { setPinnedSessionIds((prev) => { const next = new Set(prev); if (next.has(sessionId)) { next.delete(sessionId); } else { next.add(sessionId); } return next; }); }, []); const sortedSessions = React.useMemo(() => { return [...sessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds)); }, [sessions, pinnedSessionIds]); const sessionOrderIndex = React.useMemo( () => new Map(sortedSessions.map((session, index) => [session.id, index])), [sortedSessions], ); const allKnownSessionsById = React.useMemo(() => { const next = new Map(); [...sessions, ...archivedSessions].forEach((session) => { next.set(session.id, session); }); return next; }, [sessions, archivedSessions]); React.useEffect(() => { const pruned = pruneActiveNowEntries(activeNowEntries, allKnownSessionsById); if (pruned.length === activeNowEntries.length && pruned.every((entry, index) => entry.sessionId === activeNowEntries[index]?.sessionId)) { return; } setActiveNowEntries(pruned); persistActiveNowEntries(safeStorage, pruned); }, [activeNowEntries, allKnownSessionsById, safeStorage]); const childrenMap = React.useMemo(() => { const map = new Map(); sortedSessions.forEach((session) => { const parentID = (session as Session & { parentID?: string | null }).parentID; if (!parentID) { return; } const collection = map.get(parentID) ?? []; collection.push(session); map.set(parentID, collection); }); map.forEach((list) => list.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds))); return map; }, [sortedSessions, pinnedSessionIds]); useDirectoryStatusProbe({ sortedSessions, projects, directoryStatus, setDirectoryStatus, }); const emptyState = (

No sessions yet

Create your first session to start coding.

); const editingProject = React.useMemo( () => projects.find((project) => project.id === editingProjectDialogId) ?? null, [projects, editingProjectDialogId], ); const handleSaveProjectEdit = React.useCallback((data: { label: string; icon: string | null; color: string | null; iconBackground: string | null }) => { if (!editingProjectDialogId) { return; } updateProjectMeta(editingProjectDialogId, data); setEditingProjectDialogId(null); }, [editingProjectDialogId, updateProjectMeta]); const openNewWorktreeDialog = React.useCallback(() => { setNewWorktreeDialogOpen(true); }, []); const handleOpenUpdateDialog = React.useCallback(() => { const current = useUpdateStore.getState(); if (current.available && current.info) { setUpdateDialogOpen(true); return; } void updateStore.checkForUpdates().then(() => { const { available, error } = useUpdateStore.getState(); if (error) { toast.error('Failed to check for updates', { description: error }); return; } if (!available) { toast.success('You are on the latest version'); return; } setUpdateDialogOpen(true); }); }, [updateStore]); const handleOpenSettings = React.useCallback(() => { if (mobileVariant) { setSessionSwitcherOpen(false); } setSettingsDialogOpen(true); }, [mobileVariant, setSessionSwitcherOpen, setSettingsDialogOpen]); const showSidebarUpdateButton = updateStore.available && (updateStore.runtimeType === 'desktop' || updateStore.runtimeType === 'web'); const deleteSession = useSessionUIStore((state) => state.deleteSession); const deleteSessions = useSessionUIStore((state) => state.deleteSessions); const archiveSession = useSessionUIStore((state) => state.archiveSession); const archiveSessions = useSessionUIStore((state) => state.archiveSessions); const { copiedSessionId, handleSessionSelect, handleSessionDoubleClick, handleSaveEdit, handleCancelEdit, handleShareSession, handleCopyShareUrl, handleUnshareSession, handleDeleteSession, confirmDeleteSession, } = useSessionActions({ activeProjectId, currentDirectory, currentSessionId, mobileVariant, allowReselect, onSessionSelected, isSessionSearchOpen, sessionSearchQuery, setSessionSearchQuery, setIsSessionSearchOpen, setActiveProjectIdOnly, setDirectory, setActiveMainTab, setSessionSwitcherOpen, setCurrentSession, updateSessionTitle, shareSession, unshareSession, deleteSession, deleteSessions, archiveSession, archiveSessions, childrenMap, showDeletionDialog, setDeleteSessionConfirm, deleteSessionConfirm, setEditingId, setEditTitle, editingId, editTitle, }); const confirmDeleteFolder = React.useCallback(() => { if (!deleteFolderConfirm) return; const { scopeKey, folderId } = deleteFolderConfirm; setDeleteFolderConfirm(null); deleteFolder(scopeKey, folderId); }, [deleteFolderConfirm, deleteFolder]); const handleOpenDirectoryDialog = React.useCallback(() => { if (!tauriIpcAvailable || !isDesktopLocalOriginActive()) { sessionEvents.requestDirectoryDialog(); return; } import('@/lib/desktop') .then(({ requestDirectoryAccess }) => requestDirectoryAccess('')) .then((result) => { if (result.success && result.path) { const added = addProject(result.path, { id: result.projectId }); if (!added) { toast.error('Failed to add project', { description: 'Please select a valid directory.', }); } } else if (result.error && result.error !== 'Directory selection cancelled') { toast.error('Failed to select directory', { description: result.error, }); } }) .catch((error) => { console.error('Desktop: Error selecting directory:', error); toast.error('Failed to select directory'); }); }, [addProject, tauriIpcAvailable]); // Auto-expand parent session when navigating to a subagent (child) session React.useEffect(() => { if (!currentSessionId) return; const current = sessions.find((s) => s.id === currentSessionId); const parentID = (current as Session & { parentID?: string | null })?.parentID; if (!parentID) return; setExpandedParents((prev) => { if (prev.has(parentID)) return prev; const next = new Set(prev); next.add(parentID); try { safeStorage.setItem(SESSION_EXPANDED_STORAGE_KEY, JSON.stringify(Array.from(next))); } catch { /* ignored */ } return next; }); }, [currentSessionId, sessions, safeStorage]); const toggleParent = React.useCallback((sessionId: string) => { setExpandedParents((prev) => { const next = new Set(prev); if (next.has(sessionId)) { next.delete(sessionId); } else { next.add(sessionId); } try { safeStorage.setItem(SESSION_EXPANDED_STORAGE_KEY, JSON.stringify(Array.from(next))); } catch { /* ignored */ } return next; }); }, [safeStorage]); const createFolderAndStartRename = React.useCallback( (scopeKey: string, parentId?: string | null) => { if (!scopeKey) { return null; } if (parentId && collapsedFolderIds.has(parentId)) { toggleFolderCollapse(parentId); } const newFolder = createFolder(scopeKey, 'New folder', parentId); setRenamingFolderId(newFolder.id); setRenameFolderDraft(newFolder.name); return newFolder; }, [collapsedFolderIds, toggleFolderCollapse, createFolder], ); const toggleGroupSessionLimit = React.useCallback((groupId: string) => { setExpandedSessionGroups((prev) => { const next = new Set(prev); if (next.has(groupId)) { next.delete(groupId); } else { next.add(groupId); } return next; }); }, []); const collapseAllProjects = React.useCallback(() => { ignoreIntersectionUntil.current = Date.now() + 150; setCollapsedProjects(() => { const allIds = new Set(projects.map((p) => p.id)); try { safeStorage.setItem(PROJECT_COLLAPSE_STORAGE_KEY, JSON.stringify(Array.from(allIds))); } catch { /* ignored */ } if (!isVSCode) { scheduleCollapsedProjectsPersist(allIds); } return allIds; }); }, [projects, isVSCode, safeStorage, scheduleCollapsedProjectsPersist]); const expandAllProjects = React.useCallback(() => { ignoreIntersectionUntil.current = Date.now() + 150; setCollapsedProjects(() => { const empty = new Set(); try { safeStorage.setItem(PROJECT_COLLAPSE_STORAGE_KEY, JSON.stringify([])); } catch { /* ignored */ } if (!isVSCode) { scheduleCollapsedProjectsPersist(empty); } return empty; }); }, [isVSCode, safeStorage, scheduleCollapsedProjectsPersist]); const toggleProject = React.useCallback((projectId: string) => { // Ignore intersection events for a short period after toggling ignoreIntersectionUntil.current = Date.now() + 150; setCollapsedProjects((prev) => { const next = new Set(prev); if (next.has(projectId)) { next.delete(projectId); } else { next.add(projectId); } try { safeStorage.setItem(PROJECT_COLLAPSE_STORAGE_KEY, JSON.stringify(Array.from(next))); } catch { /* ignored */ } // Persist collapse state to server settings (web + desktop local/remote). if (!isVSCode) { scheduleCollapsedProjectsPersist(next); } return next; }); }, [isVSCode, safeStorage, scheduleCollapsedProjectsPersist]); const normalizedProjects = React.useMemo(() => { return projects .map((project) => ({ ...project, normalizedPath: normalizePath(project.path), })) .filter((project) => Boolean(project.normalizedPath)) as Array<{ id: string; path: string; label?: string; normalizedPath: string; icon?: string; color?: string; iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' }; iconBackground?: string; }>; }, [projects]); const normalizedProjectPaths = React.useMemo( () => normalizedProjects.map((project) => project.normalizedPath), [normalizedProjects], ); const { github } = useRuntimeAPIs(); const githubAuthStatus = useGitHubAuthStore((state) => state.status); const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked); const gitRepoStatus = useGitRepoStatusMap(normalizedProjectPaths); const ensurePrStatusEntry = useGitHubPrStatusStore((state) => state.ensureEntry); const setPrStatusParams = useGitHubPrStatusStore((state) => state.setParams); const refreshPrStatusTargets = useGitHubPrStatusStore((state) => state.refreshTargets); useProjectRepoStatus({ normalizedProjects, gitRepoStatus, setProjectRepoStatus, setProjectRootBranches, }); const isSessionsLoading = useSessionUIStore((state) => state.isLoading); useSessionFolderCleanup({ isSessionsLoading, sessions, archivedSessions, normalizedProjects, isVSCode, availableWorktreesByProject, cleanupSessions, }); const { getSessionsForProject, getArchivedSessionsForProject } = useProjectSessionLists({ isVSCode, sessions, archivedSessions, availableWorktreesByProject, }); useArchivedAutoFolders({ normalizedProjects, sessions, archivedSessions, availableWorktreesByProject, isVSCode, isSessionsLoading, foldersMap, createFolder, addSessionToFolder, cleanupSessions, }); // Keep last-known repo status to avoid UI jiggling during project switch const lastRepoStatusRef = React.useRef(false); if (activeProjectId && projectRepoStatus.has(activeProjectId)) { lastRepoStatusRef.current = Boolean(projectRepoStatus.get(activeProjectId)); } const { projectSections, groupSearchDataByGroup, sectionsForRender, searchMatchCount, } = useSessionSidebarSections({ normalizedProjects, getSessionsForProject, getArchivedSessionsForProject, availableWorktreesByProject, projectRepoStatus, projectRootBranches, lastRepoStatus: lastRepoStatusRef.current, buildGroupedSessions, hasSessionSearchQuery, normalizedSessionSearchQuery, filterSessionNodesForSearch, buildGroupSearchText, getFoldersForScope, }); const searchEmptyState = (

No matching sessions

Try a different title, branch, folder, or path.

); const activeProjectForHeader = React.useMemo( () => normalizedProjects.find((project) => project.id === activeProjectId) ?? normalizedProjects[0] ?? null, [normalizedProjects, activeProjectId], ); const activeProjectRefForHeader = React.useMemo( () => (activeProjectForHeader ? { id: activeProjectForHeader.id, path: activeProjectForHeader.normalizedPath, } : null), [activeProjectForHeader], ); const activeProjectLabelForHeader = React.useMemo( () => (activeProjectForHeader ? activeProjectForHeader.label?.trim() || formatDirectoryName(activeProjectForHeader.normalizedPath, homeDirectory) || activeProjectForHeader.normalizedPath : null), [activeProjectForHeader, homeDirectory], ); const activeProjectIsRepo = React.useMemo( () => (activeProjectForHeader ? Boolean(projectRepoStatus.get(activeProjectForHeader.id)) : false), [activeProjectForHeader, projectRepoStatus], ); // Only flip to false once the new project's status is actually resolved (present in map) const stableActiveProjectIsRepo = activeProjectForHeader && projectRepoStatus.has(activeProjectForHeader.id) ? activeProjectIsRepo : lastRepoStatusRef.current; const reserveHeaderActionsSpace = true; const useMobileNotesPanel = mobileVariant || deviceInfo.isMobile; React.useEffect(() => { if (!activeProjectForHeader) { setProjectNotesPanelOpen(false); } }, [activeProjectForHeader]); const { currentSessionDirectory } = useProjectSessionSelection({ projectSections, activeProjectId, activeSessionByProject, setActiveSessionByProject, currentSessionId, handleSessionSelect, newSessionDraftOpen, mobileVariant, openNewSessionDraft, setActiveMainTab, setSessionSwitcherOpen, sessions, worktreeMetadata, }); const { getOrderedGroups } = useGroupOrdering(groupOrderByProject); const hasInitializedArchivedCollapseRef = React.useRef(false); React.useEffect(() => { if (hasInitializedArchivedCollapseRef.current || projectSections.length === 0) { return; } const archivedGroupKeys = projectSections.flatMap((section) => section.groups .filter((group) => group.isArchivedBucket) .map((group) => `${section.project.id}:${group.id}`), ); if (archivedGroupKeys.length > 0) { setCollapsedGroups((prev) => new Set([...prev, ...archivedGroupKeys])); } hasInitializedArchivedCollapseRef.current = true; }, [projectSections]); const sessionSidebarMetaById = React.useMemo(() => { const meta = new Map(); const projectPathLengthBySessionId = new Map(); projectSections.forEach((section) => { const projectLabel = formatProjectLabel( section.project.label?.trim() || formatDirectoryName(section.project.normalizedPath, homeDirectory) || section.project.normalizedPath, ); section.groups.forEach((group) => { const secondaryMeta = group.branch && group.branch !== projectLabel ? { projectLabel, branchLabel: group.branch } : { projectLabel, branchLabel: null }; const visit = (nodes: SessionNode[]) => { nodes.forEach((node) => { const nextProjectPathLength = section.project.normalizedPath.length; const currentProjectPathLength = projectPathLengthBySessionId.get(node.session.id) ?? -1; if (nextProjectPathLength < currentProjectPathLength) { return; } meta.set(node.session.id, { node, projectId: section.project.id, groupDirectory: group.directory, secondaryMeta, }); projectPathLengthBySessionId.set(node.session.id, nextProjectPathLength); if (node.children.length > 0) { visit(node.children); } }); }; visit(group.sessions); }); }); return meta; }, [projectSections, homeDirectory]); const activeNowSessions = React.useMemo( () => deriveActiveNowSessions(activeNowEntries, new Map(sessions.map((session) => [session.id, session]))), [activeNowEntries, sessions], ); // Prefetch is wired below, after recentSessionIds is computed. const activitySections = React.useMemo(() => { const toItem = (session: Session) => { const existing = sessionSidebarMetaById.get(session.id); const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null); return { node: existing?.node ?? { session, children: [], worktree: null }, projectId: existing?.projectId ?? null, groupDirectory: existing?.groupDirectory ?? sessionDirectory, secondaryMeta: existing?.secondaryMeta ?? null, }; }; return [ { key: 'active-now' as const, title: 'recent', items: activeNowSessions.map(toItem) }, ]; }, [activeNowSessions, sessionSidebarMetaById]); const recentSessionIds = React.useMemo(() => { return new Set(activitySections.flatMap((section) => section.items.map((item) => item.node.session.id))); }, [activitySections]); const recentSessionIdsList = React.useMemo(() => [...recentSessionIds], [recentSessionIds]); useSessionPrefetch({ currentSessionId, sortedSessions, recentSessionIds: recentSessionIdsList, loadMessages: sync.syncSession, }); const sectionsForSidebarRender = React.useMemo(() => { if (!isVSCode || hasSessionSearchQuery || recentSessionIds.size === 0) { return sectionsForRender; } const filterNodes = (nodes: SessionNode[]): SessionNode[] => { return nodes.reduce((acc, node) => { if (recentSessionIds.has(node.session.id)) { return acc; } const filteredChildren = filterNodes(node.children); if (filteredChildren.length === node.children.length) { acc.push(node); return acc; } acc.push({ ...node, children: filteredChildren, }); return acc; }, []); }; return sectionsForRender.map((section) => ({ ...section, groups: section.groups.map((group) => ({ ...group, sessions: filterNodes(group.sessions), })), })); }, [isVSCode, hasSessionSearchQuery, recentSessionIds, sectionsForRender]); const prLookupKeys = React.useMemo(() => { const keys = new Set(); sectionsForSidebarRender.forEach((section) => { section.groups.forEach((group) => { const directory = normalizePath(group.directory ?? null); const branch = group.branch?.trim() || gitBranches.get(directory || '')?.trim(); if (!directory || !branch) { return; } keys.add(getGitHubPrStatusKey(directory, branch)); }); }); return [...keys]; }, [gitBranches, sectionsForSidebarRender]); const prVisualSummaryMap = usePrVisualSummaryByKeys(prLookupKeys); React.useEffect(() => { if (!githubAuthChecked || !githubAuthStatus?.connected || !github) { return; } const missingTargets: Array<{ directory: string; branch: string; remoteName?: string | null }> = []; sectionsForSidebarRender.forEach((section) => { if (collapsedProjects.has(section.project.id)) { return; } section.groups.forEach((group) => { const directory = normalizePath(group.directory ?? null); const branch = group.branch?.trim() || gitBranches.get(directory || '')?.trim(); if (!directory || !branch) { return; } const key = getGitHubPrStatusKey(directory, branch); const entry = useGitHubPrStatusStore.getState().entries[key]; if (!entry || !entry.isInitialStatusResolved) { missingTargets.push({ directory, branch }); } }); }); if (missingTargets.length === 0) { return; } const uniqueTargets = new Map(); missingTargets.forEach((target) => { const key = getGitHubPrStatusKey(target.directory, target.branch, target.remoteName ?? null); if (!uniqueTargets.has(key)) { uniqueTargets.set(key, target); } }); uniqueTargets.forEach((target, key) => { ensurePrStatusEntry(key); setPrStatusParams(key, { directory: target.directory, branch: target.branch, remoteName: target.remoteName ?? null, canShow: true, github, githubAuthChecked, githubConnected: githubAuthStatus.connected, }); }); void refreshPrStatusTargets([...uniqueTargets.values()], { force: true, silent: true, markInitialResolved: true, }); }, [ collapsedProjects, ensurePrStatusEntry, github, githubAuthChecked, githubAuthStatus?.connected, gitBranches, refreshPrStatusTargets, sectionsForSidebarRender, setPrStatusParams, ]); const desktopHeaderActionButtonClass = 'inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md leading-none text-foreground hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed'; const mobileHeaderActionButtonClass = 'inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md leading-none text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed'; const headerActionButtonClass = mobileVariant ? mobileHeaderActionButtonClass : desktopHeaderActionButtonClass; const headerActionIconClass = 'h-4.5 w-4.5'; const stuckProjectHeaders = useStickyProjectHeaders({ isDesktopShellRuntime, projectSections, projectHeaderSentinelRefs, }); const renderSessionNode = React.useCallback( ( node: SessionNode, depth = 0, groupDirectory?: string | null, projectId?: string | null, archivedBucket = false, secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null, renderContext: 'project' | 'recent' = 'project', ): React.ReactNode => ( ), [ directoryStatus, currentSessionId, pinnedSessionIds, expandedParents, hasSessionSearchQuery, normalizedSessionSearchQuery, notifyOnSubtasks, editingId, setEditingId, editTitle, setEditTitle, handleSaveEdit, handleCancelEdit, toggleParent, handleSessionSelect, handleSessionDoubleClick, togglePinnedSession, handleShareSession, copiedSessionId, handleCopyShareUrl, handleUnshareSession, openSidebarMenuKey, setOpenSidebarMenuKey, renamingFolderId, getFoldersForScope, getSessionFolderId, removeSessionFromFolder, addSessionToFolder, createFolderAndStartRename, openContextPanelTab, handleDeleteSession, mobileVariant, ], ); const toggleCollapsedGroup = React.useCallback((key: string) => { setCollapsedGroups((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); }, []); const prVisualStateByDirectoryBranch = React.useMemo(() => { const result = new Map(); for (const [key, summary] of prVisualSummaryMap) { result.set(key, { visualState: summary.visualState as PrVisualState, number: summary.number, url: summary.url, state: summary.prState as 'open' | 'closed' | 'merged', draft: summary.draft, title: summary.title, base: summary.base, head: summary.head, checks: summary.checks as PrIndicator['checks'], canMerge: summary.canMerge, mergeableState: summary.mergeableState, repo: summary.repo, }); } return result; }, [prVisualSummaryMap]); const renderGroupSessions = React.useCallback( (group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean, dragHandleProps?: SortableDragHandleProps | null, compactBodyPadding?: boolean) => ( ), [ hasSessionSearchQuery, normalizedSessionSearchQuery, groupSearchDataByGroup, expandedSessionGroups, collapsedGroups, hideDirectoryControls, getFoldersForScope, collapsedFolderIds, toggleFolderCollapse, renameFolder, deleteFolder, showDeletionDialog, renderSessionNode, currentSessionDirectory, projectRepoStatus, toggleGroupSessionLimit, mobileVariant, activeProjectId, setActiveProjectIdOnly, setActiveMainTab, setSessionSwitcherOpen, openNewSessionDraft, addSessionToFolder, createFolderAndStartRename, renamingFolderId, renameFolderDraft, pinnedSessionIds, sessionOrderIndex, prVisualStateByDirectoryBranch, toggleCollapsedGroup, ], ); const topContent = !hasSessionSearchQuery ? ( ) : null; const isInlineEditing = Boolean(renamingFolderId || editingId || editingProjectDialogId); const handleSidebarNewSession = React.useCallback(() => { setActiveMainTab('chat'); if (mobileVariant) { setSessionSwitcherOpen(false); } openNewSessionDraft(); }, [mobileVariant, openNewSessionDraft, setActiveMainTab, setSessionSwitcherOpen]); const handleOpenMultiRunFromHeader = React.useCallback(() => { setActiveMainTab('chat'); if (mobileVariant) { setSessionSwitcherOpen(false); } openMultiRunLauncher(); }, [mobileVariant, openMultiRunLauncher, setActiveMainTab, setSessionSwitcherOpen]); return (
{showDesktopSidebarChrome ? (

Close sessions

) : null} 0} openMultiRunLauncher={handleOpenMultiRunFromHeader} stableActiveProjectIsRepo={stableActiveProjectIsRepo} headerActionIconClass={headerActionIconClass} reserveHeaderActionsSpace={reserveHeaderActionsSpace} headerActionButtonClass={headerActionButtonClass} isSessionSearchOpen={isSessionSearchOpen} setIsSessionSearchOpen={setIsSessionSearchOpen} sessionSearchInputRef={sessionSearchInputRef} sessionSearchQuery={sessionSearchQuery} setSessionSearchQuery={setSessionSearchQuery} hasSessionSearchQuery={hasSessionSearchQuery} searchMatchCount={searchMatchCount} collapseAllProjects={collapseAllProjects} expandAllProjects={expandAllProjects} openScheduledTasksDialog={() => setScheduledTasksDialogOpen(true)} /> setAboutDialogOpen(true)} onOpenUpdate={handleOpenUpdateDialog} showRuntimeButtons={!isVSCode} showUpdateButton={showSidebarUpdateButton} /> {editingProject ? ( { if (!open) { setEditingProjectDialogId(null); } }} projectId={editingProject.id} projectName={editingProject.label || formatDirectoryName(editingProject.path, homeDirectory)} projectPath={editingProject.path} initialIcon={editingProject.icon} initialColor={editingProject.color} initialIconBackground={editingProject.iconBackground} onSave={handleSaveProjectEdit} /> ) : null} { setActiveMainTab('chat'); if (mobileVariant) { setSessionSwitcherOpen(false); } if (options?.sessionId) { setCurrentSession(options.sessionId); return; } openNewSessionDraft({ directoryOverride: worktreePath }); }} /> {useMobileNotesPanel ? ( setProjectNotesPanelOpen(false)} title={activeProjectLabelForHeader ? `Project notes - ${activeProjectLabelForHeader}` : 'Project notes'} > setProjectNotesPanelOpen(false)} className="p-0" /> ) : null}
); };