import React from 'react'; import { DndContext, DragOverlay, KeyboardSensor, PointerSensor, closestCenter, useSensor, useSensors, } from '@dnd-kit/core'; import { SortableContext, arrayMove, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { formatDirectoryName, formatPathForDisplay, cn } from '@/lib/utils'; import type { SessionGroup } from './types'; import type { SortableDragHandleProps } from './sortableItems'; import { ProjectHeaderIdentity, SortableGroupItem, SortableProjectItem } from './sortableItems'; import { formatProjectLabel } from './utils'; import { useI18n } from '@/lib/i18n'; import type { MainTab } from '@/stores/useUIStore'; import type { ProjectSortOrder } from '@/stores/useSessionDisplayStore'; import { streamPerfCount } from '@/stores/utils/streamDebug'; import { Icon } from '@/components/icon/Icon'; type ProjectSection = { project: { id: string; label?: string; normalizedPath: string; icon?: string; color?: string; iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' }; iconBackground?: string; }; groups: SessionGroup[]; }; const TOP_FADE_MAX_SIZE = 48; const TOP_FADE_MIN_SIZE = 32; const TOP_FADE_CLEAR_MAX_SIZE = 24; const getProjectLabel = (project: ProjectSection['project'], homeDirectory: string | null): string => ( formatProjectLabel( project.label?.trim() || formatDirectoryName(project.normalizedPath, homeDirectory) || project.normalizedPath, ) ); type Props = { topContent?: React.ReactNode; sharedSessionsOnly?: boolean; hasSharedSessions?: boolean; sectionsForRender: ProjectSection[]; projectSections: ProjectSection[]; activeProjectId: string | null; showOnlyMainWorkspace: boolean; hasSessionSearchQuery: boolean; emptyState: React.ReactNode; searchEmptyState: React.ReactNode; renderGroupSessions: ( group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean, dragHandleProps?: SortableDragHandleProps | null, compactBodyPadding?: boolean, scrollContainerRef?: React.RefObject, ) => React.ReactNode; getOrderedGroups: (projectId: string, groups: SessionGroup[]) => SessionGroup[]; setGroupOrderByProject: React.Dispatch>>; renderProjectStatusIndicator?: (projectId: string, groups: SessionGroup[]) => React.ReactNode; homeDirectory: string | null; collapsedProjects: Set; hideDirectoryControls: boolean; projectRepoStatus: Map; isDesktopShellRuntime: boolean; stickyZoneHeaders: boolean; stuckProjectHeaders: Set; mobileVariant: boolean; alwaysShowActions: boolean; toggleProject: (id: string) => void; setActiveProjectIdOnly: (id: string) => void; setActiveMainTab: (tab: MainTab) => void; setSessionSwitcherOpen: (open: boolean) => void; openNewSessionDraft: (options?: { selectedProjectId?: string | null; directoryOverride?: string | null }) => void; openNewWorktreeDialog: () => void; openWorktreesPage: (id: string) => void; openProjectEditDialog: (id: string) => void; removeProject: (id: string) => void; projectHeaderSentinelRefs: React.MutableRefObject>; reorderProjects: (fromIndex: number, toIndex: number) => void; projectSortOrder: ProjectSortOrder; openSidebarMenuKey: string | null; setOpenSidebarMenuKey: (key: string | null) => void; isInlineEditing: boolean; }; function SidebarProjectsListComponent(props: Props): React.ReactNode { streamPerfCount('ui.sidebar_projects_list.render'); const { t } = useI18n(); const enableStickyFade = props.isDesktopShellRuntime && props.stickyZoneHeaders; const projectSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); const groupSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), ); // Memoize getOrderedGroups per project so downstream consumers see a stable // array reference while inputs are unchanged (avoids O(P) fresh arrays per // list render invalidating the memoized group subtrees). const orderedGroupsCacheRef = React.useRef>(new Map()); const orderedGroupsCacheGetOrderedGroupsRef = React.useRef(props.getOrderedGroups); if (orderedGroupsCacheGetOrderedGroupsRef.current !== props.getOrderedGroups) { orderedGroupsCacheGetOrderedGroupsRef.current = props.getOrderedGroups; orderedGroupsCacheRef.current.clear(); } const cachedGetOrderedGroups = (projectId: string, groups: SessionGroup[]): SessionGroup[] => { const cache = orderedGroupsCacheRef.current; const hit = cache.get(projectId); if (hit && hit.groups === groups) { return hit.ordered; } const ordered = props.getOrderedGroups(projectId, groups); cache.set(projectId, { groups, ordered }); if (cache.size > 256) { const firstKey = cache.keys().next().value; if (firstKey !== undefined) cache.delete(firstKey); } return ordered; }; // Threaded into SessionGroupSection so the archived-bucket virtualizer // can resolve the scrolling ancestor synchronously (no getComputedStyle // walk) and skip the cost of a style recalc on every render. const scrollContainerRef = React.useRef(null); // Keep per-scroll measurements out of React state so the interaction guard // can read the current fade boundary without rerendering the sidebar. const topFadeSizeRef = React.useRef(0); // Update the compositor-owned mask on every scroll, but cross the React // render boundary only when the sticky identity overlay appears or hides. const syncTopFade = React.useCallback((scroller: HTMLElement) => { const hasTopScroll = scroller.scrollTop > 1; const topFadeSize = hasTopScroll ? Math.min(TOP_FADE_MIN_SIZE + scroller.scrollTop, TOP_FADE_MAX_SIZE) : 0; topFadeSizeRef.current = topFadeSize; scroller.style.setProperty('--scroll-shadow-top-size', `${topFadeSize}px`); scroller.style.setProperty( '--scroll-shadow-top-clear-size', `${Math.min(Math.max(topFadeSize - 8, 0), TOP_FADE_CLEAR_MAX_SIZE)}px`, ); }, []); const blockObscuredInteraction = React.useCallback(( event: React.MouseEvent | React.PointerEvent, ) => { if ((event.target as Element).closest('[data-overlay-scrollbar-thumb], [data-sidebar-sticky-header]')) return; const eventY = event.clientY - event.currentTarget.getBoundingClientRect().top; if (eventY >= topFadeSizeRef.current) return; event.preventDefault(); event.stopPropagation(); }, []); const hasProjectScroller = props.projectSections.length > 0 && props.sectionsForRender.length > 0; React.useLayoutEffect(() => { if (enableStickyFade && hasProjectScroller && scrollContainerRef.current) { syncTopFade(scrollContainerRef.current); } }, [enableStickyFade, hasProjectScroller, syncTopFade]); let stuckProject: ProjectSection['project'] | null = null; for (const section of props.projectSections) { if (props.stuckProjectHeaders.has(section.project.id)) { stuckProject = section.project; } } // The IntersectionObserver reports the stuck header asynchronously, a frame or // two after the (synchronous) mask has already hidden the real header — which // otherwise leaves a one-frame gap where the title blinks out with no crisp // replacement. Seed the overlay with the topmost rendered project so it is // ready in the same frame; the observer then corrects it. When shared sessions // lead the list, the Recent fallback below owns the top instead of a project. const leadingProject = stuckProject ?? (props.hasSharedSessions ? null : props.sectionsForRender[0]?.project ?? null); const leadingProjectLabel = leadingProject ? getProjectLabel(leadingProject, props.homeDirectory) : null; if (props.sharedSessionsOnly) { return ( {props.topContent} {!props.hasSharedSessions ? (props.hasSessionSearchQuery ? props.searchEmptyState : props.emptyState) : null} ); } if (props.projectSections.length === 0) { return {props.topContent}{props.emptyState}; } if (props.sectionsForRender.length === 0) { return {props.searchEmptyState}; } return ( // [overflow-anchor:none] — the browser's native scroll anchoring otherwise // latches onto content BELOW a growing session group (e.g. the "Show more" // button) and holds it in place, which makes newly revealed sessions look // like they insert upward. With anchoring off, scrollTop stays put and new // rows appear below naturally.
syncTopFade(event.currentTarget) : undefined} > {props.topContent} {props.showOnlyMainWorkspace ? (
{(() => { const activeSection = props.sectionsForRender.find((section) => section.project.id === props.activeProjectId) ?? props.sectionsForRender[0]; if (!activeSection) { return props.hasSessionSearchQuery ? props.searchEmptyState : props.emptyState; } const primaryGroup = 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 (!primaryGroup) { return
{t('sessions.sidebar.empty.noSessions.title')}
; } const archivedGroup = activeSection.groups.find((candidate) => candidate.isArchivedBucket); const groupsToRender = [ primaryGroup, ...(archivedGroup && archivedGroup.id !== primaryGroup.id ? [archivedGroup] : []), ]; return groupsToRender.map((group) => { const groupKey = `${activeSection.project.id}:${group.id}`; const hideGroupLabel = group.id === primaryGroup.id; return ( {props.renderGroupSessions(group, groupKey, activeSection.project.id, hideGroupLabel, null, true, scrollContainerRef)} ); }); })()}
) : ( { if (props.isInlineEditing) return; // Drag only allowed in manual sort mode - indices from visual order don't match store order in other modes if (props.projectSortOrder !== 'manual') return; const { active, over } = event; if (!over || active.id === over.id) return; const oldIndex = props.sectionsForRender.findIndex((section) => section.project.id === active.id); const newIndex = props.sectionsForRender.findIndex((section) => section.project.id === over.id); if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return; props.reorderProjects(oldIndex, newIndex); }} > section.project.id)} strategy={verticalListSortingStrategy}> {props.sectionsForRender.map((section) => { const project = section.project; const projectKey = project.id; const projectLabel = getProjectLabel(project, props.homeDirectory); const projectDescription = formatPathForDisplay(project.normalizedPath, props.homeDirectory); const isCollapsed = props.collapsedProjects.has(projectKey); const isRepo = props.projectRepoStatus.get(projectKey); return ( props.toggleProject(projectKey)} onNewSession={() => { if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey); props.setActiveMainTab('chat'); if (props.mobileVariant) props.setSessionSwitcherOpen(false); props.openNewSessionDraft({ selectedProjectId: projectKey, directoryOverride: project.normalizedPath, }); }} onNewWorktreeSession={() => { if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey); props.setActiveMainTab('chat'); props.openNewWorktreeDialog(); }} onManageWorktrees={() => props.openWorktreesPage(projectKey)} onRenameStart={() => props.openProjectEditDialog(projectKey)} onClose={() => props.removeProject(projectKey)} sentinelRef={(el) => { props.projectHeaderSentinelRefs.current.set(projectKey, el); }} showCreateButtons openSidebarMenuKey={props.openSidebarMenuKey} setOpenSidebarMenuKey={props.setOpenSidebarMenuKey} > {!isCollapsed ? (
{(() => { const orderedGroups = cachedGetOrderedGroups(projectKey, section.groups); const rootGroup = orderedGroups.find((group) => group.isMain) ?? null; const nestedGroups = rootGroup ? orderedGroups.filter((group) => group.id !== rootGroup.id) : orderedGroups; return ( { if (props.isInlineEditing) return; const { active, over } = event; if (!over || active.id === over.id) return; const oldIndex = nestedGroups.findIndex((item) => item.id === active.id); const newIndex = nestedGroups.findIndex((item) => item.id === over.id); if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return; const nextNested = arrayMove(nestedGroups, oldIndex, newIndex).map((item) => item.id); const next = rootGroup ? [rootGroup.id, ...nextNested] : nextNested; props.setGroupOrderByProject((prev) => { const map = new Map(prev); map.set(projectKey, next); return map; }); }} > {/* Root/flat sessions render directly under the project zone header; worktree and archived groups keep their own slim sortable sub-header. */} {rootGroup ? props.renderGroupSessions(rootGroup, `${projectKey}:${rootGroup.id}`, projectKey, true, null, undefined, scrollContainerRef) : null} group.id)} strategy={verticalListSortingStrategy}> {nestedGroups.map((group) => { const groupKey = `${projectKey}:${group.id}`; return ( {(dragHandleProps) => props.renderGroupSessions(group, groupKey, projectKey, false, dragHandleProps, undefined, scrollContainerRef)} ); })} ); })()}
) : null}
); })}
)}
{enableStickyFade && (leadingProject || props.hasSharedSessions) ? ( ) : null}
); } export const SidebarProjectsList = React.memo(SidebarProjectsListComponent);