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>
This commit is contained in:
David Kaya
2026-04-15 07:12:45 +02:00
co-authored by Copilot
parent 0e9d77c745
commit 575aca360a
13 changed files with 1110 additions and 23 deletions
+44
View File
@@ -1149,6 +1149,50 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
return this.persistAndBroadcast(workspace);
}
async batchSetSessionsArchived(sessionIds: string[], isArchived: boolean): Promise<WorkspaceState> {
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<WorkspaceState> {
const workspace = await this.loadWorkspace();
const idsToDelete = new Set(sessionIds);
// Collect cleanup work before mutating the array
const cleanupTasks: Promise<void>[] = [];
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<void> {
const workspace = await this.loadWorkspace();
const session = this.requireSession(workspace, sessionId);
+8
View File
@@ -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),
);