import React from 'react'; import type { Session } from '@opencode-ai/sdk/v2'; import { RiAddLine, RiArchiveLine, RiArrowDownSLine, RiArrowLeftLongLine, RiArrowRightSLine, RiDeleteBinLine, RiGitBranchLine, } from '@remixicon/react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; import { sessionEvents } from '@/lib/sessionEvents'; import type { MainTab } from '@/stores/useUIStore'; import { SessionFolderItem } from '../SessionFolderItem'; import { DroppableFolderWrapper, SessionFolderDndScope } from './sessionFolderDnd'; import type { SortableDragHandleProps } from './sortableItems'; import type { GroupSearchData, SessionGroup, SessionNode } from './types'; import { compareSessionsByPinnedAndTime, isBranchDifferentFromLabel, normalizePath, renderHighlightedText } from './utils'; import type { SessionFolder } from '@/stores/useSessionFoldersStore'; import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore'; import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore'; import { openExternalUrl } from '@/lib/url'; import { useI18n } from '@/lib/i18n'; type DeleteFolderConfirm = { scopeKey: string; folderId: string; folderName: string; subFolderCount: number; sessionCount: number; } | null; type Props = { group: SessionGroup; groupKey: string; projectId?: string | null; hideGroupLabel?: boolean; hasSessionSearchQuery: boolean; normalizedSessionSearchQuery: string; groupSearchDataByGroup: WeakMap; expandedSessionGroups: Set; collapsedGroups: Set; hideDirectoryControls: boolean; collapsedFolderIds: Set; toggleFolderCollapse: (folderId: string) => void; renameFolder: (scopeKey: string, folderId: string, name: string) => void; deleteFolder: (scopeKey: string, folderId: string) => void; showDeletionDialog: boolean; setDeleteFolderConfirm: React.Dispatch>; renderSessionNode: (node: SessionNode, depth?: number, groupDirectory?: string | null, projectId?: string | null, archivedBucket?: boolean, secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null) => React.ReactNode; currentSessionDirectory: string | null; projectRepoStatus: Map; lastRepoStatus: boolean; toggleGroupSessionLimit: (groupKey: string) => void; mobileVariant: boolean; alwaysShowActions: boolean; activeProjectId: string | null; setActiveProjectIdOnly: (id: string) => void; setActiveMainTab: (tab: MainTab) => void; setSessionSwitcherOpen: (open: boolean) => void; openNewSessionDraft: (options?: { directoryOverride?: string | null; targetFolderId?: string }) => void; addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void; createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null; renamingFolderId: string | null; renameFolderDraft: string; setRenameFolderDraft: React.Dispatch>; setRenamingFolderId: React.Dispatch>; pinnedSessionIds: Set; sessionOrderIndex: Map; prVisualStateByDirectoryBranch: Map; onToggleCollapsedGroup: (groupKey: string) => void; dragHandleProps?: SortableDragHandleProps | null; compactBodyPadding?: boolean; }; export function SessionGroupSection(props: Props): React.ReactNode { const { t } = useI18n(); const { group, groupKey, projectId, hideGroupLabel, hasSessionSearchQuery, normalizedSessionSearchQuery, groupSearchDataByGroup, expandedSessionGroups, collapsedGroups, hideDirectoryControls, collapsedFolderIds, toggleFolderCollapse, renameFolder, deleteFolder, showDeletionDialog, setDeleteFolderConfirm, renderSessionNode, projectRepoStatus, lastRepoStatus, toggleGroupSessionLimit, mobileVariant, alwaysShowActions, activeProjectId, setActiveProjectIdOnly, setActiveMainTab, setSessionSwitcherOpen, openNewSessionDraft, addSessionToFolder, createFolderAndStartRename, renamingFolderId, renameFolderDraft, setRenameFolderDraft, setRenamingFolderId, pinnedSessionIds, sessionOrderIndex, prVisualStateByDirectoryBranch, onToggleCollapsedGroup, dragHandleProps, compactBodyPadding = false, } = props; const compareSessionNodes = React.useCallback((a: SessionNode, b: SessionNode) => { const aIndex = sessionOrderIndex.get(a.session.id); const bIndex = sessionOrderIndex.get(b.session.id); if (aIndex !== undefined || bIndex !== undefined) { if (aIndex === undefined) return 1; if (bIndex === undefined) return -1; if (aIndex !== bIndex) return aIndex - bIndex; } return compareSessionsByPinnedAndTime(a.session, b.session, pinnedSessionIds); }, [pinnedSessionIds, sessionOrderIndex]); const searchData = hasSessionSearchQuery ? groupSearchDataByGroup.get(group) : null; const displayMode = useSessionDisplayStore((state) => state.displayMode); const foldersMap = useSessionFoldersStore((state) => state.foldersMap); const isMinimalMode = displayMode === 'minimal'; const isExpanded = expandedSessionGroups.has(groupKey); const isCollapsed = hasSessionSearchQuery ? false : collapsedGroups.has(groupKey); const maxVisible = hideDirectoryControls ? 10 : 5; const groupMatchesSearch = hasSessionSearchQuery ? searchData?.groupMatches === true : false; const shouldFilterGroupContents = hasSessionSearchQuery; const sourceGroupNodes = React.useMemo( () => [...(shouldFilterGroupContents ? (searchData?.filteredNodes ?? []) : group.sessions)] .sort(compareSessionNodes), [compareSessionNodes, group.sessions, searchData?.filteredNodes, shouldFilterGroupContents], ); const folderScopeKey = group.folderScopeKey ?? normalizePath(group.directory ?? null); const scopeFolders = React.useMemo( () => folderScopeKey ? (foldersMap[folderScopeKey] ?? []) : [], [folderScopeKey, foldersMap] ); const nodeBySessionId = React.useMemo(() => { const map = new Map(); const collectNodeLookup = (nodes: SessionNode[]) => { nodes.forEach((node) => { map.set(node.session.id, node); if (node.children.length > 0) { collectNodeLookup(node.children); } }); }; collectNodeLookup(sourceGroupNodes); return map; }, [sourceGroupNodes]); const allFoldersForGroupBase = React.useMemo(() => scopeFolders.map((folder) => { const nodes = folder.sessionIds .map((sid) => nodeBySessionId.get(sid)) .filter((n): n is SessionNode => Boolean(n)) .sort(compareSessionNodes); return { folder, nodes }; }), [scopeFolders, nodeBySessionId, compareSessionNodes]); const allFoldersForGroup = React.useMemo(() => { const folderMapById = new Map(allFoldersForGroupBase.map((entry) => [entry.folder.id, entry])); const childFolderIdsByParentId = new Map(); for (const { folder } of allFoldersForGroupBase) { if (!folder.parentId) continue; const existing = childFolderIdsByParentId.get(folder.parentId); if (existing) { existing.push(folder.id); } else { childFolderIdsByParentId.set(folder.parentId, [folder.id]); } } const keepByFolderId = new Map(); const shouldKeepFolder = (folderId: string): boolean => { const cached = keepByFolderId.get(folderId); if (cached !== undefined) return cached; const entry = folderMapById.get(folderId); if (!entry) { keepByFolderId.set(folderId, false); return false; } const childFolderIds = childFolderIdsByParentId.get(folderId) ?? []; // For archived buckets, hide folders with no sessions unless descendants have content. if (group.isArchivedBucket && entry.nodes.length === 0) { const hasContentInChildren = childFolderIds.some((childId) => shouldKeepFolder(childId)); keepByFolderId.set(folderId, hasContentInChildren); return hasContentInChildren; } if (!hasSessionSearchQuery) { keepByFolderId.set(folderId, true); return true; } const folderMatches = entry.folder.name.toLowerCase().includes(normalizedSessionSearchQuery); if (folderMatches || entry.nodes.length > 0) { keepByFolderId.set(folderId, true); return true; } const hasMatchingChildren = childFolderIds.some((childId) => shouldKeepFolder(childId)); keepByFolderId.set(folderId, hasMatchingChildren); return hasMatchingChildren; }; return allFoldersForGroupBase.filter(({ folder }) => shouldKeepFolder(folder.id)); }, [allFoldersForGroupBase, group.isArchivedBucket, hasSessionSearchQuery, normalizedSessionSearchQuery]); const sessionIdsInFolders = React.useMemo(() => new Set(allFoldersForGroup.flatMap((f) => f.folder.sessionIds)), [allFoldersForGroup]); const ungroupedSessions = React.useMemo(() => sourceGroupNodes.filter((node) => !sessionIdsInFolders.has(node.session.id)), [sourceGroupNodes, sessionIdsInFolders]); const rootFolders = React.useMemo(() => allFoldersForGroup.filter(({ folder }) => !folder.parentId), [allFoldersForGroup]); if (hasSessionSearchQuery && !groupMatchesSearch && rootFolders.length === 0 && ungroupedSessions.length === 0) { return null; } const totalSessions = ungroupedSessions.length; const visibleSessions = group.isArchivedBucket ? ungroupedSessions : hasSessionSearchQuery ? ungroupedSessions : (isExpanded ? ungroupedSessions : ungroupedSessions.slice(0, maxVisible)); const remainingCount = totalSessions - visibleSessions.length; const collectGroupSessions = (nodes: SessionNode[]): Session[] => { const collected: Session[] = []; const visit = (list: SessionNode[]) => { list.forEach((node) => { collected.push(node.session); if (node.children.length > 0) visit(node.children); }); }; visit(nodes); return collected; }; const allGroupSessions = collectGroupSessions(sourceGroupNodes); const isGitProject = projectId && projectRepoStatus.has(projectId) ? Boolean(projectRepoStatus.get(projectId)) : lastRepoStatus; const groupDirectoryKey = normalizePath(group.directory ?? null); const groupBranchKey = group.branch?.trim() ?? null; const prIndicator = groupDirectoryKey && groupBranchKey ? (prVisualStateByDirectoryBranch.get(`${groupDirectoryKey}::${groupBranchKey}`) ?? null) : null; const showInlinePrTitle = Boolean(prIndicator && group.branch); const showBranchSubtitle = !prIndicator && !group.isMain && Boolean(group.branch); const prVisualState = prIndicator?.visualState ?? null; const checksSummary = prIndicator && prIndicator.state === 'open' && prIndicator.checks ? t('sessions.sidebar.group.pr.checksPassed', { success: prIndicator.checks.success, total: prIndicator.checks.total, }) : null; const checksTail = prIndicator && prIndicator.state === 'open' && prIndicator.checks ? [ prIndicator.checks.failure > 0 ? t('sessions.sidebar.group.pr.failingCount', { count: prIndicator.checks.failure }) : null, prIndicator.checks.pending > 0 ? t('sessions.sidebar.group.pr.pendingCount', { count: prIndicator.checks.pending }) : null, ].filter((item): item is string => Boolean(item)).join(', ') : null; const mergeabilityLabel = prIndicator && prIndicator.state === 'open' ? (prIndicator.mergeableState === 'blocked' || prIndicator.mergeableState === 'dirty' ? t('sessions.sidebar.group.pr.conflictsOrBlocked') : (prIndicator.mergeableState === 'clean' || prIndicator.canMerge === true ? t('sessions.sidebar.group.pr.mergeable') : null)) : null; const mergeStateLabel = prIndicator && prIndicator.state === 'open' && prIndicator.mergeableState ? t('sessions.sidebar.group.pr.mergeState', { state: prIndicator.mergeableState }) : null; const baseBranchLabel = prIndicator?.base ?? null; const headBranchLabel = prIndicator?.head ?? null; const statusLine = (() => { if (!prIndicator) { return group.branch && isBranchDifferentFromLabel(group.branch, group.label) ? { label: group.branch, color: null as string | null } : null; } switch (prIndicator.visualState) { case 'merged': return { label: t('sessions.sidebar.group.pr.status.merged'), color: 'var(--pr-merged)' }; case 'open': return (prIndicator.canMerge === true || prIndicator.mergeableState === 'clean' || prIndicator.checks?.state === 'success') ? { label: t('sessions.sidebar.group.pr.status.readyToMerge'), color: 'var(--pr-open)' } : { label: t('sessions.sidebar.group.pr.status.open'), color: 'var(--pr-open)' }; case 'blocked': return { label: prIndicator.mergeableState === 'dirty' ? t('sessions.sidebar.group.pr.status.mergeConflicts') : t('sessions.sidebar.group.pr.status.mergeBlocked'), color: 'var(--pr-blocked)', }; case 'draft': return { label: t('sessions.sidebar.group.pr.status.draft'), color: 'var(--pr-draft)' }; case 'closed': return { label: t('sessions.sidebar.group.pr.status.closed'), color: 'var(--pr-closed)' }; default: return null; } })(); const branchIconColor = statusLine?.color ?? (prVisualState ? `var(--pr-${prVisualState})` : undefined); const handlePrLinkClick = (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); const url = prIndicator?.url; if (!url) { return; } void openExternalUrl(url); }; const renderOneFolderItem = (folder: SessionFolder, nodes: SessionNode[], depth: number): React.ReactNode => { const directSubFolders = allFoldersForGroup.filter(({ folder: f }) => f.parentId === folder.id); const subFolderItems = directSubFolders.length > 0 ? <>{directSubFolders.map(({ folder: sf, nodes: sn }) => renderOneFolderItem(sf, sn, depth + 1))} : undefined; const collectFolderSessions = (targetFolderId: string): Session[] => { const directNodes = allFoldersForGroup.find(({ folder: candidate }) => candidate.id === targetFolderId)?.nodes ?? []; const childFolders = allFoldersForGroup.filter(({ folder: candidate }) => candidate.parentId === targetFolderId); return [ ...collectGroupSessions(directNodes), ...childFolders.flatMap(({ folder: child }) => collectFolderSessions(child.id)), ]; }; const folderSessionsForDelete = group.isArchivedBucket ? collectFolderSessions(folder.id) : []; return ( {(droppableRef, isDropTarget) => ( toggleFolderCollapse(folder.id)} onRename={(name) => { if (folderScopeKey) renameFolder(folderScopeKey, folder.id, name); }} onDelete={() => { if (group.isArchivedBucket) { // Delete sessions in the folder // Empty folders are auto-hidden by useArchivedAutoFolders sessionEvents.requestDelete({ sessions: folderSessionsForDelete, mode: 'session', }); return; } if (!folderScopeKey) return; if (!showDeletionDialog) { deleteFolder(folderScopeKey, folder.id); return; } const subFolderCount = allFoldersForGroup.filter(({ folder: f }) => f.parentId === folder.id).length; const sessionCount = nodes.length; setDeleteFolderConfirm({ scopeKey: folderScopeKey, folderId: folder.id, folderName: folder.name, subFolderCount, sessionCount, }); }} renderSessionNode={renderSessionNode} groupDirectory={group.directory} projectId={projectId} mobileVariant={mobileVariant} alwaysShowActions={alwaysShowActions} isRenaming={renamingFolderId === folder.id} renameDraft={renamingFolderId === folder.id ? renameFolderDraft : undefined} onRenameDraftChange={(value) => setRenameFolderDraft(value)} onRenameSave={() => { const trimmed = renameFolderDraft.trim(); if (trimmed && folderScopeKey) { renameFolder(folderScopeKey, folder.id, trimmed); } setRenamingFolderId(null); setRenameFolderDraft(''); }} onRenameCancel={() => { setRenamingFolderId(null); setRenameFolderDraft(''); }} droppableRef={droppableRef} isDropTarget={isDropTarget} depth={depth} onNewSession={() => { if (projectId && projectId !== activeProjectId) setActiveProjectIdOnly(projectId); setActiveMainTab('chat'); if (mobileVariant) setSessionSwitcherOpen(false); openNewSessionDraft({ directoryOverride: group.directory, targetFolderId: folder.id }); }} onNewSubFolder={depth === 0 ? () => { if (!folderScopeKey) return; createFolderAndStartRename(folderScopeKey, folder.id); } : undefined} hideActions={false} archivedBucket={group.isArchivedBucket === true} /> )} ); }; const renderFolderItems = () => rootFolders.map(({ folder, nodes }) => renderOneFolderItem(folder, nodes, 0)); const hasWorktreeDeleteAction = Boolean(!group.isMain && group.worktree); const groupHeaderRightPadding = alwaysShowActions ? (hasWorktreeDeleteAction ? 'pr-14' : 'pr-7') : isMinimalMode ? (hasWorktreeDeleteAction ? 'pr-2 group-hover/gh:pr-14 group-focus-within/gh:pr-14' : 'pr-2') : (hasWorktreeDeleteAction ? 'pr-5 group-hover/gh:pr-14 group-focus-within/gh:pr-14' : 'pr-5'); const body = ( 0} onSessionDroppedOnFolder={(sessionId, folderId) => { if (folderScopeKey) addSessionToFolder(folderScopeKey, folderId, sessionId); }} > {renderFolderItems()} {visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true))} {totalSessions === 0 && allFoldersForGroup.length === 0 ? (
{group.isArchivedBucket ? t('sessions.sidebar.group.empty.noArchivedSessions') : t('sessions.sidebar.group.empty.noSessionsInWorkspace')}
) : null} {remainingCount > 0 && !isExpanded ? ( ) : null} {isExpanded && totalSessions > maxVisible ? ( ) : null}
); const groupBodyPaddingClass = compactBodyPadding ? 'pb-2 pl-1' : 'pb-3 pl-4'; if (hideGroupLabel) { return
{body}
; } return (
onToggleCollapsedGroup(groupKey)} role="button" tabIndex={0} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onToggleCollapsedGroup(groupKey); } }} aria-label={isCollapsed ? t('sessions.sidebar.group.expandAria', { label: group.label }) : t('sessions.sidebar.group.collapseAria', { label: group.label })} aria-expanded={!isCollapsed} >

