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 } from '@/lib/utils'; import type { SessionGroup } from '../types'; import { ProjectHeaderIdentity, SortableGroupItem, SortableProjectItem } from './sortableItems'; import { SessionGroupSection, type SessionGroupSectionProps } from './SessionGroupSection'; import { buildGroupRenderDescriptors, resolveSearchResultPlacement, selectRenderedProjectSections, type ProjectSection } from './sessionProjectRender'; import { formatProjectLabel } from '../utils'; import { useI18n } from '@/lib/i18n'; import type { ProjectSortOrder } from '@/stores/useSessionDisplayStore'; import { streamPerfCount } from '@/stores/utils/streamDebug'; import { Icon } from '@/components/icon/Icon'; import { DirectoryActionIndicator } from '../sessions/DirectoryActionIndicator'; type SessionProjectScrollerState = Pick & { visibleSessionCountByGroup: Map; }; type SessionProjectScrollerGroupProps = Pick & { pinnedSessionIds: Set; sessionOrderIndex: Map; }; type SessionProjectScrollerGroupActions = Pick; type SessionProjectScrollerModel = { topContent?: React.ReactNode; /** * Whether the top content itself holds search results. The managed chats * render only there, so without this the "no project section matched" branch * below would drop a matching chat and claim there is nothing to show. */ topContentHasSearchMatches?: boolean; hasSharedSessions?: boolean; sectionsForRender: ProjectSection[]; projectSections: ProjectSection[]; activeProjectId: string | null; singleProjectMode: boolean; singleProjectId: string | null; emptyState: React.ReactNode; searchEmptyState: React.ReactNode; projectRepoStatus: Map; stuckProjectHeaders: Set; projectHeaderSentinelRefs: React.MutableRefObject>; state: SessionProjectScrollerState; groupProps: SessionProjectScrollerGroupProps; }; type SessionProjectScrollerView = { homeDirectory: string | null; collapsedProjects: Set; showOnlyMainWorkspace: boolean; hasSessionSearchQuery: boolean; normalizedSessionSearchQuery: string; hideDirectoryControls: boolean; isDesktopShellRuntime: boolean; stickyZoneHeaders: boolean; mobileVariant: boolean; alwaysShowActions: boolean; projectSortOrder: ProjectSortOrder; }; type SessionProjectScrollerActions = { group: SessionProjectScrollerGroupActions; toggleProject: (id: string) => void; setActiveProjectIdOnly: (id: string) => 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; reorderProjects: (fromIndex: number, toIndex: number) => void; setGroupOrderByProject: React.Dispatch>>; renderProjectStatusIndicator?: (projectId: string, groups: SessionGroup[]) => React.ReactNode; setSingleProjectId: (id: string) => void; }; type Props = { model: SessionProjectScrollerModel; view: SessionProjectScrollerView; actions: SessionProjectScrollerActions; }; 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, ) ); function SessionProjectScrollerComponent(props: Props): React.ReactNode { streamPerfCount('ui.sidebar_projects_list.render'); const { t } = useI18n(); const { model, view, actions } = props; const isInlineEditing = model.state.editingId !== null; const enableStickyFade = view.isDesktopShellRuntime && view.stickyZoneHeaders && !model.singleProjectMode; const projectSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); const groupSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), ); // 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 viewport-owned fade 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; const fadeRoot = scroller.closest('.oc-sticky-fade-root'); fadeRoot?.style.setProperty('--scroll-shadow-top-size', `${topFadeSize}px`); fadeRoot?.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, ) => { // SAFETY: React's mouse and pointer events are dispatched from Elements. 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 renderedSections = selectRenderedProjectSections( model.sectionsForRender, model.singleProjectMode, model.singleProjectId, ); const hasProjectScroller = model.projectSections.length > 0 && renderedSections.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 model.projectSections) { if (model.stuckProjectHeaders.has(section.project.id)) { stuckProject = section.project; } } // The IntersectionObserver reports the stuck header asynchronously, a frame or // two after the synchronous fade 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 ?? (model.hasSharedSessions ? null : renderedSections[0]?.project ?? null); const leadingProjectLabel = leadingProject ? getProjectLabel(leadingProject, view.homeDirectory) : null; const projectPickerOptions = React.useMemo(() => model.projectSections.map((section) => ({ id: section.project.id, projectLabel: getProjectLabel(section.project, view.homeDirectory), projectDescription: formatPathForDisplay(section.project.normalizedPath, view.homeDirectory), projectIcon: section.project.icon, projectColor: section.project.color, projectIconImage: section.project.iconImage, projectIconBackground: section.project.iconBackground, })), [model.projectSections, view.homeDirectory]); if (model.projectSections.length === 0) { return {model.topContent}{model.emptyState}; } if (model.sectionsForRender.length === 0) { const placement = resolveSearchResultPlacement(model.topContentHasSearchMatches === true); return {placement === 'top-content' ? model.topContent : model.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} > {model.topContent} {view.showOnlyMainWorkspace ? (
{(() => { const activeSection = renderedSections.find((section) => section.project.id === model.activeProjectId) ?? renderedSections[0]; if (!activeSection) { return view.hasSessionSearchQuery ? model.searchEmptyState : model.emptyState; } const descriptors = buildGroupRenderDescriptors(activeSection, { mainWorkspaceOnly: true }); if (!descriptors.length) { return
{t('sessions.sidebar.empty.noSessions.title')}
; } return descriptors.map(({ group, groupKey, projectId, hideGroupLabel }) => { return ( ); }); })()}
) : ( { if (isInlineEditing) return; // Drag only allowed in manual sort mode - indices from visual order don't match store order in other modes if (view.projectSortOrder !== 'manual') return; const { active, over } = event; if (!over || active.id === over.id) return; const oldIndex = model.sectionsForRender.findIndex((section) => section.project.id === active.id); const newIndex = model.sectionsForRender.findIndex((section) => section.project.id === over.id); if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return; actions.reorderProjects(oldIndex, newIndex); }} > section.project.id)} strategy={verticalListSortingStrategy}> {renderedSections.map((section) => { const project = section.project; const projectKey = project.id; const projectLabel = getProjectLabel(project, view.homeDirectory); const projectDescription = formatPathForDisplay(project.normalizedPath, view.homeDirectory); const isCollapsed = model.singleProjectMode ? false : view.collapsedProjects.has(projectKey); const isRepo = model.projectRepoStatus.get(projectKey); return ( { if (!model.singleProjectMode) actions.toggleProject(projectKey); }} onNewSession={() => { if (projectKey !== model.activeProjectId) actions.setActiveProjectIdOnly(projectKey); if (view.mobileVariant) actions.setSessionSwitcherOpen(false); actions.openNewSessionDraft({ selectedProjectId: projectKey, directoryOverride: project.normalizedPath, }); }} onNewWorktreeSession={() => { if (projectKey !== model.activeProjectId) actions.setActiveProjectIdOnly(projectKey); actions.openNewWorktreeDialog(); }} onManageWorktrees={() => actions.openWorktreesPage(projectKey)} onRenameStart={() => actions.openProjectEditDialog(projectKey)} onClose={() => actions.removeProject(projectKey)} sentinelRef={(el) => { model.projectHeaderSentinelRefs.current.set(projectKey, el); }} showCreateButtons > {!isCollapsed ? (
{(() => { const orderedGroups = section.groups; const rootGroup = orderedGroups.find((group) => group.isMain) ?? null; const nestedGroups = rootGroup ? orderedGroups.filter((group) => group.id !== rootGroup.id) : orderedGroups; return ( { if (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; actions.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 ? : null} group.id)} strategy={verticalListSortingStrategy}> {nestedGroups.map((group) => { const groupKey = `${projectKey}:${group.id}`; return ( {(dragHandleProps) => } ); })} ); })()}
) : null}
); })}
)}
{enableStickyFade && (leadingProject || model.hasSharedSessions) ? ( ) : null}
); } export const SessionProjectScroller = React.memo(SessionProjectScrollerComponent);