import React from 'react'; import type { Session } from '@opencode-ai/sdk/v2'; import { ContextMenu } from '@base-ui/react/context-menu'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { dropdownMenuItemClass, dropdownMenuPopupClass, dropdownMenuSeparatorClass, dropdownMenuSubTriggerClass } from '@/components/ui/dropdown-menu.styles'; 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, useSessionPermissions } from '@/sync/sync-context'; import { useSync } from '@/sync/use-sync'; import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store'; import { DraggableSessionRow } from './sessionFolderDnd'; import { nodeContainsSessionId } from './sessionNodeItemUtils'; import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sessionNodeItemUtils'; import type { SessionNode } from './types'; import { formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } 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; 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: (titleOverride?: string) => void; handleCancelEdit: () => void; toggleParent: (expansionKey: string) => void; handleSessionSelect: (sessionId: string, sessionDirectory: string | null, 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; hardDelete?: 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', renderExtras?: SessionNodeRenderExtras, ) => React.ReactNode; secondaryMeta?: SecondaryMeta | null; renderContext?: 'project' | 'recent'; /** * Precomputed set of session IDs whose subtree contains the current * active session. Computed once per SessionGroupSection render (when * currentSessionId changes) instead of being recomputed in every row's * React.memo comparator. */ subtreeContainsActive: Set; /** * Precomputed set of session IDs whose subtree contains the session * currently being edited. Same rationale as subtreeContainsActive. */ subtreeContainsEditing: Set; /** * Precomputed session ID of the row whose sidebar menu is open, or null * if no menu is open. Only one row can have its menu open at a time. */ menuOpenSessionId: string | null; /** * Precomputed structural key for this node. Encodes the IDs and child * counts of all descendants so a reference-only change to `node` (e.g. * a fresh tree rebuild) can be detected with a single string compare * instead of a recursive walk per row. */ nodeStructureKey: string; /** * Resolves the per-row render extras for each child node. SessionGroupSection * walks the whole tree once to precompute the structure key for every * descendant; SessionNodeItem's recursive child render uses this lookup * to fetch the right key for each child it produces. */ childRenderExtrasFor?: (child: SessionNode) => SessionNodeChildRenderExtras; /** * Batched index of live session objects keyed by id. The previous * implementation called `useSession(session.id)` per row, which used * `findLiveSession` to iterate every child-store on every SSE event. * With M visible rows that's M×child-stores per event; the batched * map turns it into a single Map.get per row. The parent falls back * to `useSession` only when this map returns undefined. */ liveSessionById: Map; }; function SessionNodeItemComponent(props: Props): React.ReactNode { const { t } = useI18n(); const { node, depth = 0, groupDirectory, projectId, archivedBucket = false, 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', subtreeContainsActive, subtreeContainsEditing, menuOpenSessionId, childRenderExtrasFor, liveSessionById, } = props; const hasSecondaryProjectLabel = Boolean(secondaryMeta?.projectLabel); const hasSecondaryBranchLabel = Boolean(secondaryMeta?.branchLabel); const displayMode = useSessionDisplayStore((state) => state.displayMode); const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); // VS Code always uses the minimal (single-line) layout: sessions are grouped // under workspace project headers, so the second metadata row (project/branch) // is redundant. The display-mode toggle is hidden there, so force it on. const isMinimalMode = displayMode === 'minimal' || isVSCode; 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 // VS Code minimal rows reveal up to three actions on hover // (open-in-editor + quick-archive + menu, each h-4). The date sits in the // row flow, so the title must shrink enough to clear the actions or they // overlap the timestamp. Open-in-editor is always present in VS Code. ? (showQuickArchiveAction && showOpenInEditorAction ? 'group-hover:pr-18' : showQuickArchiveAction || showOpenInEditorAction ? 'group-hover:pr-14' : 'group-hover:pr-8') : '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 [renameDraft, setRenameDraft] = React.useState(editTitle); const renameDraftRef = React.useRef(renameDraft); renameDraftRef.current = renameDraft; const renameTargetRef = React.useRef(null); const formRef = React.useRef(null); const session = node.session; // Batched live-session lookup. `liveSessionById` is built once per // Sidebar render from the same `useAllLiveSessions` selector that // `useSession` would have iterated per child-store, so a Map.get // here is equivalent in observed state but O(1) per row instead of // O(child-stores). Falls back to the row session when the live map // hasn't seen this id yet (sub-render latency between when a session // is created and when the SSE-driven aggregate picks it up). const resolvedSession = liveSessionById.get(session.id) ?? session; const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null) ?? normalizePath(groupDirectory ?? null); // Archived rows are historical and never need live state, yet they point at // dozens of (often deleted) worktrees — bootstrapping each from the sidebar // triggers a pointless session-list fetch + 6×2s empty-retry storm on startup. // Skip bootstrap for archived rows; the store ref is only read on-demand via // getState() in the export handlers (never subscribed). Active rows keep // bootstrapping so live cross-directory session/status still aggregates. const directoryStore = useDirectoryStore(sessionDirectory ?? undefined, { bootstrap: !archivedBucket }); 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 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 sessionTimestamp = resolvedSession.time?.updated || resolvedSession.time?.created || Date.now(); const sessionUpdatedLabel = formatSessionDateLabel(sessionTimestamp); const sessionCompactUpdatedLabel = formatSessionCompactDateLabel(sessionTimestamp); const isMenuOpen = openSidebarMenuKey === menuInstanceKey; const [isContextMenuOpen, setIsContextMenuOpen] = React.useState(false); const isSessionMenuOpen = isMenuOpen || isContextMenuOpen; const isMultiRunLikeSession = React.useMemo(() => parseMultiRunSessionTitle(resolvedSession.title) !== null, [resolvedSession.title]); const [fusionDialogOpen, setFusionDialogOpen] = React.useState(false); const metadataSubsessionChevron = isVSCode && renderContext === 'recent' && !isMinimalMode; const inlineSubsessionChevron = isVSCode && renderContext === 'recent' && isMinimalMode; 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(renameDraftRef.current); } }; document.addEventListener('mousedown', handleDocMouseDown); return () => document.removeEventListener('mousedown', handleDocMouseDown); }, [editingId, session.id]); React.useLayoutEffect(() => { if (editingId !== session.id) { if (renameTargetRef.current === session.id) { renameTargetRef.current = null; } return; } if (renameTargetRef.current === session.id) return; renameTargetRef.current = session.id; setRenameDraft(editTitle); }, [editingId, editTitle, session.id]); if (editingId === session.id) { return (
0 && 'pl-[20px]')} >
{ event.preventDefault(); handleSaveEdit(renameDraft); }} > setRenameDraft(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) => { event.stopPropagation(); if (event.key === 'Escape') { handleCancelEdit(); return; } }} />
{!isMinimalMode ? (
{hasChildren ? {isExpanded ? : } : null} {sessionUpdatedLabel} {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); } }} style={{ minWidth: 14, minHeight: 14 }} className={cn( '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', metadataSubsessionChevron ? 'absolute left-1.5 bottom-1' : inlineSubsessionChevron ? 'relative mr-0.5 shrink-0' : cn('absolute left-0.5', isMinimalMode ? 'top-1/2 -translate-y-1/2' : 'top-[14.5px] -translate-y-1/2'), !metadataSubsessionChevron && !inlineSubsessionChevron && 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) => { if (open) { setIsContextMenuOpen(false); } 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 handleContextMenuOpenChange = (open: boolean) => { setIsContextMenuOpen(open); }; 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, 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 renderSessionMenuItems = ({ Item, Separator, Sub, SubTrigger, SubContent, }: { Item: React.ElementType; Separator: React.ElementType; Sub: React.ElementType; SubTrigger: React.ElementType; SubContent: React.ElementType; }) => ( <> { // 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} {!archivedBucket ? ( handleDeleteSession(session, { archivedBucket })}> {t('sessions.sidebar.bulkActions.archive')} ) : null} handleDeleteSession(session, { archivedBucket, hardDelete: true })}> {t('sessions.sidebar.bulkActions.delete')} ); const sessionMenuContent = ( (renamingFolderId || editingIdRef.current) ? false : true}> {renderSessionMenuItems({ Item: DropdownMenuItem, Separator: DropdownMenuSeparator, Sub: DropdownMenuSub, SubTrigger: DropdownMenuSubTrigger, SubContent: DropdownMenuSubContent, })} ); const contextMenuContent = ( (renamingFolderId || editingIdRef.current) ? false : true} style={{ backgroundColor: 'var(--surface-elevated)', color: 'var(--surface-elevated-foreground)', }} className={cn(dropdownMenuPopupClass, 'min-w-[180px]')} > {renderSessionMenuItems({ Item: ({ className, ...itemProps }: React.ComponentProps) => ( ), Separator: ({ className, ...separatorProps }: React.ComponentProps) => ( ), Sub: ContextMenu.SubmenuRoot, SubTrigger: ({ className, children, ...triggerProps }: React.ComponentProps) => ( {children} ), SubContent: ({ className, children, ...popupProps }: React.ComponentProps) => ( {children} ), })} ); return ( 0 ? 'pl-[32px]' : 'pl-[18px]', // Active (currently open) session gets a subtle primary tint; // multi-select highlight takes precedence when both apply. isActive && !isRowSelected && 'bg-primary/10', isRowSelected && 'bg-interactive-selection', )} /> } > {leadingIndicators} {subsessionChevron}
{isMinimalMode ? ( {/* VS Code already shows project context via workspace headers, so the per-row metadata tooltip is redundant noise there. */} {!isVSCode ? (
{secondaryMeta?.projectLabel ?
{secondaryMeta.projectLabel}
: null}
{sessionUpdatedLabel}
{secondaryMeta?.branchLabel ? (
{secondaryMeta.branchLabel}
) : null}
) : null}
) : ( )}
{streamingIndicator && !mobileVariant ? (
{streamingIndicator}
) : null}
{showQuickArchiveAction ? ( {t('sessions.sidebar.bulkActions.archive')} ) : null} {showOpenInEditorAction ? ( {t('sessions.sidebar.session.actions.openInEditor')} ) : null} {sessionMenuContent}
{contextMenuContent}
{hasChildren && isExpanded ? node.children.map((child): React.ReactNode => { const childRenderExtras: SessionNodeChildRenderExtras = childRenderExtrasFor ? childRenderExtrasFor(child) : { subtreeContainsActive, subtreeContainsEditing, menuOpenSessionId, nodeStructureKey: '', }; return renderSessionNode( child, depth + 1, sessionDirectory ?? groupDirectory, projectId, archivedBucket, undefined, renderContext, childRenderExtras, ); }) : 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}
); } const getNodeSessionDirectory = (node: SessionNode): string | null => { return normalizePath((node.session as Session & { directory?: string | null }).directory ?? null); }; const isSecondaryMetaEqual = (prev?: SecondaryMeta | null, next?: SecondaryMeta | null): boolean => { return (prev?.projectLabel ?? null) === (next?.projectLabel ?? null) && (prev?.branchLabel ?? null) === (next?.branchLabel ?? null); }; const getMenuSessionIdFromKey = (props: Props): string | null => { if (!props.openSidebarMenuKey) return null; const bucketTag = props.archivedBucket ? 'archived' : 'active'; const prefix = `${props.renderContext ?? 'project'}:${bucketTag}:`; return props.openSidebarMenuKey.startsWith(prefix) ? props.openSidebarMenuKey.slice(prefix.length) : null; }; const getRelevantMenuSessionId = (props: Props): string | null => { return props.menuOpenSessionId ?? getMenuSessionIdFromKey(props); }; const subtreeContainsSession = ( props: Props, sessionId: string | null, precomputed: Set, ): boolean => { if (!sessionId) return false; if (precomputed.has(props.node.session.id)) return true; return nodeContainsSessionId(props.node, sessionId); }; const hasSetMembershipChangeInNode = ( prevNode: SessionNode, nextNode: SessionNode, prevSet: Set, nextSet: Set, getKey: (node: SessionNode) => string, ): boolean => { if (prevNode.session.id !== nextNode.session.id) return true; const key = getKey(prevNode); if (prevSet.has(key) !== nextSet.has(key)) return true; if (prevNode.children.length !== nextNode.children.length) return true; for (let i = 0; i < prevNode.children.length; i += 1) { if (hasSetMembershipChangeInNode(prevNode.children[i], nextNode.children[i], prevSet, nextSet, getKey)) { return true; } } return false; }; const hasResolvedSessionChangeInNode = ( prevNode: SessionNode, nextNode: SessionNode, prevLiveSessionById: Map, nextLiveSessionById: Map, ): boolean => { if (prevNode.session.id !== nextNode.session.id) return true; const sessionId = prevNode.session.id; if ((prevLiveSessionById.get(sessionId) ?? prevNode.session) !== (nextLiveSessionById.get(sessionId) ?? nextNode.session)) { return true; } if (prevNode.children.length !== nextNode.children.length) return true; for (let i = 0; i < prevNode.children.length; i += 1) { if (hasResolvedSessionChangeInNode(prevNode.children[i], nextNode.children[i], prevLiveSessionById, nextLiveSessionById)) { return true; } } return false; }; const hasExpansionMembershipChange = (prev: Props, next: Props): boolean => { if (prev.hasSessionSearchQuery || next.hasSessionSearchQuery) return false; const prevBucketTag = prev.archivedBucket ? 'archived' : 'active'; const nextBucketTag = next.archivedBucket ? 'archived' : 'active'; return hasSetMembershipChangeInNode( prev.node, next.node, prev.expandedParents, next.expandedParents, (node) => `${prev.renderContext ?? 'project'}:${prevBucketTag}:${node.session.id}`, ) || hasSetMembershipChangeInNode( prev.node, next.node, prev.expandedParents, next.expandedParents, (node) => `${next.renderContext ?? 'project'}:${nextBucketTag}:${node.session.id}`, ); }; const areSessionNodeItemPropsEqual = (prev: Props, next: Props): boolean => { if (prev.node.session.id !== next.node.session.id) 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.renderContext ?? 'project') !== (next.renderContext ?? 'project')) return false; if (prev.mobileVariant !== next.mobileVariant) return false; if (prev.alwaysShowActions !== next.alwaysShowActions) 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.nodeStructureKey !== next.nodeStructureKey) return false; if (getNodeSessionDirectory(prev.node) !== getNodeSessionDirectory(next.node)) return false; if (!isSecondaryMetaEqual(prev.secondaryMeta, next.secondaryMeta)) return false; if (prev.liveSessionById !== next.liveSessionById && hasResolvedSessionChangeInNode(prev.node, next.node, prev.liveSessionById, next.liveSessionById)) { return false; } if (prev.pinnedSessionIds !== next.pinnedSessionIds && hasSetMembershipChangeInNode(prev.node, next.node, prev.pinnedSessionIds, next.pinnedSessionIds, (node) => node.session.id)) { return false; } if (prev.expandedParents !== next.expandedParents && hasExpansionMembershipChange(prev, next)) { return false; } if (prev.currentSessionId !== next.currentSessionId && ( subtreeContainsSession(prev, prev.currentSessionId, prev.subtreeContainsActive) || subtreeContainsSession(next, next.currentSessionId, next.subtreeContainsActive) )) { return false; } if (prev.editingId !== next.editingId && ( subtreeContainsSession(prev, prev.editingId, prev.subtreeContainsEditing) || subtreeContainsSession(next, next.editingId, next.subtreeContainsEditing) )) { return false; } if (prev.editTitle !== next.editTitle && ( subtreeContainsSession(prev, prev.editingId, prev.subtreeContainsEditing) || subtreeContainsSession(next, next.editingId, next.subtreeContainsEditing) )) { return false; } if (prev.copiedSessionId !== next.copiedSessionId && ( nodeContainsSessionId(prev.node, prev.copiedSessionId) || nodeContainsSessionId(next.node, next.copiedSessionId) )) { return false; } if (prev.openSidebarMenuKey !== next.openSidebarMenuKey) { const prevMenuSessionId = getRelevantMenuSessionId(prev); const nextMenuSessionId = getRelevantMenuSessionId(next); if (nodeContainsSessionId(prev.node, prevMenuSessionId) || nodeContainsSessionId(next.node, nextMenuSessionId)) { return false; } } if (prev.renamingFolderId !== next.renamingFolderId) { const prevMenuSessionId = getRelevantMenuSessionId(prev); const nextMenuSessionId = getRelevantMenuSessionId(next); if (nodeContainsSessionId(prev.node, prevMenuSessionId) || nodeContainsSessionId(next.node, nextMenuSessionId)) { return false; } } return prev.setEditingId === next.setEditingId && prev.setEditTitle === next.setEditTitle && prev.handleSaveEdit === next.handleSaveEdit && prev.handleCancelEdit === next.handleCancelEdit && prev.toggleParent === next.toggleParent && prev.handleSessionSelect === next.handleSessionSelect && prev.handleSessionDoubleClick === next.handleSessionDoubleClick && prev.togglePinnedSession === next.togglePinnedSession && prev.handleShareSession === next.handleShareSession && prev.handleCopyShareUrl === next.handleCopyShareUrl && prev.handleUnshareSession === next.handleUnshareSession && prev.setOpenSidebarMenuKey === next.setOpenSidebarMenuKey && prev.getFoldersForScope === next.getFoldersForScope && prev.getSessionFolderId === next.getSessionFolderId && prev.removeSessionFromFolder === next.removeSessionFromFolder && prev.addSessionToFolder === next.addSessionToFolder && prev.createFolderAndStartRename === next.createFolderAndStartRename && prev.openContextPanelTab === next.openContextPanelTab && prev.handleDeleteSession === next.handleDeleteSession && prev.renderSessionNode === next.renderSessionNode; }; export const SessionNodeItem = React.memo(SessionNodeItemComponent, areSessionNodeItemPropsEqual);