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:
@@ -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 });
|
||||
}
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user