feat: bulk select sessions in sidebar

Select toggle in header enters selection mode. Click toggles, shift-click
ranges, parent auto-includes subtasks, Cmd/Ctrl+A selects visible, Esc
exits, Cmd/Ctrl+Backspace deletes. Bulk bar: move to folder (existing or
new inline), archive/delete via existing confirm + "Never ask" pref.
Cross-scope forbidden — new scope clears previous.

Also: web sidebar header folds toggle into action row (no separate chrome
row, same position on toggle); sidebar min width bumped 250→280 so icons
fit; minimal-mode hover reveal padding trimmed.
This commit is contained in:
Bohdan Triapitsyn
2026-04-20 23:32:32 +03:00
parent daa485c5d9
commit 08258908c2
8 changed files with 709 additions and 13 deletions
@@ -48,11 +48,15 @@ import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
import type { WorktreeMetadata } from '@/types/worktree';
import type { SortableDragHandleProps } from './sidebar/sortableItems';
import {
BulkSessionDeleteConfirmDialog,
FolderDeleteConfirmDialog,
SessionDeleteConfirmDialog,
type BulkDeleteSessionsConfirmState,
type DeleteFolderConfirmState,
type DeleteSessionConfirmState,
} from './sidebar/ConfirmDialogs';
import { BulkActionBar } from './sidebar/BulkActionBar';
import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore';
import { type SessionGroup, type SessionNode } from './sidebar/types';
import {
type ActiveNowEntry,
@@ -145,6 +149,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const [renameFolderDraft, setRenameFolderDraft] = React.useState('');
const [deleteSessionConfirm, setDeleteSessionConfirm] = React.useState<DeleteSessionConfirmState>(null);
const [deleteFolderConfirm, setDeleteFolderConfirm] = React.useState<DeleteFolderConfirmState>(null);
const [bulkDeleteConfirm, setBulkDeleteConfirm] = React.useState<BulkDeleteSessionsConfirmState>(null);
const [pinnedSessionIds, setPinnedSessionIds] = React.useState<Set<string>>(() => {
try {
const raw = getSafeStorage().getItem(SESSION_PINNED_STORAGE_KEY);
@@ -251,7 +256,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const renameFolder = useSessionFoldersStore((state) => state.renameFolder);
const deleteFolder = useSessionFoldersStore((state) => state.deleteFolder);
const addSessionToFolder = useSessionFoldersStore((state) => state.addSessionToFolder);
const addSessionsToFolder = useSessionFoldersStore((state) => state.addSessionsToFolder);
const removeSessionFromFolder = useSessionFoldersStore((state) => state.removeSessionFromFolder);
const removeSessionsFromFolders = useSessionFoldersStore((state) => state.removeSessionsFromFolders);
const toggleFolderCollapse = useSessionFoldersStore((state) => state.toggleFolderCollapse);
const cleanupSessions = useSessionFoldersStore((state) => state.cleanupSessions);
const getSessionFolderId = useSessionFoldersStore((state) => state.getSessionFolderId);
@@ -389,7 +396,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}
return /Macintosh|Mac OS X/.test(navigator.userAgent || '');
}, []);
const showDesktopSidebarChrome = !mobileVariant && !isVSCode;
const isWebRuntime = !mobileVariant && !isVSCode && !isDesktopShellRuntime;
const showDesktopSidebarChrome = !mobileVariant && !isVSCode && !isWebRuntime;
const desktopSidebarTopPaddingClass = isDesktopShellRuntime && isMacPlatform && !isDesktopWindowFullscreen ? 'pl-[5.5rem]' : 'pl-3';
const desktopSidebarToggleButtonClass = 'app-region-no-drag inline-flex h-8 w-8 items-center justify-center rounded-md typography-ui-label font-medium text-foreground transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50';
@@ -1380,6 +1388,154 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
/>
) : 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(`Deleted ${deletedIds.length} session${deletedIds.length === 1 ? '' : 's'}`);
if (failedIds.length > 0) toast.error(`Failed to delete ${failedIds.length} session${failedIds.length === 1 ? '' : 's'}`);
} else {
const { archivedIds, failedIds } = await archiveSessions(ids);
if (archivedIds.length > 0) toast.success(`Archived ${archivedIds.length} session${archivedIds.length === 1 ? '' : 's'}`);
if (failedIds.length > 0) toast.error(`Failed to archive ${failedIds.length} session${failedIds.length === 1 ? '' : 's'}`);
}
useSessionMultiSelectStore.getState().clear();
}, [archiveSessions, bulkScopeIsArchived, deleteSessions, selectedIds]);
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 handleSidebarNewSession = React.useCallback(() => {
setActiveMainTab('chat');
if (mobileVariant) {
@@ -1449,6 +1605,10 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
collapseAllProjects={collapseAllProjects}
expandAllProjects={expandAllProjects}
openScheduledTasksDialog={() => setScheduledTasksDialogOpen(true)}
selectionModeEnabled={selectionModeEnabled}
onToggleSelectionMode={handleToggleSelectionMode}
showSidebarToggle={isWebRuntime}
onToggleSidebar={toggleSidebar}
/>
<SidebarProjectsList
@@ -1485,6 +1645,21 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
isInlineEditing={isInlineEditing}
/>
{selectionModeEnabled && selectedIds.size > 0 ? (
<BulkActionBar
selectedCount={selectedIds.size}
scopeKey={derivedSelectionScope}
scopeFolders={bulkScopeFolders}
archivedBucket={bulkScopeIsArchived}
onMoveToFolder={handleBulkMoveToFolder}
onCreateFolderAndMove={handleBulkCreateFolderAndMove}
onRemoveFromFolder={handleBulkRemoveFromFolder}
canRemoveFromFolder={bulkCanRemoveFromFolder}
onDelete={handleBulkDelete}
onDone={handleExitSelectionMode}
/>
) : null}
<SidebarFooter
onOpenSettings={handleOpenSettings}
onOpenShortcuts={toggleHelpDialog}
@@ -1556,6 +1731,14 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
setValue={setDeleteFolderConfirm}
onConfirm={confirmDeleteFolder}
/>
<BulkSessionDeleteConfirmDialog
value={bulkDeleteConfirm}
setValue={setBulkDeleteConfirm}
showDeletionDialog={showDeletionDialog}
setShowDeletionDialog={setShowDeletionDialog}
onConfirm={confirmBulkDelete}
/>
</div>
);
};