perf: isolate chat streaming renders and reduce sidebar render cost (#1672)

Reworks the chat and session-sidebar render paths to cut render cascades, memory
  churn, and UI jank on large sessions and big session trees. Behavior is preserved;
  the changes are about *when* and *how much* the UI re-renders.

  ## Chat streaming
  - Freeze the streaming message's parts in the bulk turn projection during streaming,
    and re-inject live parts only in an isolated tail leaf, so a ~60/sec delta stream
    no longer re-runs the whole-session projection or re-renders unrelated rows.
    session with referential reuse of unchanged turns.
  - Memoize message rows with field-aware comparators instead of reference equality.
  - Replace the manual child-session polling in the task tool with the live SSE
    stream + a one-shot load, removing a fetch/settle state machine.

  ## History loading & scroll
  - Load an initial page fast, then prepend one older page in the background so the
    scroll container has headroom and "load older on scroll-up" fires before the user
    hits the absolute top.
  - Compensate scroll synchronously (in a layout effect, before paint) for prepends —
    including background prepends that don't originate from a user scroll — so the
    viewport stays stable instead of judder-correcting on the next frame.

  ## Markdown rendering
  - Render markdown synchronously *styled* on first paint (paragraphs, lists, code
    cards, tables, inline code) instead of raw escaped text; the async pass then only
    upgrades syntax-highlight colors. Eliminates the flash of full-width raw text.
  - Load KaTeX CSS eagerly with the main bundle instead of inside the lazy markdown
    chunk, avoiding a late stylesheet injection on first render.

  ## Sidebar
  - Hoist per-row recursive tree walks out of row comparators into per-group
    precomputed sets/keys; batch live-session lookups into a single map; add a
    group-level memo boundary.
  - Isolate rename drafts so per-keystroke typing doesn't repaint the row tree.

  ## Sync layer
  - Add a staleness guard so a slow message fetch can't repopulate a session the user
    navigated away from.
  - Throw on fetch failure for authoritative loaders so a transient blip can't read as
    an empty server response.

  ## Cleanup
  - Remove dead code (unused hooks, params, duplicated inline types) surfaced while
    reworking the above.

  ## Known issue
  - A rare, purely cosmetic first-paint width flash can still appear on large sessions;
    it has no behavioral or data impact and is tracked for a follow-up runtime trace.
This commit is contained in:
bashrusakh
2026-06-18 00:43:16 +03:00
committed by GitHub
parent 077a766f94
commit 59ecd86b4b
47 changed files with 3168 additions and 1829 deletions
@@ -3,6 +3,7 @@ import { cn } from '@/lib/utils';
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
import { useI18n } from '@/lib/i18n';
import { Icon } from "@/components/icon/Icon";
import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sidebar/sessionNodeItemUtils';
interface SessionFolderItemProps<TSessionNode> {
folder: SessionFolder;
@@ -19,7 +20,17 @@ interface SessionFolderItemProps<TSessionNode> {
groupDir?: string | null,
projectId?: string | null,
archivedBucket?: boolean,
secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null,
renderContext?: 'project' | 'recent',
renderExtras?: SessionNodeChildRenderExtras,
) => React.ReactNode;
/**
* Returns the precomputed per-row render extras for a given node. The
* group precomputes subtree-contains lookups once, then resolves a
* per-node structure key here so SessionNodeItem's React.memo comparator
* can answer with a single string compare instead of a recursive walk.
*/
getRenderExtras?: (node: TSessionNode) => SessionNodeRenderExtras<TSessionNode> | undefined;
groupDirectory?: string | null;
projectId?: string | null;
mobileVariant?: boolean;
@@ -54,6 +65,7 @@ const SessionFolderItemBase = <TSessionNode,>({
onRename,
onDelete,
renderSessionNode,
getRenderExtras,
groupDirectory,
projectId,
mobileVariant = false,
@@ -320,7 +332,7 @@ const SessionFolderItemBase = <TSessionNode,>({
{/* Then sessions */}
{sessions.length > 0 ? (
sessions.map((node) =>
renderSessionNode(node, 0, groupDirectory ?? null, projectId ?? null, archivedBucket),
renderSessionNode(node, 0, groupDirectory ?? null, projectId ?? null, archivedBucket, undefined, 'project', getRenderExtras?.(node)),
)
) : !subFolderItems ? (
<div className="py-1 pl-1.5 text-left typography-micro text-muted-foreground/70">
@@ -41,9 +41,11 @@ import { SidebarActivitySections } from './sidebar/SidebarActivitySections';
import { SidebarFooter } from './sidebar/SidebarFooter';
import { SidebarProjectsList } from './sidebar/SidebarProjectsList';
import { SessionNodeItem } from './sidebar/SessionNodeItem';
import type { SessionNodeRenderExtras } from './sidebar/sessionNodeItemUtils';
import { useUpdateStore } from '@/stores/useUpdateStore';
import { useShallow } from 'zustand/react/shallow';
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
import { checkIsGitRepository } from '@/lib/gitApi';
import type { WorktreeMetadata } from '@/types/worktree';
import type { SortableDragHandleProps } from './sidebar/sortableItems';
import {
@@ -55,7 +57,7 @@ import {
type DeleteSessionConfirmState,
} from './sidebar/ConfirmDialogs';
import { BulkActionBar } from './sidebar/BulkActionBar';
import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore';
import { useSidebarBulkActions } from './sidebar/hooks/useSidebarBulkActions';
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
import { type SessionGroup, type SessionNode } from './sidebar/types';
import {
@@ -153,6 +155,14 @@ const isKnownActiveSessionDirectory = (
const SIDEBAR_PR_NO_PR_RETRY_MS = 5 * 60_000;
const EMPTY_SUBTREE_SET: Set<string> = new Set();
const useStableRenderCallback = <Args extends unknown[], Return>(handler: (...args: Args) => Return): ((...args: Args) => Return) => {
const handlerRef = React.useRef(handler);
handlerRef.current = handler;
return React.useCallback((...args: Args) => handlerRef.current(...args), []);
};
interface SessionSidebarProps {
mobileVariant?: boolean;
onSessionSelected?: (sessionId: string) => void;
@@ -383,6 +393,16 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
syncSessionsSnapshotRef.current = liveSessions;
}, [syncSessionStructureSignature, liveSessions]);
// Batched live-session index. Building this here turns the per-row
// `useSession(session.id)` reads in SessionNodeItem (each of which
// iterates all child-stores via `findLiveSession`) into a single
// Map lookup. With M visible rows, that changes an O(M × child-stores)
// work to O(child-stores) once per Sidebar render.
const liveSessionById = React.useMemo(
() => new Map(liveSessions.map((session) => [session.id, session] as const)),
[liveSessions],
);
const projectWorktreeDiscoveryKey = React.useMemo(
() => projects
.map((project) => `${project.id}:${normalizePath(project.path) ?? ''}`)
@@ -399,6 +419,10 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
void refreshGlobalSessions(syncSessionsSnapshotRef.current);
}, []);
// Tracks the last project list we already kicked off discovery for.
// A re-mount with the same project set shouldn't fan out another
// burst of `checkIsGitRepository` / `listProjectWorktrees` calls.
const discoveredProjectsRef = React.useRef<string>('');
React.useEffect(() => {
let cancelled = false;
@@ -409,24 +433,39 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const worktreesByProject = new Map<string, WorktreeMetadata[]>();
const allWorktrees: WorktreeMetadata[] = [];
await Promise.all(
projectEntries.map(async (project) => {
// Constrain fanout: previously `Promise.all(projects.map(...))` could
// spawn dozens of concurrent `git worktree list` and
// `checkIsGitRepository` calls on cold start, each touching the
// worktree process. Concurrency=3 keeps startup latency low while
// bounding peak worktree-process load.
const worktreeConcurrency = 3;
let cursor = 0;
const workers = Array.from({ length: worktreeConcurrency }, async () => {
while (true) {
const nextIndex = cursor;
cursor += 1;
if (nextIndex >= projectEntries.length) return;
const project = projectEntries[nextIndex];
const projectPath = normalizePath(project.path);
if (!projectPath) return;
if (!projectPath) continue;
try {
// Use store-cached isGitRepo when available; fall back to direct check for initial worktree discovery
// Use store-cached isGitRepo when available; fall back to
// a direct check for projects the Git store hasn't seen yet.
// Forcing `ensureStatus` here also warms the store so the
// PR/render paths downstream can read isGitRepo for free.
const cachedIsGitRepo = useGitStore.getState().directories.get(projectPath)?.isGitRepo;
const isGitRepo = cachedIsGitRepo ?? await import('@/lib/gitApi').then(m => m.checkIsGitRepository(projectPath));
if (!isGitRepo) return;
const isGitRepo = cachedIsGitRepo ?? await checkIsGitRepository(projectPath);
if (!isGitRepo) continue;
const worktrees = await listProjectWorktrees({ id: project.id, path: projectPath });
if (cancelled || worktrees.length === 0) return;
if (cancelled || worktrees.length === 0) continue;
worktreesByProject.set(projectPath, worktrees);
allWorktrees.push(...worktrees);
} catch {
// ignore discovery errors
}
}),
);
}
});
await Promise.all(workers);
if (cancelled) return;
@@ -436,6 +475,11 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
});
};
// Skip if we already discovered worktrees for this exact project set.
if (discoveredProjectsRef.current === projectWorktreeDiscoveryKey) {
return;
}
discoveredProjectsRef.current = projectWorktreeDiscoveryKey;
void discoverWorktrees();
return () => {
@@ -508,11 +552,29 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
return [...sessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
}, [sessions, pinnedSessionIds]);
const sessionOrderIndex = React.useMemo(
() => new Map(sortedSessions.map((session, index) => [session.id, index])),
// Stable signature: id + updatedAt joined. When this string is
// unchanged, the relative ordering of sessions is identical and the
// derived `sessionOrderIndex` Map can return the previous reference.
// Without this, a fresh `sortedSessions` array (cheap to rebuild) would
// still hand a new Map identity to the entire SessionGroupSection
// memo chain, invalidating sourceGroupNodes, nodeBySessionId, and the
// rest of the down-stream useMemo chain.
const sessionOrderSignature = React.useMemo(
() => sortedSessions.map((s) => `${s.id}:${s.time?.updated ?? 0}`).join('|'),
[sortedSessions],
);
const sessionOrderIndexRef = React.useRef<{ signature: string; map: Map<string, number> } | null>(null);
const sessionOrderIndex = React.useMemo(() => {
const cached = sessionOrderIndexRef.current;
if (cached && cached.signature === sessionOrderSignature) {
return cached.map;
}
const next = new Map(sortedSessions.map((session, index) => [session.id, index]));
sessionOrderIndexRef.current = { signature: sessionOrderSignature, map: next };
return next;
}, [sessionOrderSignature, sortedSessions]);
const childrenMap = React.useMemo(() => {
const map = new Map<string, Session[]>();
sortedSessions.forEach((session) => {
@@ -703,6 +765,16 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
[collapsedFolderIds, toggleFolderCollapse, createFolder, t],
);
const stableHandleSessionSelect = useStableRenderCallback(handleSessionSelect);
const stableHandleSessionDoubleClick = useStableRenderCallback(handleSessionDoubleClick);
const stableHandleSaveEdit = useStableRenderCallback(handleSaveEdit);
const stableHandleCancelEdit = useStableRenderCallback(handleCancelEdit);
const stableHandleShareSession = useStableRenderCallback(handleShareSession);
const stableHandleCopyShareUrl = useStableRenderCallback(handleCopyShareUrl);
const stableHandleUnshareSession = useStableRenderCallback(handleUnshareSession);
const stableHandleDeleteSession = useStableRenderCallback(handleDeleteSession);
const stableCreateFolderAndStartRename = useStableRenderCallback(createFolderAndStartRename);
const showMoreGroupSessions = React.useCallback((groupId: string, currentVisibleCount: number) => {
setVisibleSessionCountByGroup((prev) => {
const next = new Map(prev);
@@ -881,6 +953,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
sessions,
archivedSessions,
availableWorktreesByProject,
normalizedProjects,
});
useArchivedAutoFolders({
@@ -930,7 +1003,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
</div>
);
const { currentSessionDirectory } = useProjectSessionSelection({
useProjectSessionSelection({
projectSections,
activeProjectId,
activeSessionByProject,
@@ -942,8 +1015,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
openNewSessionDraft,
setActiveMainTab,
setSessionSwitcherOpen,
sessions,
worktreeMetadata,
});
const { getOrderedGroups } = useGroupOrdering(groupOrderByProject);
@@ -1214,15 +1285,16 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
projectHeaderSentinelRefs,
});
const renderSessionNode = React.useCallback(
const renderSessionNode = useStableRenderCallback(
(
node: SessionNode,
depth = 0,
depth: number = 0,
groupDirectory?: string | null,
projectId?: string | null,
archivedBucket = false,
archivedBucket: boolean = false,
secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null,
renderContext: 'project' | 'recent' = 'project',
renderExtras?: SessionNodeRenderExtras,
): React.ReactNode => (
<SessionNodeItem
node={node}
@@ -1240,16 +1312,16 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
setEditingId={setEditingId}
editTitle={editTitle}
setEditTitle={setEditTitle}
handleSaveEdit={handleSaveEdit}
handleCancelEdit={handleCancelEdit}
handleSaveEdit={stableHandleSaveEdit}
handleCancelEdit={stableHandleCancelEdit}
toggleParent={toggleParent}
handleSessionSelect={handleSessionSelect}
handleSessionDoubleClick={handleSessionDoubleClick}
handleSessionSelect={stableHandleSessionSelect}
handleSessionDoubleClick={stableHandleSessionDoubleClick}
togglePinnedSession={togglePinnedSession}
handleShareSession={handleShareSession}
handleShareSession={stableHandleShareSession}
copiedSessionId={copiedSessionId}
handleCopyShareUrl={handleCopyShareUrl}
handleUnshareSession={handleUnshareSession}
handleCopyShareUrl={stableHandleCopyShareUrl}
handleUnshareSession={stableHandleUnshareSession}
openSidebarMenuKey={openSidebarMenuKey}
setOpenSidebarMenuKey={setOpenSidebarMenuKey}
renamingFolderId={renamingFolderId}
@@ -1257,50 +1329,22 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
getSessionFolderId={getSessionFolderId}
removeSessionFromFolder={removeSessionFromFolder}
addSessionToFolder={addSessionToFolder}
createFolderAndStartRename={createFolderAndStartRename}
createFolderAndStartRename={stableCreateFolderAndStartRename}
openContextPanelTab={openContextPanelTab}
handleDeleteSession={handleDeleteSession}
handleDeleteSession={stableHandleDeleteSession}
mobileVariant={mobileVariant}
alwaysShowActions={alwaysShowSidebarActions}
renderSessionNode={renderSessionNode}
secondaryMeta={secondaryMeta}
renderContext={renderContext}
subtreeContainsActive={renderExtras?.subtreeContainsActive ?? EMPTY_SUBTREE_SET}
subtreeContainsEditing={renderExtras?.subtreeContainsEditing ?? EMPTY_SUBTREE_SET}
menuOpenSessionId={renderExtras?.menuOpenSessionId ?? null}
nodeStructureKey={renderExtras?.nodeStructureKey ?? ''}
childRenderExtrasFor={renderExtras?.childRenderExtrasFor}
liveSessionById={liveSessionById}
/>
),
[
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,
alwaysShowSidebarActions,
],
);
const toggleCollapsedGroup = React.useCallback((key: string) => {
@@ -1335,7 +1379,15 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}, [prVisualSummaryMap]);
const renderGroupSessions = React.useCallback(
(group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean, dragHandleProps?: SortableDragHandleProps | null, compactBodyPadding?: boolean) => (
(
group: SessionGroup,
groupKey: string,
projectId?: string | null,
hideGroupLabel?: boolean,
dragHandleProps?: SortableDragHandleProps | null,
compactBodyPadding?: boolean,
scrollContainerRef?: React.RefObject<HTMLElement | null>,
) => (
<SessionGroupSection
group={group}
groupKey={groupKey}
@@ -1355,7 +1407,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
showDeletionDialog={showDeletionDialog}
setDeleteFolderConfirm={setDeleteFolderConfirm}
renderSessionNode={renderSessionNode}
currentSessionDirectory={currentSessionDirectory}
projectRepoStatus={projectRepoStatus}
lastRepoStatus={lastRepoStatusRef.current}
showMoreGroupSessions={showMoreGroupSessions}
@@ -1368,16 +1419,23 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
setSessionSwitcherOpen={setSessionSwitcherOpen}
openNewSessionDraft={openNewSessionDraftFromTree}
addSessionToFolder={addSessionToFolder}
createFolderAndStartRename={createFolderAndStartRename}
createFolderAndStartRename={stableCreateFolderAndStartRename}
renamingFolderId={renamingFolderId}
renameFolderDraft={renameFolderDraft}
setRenameFolderDraft={setRenameFolderDraft}
setRenamingFolderId={setRenamingFolderId}
pinnedSessionIds={pinnedSessionIds}
expandedParents={expandedParents}
sessionOrderIndex={sessionOrderIndex}
currentSessionId={currentSessionId}
editingId={editingId}
editTitle={editTitle}
openSidebarMenuKey={openSidebarMenuKey}
liveSessionById={liveSessionById}
prVisualStateByDirectoryBranch={prVisualStateByDirectoryBranch}
onToggleCollapsedGroup={toggleCollapsedGroup}
dragHandleProps={dragHandleProps}
scrollContainerRef={scrollContainerRef}
/>
),
[
@@ -1393,7 +1451,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
deleteFolder,
showDeletionDialog,
renderSessionNode,
currentSessionDirectory,
projectRepoStatus,
showMoreGroupSessions,
resetGroupSessionLimit,
@@ -1405,11 +1462,17 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
setSessionSwitcherOpen,
openNewSessionDraftFromTree,
addSessionToFolder,
createFolderAndStartRename,
stableCreateFolderAndStartRename,
renamingFolderId,
renameFolderDraft,
pinnedSessionIds,
expandedParents,
sessionOrderIndex,
currentSessionId,
editingId,
editTitle,
openSidebarMenuKey,
liveSessionById,
prVisualStateByDirectoryBranch,
toggleCollapsedGroup,
],
@@ -1419,174 +1482,40 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
<SidebarActivitySections
sections={activitySections}
renderSessionNode={renderSessionNode}
currentSessionId={currentSessionId}
editingId={editingId}
openSidebarMenuKey={openSidebarMenuKey}
variant="section"
/>
) : null;
const isInlineEditing = Boolean(renamingFolderId || editingId || editingProjectDialogId);
const selectionModeEnabled = useSessionMultiSelectStore((state) => state.enabled);
const selectedIds = useSessionMultiSelectStore((state) => state.selectedIds);
const selectionScopeKey = useSessionMultiSelectStore((state) => state.scopeKey);
const multiSelectStoreApi = useSessionMultiSelectStore;
const handleToggleSelectionMode = React.useCallback(() => {
useSessionMultiSelectStore.getState().toggleMode();
}, []);
const handleExitSelectionMode = React.useCallback(() => {
useSessionMultiSelectStore.getState().disable();
}, []);
const bulkScopeIsArchived = React.useMemo(() => {
if (selectedIds.size === 0) return false;
if (typeof document === 'undefined') return false;
let sawActive = false;
let sawArchived = false;
for (const id of selectedIds) {
const rows = document.querySelectorAll<HTMLElement>(`[data-session-row="${CSS.escape(id)}"]`);
for (const row of rows) {
if (row.getAttribute('data-session-archived') === '1') sawArchived = true;
else sawActive = true;
}
}
return sawArchived && !sawActive;
}, [selectedIds]);
const derivedSelectionScope = React.useMemo(() => {
if (selectionScopeKey) return selectionScopeKey;
if (selectedIds.size === 0) return null;
if (typeof document === 'undefined') return null;
for (const id of selectedIds) {
const row = document.querySelector<HTMLElement>(`[data-session-row="${CSS.escape(id)}"]`);
const scope = row?.getAttribute('data-session-scope');
if (scope && scope.length > 0) return scope;
}
return null;
}, [selectedIds, selectionScopeKey]);
const bulkScopeFolders = React.useMemo(() => {
if (!derivedSelectionScope) return [];
return foldersMap[derivedSelectionScope] ?? [];
}, [foldersMap, derivedSelectionScope]);
const bulkCanRemoveFromFolder = React.useMemo(() => {
if (!derivedSelectionScope || selectedIds.size === 0) return false;
const scopeFolders = foldersMap[derivedSelectionScope] ?? [];
for (const folder of scopeFolders) {
for (const id of folder.sessionIds) {
if (selectedIds.has(id)) return true;
}
}
return false;
}, [foldersMap, derivedSelectionScope, selectedIds]);
const handleBulkMoveToFolder = React.useCallback((folderId: string) => {
if (!derivedSelectionScope || selectedIds.size === 0) return;
addSessionsToFolder(derivedSelectionScope, folderId, Array.from(selectedIds));
}, [addSessionsToFolder, selectedIds, derivedSelectionScope]);
const handleBulkCreateFolderAndMove = React.useCallback(() => {
if (!derivedSelectionScope || selectedIds.size === 0) return;
const newFolder = createFolderAndStartRename(derivedSelectionScope);
if (!newFolder) return;
addSessionsToFolder(derivedSelectionScope, newFolder.id, Array.from(selectedIds));
}, [addSessionsToFolder, createFolderAndStartRename, selectedIds, derivedSelectionScope]);
const handleBulkRemoveFromFolder = React.useCallback(() => {
if (!derivedSelectionScope || selectedIds.size === 0) return;
removeSessionsFromFolders(derivedSelectionScope, Array.from(selectedIds));
}, [removeSessionsFromFolders, selectedIds, derivedSelectionScope]);
const executeBulkDelete = React.useCallback(async () => {
const ids = Array.from(selectedIds);
if (ids.length === 0) return;
if (bulkScopeIsArchived) {
const { deletedIds, failedIds } = await deleteSessions(ids);
if (deletedIds.length > 0) {
toast.success(deletedIds.length === 1
? t('sessions.sidebar.bulkActions.deletedSingle', { count: deletedIds.length })
: t('sessions.sidebar.bulkActions.deletedPlural', { count: deletedIds.length }));
}
if (failedIds.length > 0) {
toast.error(failedIds.length === 1
? t('sessions.sidebar.bulkActions.failedDeleteSingle', { count: failedIds.length })
: t('sessions.sidebar.bulkActions.failedDeletePlural', { count: failedIds.length }));
}
} else {
const { archivedIds, failedIds } = await archiveSessions(ids);
if (archivedIds.length > 0) {
toast.success(archivedIds.length === 1
? t('sessions.sidebar.bulkActions.archivedSingle', { count: archivedIds.length })
: t('sessions.sidebar.bulkActions.archivedPlural', { count: archivedIds.length }));
}
if (failedIds.length > 0) {
toast.error(failedIds.length === 1
? t('sessions.sidebar.bulkActions.failedArchiveSingle', { count: failedIds.length })
: t('sessions.sidebar.bulkActions.failedArchivePlural', { count: failedIds.length }));
}
}
useSessionMultiSelectStore.getState().clear();
}, [archiveSessions, bulkScopeIsArchived, deleteSessions, selectedIds, t]);
const handleBulkDelete = React.useCallback(() => {
const count = selectedIds.size;
if (count === 0) return;
if (!showDeletionDialog) {
void executeBulkDelete();
return;
}
setBulkDeleteConfirm({ sessionCount: count, archivedBucket: bulkScopeIsArchived });
}, [bulkScopeIsArchived, executeBulkDelete, selectedIds, showDeletionDialog]);
const confirmBulkDelete = React.useCallback(async () => {
setBulkDeleteConfirm(null);
await executeBulkDelete();
}, [executeBulkDelete]);
React.useEffect(() => {
if (!selectionModeEnabled) return;
const isMac = typeof navigator !== 'undefined' && /Macintosh|Mac OS X/.test(navigator.userAgent || '');
const listener = (event: KeyboardEvent) => {
if (isInlineEditing) return;
const target = event.target as HTMLElement | null;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
return;
}
const modifier = isMac ? event.metaKey : event.ctrlKey;
if (event.key === 'Escape') {
event.preventDefault();
useSessionMultiSelectStore.getState().disable();
return;
}
if (modifier && event.key === 'Backspace') {
event.preventDefault();
handleBulkDelete();
return;
}
if (modifier && (event.key === 'a' || event.key === 'A')) {
const rows = typeof document !== 'undefined'
? Array.from(document.querySelectorAll<HTMLElement>('[data-session-row]'))
: [];
if (rows.length === 0) return;
event.preventDefault();
const currentScope = multiSelectStoreApi.getState().scopeKey;
const targetScope = currentScope
?? rows[0]?.getAttribute('data-session-scope')
?? null;
const scopeFilter = (el: HTMLElement): boolean => {
if (!targetScope) return true;
return el.getAttribute('data-session-scope') === targetScope;
};
const ids = rows
.filter(scopeFilter)
.map((el) => el.getAttribute('data-session-row'))
.filter((id): id is string => typeof id === 'string' && id.length > 0);
if (ids.length === 0) return;
multiSelectStoreApi.getState().replaceAll(ids, targetScope || null);
}
};
window.addEventListener('keydown', listener);
return () => window.removeEventListener('keydown', listener);
}, [handleBulkDelete, isInlineEditing, multiSelectStoreApi, selectionModeEnabled]);
const {
selectionModeEnabled,
hasSelection,
selectedIdsSize,
bulkScopeIsArchived,
derivedSelectionScope,
bulkScopeFolders,
bulkCanRemoveFromFolder,
handleToggleSelectionMode,
handleExitSelectionMode,
handleBulkMoveToFolder,
handleBulkCreateFolderAndMove,
handleBulkRemoveFromFolder,
handleBulkDelete,
confirmBulkDelete,
} = useSidebarBulkActions({
isInlineEditing,
showDeletionDialog,
foldersMap,
addSessionsToFolder,
removeSessionsFromFolders,
createFolderAndStartRename,
archiveSessions,
deleteSessions,
setBulkDeleteConfirm,
});
const handleOpenMultiRunFromHeader = React.useCallback(() => {
setActiveMainTab('chat');
if (mobileVariant) {
@@ -1670,9 +1599,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
isInlineEditing={isInlineEditing}
/>
{selectionModeEnabled && selectedIds.size > 0 ? (
{selectionModeEnabled && hasSelection ? (
<BulkActionBar
selectedCount={selectedIds.size}
selectedCount={selectedIdsSize}
scopeKey={derivedSelectionScope}
scopeFolders={bulkScopeFolders}
archivedBucket={bulkScopeIsArchived}
@@ -5,6 +5,11 @@ import type { Session } from '@opencode-ai/sdk/v2';
// Archived buckets routinely grow into the hundreds/thousands; virtualize
// when we cross this row count so the DOM stays bounded.
const ARCHIVED_VIRTUALIZE_THRESHOLD = 50;
// Active/worktree groups can also grow large (a single worktree with 80+
// sessions), and unlike the archive they're interactive from the start.
// Virtualize eagerly for non-archived groups to keep the rendered row
// count bounded. With overscan ~8 the visible behavior is identical.
const ACTIVE_VIRTUALIZE_THRESHOLD = 30;
// Compact rows in the archived bucket without nested subagents render
// around 24-32px; virtua measures mounted rows and uses this as the initial hint.
const ARCHIVED_ROW_ESTIMATE_PX = 28;
@@ -18,6 +23,13 @@ import { DroppableFolderWrapper, SessionFolderDndScope } from './sessionFolderDn
import type { SortableDragHandleProps } from './sortableItems';
import type { GroupSearchData, SessionGroup, SessionNode } from './types';
import { compareSessionsByPinnedAndTime, isBranchDifferentFromLabel, normalizePath, renderHighlightedText } from './utils';
import {
collectSubtreeContainingId,
computeNodeStructureKey,
nodeContainsSessionId,
resolveMenuOpenSessionId,
} from './sessionNodeItemUtils';
import type { SessionNodeRenderExtras } from './sessionNodeItemUtils';
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
@@ -50,8 +62,16 @@ type Props = {
deleteFolder: (scopeKey: string, folderId: string) => void;
showDeletionDialog: boolean;
setDeleteFolderConfirm: React.Dispatch<React.SetStateAction<DeleteFolderConfirm>>;
renderSessionNode: (node: SessionNode, depth?: number, groupDirectory?: string | null, projectId?: string | null, archivedBucket?: boolean, secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null) => React.ReactNode;
currentSessionDirectory: string | null;
renderSessionNode: (
node: SessionNode,
depth?: number,
groupDirectory?: string | null,
projectId?: string | null,
archivedBucket?: boolean,
secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null,
renderContext?: 'project' | 'recent',
renderExtras?: SessionNodeRenderExtras,
) => React.ReactNode;
projectRepoStatus: Map<string, boolean | null>;
lastRepoStatus: boolean;
showMoreGroupSessions: (groupKey: string, currentVisibleCount: number) => void;
@@ -70,7 +90,13 @@ type Props = {
setRenameFolderDraft: React.Dispatch<React.SetStateAction<string>>;
setRenamingFolderId: React.Dispatch<React.SetStateAction<string | null>>;
pinnedSessionIds: Set<string>;
expandedParents: Set<string>;
sessionOrderIndex: Map<string, number>;
currentSessionId: string | null;
editingId: string | null;
editTitle: string;
openSidebarMenuKey: string | null;
liveSessionById: Map<string, Session>;
prVisualStateByDirectoryBranch: Map<string, {
visualState: 'draft' | 'open' | 'blocked' | 'merged' | 'closed';
number: number;
@@ -97,9 +123,196 @@ type Props = {
onToggleCollapsedGroup: (groupKey: string) => void;
dragHandleProps?: SortableDragHandleProps | null;
compactBodyPadding?: boolean;
/**
* Optional scroll container ref threaded from the outer ScrollableOverlay.
* When provided, the virtualization effect can resolve the scrolling
* ancestor synchronously and skip the getComputedStyle walk on every
* render of an expanded archived bucket.
*/
scrollContainerRef?: React.RefObject<HTMLElement | null>;
};
export function SessionGroupSection(props: Props): React.ReactNode {
const groupContainsSessionId = (group: SessionGroup, sessionId: string | null): boolean => {
if (!sessionId) return false;
return group.sessions.some((node) => nodeContainsSessionId(node, sessionId));
};
const groupHasPinnedMembershipChange = (
group: SessionGroup,
prevPinnedSessionIds: Set<string>,
nextPinnedSessionIds: Set<string>,
): boolean => {
const visit = (node: SessionNode): boolean => {
const sessionId = node.session.id;
if (prevPinnedSessionIds.has(sessionId) !== nextPinnedSessionIds.has(sessionId)) return true;
return node.children.some(visit);
};
return group.sessions.some(visit);
};
const groupHasSessionOrderChange = (
group: SessionGroup,
prevSessionOrderIndex: Map<string, number>,
nextSessionOrderIndex: Map<string, number>,
): boolean => {
const visit = (node: SessionNode): boolean => {
const sessionId = node.session.id;
if (prevSessionOrderIndex.get(sessionId) !== nextSessionOrderIndex.get(sessionId)) return true;
return node.children.some(visit);
};
return group.sessions.some(visit);
};
const groupHasExpansionMembershipChange = (
group: SessionGroup,
prevExpandedParents: Set<string>,
nextExpandedParents: Set<string>,
): boolean => {
const bucketTag = group.isArchivedBucket ? 'archived' : 'active';
const visit = (node: SessionNode): boolean => {
const key = `project:${bucketTag}:${node.session.id}`;
if (prevExpandedParents.has(key) !== nextExpandedParents.has(key)) return true;
return node.children.some(visit);
};
return group.sessions.some(visit);
};
const groupHasResolvedSessionChange = (
group: SessionGroup,
prevLiveSessionById: Map<string, Session>,
nextLiveSessionById: Map<string, Session>,
): boolean => {
const visit = (node: SessionNode): boolean => {
const sessionId = node.session.id;
if ((prevLiveSessionById.get(sessionId) ?? node.session) !== (nextLiveSessionById.get(sessionId) ?? node.session)) {
return true;
}
return node.children.some(visit);
};
return group.sessions.some(visit);
};
const getProjectRepoStatusValue = (props: Props): boolean | null | undefined => {
if (!props.projectId) return undefined;
return props.projectRepoStatus.has(props.projectId)
? props.projectRepoStatus.get(props.projectId)
: undefined;
};
const areGroupPropsEqual = (prev: Props, next: Props): boolean => {
// Bail on Object.is for the props that drive the most work: the group
// itself, its key, and the group-level chrome. These change rarely and
// any change should force a re-render of this group.
if (prev.group !== next.group) return false;
if (prev.groupKey !== next.groupKey) return false;
if (prev.projectId !== next.projectId) return false;
if (prev.hideGroupLabel !== next.hideGroupLabel) return false;
if (prev.compactBodyPadding !== next.compactBodyPadding) return false;
if (prev.groupSearchDataByGroup !== next.groupSearchDataByGroup) return false;
if (prev.visibleSessionCount !== next.visibleSessionCount) return false;
if (prev.collapsedGroups !== next.collapsedGroups
&& prev.collapsedGroups.has(prev.groupKey) !== next.collapsedGroups.has(next.groupKey)) {
return false;
}
if (prev.projectRepoStatus !== next.projectRepoStatus
&& getProjectRepoStatusValue(prev) !== getProjectRepoStatusValue(next)) {
return false;
}
if (prev.pinnedSessionIds !== next.pinnedSessionIds
&& groupHasPinnedMembershipChange(next.group, prev.pinnedSessionIds, next.pinnedSessionIds)) {
return false;
}
if (prev.expandedParents !== next.expandedParents
&& groupHasExpansionMembershipChange(next.group, prev.expandedParents, next.expandedParents)) {
return false;
}
if (prev.sessionOrderIndex !== next.sessionOrderIndex
&& groupHasSessionOrderChange(next.group, prev.sessionOrderIndex, next.sessionOrderIndex)) {
return false;
}
if (prev.currentSessionId !== next.currentSessionId
&& (groupContainsSessionId(prev.group, prev.currentSessionId) || groupContainsSessionId(next.group, next.currentSessionId))) {
return false;
}
if (prev.editingId !== next.editingId
&& (groupContainsSessionId(prev.group, prev.editingId) || groupContainsSessionId(next.group, next.editingId))) {
return false;
}
if (prev.editTitle !== next.editTitle
&& (groupContainsSessionId(prev.group, prev.editingId) || groupContainsSessionId(next.group, next.editingId))) {
return false;
}
if (prev.openSidebarMenuKey !== next.openSidebarMenuKey) {
const prevMenuSessionId = resolveMenuOpenSessionId(prev.group.sessions, prev.openSidebarMenuKey, 'project', Boolean(prev.group.isArchivedBucket));
const nextMenuSessionId = resolveMenuOpenSessionId(next.group.sessions, next.openSidebarMenuKey, 'project', Boolean(next.group.isArchivedBucket));
if (prevMenuSessionId || nextMenuSessionId) return false;
}
if (prev.liveSessionById !== next.liveSessionById
&& groupHasResolvedSessionChange(next.group, prev.liveSessionById, next.liveSessionById)) {
return false;
}
// Per-row / per-state props. The PR-visual-state map flips frequently
// during bootstrap but a single group's value is usually stable, so we
// compare only the value this group actually consumes instead of the
// whole map reference.
if (prev.prVisualStateByDirectoryBranch !== next.prVisualStateByDirectoryBranch) {
const prevVal = prev.group?.directory && prev.group?.branch
? prev.prVisualStateByDirectoryBranch.get(`${prev.group.directory}::${prev.group.branch.trim()}`)
: undefined;
const nextVal = next.group?.directory && next.group?.branch
? next.prVisualStateByDirectoryBranch.get(`${next.group.directory}::${next.group.branch.trim()}`)
: undefined;
if (!Object.is(prevVal, nextVal)) return false;
}
// Other props are typically stable references from the parent. Default
// to reference equality (the cheap path) and only re-render when the
// parent actually swapped something.
return (
prev.hasSessionSearchQuery === next.hasSessionSearchQuery
&& prev.normalizedSessionSearchQuery === next.normalizedSessionSearchQuery
&& prev.hideDirectoryControls === next.hideDirectoryControls
&& prev.collapsedFolderIds === next.collapsedFolderIds
&& prev.toggleFolderCollapse === next.toggleFolderCollapse
&& prev.renameFolder === next.renameFolder
&& prev.deleteFolder === next.deleteFolder
&& prev.showDeletionDialog === next.showDeletionDialog
&& prev.setDeleteFolderConfirm === next.setDeleteFolderConfirm
&& prev.renderSessionNode === next.renderSessionNode
&& prev.lastRepoStatus === next.lastRepoStatus
&& prev.showMoreGroupSessions === next.showMoreGroupSessions
&& prev.resetGroupSessionLimit === next.resetGroupSessionLimit
&& prev.mobileVariant === next.mobileVariant
&& prev.alwaysShowActions === next.alwaysShowActions
&& prev.activeProjectId === next.activeProjectId
&& prev.setActiveProjectIdOnly === next.setActiveProjectIdOnly
&& prev.setActiveMainTab === next.setActiveMainTab
&& prev.setSessionSwitcherOpen === next.setSessionSwitcherOpen
&& prev.openNewSessionDraft === next.openNewSessionDraft
&& prev.addSessionToFolder === next.addSessionToFolder
&& prev.createFolderAndStartRename === next.createFolderAndStartRename
&& prev.renamingFolderId === next.renamingFolderId
&& prev.renameFolderDraft === next.renameFolderDraft
&& prev.setRenameFolderDraft === next.setRenameFolderDraft
&& prev.setRenamingFolderId === next.setRenamingFolderId
&& prev.onToggleCollapsedGroup === next.onToggleCollapsedGroup
&& prev.dragHandleProps === next.dragHandleProps
&& prev.scrollContainerRef === next.scrollContainerRef
);
};
function SessionGroupSectionBase(props: Props): React.ReactNode {
const { t } = useI18n();
const {
group,
@@ -138,10 +351,14 @@ export function SessionGroupSection(props: Props): React.ReactNode {
setRenamingFolderId,
pinnedSessionIds,
sessionOrderIndex,
currentSessionId,
editingId,
openSidebarMenuKey,
prVisualStateByDirectoryBranch,
onToggleCollapsedGroup,
dragHandleProps,
compactBodyPadding = false,
scrollContainerRef,
} = props;
const compareSessionNodes = React.useCallback((a: SessionNode, b: SessionNode) => {
@@ -254,6 +471,79 @@ export function SessionGroupSection(props: Props): React.ReactNode {
const ungroupedSessions = React.useMemo(() => sourceGroupNodes.filter((node) => !sessionIdsInFolders.has(node.session.id)), [sourceGroupNodes, sessionIdsInFolders]);
const rootFolders = React.useMemo(() => allFoldersForGroup.filter(({ folder }) => !folder.parentId), [allFoldersForGroup]);
// Precompute per-row "subtree contains active session" and "subtree contains
// editing session" lookups once per render. The previous design walked the
// node tree inside SessionNodeItem.areEqual for every row, which is O(M^2)
// across the whole sidebar. These sets let areEqual answer with a single
// Set.has lookup, so the cost is O(M) once per SessionGroupSection render.
const renderContextForGroup = 'project' as const;
const subtreeContainsActive = React.useMemo(() => {
const set = new Set<string>();
collectSubtreeContainingId(sourceGroupNodes, currentSessionId, set);
allFoldersForGroup.forEach(({ nodes }) => {
collectSubtreeContainingId(nodes, currentSessionId, set);
});
return set;
}, [sourceGroupNodes, allFoldersForGroup, currentSessionId]);
const subtreeContainsEditing = React.useMemo(() => {
const set = new Set<string>();
collectSubtreeContainingId(sourceGroupNodes, editingId, set);
allFoldersForGroup.forEach(({ nodes }) => {
collectSubtreeContainingId(nodes, editingId, set);
});
return set;
}, [sourceGroupNodes, allFoldersForGroup, editingId]);
const menuOpenSessionId = React.useMemo(() => {
if (!openSidebarMenuKey) return null;
const fromSource = resolveMenuOpenSessionId(sourceGroupNodes, openSidebarMenuKey, renderContextForGroup, Boolean(group.isArchivedBucket));
if (fromSource) return fromSource;
for (const { nodes } of allFoldersForGroup) {
const id = resolveMenuOpenSessionId(nodes, openSidebarMenuKey, renderContextForGroup, Boolean(group.isArchivedBucket));
if (id) return id;
}
return null;
}, [openSidebarMenuKey, sourceGroupNodes, allFoldersForGroup, group.isArchivedBucket]);
const buildNodeStructureKeyByNode = React.useCallback((nodes: SessionNode[]): WeakMap<SessionNode, string> => {
const map = new WeakMap<SessionNode, string>();
const visit = (node: SessionNode): void => {
map.set(node, computeNodeStructureKey(node));
for (const child of node.children) {
visit(child);
}
};
nodes.forEach(visit);
return map;
}, []);
const nodeStructureKeyBySourceNode = React.useMemo(
() => buildNodeStructureKeyByNode(sourceGroupNodes),
[buildNodeStructureKeyByNode, sourceGroupNodes],
);
const nodeStructureKeyByFolderNode = React.useMemo(
() => {
const map = new WeakMap<SessionNode, string>();
allFoldersForGroup.forEach(({ nodes }) => {
nodes.forEach((node) => map.set(node, computeNodeStructureKey(node)));
});
return map;
},
[allFoldersForGroup],
);
const resolveNodeStructureKey = React.useCallback((node: SessionNode): string => {
return nodeStructureKeyBySourceNode.get(node) ?? nodeStructureKeyByFolderNode.get(node) ?? '';
}, [nodeStructureKeyBySourceNode, nodeStructureKeyByFolderNode]);
const childRenderExtrasFor = React.useCallback((child: SessionNode) => ({
subtreeContainsActive,
subtreeContainsEditing,
menuOpenSessionId,
nodeStructureKey: resolveNodeStructureKey(child),
}), [subtreeContainsActive, subtreeContainsEditing, menuOpenSessionId, resolveNodeStructureKey]);
const totalSessions = ungroupedSessions.length;
const visibleSessions = group.isArchivedBucket
? ungroupedSessions
@@ -263,15 +553,21 @@ export function SessionGroupSection(props: Props): React.ReactNode {
const remainingCount = totalSessions - visibleSessions.length;
const canShowLess = !group.isArchivedBucket && !hasSessionSearchQuery && totalSessions > maxVisible && remainingCount === 0;
// Virtualize the archived bucket once it grows past a threshold. The
// archived list is the only group that can routinely hit hundreds or
// thousands of rows (projects accumulate archived sessions over time);
// every other group renders eagerly because they're small. All hooks
// below MUST stay above the search-empty early-return so they fire in
// the same order every render — rules-of-hooks.
// Virtualize large groups. Archived buckets grow into the hundreds or
// thousands of rows; active/worktree groups can also hit 80+ sessions
// when a single worktree accumulates over time. Both paths share the
// same virtua Virtualizer; the threshold just controls when we mount
// it. The visible behavior is identical because virtua uses overscan
// (8) for the buffer zone. All hooks below MUST stay above the
// search-empty early-return so they fire in the same order every
// render — rules-of-hooks.
const shouldVirtualizeArchived = group.isArchivedBucket === true
&& !hasSessionSearchQuery
&& visibleSessions.length >= ARCHIVED_VIRTUALIZE_THRESHOLD;
const shouldVirtualizeActive = group.isArchivedBucket !== true
&& !hasSessionSearchQuery
&& visibleSessions.length >= ACTIVE_VIRTUALIZE_THRESHOLD;
const shouldVirtualize = shouldVirtualizeArchived || shouldVirtualizeActive;
const archivedVirtualContainerRef = React.useRef<HTMLDivElement | null>(null);
const archivedScrollRef = React.useRef<HTMLElement | null>(null);
@@ -284,22 +580,31 @@ export function SessionGroupSection(props: Props): React.ReactNode {
// element and renders rows in the wrong subset / position.
const [archivedScrollMargin, setArchivedScrollMargin] = React.useState(0);
// Find the nearest scrolling ancestor by walking up the DOM. The sidebar
// routes its scroll through `ScrollableOverlay` higher up the tree;
// threading a ref through every intermediate component would be invasive
// for this single use case.
// Resolve the scrolling ancestor and measure the virtual container's offset
// from its content origin, both on every render. The container ref is null
// while the archived bucket is collapsed (the body isn't mounted), so a
// Resolve the scrolling ancestor. When the parent has threaded a
// `scrollContainerRef` (Layer 1.4), use it directly to skip the
// `getComputedStyle` walk on every render of an expanded archived
// bucket — the walk is one of the more expensive operations in the
// hot path because it forces a style recalc on every parent up the
// tree. Fall back to the legacy walk only when the ref is missing.
//
// We also still re-run when the archive flips between expanded/collapsed,
// and on a ResizeObserver-driven layout change of the container, so a
// dep-gated effect that only fires when shouldVirtualizeArchived flips
// would miss the eventual mount and leave the scroll element null forever.
// Running on every render lets us pick up the container as soon as
// expanding the bucket mounts it; the cached scroll element is reused as
// long as it still contains the container. Both state setters compare
// before writing, so a stable layout produces no state churn.
// would miss the eventual mount and leave the scroll element null.
const [, setLayoutVersion] = React.useState(0);
React.useEffect(() => {
if (!shouldVirtualize) return;
const container = archivedVirtualContainerRef.current;
if (!container) return;
if (typeof ResizeObserver === 'undefined') return;
const ro = new ResizeObserver(() => setLayoutVersion((v) => v + 1));
ro.observe(container);
return () => ro.disconnect();
}, [shouldVirtualize]);
// eslint-disable-next-line react-hooks/exhaustive-deps
React.useLayoutEffect(() => {
if (!shouldVirtualizeArchived) {
if (!shouldVirtualize) {
if (archivedScrollEl !== null) setArchivedScrollEl(null);
archivedScrollRef.current = null;
if (archivedScrollMargin !== 0) setArchivedScrollMargin(0);
@@ -312,7 +617,15 @@ export function SessionGroupSection(props: Props): React.ReactNode {
return;
}
let scrollEl: HTMLElement | null = archivedScrollEl;
if (!scrollEl || !scrollEl.contains(container)) {
const providedScrollEl = scrollContainerRef?.current ?? null;
if (providedScrollEl && providedScrollEl.contains(container)) {
scrollEl = providedScrollEl;
if (scrollEl !== archivedScrollEl) {
archivedScrollRef.current = scrollEl;
setArchivedScrollEl(scrollEl);
return;
}
} else if (!scrollEl || !scrollEl.contains(container)) {
// Walk up to find the nearest scrolling ancestor. Only happens on
// first mount or if the DOM tree restructured.
let el: HTMLElement | null = container.parentElement;
@@ -327,8 +640,6 @@ export function SessionGroupSection(props: Props): React.ReactNode {
if (scrollEl !== archivedScrollEl) {
archivedScrollRef.current = scrollEl;
setArchivedScrollEl(scrollEl);
// setState triggers a re-render; bail out and let the next pass
// measure the margin against the fresh element.
return;
}
}
@@ -339,11 +650,9 @@ export function SessionGroupSection(props: Props): React.ReactNode {
setArchivedScrollMargin((prev) => (Math.abs(prev - offset) < 1 ? prev : offset));
});
if (hasSessionSearchQuery && !groupMatchesSearch && rootFolders.length === 0 && ungroupedSessions.length === 0) {
return null;
}
const collectGroupSessions = (nodes: SessionNode[]): Session[] => {
// Hooks below MUST stay above the search-empty early-return so they
// fire in the same order every render — rules-of-hooks.
const collectGroupSessions = React.useCallback((nodes: SessionNode[]): Session[] => {
const collected: Session[] = [];
const visit = (list: SessionNode[]) => {
list.forEach((node) => {
@@ -353,9 +662,52 @@ export function SessionGroupSection(props: Props): React.ReactNode {
};
visit(nodes);
return collected;
};
}, []);
// The "delete all in group" handler closes over the full list of
// sessions in this group. Memoize so the recursive flatten only runs
// when the underlying source group nodes change, not on every render.
const allGroupSessions = React.useMemo(
() => (group.isArchivedBucket ? collectGroupSessions(sourceGroupNodes) : []),
[collectGroupSessions, sourceGroupNodes, group.isArchivedBucket],
);
// Precompute the per-folder "delete all sessions in folder" list once
// per render. The previous design ran a recursive `collectFolderSessions`
// walk inside each folder's render, which is O(F × (S + F)) per group
// render. With F=50 folders and S=200 archived sessions this is
// significant; the precompute makes it O(F + S) once.
const folderSessionsForDeleteById = React.useMemo(() => {
if (!group.isArchivedBucket) return new Map<string, Session[]>();
const result = new Map<string, Session[]>();
const childIdsByParentId = new Map<string, string[]>();
for (const { folder } of allFoldersForGroup) {
if (!folder.parentId) continue;
const existing = childIdsByParentId.get(folder.parentId) ?? [];
existing.push(folder.id);
childIdsByParentId.set(folder.parentId, existing);
}
const visit = (targetFolderId: string, seen: Set<string>): Session[] => {
if (seen.has(targetFolderId)) return [];
seen.add(targetFolderId);
const directEntry = allFoldersForGroup.find(({ folder: candidate }) => candidate.id === targetFolderId);
const collected: Session[] = directEntry ? collectGroupSessions(directEntry.nodes) : [];
const childIds = childIdsByParentId.get(targetFolderId) ?? [];
for (const childId of childIds) {
collected.push(...visit(childId, seen));
}
return collected;
};
for (const { folder } of allFoldersForGroup) {
result.set(folder.id, visit(folder.id, new Set()));
}
return result;
}, [allFoldersForGroup, collectGroupSessions, group.isArchivedBucket]);
if (hasSessionSearchQuery && !groupMatchesSearch && rootFolders.length === 0 && ungroupedSessions.length === 0) {
return null;
}
const allGroupSessions = collectGroupSessions(sourceGroupNodes);
const isGitProject = projectId && projectRepoStatus.has(projectId)
? Boolean(projectRepoStatus.get(projectId))
: lastRepoStatus;
@@ -437,15 +789,7 @@ export function SessionGroupSection(props: Props): React.ReactNode {
const subFolderItems = directSubFolders.length > 0
? <>{directSubFolders.map(({ folder: sf, nodes: sn }) => renderOneFolderItem(sf, sn, depth + 1))}</>
: undefined;
const collectFolderSessions = (targetFolderId: string): Session[] => {
const directNodes = allFoldersForGroup.find(({ folder: candidate }) => candidate.id === targetFolderId)?.nodes ?? [];
const childFolders = allFoldersForGroup.filter(({ folder: candidate }) => candidate.parentId === targetFolderId);
return [
...collectGroupSessions(directNodes),
...childFolders.flatMap(({ folder: child }) => collectFolderSessions(child.id)),
];
};
const folderSessionsForDelete = group.isArchivedBucket ? collectFolderSessions(folder.id) : [];
const folderSessionsForDelete = folderSessionsForDeleteById.get(folder.id) ?? [];
return (
<DroppableFolderWrapper key={folder.id} folderId={folder.id}>
@@ -485,6 +829,15 @@ export function SessionGroupSection(props: Props): React.ReactNode {
});
}}
renderSessionNode={renderSessionNode}
getRenderExtras={resolveNodeStructureKey
? (node) => ({
subtreeContainsActive,
subtreeContainsEditing,
menuOpenSessionId,
nodeStructureKey: resolveNodeStructureKey(node),
childRenderExtrasFor,
})
: undefined}
groupDirectory={group.directory}
projectId={projectId}
mobileVariant={mobileVariant}
@@ -546,7 +899,7 @@ export function SessionGroupSection(props: Props): React.ReactNode {
}}
>
{renderFolderItems()}
{shouldVirtualizeArchived ? (
{shouldVirtualize ? (
<div ref={archivedVirtualContainerRef}>
<Virtualizer
data={visibleSessions}
@@ -555,11 +908,23 @@ export function SessionGroupSection(props: Props): React.ReactNode {
scrollRef={archivedScrollRef}
startMargin={archivedScrollMargin}
>
{(node) => renderSessionNode(node, 0, group.directory, projectId, true) as React.ReactElement}
{(node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
subtreeContainsActive,
subtreeContainsEditing,
menuOpenSessionId,
nodeStructureKey: resolveNodeStructureKey(node),
childRenderExtrasFor,
}) as React.ReactElement}
</Virtualizer>
</div>
) : (
visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true))
visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
subtreeContainsActive,
subtreeContainsEditing,
menuOpenSessionId,
nodeStructureKey: resolveNodeStructureKey(node),
childRenderExtrasFor,
}))
)}
{totalSessions === 0 && allFoldersForGroup.length === 0 ? (
<div className="py-1 text-left typography-micro text-muted-foreground">
@@ -846,3 +1211,5 @@ export function SessionGroupSection(props: Props): React.ReactNode {
</div>
);
}
export const SessionGroupSection = React.memo(SessionGroupSectionBase, areGroupPropsEqual);
@@ -21,10 +21,12 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
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 { 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';
@@ -61,7 +63,7 @@ type Props = {
setEditingId: (id: string | null) => void;
editTitle: string;
setEditTitle: (value: string) => void;
handleSaveEdit: () => void;
handleSaveEdit: (titleOverride?: string) => void;
handleCancelEdit: () => void;
toggleParent: (expansionKey: string) => void;
handleSessionSelect: (sessionId: string, sessionDirectory: string | null, projectId?: string | null) => void;
@@ -83,133 +85,58 @@ type Props = {
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') => React.ReactNode;
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';
};
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 ((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;
/**
* 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<string>;
/**
* Precomputed set of session IDs whose subtree contains the session
* currently being edited. Same rationale as subtreeContainsActive.
*/
subtreeContainsEditing: Set<string>;
/**
* 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<string, Session>;
};
function SessionNodeItemComponent(props: Props): React.ReactNode {
@@ -255,6 +182,11 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
renderSessionNode,
secondaryMeta,
renderContext = 'project',
subtreeContainsActive,
subtreeContainsEditing,
menuOpenSessionId,
childRenderExtrasFor,
liveSessionById,
} = props;
const hasSecondaryProjectLabel = Boolean(secondaryMeta?.projectLabel);
const hasSecondaryBranchLabel = Boolean(secondaryMeta?.branchLabel);
@@ -302,11 +234,21 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
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<string | null>(null);
const formRef = React.useRef<HTMLFormElement>(null);
const session = node.session;
const liveSession = useSession(session.id);
const resolvedSession = liveSession ?? 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)
@@ -477,13 +419,25 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
if (editingId !== session.id) return;
const handleDocMouseDown = (e: MouseEvent) => {
if (formRef.current && !formRef.current.contains(e.target as Node)) {
handleSaveEditRef.current();
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 (
<div
@@ -496,24 +450,21 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
className="flex w-full items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
handleSaveEdit();
handleSaveEdit(renameDraft);
}}
>
<input
value={editTitle}
onChange={(event) => setEditTitle(event.target.value)}
value={renameDraft}
onChange={(event) => 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') {
event.stopPropagation();
handleCancelEdit();
return;
}
if (event.key === ' ' || event.key === 'Enter') {
event.stopPropagation();
}
}}
/>
<button
@@ -1030,18 +981,18 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
) : (
<button
type="button"
onPointerDown={handleRowPointerDown}
onPointerUp={handleRowPointerEnd}
onPointerCancel={handleRowPointerEnd}
onMouseDown={handleRowMouseDown}
onClick={(event) => handleRowSelect(event)}
onPointerDown={handleRowPointerDown}
onPointerUp={handleRowPointerEnd}
onPointerCancel={handleRowPointerEnd}
onMouseDown={handleRowMouseDown}
onClick={(event) => handleRowSelect(event)}
onDoubleClick={(e) => {
e.stopPropagation();
handleSessionDoubleClick(session.id, sessionTitle);
}}
className={cn(
'flex min-w-0 flex-1 cursor-pointer flex-col gap-0 overflow-hidden rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 text-foreground select-none transition-[padding]',
isTouchPressed && 'bg-interactive-hover/70',
'flex min-w-0 flex-1 cursor-pointer flex-col gap-0 overflow-hidden rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 text-foreground select-none transition-[padding]',
isTouchPressed && 'bg-interactive-hover/70',
alwaysShowActions
? (isVSCode ? revealPaddingClass : alwaysActionPaddingClass)
: revealPaddingClass
@@ -1159,7 +1110,26 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
</ContextMenu.Root>
</DraggableSessionRow>
{hasChildren && isExpanded
? node.children.map((child) => renderSessionNode(child, depth + 1, sessionDirectory ?? groupDirectory, projectId, archivedBucket, undefined, renderContext))
? 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}
<Dialog open={exportDialogOpen} onOpenChange={setExportDialogOpen}>
<DialogContent showCloseButton={false} className="max-w-sm gap-5">
@@ -1213,4 +1183,194 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
);
}
export const SessionNodeItem = React.memo(SessionNodeItemComponent, areEqual);
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<string>,
): 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<string>,
nextSet: Set<string>,
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<string, Session>,
nextLiveSessionById: Map<string, Session>,
): 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);
@@ -3,6 +3,12 @@ import { cn } from '@/lib/utils';
import type { SessionNode } from './types';
import { useI18n } from '@/lib/i18n';
import { Icon } from "@/components/icon/Icon";
import {
collectSubtreeContainingId,
computeNodeStructureKey,
resolveMenuOpenSessionId,
} from './sessionNodeItemUtils';
import type { SessionNodeRenderExtras } from './sessionNodeItemUtils';
type ActivityItem = {
node: SessionNode;
@@ -22,17 +28,34 @@ type ActivitySection = {
type Props = {
sections: ActivitySection[];
renderSessionNode: (node: SessionNode, depth?: number, groupDirectory?: string | null, projectId?: string | null, archivedBucket?: boolean, secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null, renderContext?: 'project' | 'recent') => React.ReactNode;
renderSessionNode: (
node: SessionNode,
depth?: number,
groupDirectory?: string | null,
projectId?: string | null,
archivedBucket?: boolean,
secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null,
renderContext?: 'project' | 'recent',
renderExtras?: SessionNodeRenderExtras,
) => React.ReactNode;
currentSessionId: string | null;
editingId: string | null;
openSidebarMenuKey: string | null;
variant?: 'section' | 'flat';
initialVisibleCount?: number;
batchSize?: number;
};
type RenderExtras = SessionNodeRenderExtras;
const MAX_VISIBLE_RECENT_SESSIONS = 7;
export function SidebarActivitySections({
sections,
renderSessionNode,
currentSessionId,
editingId,
openSidebarMenuKey,
variant = 'section',
initialVisibleCount = MAX_VISIBLE_RECENT_SESSIONS,
batchSize = MAX_VISIBLE_RECENT_SESSIONS,
@@ -77,6 +100,36 @@ export function SidebarActivitySections({
});
}, [batchSize]);
const buildRenderExtras = React.useCallback((nodes: SessionNode[]) => {
const subtreeContainsActive = new Set<string>();
collectSubtreeContainingId(nodes, currentSessionId, subtreeContainsActive);
const subtreeContainsEditing = new Set<string>();
collectSubtreeContainingId(nodes, editingId, subtreeContainsEditing);
const menuOpenSessionId = resolveMenuOpenSessionId(nodes, openSidebarMenuKey, 'recent', false);
const nodeStructureKeyByNode = new WeakMap<SessionNode, string>();
const visit = (node: SessionNode): void => {
nodeStructureKeyByNode.set(node, computeNodeStructureKey(node));
node.children.forEach(visit);
};
nodes.forEach(visit);
const childRenderExtrasFor = (child: SessionNode): RenderExtras => ({
subtreeContainsActive,
subtreeContainsEditing,
menuOpenSessionId,
nodeStructureKey: nodeStructureKeyByNode.get(child) ?? '',
childRenderExtrasFor,
});
return (node: SessionNode): RenderExtras => ({
subtreeContainsActive,
subtreeContainsEditing,
menuOpenSessionId,
nodeStructureKey: nodeStructureKeyByNode.get(node) ?? '',
childRenderExtrasFor,
});
}, [currentSessionId, editingId, openSidebarMenuKey]);
const visibleSections = sections.filter((section) => section.items.length > 0);
if (visibleSections.length === 0) {
return null;
@@ -93,11 +146,22 @@ export function SidebarActivitySections({
const visibleItems = section.items.slice(0, visibleLimit);
const remainingCount = section.items.length - visibleItems.length;
const canShowFewer = !flatVariant && section.items.length > initialVisibleCount && remainingCount === 0;
const getRenderExtras = buildRenderExtras(visibleItems.map((item) => item.node));
const renderItem = (item: ActivityItem) => renderSessionNode(
item.node,
0,
item.groupDirectory,
item.projectId,
false,
item.secondaryMeta,
'recent',
getRenderExtras(item.node),
);
if (flatVariant) {
return (
<div key={section.key} className="space-y-0.5">
{visibleItems.map((item) => renderSessionNode(item.node, 0, item.groupDirectory, item.projectId, false, item.secondaryMeta, 'recent'))}
{visibleItems.map(renderItem)}
{remainingCount > 0 ? (
<button
type="button"
@@ -126,7 +190,7 @@ export function SidebarActivitySections({
</button>
{!isCollapsed ? (
<div className={cn('space-y-0.5 pl-7')}>
{visibleItems.map((item) => renderSessionNode(item.node, 0, item.groupDirectory, item.projectId, false, item.secondaryMeta, 'recent'))}
{visibleItems.map(renderItem)}
{remainingCount > 0 ? (
<button
type="button"
@@ -42,7 +42,15 @@ type Props = {
hasSessionSearchQuery: boolean;
emptyState: React.ReactNode;
searchEmptyState: React.ReactNode;
renderGroupSessions: (group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean, dragHandleProps?: SortableDragHandleProps | null, compactBodyPadding?: boolean) => React.ReactNode;
renderGroupSessions: (
group: SessionGroup,
groupKey: string,
projectId?: string | null,
hideGroupLabel?: boolean,
dragHandleProps?: SortableDragHandleProps | null,
compactBodyPadding?: boolean,
scrollContainerRef?: React.RefObject<HTMLElement | null>,
) => React.ReactNode;
homeDirectory: string | null;
collapsedProjects: Set<string>;
hideDirectoryControls: boolean;
@@ -78,6 +86,39 @@ export function SidebarProjectsList(props: Props): React.ReactNode {
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
);
// Threaded into SessionGroupSection so the archived-bucket virtualizer
// can resolve the scrolling ancestor synchronously (no getComputedStyle
// walk) and skip the cost of a style recalc on every render.
const scrollContainerRef = React.useRef<HTMLElement | null>(null);
// Memoize the result of getOrderedGroups. The callback is stable
// (deps: [groupOrderByProject]) and `section.groups` is a stable
// reference from useSessionSidebarSections, but the caller discards
// the result on every render and the callback allocates a new array
// each time. With many projects and many sidebar re-renders this
// builds O(P) arrays per render. The cache returns the same array
// reference when the inputs haven't changed, so the downstream
// orderedGroups.filter/find work and any consumer-memoization see a
// stable reference.
const orderedGroupsCacheRef = React.useRef<Map<string, { groups: SessionGroup[]; ordered: SessionGroup[] }>>(new Map());
const cachedGetOrderedGroups = (projectId: string, groups: SessionGroup[]): SessionGroup[] => {
const cache = orderedGroupsCacheRef.current;
const hit = cache.get(projectId);
if (hit && hit.groups === groups) {
return hit.ordered;
}
const ordered = props.getOrderedGroups(projectId, groups);
cache.set(projectId, { groups, ordered });
// Bound the cache so re-ordering projects (which replaces the
// projects list and invalidates every projectId) doesn't grow
// unboundedly.
if (cache.size > 256) {
const firstKey = cache.keys().next().value;
if (firstKey !== undefined) cache.delete(firstKey);
}
return ordered;
};
if (props.sharedSessionsOnly) {
return (
<ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pr-2', props.mobileVariant ? '' : '')}>
@@ -96,7 +137,7 @@ export function SidebarProjectsList(props: Props): React.ReactNode {
}
return (
<ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-2', props.mobileVariant ? '' : '')}>
<ScrollableOverlay ref={scrollContainerRef} useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-2', props.mobileVariant ? '' : '')}>
{props.topContent}
{props.showOnlyMainWorkspace ? (
<div className="space-y-[0.6rem] py-1">
@@ -124,7 +165,7 @@ export function SidebarProjectsList(props: Props): React.ReactNode {
const hideGroupLabel = group.id === primaryGroup.id;
return (
<React.Fragment key={groupKey}>
{props.renderGroupSessions(group, groupKey, activeSection.project.id, hideGroupLabel, null, true)}
{props.renderGroupSessions(group, groupKey, activeSection.project.id, hideGroupLabel, null, true, scrollContainerRef)}
</React.Fragment>
);
});
@@ -158,7 +199,7 @@ export function SidebarProjectsList(props: Props): React.ReactNode {
const isCollapsed = props.collapsedProjects.has(projectKey);
const isActiveProject = projectKey === props.activeProjectId;
const isRepo = props.projectRepoStatus.get(projectKey);
const orderedGroups = props.getOrderedGroups(projectKey, section.groups);
const orderedGroups = cachedGetOrderedGroups(projectKey, section.groups);
const rootGroup = orderedGroups.find((group) => group.isMain) ?? null;
const nestedGroups = rootGroup
? orderedGroups.filter((group) => group.id !== rootGroup.id)
@@ -223,13 +264,13 @@ export function SidebarProjectsList(props: Props): React.ReactNode {
});
}}
>
{rootGroup ? props.renderGroupSessions(rootGroup, `${projectKey}:${rootGroup.id}`, projectKey, true) : null}
{rootGroup ? props.renderGroupSessions(rootGroup, `${projectKey}:${rootGroup.id}`, projectKey, true, null, undefined, scrollContainerRef) : null}
<SortableContext items={nestedGroups.map((group) => group.id)} strategy={verticalListSortingStrategy}>
{nestedGroups.map((group) => {
const groupKey = `${projectKey}:${group.id}`;
return (
<SortableGroupItem key={group.id} id={group.id} disabled={props.isInlineEditing}>
{(dragHandleProps) => props.renderGroupSessions(group, groupKey, projectKey, false, dragHandleProps)}
{(dragHandleProps) => props.renderGroupSessions(group, groupKey, projectKey, false, dragHandleProps, undefined, scrollContainerRef)}
</SortableGroupItem>
);
})}
@@ -61,6 +61,16 @@ export const useProjectRepoStatus = (args: Args): void => {
// any single project's branch settles (the old N² cascade).
const resolvedInputKeyByProjectId = React.useRef<Map<string, string>>(new Map());
// TTL cache: when a project's `gitRepoStatus` refreshes (it can fire on
// every background poll even if the branch is unchanged), skip the
// `getRootBranch` re-resolution if we resolved the same input within
// the TTL window. 5 minutes matches the polling interval that typically
// drives these refreshes, so we always serve cached results on
// background updates and only re-resolve on cold start or actual
// branch changes (those still invalidate via the input-key check).
const rootBranchCacheRef = React.useRef<Map<string, { branch: string; at: number }>>(new Map());
const ROOT_BRANCH_TTL_MS = 5 * 60_000;
React.useEffect(() => {
let cancelled = false;
@@ -73,13 +83,16 @@ export const useProjectRepoStatus = (args: Args): void => {
for (const id of resolvedInputKeyByProjectId.current.keys()) {
if (!validIds.has(id)) {
resolvedInputKeyByProjectId.current.delete(id);
rootBranchCacheRef.current.delete(id);
}
}
const now = Date.now();
const pending = normalizedProjects.filter((project) => {
const status = gitRepoStatus.get(project.normalizedPath);
if (status?.isGitRepo === false) {
resolvedInputKeyByProjectId.current.delete(project.id);
rootBranchCacheRef.current.delete(project.id);
return false;
}
if (status?.isGitRepo !== true || status.branch === null) {
@@ -88,7 +101,21 @@ export const useProjectRepoStatus = (args: Args): void => {
const currentBranch = status.branch.trim();
const currentInputKey = `${project.normalizedPath}\0${currentBranch}`;
const lastInputKey = resolvedInputKeyByProjectId.current.get(project.id);
return lastInputKey === undefined || lastInputKey !== currentInputKey;
if (lastInputKey === currentInputKey) {
// We've already resolved this exact (path, branch) pair.
// The TTL cache is just an extra protection for the case
// where the input key was reset by a transient blip —
// keep the existing map entry fresh so future re-renders
// hit the cache instead of refetching.
const cached = rootBranchCacheRef.current.get(project.id);
if (cached) cached.at = now;
return false;
}
// Same input? Serve from TTL cache if it's still warm.
if (lastInputKey !== undefined && now - (rootBranchCacheRef.current.get(project.id)?.at ?? 0) < ROOT_BRANCH_TTL_MS) {
return false;
}
return true;
});
if (pending.length === 0) {
@@ -113,6 +140,7 @@ export const useProjectRepoStatus = (args: Args): void => {
return;
}
const nowAfter = Date.now();
setProjectRootBranches((prev) => {
const next = new Map(prev);
resolved.forEach(({ id, branch }) => {
@@ -122,8 +150,11 @@ export const useProjectRepoStatus = (args: Args): void => {
});
return next;
});
resolved.forEach(({ id, inputKey }) => {
resolved.forEach(({ id, inputKey, branch }) => {
resolvedInputKeyByProjectId.current.set(id, inputKey);
if (branch) {
rootBranchCacheRef.current.set(id, { branch, at: nowAfter });
}
});
};
void run();
@@ -133,5 +164,9 @@ export const useProjectRepoStatus = (args: Args): void => {
cancelled = true;
clearTimeout(timer);
};
// ROOT_BRANCH_TTL_MS is a module-level constant; intentionally not
// in the deps array since it never changes during the component
// lifetime.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [normalizedProjects, projectGitBranchesKey, gitRepoStatus, setProjectRootBranches]);
};
@@ -5,11 +5,22 @@ import { dedupeSessionsById, isSessionRelatedToProject, normalizePath } from '..
type WorktreeMeta = { path: string };
type NormalizedProject = { id: string; normalizedPath: string };
type Args = {
isVSCode: boolean;
sessions: Session[];
archivedSessions: Session[];
availableWorktreesByProject: Map<string, WorktreeMeta[]>;
/**
* The set of normalized projects the sidebar will render. Used in
* Layer 4.13 to precompute the allowed directory set so the per-row
* `sessionsByDirectory` Map only contains buckets the sidebar will
* actually consume. With 10 projects × 5 worktrees and 100 sessions
* per directory this drops the Map from N entries to the small
* subset the sidebar needs.
*/
normalizedProjects: NormalizedProject[];
};
export const useProjectSessionLists = (args: Args) => {
@@ -18,8 +29,32 @@ export const useProjectSessionLists = (args: Args) => {
sessions,
archivedSessions,
availableWorktreesByProject,
normalizedProjects,
} = args;
// Precompute the set of directories the sidebar will ever ask about:
// every project's normalized path plus the path of each registered
// worktree. Walking this set is O(P + W) per Sidebar render and lets
// us skip the bulk of `sessions` (whose directory is not associated
// with a known project) when building `sessionsByDirectory`.
const allowedDirectories = React.useMemo(() => {
const set = new Set<string>();
normalizedProjects.forEach((project) => {
if (project.normalizedPath) {
set.add(project.normalizedPath);
}
});
if (!isVSCode) {
for (const worktrees of availableWorktreesByProject.values()) {
for (const worktree of worktrees) {
const normalized = normalizePath(worktree.path);
if (normalized) set.add(normalized);
}
}
}
return set;
}, [normalizedProjects, availableWorktreesByProject, isVSCode]);
const sessionsByDirectory = React.useMemo(() => {
const next = new Map<string, Session[]>();
sessions.forEach((session) => {
@@ -27,13 +62,21 @@ export const useProjectSessionLists = (args: Args) => {
if (!directory) {
return;
}
// Skip sessions whose directory doesn't belong to any known
// project or worktree. Without this filter the Map grows with
// every session the server has ever seen, even ones for
// long-removed worktrees; the sidebar's downstream filters
// would then drop them anyway.
if (!allowedDirectories.has(directory)) {
return;
}
const collection = next.get(directory) ?? [];
collection.push(session);
next.set(directory, collection);
});
return next;
}, [sessions]);
}, [sessions, allowedDirectories]);
const getSessionsForProject = React.useCallback(
(project: { normalizedPath: string }) => {
@@ -22,11 +22,9 @@ type Args = {
openNewSessionDraft: (options?: { directoryOverride?: string | null }) => void;
setActiveMainTab: (tab: MainTab) => void;
setSessionSwitcherOpen: (open: boolean) => void;
sessions: Session[];
worktreeMetadata: Map<string, { path?: string | null }>;
};
export const useProjectSessionSelection = (args: Args): { currentSessionDirectory: string | null } => {
export const useProjectSessionSelection = (args: Args): void => {
const {
projectSections,
activeProjectId,
@@ -39,8 +37,6 @@ export const useProjectSessionSelection = (args: Args): { currentSessionDirector
openNewSessionDraft,
setActiveMainTab,
setSessionSwitcherOpen,
sessions,
worktreeMetadata,
} = args;
const projectSessionMeta = React.useMemo(() => {
@@ -101,6 +97,7 @@ export const useProjectSessionSelection = (args: Args): { currentSessionDirector
if (previousActiveProjectRef.current === activeProjectId) {
return;
}
const section = projectSections.find((item) => item.project.id === activeProjectId);
if (!section) {
return;
@@ -173,20 +170,4 @@ export const useProjectSessionSelection = (args: Args): { currentSessionDirector
});
}, [activeProjectId, currentSessionId, projectSessionMeta, setActiveSessionByProject]);
const currentSessionDirectory = React.useMemo(() => {
if (!currentSessionId) {
return null;
}
const metadataPath = worktreeMetadata.get(currentSessionId)?.path;
if (metadataPath) {
return normalizePath(metadataPath) ?? metadataPath;
}
const activeSession = sessions.find((session) => session.id === currentSessionId);
if (!activeSession) {
return null;
}
return normalizePath((activeSession as Session & { directory?: string | null }).directory ?? null);
}, [currentSessionId, sessions, worktreeMetadata]);
return { currentSessionDirectory };
};
@@ -105,9 +105,9 @@ export const useSessionActions = (args: Args) => {
args.setEditTitle(sessionTitle);
}, [args]);
const handleSaveEdit = React.useCallback(async () => {
const handleSaveEdit = React.useCallback(async (titleOverride?: string) => {
if (!args.editingId) return;
const trimmed = args.editTitle.trim();
const trimmed = (titleOverride ?? args.editTitle).trim();
if (trimmed) {
await args.updateSessionTitle(args.editingId, trimmed);
}
@@ -0,0 +1,237 @@
import React from 'react';
import { toast } from '@/components/ui';
import { useI18n } from '@/lib/i18n';
import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore';
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
type Args = {
isInlineEditing: boolean;
showDeletionDialog: boolean;
foldersMap: Record<string, SessionFolder[]>;
addSessionsToFolder: (scopeKey: string, folderId: string, sessionIds: string[]) => void;
removeSessionsFromFolders: (scopeKey: string, sessionIds: string[]) => void;
createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
archiveSessions: (ids: string[]) => Promise<{ archivedIds: string[]; failedIds: string[] }>;
deleteSessions: (ids: string[]) => Promise<{ deletedIds: string[]; failedIds: string[] }>;
setBulkDeleteConfirm: React.Dispatch<React.SetStateAction<{
sessionCount: number;
archivedBucket: boolean;
} | null>>;
};
/**
* Bulk-action logic for the sidebar. The hot-path concern is that this
* hook subscribes to `useSessionMultiSelectStore` — which can fire on
* every selection toggle and on every setRange/toggleSelected call —
* but the rest of the Sidebar tree only needs the boolean
* `selectionModeEnabled` flag to decide whether to render the
* selection chrome.
*
* To keep that subscription narrow, the heavy work (folders lookup,
* DOM-attribute scanning for the active/archived scope, etc.) is
* deferred behind a `selectedIds.size > 0` check inside the hook
* itself, so toggling selection mode on/off does not force the
* downstream useMemo chain to re-evaluate when no rows are selected.
*/
export const useSidebarBulkActions = (args: Args) => {
const { t } = useI18n();
const {
isInlineEditing,
showDeletionDialog,
foldersMap,
addSessionsToFolder,
removeSessionsFromFolders,
createFolderAndStartRename,
archiveSessions,
deleteSessions,
setBulkDeleteConfirm,
} = args;
const selectionModeEnabled = useSessionMultiSelectStore((state) => state.enabled);
const selectedIdsSize = useSessionMultiSelectStore((state) => state.selectedIds.size);
const hasSelection = selectedIdsSize > 0;
const selectedIds = useSessionMultiSelectStore((state) => state.selectedIds);
const selectionScopeKey = useSessionMultiSelectStore((state) => state.scopeKey);
const handleToggleSelectionMode = React.useCallback(() => {
useSessionMultiSelectStore.getState().toggleMode();
}, []);
const handleExitSelectionMode = React.useCallback(() => {
useSessionMultiSelectStore.getState().disable();
}, []);
// All of the below short-circuit on `hasSelection` so the DOM-scanning
// and folder-lookup work only runs when there's something to act on.
const bulkScopeIsArchived = React.useMemo(() => {
if (!hasSelection) return false;
if (typeof document === 'undefined') return false;
let sawActive = false;
let sawArchived = false;
for (const id of selectedIds) {
const rows = document.querySelectorAll<HTMLElement>(`[data-session-row="${CSS.escape(id)}"]`);
for (const row of rows) {
if (row.getAttribute('data-session-archived') === '1') sawArchived = true;
else sawActive = true;
}
}
return sawArchived && !sawActive;
}, [hasSelection, selectedIds]);
const derivedSelectionScope = React.useMemo(() => {
if (selectionScopeKey) return selectionScopeKey;
if (!hasSelection) return null;
if (typeof document === 'undefined') return null;
for (const id of selectedIds) {
const row = document.querySelector<HTMLElement>(`[data-session-row="${CSS.escape(id)}"]`);
const scope = row?.getAttribute('data-session-scope');
if (scope && scope.length > 0) return scope;
}
return null;
}, [hasSelection, selectedIds, selectionScopeKey]);
const bulkScopeFolders = React.useMemo(() => {
if (!derivedSelectionScope) return [];
return foldersMap[derivedSelectionScope] ?? [];
}, [foldersMap, derivedSelectionScope]);
const bulkCanRemoveFromFolder = React.useMemo(() => {
if (!derivedSelectionScope || !hasSelection) return false;
const scopeFolders = foldersMap[derivedSelectionScope] ?? [];
for (const folder of scopeFolders) {
for (const id of folder.sessionIds) {
if (selectedIds.has(id)) return true;
}
}
return false;
}, [foldersMap, derivedSelectionScope, hasSelection, selectedIds]);
const handleBulkMoveToFolder = React.useCallback((folderId: string) => {
if (!derivedSelectionScope || !hasSelection) return;
addSessionsToFolder(derivedSelectionScope, folderId, Array.from(selectedIds));
}, [addSessionsToFolder, selectedIds, derivedSelectionScope, hasSelection]);
const handleBulkCreateFolderAndMove = React.useCallback(() => {
if (!derivedSelectionScope || !hasSelection) return;
const newFolder = createFolderAndStartRename(derivedSelectionScope);
if (!newFolder) return;
addSessionsToFolder(derivedSelectionScope, newFolder.id, Array.from(selectedIds));
}, [addSessionsToFolder, createFolderAndStartRename, selectedIds, derivedSelectionScope, hasSelection]);
const handleBulkRemoveFromFolder = React.useCallback(() => {
if (!derivedSelectionScope || !hasSelection) return;
removeSessionsFromFolders(derivedSelectionScope, Array.from(selectedIds));
}, [removeSessionsFromFolders, selectedIds, derivedSelectionScope, hasSelection]);
const executeBulkDelete = React.useCallback(async () => {
const ids = Array.from(selectedIds);
if (ids.length === 0) return;
if (bulkScopeIsArchived) {
const { deletedIds, failedIds } = await deleteSessions(ids);
if (deletedIds.length > 0) {
toast.success(deletedIds.length === 1
? t('sessions.sidebar.bulkActions.deletedSingle', { count: deletedIds.length })
: t('sessions.sidebar.bulkActions.deletedPlural', { count: deletedIds.length }));
}
if (failedIds.length > 0) {
toast.error(failedIds.length === 1
? t('sessions.sidebar.bulkActions.failedDeleteSingle', { count: failedIds.length })
: t('sessions.sidebar.bulkActions.failedDeletePlural', { count: failedIds.length }));
}
} else {
const { archivedIds, failedIds } = await archiveSessions(ids);
if (archivedIds.length > 0) {
toast.success(archivedIds.length === 1
? t('sessions.sidebar.bulkActions.archivedSingle', { count: archivedIds.length })
: t('sessions.sidebar.bulkActions.archivedPlural', { count: archivedIds.length }));
}
if (failedIds.length > 0) {
toast.error(failedIds.length === 1
? t('sessions.sidebar.bulkActions.failedArchiveSingle', { count: failedIds.length })
: t('sessions.sidebar.bulkActions.failedArchivePlural', { count: failedIds.length }));
}
}
useSessionMultiSelectStore.getState().clear();
}, [archiveSessions, bulkScopeIsArchived, deleteSessions, selectedIds, t]);
const handleBulkDelete = React.useCallback(() => {
if (!hasSelection) return;
const count = selectedIds.size;
if (!showDeletionDialog) {
void executeBulkDelete();
return;
}
setBulkDeleteConfirm({ sessionCount: count, archivedBucket: bulkScopeIsArchived });
}, [bulkScopeIsArchived, executeBulkDelete, selectedIds, showDeletionDialog, setBulkDeleteConfirm, hasSelection]);
const confirmBulkDelete = React.useCallback(async () => {
setBulkDeleteConfirm(null);
await executeBulkDelete();
// setBulkDeleteConfirm is a stable React state setter; intentionally
// omitted from deps to avoid forcing the keyboard-listener effect
// below to re-subscribe on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [executeBulkDelete]);
React.useEffect(() => {
if (!selectionModeEnabled) return;
const isMac = typeof navigator !== 'undefined' && /Macintosh|Mac OS X/.test(navigator.userAgent || '');
const listener = (event: KeyboardEvent) => {
if (isInlineEditing) return;
const target = event.target as HTMLElement | null;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
return;
}
const modifier = isMac ? event.metaKey : event.ctrlKey;
if (event.key === 'Escape') {
event.preventDefault();
useSessionMultiSelectStore.getState().disable();
return;
}
if (modifier && event.key === 'Backspace') {
event.preventDefault();
handleBulkDelete();
return;
}
if (modifier && (event.key === 'a' || event.key === 'A')) {
const rows = typeof document !== 'undefined'
? Array.from(document.querySelectorAll<HTMLElement>('[data-session-row]'))
: [];
if (rows.length === 0) return;
event.preventDefault();
const currentScope = useSessionMultiSelectStore.getState().scopeKey;
const targetScope = currentScope
?? rows[0]?.getAttribute('data-session-scope')
?? null;
const scopeFilter = (el: HTMLElement): boolean => {
if (!targetScope) return true;
return el.getAttribute('data-session-scope') === targetScope;
};
const ids = rows
.filter(scopeFilter)
.map((el) => el.getAttribute('data-session-row'))
.filter((id): id is string => typeof id === 'string' && id.length > 0);
if (ids.length === 0) return;
useSessionMultiSelectStore.getState().replaceAll(ids, targetScope || null);
}
};
window.addEventListener('keydown', listener);
return () => window.removeEventListener('keydown', listener);
}, [handleBulkDelete, isInlineEditing, selectionModeEnabled]);
return {
selectionModeEnabled,
hasSelection,
selectedIdsSize,
bulkScopeIsArchived,
derivedSelectionScope,
bulkScopeFolders,
bulkCanRemoveFromFolder,
handleToggleSelectionMode,
handleExitSelectionMode,
handleBulkMoveToFolder,
handleBulkCreateFolderAndMove,
handleBulkRemoveFromFolder,
handleBulkDelete,
confirmBulkDelete,
};
};
@@ -0,0 +1,120 @@
import type { SessionNode } from './types';
/**
* Per-row render extras precomputed once per group render and threaded down to
* each `SessionNodeItem`. Hoisting these out of the row `React.memo` comparator
* turns an O(rows × subtree-depth) walk into per-row `Set.has`/string compares.
*
* The child variant intentionally omits `childRenderExtrasFor` — the resolver is
* shared from the group and re-passed, so it does not need to recurse through
* each child's extras object.
*/
export type SessionNodeChildRenderExtras = {
subtreeContainsActive: Set<string>;
subtreeContainsEditing: Set<string>;
menuOpenSessionId: string | null;
nodeStructureKey: string;
};
export type SessionNodeRenderExtras<TNode = SessionNode> = SessionNodeChildRenderExtras & {
childRenderExtrasFor?: (child: TNode) => SessionNodeChildRenderExtras;
};
/**
* Walk `nodes` and add `node.session.id` to `result` for every node
* whose subtree contains `targetId`. This is used to precompute, once
* per SessionGroupSection render, which rows need to update when
* `currentSessionId` or `editingId` changes. With M visible rows, this
* turns an O(M × subtree-depth) walk inside `SessionNodeItem.areEqual`
* into a single O(M) `Set.has` per row.
*/
export const collectSubtreeContainingId = (
nodes: SessionNode[],
targetId: string | null,
result: Set<string>,
): void => {
if (!targetId) return;
const visit = (node: SessionNode): boolean => {
let containsTarget = node.session.id === targetId;
for (const child of node.children) {
containsTarget = visit(child) || containsTarget;
}
if (containsTarget) {
result.add(node.session.id);
}
return containsTarget;
};
for (const node of nodes) {
visit(node);
}
};
export const nodeContainsSessionId = (node: SessionNode, sessionId: string | null): boolean => {
if (!sessionId) {
return false;
}
if (node.session.id === sessionId) {
return true;
}
for (const child of node.children) {
if (nodeContainsSessionId(child, sessionId)) {
return true;
}
}
return false;
};
/**
* Build a structural key for `node` that encodes the IDs of all
* descendants. Used by `SessionNodeItem.areEqual` so a reference-only
* rebuild of the tree (which happens on every `buildGroupedSessions`
* pass) can be detected with a single string compare instead of a
* recursive walk per row.
*/
export const computeNodeStructureKey = (node: SessionNode): string => {
if (node.children.length === 0) {
return '';
}
const childKeys = node.children.map((child) => {
if (child.children.length === 0) {
return child.session.id;
}
return `${child.session.id}:${computeNodeStructureKey(child)}`;
});
return childKeys.join('|');
};
/**
* Resolve the session id whose sidebar menu is open, or null if no
* menu is open. Only one row can have its menu open at a time.
*/
export const resolveMenuOpenSessionId = (
nodes: SessionNode[],
menuKey: string | null,
renderContext: 'project' | 'recent',
archivedBucket: boolean,
): string | null => {
if (!menuKey) return null;
const bucketTag = archivedBucket ? 'archived' : 'active';
let result: string | null = null;
const visit = (node: SessionNode): boolean => {
const nodeMenuKey = `${renderContext}:${bucketTag}:${node.session.id}`;
if (nodeMenuKey === menuKey) {
result = node.session.id;
return true;
}
for (const child of node.children) {
if (visit(child)) return true;
}
return false;
};
nodes.forEach((node) => visit(node));
return result;
};