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