import React from 'react'; import type { Session } from '@opencode-ai/sdk/v2'; import { toast } from 'sonner'; import { DndContext, DragOverlay, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent, type DragStartEvent, } from '@dnd-kit/core'; import { SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; import { RiAddLine, RiArrowDownSLine, RiArrowRightSLine, RiCheckLine, RiCloseLine, RiDeleteBinLine, RiErrorWarningLine, RiFileCopyLine, RiGitBranchLine, RiLinkUnlinkM, RiMore2Line, RiPencilAiLine, RiShare2Line, RiShieldLine, } from '@remixicon/react'; import { sessionEvents } from '@/lib/sessionEvents'; import { ArrowsMerge } from '@/components/icons/ArrowsMerge'; import { formatDirectoryName, formatPathForDisplay, cn } from '@/lib/utils'; import { useSessionStore } from '@/stores/useSessionStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useUIStore } from '@/stores/useUIStore'; import type { WorktreeMetadata } from '@/types/worktree'; import { opencodeClient } from '@/lib/opencode/client'; import { checkIsGitRepository } from '@/lib/gitApi'; import { getSafeStorage } from '@/stores/utils/safeStorage'; const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse'; const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents'; const formatDateLabel = (value: string | number) => { const targetDate = new Date(value); const today = new Date(); const isSameDay = (a: Date, b: Date) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); const yesterday = new Date(today); yesterday.setDate(today.getDate() - 1); if (isSameDay(targetDate, today)) { return 'Today'; } if (isSameDay(targetDate, yesterday)) { return 'Yesterday'; } const formatted = targetDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', }); return formatted.replace(',', ''); }; const normalizePath = (value?: string | null) => { if (!value) { return null; } const normalized = value.replace(/\\/g, '/').replace(/\/+$/, ''); return normalized.length === 0 ? '/' : normalized; }; // Format project label: kebab-case/snake_case → Title Case const formatProjectLabel = (label: string): string => { return label .replace(/[-_]/g, ' ') .replace(/\b\w/g, (char) => char.toUpperCase()); }; type SessionNode = { session: Session; children: SessionNode[]; worktree: WorktreeMetadata | null; }; type SessionGroup = { id: string; label: string; description: string | null; isMain: boolean; worktree: WorktreeMetadata | null; directory: string | null; sessions: SessionNode[]; }; interface SortableProjectItemProps { id: string; projectLabel: string; projectDescription: string; isCollapsed: boolean; isActiveProject: boolean; isRepo: boolean; isHovered: boolean; isDesktopRuntime: boolean; isStuck: boolean; hideDirectoryControls: boolean; mobileVariant: boolean; onToggle: () => void; onHoverChange: (hovered: boolean) => void; onNewSession: () => void; onOpenMultiRunLauncher: () => void; onClose: () => void; sentinelRef: (el: HTMLDivElement | null) => void; children?: React.ReactNode; } const SortableProjectItem: React.FC = ({ id, projectLabel, projectDescription, isCollapsed, isActiveProject, isRepo, isHovered, isDesktopRuntime, isStuck, hideDirectoryControls, mobileVariant, onToggle, onHoverChange, onNewSession, onOpenMultiRunLauncher, onClose, sentinelRef, children, }) => { const { attributes, listeners, setNodeRef, isDragging, } = useSortable({ id }); return (
{/* Sentinel for sticky detection */} {isDesktopRuntime && ( ); }; // Drag overlay component - shows only the header during drag interface ProjectDragOverlayProps { projectLabel: string; isActiveProject: boolean; isCollapsed: boolean; } const ProjectDragOverlay: React.FC = ({ projectLabel, isActiveProject, isCollapsed, }) => { return (
{projectLabel} {isCollapsed ? ( ) : ( )}
); }; interface SessionSidebarProps { mobileVariant?: boolean; onSessionSelected?: (sessionId: string) => void; allowReselect?: boolean; hideDirectoryControls?: boolean; showOnlyMainWorkspace?: boolean; } export const SessionSidebar: React.FC = ({ mobileVariant = false, onSessionSelected, allowReselect = false, hideDirectoryControls = false, showOnlyMainWorkspace = false, }) => { const [editingId, setEditingId] = React.useState(null); const [editTitle, setEditTitle] = React.useState(''); const [copiedSessionId, setCopiedSessionId] = React.useState(null); const copyTimeout = React.useRef(null); const [expandedParents, setExpandedParents] = React.useState>(new Set()); const [directoryStatus, setDirectoryStatus] = React.useState>( () => new Map(), ); const checkingDirectories = React.useRef>(new Set()); const safeStorage = React.useMemo(() => getSafeStorage(), []); const [collapsedProjects, setCollapsedProjects] = React.useState>(new Set()); const [projectRepoStatus, setProjectRepoStatus] = React.useState>(new Map()); const [expandedSessionGroups, setExpandedSessionGroups] = React.useState>(new Set()); const [hoveredProjectId, setHoveredProjectId] = React.useState(null); const [activeDragId, setActiveDragId] = React.useState(null); const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState>(new Set()); 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 setActiveProject = useProjectsStore((state) => state.setActiveProject); const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly); const reorderProjects = useProjectsStore((state) => state.reorderProjects); const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen); const openMultiRunLauncher = useUIStore((state) => state.openMultiRunLauncher); const sessions = useSessionStore((state) => state.sessions); const sessionsByDirectory = useSessionStore((state) => state.sessionsByDirectory); const currentSessionId = useSessionStore((state) => state.currentSessionId); const setCurrentSession = useSessionStore((state) => state.setCurrentSession); const updateSessionTitle = useSessionStore((state) => state.updateSessionTitle); const shareSession = useSessionStore((state) => state.shareSession); const unshareSession = useSessionStore((state) => state.unshareSession); const sessionMemoryState = useSessionStore((state) => state.sessionMemoryState); const sessionActivityPhase = useSessionStore((state) => state.sessionActivityPhase); const permissions = useSessionStore((state) => state.permissions); const worktreeMetadata = useSessionStore((state) => state.worktreeMetadata); const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject); const getSessionsByDirectory = useSessionStore((state) => state.getSessionsByDirectory); const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft); const [isDesktopRuntime, setIsDesktopRuntime] = React.useState(() => { if (typeof window === 'undefined') { return false; } return typeof window.opencodeDesktop !== 'undefined'; }); React.useEffect(() => { try { const storedParents = safeStorage.getItem(SESSION_EXPANDED_STORAGE_KEY); if (storedParents) { const parsed = JSON.parse(storedParents); if (Array.isArray(parsed)) { setExpandedParents(new Set(parsed.filter((item) => typeof item === 'string'))); } } const storedProjects = safeStorage.getItem(PROJECT_COLLAPSE_STORAGE_KEY); if (storedProjects) { const parsed = JSON.parse(storedProjects); if (Array.isArray(parsed)) { setCollapsedProjects(new Set(parsed.filter((item) => typeof item === 'string'))); } } } catch { /* ignored */ } }, [safeStorage]); React.useEffect(() => { if (typeof window === 'undefined') { return; } setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined'); }, []); const sortedSessions = React.useMemo(() => { return [...sessions].sort((a, b) => (b.time?.created || 0) - (a.time?.created || 0)); }, [sessions]); React.useEffect(() => { let cancelled = false; const normalizedProjects = projects .map((project) => ({ id: project.id, path: normalizePath(project.path) })) .filter((project): project is { id: string; path: string } => Boolean(project.path)); setProjectRepoStatus(new Map()); if (normalizedProjects.length === 0) { return () => { cancelled = true; }; } normalizedProjects.forEach((project) => { checkIsGitRepository(project.path) .then((result) => { if (!cancelled) { setProjectRepoStatus((prev) => { const next = new Map(prev); next.set(project.id, result); return next; }); } }) .catch(() => { if (!cancelled) { setProjectRepoStatus((prev) => { const next = new Map(prev); next.set(project.id, null); return next; }); } }); }); return () => { cancelled = true; }; }, [projects]); const parentMap = React.useMemo(() => { const map = new Map(); sortedSessions.forEach((session) => { const parentID = (session as Session & { parentID?: string | null }).parentID; if (parentID) { map.set(session.id, parentID); } }); return map; }, [sortedSessions]); 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) => (b.time?.created || 0) - (a.time?.created || 0))); return map; }, [sortedSessions]); React.useEffect(() => { if (!currentSessionId) { return; } setExpandedParents((previous) => { const next = new Set(previous); let cursor = parentMap.get(currentSessionId) || null; let changed = false; while (cursor) { if (!next.has(cursor)) { next.add(cursor); changed = true; } cursor = parentMap.get(cursor) || null; } return changed ? next : previous; }); }, [currentSessionId, parentMap]); React.useEffect(() => { const directories = new Set(); sortedSessions.forEach((session) => { const dir = normalizePath((session as Session & { directory?: string | null }).directory ?? null); if (dir) { directories.add(dir); } }); projects.forEach((project) => { const normalized = normalizePath(project.path); if (normalized) { directories.add(normalized); } }); directories.forEach((directory) => { const known = directoryStatus.get(directory); if ((known && known !== 'unknown') || checkingDirectories.current.has(directory)) { return; } checkingDirectories.current.add(directory); opencodeClient .listLocalDirectory(directory) .then(() => { setDirectoryStatus((prev) => { const next = new Map(prev); if (next.get(directory) === 'exists') { return prev; } next.set(directory, 'exists'); return next; }); }) .catch(() => { setDirectoryStatus((prev) => { const next = new Map(prev); if (next.get(directory) === 'missing') { return prev; } next.set(directory, 'missing'); return next; }); }) .finally(() => { checkingDirectories.current.delete(directory); }); }); }, [sortedSessions, projects, directoryStatus]); React.useEffect(() => { return () => { if (copyTimeout.current) { clearTimeout(copyTimeout.current); } }; }, []); const emptyState = (

No sessions yet

Create your first session to start coding.

); const handleSessionSelect = React.useCallback( (sessionId: string, sessionDirectory?: string | null, disabled?: boolean, projectId?: string | null) => { if (disabled) { return; } if (projectId && projectId !== activeProjectId) { // Important: avoid switching to the project root first (that can select the wrong session). setActiveProjectIdOnly(projectId); } if (sessionDirectory && sessionDirectory !== currentDirectory) { setDirectory(sessionDirectory, { showOverlay: false }); } if (mobileVariant) { setActiveMainTab('chat'); setSessionSwitcherOpen(false); } if (!allowReselect && sessionId === currentSessionId) { onSessionSelected?.(sessionId); return; } setCurrentSession(sessionId); onSessionSelected?.(sessionId); }, [ activeProjectId, allowReselect, currentDirectory, currentSessionId, mobileVariant, onSessionSelected, setActiveMainTab, setActiveProjectIdOnly, setCurrentSession, setDirectory, setSessionSwitcherOpen, ], ); const handleSaveEdit = React.useCallback(async () => { if (editingId && editTitle.trim()) { await updateSessionTitle(editingId, editTitle.trim()); setEditingId(null); setEditTitle(''); } }, [editingId, editTitle, updateSessionTitle]); const handleCancelEdit = React.useCallback(() => { setEditingId(null); setEditTitle(''); }, []); const handleShareSession = React.useCallback( async (session: Session) => { const result = await shareSession(session.id); if (result && result.share?.url) { toast.success('Session shared', { description: 'You can copy the link from the menu.', }); } else { toast.error('Unable to share session'); } }, [shareSession], ); const handleCopyShareUrl = React.useCallback((url: string, sessionId: string) => { navigator.clipboard .writeText(url) .then(() => { setCopiedSessionId(sessionId); if (copyTimeout.current) { clearTimeout(copyTimeout.current); } copyTimeout.current = window.setTimeout(() => { setCopiedSessionId(null); copyTimeout.current = null; }, 2000); }) .catch(() => { toast.error('Failed to copy URL'); }); }, []); const handleUnshareSession = React.useCallback( async (sessionId: string) => { const result = await unshareSession(sessionId); if (result) { toast.success('Session unshared'); } else { toast.error('Unable to unshare session'); } }, [unshareSession], ); const collectDescendants = React.useCallback( (sessionId: string): Session[] => { const collected: Session[] = []; const visit = (id: string) => { const children = childrenMap.get(id) ?? []; children.forEach((child) => { collected.push(child); visit(child.id); }); }; visit(sessionId); return collected; }, [childrenMap], ); const deleteSession = useSessionStore((state) => state.deleteSession); const deleteSessions = useSessionStore((state) => state.deleteSessions); const handleDeleteSession = React.useCallback( async (session: Session) => { const descendants = collectDescendants(session.id); // Check if this is a worktree session - if so, show confirmation dialog const worktree = worktreeMetadata.get(session.id); if (worktree) { // Find ALL sessions linked to this worktree (not just the triggered one) const worktreePath = worktree.path; const allWorktreeSessions: Session[] = []; const sessionIdSet = new Set(); // Iterate through all sessions and find those linked to the same worktree sessions.forEach((s) => { const meta = worktreeMetadata.get(s.id); if (meta && meta.path === worktreePath && !sessionIdSet.has(s.id)) { allWorktreeSessions.push(s); sessionIdSet.add(s.id); // Also collect descendants of each worktree session const sessionDescendants = collectDescendants(s.id); sessionDescendants.forEach((desc) => { if (!sessionIdSet.has(desc.id)) { allWorktreeSessions.push(desc); sessionIdSet.add(desc.id); } }); } }); sessionEvents.requestDelete({ sessions: allWorktreeSessions, mode: 'worktree', worktree, }); return; } if (descendants.length === 0) { const success = await deleteSession(session.id); if (success) { toast.success('Session deleted'); } else { toast.error('Failed to delete session'); } } else { const ids = [session.id, ...descendants.map((s) => s.id)]; const { deletedIds, failedIds } = await deleteSessions(ids); if (deletedIds.length > 0) { toast.success(`Deleted ${deletedIds.length} session${deletedIds.length === 1 ? '' : 's'}`); } if (failedIds.length > 0) { toast.error(`Failed to delete ${failedIds.length} session${failedIds.length === 1 ? '' : 's'}`); } } }, [collectDescendants, deleteSession, deleteSessions, worktreeMetadata, sessions], ); const handleOpenDirectoryDialog = React.useCallback(() => { if (isDesktopRuntime && window.opencodeDesktop?.requestDirectoryAccess) { window.opencodeDesktop .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'); }); } else { sessionEvents.requestDirectoryDialog(); } }, [addProject, isDesktopRuntime]); 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 buildNode = React.useCallback( (session: Session): SessionNode => { const children = childrenMap.get(session.id) ?? []; return { session, children: children.map((child) => buildNode(child)), worktree: worktreeMetadata.get(session.id) ?? null, }; }, [childrenMap, worktreeMetadata], ); const buildGroupedSessions = React.useCallback( (projectSessions: Session[], projectRoot: string | null, availableWorktrees: WorktreeMetadata[]) => { const normalizedProjectRoot = normalizePath(projectRoot ?? null); const sortedProjectSessions = [...projectSessions].sort((a, b) => (b.time?.created || 0) - (a.time?.created || 0)); const sessionMap = new Map(sortedProjectSessions.map((session) => [session.id, session])); const childrenMap = new Map(); sortedProjectSessions.forEach((session) => { const parentID = (session as Session & { parentID?: string | null }).parentID; if (!parentID) { return; } const collection = childrenMap.get(parentID) ?? []; collection.push(session); childrenMap.set(parentID, collection); }); childrenMap.forEach((list) => list.sort((a, b) => (b.time?.created || 0) - (a.time?.created || 0))); // Build worktree lookup map const worktreeByPath = new Map(); availableWorktrees.forEach((meta) => { if (meta.path) { const normalized = normalizePath(meta.path) ?? meta.path; worktreeByPath.set(normalized, meta); } }); // Helper to get worktree metadata for a session const getSessionWorktree = (session: Session): WorktreeMetadata | null => { const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null); const sessionWorktreeMeta = worktreeMetadata.get(session.id) ?? null; if (sessionWorktreeMeta) return sessionWorktreeMeta; if (sessionDirectory) { const worktree = worktreeByPath.get(sessionDirectory) ?? null; // Only count as worktree if it's not the main project root if (worktree && sessionDirectory !== normalizedProjectRoot) { return worktree; } } return null; }; const buildProjectNode = (session: Session): SessionNode => { const children = childrenMap.get(session.id) ?? []; return { session, children: children.map((child) => buildProjectNode(child)), worktree: getSessionWorktree(session), }; }; // Find root sessions (no parent or parent not in current project) const roots = sortedProjectSessions.filter((session) => { const parentID = (session as Session & { parentID?: string | null }).parentID; if (!parentID) { return true; } return !sessionMap.has(parentID); }); // Build all session nodes into a single flat group sorted by date const allSessions: SessionNode[] = roots.map((session) => buildProjectNode(session)); // Return single group with all sessions return [{ id: 'all', label: 'All sessions', description: normalizedProjectRoot ? formatPathForDisplay(normalizedProjectRoot, homeDirectory) : null, isMain: true, worktree: null, directory: normalizedProjectRoot, sessions: allSessions, }]; }, [homeDirectory, worktreeMetadata] ); 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 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 */ } return next; }); }, [safeStorage]); 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; }>; }, [projects]); const getSessionsForProject = React.useCallback( (project: { normalizedPath: string }) => { const worktreesForProject = availableWorktreesByProject.get(project.normalizedPath) ?? []; const directories = [ project.normalizedPath, ...worktreesForProject .map((meta) => normalizePath(meta.path) ?? meta.path) .filter((value): value is string => Boolean(value)), ]; const seen = new Set(); const collected: Session[] = []; directories.forEach((directory) => { const sessionsForDirectory = sessionsByDirectory.get(directory) ?? getSessionsByDirectory(directory); sessionsForDirectory.forEach((session) => { if (seen.has(session.id)) { return; } seen.add(session.id); collected.push(session); }); }); return collected; }, [availableWorktreesByProject, getSessionsByDirectory, sessionsByDirectory], ); const projectSections = React.useMemo(() => { return normalizedProjects.map((project) => { const projectSessions = getSessionsForProject(project); const worktreesForProject = availableWorktreesByProject.get(project.normalizedPath) ?? []; const groups = buildGroupedSessions(projectSessions, project.normalizedPath, worktreesForProject); return { project, groups, }; }); }, [normalizedProjects, getSessionsForProject, buildGroupedSessions, availableWorktreesByProject]); // Track when project sticky headers become "stuck" React.useEffect(() => { if (!isDesktopRuntime) return; const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { const projectId = (entry.target as HTMLElement).dataset.projectId; if (!projectId) return; setStuckProjectHeaders((prev) => { const next = new Set(prev); if (!entry.isIntersecting) { next.add(projectId); } else { next.delete(projectId); } return next; }); }); }, { threshold: 0 } ); projectHeaderSentinelRefs.current.forEach((el) => { if (el) observer.observe(el); }); return () => observer.disconnect(); }, [isDesktopRuntime, projectSections]); const renderSessionNode = React.useCallback( (node: SessionNode, depth = 0, groupDirectory?: string | null, projectId?: string | null): React.ReactNode => { const session = node.session; const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null) ?? normalizePath(groupDirectory ?? null); const directoryState = sessionDirectory ? directoryStatus.get(sessionDirectory) : null; const isMissingDirectory = directoryState === 'missing'; const memoryState = sessionMemoryState.get(session.id); const isActive = currentSessionId === session.id; const sessionTitle = session.title || 'Untitled Session'; const hasChildren = node.children.length > 0; const isExpanded = expandedParents.has(session.id); const additions = session.summary?.additions; const deletions = session.summary?.deletions; const hasSummary = typeof additions === 'number' || typeof deletions === 'number'; if (editingId === session.id) { return (
0 && 'pl-[20px]', )} >
{ event.preventDefault(); handleSaveEdit(); }} > setEditTitle(event.target.value)} className="flex-1 min-w-0 bg-transparent typography-ui-label outline-none placeholder:text-muted-foreground" autoFocus placeholder="Rename session" onKeyDown={(event) => { if (event.key === 'Escape') handleCancelEdit(); }} />
{hasChildren ? ( {isExpanded ? ( ) : ( )} ) : null} {formatDateLabel(session.time?.created || Date.now())} {session.share ? ( ) : null} {hasSummary && ((additions ?? 0) !== 0 || (deletions ?? 0) !== 0) ? ( +{Math.max(0, additions ?? 0)} / -{Math.max(0, deletions ?? 0)} ) : null} {hasChildren ? ( {node.children.length} {node.children.length === 1 ? 'task' : 'tasks'} ) : null}
); } const phase = sessionActivityPhase?.get(session.id) ?? 'idle'; const isStreaming = phase === 'busy' || phase === 'cooldown'; const pendingPermissionCount = permissions.get(session.id)?.length ?? 0; const streamingIndicator = (() => { if (!memoryState) return null; if (memoryState.isZombie) { return ; } return null; })(); return (
0 && 'pl-[20px]', )} >
{streamingIndicator} { setEditingId(session.id); setEditTitle(sessionTitle); }} className="[&>svg]:mr-1" > Rename {!session.share ? ( handleShareSession(session)} className="[&>svg]:mr-1"> Share ) : ( <> { if (session.share?.url) { handleCopyShareUrl(session.share.url, session.id); } }} className="[&>svg]:mr-1" > {copiedSessionId === session.id ? ( <> Copied ) : ( <> Copy link )} handleUnshareSession(session.id)} className="[&>svg]:mr-1"> Unshare )} {node.worktree ? ( { if (projectId && projectId !== activeProjectId) { setActiveProject(projectId); } setActiveMainTab('chat'); if (mobileVariant) { setSessionSwitcherOpen(false); } openNewSessionDraft({ directoryOverride: node.worktree?.path ?? null }); }} className="[&>svg]:mr-1" > New session in worktree ) : null} handleDeleteSession(session)} > Remove
{hasChildren && isExpanded ? node.children.map((child) => renderSessionNode(child, depth + 1, sessionDirectory ?? groupDirectory, projectId), ) : null}
); }, [ directoryStatus, sessionMemoryState, sessionActivityPhase, permissions, currentSessionId, expandedParents, editingId, editTitle, handleSaveEdit, handleCancelEdit, toggleParent, handleSessionSelect, handleShareSession, handleCopyShareUrl, handleUnshareSession, handleDeleteSession, copiedSessionId, mobileVariant, activeProjectId, setActiveProject, setActiveMainTab, setSessionSwitcherOpen, openNewSessionDraft, ], ); const renderGroupSessions = React.useCallback( (group: SessionGroup, groupKey: string, projectId?: string | null) => { const isExpanded = expandedSessionGroups.has(groupKey); const maxVisible = hideDirectoryControls ? 10 : 5; const totalSessions = group.sessions.length; const visibleSessions = isExpanded ? group.sessions : group.sessions.slice(0, maxVisible); const remainingCount = totalSessions - visibleSessions.length; return ( <> {visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId))} {totalSessions === 0 ? (
No sessions in this workspace yet.
) : null} {remainingCount > 0 && !isExpanded ? ( ) : null} {isExpanded && totalSessions > maxVisible ? ( ) : null} ); }, [expandedSessionGroups, hideDirectoryControls, renderSessionNode, toggleGroupSessionLimit] ); // DnD sensors for project reordering const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8, }, }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }) ); const handleDragStart = React.useCallback( (event: DragStartEvent) => { setActiveDragId(event.active.id as string); }, [] ); const handleDragEnd = React.useCallback( (event: DragEndEvent) => { const { active, over } = event; setActiveDragId(null); if (!over || active.id === over.id) { return; } const oldIndex = normalizedProjects.findIndex((p) => p.id === active.id); const newIndex = normalizedProjects.findIndex((p) => p.id === over.id); if (oldIndex !== -1 && newIndex !== -1) { reorderProjects(oldIndex, newIndex); } }, [normalizedProjects, reorderProjects] ); const handleDragCancel = React.useCallback(() => { setActiveDragId(null); }, []); // Get the active dragging project for the overlay const activeDragProject = React.useMemo(() => { if (!activeDragId) return null; const section = projectSections.find((s) => s.project.id === activeDragId); if (!section) return null; const project = section.project; return { id: project.id, label: formatProjectLabel( project.label?.trim() || formatDirectoryName(project.normalizedPath, homeDirectory) || project.normalizedPath ), isActive: project.id === activeProjectId, isCollapsed: collapsedProjects.has(project.id), }; }, [activeDragId, projectSections, homeDirectory, activeProjectId, collapsedProjects]); return (
{!hideDirectoryControls && (

Projects

{projects.length}
)} {projectSections.length === 0 ? ( emptyState ) : showOnlyMainWorkspace ? (
{(() => { const activeSection = projectSections.find((section) => section.project.id === activeProjectId) ?? projectSections[0]; if (!activeSection) { return emptyState; } // VS Code sessions view typically only shows one workspace, but sessions may live in worktrees or // canonicalized paths. Prefer the main group if it has sessions; otherwise fall back to any group // that contains sessions so we don't show an empty list when sessions exist. const group = activeSection.groups.find((candidate) => candidate.isMain && candidate.sessions.length > 0) ?? activeSection.groups.find((candidate) => candidate.sessions.length > 0) ?? activeSection.groups.find((candidate) => candidate.isMain) ?? activeSection.groups[0]; if (!group) { return (
No sessions yet.
); } const groupKey = `${activeSection.project.id}:${group.id}`; return renderGroupSessions(group, groupKey, activeSection.project.id); })()}
) : ( p.id)} strategy={verticalListSortingStrategy} > {projectSections.map((section) => { const project = section.project; const projectKey = project.id; const projectLabel = formatProjectLabel( project.label?.trim() || formatDirectoryName(project.normalizedPath, homeDirectory) || project.normalizedPath ); const projectDescription = formatPathForDisplay(project.normalizedPath, homeDirectory); const isCollapsed = collapsedProjects.has(projectKey); const isActiveProject = projectKey === activeProjectId; const isRepo = projectRepoStatus.get(projectKey); const isHovered = hoveredProjectId === projectKey; return ( toggleProject(projectKey)} onHoverChange={(hovered) => setHoveredProjectId(hovered ? projectKey : null)} onNewSession={() => { if (projectKey !== activeProjectId) { setActiveProject(projectKey); } setActiveMainTab('chat'); if (mobileVariant) { setSessionSwitcherOpen(false); } openNewSessionDraft({ directoryOverride: project.normalizedPath }); }} onOpenMultiRunLauncher={() => { if (projectKey !== activeProjectId) { setActiveProject(projectKey); } openMultiRunLauncher(); }} onClose={() => removeProject(projectKey)} sentinelRef={(el) => { projectHeaderSentinelRefs.current.set(projectKey, el); }} > {!isCollapsed ? (
{section.groups[0] ? renderGroupSessions(section.groups[0], `${projectKey}:all`, projectKey) : (
No sessions yet.
)}
) : null}
); })}
{activeDragProject ? ( ) : null}
)}
); };