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
@@ -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}