import React from 'react'; import type { Session } from '@opencode-ai/sdk/v2'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; import { canUseElectronDesktopIPC, invokeDesktop, isVSCodeRuntime } from '@/lib/desktop'; import { toast } from '@/components/ui'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Icon } from "@/components/icon/Icon"; import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getExportRevealLabelKey, revealExportedMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession'; import type { ChildSessionExport } from '@/lib/exportSession'; import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSessionStatus, useSession, useSessionPermissions } from '@/sync/sync-context'; import { useSync } from '@/sync/use-sync'; import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store'; import { DraggableSessionRow } from './sessionFolderDnd'; import type { SessionNode, SessionSummaryMeta } from './types'; import { formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText, resolveSessionDiffStats } from './utils'; import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore'; import { useSessionUnseenCount } from '@/sync/notification-store'; import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore'; import { useI18n } from '@/lib/i18n'; import { getRuntimeBearerTokenSync } from '@/lib/runtime-auth'; import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch'; import { parseMultiRunSessionTitle } from '@/lib/multirun/title'; import { MultiRunFusionDialog } from '@/components/multirun/MultiRunFusionDialog'; import { FusionIcon } from '@/components/icons/FusionIcon'; import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; type Folder = { id: string; name: string; sessionIds: string[] }; type SecondaryMeta = { projectLabel?: string | null; branchLabel?: string | null; }; type Props = { node: SessionNode; depth?: number; groupDirectory?: string | null; projectId?: string | null; archivedBucket?: boolean; directoryStatus: Map; currentSessionId: string | null; pinnedSessionIds: Set; expandedParents: Set; hasSessionSearchQuery: boolean; normalizedSessionSearchQuery: string; notifyOnSubtasks: boolean; editingId: string | null; setEditingId: (id: string | null) => void; editTitle: string; setEditTitle: (value: string) => void; handleSaveEdit: () => void; handleCancelEdit: () => void; toggleParent: (expansionKey: string) => void; handleSessionSelect: (sessionId: string, sessionDirectory: string | null, isMissingDirectory: boolean, projectId?: string | null) => void; handleSessionDoubleClick: (sessionId: string, sessionTitle: string) => void; togglePinnedSession: (sessionId: string) => void; handleShareSession: (session: Session) => void; copiedSessionId: string | null; handleCopyShareUrl: (url: string, sessionId: string) => void; handleUnshareSession: (sessionId: string) => void; openSidebarMenuKey: string | null; setOpenSidebarMenuKey: (key: string | null) => void; renamingFolderId: string | null; getFoldersForScope: (scopeKey: string) => Folder[]; getSessionFolderId: (scopeKey: string, sessionId: string) => string | null; removeSessionFromFolder: (scopeKey: string, sessionId: string) => void; addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void; createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null; openContextPanelTab: (directory: string, options: { mode: 'chat'; dedupeKey: string; label: string; readOnly?: boolean }) => void; handleDeleteSession: (session: Session, source?: { archivedBucket?: boolean }) => void; mobileVariant: boolean; alwaysShowActions: boolean; renderSessionNode: (node: SessionNode, depth?: number, groupDirectory?: string | null, projectId?: string | null, archivedBucket?: boolean, secondaryMeta?: SecondaryMeta | null, renderContext?: 'project' | 'recent') => React.ReactNode; secondaryMeta?: SecondaryMeta | null; renderContext?: 'project' | 'recent'; }; const getNodeChildSignature = (node: SessionNode): string => { if (node.children.length === 0) { return ''; } return node.children .map((child) => `${child.session.id}:${child.children.length}`) .join('|'); }; const treeContainsSessionId = (node: SessionNode, sessionId: string | null): boolean => { if (!sessionId) { return false; } if (node.session.id === sessionId) { return true; } for (const child of node.children) { if (treeContainsSessionId(child, sessionId)) { return true; } } return false; }; const treeContainsMenuKey = ( node: SessionNode, menuKey: string | null, renderContext: 'project' | 'recent', archivedBucket: boolean, ): boolean => { if (!menuKey) { return false; } const nodeMenuKey = `${renderContext}:${archivedBucket ? 'archived' : 'active'}:${node.session.id}`; if (nodeMenuKey === menuKey) { return true; } for (const child of node.children) { if (treeContainsMenuKey(child, menuKey, renderContext, archivedBucket)) { return true; } } return false; }; const areEqual = (prev: Props, next: Props): boolean => { const prevSession = prev.node.session; const nextSession = next.node.session; const prevSessionId = prevSession.id; const nextSessionId = nextSession.id; if (prevSessionId !== nextSessionId) return false; if (prev.node.session !== next.node.session) return false; if (getNodeChildSignature(prev.node) !== getNodeChildSignature(next.node)) return false; if (prev.depth !== next.depth) return false; if (prev.groupDirectory !== next.groupDirectory) return false; if (prev.projectId !== next.projectId) return false; if (prev.archivedBucket !== next.archivedBucket) return false; if (prev.currentSessionId !== next.currentSessionId) { const prevActiveInTree = treeContainsSessionId(prev.node, prev.currentSessionId); const nextActiveInTree = treeContainsSessionId(next.node, next.currentSessionId); if (prevActiveInTree || nextActiveInTree) { return false; } } if (prev.pinnedSessionIds.has(prevSessionId) !== next.pinnedSessionIds.has(nextSessionId)) return false; // Expansion is keyed per render context, so compare the composite key // matching the one isExpanded reads from in render. If a session appears // in two contexts (project + recent), they have independent state. { const prevRenderContext = prev.renderContext ?? 'project'; const nextRenderContext = next.renderContext ?? 'project'; const prevArchived = prev.archivedBucket ?? false; const nextArchived = next.archivedBucket ?? false; const prevExpansionKey = `${prevRenderContext}:${prevArchived ? 'archived' : 'active'}:${prevSessionId}`; const nextExpansionKey = `${nextRenderContext}:${nextArchived ? 'archived' : 'active'}:${nextSessionId}`; if (prev.expandedParents.has(prevExpansionKey) !== next.expandedParents.has(nextExpansionKey)) return false; } if (prev.hasSessionSearchQuery !== next.hasSessionSearchQuery) return false; if (prev.normalizedSessionSearchQuery !== next.normalizedSessionSearchQuery) return false; if (prev.notifyOnSubtasks !== next.notifyOnSubtasks) return false; if (prev.editingId !== next.editingId) { const prevEditingInTree = treeContainsSessionId(prev.node, prev.editingId); const nextEditingInTree = treeContainsSessionId(next.node, next.editingId); if (prevEditingInTree || nextEditingInTree) { return false; } } if (prev.editTitle !== next.editTitle) { const prevEditingInTree = treeContainsSessionId(prev.node, prev.editingId); const nextEditingInTree = treeContainsSessionId(next.node, next.editingId); if (prevEditingInTree || nextEditingInTree) { return false; } } if ((prev.copiedSessionId === prevSessionId) !== (next.copiedSessionId === nextSessionId)) return false; const prevMenuInTree = treeContainsMenuKey(prev.node, prev.openSidebarMenuKey, prev.renderContext ?? 'project', prev.archivedBucket ?? false); const nextMenuInTree = treeContainsMenuKey(next.node, next.openSidebarMenuKey, next.renderContext ?? 'project', next.archivedBucket ?? false); if (prevMenuInTree !== nextMenuInTree) return false; const prevDirectory = normalizePath((prevSession as Session & { directory?: string | null }).directory ?? null) ?? normalizePath(prev.groupDirectory ?? null); const nextDirectory = normalizePath((nextSession as Session & { directory?: string | null }).directory ?? null) ?? normalizePath(next.groupDirectory ?? null); if (prevDirectory !== nextDirectory) return false; if ((prevDirectory ? prev.directoryStatus.get(prevDirectory) : null) !== (nextDirectory ? next.directoryStatus.get(nextDirectory) : null)) return false; if ((prev.secondaryMeta?.projectLabel ?? null) !== (next.secondaryMeta?.projectLabel ?? null)) return false; if ((prev.secondaryMeta?.branchLabel ?? null) !== (next.secondaryMeta?.branchLabel ?? null)) return false; if (prev.mobileVariant !== next.mobileVariant) return false; if (prev.alwaysShowActions !== next.alwaysShowActions) return false; if ((prev.renderContext ?? 'project') !== (next.renderContext ?? 'project')) return false; if (prev.renamingFolderId !== next.renamingFolderId) return false; return true; }; function SessionNodeItemComponent(props: Props): React.ReactNode { const { t } = useI18n(); const { node, depth = 0, groupDirectory, projectId, archivedBucket = false, directoryStatus, currentSessionId, pinnedSessionIds, expandedParents, hasSessionSearchQuery, normalizedSessionSearchQuery, notifyOnSubtasks, editingId, setEditingId, editTitle, setEditTitle, handleSaveEdit, handleCancelEdit, toggleParent, handleSessionSelect, handleSessionDoubleClick, togglePinnedSession, handleShareSession, copiedSessionId, handleCopyShareUrl, handleUnshareSession, openSidebarMenuKey, setOpenSidebarMenuKey, renamingFolderId, getFoldersForScope, getSessionFolderId, removeSessionFromFolder, addSessionToFolder, createFolderAndStartRename, openContextPanelTab, handleDeleteSession, mobileVariant, alwaysShowActions, renderSessionNode, secondaryMeta, renderContext = 'project', } = props; const hasSecondaryProjectLabel = Boolean(secondaryMeta?.projectLabel); const hasSecondaryBranchLabel = Boolean(secondaryMeta?.branchLabel); const displayMode = useSessionDisplayStore((state) => state.displayMode); const isMinimalMode = displayMode === 'minimal'; const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); const isElectron = React.useMemo(() => canUseElectronDesktopIPC(), []); const runtimeApis = React.useContext(RuntimeAPIContext); const revealOnHoverClass = isVSCode ? 'group-hover:opacity-100 group-hover:pointer-events-auto' : 'group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto'; const hideOnHoverClass = isVSCode ? 'group-hover:opacity-0' : 'group-hover:opacity-0 group-focus-within:opacity-0'; const showOpenInEditorAction = isVSCode; const showQuickArchiveAction = !archivedBucket && !mobileVariant; const revealPaddingClass = isMinimalMode ? (isVSCode ? 'group-hover:pr-2' : 'group-hover:pr-2 group-focus-within:pr-2') : (isVSCode ? (showQuickArchiveAction && showOpenInEditorAction ? 'group-hover:pr-18' : showQuickArchiveAction || showOpenInEditorAction ? 'group-hover:pr-12' : 'group-hover:pr-5') : (showQuickArchiveAction ? 'group-hover:pr-12 group-focus-within:pr-12' : 'group-hover:pr-5 group-focus-within:pr-5')); const alwaysActionPaddingClass = showQuickArchiveAction ? 'pr-13' : 'pr-7'; const suppressNextSelectRef = React.useRef(false); const [isTouchPressed, setIsTouchPressed] = React.useState(false); const editingIdRef = React.useRef(editingId); editingIdRef.current = editingId; const pendingRenameRef = React.useRef<{ id: string; title: string } | null>(null); const handleSaveEditRef = React.useRef(handleSaveEdit); handleSaveEditRef.current = handleSaveEdit; const formRef = React.useRef(null); const session = node.session; const liveSession = useSession(session.id); const resolvedSession = liveSession ?? session; const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null) ?? normalizePath(groupDirectory ?? null); const directoryStore = useDirectoryStore(sessionDirectory ?? undefined); const sync = useSync(); const selectionModeEnabled = useSessionMultiSelectStore((state) => state.enabled); const isRowSelected = useSessionMultiSelectStore( React.useCallback((state) => state.selectedIds.has(session.id), [session.id]), ); const toggleRowSelected = useSessionMultiSelectStore((state) => state.toggleSelected); const setRowRange = useSessionMultiSelectStore((state) => state.setRange); const collectNodeDescendantIds = React.useCallback((root: SessionNode): string[] => { const out: string[] = []; const walk = (n: SessionNode) => { n.children.forEach((child) => { out.push(child.session.id); walk(child); }); }; walk(root); return out; }, []); const [exportDialogOpen, setExportDialogOpen] = React.useState(false); const [exportIncludeSubtasks, setExportIncludeSubtasks] = React.useState(true); const menuInstanceKey = `${renderContext}:${archivedBucket ? 'archived' : 'active'}:${session.id}`; const isZombie = useViewportStore( React.useCallback((state) => Boolean(state.sessionMemoryState.get(viewportSessionKey(session.id))?.isZombie), [session.id]), ); const sessionStatus = useGlobalSessionStatus(session.id); const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined); const directoryState = sessionDirectory ? directoryStatus.get(sessionDirectory) : null; const isMissingDirectory = directoryState === 'missing'; const isActive = currentSessionId === session.id; const sessionTitle = resolvedSession.title || t('sessions.sidebar.session.untitled'); const hasChildren = node.children.length > 0; const isPinnedSession = pinnedSessionIds.has(session.id); // Per-render-context expansion key: the same session can appear in both // the project's root and the "Recent" list, and expanding one should not // expand the other. Matches the format of menuInstanceKey. const expansionKey = menuInstanceKey; const isExpanded = hasSessionSearchQuery ? true : expandedParents.has(expansionKey); const isSubtaskSession = Boolean((resolvedSession as Session & { parentID?: string | null }).parentID); const unseenCount = useSessionUnseenCount(session.id); const needsAttention = unseenCount > 0 && (!isSubtaskSession || notifyOnSubtasks); const sessionSummary = resolvedSession.summary as SessionSummaryMeta | undefined; const sessionDiffStats = resolveSessionDiffStats(sessionSummary); const sessionTimestamp = resolvedSession.time?.updated || resolvedSession.time?.created || Date.now(); const sessionUpdatedLabel = formatSessionDateLabel(sessionTimestamp); const sessionCompactUpdatedLabel = formatSessionCompactDateLabel(sessionTimestamp); const isMenuOpen = openSidebarMenuKey === menuInstanceKey; const isMultiRunLikeSession = React.useMemo(() => parseMultiRunSessionTitle(resolvedSession.title) !== null, [resolvedSession.title]); const [fusionDialogOpen, setFusionDialogOpen] = React.useState(false); const descendantCount = React.useMemo(() => collectNodeDescendantIds(node).length, [collectNodeDescendantIds, node]); const collectChildExports = React.useCallback(async (children: SessionNode[]): Promise<{ children: ChildSessionExport[]; skipped: number }> => { const results: ChildSessionExport[] = []; let skipped = 0; for (const child of children) { try { await sync.ensureSessionRenderable(child.session.id); const childRecords = buildSessionMessageRecordsSnapshot(directoryStore.getState(), child.session.id).list; const childTitle = child.session.title || t('sessions.sidebar.session.export.untitledSubagent'); const childAgent = (child.session as Session & { agent?: string }).agent; const grandChildren = await collectChildExports(child.children); skipped += grandChildren.skipped; results.push({ title: childTitle, agent: childAgent, records: childRecords, children: grandChildren.children, }); } catch { skipped += collectNodeDescendantIds(child).length + 1; } } return { children: results, skipped }; }, [collectNodeDescendantIds, directoryStore, sync, t]); const showSkippedSubtasksWarning = React.useCallback((count: number) => { if (count <= 0) return; toast.warning(count === 1 ? t('sessions.sidebar.session.export.skippedSubtaskSingle', { count }) : t('sessions.sidebar.session.export.skippedSubtaskMany', { count })); }, [t]); const doExportSession = React.useCallback(async (includeSubtasks: boolean) => { if (!sessionDirectory) { toast.error(t('sessions.sidebar.session.export.nothingToExport')); return; } await sync.ensureSessionRenderable(session.id); const records = buildSessionMessageRecordsSnapshot(directoryStore.getState(), session.id).list; if (records.length === 0) { toast.error(t('sessions.sidebar.session.export.nothingToExport')); return; } let childExports: ChildSessionExport[] | undefined; let skippedSubtaskCount = 0; if (includeSubtasks && node.children.length > 0) { const collected = await collectChildExports(node.children); childExports = collected.children; skippedSubtaskCount = collected.skipped; } const markdown = formatSessionAsMarkdown(records, resolvedSession.title ?? null, childExports); const filename = buildExportFilename(resolvedSession.title ?? null); const savedPath = await saveAsMarkdownDesktop(markdown, filename); if (savedPath) { toast.success(t('sessions.sidebar.session.export.success'), { action: { label: t(getExportRevealLabelKey()), onClick: () => { void revealExportedMarkdown(savedPath).then((revealed) => { if (!revealed) { toast.error(t('sessions.sidebar.session.export.failedRevealPath')); } }); }, }, }); showSkippedSubtasksWarning(skippedSubtaskCount); return; } downloadAsMarkdown(markdown, filename); toast.success(t('sessions.sidebar.session.export.success')); showSkippedSubtasksWarning(skippedSubtaskCount); }, [collectChildExports, directoryStore, node.children, resolvedSession.title, session.id, sessionDirectory, showSkippedSubtasksWarning, sync, t]); const handleExportSession = React.useCallback(async () => { if (node.children.length > 0) { setExportIncludeSubtasks(true); setExportDialogOpen(true); return; } await doExportSession(false); }, [doExportSession, node.children.length]); const handleOpenMiniChatWindow = React.useCallback(() => { if (!sessionDirectory) return; void invokeDesktop('desktop_open_session_mini_chat_window', { sessionId: session.id, directory: sessionDirectory, apiBaseUrl: getRuntimeApiBaseUrl(), clientToken: getRuntimeBearerTokenSync(), }).catch((error) => { console.warn('[session-sidebar] failed to open mini chat window', error); }); }, [session.id, sessionDirectory]); // Capture outside-clicks to save edits — immune to focus-race with onBlur. React.useEffect(() => { if (editingId !== session.id) return; const handleDocMouseDown = (e: MouseEvent) => { if (formRef.current && !formRef.current.contains(e.target as Node)) { handleSaveEditRef.current(); } }; document.addEventListener('mousedown', handleDocMouseDown); return () => document.removeEventListener('mousedown', handleDocMouseDown); }, [editingId, session.id]); 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={t('sessions.sidebar.session.menu.rename')} onKeyDown={(event) => { if (event.key === 'Escape') { event.stopPropagation(); handleCancelEdit(); return; } if (event.key === ' ' || event.key === 'Enter') { event.stopPropagation(); } }} />
{!isMinimalMode ? (
{hasChildren ? {isExpanded ? : } : null} {sessionUpdatedLabel} {sessionDiffStats ? +{sessionDiffStats.additions}/-{sessionDiffStats.deletions} : null} {hasSecondaryProjectLabel ? {secondaryMeta?.projectLabel} : null} {hasSecondaryBranchLabel ? {secondaryMeta?.branchLabel} : null}
) : null}
); } const statusType = sessionStatus?.type ?? 'idle'; const isStreaming = statusType === 'busy' || statusType === 'retry'; const pendingPermissionCount = sessionPermissions.length; const showUnreadStatus = !isStreaming && needsAttention && !isActive; const showStatusMarker = isStreaming || showUnreadStatus; const statusMarkerContent = isStreaming ? ( ) : ( ); const leadingIndicators = showStatusMarker || isPinnedSession ? ( {showStatusMarker ? statusMarkerContent : null} {isPinnedSession ? : null} ) : null; const subsessionChevron = hasChildren ? ( { event.stopPropagation(); toggleParent(expansionKey); }} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); event.stopPropagation(); toggleParent(expansionKey); } }} className={cn( 'absolute left-[-10px] inline-flex h-3.5 w-3.5 items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity', isMinimalMode ? 'top-1/2 -translate-y-1/2' : 'top-[14.5px] -translate-y-1/2', isMinimalMode && showStatusMarker && !alwaysShowActions ? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto' : '', )} aria-label={isExpanded ? t('sessions.sidebar.session.subsessions.collapse') : t('sessions.sidebar.session.subsessions.expand')} > {isExpanded ? : } ) : null; const streamingIndicator = isZombie ? : null; const handleMenuOpenChange = (open: boolean) => { setOpenSidebarMenuKey(open ? menuInstanceKey : null); }; const handleMenuOpenChangeComplete = (open: boolean) => { if (!open && pendingRenameRef.current) { const { id, title } = pendingRenameRef.current; pendingRenameRef.current = null; setEditingId(id); setEditTitle(title); } }; const handleMenuTriggerClick = (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); setOpenSidebarMenuKey(isMenuOpen ? null : menuInstanceKey); }; const handleMenuTriggerPointerDown = (event: React.PointerEvent) => { event.preventDefault(); event.stopPropagation(); }; const handleMenuTriggerMouseDown = (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); }; const handleQuickArchivePointerDown = (event: React.PointerEvent) => { event.preventDefault(); event.stopPropagation(); }; const handleQuickArchiveMouseDown = (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); }; const handleQuickArchiveClick = (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); setOpenSidebarMenuKey(null); handleDeleteSession(session, { archivedBucket }); }; const handleOpenInEditorPointerDown = (event: React.PointerEvent) => { event.preventDefault(); event.stopPropagation(); }; const handleOpenInEditorMouseDown = (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); }; const handleOpenInEditorClick = (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); void runtimeApis?.vscode?.executeCommand('openchamber.openSessionInEditor', session.id, sessionTitle); }; const handleRowSelect = (event?: React.MouseEvent) => { if (suppressNextSelectRef.current) { suppressNextSelectRef.current = false; return; } if (selectionModeEnabled) { event?.preventDefault(); event?.stopPropagation(); if (event?.shiftKey) { const rows = typeof document !== 'undefined' ? Array.from(document.querySelectorAll('[data-session-row]')) : []; const orderedIds = rows .map((el) => el.getAttribute('data-session-row')) .filter((id): id is string => typeof id === 'string' && id.length > 0); const currentAnchor = useSessionMultiSelectStore.getState().anchorId; const descendantsById = new Map(); descendantsById.set(session.id, collectNodeDescendantIds(node)); setRowRange(currentAnchor, session.id, orderedIds, sessionDirectory ?? null, descendantsById); return; } toggleRowSelected(session.id, sessionDirectory ?? null, collectNodeDescendantIds(node)); return; } handleSessionSelect(session.id, sessionDirectory, isMissingDirectory, projectId); }; const handleRowMouseDown = (event: React.MouseEvent) => { if (event.button === 2 || (event.button === 0 && event.ctrlKey && !selectionModeEnabled)) { suppressNextSelectRef.current = true; } }; const handleRowPointerDown = (event: React.PointerEvent) => { if (mobileVariant && event.pointerType === 'touch') { setIsTouchPressed(true); } }; const handleRowPointerEnd = (event: React.PointerEvent) => { if (mobileVariant && event.pointerType === 'touch') { setIsTouchPressed(false); } }; const sessionMenuContent = ( (renamingFolderId || editingIdRef.current) ? false : true}> { // Defer rename until dropdown close transition completes. // onOpenChangeComplete fires after animation + focus cleanup are done, // avoiding focus stealing from Base UI's unmount cleanup. pendingRenameRef.current = { id: session.id, title: sessionTitle }; }} className="[&>svg]:mr-1" > {t('sessions.sidebar.session.menu.rename')} togglePinnedSession(session.id)} className="[&>svg]:mr-1"> {isPinnedSession ? : } {isPinnedSession ? t('sessions.sidebar.session.menu.unpin') : t('sessions.sidebar.session.menu.pin')} {!resolvedSession.share ? ( handleShareSession(resolvedSession)} className="[&>svg]:mr-1"> {t('sessions.sidebar.session.menu.share')} ) : ( <> { if (resolvedSession.share?.url) handleCopyShareUrl(resolvedSession.share.url, session.id); }} className="[&>svg]:mr-1"> {copiedSessionId === session.id ? <>{t('sessions.sidebar.session.menu.copied')} : <>{t('sessions.sidebar.session.menu.copyLink')}} handleUnshareSession(session.id)} className="[&>svg]:mr-1"> {t('sessions.sidebar.session.menu.unshare')} )} { void handleExportSession(); }} className="[&>svg]:mr-1"> {t('sessions.sidebar.session.menu.exportMarkdown')} {isMultiRunLikeSession ? ( setFusionDialogOpen(true)} className="[&>svg]:mr-1"> {t('sessions.sidebar.session.menu.runFusion')} ) : null} {sessionDirectory && !archivedBucket ? (() => { const scopeFolders = getFoldersForScope(sessionDirectory); const currentFolderId = getSessionFolderId(sessionDirectory, session.id); return ( <> {t('sessions.sidebar.folders.moveToFolder')} {scopeFolders.length === 0 ? ( {t('sessions.sidebar.folders.none')} ) : ( scopeFolders.map((folder) => ( { if (currentFolderId === folder.id) removeSessionFromFolder(sessionDirectory, session.id); else addSessionToFolder(sessionDirectory, folder.id, session.id); }}> {folder.name} {currentFolderId === folder.id ? : null} )) )} { const newFolder = createFolderAndStartRename(sessionDirectory); if (!newFolder) return; addSessionToFolder(sessionDirectory, newFolder.id, session.id); }}> {t('sessions.sidebar.folders.newFolderEllipsis')} {currentFolderId ? ( { removeSessionFromFolder(sessionDirectory, session.id); }} className="text-destructive focus:text-destructive"> {t('sessions.sidebar.folders.removeFromFolder')} ) : null} ); })() : null} {!isVSCode ? ( { if (!sessionDirectory) return; openContextPanelTab(sessionDirectory, { mode: 'chat', dedupeKey: `session:${session.id}`, label: sessionTitle, }); }} className="[&>svg]:mr-1" > {t('sessions.sidebar.session.menu.openInSidePanel')} {t('sessions.sidebar.session.menu.betaBadge')} ) : null} {isElectron ? ( {t('sessions.sidebar.session.menu.openMiniChatWindow')} ) : null} handleDeleteSession(session, { archivedBucket })}> {archivedBucket ? t('sessions.sidebar.bulkActions.delete') : t('sessions.sidebar.bulkActions.archive')} ); return (
0 && 'pl-[20px]', isRowSelected && 'bg-primary/15', )} > {leadingIndicators} {subsessionChevron}
{isMinimalMode ? (
{secondaryMeta?.projectLabel ?
{secondaryMeta.projectLabel}
: null}
{sessionUpdatedLabel}
{secondaryMeta?.branchLabel || sessionDiffStats ? (
{secondaryMeta?.branchLabel ? (
{secondaryMeta.branchLabel}
) : null} {sessionDiffStats ? +{sessionDiffStats.additions}-{sessionDiffStats.deletions} : null}
) : null}
) : ( )}
{streamingIndicator && !mobileVariant ? (
{streamingIndicator}
) : null}
{showQuickArchiveAction ? ( {t('sessions.sidebar.bulkActions.archive')} ) : null} {showOpenInEditorAction ? ( {t('sessions.sidebar.session.actions.openInEditor')} ) : null} {sessionMenuContent}
{hasChildren && isExpanded ? node.children.map((child) => renderSessionNode(child, depth + 1, sessionDirectory ?? groupDirectory, projectId, archivedBucket, undefined, renderContext)) : null} {t('sessions.sidebar.session.export.dialog.title')} {descendantCount === 1 ? t('sessions.sidebar.session.export.dialog.descriptionSingle', { count: descendantCount }) : t('sessions.sidebar.session.export.dialog.descriptionMany', { count: descendantCount })} {isMultiRunLikeSession ? ( ) : null}
); } export const SessionNodeItem = React.memo(SessionNodeItemComponent, areEqual);