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
@@ -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 });
}
},
}));