Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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',
Expand All @@ -96,17 +116,18 @@ const ConversationRow = observer(function ConversationRow({
<div
role="button"
tabIndex={0}
onClick={() => 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'
)}
>
<AgentIcon id={conversation.data.providerId} size={16} className="size-4" />
Expand All @@ -131,7 +152,12 @@ const ConversationRow = observer(function ConversationRow({
) : (
<span className="min-w-0 flex-1 truncate">{displayTitle}</span>
)}
<span className="shrink-0">
<span
className={cn(
'shrink-0 transition-opacity',
isSelected ? 'opacity-0' : 'group-hover:opacity-0'
)}
>
{conversation.indicatorStatus ? (
<AgentStatusIndicator status={conversation.indicatorStatus} disableTooltip />
) : (
Expand All @@ -142,6 +168,31 @@ const ConversationRow = observer(function ConversationRow({
/>
)}
</span>
<div
onClick={(e) => 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'
)}
>
<Checkbox
checked={isSelected}
onMouseDown={(e) => {
checkboxShiftKeyRef.current = e.shiftKey;
}}
onKeyDown={(e) => {
e.stopPropagation();
checkboxShiftKeyRef.current = e.shiftKey;
}}
onCheckedChange={() => {
onToggleSelect(checkboxShiftKeyRef.current);
checkboxShiftKeyRef.current = false;
}}
aria-label="Select conversation"
/>
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent finalFocus={false}>
Expand All @@ -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 (
<ListPopoverCard className="justify-between">
<span className="whitespace-nowrap text-foreground-muted">{count} selected</span>
<div className="flex items-center gap-2">
<Button variant="destructive" size="sm" onClick={onDelete}>
<Trash2 className="size-3.5" />
Delete
</Button>
<Button variant="ghost" size="icon-xs" onClick={onClear} aria-label="Clear selection">
<X className="size-3.5" />
</Button>
</div>
</ListPopoverCard>
);
}

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) => {
Expand All @@ -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<HTMLDivElement>(null);

Expand All @@ -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,
Expand All @@ -193,16 +298,24 @@ export const SidebarConversationsList = observer(function SidebarConversationsLi
};

return (
<div className="flex h-full w-full flex-col">
<div className="relative flex h-full w-full flex-col">
<div className="flex shrink-0 items-center justify-between pt-2 pr-2 pb-1 pl-4">
<MicroLabel>Conversations</MicroLabel>
<Button size="icon-sm" variant="ghost" onClick={handleCreate}>
<Plus className="size-3.5" />
</Button>
</div>

<div ref={parentRef} className="min-h-0 flex-1 overflow-y-auto px-2">
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
<div
ref={parentRef}
className={cn('min-h-0 flex-1 overflow-y-auto px-2', selectedCount > 0 && 'pb-20')}
>
<div
style={{
height: virtualizer.getTotalSize(),
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const conversationId = conversationIds[virtualItem.index]!;
return (
Expand All @@ -217,12 +330,19 @@ export const SidebarConversationsList = observer(function SidebarConversationsLi
transform: `translateY(${virtualItem.start}px)`,
}}
>
<ConversationRow conversationId={conversationId} />
<ConversationRow
conversationId={conversationId}
isSelected={selectedIds.has(conversationId)}
hasSelection={selectedCount > 0}
onToggleSelect={(shiftKey) => toggleSelect(conversationId, { range: shiftKey })}
/>
</div>
);
})}
</div>
</div>

<SelectionBar count={selectedCount} onClear={clearSelection} onDelete={handleBulkDelete} />
</div>
);
});
65 changes: 53 additions & 12 deletions apps/emdash-desktop/src/renderer/lib/hooks/use-multi-select.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import { useCallback, useMemo, useState } from 'react';

interface SelectionAnchor {
id: string;
orderedIds: string[];
}

interface UseMultiSelectOptions<T> {
items: ReadonlyArray<T>;
getId: (item: T) => string;
}

export interface UseMultiSelectResult {
selectedIds: Set<string>;
toggle: (id: string) => void;
selectedCount: number;
selectedOrderedIds: string[];
toggle: (id: string, options?: { range?: boolean }) => void;
selectAll: () => void;
clear: () => void;
isSelected: (id: string) => boolean;
Expand All @@ -18,27 +25,61 @@ export function useMultiSelect<T>({
getId,
}: UseMultiSelectOptions<T>): UseMultiSelectResult {
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
const [selectionAnchor, setSelectionAnchor] = useState<SelectionAnchor | null>(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;
Comment thread
janburzinski marked this conversation as resolved.
}
}

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