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, + }; }