From 575aca360a7105ea6f5e471cd89bd7e9399f018e Mon Sep 17 00:00:00 2001 From: David Kaya Date: Wed, 15 Apr 2026 07:12:45 +0200 Subject: [PATCH] feat: batch session archive and delete with multi-select UI Add multi-select mode to the sidebar session list, enabling users to archive, restore, or delete multiple sessions at once. UX: - Ctrl+Click (Cmd+Click on Mac) any session to enter multi-select mode - Animated checkboxes stagger-reveal across all visible sessions - Click to toggle, Shift+Click for range selection - Running sessions are excluded from selection (safety guard) - Floating action bar slides up with count pill, Archive, Delete, Cancel - Batch delete shows a confirmation dialog with hold-to-confirm for 3+ sessions and a scrollable list of session titles - Batch archive shows an undo toast with 5-second auto-dismiss - Escape key or Cancel button exits multi-select mode Backend: - batchSetSessionsArchived IPC: single load/mutate/persist cycle for N sessions instead of N sequential calls - batchDeleteSessions IPC: parallel cleanup of scratchpad dirs and SDK sessions, then single workspace persist Accessibility: - Action bar: role=toolbar, aria-label - Checkboxes: role=checkbox, aria-checked, keyboard-activatable - Selection count: aria-live=polite - Delete dialog: role=alertdialog, aria-modal, focus trap Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/main/AryxAppService.ts | 44 +++ src/main/ipc/registerIpcHandlers.ts | 8 + src/preload/index.ts | 2 + src/renderer/App.tsx | 6 + src/renderer/components/Sidebar.tsx | 271 ++++++++++++++++-- .../components/sidebar/BatchActionBar.tsx | 92 ++++++ .../sidebar/BatchDeleteConfirmDialog.tsx | 218 ++++++++++++++ src/renderer/components/sidebar/UndoToast.tsx | 77 +++++ src/renderer/hooks/useSessionSelection.ts | 104 +++++++ src/renderer/styles.css | 111 +++++++ src/shared/contracts/channels.ts | 2 + src/shared/contracts/ipc.ts | 11 + tests/renderer/sessionSelection.test.ts | 187 ++++++++++++ 13 files changed, 1110 insertions(+), 23 deletions(-) create mode 100644 src/renderer/components/sidebar/BatchActionBar.tsx create mode 100644 src/renderer/components/sidebar/BatchDeleteConfirmDialog.tsx create mode 100644 src/renderer/components/sidebar/UndoToast.tsx create mode 100644 src/renderer/hooks/useSessionSelection.ts create mode 100644 tests/renderer/sessionSelection.test.ts diff --git a/src/main/AryxAppService.ts b/src/main/AryxAppService.ts index 8e49aec..945fa71 100644 --- a/src/main/AryxAppService.ts +++ b/src/main/AryxAppService.ts @@ -1149,6 +1149,50 @@ export class AryxAppService extends EventEmitter { return this.persistAndBroadcast(workspace); } + async batchSetSessionsArchived(sessionIds: string[], isArchived: boolean): Promise { + const workspace = await this.loadWorkspace(); + const now = nowIso(); + for (const sessionId of sessionIds) { + const session = workspace.sessions.find((s) => s.id === sessionId); + if (session) { + session.isArchived = isArchived; + session.updatedAt = now; + } + } + return this.persistAndBroadcast(workspace); + } + + async batchDeleteSessions(sessionIds: string[]): Promise { + const workspace = await this.loadWorkspace(); + const idsToDelete = new Set(sessionIds); + + // Collect cleanup work before mutating the array + const cleanupTasks: Promise[] = []; + for (const session of workspace.sessions) { + if (!idsToDelete.has(session.id)) continue; + + const scratchpadDirectory = this.resolveScratchpadSessionDirectory(session); + if (scratchpadDirectory) { + cleanupTasks.push(rm(scratchpadDirectory, { recursive: true, force: true })); + } + cleanupTasks.push( + this.sidecar.deleteSession(session.id).then(() => undefined).catch(() => { + // Best-effort — don't fail the deletion if SDK cleanup fails + }), + ); + } + + await Promise.allSettled(cleanupTasks); + + workspace.sessions = workspace.sessions.filter((s) => !idsToDelete.has(s.id)); + + if (workspace.selectedSessionId && idsToDelete.has(workspace.selectedSessionId)) { + workspace.selectedSessionId = workspace.sessions[0]?.id; + } + + return this.persistAndBroadcast(workspace); + } + async regenerateSessionMessage(sessionId: string, messageId: string): Promise { const workspace = await this.loadWorkspace(); const session = this.requireSession(workspace, sessionId); diff --git a/src/main/ipc/registerIpcHandlers.ts b/src/main/ipc/registerIpcHandlers.ts index 406bcbf..0af1567 100644 --- a/src/main/ipc/registerIpcHandlers.ts +++ b/src/main/ipc/registerIpcHandlers.ts @@ -4,6 +4,8 @@ import type { BrowserWindow } from 'electron'; import { ipcChannels } from '@shared/contracts/channels'; import type { BranchSessionInput, + BatchDeleteSessionsInput, + BatchSetSessionsArchivedInput, CancelSessionTurnInput, CommitProjectGitChangesInput, CreateSessionInput, @@ -224,6 +226,12 @@ export function registerIpcHandlers( ipcMain.handle(ipcChannels.deleteSession, (_event, input: DeleteSessionInput) => service.deleteSession(input.sessionId), ); + ipcMain.handle(ipcChannels.batchSetSessionsArchived, (_event, input: BatchSetSessionsArchivedInput) => + service.batchSetSessionsArchived(input.sessionIds, input.isArchived), + ); + ipcMain.handle(ipcChannels.batchDeleteSessions, (_event, input: BatchDeleteSessionsInput) => + service.batchDeleteSessions(input.sessionIds), + ); ipcMain.handle(ipcChannels.regenerateSessionMessage, (_event, input: RegenerateSessionMessageInput) => service.regenerateSessionMessage(input.sessionId, input.messageId), ); diff --git a/src/preload/index.ts b/src/preload/index.ts index e40e244..3775a42 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -67,6 +67,8 @@ const api: ElectronApi = { setSessionPinned: (input) => ipcRenderer.invoke(ipcChannels.setSessionPinned, input), setSessionArchived: (input) => ipcRenderer.invoke(ipcChannels.setSessionArchived, input), deleteSession: (input) => ipcRenderer.invoke(ipcChannels.deleteSession, input), + batchSetSessionsArchived: (input) => ipcRenderer.invoke(ipcChannels.batchSetSessionsArchived, input), + batchDeleteSessions: (input) => ipcRenderer.invoke(ipcChannels.batchDeleteSessions, input), regenerateSessionMessage: (input) => ipcRenderer.invoke(ipcChannels.regenerateSessionMessage, input), editAndResendSessionMessage: (input) => ipcRenderer.invoke(ipcChannels.editAndResendSessionMessage, input), sendSessionMessage: (input) => ipcRenderer.invoke(ipcChannels.sendSessionMessage, input), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 70eb30a..4397d46 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -940,6 +940,12 @@ export default function App() { onDeleteSession={(sessionId) => { void api.deleteSession({ sessionId }); }} + onBatchArchiveSessions={(sessionIds, isArchived) => { + void api.batchSetSessionsArchived({ sessionIds, isArchived }); + }} + onBatchDeleteSessions={(sessionIds) => { + void api.batchDeleteSessions({ sessionIds }); + }} onRefreshGitContext={(projectId) => { void api.refreshProjectGitContext(projectId); }} diff --git a/src/renderer/components/Sidebar.tsx b/src/renderer/components/Sidebar.tsx index f60de2d..e9e01d3 100644 --- a/src/renderer/components/Sidebar.tsx +++ b/src/renderer/components/Sidebar.tsx @@ -1,10 +1,11 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import appIconUrl from '../../../assets/icons/icon.png'; import { isMac } from '@renderer/lib/platform'; import { AlertTriangle, Archive, ArrowLeftRight, + Check, ChevronDown, ChevronRight, Circle, @@ -35,6 +36,10 @@ import { querySessions } from '@shared/domain/sessionLibrary'; import type { UpdateStatus } from '@shared/contracts/ipc'; import type { WorkspaceState } from '@shared/domain/workspace'; import { UpdateBanner } from '@renderer/components/ui'; +import { useSessionSelection } from '@renderer/hooks/useSessionSelection'; +import { BatchActionBar } from '@renderer/components/sidebar/BatchActionBar'; +import { BatchDeleteConfirmDialog } from '@renderer/components/sidebar/BatchDeleteConfirmDialog'; +import { UndoToast } from '@renderer/components/sidebar/UndoToast'; interface SidebarProps { workspace: WorkspaceState; @@ -50,6 +55,8 @@ interface SidebarProps { onSetSessionPinned: (sessionId: string, isPinned: boolean) => void; onSetSessionArchived: (sessionId: string, isArchived: boolean) => void; onDeleteSession: (sessionId: string) => void; + onBatchArchiveSessions: (sessionIds: string[], isArchived: boolean) => void; + onBatchDeleteSessions: (sessionIds: string[]) => void; onRefreshGitContext: (projectId: string) => void; updateStatus?: UpdateStatus; onViewUpdateDetails?: () => void; @@ -183,6 +190,10 @@ function SessionItem({ onOpenMenu, onRenameSubmit, onRenameCancel, + isSelecting, + isSelected, + selectionIndex, + onToggleSelection, }: { session: SessionRecord; workflow?: WorkflowDefinition; @@ -192,6 +203,10 @@ function SessionItem({ onOpenMenu: (e: React.MouseEvent) => void; onRenameSubmit: (title: string) => void; onRenameCancel: () => void; + isSelecting?: boolean; + isSelected?: boolean; + selectionIndex?: number; + onToggleSelection?: () => void; }) { const isRunning = session.status === 'running'; const isError = session.status === 'error'; @@ -201,6 +216,7 @@ function SessionItem({ const visual = modeVisuals[mode]; const ModeIcon = visual.icon; const agentCount = workflow ? resolveWorkflowAgentNodes(workflow).length : 1; + const isSelectDisabled = isRunning && isSelecting; const [renameText, setRenameText] = useState(session.title); const inputRef = useRef(null); @@ -232,34 +248,81 @@ function SessionItem({ else onRenameCancel(); } + function handleClick(e: React.MouseEvent) { + if (isRenaming) return; + if (isSelecting) { + if (!isSelectDisabled) onToggleSelection?.(); + return; + } + onSelect(); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if ((e.key === 'Enter' || e.key === ' ') && !isRenaming) { + e.preventDefault(); + if (isSelecting) { + if (!isSelectDisabled) onToggleSelection?.(); + } else { + onSelect(); + } + } + } + return (
{ if ((e.key === 'Enter' || e.key === ' ') && !isRenaming) { e.preventDefault(); onSelect(); } }} + onKeyDown={handleKeyDown} > {/* Running/approval left accent bar */} - {isRunning && !hasPendingApproval && ( + {isRunning && !hasPendingApproval && !isSelecting && ( )} - {hasPendingApproval && ( + {hasPendingApproval && !isSelecting && ( )} + {/* Selection accent bar */} + {isSelecting && isSelected && ( + + )} - {/* Mode icon */} - - - + {/* Mode icon or selection checkbox */} + {isSelecting ? ( + + + {isSelected && } + + + ) : ( + + + + )} {/* Content */}
@@ -342,8 +405,8 @@ function SessionItem({
- {/* Actions button (hidden during rename) */} - {!isRenaming && ( + {/* Actions button (hidden during rename and selection mode) */} + {!isRenaming && !isSelecting && ( + + + {/* Bottom row — action buttons */} +
+ + + +
+ + ); +} diff --git a/src/renderer/components/sidebar/BatchDeleteConfirmDialog.tsx b/src/renderer/components/sidebar/BatchDeleteConfirmDialog.tsx new file mode 100644 index 0000000..ebf2d15 --- /dev/null +++ b/src/renderer/components/sidebar/BatchDeleteConfirmDialog.tsx @@ -0,0 +1,218 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { AlertTriangle, Trash2, X } from 'lucide-react'; +import type { SessionRecord } from '@shared/domain/session'; + +interface BatchDeleteConfirmDialogProps { + sessions: SessionRecord[]; + onConfirm: () => void; + onCancel: () => void; +} + +const HOLD_DURATION_MS = 1500; +const HOLD_THRESHOLD = 3; + +export function BatchDeleteConfirmDialog({ + sessions, + onConfirm, + onCancel, +}: BatchDeleteConfirmDialogProps) { + const requiresHold = sessions.length >= HOLD_THRESHOLD; + const [holdProgress, setHoldProgress] = useState(0); + const holdTimerRef = useRef(null); + const holdStartRef = useRef(null); + const dialogRef = useRef(null); + + // Focus trap + useEffect(() => { + const el = dialogRef.current; + if (!el) return; + + const focusable = el.querySelectorAll( + 'button, [tabindex]:not([tabindex="-1"])', + ); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + first?.focus(); + + function handleTab(e: KeyboardEvent) { + if (e.key !== 'Tab') return; + if (!first || !last) return; + + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + } + + function handleEscape(e: KeyboardEvent) { + if (e.key === 'Escape') { + e.stopPropagation(); + onCancel(); + } + } + + document.addEventListener('keydown', handleTab); + document.addEventListener('keydown', handleEscape); + return () => { + document.removeEventListener('keydown', handleTab); + document.removeEventListener('keydown', handleEscape); + }; + }, [onCancel]); + + const startHold = useCallback(() => { + if (!requiresHold) return; + holdStartRef.current = performance.now(); + + const tick = () => { + if (!holdStartRef.current) return; + const elapsed = performance.now() - holdStartRef.current; + const progress = Math.min(elapsed / HOLD_DURATION_MS, 1); + setHoldProgress(progress); + + if (progress >= 1) { + onConfirm(); + return; + } + holdTimerRef.current = requestAnimationFrame(tick); + }; + holdTimerRef.current = requestAnimationFrame(tick); + }, [requiresHold, onConfirm]); + + const cancelHold = useCallback(() => { + holdStartRef.current = null; + if (holdTimerRef.current !== null) { + cancelAnimationFrame(holdTimerRef.current); + holdTimerRef.current = null; + } + setHoldProgress(0); + }, []); + + // Clean up on unmount + useEffect(() => { + return () => { + if (holdTimerRef.current !== null) cancelAnimationFrame(holdTimerRef.current); + }; + }, []); + + return ( + <> + {/* Backdrop */} +
+ + {/* Dialog */} +
+
+ {/* Header */} +
+
+
+ +
+
+

+ Delete {sessions.length} session{sessions.length === 1 ? '' : 's'}? +

+
+
+ +
+ + {/* Session list */} +
+
+ {sessions.map((session) => ( +
+ {session.title} +
+ ))} +
+
+ + {/* Warning */} +
+
+ + This action cannot be undone. All messages and session data will be permanently removed. +
+
+ + {/* Actions */} +
+ + {requiresHold ? ( + + ) : ( + + )} +
+
+
+ + ); +} diff --git a/src/renderer/components/sidebar/UndoToast.tsx b/src/renderer/components/sidebar/UndoToast.tsx new file mode 100644 index 0000000..5c192e3 --- /dev/null +++ b/src/renderer/components/sidebar/UndoToast.tsx @@ -0,0 +1,77 @@ +import { useEffect, useRef, useState } from 'react'; +import { Undo2, X } from 'lucide-react'; + +interface UndoToastProps { + message: string; + onUndo: () => void; + onDismiss: () => void; + duration?: number; +} + +export function UndoToast({ + message, + onUndo, + onDismiss, + duration = 5000, +}: UndoToastProps) { + const [exiting, setExiting] = useState(false); + const timerRef = useRef>(undefined); + + useEffect(() => { + timerRef.current = setTimeout(() => { + setExiting(true); + setTimeout(onDismiss, 200); + }, duration); + + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, [duration, onDismiss]); + + function handleUndo() { + if (timerRef.current) clearTimeout(timerRef.current); + onUndo(); + } + + function handleDismiss() { + if (timerRef.current) clearTimeout(timerRef.current); + setExiting(true); + setTimeout(onDismiss, 200); + } + + return ( +
+ + {message} + + + + + {/* Auto-dismiss progress bar */} + +
+ ); +} diff --git a/src/renderer/hooks/useSessionSelection.ts b/src/renderer/hooks/useSessionSelection.ts new file mode 100644 index 0000000..8b549da --- /dev/null +++ b/src/renderer/hooks/useSessionSelection.ts @@ -0,0 +1,104 @@ +import { useCallback, useRef, useState } from 'react'; + +export interface SessionSelectionState { + /** Currently selected session IDs */ + selectedIds: Set; + /** Whether multi-select mode is active */ + isSelecting: boolean; + /** Toggle a single session's selection */ + toggle: (sessionId: string) => void; + /** Shift+click range selection: selects everything between the last anchor and the target */ + rangeSelect: (sessionId: string, allVisibleIds: string[]) => void; + /** Select all provided session IDs */ + selectAll: (sessionIds: string[]) => void; + /** Deselect all */ + deselectAll: () => void; + /** Enter multi-select mode with an initial selection */ + enterSelectionMode: (initialId: string) => void; + /** Exit multi-select mode and clear selection */ + exitSelectionMode: () => void; + /** Check if a session is selected */ + isSelected: (sessionId: string) => boolean; +} + +export function useSessionSelection(): SessionSelectionState { + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [isSelecting, setIsSelecting] = useState(false); + + // Tracks the last explicitly toggled session for shift+click range behaviour + const anchorRef = useRef(null); + + const enterSelectionMode = useCallback((initialId: string) => { + setIsSelecting(true); + setSelectedIds(new Set([initialId])); + anchorRef.current = initialId; + }, []); + + const exitSelectionMode = useCallback(() => { + setIsSelecting(false); + setSelectedIds(new Set()); + anchorRef.current = null; + }, []); + + const toggle = useCallback((sessionId: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(sessionId)) { + next.delete(sessionId); + } else { + next.add(sessionId); + } + anchorRef.current = sessionId; + return next; + }); + }, []); + + const rangeSelect = useCallback((sessionId: string, allVisibleIds: string[]) => { + const anchor = anchorRef.current; + if (!anchor) { + // No anchor yet — treat as a simple toggle + setSelectedIds(new Set([sessionId])); + anchorRef.current = sessionId; + return; + } + + const anchorIdx = allVisibleIds.indexOf(anchor); + const targetIdx = allVisibleIds.indexOf(sessionId); + if (anchorIdx === -1 || targetIdx === -1) return; + + const start = Math.min(anchorIdx, targetIdx); + const end = Math.max(anchorIdx, targetIdx); + const rangeIds = allVisibleIds.slice(start, end + 1); + + setSelectedIds((prev) => { + const next = new Set(prev); + for (const id of rangeIds) { + next.add(id); + } + return next; + }); + // Anchor stays the same for contiguous range extension + }, []); + + const selectAll = useCallback((sessionIds: string[]) => { + setSelectedIds(new Set(sessionIds)); + }, []); + + const deselectAll = useCallback(() => { + setSelectedIds(new Set()); + }, []); + + const isSelected = useCallback((sessionId: string) => selectedIds.has(sessionId), [selectedIds]); + + return { + selectedIds, + isSelecting, + toggle, + rangeSelect, + selectAll, + deselectAll, + enterSelectionMode, + exitSelectionMode, + isSelected, + }; +} diff --git a/src/renderer/styles.css b/src/renderer/styles.css index b8dce15..d338ef4 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -674,6 +674,108 @@ body { animation: intent-divider-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) both; } +/* ── Batch selection animations ──────────────────────────────── */ + +@keyframes selection-checkbox-in { + from { + opacity: 0; + transform: scale(0); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes selection-checkbox-out { + from { + opacity: 1; + transform: scale(1); + } + to { + opacity: 0; + transform: scale(0); + } +} + +.selection-checkbox-enter { + animation: selection-checkbox-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) both; +} + +.selection-checkbox-exit { + animation: selection-checkbox-out 0.15s cubic-bezier(0.16, 1, 0.3, 1) both; +} + +@keyframes checkbox-check { + 0% { + transform: scale(1); + } + 40% { + transform: scale(1.2); + } + 100% { + transform: scale(1); + } +} + +.checkbox-check { + animation: checkbox-check 0.2s cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes batch-action-bar-in { + from { + opacity: 0; + transform: translateY(100%); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.batch-action-bar-enter { + animation: batch-action-bar-in 0.25s cubic-bezier(0.16, 1, 0.3, 1) both; +} + +@keyframes undo-toast-in { + from { + opacity: 0; + transform: translateY(8px) scale(0.96); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes undo-toast-out { + from { + opacity: 1; + transform: translateY(0) scale(1); + } + to { + opacity: 0; + transform: translateY(8px) scale(0.96); + } +} + +.undo-toast-enter { + animation: undo-toast-in 0.25s cubic-bezier(0.16, 1, 0.3, 1) both; +} + +.undo-toast-exit { + animation: undo-toast-out 0.2s cubic-bezier(0.16, 1, 0.3, 1) both; +} + +@keyframes toast-progress { + from { + transform: scaleX(1); + } + to { + transform: scaleX(0); + } +} + /* ── Respect reduced motion ──────────────────────────────────── */ @media (prefers-reduced-motion: reduce) { @@ -697,6 +799,15 @@ body { animation: none; } + .selection-checkbox-enter, + .selection-checkbox-exit, + .checkbox-check, + .batch-action-bar-enter, + .undo-toast-enter, + .undo-toast-exit { + animation: none; + } + .qp-border-streaming { animation: none; border-color: var(--color-accent); diff --git a/src/shared/contracts/channels.ts b/src/shared/contracts/channels.ts index 89286c1..1804a18 100644 --- a/src/shared/contracts/channels.ts +++ b/src/shared/contracts/channels.ts @@ -49,6 +49,8 @@ export const ipcChannels = { setSessionPinned: 'sessions:set-pinned', setSessionArchived: 'sessions:set-archived', deleteSession: 'sessions:delete', + batchSetSessionsArchived: 'sessions:batch-set-archived', + batchDeleteSessions: 'sessions:batch-delete', regenerateSessionMessage: 'sessions:regenerate-message', editAndResendSessionMessage: 'sessions:edit-and-resend-message', sendSessionMessage: 'sessions:send-message', diff --git a/src/shared/contracts/ipc.ts b/src/shared/contracts/ipc.ts index 45a4880..a0b111d 100644 --- a/src/shared/contracts/ipc.ts +++ b/src/shared/contracts/ipc.ts @@ -218,6 +218,15 @@ export interface DeleteSessionInput { sessionId: string; } +export interface BatchSetSessionsArchivedInput { + sessionIds: string[]; + isArchived: boolean; +} + +export interface BatchDeleteSessionsInput { + sessionIds: string[]; +} + export interface ResizeTerminalInput { cols: number; rows: number; @@ -334,6 +343,8 @@ export interface ElectronApi { setSessionPinned(input: SetSessionPinnedInput): Promise; setSessionArchived(input: SetSessionArchivedInput): Promise; deleteSession(input: DeleteSessionInput): Promise; + batchSetSessionsArchived(input: BatchSetSessionsArchivedInput): Promise; + batchDeleteSessions(input: BatchDeleteSessionsInput): Promise; regenerateSessionMessage(input: RegenerateSessionMessageInput): Promise; editAndResendSessionMessage(input: EditAndResendSessionMessageInput): Promise; sendSessionMessage(input: SendSessionMessageInput): Promise; diff --git a/tests/renderer/sessionSelection.test.ts b/tests/renderer/sessionSelection.test.ts new file mode 100644 index 0000000..9e613c9 --- /dev/null +++ b/tests/renderer/sessionSelection.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, test } from 'bun:test'; + +/** + * Test the core selection logic extracted from useSessionSelection. + * Since the hook is thin state wrapper, we test the pure logic operations directly. + */ + +describe('session selection logic', () => { + // Helpers that mirror the hook's internal logic + function toggle(selected: Set, sessionId: string): Set { + const next = new Set(selected); + if (next.has(sessionId)) next.delete(sessionId); + else next.add(sessionId); + return next; + } + + function rangeSelect( + selected: Set, + anchor: string | null, + target: string, + allVisibleIds: string[], + ): Set { + if (!anchor) return new Set([target]); + const anchorIdx = allVisibleIds.indexOf(anchor); + const targetIdx = allVisibleIds.indexOf(target); + if (anchorIdx === -1 || targetIdx === -1) return selected; + const start = Math.min(anchorIdx, targetIdx); + const end = Math.max(anchorIdx, targetIdx); + const next = new Set(selected); + for (const id of allVisibleIds.slice(start, end + 1)) next.add(id); + return next; + } + + function selectAll(sessionIds: string[]): Set { + return new Set(sessionIds); + } + + function filterSelectable(sessions: { id: string; status: string }[]): string[] { + return sessions.filter((s) => s.status !== 'running').map((s) => s.id); + } + + describe('toggle', () => { + test('adds a session to empty selection', () => { + const result = toggle(new Set(), 'a'); + expect(result.has('a')).toBe(true); + expect(result.size).toBe(1); + }); + + test('removes an already-selected session', () => { + const result = toggle(new Set(['a', 'b']), 'a'); + expect(result.has('a')).toBe(false); + expect(result.has('b')).toBe(true); + expect(result.size).toBe(1); + }); + + test('adds to existing selection', () => { + const result = toggle(new Set(['a']), 'b'); + expect(result.has('a')).toBe(true); + expect(result.has('b')).toBe(true); + expect(result.size).toBe(2); + }); + }); + + describe('rangeSelect', () => { + const visible = ['a', 'b', 'c', 'd', 'e']; + + test('selects range from anchor to target (forward)', () => { + const result = rangeSelect(new Set(['b']), 'b', 'd', visible); + expect([...result].sort()).toEqual(['b', 'c', 'd']); + }); + + test('selects range from anchor to target (backward)', () => { + const result = rangeSelect(new Set(['d']), 'd', 'b', visible); + expect([...result].sort()).toEqual(['b', 'c', 'd']); + }); + + test('preserves existing selections outside the range', () => { + const result = rangeSelect(new Set(['a', 'c']), 'c', 'e', visible); + expect([...result].sort()).toEqual(['a', 'c', 'd', 'e']); + }); + + test('falls back to single selection when no anchor', () => { + const result = rangeSelect(new Set(), null, 'c', visible); + expect([...result]).toEqual(['c']); + }); + + test('no-ops when anchor is not in visible list', () => { + const result = rangeSelect(new Set(['x']), 'x', 'c', visible); + expect([...result]).toEqual(['x']); + }); + + test('no-ops when target is not in visible list', () => { + const result = rangeSelect(new Set(['a']), 'a', 'z', visible); + expect([...result]).toEqual(['a']); + }); + }); + + describe('selectAll', () => { + test('selects all provided IDs', () => { + const result = selectAll(['a', 'b', 'c']); + expect(result.size).toBe(3); + expect(result.has('a')).toBe(true); + expect(result.has('b')).toBe(true); + expect(result.has('c')).toBe(true); + }); + + test('handles empty list', () => { + const result = selectAll([]); + expect(result.size).toBe(0); + }); + }); + + describe('filterSelectable (running session exclusion)', () => { + test('excludes running sessions', () => { + const sessions = [ + { id: 'a', status: 'idle' }, + { id: 'b', status: 'running' }, + { id: 'c', status: 'error' }, + { id: 'd', status: 'running' }, + ]; + const result = filterSelectable(sessions); + expect(result).toEqual(['a', 'c']); + }); + + test('includes all when none are running', () => { + const sessions = [ + { id: 'a', status: 'idle' }, + { id: 'b', status: 'idle' }, + ]; + const result = filterSelectable(sessions); + expect(result).toEqual(['a', 'b']); + }); + + test('returns empty when all are running', () => { + const sessions = [ + { id: 'a', status: 'running' }, + { id: 'b', status: 'running' }, + ]; + const result = filterSelectable(sessions); + expect(result).toEqual([]); + }); + }); + + describe('enter and exit selection mode', () => { + test('entering with an initial ID creates a set with that ID', () => { + const selected = new Set(['initial']); + expect(selected.size).toBe(1); + expect(selected.has('initial')).toBe(true); + }); + + test('exiting clears all selections', () => { + const selected = new Set(['a', 'b', 'c']); + const cleared = new Set(); + expect(cleared.size).toBe(0); + expect(selected.size).toBe(3); + }); + }); + + describe('batch archive determination', () => { + test('allSelectedArchived is true when all selected are archived', () => { + const sessions = [ + { id: 'a', isArchived: true }, + { id: 'b', isArchived: true }, + { id: 'c', isArchived: false }, + ]; + const selected = new Set(['a', 'b']); + const allArchived = [...selected].every((id) => { + const s = sessions.find((s) => s.id === id); + return s?.isArchived; + }); + expect(allArchived).toBe(true); + }); + + test('allSelectedArchived is false when any selected is not archived', () => { + const sessions = [ + { id: 'a', isArchived: true }, + { id: 'b', isArchived: false }, + ]; + const selected = new Set(['a', 'b']); + const allArchived = [...selected].every((id) => { + const s = sessions.find((s) => s.id === id); + return s?.isArchived; + }); + expect(allArchived).toBe(false); + }); + }); +});