diff --git a/apps/emdash-desktop/src/renderer/features/tasks/conversations/sidebar-conversations-list.tsx b/apps/emdash-desktop/src/renderer/features/tasks/conversations/sidebar-conversations-list.tsx
index 63e0e66a58..ff040d3ce0 100644
--- a/apps/emdash-desktop/src/renderer/features/tasks/conversations/sidebar-conversations-list.tsx
+++ b/apps/emdash-desktop/src/renderer/features/tasks/conversations/sidebar-conversations-list.tsx
@@ -1,5 +1,5 @@
import { useVirtualizer } from '@tanstack/react-virtual';
-import { Pencil, Plus, Trash2 } from 'lucide-react';
+import { Pencil, Plus, Trash2, X } from 'lucide-react';
import { observer } from 'mobx-react-lite';
import { useCallback, useRef, useState } from 'react';
import { formatConversationTitleForDisplay } from '@renderer/features/tasks/conversations/conversation-title-utils';
@@ -9,8 +9,11 @@ import {
useWorkspaceViewModel,
} from '@renderer/features/tasks/task-view-context';
import { AgentIcon } from '@renderer/lib/components/agent-icon';
+import { ListPopoverCard } from '@renderer/lib/components/list-popover-card';
+import { useMultiSelect } from '@renderer/lib/hooks/use-multi-select';
import { useShowModal } from '@renderer/lib/modal/modal-provider';
import { Button } from '@renderer/lib/ui/button';
+import { Checkbox } from '@renderer/lib/ui/checkbox';
import {
ContextMenu,
ContextMenuContent,
@@ -25,14 +28,22 @@ import { MAX_CONVERSATION_TITLE_LENGTH } from '@shared/core/conversations/conver
import { AgentStatusIndicator } from '../components/agent-status-indicator';
const ROW_HEIGHT = 32;
+const getConversationId = (id: string) => id;
const ConversationRow = observer(function ConversationRow({
conversationId,
+ isSelected,
+ hasSelection,
+ onToggleSelect,
}: {
conversationId: string;
+ isSelected: boolean;
+ hasSelection: boolean;
+ onToggleSelect: (shiftKey: boolean) => void;
}) {
const [isEditing, setIsEditing] = useState(false);
const committedRef = useRef(false);
+ const checkboxShiftKeyRef = useRef(false);
const taskView = useWorkspaceViewModel();
const conversations = useConversations();
const { tabManager, tabGroupManager } = taskView;
@@ -74,10 +85,19 @@ const ConversationRow = observer(function ConversationRow({
};
const handleDoubleClick = () => {
+ if (hasSelection) return;
tabGroupManager.openConversation(conversationId);
handleRename();
};
+ const handleRowClick = (shiftKey: boolean) => {
+ if (shiftKey || hasSelection) {
+ onToggleSelect(shiftKey);
+ return;
+ }
+ tabGroupManager.openConversationPreview(conversationId);
+ };
+
const handleDelete = () => {
showConfirm({
title: 'Delete conversation',
@@ -96,17 +116,18 @@ const ConversationRow = observer(function ConversationRow({
tabGroupManager.openConversationPreview(conversationId)}
+ onClick={(e) => handleRowClick(e.shiftKey)}
onDoubleClick={handleDoubleClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
- tabGroupManager.openConversationPreview(conversationId);
+ handleRowClick(e.shiftKey);
}
}}
className={cn(
- 'flex w-full items-center gap-2 h-8 rounded-md px-2 text-left text-sm text-foreground-muted transition-colors hover:bg-background-1 hover:text-foreground',
- isActive && 'bg-background-2 text-foreground hover:bg-background-2'
+ 'group relative flex w-full items-center gap-2 h-8 rounded-md px-2 text-left text-sm text-foreground-muted transition-colors hover:bg-background-1 hover:text-foreground',
+ isActive && 'bg-background-2 text-foreground hover:bg-background-2',
+ isSelected && 'bg-background-2 text-foreground hover:bg-background-2'
)}
>
@@ -131,7 +152,12 @@ const ConversationRow = observer(function ConversationRow({
) : (
{displayTitle}
)}
-
+
{conversation.indicatorStatus ? (
) : (
@@ -142,6 +168,31 @@ const ConversationRow = observer(function ConversationRow({
/>
)}
+ e.stopPropagation()}
+ className={cn(
+ 'absolute top-1/2 right-2 -translate-y-1/2 transition-opacity',
+ isSelected
+ ? 'opacity-100'
+ : 'pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100'
+ )}
+ >
+ {
+ checkboxShiftKeyRef.current = e.shiftKey;
+ }}
+ onKeyDown={(e) => {
+ e.stopPropagation();
+ checkboxShiftKeyRef.current = e.shiftKey;
+ }}
+ onCheckedChange={() => {
+ onToggleSelect(checkboxShiftKeyRef.current);
+ checkboxShiftKeyRef.current = false;
+ }}
+ aria-label="Select conversation"
+ />
+
@@ -159,11 +210,39 @@ const ConversationRow = observer(function ConversationRow({
);
});
+function SelectionBar({
+ count,
+ onClear,
+ onDelete,
+}: {
+ count: number;
+ onClear: () => void;
+ onDelete: () => void;
+}) {
+ if (count === 0) return null;
+
+ return (
+
+ {count} selected
+
+
+
+
+
+ );
+}
+
export const SidebarConversationsList = observer(function SidebarConversationsList() {
const { projectId, taskId } = useTaskViewContext();
const taskView = useWorkspaceViewModel();
const conversations = useConversations();
const { tabGroupManager } = taskView;
+ const showConfirm = useShowModal('confirmActionModal');
const showCreateConversationModal = useShowModal('createConversationModal');
const conversationIds = Array.from(conversations.conversations.values())
.sort((a, b) => {
@@ -172,6 +251,13 @@ export const SidebarConversationsList = observer(function SidebarConversationsLi
return bTime - aTime;
})
.map((c) => c.data.id);
+ const {
+ selectedIds,
+ selectedCount,
+ selectedOrderedIds: selectedConversationIds,
+ clear: clearSelection,
+ toggle: toggleSelect,
+ } = useMultiSelect({ items: conversationIds, getId: getConversationId });
const parentRef = useRef(null);
@@ -182,6 +268,25 @@ export const SidebarConversationsList = observer(function SidebarConversationsLi
overscan: 5,
});
+ const handleBulkDelete = () => {
+ const ids = selectedConversationIds;
+ if (ids.length === 0) return;
+
+ showConfirm({
+ title: ids.length === 1 ? 'Delete conversation' : 'Delete conversations',
+ description:
+ ids.length === 1
+ ? '1 conversation will be permanently deleted. This action cannot be undone.'
+ : `${ids.length} conversations will be permanently deleted. This action cannot be undone.`,
+ confirmLabel: 'Delete',
+ variant: 'destructive',
+ onSuccess: () => {
+ ids.forEach((id) => void conversations.deleteConversation(id));
+ clearSelection();
+ },
+ });
+ };
+
const handleCreate = () => {
showCreateConversationModal({
projectId,
@@ -193,7 +298,7 @@ export const SidebarConversationsList = observer(function SidebarConversationsLi
};
return (
-
+
Conversations
-
-
+
0 && 'pb-20')}
+ >
+
{virtualizer.getVirtualItems().map((virtualItem) => {
const conversationId = conversationIds[virtualItem.index]!;
return (
@@ -217,12 +330,19 @@ export const SidebarConversationsList = observer(function SidebarConversationsLi
transform: `translateY(${virtualItem.start}px)`,
}}
>
-
+ 0}
+ onToggleSelect={(shiftKey) => toggleSelect(conversationId, { range: shiftKey })}
+ />
);
})}
+
+
);
});
diff --git a/apps/emdash-desktop/src/renderer/lib/hooks/use-multi-select.ts b/apps/emdash-desktop/src/renderer/lib/hooks/use-multi-select.ts
index 30bfa7c259..c87467a1b1 100644
--- a/apps/emdash-desktop/src/renderer/lib/hooks/use-multi-select.ts
+++ b/apps/emdash-desktop/src/renderer/lib/hooks/use-multi-select.ts
@@ -1,5 +1,10 @@
import { useCallback, useMemo, useState } from 'react';
+interface SelectionAnchor {
+ id: string;
+ orderedIds: string[];
+}
+
interface UseMultiSelectOptions
{
items: ReadonlyArray;
getId: (item: T) => string;
@@ -7,7 +12,9 @@ interface UseMultiSelectOptions {
export interface UseMultiSelectResult {
selectedIds: Set;
- toggle: (id: string) => void;
+ selectedCount: number;
+ selectedOrderedIds: string[];
+ toggle: (id: string, options?: { range?: boolean }) => void;
selectAll: () => void;
clear: () => void;
isSelected: (id: string) => boolean;
@@ -18,27 +25,61 @@ export function useMultiSelect({
getId,
}: UseMultiSelectOptions): UseMultiSelectResult {
const [selectedIds, setSelectedIds] = useState>(() => new Set());
+ const [selectionAnchor, setSelectionAnchor] = useState(null);
+ const allIds = useMemo(() => items.map(getId), [items, getId]);
+ const selectedOrderedIds = useMemo(
+ () => allIds.filter((id) => selectedIds.has(id)),
+ [allIds, selectedIds]
+ );
- const toggle = useCallback((id: string) => {
- setSelectedIds((prev) => {
- const next = new Set(prev);
- if (next.has(id)) next.delete(id);
- else next.add(id);
- return next;
- });
- }, []);
+ const toggle = useCallback(
+ (id: string, options?: { range?: boolean }) => {
+ if (options?.range && selectionAnchor && selectionAnchor.id !== id) {
+ const fromIndex = selectionAnchor.orderedIds.indexOf(selectionAnchor.id);
+ const toIndex = selectionAnchor.orderedIds.indexOf(id);
+ if (fromIndex !== -1 && toIndex !== -1) {
+ const [start, end] = fromIndex < toIndex ? [fromIndex, toIndex] : [toIndex, fromIndex];
+ const rangeIds = selectionAnchor.orderedIds.slice(start, end + 1);
+ setSelectedIds((prev) => {
+ const next = new Set(prev);
+ rangeIds.forEach((rangeId) => next.add(rangeId));
+ return next;
+ });
+ setSelectionAnchor({ id, orderedIds: allIds });
+ return;
+ }
+ }
+
+ setSelectedIds((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ setSelectionAnchor({ id, orderedIds: allIds });
+ },
+ [allIds, selectionAnchor]
+ );
const clear = useCallback(() => {
setSelectedIds((prev) => (prev.size === 0 ? prev : new Set()));
+ setSelectionAnchor(null);
}, []);
- const allIds = useMemo(() => items.map(getId), [items, getId]);
-
const selectAll = useCallback(() => {
setSelectedIds(new Set(allIds));
+ setSelectionAnchor(null);
}, [allIds]);
const isSelected = useCallback((id: string) => selectedIds.has(id), [selectedIds]);
- return { selectedIds, toggle, selectAll, clear, isSelected };
+ return {
+ selectedIds,
+ selectedCount: selectedOrderedIds.length,
+ selectedOrderedIds,
+ toggle,
+ selectAll,
+ clear,
+ isSelected,
+ };
}