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, formatDirectoryName } 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 { isSessionPinned, useSessionPinnedStore } from '@/stores/useSessionPinnedStore'; import { Icon } from "@/components/icon/Icon"; import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getExportRevealLabelKey, revealExportedMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession'; import type { ChildSessionExport } from '@/lib/exportSession'; import { useGlobalSessionStatus, useSessionPermissions, useSessionQuestionCount } from '@/sync/sync-context'; import { usePrefetchSessionMessages, useSessionMessageRecordsForExport } from '@/sync/use-sync'; import { getSyncSessionMaterializationStatus } from '@/sync/sync-refs'; import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store'; import { DraggableSessionRow } from '../folders/sessionFolderDnd'; import { canShowSessionWorktreeMenu, getSessionWorktreeMenuDisabled, nodeContainsSessionId, nodeHasPinnedMembershipChange, selectQuestionBadgeSessionScopes, selectRowBadgeVisibilityClass } from './sessionNodeItemUtils'; import type { SessionNode } from '../types'; import { formatProjectLabel, formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from '../utils'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore'; import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore'; import { useSessionUnseenCount } from '@/sync/notification-store'; import { useHasSessionActivityDuration } from '@/sync/session-activity-timing'; import { SessionActivityDuration } from '@/components/session/SessionActivityDuration'; import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore'; import { useI18n } from '@/lib/i18n'; import { useShiftKeyHeld } from '@/hooks/useShiftKeyHeld'; import { getSessionGoal } from '@/lib/sessionGoalMetadata'; import { sessionGoalStatusColor, sessionGoalStatusLabelKey } from '@/lib/sessionGoalPresentation'; import { getRuntimeBearerTokenSync } from '@/lib/runtime-auth'; import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch'; import { getChatsRootFromDirectory } from '@/lib/chatDirectories'; import { parseMultiRunSessionTitle } from '@/lib/multirun/title'; import { MultiRunFusionDialog } from '@/components/multirun/MultiRunFusionDialog'; import { FusionIcon } from '@/components/icons/FusionIcon'; import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; import { buildSessionTreeMoveMessages, requestSessionTreeMove, useIsSessionWorktreeMovePending, } from '@/lib/worktrees/sessionWorktreeMove'; import { streamPerfCount } from '@/stores/utils/streamDebug'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore'; import { useUIStore } from '@/stores/useUIStore'; import type { WorktreeMetadata } from '@/types/worktree'; import { getSessionWorktreeMenuState, type SessionWorktreeMenuTarget, type StartSessionWorktreeMenuLoadResult, } from '../sessionWorktreeMenu'; type SecondaryMeta = { projectLabel?: string | null; branchLabel?: string | null; }; export type SessionNodeItemProps = { node: SessionNode; depth?: number; groupDirectory?: string | null; projectId?: string | null; archivedBucket?: boolean; 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) => void; handleSessionDoubleClick: (sessionId: string, sessionTitle: string) => void; handleShareSession: (session: Session) => void; copiedSessionId: string | null; handleCopyShareUrl: (url: string, sessionId: string) => void; handleCopySessionId: (sessionId: string) => void; handleUnshareSession: (sessionId: string) => void; openSidebarMenuKey: string | null; setOpenSidebarMenuKey: (key: string | null) => void; createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null; handleDeleteSession: (session: Session, source?: { archivedBucket?: boolean; hardDelete?: boolean; skipConfirm?: boolean }) => void; handleRestoreSession: (session: Session) => void; startSessionWorktreeMenuLoad: (args: { projectId: string | null; sourceDirectory: string | null; currentWorktree: WorktreeMetadata | null; }) => StartSessionWorktreeMenuLoadResult; mobileVariant: boolean; alwaysShowActions: boolean; secondaryMeta?: SecondaryMeta | null; renderContext?: 'project' | 'recent'; /** * Precomputed set of session IDs whose subtree contains the session * currently being edited. Precomputed once per group render. */ 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; /** * Bumped once a minute by the Recent list so the compact relative * timestamp rendered below recomputes instead of freezing at the value it * had when the row first mounted. */ relativeTimeTick?: number; /** * 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. */ children?: React.ReactNode; }; const areNodeWorktreeRenderSemanticsEqual = (prev: SessionNode, next: SessionNode): boolean => ( normalizePath(prev.worktree?.path ?? null) === normalizePath(next.worktree?.path ?? null) && prev.worktree?.branch === next.worktree?.branch ); // Shared row geometry: the gutter edge matches the zone-header band padding // (px-1.5 = 6px), the marker slot is icon-wide (14px) with a 6px gap, so row // text starts exactly where the zone-header label starts. Nested children // shift by one gutter step per depth level. const ROW_GUTTER_LEFT_PX = 6; const ROW_DEPTH_STEP_PX = 14; const ROW_TEXT_LEFT_PX = ROW_GUTTER_LEFT_PX + 14 + 6; const cancelScrollAnchorByContainer = new WeakMap void>(); const holdSessionRowPosition = (target: HTMLElement): void => { const row = target.closest('[data-session-row]'); const container = row?.closest('.overlay-scrollbar-container'); if (!row || !container) return; cancelScrollAnchorByContainer.get(container)?.(); const initialTop = row.getBoundingClientRect().top; let remainingFrames = 3; let cancelled = false; let frameId: number | null = null; const cancel = () => { cancelled = true; if (frameId !== null) window.cancelAnimationFrame(frameId); frameId = null; cancelScrollAnchorByContainer.delete(container); container.removeEventListener('wheel', cancel); container.removeEventListener('touchstart', cancel); }; const restore = () => { if (cancelled || !row.isConnected || !container.isConnected) { cancel(); return; } const delta = row.getBoundingClientRect().top - initialTop; if (Math.abs(delta) > 0.5) { container.scrollTop += delta; streamPerfCount('ui.sidebar.selection_scroll_anchor_adjustment'); } remainingFrames -= 1; if (remainingFrames <= 0) { cancel(); return; } frameId = window.requestAnimationFrame(restore); }; container.addEventListener('wheel', cancel, { passive: true }); container.addEventListener('touchstart', cancel, { passive: true }); cancelScrollAnchorByContainer.set(container, cancel); frameId = window.requestAnimationFrame(restore); }; type QuickSessionActionProps = { archiveLabel: string; deleteLabel: string; buttonSizeClass: string; iconSizeClass: string; onPointerDown: (event: React.PointerEvent) => void; onMouseDown: (event: React.MouseEvent) => void; onArchive: (event: React.MouseEvent) => void; onDelete: (event: React.MouseEvent) => void; }; // Extracted so only this small button re-renders when Shift is pressed/released, // instead of every mounted session row. const QuickSessionAction = React.memo(function QuickSessionAction({ archiveLabel, deleteLabel, buttonSizeClass, iconSizeClass, onPointerDown, onMouseDown, onArchive, onDelete, }: QuickSessionActionProps): React.ReactNode { const shiftHeld = useShiftKeyHeld(); const label = shiftHeld ? deleteLabel : archiveLabel; const handleClick = (event: React.MouseEvent) => { if (shiftHeld || event.shiftKey) { onDelete(event); return; } onArchive(event); }; return ( {label} ); }); function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode { streamPerfCount('ui.sidebar_session_node.render'); const { t } = useI18n(); const { node, depth = 0, groupDirectory, projectId, archivedBucket = false, pinnedSessionIds, expandedParents, hasSessionSearchQuery, normalizedSessionSearchQuery, notifyOnSubtasks, editingId, setEditingId, editTitle, setEditTitle, handleSaveEdit, handleCancelEdit, toggleParent, handleSessionSelect, handleSessionDoubleClick, handleShareSession, copiedSessionId, handleCopyShareUrl, handleCopySessionId, handleUnshareSession, openSidebarMenuKey, setOpenSidebarMenuKey, createFolderAndStartRename, handleDeleteSession, handleRestoreSession, startSessionWorktreeMenuLoad, mobileVariant, alwaysShowActions, secondaryMeta, renderContext = 'project', children, } = props; const togglePinnedSession = useSessionPinnedStore((state) => state.toggle); const getFoldersForScope = useSessionFoldersStore((state) => state.getFoldersForScope); const getSessionFolderId = useSessionFoldersStore((state) => state.getSessionFolderId); const removeSessionFromFolder = useSessionFoldersStore((state) => state.removeSessionFromFolder); const addSessionToFolder = useSessionFoldersStore((state) => state.addSessionToFolder); const openContextPanelTab = useUIStore((state) => state.openContextPanelTab); 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 = isVSCode // VS Code 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') // Reserve just enough room for the hover-revealed actions (two 16px // buttons + gap, anchored at the row edge past the title's own end) so // they never overlap the title without leaving a large hole. : (showQuickArchiveAction ? 'group-hover:pr-7 group-focus-within:pr-7' : 'group-hover:pr-3 group-focus-within:pr-3'); 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 pendingFolderCreateRef = React.useRef(false); 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; const resolvedSession = session; // Tooltip context: recent rows receive project/branch via secondaryMeta; // project rows resolve them from the row's own props/node instead. const projectLabelFromStore = useProjectsStore( React.useCallback((state) => { if (secondaryMeta?.projectLabel || !projectId) return null; const project = state.projects.find((entry) => entry.id === projectId); if (!project) return null; return project.label?.trim() || formatDirectoryName(normalizePath(project.path) ?? project.path, null) || project.path; }, [projectId, secondaryMeta?.projectLabel]), ); const tooltipProjectLabel = secondaryMeta?.projectLabel ?? (projectLabelFromStore ? formatProjectLabel(projectLabelFromStore) : null); const tooltipBranchLabel = secondaryMeta?.branchLabel ?? node.worktree?.branch ?? null; const prLookupKey = React.useMemo(() => { if (isVSCode) return null; const branch = node.worktree?.branch?.trim(); const directory = normalizePath(node.worktree?.path ?? null); return branch && directory ? getGitHubPrStatusKey(directory, branch) : null; }, [isVSCode, node.worktree]); const prSummary = usePrVisualSummary(prLookupKey); const prIconColor = prSummary ? `var(--pr-${prSummary.visualState})` : undefined; const sessionGroupingMode = useSessionDisplayStore((state) => state.sessionGroupingMode); // In by-worktree grouping the project tree already shows the branch on the // group sub-header, so the per-row marker only appears in flat mode and in // the mixed-context recent list. const showInlineBranchMarker = Boolean(tooltipBranchLabel) && (renderContext === 'recent' || sessionGroupingMode === 'flat'); const prStatusLabel = React.useMemo(() => { if (!prSummary) return null; switch (prSummary.visualState) { case 'merged': return t('sessions.sidebar.group.pr.status.merged'); case 'open': return (prSummary.canMerge === true || prSummary.mergeableState === 'clean' || prSummary.checks?.state === 'success') ? t('sessions.sidebar.group.pr.status.readyToMerge') : t('sessions.sidebar.group.pr.status.open'); case 'blocked': return prSummary.mergeableState === 'dirty' ? t('sessions.sidebar.group.pr.status.mergeConflicts') : t('sessions.sidebar.group.pr.status.mergeBlocked'); case 'draft': return t('sessions.sidebar.group.pr.status.draft'); case 'closed': return t('sessions.sidebar.group.pr.status.closed'); default: return null; } }, [prSummary, t]); const isActive = useSessionUIStore((state) => state.currentSessionId === session.id); const sessionDirectory = normalizePath(session.directory ?? null) ?? normalizePath(groupDirectory ?? null); // Multi-select scope: sessions are flat per project, so selection groups by // project (falling back to the directory when no project is known) โ€” a // selection must survive mixing sessions from different worktrees. const selectionScopeKey = projectId ?? sessionDirectory ?? null; const loadExportRecords = useSessionMessageRecordsForExport(); const prefetchSessionMessages = usePrefetchSessionMessages(); // Same gate as the sidebar's neighbor prefetch: the VS Code webview keeps // its message traffic to what is actually opened. const prefetchOnPressDisabled = isVSCode; 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 collectNodeDescendantSessions = React.useCallback((root: SessionNode): Session[] => { const out: Session[] = []; const walk = (current: SessionNode) => { current.children.forEach((child) => { out.push(child.session); 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 statusType = sessionStatus?.type ?? 'idle'; const isStreaming = statusType === 'busy' || statusType === 'retry'; // Read as a boolean, not as the value: the row must not re-render on every // tick of the counter it only decides to mount. const hasActivityDuration = useHasSessionActivityDuration(session.id, isStreaming); const isMovingToWorktree = useIsSessionWorktreeMovePending(session.id); const currentWorktreeMetadata = node.worktree ?? useSessionUIStore.getState().getWorktreeMetadata(session.id) ?? null; const [worktreeTargets, setWorktreeTargets] = React.useState([]); const [worktreeTargetsLoading, setWorktreeTargetsLoading] = React.useState(false); const [worktreeTargetsLoadFailed, setWorktreeTargetsLoadFailed] = React.useState(false); const worktreeSubmenuOpenRef = React.useRef(false); const worktreeLoadSequenceRef = React.useRef(0); const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined, { bootstrap: false }); const sessionGoal = getSessionGoal(resolvedSession); const sessionGoalGlyph = sessionGoal ? ( // SAFETY: sessionGoalStatusLabelKey contains an i18n key for every SessionGoalStatus. ) : null; const sessionTitle = resolvedSession.title || t('sessions.sidebar.session.untitled'); const hasChildren = node.children.length > 0; const isPinnedSession = isSessionPinned(pinnedSessionIds, sessionDirectory, 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 questionBadgeSessionScopes = React.useMemo( () => selectQuestionBadgeSessionScopes(node, isExpanded, sessionDirectory), [isExpanded, node, sessionDirectory], ); const pendingQuestionCount = useSessionQuestionCount(questionBadgeSessionScopes); const isSubtaskSession = Boolean(resolvedSession.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 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 { if (!sessionDirectory) throw new Error('Session directory is required for export'); const childRecords = await loadExportRecords({ directory: sessionDirectory, sessionID: child.session.id }); if (!childRecords) throw new Error('Session runtime changed during export'); const childTitle = child.session.title || t('sessions.sidebar.session.export.untitledSubagent'); // SAFETY: OpenCode session payloads may carry the optional agent label used by exports. 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, loadExportRecords, sessionDirectory, 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; } const records = await loadExportRecords({ directory: sessionDirectory, sessionID: session.id }).catch(() => null); if (!records) { toast.error(t('sessions.sidebar.session.export.failedLoadHistory')); return; } 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, loadExportRecords, node.children, resolvedSession.title, session.id, sessionDirectory, showSkippedSubtasksWarning, 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) => { // The same session can be rendered twice (recent + project), each with // its own rename form. A click inside ANY rename form for this session // must not count as "outside", or the sibling instance would save and // exit the rename mid-edit. // SAFETY: DOM mousedown targets are Nodes; closest is used only when the target is an Element. const target = e.target instanceof HTMLElement ? e.target : null; const withinRenameForm = target?.closest?.(`[data-session-rename-form="${CSS.escape(session.id)}"]`); if (formRef.current && !withinRenameForm) { 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 (
{ 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; } }} />
); } const pendingPermissionCount = sessionPermissions.length; const pendingQuestionLabel = pendingQuestionCount === 1 ? t('sessions.sidebar.session.status.questionPendingSingle') : t('sessions.sidebar.session.status.questionPendingMany', { count: pendingQuestionCount }); // Actions are permanently visible (with matching permanent padding) only in // the non-VSCode alwaysShowActions layout; every other layout hover-reveals // them over the row's right edge, where the badges live (#2284). const badgeVisibilityClass = selectRowBadgeVisibilityClass({ actionsAlwaysVisible: alwaysShowActions && !isVSCode, menuOpen: isSessionMenuOpen, hideOnHoverClass, }); const showUnreadStatus = !isMovingToWorktree && !isStreaming && needsAttention && !isActive; const showStatusMarker = isStreaming || showUnreadStatus; // Both states are the same static dot; only the color separates "running" // from "unread". The elapsed-turn readout on the right carries the motion // that a spinner used to, at one repaint per second instead of per frame. const statusMarkerLabel = isStreaming ? t('sessions.sidebar.session.status.active') : t('sessions.sidebar.session.status.unread'); const statusMarkerContent = ( ); // The settled duration lives exactly as long as the unread marker does, so a // session read (or watched) while it finishes never keeps a stale total. const showActivityDuration = (isStreaming || showUnreadStatus) && hasActivityDuration; const hideLeadingIndicatorOnHover = !alwaysShowActions && hasChildren && (isMovingToWorktree || showStatusMarker || isPinnedSession); const showPinnedMarker = isPinnedSession && !isMovingToWorktree && !showStatusMarker; const pinnedMarkerContent = ( ); const leadingIndicators = isMovingToWorktree || showStatusMarker || showPinnedMarker ? ( {isMovingToWorktree ? ( ) : showStatusMarker ? statusMarkerContent : showPinnedMarker ? pinnedMarkerContent : null} ) : null; const hideChevronUntilHover = hasChildren && !alwaysShowActions && (isMovingToWorktree || showStatusMarker || isPinnedSession); const subsessionChevron = hasChildren ? ( { event.stopPropagation(); // Blur mouse-click focus so the hover-only chevron/indicator swap // resets on mouse-leave instead of sticking via :focus-within. event.currentTarget.blur(); toggleParent(expansionKey); }} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); event.stopPropagation(); toggleParent(expansionKey); } }} style={{ minWidth: 14, minHeight: 14, left: ROW_GUTTER_LEFT_PX + depth * ROW_DEPTH_STEP_PX }} className={cn( 'absolute top-1/2 inline-flex h-3.5 w-3.5 -translate-y-1/2 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', hideChevronUntilHover ? '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 handleQuickDeleteClick = (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); setOpenSidebarMenuKey(null); handleDeleteSession(session, { archivedBucket, hardDelete: true, skipConfirm: true }); }; 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 = Array.from(document.querySelectorAll('[data-session-row]')); const orderedIds = rows .map((el) => el.getAttribute('data-session-row')) .filter((id): id is string => id !== null && id.length > 0); const currentAnchor = useSessionMultiSelectStore.getState().anchorId; const descendantsById = new Map(); descendantsById.set(session.id, collectNodeDescendantIds(node)); setRowRange(currentAnchor, session.id, orderedIds, selectionScopeKey, descendantsById); return; } toggleRowSelected(session.id, selectionScopeKey, collectNodeDescendantIds(node)); return; } if (event?.currentTarget) holdSessionRowPosition(event.currentTarget); handleSessionSelect(session.id, sessionDirectory); }; // The selection/active highlight covers the WHOLE row box (gutter, edge // paddings), while the primary click target is the inner title button. // Make the rest of the highlighted box clickable too โ€” but only for clicks // that did not originate from an interactive child (title button, chevron, // action menu), so nothing double-fires. const handleRowBackgroundClick = (event: React.MouseEvent) => { if (event.defaultPrevented) return; // SAFETY: React click targets are DOM EventTargets; closest is valid only for HTMLElements. const target = event.target instanceof HTMLElement ? event.target : null; if (target?.closest('button, a, input, [role="menuitem"], [role="menu"]')) return; handleRowSelect(event); }; 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); } // The press is the earliest signal that this row is about to be opened. // Starting the message load here puts the request on the wire before the // click handler and the render it triggers, so a cold open overlaps the // network round trip with that work instead of waiting for it. if ( event.button === 0 && !isActive && !selectionModeEnabled && !prefetchOnPressDisabled && sessionDirectory && !getSyncSessionMaterializationStatus(session.id, sessionDirectory).renderable ) { void prefetchSessionMessages({ directory: sessionDirectory, sessionID: session.id }).catch(() => undefined); } }; const handleRowPointerEnd = (event: React.PointerEvent) => { if (mobileVariant && event.pointerType === 'touch') { setIsTouchPressed(false); } }; const handleWorktreeSubmenuOpenChange = (open: boolean) => { worktreeSubmenuOpenRef.current = open; worktreeLoadSequenceRef.current += 1; const loadSequence = worktreeLoadSequenceRef.current; if (!open) { setWorktreeTargetsLoading(false); setWorktreeTargetsLoadFailed(false); return; } const load = startSessionWorktreeMenuLoad({ projectId: projectId ?? null, sourceDirectory: sessionDirectory, currentWorktree: currentWorktreeMetadata, }); setWorktreeTargets(load.cachedTargets); setWorktreeTargetsLoading(true); setWorktreeTargetsLoadFailed(false); void load.refreshTargets .then((freshTargets) => { if (!worktreeSubmenuOpenRef.current || worktreeLoadSequenceRef.current !== loadSequence) { return; } setWorktreeTargets(freshTargets); setWorktreeTargetsLoading(false); setWorktreeTargetsLoadFailed(false); }) .catch(() => { if (!worktreeSubmenuOpenRef.current || worktreeLoadSequenceRef.current !== loadSequence) { return; } setWorktreeTargetsLoading(false); setWorktreeTargetsLoadFailed(true); }); }; 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')} handleCopySessionId(session.id)} className="[&>svg]:mr-1"> {t('sessions.sidebar.session.menu.copyId')} sessionDirectory && togglePinnedSession({ directory: sessionDirectory, sessionId: 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')} {canShowSessionWorktreeMenu({ isSubtaskSession, archivedBucket: Boolean(archivedBucket), isVSCode, sessionDirectory }) ? (() => { const isWorktreeMenuDisabled = getSessionWorktreeMenuDisabled({ sessionDirectory, isStreaming, isMovingToWorktree, }); const worktreeMenuState = getSessionWorktreeMenuState({ targets: worktreeTargets, isRefreshing: worktreeTargetsLoading, loadFailed: worktreeTargetsLoadFailed, }); return ( {t('sessions.sidebar.session.menu.moveToWorktreeTargets')} {isMovingToWorktree ? t('sessions.sidebar.session.moveToWorktree.tooltipMoving') : isStreaming ? t('sessions.sidebar.session.moveToWorktree.tooltipBusy') : t('sessions.sidebar.session.moveToWorktree.tooltipTargets')} {worktreeTargets.map((target) => { const targetPath = normalizePath(target.metadata.path ?? null) ?? target.metadata.path; const itemLabel = target.isPrimary ? t('sessions.sidebar.session.moveToWorktree.main') : (target.metadata.label || target.metadata.branch || target.metadata.name || target.metadata.path); const isDisabled = target.isCurrent || target.metadata.worktreeStatus !== 'ready'; return ( { if (isDisabled || !sessionDirectory) { return; } requestSessionTreeMove({ kind: 'existing', root: resolvedSession, descendants: collectNodeDescendantSessions(node), sourceDirectory: sessionDirectory, destination: target.metadata, messages: buildSessionTreeMoveMessages(t, { success: 'sessions.sidebar.session.moveToWorktree.existingSuccess', failure: 'sessions.sidebar.session.moveToWorktree.existingFailed', }), }); }} > {itemLabel} {target.isCurrent ? {t('sessions.sidebar.session.moveToWorktree.current')} : null} {target.isCurrent ? ); })} {worktreeMenuState.refreshState === 'loading' ? ( {t('sessions.sidebar.session.moveToWorktree.refreshing')} ) : null} {worktreeMenuState.refreshState === 'error' ? ( {t('sessions.sidebar.session.moveToWorktree.loadFailed')} ) : null} {worktreeMenuState.showNewWorktreeAction ? ( { if (isWorktreeMenuDisabled || !sessionDirectory) return; requestSessionTreeMove({ kind: 'quick', root: resolvedSession, descendants: collectNodeDescendantSessions(node), sourceDirectory: sessionDirectory, messages: buildSessionTreeMoveMessages(t, { success: 'sessions.sidebar.session.moveToWorktree.success', failure: 'sessions.sidebar.session.moveToWorktree.failed', }), }); }} className="[&>svg]:mr-1" > {t('sessions.sidebar.session.menu.newWorktree')} ) : null} ); })() : null} {isMultiRunLikeSession ? ( setFusionDialogOpen(true)} className="[&>svg]:mr-1"> {t('sessions.sidebar.session.menu.runFusion')} ) : null} {sessionDirectory && !archivedBucket ? (() => { // Folders are flat per project: list folders from every scope of the // owning project (root + all worktrees) so sessions can be filed // across worktrees. Each action targets the folder's owning scope, // and moving between scopes clears the previous membership first. const scopes: string[] = []; const pushScope = (candidate: string | null | undefined) => { const normalized = normalizePath(candidate ?? null); if (normalized && !scopes.includes(normalized)) scopes.push(normalized); }; if (projectId && !isVSCode) { const project = useProjectsStore.getState().projects.find((entry) => entry.id === projectId); const projectRoot = normalizePath(project?.path ?? null); pushScope(projectRoot); if (projectRoot) { (useSessionUIStore.getState().availableWorktreesByProject.get(projectRoot) ?? []) .forEach((worktree) => pushScope(worktree.path)); } } pushScope(getChatsRootFromDirectory(sessionDirectory)); pushScope(sessionDirectory); const folderEntries = scopes.flatMap((scope) => getFoldersForScope(scope).map((folder) => ({ scope, folder }))); const currentEntry = folderEntries.find(({ scope, folder }) => getSessionFolderId(scope, session.id) === folder.id) ?? null; const defaultScope = scopes[0] ?? sessionDirectory; return ( <> {t('sessions.sidebar.folders.moveToFolder')} {folderEntries.length === 0 ? ( {t('sessions.sidebar.folders.none')} ) : ( folderEntries.map(({ scope, folder }) => { const isCurrent = currentEntry?.folder.id === folder.id; return ( { if (isCurrent) { removeSessionFromFolder(scope, session.id); return; } if (currentEntry && currentEntry.scope !== scope) { removeSessionFromFolder(currentEntry.scope, session.id); } addSessionToFolder(scope, folder.id, session.id); }}> {folder.name} {isCurrent ? : null} ); }) )} { const newFolder = createFolderAndStartRename(defaultScope); if (!newFolder) return; pendingFolderCreateRef.current = true; if (currentEntry && currentEntry.scope !== defaultScope) { removeSessionFromFolder(currentEntry.scope, session.id); } addSessionToFolder(defaultScope, newFolder.id, session.id); }}> {t('sessions.sidebar.folders.newFolderEllipsis')} {currentEntry ? ( { removeSessionFromFolder(currentEntry.scope, 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, sessionTitleFallback: 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} {archivedBucket ? ( handleRestoreSession(session)}> {t('sessions.sidebar.bulkActions.restore')} ) : null} handleDeleteSession(session, { archivedBucket, hardDelete: true })}> {t('sessions.sidebar.bulkActions.delete')} ); const sessionMenuContent = ( { if (pendingFolderCreateRef.current) { pendingFolderCreateRef.current = false; return false; } return editingIdRef.current ? false : true; }}> {renderSessionMenuItems({ Item: DropdownMenuItem, Separator: DropdownMenuSeparator, Sub: DropdownMenuSub, SubTrigger: DropdownMenuSubTrigger, SubContent: DropdownMenuSubContent, })} ); const contextMenuContent = ( { if (pendingFolderCreateRef.current) { pendingFolderCreateRef.current = false; return false; } return editingIdRef.current ? false : true; }} style={{ 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 ( } > {leadingIndicators} {subsessionChevron}
{( {/* VS Code already shows project context via workspace headers, so the per-row metadata tooltip is redundant noise there. */} {!isVSCode ? (
{sessionTitle} {sessionCompactUpdatedLabel}
{tooltipProjectLabel ? (
{tooltipProjectLabel}
) : null} {tooltipBranchLabel ? (
{tooltipBranchLabel}
) : null} {prSummary && prStatusLabel ? (
#{prSummary.number} ยท {prStatusLabel}
) : null}
) : null}
)}
{streamingIndicator && !mobileVariant ? (
{streamingIndicator}
) : null}
{showQuickArchiveAction ? ( ) : null} {showOpenInEditorAction ? ( {t('sessions.sidebar.session.actions.openInEditor')} ) : null} {sessionMenuContent}
{contextMenuContent}
{hasChildren && isExpanded ? children : 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.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: SessionNodeItemProps): 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: SessionNodeItemProps): string | null => { return props.menuOpenSessionId ?? getMenuSessionIdFromKey(props); }; const subtreeContainsSession = ( props: SessionNodeItemProps, 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 hasExpansionMembershipChange = (prev: SessionNodeItemProps, next: SessionNodeItemProps): 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 areSessionRenderSemanticsEqual = (prev: Session, next: Session): boolean => ( prev.id === next.id && prev.title === next.title && prev.directory === next.directory && prev.parentID === next.parentID && prev.share?.url === next.share?.url && prev.time?.created === next.time?.created && prev.time?.updated === next.time?.updated && prev.time?.archived === next.time?.archived ); // Returns the name of the first prop whose change requires a render, or null // when the row can skip it. The name feeds the stream perf counters so sidebar // churn is explained, not only counted. const sessionNodeItemPropsChange = (prev: SessionNodeItemProps, next: SessionNodeItemProps): string | null => { if (prev.node.session.id !== next.node.session.id) return 'node'; if (!areSessionRenderSemanticsEqual(prev.node.session, next.node.session)) return 'node'; if (!areNodeWorktreeRenderSemanticsEqual(prev.node, next.node)) return 'node'; if (prev.depth !== next.depth) return 'depth'; if (prev.groupDirectory !== next.groupDirectory) return 'groupDirectory'; if (prev.projectId !== next.projectId) return 'projectId'; if (prev.archivedBucket !== next.archivedBucket) return 'archivedBucket'; if ((prev.renderContext ?? 'project') !== (next.renderContext ?? 'project')) return 'renderContext'; if (prev.mobileVariant !== next.mobileVariant) return 'mobileVariant'; if (prev.alwaysShowActions !== next.alwaysShowActions) return 'alwaysShowActions'; if (prev.hasSessionSearchQuery !== next.hasSessionSearchQuery) return 'hasSessionSearchQuery'; if (prev.normalizedSessionSearchQuery !== next.normalizedSessionSearchQuery) return 'normalizedSessionSearchQuery'; if (prev.notifyOnSubtasks !== next.notifyOnSubtasks) return 'notifyOnSubtasks'; if (prev.nodeStructureKey !== next.nodeStructureKey) return 'nodeStructureKey'; if (prev.relativeTimeTick !== next.relativeTimeTick) return 'relativeTimeTick'; if (getNodeSessionDirectory(prev.node) !== getNodeSessionDirectory(next.node)) return 'nodeDirectory'; if (!isSecondaryMetaEqual(prev.secondaryMeta, next.secondaryMeta)) return 'secondaryMeta'; if (prev.pinnedSessionIds !== next.pinnedSessionIds && nodeHasPinnedMembershipChange( prev.node, next.node, prev.pinnedSessionIds, next.pinnedSessionIds, prev.groupDirectory, next.groupDirectory, )) { return 'pinnedSessionIds'; } if (prev.expandedParents !== next.expandedParents && hasExpansionMembershipChange(prev, next)) { return 'expandedParents'; } if (prev.editingId !== next.editingId && ( subtreeContainsSession(prev, prev.editingId, prev.subtreeContainsEditing) || subtreeContainsSession(next, next.editingId, next.subtreeContainsEditing) )) { return 'editingId'; } if (prev.editTitle !== next.editTitle && ( subtreeContainsSession(prev, prev.editingId, prev.subtreeContainsEditing) || subtreeContainsSession(next, next.editingId, next.subtreeContainsEditing) )) { return 'editTitle'; } if (prev.copiedSessionId !== next.copiedSessionId && ( nodeContainsSessionId(prev.node, prev.copiedSessionId) || nodeContainsSessionId(next.node, next.copiedSessionId) )) { return 'copiedSessionId'; } if (prev.openSidebarMenuKey !== next.openSidebarMenuKey) { const prevMenuSessionId = getRelevantMenuSessionId(prev); const nextMenuSessionId = getRelevantMenuSessionId(next); if (nodeContainsSessionId(prev.node, prevMenuSessionId) || nodeContainsSessionId(next.node, nextMenuSessionId)) { return 'openSidebarMenuKey'; } } const callbacksEqual = 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.handleShareSession === next.handleShareSession && prev.handleCopyShareUrl === next.handleCopyShareUrl && prev.handleCopySessionId === next.handleCopySessionId && prev.handleUnshareSession === next.handleUnshareSession && prev.setOpenSidebarMenuKey === next.setOpenSidebarMenuKey && prev.createFolderAndStartRename === next.createFolderAndStartRename && prev.handleDeleteSession === next.handleDeleteSession && prev.handleRestoreSession === next.handleRestoreSession && prev.startSessionWorktreeMenuLoad === next.startSessionWorktreeMenuLoad && prev.children === next.children; if (!callbacksEqual) return 'callbacks'; return null; }; const areSessionNodeItemPropsEqual = (prev: SessionNodeItemProps, next: SessionNodeItemProps): boolean => { const changed = sessionNodeItemPropsChange(prev, next); if (changed === null) return true; streamPerfCount(`ui.sidebar_session_node.props_changed.${changed}`); return false; }; export const SessionNodeItem = React.memo(SessionNodeItemComponent, areSessionNodeItemPropsEqual);