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
@@ -3,8 +3,8 @@ import { cn } from '@/lib/utils';
import { ErrorBoundary } from '../ui/ErrorBoundary';
import { useUIStore } from '@/stores/useUIStore';
export const SIDEBAR_CONTENT_WIDTH = 250;
const SIDEBAR_MIN_WIDTH = 250;
export const SIDEBAR_CONTENT_WIDTH = 280;
const SIDEBAR_MIN_WIDTH = 280;
const SIDEBAR_MAX_WIDTH = 500;
interface SidebarProps {
@@ -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>
);
};
@@ -0,0 +1,132 @@
import React from 'react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import {
RiAddLine,
RiCloseLine,
RiDeleteBinLine,
RiFolderLine,
} from '@remixicon/react';
import { cn } from '@/lib/utils';
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
type Props = {
selectedCount: number;
scopeKey: string | null;
scopeFolders: SessionFolder[];
archivedBucket: boolean;
onMoveToFolder: (folderId: string) => void;
onCreateFolderAndMove: () => void;
onRemoveFromFolder: () => void;
canRemoveFromFolder: boolean;
onDelete: () => void;
onDone: () => void;
};
export const BulkActionBar: React.FC<Props> = ({
selectedCount,
scopeKey,
scopeFolders,
archivedBucket,
onMoveToFolder,
onCreateFolderAndMove,
onRemoveFromFolder,
canRemoveFromFolder,
onDelete,
onDone,
}) => {
const canMoveToFolder = Boolean(scopeKey) && !archivedBucket;
const destructiveLabel = archivedBucket ? 'Delete' : 'Archive';
const iconButtonClass = 'inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-interactive-hover/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50';
const destructiveIconButtonClass = 'inline-flex h-7 w-7 items-center justify-center rounded-md text-destructive hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/50';
return (
<div className="flex shrink-0 items-center gap-1 border-t border-border px-2.5 py-1.5">
<span className="typography-ui-label text-muted-foreground whitespace-nowrap">
{selectedCount} selected
</span>
<div className="ml-auto flex items-center gap-0.5">
{canMoveToFolder ? (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
className={iconButtonClass}
aria-label="Move to folder"
>
<RiFolderLine className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}><p>Move to folder</p></TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="min-w-[180px]">
{scopeFolders.length === 0 ? (
<DropdownMenuItem disabled className="text-muted-foreground">
No folders yet
</DropdownMenuItem>
) : (
scopeFolders.map((folder) => (
<DropdownMenuItem key={folder.id} onClick={() => onMoveToFolder(folder.id)}>
<span className="flex-1 truncate">{folder.name}</span>
</DropdownMenuItem>
))
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onCreateFolderAndMove}>
<RiAddLine className="mr-1 h-4 w-4" />
New folder...
</DropdownMenuItem>
{canRemoveFromFolder ? (
<DropdownMenuItem
onClick={onRemoveFromFolder}
className="text-destructive focus:text-destructive"
>
<RiCloseLine className="mr-1 h-4 w-4" />
Remove from folder
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onDelete}
className={cn(destructiveIconButtonClass)}
aria-label={destructiveLabel}
>
<RiDeleteBinLine className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}><p>{destructiveLabel}</p></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onDone}
className={iconButtonClass}
aria-label="Exit selection"
>
<RiCloseLine className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}><p>Exit selection</p></TooltipContent>
</Tooltip>
</div>
</div>
);
};
@@ -65,6 +65,66 @@ export function SessionDeleteConfirmDialog(props: {
);
}
export type BulkDeleteSessionsConfirmState = {
sessionCount: number;
archivedBucket: boolean;
} | null;
export function BulkSessionDeleteConfirmDialog(props: {
value: BulkDeleteSessionsConfirmState;
setValue: (next: BulkDeleteSessionsConfirmState) => void;
showDeletionDialog: boolean;
setShowDeletionDialog: (next: boolean) => void;
onConfirm: () => Promise<void> | void;
}): React.ReactNode {
const { value, setValue, showDeletionDialog, setShowDeletionDialog, onConfirm } = props;
const archived = value?.archivedBucket === true;
const n = value?.sessionCount ?? 0;
const plural = n === 1 ? '' : 's';
const title = archived ? 'Delete sessions?' : 'Archive sessions?';
const description = archived
? `${n} session${plural} will be permanently deleted.`
: `${n} session${plural} will be archived.`;
return (
<Dialog open={Boolean(value)} onOpenChange={(open) => { if (!open) setValue(null); }}>
<DialogContent showCloseButton={false} className="max-w-sm gap-5">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter className="w-full sm:items-center sm:justify-between">
<button
type="button"
onClick={() => setShowDeletionDialog(!showDeletionDialog)}
className="inline-flex items-center gap-1.5 typography-ui-label text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50"
aria-pressed={!showDeletionDialog}
>
{!showDeletionDialog ? <RiCheckboxLine className="h-4 w-4 text-primary" /> : <RiCheckboxBlankLine className="h-4 w-4" />}
Never ask
</button>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setValue(null)}
className="inline-flex h-8 items-center justify-center rounded-md border border-border px-3 typography-ui-label text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
>
Cancel
</button>
<button
type="button"
onClick={() => void onConfirm()}
className="inline-flex h-8 items-center justify-center rounded-md bg-destructive px-3 typography-ui-label text-destructive-foreground hover:bg-destructive/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/50"
>
{archived ? 'Delete' : 'Archive'}
</button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export type DeleteFolderConfirmState = {
scopeKey: string;
folderId: string;
@@ -44,6 +44,7 @@ import type { SessionNode, SessionSummaryMeta } from './types';
import { formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText, resolveSessionDiffStats } from './utils';
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
import { useSessionUnseenCount } from '@/sync/notification-store';
import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore';
type Folder = { id: string; name: string; sessionIds: string[] };
@@ -250,14 +251,37 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
const hideOnHoverClass = isVSCode
? 'group-hover:opacity-0'
: 'group-hover:opacity-0 group-focus-within:opacity-0';
const revealPaddingClass = isVSCode
? 'group-hover:pr-5'
: 'group-hover:pr-5 group-focus-within:pr-5';
const revealPaddingClass = isMinimalMode
? (isVSCode
? 'group-hover:pr-1'
: 'group-hover:pr-1 group-focus-within:pr-1')
: (isVSCode
? 'group-hover:pr-5'
: 'group-hover:pr-5 group-focus-within:pr-5');
const suppressNextSelectRef = React.useRef(false);
const session = node.session;
const liveSession = useSession(session.id);
const resolvedSession = liveSession ?? session;
const selectionModeEnabled = useSessionMultiSelectStore((state) => state.enabled);
const isRowSelected = useSessionMultiSelectStore(
React.useCallback((state) => state.selectedIds.has(session.id), [session.id]),
);
const toggleRowSelected = useSessionMultiSelectStore((state) => state.toggleSelected);
const setRowRange = useSessionMultiSelectStore((state) => state.setRange);
const collectNodeDescendantIds = React.useCallback((root: SessionNode): string[] => {
const out: string[] = [];
const walk = (n: SessionNode) => {
n.children.forEach((child) => {
out.push(child.session.id);
walk(child);
});
};
walk(root);
return out;
}, []);
const menuInstanceKey = `${renderContext}:${archivedBucket ? 'archived' : 'active'}:${session.id}`;
const sessionDirectory =
normalizePath((session as Session & { directory?: string | null }).directory ?? null)
@@ -460,16 +484,35 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
event.stopPropagation();
};
const handleRowSelect = () => {
const handleRowSelect = (event?: React.MouseEvent<HTMLButtonElement>) => {
if (suppressNextSelectRef.current) {
suppressNextSelectRef.current = false;
return;
}
if (selectionModeEnabled) {
event?.preventDefault();
event?.stopPropagation();
if (event?.shiftKey) {
const rows = typeof document !== 'undefined'
? Array.from(document.querySelectorAll<HTMLElement>('[data-session-row]'))
: [];
const orderedIds = rows
.map((el) => el.getAttribute('data-session-row'))
.filter((id): id is string => typeof id === 'string' && id.length > 0);
const currentAnchor = useSessionMultiSelectStore.getState().anchorId;
const descendantsById = new Map<string, string[]>();
descendantsById.set(session.id, collectNodeDescendantIds(node));
setRowRange(currentAnchor, session.id, orderedIds, sessionDirectory ?? null, descendantsById);
return;
}
toggleRowSelected(session.id, sessionDirectory ?? null, collectNodeDescendantIds(node));
return;
}
handleSessionSelect(session.id, sessionDirectory, isMissingDirectory, projectId);
};
const handleRowMouseDown = (event: React.MouseEvent<HTMLButtonElement>) => {
if (event.button === 2 || (event.button === 0 && event.ctrlKey)) {
if (event.button === 2 || (event.button === 0 && event.ctrlKey && !selectionModeEnabled)) {
suppressNextSelectRef.current = true;
}
};
@@ -578,7 +621,15 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
<React.Fragment key={session.id}>
<DraggableSessionRow sessionId={session.id} sessionDirectory={sessionDirectory ?? null} sessionTitle={sessionTitle}>
<div
className={cn('group relative flex items-center rounded-sm px-1.5 py-1', isMissingDirectory ? 'opacity-75' : '', depth > 0 && 'pl-[20px]')}
data-session-row={session.id}
data-session-scope={sessionDirectory ?? ''}
data-session-archived={archivedBucket ? '1' : '0'}
className={cn(
'group relative my-0.5 flex items-center rounded-sm px-1.5 py-1',
isMissingDirectory ? 'opacity-75' : '',
depth > 0 && 'pl-[20px]',
isRowSelected && 'bg-primary/15',
)}
>
{minimalLeadingStatusMarker}
{subsessionChevron}
@@ -590,7 +641,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
type="button"
disabled={isMissingDirectory}
onMouseDown={handleRowMouseDown}
onClick={handleRowSelect}
onClick={(event) => handleRowSelect(event)}
onDoubleClick={(e) => {
e.stopPropagation();
handleSessionDoubleClick();
@@ -651,7 +702,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
type="button"
disabled={isMissingDirectory}
onMouseDown={handleRowMouseDown}
onClick={handleRowSelect}
onClick={(event) => handleRowSelect(event)}
onDoubleClick={(e) => {
e.stopPropagation();
handleSessionDoubleClick();
@@ -9,15 +9,18 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import {
RiCheckLine,
RiCheckboxMultipleLine,
RiChatNewLine,
RiEqualizer2Line,
RiFolderAddLine,
RiLayoutLeftLine,
RiSearchLine,
RiCloseLine,
RiContractUpDownLine,
RiExpandUpDownLine,
RiCalendarScheduleLine,
} from '@remixicon/react';
import { cn } from '@/lib/utils';
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
@@ -40,6 +43,10 @@ type Props = {
collapseAllProjects: () => void;
expandAllProjects: () => void;
openScheduledTasksDialog: () => void;
selectionModeEnabled: boolean;
onToggleSelectionMode: () => void;
showSidebarToggle?: boolean;
onToggleSidebar?: () => void;
};
export function SidebarHeader(props: Props): React.ReactNode {
@@ -62,6 +69,10 @@ export function SidebarHeader(props: Props): React.ReactNode {
collapseAllProjects,
expandAllProjects,
openScheduledTasksDialog,
selectionModeEnabled,
onToggleSelectionMode,
showSidebarToggle = false,
onToggleSidebar,
} = props;
const displayMode = useSessionDisplayStore((state) => state.displayMode);
@@ -72,11 +83,36 @@ export function SidebarHeader(props: Props): React.ReactNode {
}
return (
<div className="select-none flex-shrink-0 px-2.5 py-1">
<div
className={cn(
'select-none flex-shrink-0',
showSidebarToggle ? 'pl-3 pr-3' : 'px-2.5 py-1',
)}
>
{reserveHeaderActionsSpace ? (
<div className="flex h-auto min-h-8 flex-col gap-1">
<div
className={cn(
'flex h-auto flex-col gap-1',
showSidebarToggle ? 'min-h-[var(--oc-header-height,56px)] justify-center' : 'min-h-8',
)}
>
<div className="flex h-8 items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
{showSidebarToggle && onToggleSidebar ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onToggleSidebar}
className="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"
aria-label="Close sessions"
>
<RiLayoutLeftLine className="h-[18px] w-[18px]" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>Close sessions</p></TooltipContent>
</Tooltip>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<button
@@ -150,6 +186,23 @@ export function SidebarHeader(props: Props): React.ReactNode {
<TooltipContent side="bottom" sideOffset={4}><p>Search sessions</p></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onToggleSelectionMode}
className={cn(headerActionButtonClass, selectionModeEnabled && 'bg-interactive-hover text-primary')}
aria-label={selectionModeEnabled ? 'Exit selection' : 'Select sessions'}
aria-pressed={selectionModeEnabled}
>
<RiCheckboxMultipleLine className={headerActionIconClass} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
<p>{selectionModeEnabled ? 'Exit selection' : 'Select sessions'}</p>
</TooltipContent>
</Tooltip>
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
@@ -29,7 +29,9 @@ interface SessionFoldersActions {
renameFolder: (scopeKey: string, folderId: string, name: string) => void;
deleteFolder: (scopeKey: string, folderId: string) => void;
addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void;
addSessionsToFolder: (scopeKey: string, folderId: string, sessionIds: string[]) => void;
removeSessionFromFolder: (scopeKey: string, sessionId: string) => void;
removeSessionsFromFolders: (scopeKey: string, sessionIds: string[]) => void;
toggleFolderCollapse: (folderId: string) => void;
cleanupSessions: (scopeKey: string, existingSessionIds: Set<string>) => void;
getSessionFolderId: (scopeKey: string, sessionId: string) => string | null;
@@ -381,6 +383,64 @@ export const useSessionFoldersStore = create<SessionFoldersStore>()(
persistState(nextMap, nextCollapsed ?? get().collapsedFolderIds);
},
addSessionsToFolder: (scopeKey: string, folderId: string, sessionIds: string[]): void => {
if (!scopeKey || !folderId || sessionIds.length === 0) return;
const current = get().foldersMap;
const scopeFolders = current[scopeKey];
if (!scopeFolders) return;
const idSet = new Set(sessionIds.filter((id) => typeof id === 'string' && id.length > 0));
if (idSet.size === 0) return;
const nextFolders = scopeFolders.map((folder) => {
const withoutSessions = folder.sessionIds.filter((id) => !idSet.has(id));
if (folder.id === folderId) {
return { ...folder, sessionIds: [...withoutSessions, ...idSet] };
}
if (withoutSessions.length !== folder.sessionIds.length) {
return { ...folder, sessionIds: withoutSessions };
}
return folder;
});
const nextMap: SessionFoldersMap = { ...current, [scopeKey]: nextFolders };
const nextCollapsed = syncCollapsedAfterFolderCleanup(scopeFolders, nextFolders, get().collapsedFolderIds);
set(nextCollapsed
? { foldersMap: nextMap, collapsedFolderIds: nextCollapsed }
: { foldersMap: nextMap });
persistState(nextMap, nextCollapsed ?? get().collapsedFolderIds);
},
removeSessionsFromFolders: (scopeKey: string, sessionIds: string[]): void => {
if (!scopeKey || sessionIds.length === 0) return;
const current = get().foldersMap;
const scopeFolders = current[scopeKey];
if (!scopeFolders) return;
const idSet = new Set(sessionIds.filter((id) => typeof id === 'string' && id.length > 0));
if (idSet.size === 0) return;
let changed = false;
const nextFolders = scopeFolders.map((folder) => {
const filtered = folder.sessionIds.filter((id) => !idSet.has(id));
if (filtered.length !== folder.sessionIds.length) {
changed = true;
return { ...folder, sessionIds: filtered };
}
return folder;
});
if (!changed) return;
const nextMap: SessionFoldersMap = { ...current, [scopeKey]: nextFolders };
const nextCollapsed = syncCollapsedAfterFolderCleanup(scopeFolders, nextFolders, get().collapsedFolderIds);
set(nextCollapsed
? { foldersMap: nextMap, collapsedFolderIds: nextCollapsed }
: { foldersMap: nextMap });
persistState(nextMap, nextCollapsed ?? get().collapsedFolderIds);
},
removeSessionFromFolder: (scopeKey: string, sessionId: string): void => {
if (!scopeKey || !sessionId) return;
const current = get().foldersMap;
@@ -0,0 +1,157 @@
import { create } from 'zustand';
interface SessionMultiSelectState {
enabled: boolean;
selectedIds: Set<string>;
scopeKey: string | null;
anchorId: string | null;
}
interface SessionMultiSelectActions {
enable: () => void;
disable: () => void;
toggleMode: () => void;
toggleSelected: (id: string, scope: string | null, descendants?: string[]) => void;
setRange: (fromId: string | null, toId: string, orderedIds: string[], scope: string | null, descendantsById?: Map<string, string[]>) => void;
replaceAll: (ids: string[], scope: string | null) => void;
clear: () => void;
removeMany: (ids: string[]) => void;
}
type SessionMultiSelectStore = SessionMultiSelectState & SessionMultiSelectActions;
const expandWithDescendants = (ids: Iterable<string>, descendantsById?: Map<string, string[]>): string[] => {
if (!descendantsById) {
return Array.from(ids);
}
const out: string[] = [];
const seen = new Set<string>();
for (const id of ids) {
if (!seen.has(id)) {
seen.add(id);
out.push(id);
}
const descendants = descendantsById.get(id);
if (!descendants) continue;
for (const d of descendants) {
if (!seen.has(d)) {
seen.add(d);
out.push(d);
}
}
}
return out;
};
export const useSessionMultiSelectStore = create<SessionMultiSelectStore>()((set, get) => ({
enabled: false,
selectedIds: new Set<string>(),
scopeKey: null,
anchorId: null,
enable: () => {
if (get().enabled) return;
set({ enabled: true });
},
disable: () => {
set({ enabled: false, selectedIds: new Set(), scopeKey: null, anchorId: null });
},
toggleMode: () => {
if (get().enabled) {
set({ enabled: false, selectedIds: new Set(), scopeKey: null, anchorId: null });
} else {
set({ enabled: true });
}
},
toggleSelected: (id, scope, descendants) => {
const state = get();
const nextIds = new Set(state.selectedIds);
let nextScope = state.scopeKey;
let nextAnchor = state.anchorId;
const scopeChanged = scope !== null && state.scopeKey !== null && scope !== state.scopeKey;
if (scopeChanged) {
nextIds.clear();
nextScope = scope;
nextAnchor = null;
} else if (nextScope === null && scope !== null) {
nextScope = scope;
}
const toToggle = descendants && descendants.length > 0 ? [id, ...descendants] : [id];
const isCurrentlySelected = nextIds.has(id);
if (isCurrentlySelected) {
for (const targetId of toToggle) {
nextIds.delete(targetId);
}
if (nextAnchor === id) {
nextAnchor = null;
}
} else {
for (const targetId of toToggle) {
nextIds.add(targetId);
}
nextAnchor = id;
}
if (nextIds.size === 0) {
set({ selectedIds: nextIds, scopeKey: null, anchorId: null });
} else {
set({ selectedIds: nextIds, scopeKey: nextScope, anchorId: nextAnchor });
}
},
setRange: (fromId, toId, orderedIds, scope, descendantsById) => {
const state = get();
if (orderedIds.length === 0) return;
const effectiveFrom = fromId && orderedIds.includes(fromId) ? fromId : orderedIds[0];
const start = orderedIds.indexOf(effectiveFrom);
const end = orderedIds.indexOf(toId);
if (start < 0 || end < 0) return;
const [lo, hi] = start <= end ? [start, end] : [end, start];
const slice = orderedIds.slice(lo, hi + 1);
const expanded = expandWithDescendants(slice, descendantsById);
const scopeChanged = scope !== null && state.scopeKey !== null && scope !== state.scopeKey;
const baseIds = scopeChanged ? new Set<string>() : new Set(state.selectedIds);
for (const id of expanded) {
baseIds.add(id);
}
set({
selectedIds: baseIds,
scopeKey: scope ?? state.scopeKey,
anchorId: effectiveFrom,
});
},
replaceAll: (ids, scope) => {
if (ids.length === 0) {
set({ selectedIds: new Set(), scopeKey: null, anchorId: null });
return;
}
set({ selectedIds: new Set(ids), scopeKey: scope, anchorId: ids[0] ?? null });
},
clear: () => {
set({ selectedIds: new Set(), scopeKey: null, anchorId: null });
},
removeMany: (ids) => {
const state = get();
if (state.selectedIds.size === 0 || ids.length === 0) return;
const next = new Set(state.selectedIds);
for (const id of ids) {
next.delete(id);
}
if (next.size === state.selectedIds.size) return;
if (next.size === 0) {
set({ selectedIds: next, scopeKey: null, anchorId: null });
} else {
set({ selectedIds: next });
}
},
}));