From 3fadde1e6d2111a76f09eb097a6a95cbb30a907e Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Mon, 29 Dec 2025 15:21:20 -0600 Subject: [PATCH] feat: Add multi-chat delete functionality - Add 'Select' option to chat dropdown menu to enter selection mode - Support selecting up to 20 chats for bulk deletion - Use batch delete API from @opensecret/react 1.5.3 - Long-press on mobile enters selection mode - Selection mode shows count and Delete button in sidebar header Closes #351 Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- frontend/bun.lock | 4 +- frontend/package.json | 2 +- frontend/src/components/BulkDeleteDialog.tsx | 57 +++++ frontend/src/components/ChatHistoryList.tsx | 256 ++++++++++++++++--- frontend/src/components/Sidebar.tsx | 97 ++++++- 5 files changed, 366 insertions(+), 50 deletions(-) create mode 100644 frontend/src/components/BulkDeleteDialog.tsx diff --git a/frontend/bun.lock b/frontend/bun.lock index 5e73ef45..acf319d9 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -4,7 +4,7 @@ "": { "name": "maple", "dependencies": { - "@opensecret/react": "1.5.2", + "@opensecret/react": "1.5.3", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-checkbox": "^1.3.3", @@ -223,7 +223,7 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - "@opensecret/react": ["@opensecret/react@1.5.2", "", { "dependencies": { "@peculiar/x509": "^1.12.2", "@stablelib/base64": "^2.0.0", "@stablelib/chacha20poly1305": "^2.0.0", "@stablelib/random": "^2.0.0", "cbor2": "^1.7.0", "tweetnacl": "^1.0.3", "zod": "^3.23.8" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-a2CU4yNIIo1vvxFqrj8/S28e/6NsG34g/DJw4wnEYoFpqqePLY8cOQTzUQbtNEqVBQfOYlkeFQ/MIiuuZkISgg=="], + "@opensecret/react": ["@opensecret/react@1.5.3", "", { "dependencies": { "@peculiar/x509": "^1.12.2", "@stablelib/base64": "^2.0.0", "@stablelib/chacha20poly1305": "^2.0.0", "@stablelib/random": "^2.0.0", "cbor2": "^1.7.0", "tweetnacl": "^1.0.3", "zod": "^3.23.8" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-ObEm+9B9X2bzEiEWQ0B+wrbVTy2+gOFOYgjdVFD3sOEuwPmripbi9U0A1VAdjFfqmunGkhjco8RGMebKo2CrTA=="], "@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.3.15", "", { "dependencies": { "@peculiar/asn1-schema": "^2.3.15", "@peculiar/asn1-x509": "^2.3.15", "@peculiar/asn1-x509-attr": "^2.3.15", "asn1js": "^3.0.5", "tslib": "^2.8.1" } }, "sha512-B+DoudF+TCrxoJSTjjcY8Mmu+lbv8e7pXGWrhNp2/EGJp9EEcpzjBCar7puU57sGifyzaRVM03oD5L7t7PghQg=="], diff --git a/frontend/package.json b/frontend/package.json index 18bdea15..4c6787c9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,7 +17,7 @@ "mdast-util-gfm-autolink-literal": "2.0.0" }, "dependencies": { - "@opensecret/react": "1.5.2", + "@opensecret/react": "1.5.3", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-checkbox": "^1.3.3", diff --git a/frontend/src/components/BulkDeleteDialog.tsx b/frontend/src/components/BulkDeleteDialog.tsx new file mode 100644 index 00000000..c28f96bb --- /dev/null +++ b/frontend/src/components/BulkDeleteDialog.tsx @@ -0,0 +1,57 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle +} from "@/components/ui/alert-dialog"; + +interface BulkDeleteDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + count: number; + isDeleting?: boolean; +} + +export function BulkDeleteDialog({ + open, + onOpenChange, + onConfirm, + count, + isDeleting = false +}: BulkDeleteDialogProps) { + const handleConfirm = (e: React.MouseEvent) => { + e.preventDefault(); + onConfirm(); + }; + + return ( + + + + + Delete {count} chat{count !== 1 ? "s" : ""}? + + + This will permanently delete {count} selected chat{count !== 1 ? "s" : ""}. This action + cannot be undone. + + + + Cancel + + {isDeleting ? "Deleting..." : "Delete"} + + + + + ); +} diff --git a/frontend/src/components/ChatHistoryList.tsx b/frontend/src/components/ChatHistoryList.tsx index 53f83501..1c5d24c1 100644 --- a/frontend/src/components/ChatHistoryList.tsx +++ b/frontend/src/components/ChatHistoryList.tsx @@ -1,14 +1,23 @@ import { useState, useMemo, useCallback, useEffect, useRef, useContext } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { MoreHorizontal, Trash, Pencil, ChevronDown, ChevronRight } from "lucide-react"; +import { + MoreHorizontal, + Trash, + Pencil, + ChevronDown, + ChevronRight, + CheckSquare +} from "lucide-react"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { Checkbox } from "@/components/ui/checkbox"; import { RenameChatDialog } from "@/components/RenameChatDialog"; import { DeleteChatDialog } from "@/components/DeleteChatDialog"; +import { BulkDeleteDialog } from "@/components/BulkDeleteDialog"; import { useOpenAI } from "@/ai/useOpenAi"; import { useOpenSecret } from "@opensecret/react"; import { useRouter } from "@tanstack/react-router"; @@ -18,6 +27,10 @@ interface ChatHistoryListProps { currentChatId?: string; searchQuery?: string; isMobile?: boolean; + isSelectionMode?: boolean; + onExitSelectionMode?: () => void; + selectedIds: Set; + onSelectionChange: (ids: Set) => void; } interface Conversation { @@ -40,7 +53,11 @@ interface ArchivedChat { export function ChatHistoryList({ currentChatId, searchQuery = "", - isMobile = false + isMobile = false, + isSelectionMode = false, + onExitSelectionMode, + selectedIds, + onSelectionChange }: ChatHistoryListProps) { const openai = useOpenAI(); const opensecret = useOpenSecret(); @@ -49,8 +66,11 @@ export function ChatHistoryList({ const localState = useContext(LocalStateContext); const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false); + const [isBulkDeleting, setIsBulkDeleting] = useState(false); const [selectedChat, setSelectedChat] = useState<{ id: string; title: string } | null>(null); const [isArchivedExpanded, setIsArchivedExpanded] = useState(false); + const longPressTimerRef = useRef | null>(null); // Pagination states const [oldestConversationId, setOldestConversationId] = useState(); @@ -321,6 +341,132 @@ export function ChatHistoryList({ [openai, currentChatId, archivedChats, localState, queryClient, router] ); + const MAX_SELECTION = 20; + + // Toggle selection of a single chat + const toggleSelection = useCallback( + (chatId: string) => { + const newSelection = new Set(selectedIds); + if (newSelection.has(chatId)) { + newSelection.delete(chatId); + } else { + // Enforce max selection limit + if (newSelection.size >= MAX_SELECTION) { + return; + } + newSelection.add(chatId); + } + onSelectionChange(newSelection); + }, + [selectedIds, onSelectionChange] + ); + + // Handle bulk delete + const handleBulkDelete = useCallback(async () => { + if (selectedIds.size === 0) return; + + setIsBulkDeleting(true); + try { + const idsToDelete = Array.from(selectedIds); + + // Separate archived chats from API conversations + const archivedIds = idsToDelete.filter((id) => archivedChats?.some((chat) => chat.id === id)); + const conversationIds = idsToDelete.filter( + (id) => !archivedChats?.some((chat) => chat.id === id) + ); + + // Delete API conversations using batch delete + if (conversationIds.length > 0 && opensecret) { + const result = await opensecret.batchDeleteConversations(conversationIds); + + // Remove successfully deleted conversations from local state + const deletedIds = new Set( + result.data.filter((item) => item.deleted).map((item) => item.id) + ); + setConversations((prev) => prev.filter((conv) => !deletedIds.has(conv.id))); + } + + // Delete archived chats individually + for (const id of archivedIds) { + if (localState?.deleteChat) { + await localState.deleteChat(id); + } + } + + // Refresh archived chats if any were deleted + if (archivedIds.length > 0) { + queryClient.invalidateQueries({ queryKey: ["archivedChats"] }); + } + + // If current chat was deleted, navigate to home + if (selectedIds.has(currentChatId || "")) { + const params = new URLSearchParams(window.location.search); + params.delete("conversation_id"); + window.history.replaceState({}, "", params.toString() ? `/?${params}` : "/"); + window.dispatchEvent(new Event("newchat")); + } + + // Clear selection and exit selection mode + onSelectionChange(new Set()); + onExitSelectionMode?.(); + setIsBulkDeleteDialogOpen(false); + } catch (error) { + console.error("Error bulk deleting chats:", error); + } finally { + setIsBulkDeleting(false); + } + }, [ + selectedIds, + archivedChats, + opensecret, + localState, + queryClient, + currentChatId, + onSelectionChange, + onExitSelectionMode + ]); + + // Long press handlers for mobile selection mode activation + const handleLongPressStart = useCallback( + (chatId: string) => { + if (isSelectionMode) return; // Already in selection mode + + longPressTimerRef.current = setTimeout(() => { + // Enter selection mode and select this chat + onSelectionChange(new Set([chatId])); + }, 500); // 500ms long press + }, + [isSelectionMode, onSelectionChange] + ); + + const handleLongPressEnd = useCallback(() => { + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current); + longPressTimerRef.current = null; + } + }, []); + + // Cleanup long-press timer on unmount + useEffect(() => { + return () => { + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current); + longPressTimerRef.current = null; + } + }; + }, []); + + // Expose bulk delete dialog trigger + useEffect(() => { + const handleOpenBulkDelete = () => { + if (selectedIds.size > 0) { + setIsBulkDeleteDialogOpen(true); + } + }; + window.addEventListener("openbulkdelete", handleOpenBulkDelete); + return () => window.removeEventListener("openbulkdelete", handleOpenBulkDelete); + }, [selectedIds.size]); + const handleOpenRenameDialog = useCallback((conv: Conversation) => { const title = conv.metadata?.title || "Untitled Chat"; setSelectedChat({ id: conv.id, title }); @@ -440,6 +586,7 @@ export function ChatHistoryList({ {filteredConversations.map((conv: Conversation, index: number) => { const title = conv.metadata?.title || "Untitled Chat"; const isActive = conv.id === currentChatId; + const isSelected = selectedIds.has(conv.id); const isLastConversation = index === filteredConversations.length - 1; // Only attach ref when not searching and it's the last item const shouldAttachRef = isLastConversation && !searchQuery.trim(); @@ -447,46 +594,81 @@ export function ChatHistoryList({ return (
e.preventDefault()} >
handleSelectConversation(conv.id)} + onClick={() => { + if (isSelectionMode) { + toggleSelection(conv.id); + } else { + handleSelectConversation(conv.id); + } + }} + onMouseDown={() => isMobile && handleLongPressStart(conv.id)} + onMouseUp={handleLongPressEnd} + onMouseLeave={handleLongPressEnd} + onTouchStart={() => isMobile && handleLongPressStart(conv.id)} + onTouchEnd={handleLongPressEnd} + onTouchCancel={handleLongPressEnd} className={`rounded-lg py-2 transition-all hover:text-primary cursor-pointer ${ - isActive ? "text-primary" : "text-muted-foreground" - }`} + isActive && !isSelectionMode ? "text-primary" : "text-muted-foreground" + } ${isSelectionMode ? "pl-8" : ""}`} > -
{title}
+ {isSelectionMode && ( +
+ toggleSelection(conv.id)} + onClick={(e) => e.stopPropagation()} + className="data-[state=checked]:bg-primary" + /> +
+ )} +
+ {title} +
{new Date(conv.created_at * 1000).toLocaleDateString()}
- - - - - - handleOpenRenameDialog(conv)}> - - Rename Chat - - handleOpenDeleteDialog(conv)}> - - Delete Chat - - - -
+ {!isSelectionMode && ( + + + + + + onSelectionChange(new Set([conv.id]))}> + + Select + + handleOpenRenameDialog(conv)}> + + Rename Chat + + handleOpenDeleteDialog(conv)}> + + Delete Chat + + + + )} + {!isSelectionMode && ( +
+ )}
); })} @@ -589,6 +771,14 @@ export function ChatHistoryList({ /> )} + + ); } diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index d0b7e865..b91ae2ef 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,9 +1,17 @@ -import { Search, SquarePenIcon, PanelRightClose, PanelRightOpen, XCircle } from "lucide-react"; +import { + Search, + SquarePenIcon, + PanelRightClose, + PanelRightOpen, + XCircle, + Trash2, + X +} from "lucide-react"; import { Button } from "./ui/button"; import { useLocation, useRouter } from "@tanstack/react-router"; import { ChatHistoryList } from "./ChatHistoryList"; import { AccountMenu } from "./AccountMenu"; -import { useRef, useEffect, KeyboardEvent, useCallback, useLayoutEffect } from "react"; +import { useRef, useEffect, KeyboardEvent, useCallback, useLayoutEffect, useState } from "react"; import { cn, useClickOutside, useIsMobile } from "@/utils/utils"; import { Input } from "./ui/input"; import { useLocalState } from "@/state/useLocalState"; @@ -22,6 +30,28 @@ export function Sidebar({ const { searchQuery, setSearchQuery, isSearchVisible, setIsSearchVisible } = useLocalState(); const searchInputRef = useRef(null); + // Multi-select state + const [isSelectionMode, setIsSelectionMode] = useState(false); + const [selectedIds, setSelectedIds] = useState>(new Set()); + + // Enter selection mode when items are selected (e.g., via long press) + useEffect(() => { + if (selectedIds.size > 0 && !isSelectionMode) { + setIsSelectionMode(true); + } + }, [selectedIds.size, isSelectionMode]); + + const exitSelectionMode = useCallback(() => { + setIsSelectionMode(false); + setSelectedIds(new Set()); + }, []); + + const handleDeleteSelected = useCallback(() => { + if (selectedIds.size > 0) { + window.dispatchEvent(new Event("openbulkdelete")); + } + }, [selectedIds.size]); + async function addChat() { // If sidebar is open on mobile, close it if (isOpen && isMobile) { @@ -157,17 +187,48 @@ export function Sidebar({ -
-

History

- +
+ {isSelectionMode ? ( + <> +
+ + + {selectedIds.size >= 20 ? "max" : selectedIds.size} selected + +
+ + + ) : ( + <> +

History

+ + + )}
{isSearchVisible && (
@@ -193,7 +254,15 @@ export function Sidebar({
)}