{showInlinePrTitle && prIndicator ? ( {isCollapsed ? : } {prIndicator.url ? ( ) : ( #{prIndicator.number} )}

{(baseBranchLabel || headBranchLabel) ? (
{baseBranchLabel && headBranchLabel ? ( <> {baseBranchLabel} {headBranchLabel} ) : ( {baseBranchLabel ?? headBranchLabel ?? ''} )}
) : null} {mergeStateLabel ?
{mergeStateLabel}
: null} {(mergeabilityLabel || checksSummary) ? (
{mergeabilityLabel ?? ''} {mergeabilityLabel && checksSummary ? ' • ' : ''} {checksSummary ?? ''} {checksTail ? ` (${checksTail})` : ''}
) : null}
{group.branch} ) : group.isArchivedBucket ? ( {isCollapsed ? : } {renderHighlightedText(group.label, normalizedSessionSearchQuery)} ) : (!group.isMain || group.worktree) ? ( {isCollapsed ? : } {renderHighlightedText(group.label, normalizedSessionSearchQuery)} ) : ( renderHighlightedText(group.label, normalizedSessionSearchQuery) )}

{showBranchSubtitle && statusLine ? ( {group.isArchivedBucket ? ( ) : (!group.isMain || isGitProject) ? ( showInlinePrTitle && prIndicator ? (
{(baseBranchLabel || headBranchLabel) ? (
{baseBranchLabel && headBranchLabel ? ( <> {baseBranchLabel} {headBranchLabel} ) : ( {baseBranchLabel ?? headBranchLabel ?? ''} )}
) : null} {mergeStateLabel ?
{mergeStateLabel}
: null} {(mergeabilityLabel || checksSummary) ? (
{mergeabilityLabel ?? ''} {mergeabilityLabel && checksSummary ? ' • ' : ''} {checksSummary ?? ''} {checksTail ? ` (${checksTail})` : ''}
) : null}
) : ( ) ) : null} {statusLine.label}
) : null}
{group.isArchivedBucket && allGroupSessions.length > 0 ? (

{t('sessions.sidebar.group.actions.deleteArchivedSessions')}

) : null} {group.directory && !group.isMain && group.worktree ? (

{t('sessions.sidebar.group.actions.deleteWorktree')}

) : null} {group.directory ? (

{t('sessions.sidebar.project.actions.newDraftSession')}

) : null}
{!isCollapsed ?
{body}
: null}
); }