perf(ui): streamline session sidebar state

This commit is contained in:
c_w_xiaohei
2026-08-26 00:42:36 +08:00
parent 3b7d5e1c34
commit 26dbc2f309
84 changed files with 5159 additions and 2809 deletions
@@ -0,0 +1,149 @@
import React from 'react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon";
import { cn } from '@/lib/utils';
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
import { useI18n } from '@/lib/i18n';
type Props = {
selectedCount: number;
scopeKey: string | null;
scopeFolders: SessionFolder[];
archivedBucket: boolean;
onMoveToFolder: (folderId: string) => void;
onCreateFolderAndMove: () => void;
onRemoveFromFolder: () => void;
canRemoveFromFolder: boolean;
onRestore: () => void;
onDelete: () => void;
onDone: () => void;
};
export const BulkActionBar: React.FC<Props> = ({
selectedCount,
scopeKey,
scopeFolders,
archivedBucket,
onMoveToFolder,
onCreateFolderAndMove,
onRemoveFromFolder,
canRemoveFromFolder,
onRestore,
onDelete,
onDone,
}) => {
const { t } = useI18n();
const canMoveToFolder = Boolean(scopeKey) && !archivedBucket;
const destructiveLabel = archivedBucket
? t('sessions.sidebar.bulkActions.delete')
: t('sessions.sidebar.bulkActions.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">
{t('sessions.sidebar.bulkActions.selectedCount', { count: selectedCount })}
</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={t('sessions.sidebar.bulkActions.moveToFolder')}
>
<Icon name="folder" className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}><p>{t('sessions.sidebar.bulkActions.moveToFolder')}</p></TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="min-w-[180px]">
{scopeFolders.length === 0 ? (
<DropdownMenuItem disabled className="text-muted-foreground">
{t('sessions.sidebar.folders.none')}
</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}>
<Icon name="add" className="mr-1 h-4 w-4" />
{t('sessions.sidebar.folders.newFolderEllipsis')}
</DropdownMenuItem>
{canRemoveFromFolder ? (
<DropdownMenuItem
onClick={onRemoveFromFolder}
className="text-destructive focus:text-destructive"
>
<Icon name="close" className="mr-1 h-4 w-4" />
{t('sessions.sidebar.folders.removeFromFolder')}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
) : null}
{archivedBucket ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onRestore}
className={iconButtonClass}
aria-label={t('sessions.sidebar.bulkActions.restore')}
>
<Icon name="inbox-unarchive" className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}><p>{t('sessions.sidebar.bulkActions.restore')}</p></TooltipContent>
</Tooltip>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onDelete}
className={cn(destructiveIconButtonClass)}
aria-label={destructiveLabel}
>
<Icon name="delete-bin" 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={t('sessions.sidebar.header.actions.exitSelection')}
>
<Icon name="close" className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}><p>{t('sessions.sidebar.header.actions.exitSelection')}</p></TooltipContent>
</Tooltip>
</div>
</div>
);
};
@@ -0,0 +1,79 @@
import { describe, expect, mock, test } from 'bun:test';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { I18nProvider } from '@/lib/i18n';
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore';
import { installHookTestDom } from '../test-utils/testDom';
type BulkActionCapture = {
onCreateFolderAndMove: () => void;
};
let bulkActionCapture: BulkActionCapture | null = null;
mock.module('./BulkActionBar', () => ({
BulkActionBar: (props: BulkActionCapture) => {
bulkActionCapture = props;
return null;
},
}));
mock.module('./ConfirmDialogs', () => ({
BulkSessionDeleteConfirmDialog: () => null,
}));
const { SessionBulkActions } = await import('./SessionBulkActions');
describe('SessionBulkActions public behavior', () => {
test('moves the selected sessions into a newly created folder while a row edit is active', async () => {
const dom = installHookTestDom();
const root = createRoot(dom.container);
const originalFolders = useSessionFoldersStore.getState();
const originalSelection = useSessionMultiSelectStore.getState();
const cssDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'CSS');
const renameRequests: Array<{ scopeKey: string; folder: { id: string; name: string } }> = [];
const moved: Array<{ scopeKey: string; folderId: string; ids: string[] }> = [];
useSessionFoldersStore.setState({
foldersMap: {},
addSessionsToFolder: (scopeKey, folderId, ids) => moved.push({ scopeKey, folderId, ids }),
});
useSessionMultiSelectStore.setState({
enabled: true,
selectedIds: new Set(['session-a']),
scopeKey: 'project-a',
anchorId: 'session-a',
});
Object.defineProperty(globalThis, 'CSS', {
configurable: true,
value: { escape: (value: string) => value },
});
try {
await act(async () => root.render(
<I18nProvider>
<SessionBulkActions
getFolderScopesForProject={() => [{ scopeKey: '/workspace', directory: '/workspace' }]}
isInlineEditing
startFolderRename={(scopeKey, folder) => renameRequests.push({ scopeKey, folder })}
/>
</I18nProvider>,
));
expect(bulkActionCapture).not.toBeNull();
await act(async () => bulkActionCapture?.onCreateFolderAndMove());
const createdFolder = useSessionFoldersStore.getState().foldersMap['/workspace']?.[0];
expect(createdFolder?.name).toBe('New folder');
expect(renameRequests).toEqual([{ scopeKey: '/workspace', folder: createdFolder }]);
expect(moved).toEqual([{ scopeKey: '/workspace', folderId: createdFolder?.id ?? '', ids: ['session-a'] }]);
} finally {
await act(async () => root.unmount());
useSessionFoldersStore.setState(originalFolders, true);
useSessionMultiSelectStore.setState(originalSelection, true);
if (cssDescriptor) Object.defineProperty(globalThis, 'CSS', cssDescriptor);
else Reflect.deleteProperty(globalThis, 'CSS');
bulkActionCapture = null;
dom.restore();
}
});
});
@@ -0,0 +1,67 @@
import React from 'react';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useUIStore } from '@/stores/useUIStore';
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
import { BulkActionBar } from './BulkActionBar';
import { BulkSessionDeleteConfirmDialog, type BulkDeleteSessionsConfirmState } from '../shell/ConfirmDialogs';
import { useSidebarBulkActions } from './useSidebarBulkActions';
type Props = {
getFolderScopesForProject: (projectId: string) => readonly { scopeKey: string; directory: string | null }[];
isInlineEditing: boolean;
startFolderRename: (scopeKey: string, folder: { id: string; name: string }) => void;
};
/** Owns the sidebar selection projection and its destructive confirmation. */
export function SessionBulkActions({ getFolderScopesForProject, isInlineEditing, startFolderRename }: Props): React.ReactNode {
const [bulkDeleteConfirm, setBulkDeleteConfirm] = React.useState<BulkDeleteSessionsConfirmState>(null);
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
const foldersMap = useSessionFoldersStore((state) => state.foldersMap);
const createFolder = useSessionFoldersStore((state) => state.createFolder);
const addSessionsToFolder = useSessionFoldersStore((state) => state.addSessionsToFolder);
const removeSessionsFromFolders = useSessionFoldersStore((state) => state.removeSessionsFromFolders);
const archiveSessions = useSessionUIStore((state) => state.archiveSessions);
const unarchiveSessions = useSessionUIStore((state) => state.unarchiveSessions);
const deleteSessions = useSessionUIStore((state) => state.deleteSessions);
const bulk = useSidebarBulkActions({
isInlineEditing,
showDeletionDialog,
foldersMap,
getFolderScopesForProject,
addSessionsToFolder,
removeSessionsFromFolders,
createFolderAndStartRename: (scopeKey) => {
const folder = createFolder(scopeKey, 'New folder');
startFolderRename(scopeKey, folder);
return folder;
},
archiveSessions,
unarchiveSessions,
deleteSessions,
setBulkDeleteConfirm,
});
return <>
{bulk.selectionModeEnabled && bulk.hasSelection ? <BulkActionBar
selectedCount={bulk.selectedIdsSize}
scopeKey={bulk.derivedSelectionScope}
scopeFolders={bulk.bulkScopeFolders}
archivedBucket={bulk.bulkScopeIsArchived}
onMoveToFolder={bulk.handleBulkMoveToFolder}
onCreateFolderAndMove={bulk.handleBulkCreateFolderAndMove}
onRemoveFromFolder={bulk.handleBulkRemoveFromFolder}
canRemoveFromFolder={bulk.bulkCanRemoveFromFolder}
onRestore={bulk.handleBulkRestore}
onDelete={bulk.handleBulkDelete}
onDone={bulk.handleExitSelectionMode}
/> : null}
<BulkSessionDeleteConfirmDialog
value={bulkDeleteConfirm}
setValue={setBulkDeleteConfirm}
showDeletionDialog={showDeletionDialog}
setShowDeletionDialog={setShowDeletionDialog}
onConfirm={bulk.confirmBulkDelete}
/>
</>;
}
@@ -0,0 +1,58 @@
import { describe, expect, mock, test } from 'bun:test';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { installHookTestDom } from '../test-utils/testDom';
type DragEnd = (event: {
active: { data: { current: { type: string; sessionId: string } } };
over: { data: { current: { type: string; folderId: string } } } | null;
}) => void;
let handleDragEnd: DragEnd | null = null;
mock.module('@dnd-kit/core', () => ({
DndContext: ({ children, onDragEnd }: { children: React.ReactNode; onDragEnd: DragEnd }) => {
handleDragEnd = onDragEnd;
return <>{children}</>;
},
DragOverlay: ({ children }: { children: React.ReactNode }) => <>{children}</>,
PointerSensor: class {},
closestCenter: () => null,
useSensor: () => null,
useSensors: () => [],
useDraggable: () => ({ attributes: {}, listeners: {}, setNodeRef: () => undefined, isDragging: false }),
useDroppable: () => ({ setNodeRef: () => undefined, isOver: false }),
}));
const { SessionFolderDndScope } = await import('./sessionFolderDnd');
describe('SessionFolderDndScope public behavior', () => {
test('routes a session-folder drop without depending on row edit or menu state', async () => {
const dom = installHookTestDom();
const root = createRoot(dom.container);
const drops: Array<{ sessionId: string; folderId: string }> = [];
try {
await act(async () => root.render(
<SessionFolderDndScope
scopeKey="/workspace"
hasFolders
onSessionDroppedOnFolder={(sessionId, folderId) => drops.push({ sessionId, folderId })}
>
{null}
</SessionFolderDndScope>,
));
expect(handleDragEnd).not.toBeNull();
await act(async () => handleDragEnd?.({
active: { data: { current: { type: 'session', sessionId: 'session-a' } } },
over: { data: { current: { type: 'folder', folderId: 'folder-a' } } },
}));
expect(drops).toEqual([{ sessionId: 'session-a', folderId: 'folder-a' }]);
} finally {
await act(async () => root.unmount());
handleDragEnd = null;
dom.restore();
}
});
});
@@ -0,0 +1,134 @@
import React from 'react';
import {
DndContext,
DragOverlay,
PointerSensor,
closestCenter,
useSensor,
useSensors,
useDraggable,
useDroppable,
type DragEndEvent,
} from '@dnd-kit/core';
import { Icon } from "@/components/icon/Icon";
export const DraggableSessionRow: React.FC<{
sessionId: string;
sessionDirectory: string | null;
sessionTitle: string;
children: React.ReactNode;
}> = ({ sessionId, sessionDirectory, sessionTitle, children }) => {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `session-drag:${sessionId}`,
data: { type: 'session', sessionId, sessionDirectory, sessionTitle },
});
const handlePointerDown = React.useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
e.stopPropagation();
if (listeners?.onPointerDown) {
(listeners.onPointerDown as (event: React.PointerEvent) => void)(e);
}
},
[listeners],
);
return (
<div
ref={setNodeRef}
{...attributes}
onPointerDown={handlePointerDown}
className={isDragging ? 'opacity-30' : undefined}
>
{children}
</div>
);
};
export const DroppableFolderWrapper: React.FC<{
folderId: string;
children: (
droppableRef: (node: HTMLElement | null) => void,
isOver: boolean,
) => React.ReactNode;
}> = ({ folderId, children }) => {
const { setNodeRef, isOver } = useDroppable({
id: `folder-drop:${folderId}`,
data: { type: 'folder', folderId },
});
return <>{children(setNodeRef, isOver)}</>;
};
export const SessionFolderDndScope: React.FC<{
scopeKey: string | null;
hasFolders: boolean;
onSessionDroppedOnFolder: (sessionId: string, folderId: string) => void;
children: React.ReactNode;
}> = ({ scopeKey, hasFolders, onSessionDroppedOnFolder, children }) => {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
);
const [activeDragId, setActiveDragId] = React.useState<string | null>(null);
const [activeDragTitle, setActiveDragTitle] = React.useState<string>('Session');
const [activeDragWidth, setActiveDragWidth] = React.useState<number | null>(null);
const [activeDragHeight, setActiveDragHeight] = React.useState<number | null>(null);
if (!scopeKey) {
return <>{children}</>;
}
const handleDragEnd = (event: DragEndEvent) => {
setActiveDragId(null);
setActiveDragWidth(null);
setActiveDragHeight(null);
const { active, over } = event;
if (!over) return;
const activeData = active.data.current as { type?: string; sessionId?: string } | undefined;
const overData = over.data.current as { type?: string; folderId?: string } | undefined;
if (activeData?.type === 'session' && activeData.sessionId && overData?.type === 'folder' && overData.folderId) {
onSessionDroppedOnFolder(activeData.sessionId, overData.folderId);
}
};
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={(event) => {
const data = event.active.data.current as { type?: string; sessionId?: string; sessionTitle?: string } | undefined;
if (data?.type === 'session' && data.sessionId) {
setActiveDragId(data.sessionId);
setActiveDragTitle(data.sessionTitle ?? 'Session');
const width = event.active.rect.current.initial?.width;
const height = event.active.rect.current.initial?.height;
setActiveDragWidth(typeof width === 'number' ? width : null);
setActiveDragHeight(typeof height === 'number' ? height : null);
}
}}
onDragCancel={() => {
setActiveDragId(null);
setActiveDragWidth(null);
setActiveDragHeight(null);
}}
onDragEnd={handleDragEnd}
>
{children}
<DragOverlay>
{activeDragId && hasFolders ? (
<div
style={{
width: activeDragWidth ? `${activeDragWidth}px` : 'auto',
height: activeDragHeight ? `${activeDragHeight}px` : 'auto',
}}
className="flex items-center rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-2.5 py-1 shadow-none pointer-events-none"
>
<Icon name="sticky-note" className="h-4 w-4 text-muted-foreground mr-2 flex-shrink-0" />
<div className="min-w-0 flex-1 truncate typography-ui-label font-normal text-foreground">
{activeDragTitle}
</div>
</div>
) : null}
</DragOverlay>
</DndContext>
);
};
@@ -0,0 +1,86 @@
import React from 'react';
import {
getArchivedScopeKey,
resolveArchivedFolderName,
} from '../utils';
import type { SessionOwnershipIndex } from '../sessions/sessionOwnership';
type ProjectForArchivedFolders = {
id: string;
normalizedPath: string;
};
type FolderEntry = {
id: string;
name: string;
sessionIds: string[];
};
type Args = {
enabled?: boolean;
normalizedProjects: ProjectForArchivedFolders[];
ownership: SessionOwnershipIndex;
isSessionsLoading: boolean;
hasAuthoritativeGlobalSessions: boolean;
isWorktreeTopologyLoading: boolean;
unresolvedWorktreeProjectPaths: ReadonlySet<string>;
foldersMap: Record<string, FolderEntry[]>;
createFolder: (scopeKey: string, name: string, parentId?: string | null) => FolderEntry;
addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void;
};
export const useArchivedAutoFolders = (args: Args): void => {
const {
normalizedProjects,
enabled = true,
ownership,
isSessionsLoading,
hasAuthoritativeGlobalSessions,
isWorktreeTopologyLoading,
unresolvedWorktreeProjectPaths,
foldersMap,
createFolder,
addSessionToFolder,
} = args;
React.useEffect(() => {
if (!enabled || isSessionsLoading || !hasAuthoritativeGlobalSessions || isWorktreeTopologyLoading) {
return;
}
normalizedProjects.forEach((project) => {
if (unresolvedWorktreeProjectPaths.has(project.normalizedPath)) {
return;
}
const scopeKey = getArchivedScopeKey(project.normalizedPath);
const projectArchivedSessions = ownership.archivedSessionsByProject.get(project.id) ?? [];
const existingFolders = foldersMap[scopeKey] ?? [];
const folderByName = new Map(existingFolders.map((folder) => [folder.name.toLowerCase(), folder]));
projectArchivedSessions.forEach((session) => {
const folderName = resolveArchivedFolderName(session, project.normalizedPath);
const key = folderName.toLowerCase();
let folder = folderByName.get(key);
if (!folder) {
folder = createFolder(scopeKey, folderName);
folderByName.set(key, folder);
}
if (!folder.sessionIds.includes(session.id)) {
addSessionToFolder(scopeKey, folder.id, session.id);
}
});
});
}, [
normalizedProjects,
enabled,
ownership,
isSessionsLoading,
hasAuthoritativeGlobalSessions,
isWorktreeTopologyLoading,
unresolvedWorktreeProjectPaths,
foldersMap,
createFolder,
addSessionToFolder,
]);
};
@@ -0,0 +1,19 @@
import { describe, expect, test } from 'bun:test';
import { resolveSelectionFolderScopes } from './useSidebarBulkActions';
describe('sidebar bulk project scopes', () => {
test('uses every root and worktree scope owned by the selected project', () => {
const scopes = resolveSelectionFolderScopes('project-a', (projectId) => projectId === 'project-a'
? [
{ scopeKey: '/workspace/project-a', directory: '/workspace/project-a' },
{ scopeKey: '/workspace/project-a-worktree', directory: '/workspace/project-a-worktree' },
]
: []);
expect(scopes).toEqual(['/workspace/project-a', '/workspace/project-a-worktree']);
});
test('keeps a directory scope when no project scope owns it', () => {
expect(resolveSelectionFolderScopes('/workspace/vscode', () => [])).toEqual(['/workspace/vscode']);
});
});
@@ -0,0 +1,305 @@
import React from 'react';
import { toast } from '@/components/ui';
import { useI18n } from '@/lib/i18n';
import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore';
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
type Args = {
isInlineEditing: boolean;
showDeletionDialog: boolean;
foldersMap: Record<string, SessionFolder[]>;
/**
* Selection scope is the project id (flat per-project session list); this
* map resolves it to the project's folder scopes (root + worktrees). When
* the scope is missing here it is treated as a plain directory scope.
*/
getFolderScopesForProject: (projectId: string) => readonly { scopeKey: string; directory: string | null }[];
addSessionsToFolder: (scopeKey: string, folderId: string, sessionIds: string[]) => void;
removeSessionsFromFolders: (scopeKey: string, sessionIds: string[]) => void;
createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
archiveSessions: (ids: string[]) => Promise<{ archivedIds: string[]; failedIds: string[] }>;
unarchiveSessions: (ids: string[]) => Promise<{ restoredIds: string[]; failedIds: string[] }>;
deleteSessions: (ids: string[]) => Promise<{ deletedIds: string[]; failedIds: string[] }>;
setBulkDeleteConfirm: React.Dispatch<React.SetStateAction<{
sessionCount: number;
archivedBucket: boolean;
} | null>>;
};
export const resolveSelectionFolderScopes = (
selectionScope: string | null,
getFolderScopesForProject: Args['getFolderScopesForProject'],
): string[] => {
if (!selectionScope) return [];
const projectScopes = getFolderScopesForProject(selectionScope);
return projectScopes.length > 0
? projectScopes.map((scope) => scope.scopeKey)
: [selectionScope];
};
/**
* Bulk-action logic for the sidebar. The hot-path concern is that this
* hook subscribes to `useSessionMultiSelectStore` — which can fire on
* every selection toggle and on every setRange/toggleSelected call —
* but the rest of the Sidebar tree only needs the boolean
* `selectionModeEnabled` flag to decide whether to render the
* selection chrome.
*
* To keep that subscription narrow, the heavy work (folders lookup,
* DOM-attribute scanning for the active/archived scope, etc.) is
* deferred behind a `selectedIds.size > 0` check inside the hook
* itself, so toggling selection mode on/off does not force the
* downstream useMemo chain to re-evaluate when no rows are selected.
*/
export const useSidebarBulkActions = (args: Args) => {
const { t } = useI18n();
const {
isInlineEditing,
showDeletionDialog,
foldersMap,
getFolderScopesForProject,
addSessionsToFolder,
removeSessionsFromFolders,
createFolderAndStartRename,
archiveSessions,
unarchiveSessions,
deleteSessions,
setBulkDeleteConfirm,
} = args;
const selectionModeEnabled = useSessionMultiSelectStore((state) => state.enabled);
const selectedIdsSize = useSessionMultiSelectStore((state) => state.selectedIds.size);
const hasSelection = selectedIdsSize > 0;
const selectedIds = useSessionMultiSelectStore((state) => state.selectedIds);
const selectionScopeKey = useSessionMultiSelectStore((state) => state.scopeKey);
const handleToggleSelectionMode = React.useCallback(() => {
useSessionMultiSelectStore.getState().toggleMode();
}, []);
const handleExitSelectionMode = React.useCallback(() => {
useSessionMultiSelectStore.getState().disable();
}, []);
// All of the below short-circuit on `hasSelection` so the DOM-scanning
// and folder-lookup work only runs when there's something to act on.
const bulkScopeIsArchived = React.useMemo(() => {
if (!hasSelection) 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;
}, [hasSelection, selectedIds]);
const derivedSelectionScope = React.useMemo(() => {
if (selectionScopeKey) return selectionScopeKey;
if (!hasSelection) 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;
}, [hasSelection, selectedIds, selectionScopeKey]);
// The selection scope is a project id; folders live per directory scope
// (project root + each worktree). Resolve all of them, in project order.
const selectionFolderScopes = React.useMemo<string[]>(() => {
return resolveSelectionFolderScopes(derivedSelectionScope, getFolderScopesForProject);
}, [derivedSelectionScope, getFolderScopesForProject]);
const bulkScopeFolders = React.useMemo(() => {
return selectionFolderScopes.flatMap((scope) => foldersMap[scope] ?? []);
}, [foldersMap, selectionFolderScopes]);
const resolveFolderScope = React.useCallback((folderId: string): string | null => {
for (const scope of selectionFolderScopes) {
if ((foldersMap[scope] ?? []).some((folder) => folder.id === folderId)) return scope;
}
return null;
}, [foldersMap, selectionFolderScopes]);
const bulkCanRemoveFromFolder = React.useMemo(() => {
if (!hasSelection) return false;
for (const scope of selectionFolderScopes) {
for (const folder of foldersMap[scope] ?? []) {
for (const id of folder.sessionIds) {
if (selectedIds.has(id)) return true;
}
}
}
return false;
}, [foldersMap, selectionFolderScopes, hasSelection, selectedIds]);
const moveSelectionToFolder = React.useCallback((targetScope: string, folderId: string) => {
const ids = Array.from(selectedIds);
// Clear memberships in every other scope first — the store only dedupes
// within one scope, and a session must live in a single folder.
for (const scope of selectionFolderScopes) {
if (scope === targetScope) continue;
removeSessionsFromFolders(scope, ids);
}
addSessionsToFolder(targetScope, folderId, ids);
}, [addSessionsToFolder, removeSessionsFromFolders, selectedIds, selectionFolderScopes]);
const handleBulkMoveToFolder = React.useCallback((folderId: string) => {
if (!hasSelection) return;
const targetScope = resolveFolderScope(folderId);
if (!targetScope) return;
moveSelectionToFolder(targetScope, folderId);
}, [hasSelection, moveSelectionToFolder, resolveFolderScope]);
const handleBulkCreateFolderAndMove = React.useCallback(() => {
const targetScope = selectionFolderScopes[0];
if (!targetScope || !hasSelection) return;
const newFolder = createFolderAndStartRename(targetScope);
if (!newFolder) return;
moveSelectionToFolder(targetScope, newFolder.id);
}, [createFolderAndStartRename, hasSelection, moveSelectionToFolder, selectionFolderScopes]);
const handleBulkRemoveFromFolder = React.useCallback(() => {
if (!hasSelection) return;
const ids = Array.from(selectedIds);
for (const scope of selectionFolderScopes) {
removeSessionsFromFolders(scope, ids);
}
}, [removeSessionsFromFolders, selectedIds, selectionFolderScopes, hasSelection]);
const executeBulkDelete = React.useCallback(async () => {
const ids = Array.from(selectedIds);
if (ids.length === 0) return;
if (bulkScopeIsArchived) {
const { deletedIds, failedIds } = await deleteSessions(ids);
if (deletedIds.length > 0) {
toast.success(deletedIds.length === 1
? t('sessions.sidebar.bulkActions.deletedSingle', { count: deletedIds.length })
: t('sessions.sidebar.bulkActions.deletedPlural', { count: deletedIds.length }));
}
if (failedIds.length > 0) {
toast.error(failedIds.length === 1
? t('sessions.sidebar.bulkActions.failedDeleteSingle', { count: failedIds.length })
: t('sessions.sidebar.bulkActions.failedDeletePlural', { count: failedIds.length }));
}
} else {
const { archivedIds, failedIds } = await archiveSessions(ids);
if (archivedIds.length > 0) {
toast.success(archivedIds.length === 1
? t('sessions.sidebar.bulkActions.archivedSingle', { count: archivedIds.length })
: t('sessions.sidebar.bulkActions.archivedPlural', { count: archivedIds.length }));
}
if (failedIds.length > 0) {
toast.error(failedIds.length === 1
? t('sessions.sidebar.bulkActions.failedArchiveSingle', { count: failedIds.length })
: t('sessions.sidebar.bulkActions.failedArchivePlural', { count: failedIds.length }));
}
}
useSessionMultiSelectStore.getState().clear();
}, [archiveSessions, bulkScopeIsArchived, deleteSessions, selectedIds, t]);
const handleBulkDelete = React.useCallback(() => {
if (!hasSelection) return;
const count = selectedIds.size;
if (!showDeletionDialog) {
void executeBulkDelete();
return;
}
setBulkDeleteConfirm({ sessionCount: count, archivedBucket: bulkScopeIsArchived });
}, [bulkScopeIsArchived, executeBulkDelete, selectedIds, showDeletionDialog, setBulkDeleteConfirm, hasSelection]);
const handleBulkRestore = React.useCallback(async () => {
if (!hasSelection || !bulkScopeIsArchived) return;
const ids = Array.from(selectedIds);
const { restoredIds, failedIds } = await unarchiveSessions(ids);
if (restoredIds.length > 0) {
toast.success(restoredIds.length === 1
? t('sessions.sidebar.bulkActions.restoredSingle', { count: restoredIds.length })
: t('sessions.sidebar.bulkActions.restoredPlural', { count: restoredIds.length }));
}
if (failedIds.length > 0) {
toast.error(failedIds.length === 1
? t('sessions.sidebar.bulkActions.failedRestoreSingle', { count: failedIds.length })
: t('sessions.sidebar.bulkActions.failedRestorePlural', { count: failedIds.length }));
}
useSessionMultiSelectStore.getState().clear();
}, [bulkScopeIsArchived, hasSelection, selectedIds, t, unarchiveSessions]);
const confirmBulkDelete = React.useCallback(async () => {
setBulkDeleteConfirm(null);
await executeBulkDelete();
// setBulkDeleteConfirm is a stable React state setter; intentionally
// omitted from deps to avoid forcing the keyboard-listener effect
// below to re-subscribe on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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 = useSessionMultiSelectStore.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;
useSessionMultiSelectStore.getState().replaceAll(ids, targetScope || null);
}
};
window.addEventListener('keydown', listener);
return () => window.removeEventListener('keydown', listener);
}, [handleBulkDelete, isInlineEditing, selectionModeEnabled]);
return {
selectionModeEnabled,
hasSelection,
selectedIdsSize,
bulkScopeIsArchived,
derivedSelectionScope,
bulkScopeFolders,
bulkCanRemoveFromFolder,
handleToggleSelectionMode,
handleExitSelectionMode,
handleBulkMoveToFolder,
handleBulkCreateFolderAndMove,
handleBulkRemoveFromFolder,
handleBulkDelete,
handleBulkRestore,
confirmBulkDelete,
};
};