diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index e3e620030b..42380cc9ba 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -6,6 +6,7 @@ import { LoaderIcon, PlusIcon, RefreshCwIcon, + Trash2, Undo2Icon, XIcon, } from "lucide-react"; @@ -49,7 +50,17 @@ import { ensureNativeApi, readNativeApi } from "../../nativeApi"; import { useStore } from "../../store"; import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; import { cn } from "../../lib/utils"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; import { Button } from "../ui/button"; +import { Checkbox } from "../ui/checkbox"; import { Collapsible, CollapsibleContent } from "../ui/collapsible"; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; import { Input } from "../ui/input"; @@ -1416,7 +1427,11 @@ export function GeneralSettingsPanel() { export function ArchivedThreadsPanel() { const projects = useStore((store) => store.projects); const threads = useStore((store) => store.threads); - const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); + const appSettings = useSettings(); + const { unarchiveThread, confirmAndDeleteThread, deleteThread } = useThreadActions(); + const [selectedArchivedIds, setSelectedArchivedIds] = useState(() => new Set()); + const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false); + const archivedGroups = useMemo(() => { const projectById = new Map(projects.map((project) => [project.id, project] as const)); return [...projectById.values()] @@ -1433,6 +1448,106 @@ export function ArchivedThreadsPanel() { .filter((group) => group.threads.length > 0); }, [projects, threads]); + const allArchivedThreadIds = useMemo( + () => archivedGroups.flatMap((group) => group.threads.map((thread) => thread.id)), + [archivedGroups], + ); + + const archivedIdsKey = useMemo(() => allArchivedThreadIds.join("\0"), [allArchivedThreadIds]); + + useEffect(() => { + const valid = new Set(allArchivedThreadIds); + setSelectedArchivedIds((previous) => { + let changed = false; + const next = new Set(); + for (const id of previous) { + if (valid.has(id)) { + next.add(id); + } else { + changed = true; + } + } + return changed ? next : previous; + }); + }, [archivedIdsKey, allArchivedThreadIds]); + + const selectedCount = selectedArchivedIds.size; + const allSelected = + allArchivedThreadIds.length > 0 && selectedCount === allArchivedThreadIds.length; + const noneSelected = selectedCount === 0; + + const toggleArchivedSelected = useCallback((threadId: ThreadId, checked: boolean) => { + setSelectedArchivedIds((previous) => { + const next = new Set(previous); + if (checked) { + next.add(threadId); + } else { + next.delete(threadId); + } + return next; + }); + }, []); + + const handleBulkUnarchiveArchived = useCallback(async () => { + const ids = [...selectedArchivedIds]; + if (ids.length === 0) return; + for (const threadId of ids) { + try { + await unarchiveThread(threadId); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to unarchive thread", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + } + setSelectedArchivedIds(new Set()); + }, [selectedArchivedIds, unarchiveThread]); + + const executeBulkDeleteArchived = useCallback(async () => { + const ids = [...selectedArchivedIds]; + if (ids.length === 0) return; + const deletedIds = new Set(ids); + for (const threadId of ids) { + try { + await deleteThread(threadId, { deletedThreadIds: deletedIds }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to delete thread", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + } + setSelectedArchivedIds(new Set()); + }, [deleteThread, selectedArchivedIds]); + + const requestBulkDeleteArchived = useCallback(() => { + if (selectedArchivedIds.size === 0) return; + if (!appSettings.confirmThreadDelete) { + void executeBulkDeleteArchived(); + return; + } + setBulkDeleteDialogOpen(true); + }, [appSettings.confirmThreadDelete, executeBulkDeleteArchived, selectedArchivedIds.size]); + + const confirmBulkDeleteFromDialog = useCallback(() => { + void (async () => { + try { + await executeBulkDeleteArchived(); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to delete threads", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } finally { + setBulkDeleteDialogOpen(false); + } + })(); + }, [executeBulkDeleteArchived]); + const handleArchivedThreadContextMenu = useCallback( async (threadId: ThreadId, position: { x: number; y: number }) => { const api = readNativeApi(); @@ -1480,54 +1595,125 @@ export function ArchivedThreadsPanel() { ) : ( - archivedGroups.map(({ project, threads: projectThreads }) => ( - } - > - {projectThreads.map((thread) => ( -
{ - event.preventDefault(); - void handleArchivedThreadContextMenu(thread.id, { - x: event.clientX, - y: event.clientY, - }); - }} - > -
-

{thread.title}

-

- Archived {formatRelativeTimeLabel(thread.archivedAt ?? thread.createdAt)} - {" \u00b7 Created "} - {formatRelativeTimeLabel(thread.createdAt)} -

-
- + +
+ {archivedGroups.map(({ project, threads: projectThreads }) => ( + } + > + {projectThreads.map((thread) => ( +
{ + event.preventDefault(); + void handleArchivedThreadContextMenu(thread.id, { + x: event.clientX, + y: event.clientY, + }); + }} > - - Unarchive +
+ toggleArchivedSelected(thread.id, value === true)} + onClick={(event) => { + event.stopPropagation(); + }} + /> +
+

+ {thread.title} +

+

+ Archived {formatRelativeTimeLabel(thread.archivedAt ?? thread.createdAt)} + {" \u00b7 Created "} + {formatRelativeTimeLabel(thread.createdAt)} +

+
+
+ +
+ ))} +
+ ))} + + + + + Delete {selectedCount} selected thread{selectedCount === 1 ? "" : "s"}? + + + This permanently removes conversation history for the selected threads. This + cannot be undone. + + + + }>Cancel + - - ))} -
- )) + + + + )} );