import React from 'react'; import { cn } from '@/lib/utils'; import type { SessionNode } from '../types'; import { useI18n } from '@/lib/i18n'; import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore'; import { Icon } from "@/components/icon/Icon"; import { collectSubtreeContainingId, computeNodeStructureKey, resolveMenuOpenSessionId, } from '../sessions/sessionNodeItemUtils'; import type { SessionNodeRenderExtras } from '../sessions/sessionNodeItemUtils'; import { SessionTreeItem, type SessionTreeItemProps } from '../sessions/SessionTreeItem'; export type ActivityItem = { node: SessionNode; projectId: string | null; groupDirectory: string | null; secondaryMeta: { projectLabel?: string | null; branchLabel?: string | null; } | null; }; type ActivitySection = { key: 'active-now' | 'chats'; title: string; items: ActivityItem[]; }; type Props = { sections: ActivitySection[]; expansionState?: ReadonlySet; variant?: 'section' | 'flat'; initialVisibleCount?: number; batchSize?: number; isDesktopShellRuntime: boolean; pinnedSessionIds: Set; expandedParents: Set; hasSessionSearchQuery: boolean; normalizedSessionSearchQuery: string; notifyOnSubtasks: boolean; editingId: string | null; editTitle: string; copiedSessionId: string | null; openSidebarMenuKey: string | null; mobileVariant: boolean; alwaysShowActions: boolean; onNewChat?: () => void; renderChatsSection?: (items: ActivityItem[]) => React.ReactNode; } & Pick; type RenderExtras = SessionNodeRenderExtras; const MAX_VISIBLE_RECENT_SESSIONS = 7; export function SidebarActivitySections(props: Props): React.ReactNode { const { sections, variant = 'section', initialVisibleCount = MAX_VISIBLE_RECENT_SESSIONS, batchSize = MAX_VISIBLE_RECENT_SESSIONS, } = props; const { t } = useI18n(); const { pinnedSessionIds } = props; const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders); const [collapsed, setCollapsed] = React.useState>(new Set()); const [visibleCountBySection, setVisibleCountBySection] = React.useState>(new Map()); const flatVariant = variant === 'flat'; const resetSectionLimit = React.useCallback((key: string) => { setVisibleCountBySection((prev) => { if (!prev.has(key)) { return prev; } const next = new Map(prev); next.delete(key); return next; }); }, []); const toggleSection = React.useCallback((key: string) => { // Collapsing/expanding resets any "show more" batches, matching the // worktree/project group behavior. resetSectionLimit(key); setCollapsed((prev) => { const next = new Set(prev); if (next.has(key)) { next.delete(key); } else { next.add(key); } return next; }); }, [resetSectionLimit]); const showMoreSessions = React.useCallback((key: string, currentVisibleCount: number, totalCount: number) => { setVisibleCountBySection((prev) => { const nextVisibleCount = Math.min(totalCount, currentVisibleCount + batchSize); const next = new Map(prev); next.set(key, nextVisibleCount); return next; }); }, [batchSize]); const buildRenderExtras = React.useCallback((nodes: SessionNode[]) => { const subtreeContainsEditing = new Set(); collectSubtreeContainingId(nodes, props.editingId, subtreeContainsEditing); const menuOpenSessionId = resolveMenuOpenSessionId(nodes, props.openSidebarMenuKey, 'recent', false); const nodeStructureKeyByNode = new WeakMap(); const visit = (node: SessionNode): void => { nodeStructureKeyByNode.set(node, computeNodeStructureKey(node)); node.children.forEach(visit); }; nodes.forEach(visit); const childRenderExtrasFor = (child: SessionNode): RenderExtras => ({ subtreeContainsEditing, menuOpenSessionId, nodeStructureKey: nodeStructureKeyByNode.get(child) ?? '', childRenderExtrasFor, }); return (node: SessionNode): RenderExtras => ({ subtreeContainsEditing, menuOpenSessionId, nodeStructureKey: nodeStructureKeyByNode.get(node) ?? '', childRenderExtrasFor, }); }, [props.editingId, props.openSidebarMenuKey]); const visibleSections = sections.filter((section) => section.items.length > 0 || section.key === 'chats'); if (visibleSections.length === 0) { return null; } return ( // No top padding: the recent header must start flush with the scroll // edge, otherwise it visually "bumps" a few pixels before sticking.
{visibleSections.map((section) => { const isCollapsed = collapsed.has(section.key); const visibleLimit = Math.max( initialVisibleCount, visibleCountBySection.get(section.key) ?? initialVisibleCount, ); const visibleItems = section.items.slice(0, visibleLimit); const remainingCount = section.items.length - visibleItems.length; const usesCustomRenderer = section.key === 'chats' && Boolean(props.renderChatsSection); const canShowFewer = !usesCustomRenderer && !flatVariant && section.items.length > initialVisibleCount && remainingCount === 0; const getRenderExtras = buildRenderExtras(visibleItems.map((item) => item.node)); const renderItem = (item: ActivityItem) => ( ); if (flatVariant) { return (
{visibleItems.map(renderItem)} {remainingCount > 0 ? ( ) : null}
); } return (
{section.key === 'chats' && props.onNewChat ? ( ) : null}
{!isCollapsed ? (
{usesCustomRenderer ? props.renderChatsSection?.(section.items) : visibleItems.map(renderItem)} {!usesCustomRenderer && remainingCount > 0 ? ( ) : null} {canShowFewer ? ( ) : null}
) : null}
); })}
); }