From 1930614a2f8a1e8d354679f20918de8aa3a13d23 Mon Sep 17 00:00:00 2001 From: FosanzDev Date: Mon, 23 Feb 2026 18:02:19 +0100 Subject: [PATCH 1/2] feat (#2603): Add bulk thread deletion with UI and API support. Additional change: Set three dots visible for mobile view --- backend/chainlit/server.py | 26 +++ backend/chainlit/translations/en-US.json | 2 + backend/chainlit/types.py | 4 + .../src/components/LeftSidebar/ThreadList.tsx | 214 +++++++++++++++--- libs/react-client/src/api/index.tsx | 14 +- 5 files changed, 229 insertions(+), 31 deletions(-) diff --git a/backend/chainlit/server.py b/backend/chainlit/server.py index 6accf1ccc4..e34d98ee8e 100644 --- a/backend/chainlit/server.py +++ b/backend/chainlit/server.py @@ -66,6 +66,7 @@ ConnectMCPRequest, DeleteFeedbackRequest, DeleteThreadRequest, + DeleteThreadsRequest, DisconnectMCPRequest, ElementRequest, GetThreadsRequest, @@ -1241,6 +1242,31 @@ async def delete_thread( return JSONResponse(content={"success": True}) +@router.delete("/project/threads") +async def delete_threads( + request: Request, + payload: DeleteThreadsRequest, + current_user: UserParam, +): + """Delete multiple threads.""" + + data_layer = get_data_layer() + + if not data_layer: + raise HTTPException(status_code=400, detail="Data persistence is not enabled") + + if not current_user: + raise HTTPException(status_code=401, detail="Unauthorized") + + thread_ids = payload.threadIds + + for thread_id in thread_ids: + await is_thread_author(current_user.identifier, thread_id) + await data_layer.delete_thread(thread_id) + + return JSONResponse(content={"success": True}) + + @router.post("/project/action") async def call_action( payload: CallActionRequest, diff --git a/backend/chainlit/translations/en-US.json b/backend/chainlit/translations/en-US.json index 028b750a0a..64c631ae80 100644 --- a/backend/chainlit/translations/en-US.json +++ b/backend/chainlit/translations/en-US.json @@ -6,6 +6,7 @@ "continue": "Continue", "goBack": "Go Back", "reset": "Reset", + "select": "Select", "submit": "Submit" }, "status": { @@ -176,6 +177,7 @@ "delete": { "title": "Confirm deletion", "description": "This will delete the thread as well as its messages and elements. This action cannot be undone", + "description_plural": "This will delete {{count}} threads as well as their messages and elements. This action cannot be undone", "success": "Chat deleted", "inProgress": "Deleting chat" }, diff --git a/backend/chainlit/types.py b/backend/chainlit/types.py index 43d8e7cefa..8e69912ab4 100644 --- a/backend/chainlit/types.py +++ b/backend/chainlit/types.py @@ -231,6 +231,10 @@ class DeleteThreadRequest(BaseModel): threadId: str +class DeleteThreadsRequest(BaseModel): + threadIds: List[str] + + class DeleteFeedbackRequest(BaseModel): feedbackId: str diff --git a/frontend/src/components/LeftSidebar/ThreadList.tsx b/frontend/src/components/LeftSidebar/ThreadList.tsx index 42d52706da..be0426727f 100644 --- a/frontend/src/components/LeftSidebar/ThreadList.tsx +++ b/frontend/src/components/LeftSidebar/ThreadList.tsx @@ -1,6 +1,6 @@ import { cn } from '@/lib/utils'; import { size } from 'lodash'; -import { Share2 } from 'lucide-react'; +import { Share2, Trash2 } from 'lucide-react'; import { useContext, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link, useNavigate } from 'react-router-dom'; @@ -32,6 +32,7 @@ import { AlertDialogTitle } from '@/components/ui/alert-dialog'; import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; import { Dialog, DialogContent, @@ -79,6 +80,9 @@ export function ThreadList({ const { clear } = useChatInteract(); const { threadId: currentThreadId } = useChatMessages(); const [threadIdToDelete, setThreadIdToDelete] = useState(); + const [isDeletingSelected, setIsDeletingSelected] = useState(false); + const [selectedThreadIds, setSelectedThreadIds] = useState([]); + const [isSelectionMode, setIsSelectionMode] = useState(false); const [threadIdToRename, setThreadIdToRename] = useState(); const [threadNewName, setThreadNewName] = useState(); const setThreadHistory = useSetRecoilState(threadHistoryState); @@ -176,6 +180,45 @@ export function ThreadList({ }); }; + const handleDeleteSelected = async () => { + if (selectedThreadIds.length === 0) return; + + if ( + selectedThreadIds.includes(idToResume || '') || + selectedThreadIds.includes(currentThreadId || '') + ) { + clear(); + await new Promise((resolve) => setTimeout(resolve, 300)); + } + + toast.promise(apiClient.deleteThreads(selectedThreadIds), { + loading: ( + + ), + success: () => { + setThreadHistory((prev) => ({ + ...prev, + threads: prev?.threads?.filter( + (t) => !selectedThreadIds.includes(t.id) + ) + })); + setSelectedThreadIds([]); + setIsSelectionMode(false); + navigate('/'); + return ( + + ); + }, + error: (err) => { + if (err instanceof ClientError) { + return {err.message}; + } else { + return ; + } + } + }); + }; + const handleRenameThread = () => { if (!threadIdToRename || !threadNewName) return; @@ -259,6 +302,31 @@ export function ThreadList({ + setIsDeletingSelected(open)} + > + + + + + + + {t('threadHistory.thread.actions.delete.description_plural', { + count: selectedThreadIds.length + })} + + + + + + + + + + + + setThreadIdToRename(undefined)} @@ -312,6 +380,39 @@ export function ThreadList({ threadId={threadIdToShare || null} /> +
+ + {isSelectionMode && ( +
+ {selectedThreadIds.length > 0 && ( + + )} +
+ )} +
{sortedTimeGroupKeys.map((group) => { const items = threadHistory!.timeGroupedThreads![group]; return ( @@ -329,51 +430,104 @@ export function ThreadList({ + {isSelectionMode && ( + { + if (checked) { + setSelectedThreadIds([ + ...selectedThreadIds, + thread.id + ]); + } else { + setSelectedThreadIds( + selectedThreadIds.filter( + (id) => id !== thread.id + ) + ); + } + }} + className="ml-2 shrink-0 transition-opacity" + /> + )} - - + + { + if (isSelectionMode) { + e.preventDefault(); + e.stopPropagation(); + const isSelected = selectedThreadIds.includes( + thread.id + ); + if (isSelected) { + setSelectedThreadIds( + selectedThreadIds.filter( + (id) => id !== thread.id + ) + ); + } else { + setSelectedThreadIds([ + ...selectedThreadIds, + thread.id + ]); + } + } + }} + > - + {thread.metadata?.is_shared ? (