From 4bcafba78ba84bc7516d1d8e24bbcbebc07032fe Mon Sep 17 00:00:00 2001 From: adityavardhansharma Date: Tue, 30 Jun 2026 15:03:17 +0100 Subject: [PATCH 1/5] feat(settings): add archived thread bulk actions --- .../settings/SettingsPanels.logic.test.ts | 64 +++ .../settings/SettingsPanels.logic.ts | 31 ++ .../components/settings/SettingsPanels.tsx | 434 +++++++++++++++--- 3 files changed, 454 insertions(+), 75 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.logic.test.ts b/apps/web/src/components/settings/SettingsPanels.logic.test.ts index d783d16c7ada..f5e037e05810 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.test.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.test.ts @@ -1,12 +1,17 @@ import { DEFAULT_SERVER_SETTINGS, + EnvironmentId, ProviderDriverKind, ProviderInstanceId, + ThreadId, type ProviderInstanceConfig, } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { + archivedThreadSelectionKey, + buildArchivedThreadSelectionKeys, buildProviderInstanceUpdatePatch, + pruneArchivedThreadSelection, formatDiagnosticsDescription, } from "./SettingsPanels.logic"; @@ -102,3 +107,62 @@ describe("buildProviderInstanceUpdatePatch", () => { expect(patch.providers).toBeUndefined(); }); }); + +describe("archived thread selection helpers", () => { + it("scopes selection keys by environment and thread", () => { + const threadId = ThreadId.make("thread-1"); + + expect( + archivedThreadSelectionKey({ + environmentId: EnvironmentId.make("environment-a"), + threadId, + }), + ).toBe("environment-a:thread-1"); + expect( + archivedThreadSelectionKey({ + environmentId: EnvironmentId.make("environment-b"), + threadId, + }), + ).toBe("environment-b:thread-1"); + }); + + it("keeps duplicate thread ids distinct across environments", () => { + const threadId = ThreadId.make("thread-1"); + const selected = new Set([ + archivedThreadSelectionKey({ + environmentId: EnvironmentId.make("environment-a"), + threadId, + }), + archivedThreadSelectionKey({ + environmentId: EnvironmentId.make("environment-b"), + threadId, + }), + ]); + + expect(selected.size).toBe(2); + }); + + it("builds select-all keys from scoped archived threads", () => { + expect( + buildArchivedThreadSelectionKeys([ + { + environmentId: EnvironmentId.make("environment-a"), + threadId: ThreadId.make("thread-1"), + }, + { + environmentId: EnvironmentId.make("environment-b"), + threadId: ThreadId.make("thread-1"), + }, + ]), + ).toEqual(["environment-a:thread-1", "environment-b:thread-1"]); + }); + + it("prunes selections that are no longer present in archived snapshots", () => { + const selected = new Set(["environment-a:thread-1", "environment-a:thread-2"]); + const available = new Set(["environment-a:thread-2", "environment-b:thread-1"]); + + expect([...pruneArchivedThreadSelection(selected, available)]).toEqual([ + "environment-a:thread-2", + ]); + }); +}); diff --git a/apps/web/src/components/settings/SettingsPanels.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts index 99d7052965aa..a2ffac2bdeeb 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.ts @@ -1,12 +1,43 @@ +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { + EnvironmentId, ProviderDriverKind, ProviderInstanceConfig, ProviderInstanceId, ServerSettings, + ThreadId, UnifiedSettings, } from "@t3tools/contracts"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +export type ArchivedThreadSelectionItem = { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; +}; + +export function archivedThreadSelectionKey(input: ArchivedThreadSelectionItem): string { + return scopedThreadKey(scopeThreadRef(input.environmentId, input.threadId)); +} + +export function buildArchivedThreadSelectionKeys( + threads: ReadonlyArray, +): ReadonlyArray { + return threads.map(archivedThreadSelectionKey); +} + +export function pruneArchivedThreadSelection( + selectedKeys: ReadonlySet, + availableKeys: ReadonlySet, +): Set { + const next = new Set(); + for (const key of selectedKeys) { + if (availableKeys.has(key)) { + next.add(key); + } + } + return next; +} + function collapseOtelSignalsUrl(input: { readonly tracesUrl: string; readonly metricsUrl: string; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 40017d563143..dfbdf45cddc6 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,6 +1,13 @@ -import { ArchiveIcon, ArchiveX, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react"; +import { + ArchiveIcon, + ArchiveX, + LoaderIcon, + PlusIcon, + RefreshCwIcon, + Trash2Icon, +} from "lucide-react"; import { Link } from "@tanstack/react-router"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAtomValue } from "@effect/atom-react"; import { defaultInstanceIdForDriver, @@ -11,7 +18,7 @@ import { type ProviderInstanceId, type ScopedThreadRef, } from "@t3tools/contracts"; -import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { isAtomCommandInterrupted, @@ -37,7 +44,11 @@ import { TraitsPicker } from "../chat/TraitsPicker"; import { isElectron } from "../../env"; import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing"; import { useTheme } from "../../hooks/useTheme"; -import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; +import { + useClientSettings, + usePrimarySettings, + useUpdatePrimarySettings, +} from "../../hooks/useSettings"; import { useThreadActions } from "../../hooks/useThreadActions"; import { useDesktopUpdateState } from "../../state/desktopUpdate"; import { @@ -59,7 +70,17 @@ import { usePrimaryEnvironment } from "../../state/environments"; import { useProjects } from "../../state/entities"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; import { Button } from "../ui/button"; +import { Checkbox } from "../ui/checkbox"; import { DraftInput } from "../ui/draft-input"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; @@ -76,8 +97,10 @@ import { import { ProviderInstanceCard } from "./ProviderInstanceCard"; import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; import { + buildArchivedThreadSelectionKeys, buildProviderInstanceUpdatePatch, formatDiagnosticsDescription, + pruneArchivedThreadSelection, } from "./SettingsPanels.logic"; import { SettingResetButton, @@ -1422,7 +1445,13 @@ export function ProviderSettingsPanel() { export function ArchivedThreadsPanel() { const projects = useProjects(); - const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); + const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete); + const { unarchiveThread, confirmAndDeleteThread, deleteThread } = useThreadActions(); + const [selectedArchivedThreadKeys, setSelectedArchivedThreadKeys] = useState( + () => new Set(), + ); + const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false); + const [bulkActionPending, setBulkActionPending] = useState<"delete" | "unarchive" | null>(null); const environmentIds = useMemo( () => [...new Set(projects.map((project) => project.environmentId))], [projects], @@ -1484,6 +1513,167 @@ export function ArchivedThreadsPanel() { return groups; }, [archivedSnapshots]); + const archivedThreadEntries = useMemo( + () => + archivedGroups.flatMap(({ threads: projectThreads }) => + projectThreads.map((thread) => { + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + return { + key: scopedThreadKey(threadRef), + threadRef, + }; + }), + ), + [archivedGroups], + ); + + const archivedThreadSelectionKeys = useMemo( + () => + buildArchivedThreadSelectionKeys( + archivedThreadEntries.map((entry) => ({ + environmentId: entry.threadRef.environmentId, + threadId: entry.threadRef.threadId, + })), + ), + [archivedThreadEntries], + ); + const archivedThreadSelectionKeySet = useMemo( + () => new Set(archivedThreadSelectionKeys), + [archivedThreadSelectionKeys], + ); + const archivedThreadSelectionKeySignature = archivedThreadSelectionKeys.join("\0"); + + useEffect(() => { + setSelectedArchivedThreadKeys((previous) => { + const next = pruneArchivedThreadSelection(previous, archivedThreadSelectionKeySet); + if (next.size === previous.size && [...next].every((key) => previous.has(key))) { + return previous; + } + return next; + }); + }, [archivedThreadSelectionKeySet, archivedThreadSelectionKeySignature]); + + const selectedArchivedThreadEntries = useMemo( + () => archivedThreadEntries.filter((entry) => selectedArchivedThreadKeys.has(entry.key)), + [archivedThreadEntries, selectedArchivedThreadKeys], + ); + const selectedArchivedThreadCount = selectedArchivedThreadEntries.length; + const isArchiveBulkActionPending = bulkActionPending !== null; + const allArchivedThreadsSelected = + archivedThreadEntries.length > 0 && + selectedArchivedThreadCount === archivedThreadEntries.length; + const someArchivedThreadsSelected = + selectedArchivedThreadCount > 0 && !allArchivedThreadsSelected; + + const clearSelectedArchivedThreads = useCallback(() => { + setSelectedArchivedThreadKeys(new Set()); + }, []); + + const toggleArchivedThreadSelection = useCallback((threadKey: string, checked: boolean) => { + setSelectedArchivedThreadKeys((previous) => { + const next = new Set(previous); + if (checked) { + next.add(threadKey); + } else { + next.delete(threadKey); + } + return next; + }); + }, []); + + const executeBulkUnarchiveArchivedThreads = useCallback(async () => { + if (selectedArchivedThreadEntries.length === 0 || bulkActionPending !== null) return; + const totalCount = selectedArchivedThreadEntries.length; + let failedCount = 0; + let lastErrorMessage = "An error occurred."; + setBulkActionPending("unarchive"); + try { + for (const entry of selectedArchivedThreadEntries) { + const result = await unarchiveThread(entry.threadRef); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + failedCount += 1; + lastErrorMessage = error instanceof Error ? error.message : "An error occurred."; + } + } + } + clearSelectedArchivedThreads(); + refreshArchivedThreads(); + if (failedCount > 0) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to unarchive ${failedCount} of ${totalCount} threads`, + description: lastErrorMessage, + }), + ); + } + } finally { + setBulkActionPending(null); + } + }, [ + bulkActionPending, + clearSelectedArchivedThreads, + refreshArchivedThreads, + selectedArchivedThreadEntries, + unarchiveThread, + ]); + + const executeBulkDeleteArchivedThreads = useCallback(async () => { + if (selectedArchivedThreadEntries.length === 0 || bulkActionPending !== null) return; + const totalCount = selectedArchivedThreadEntries.length; + const deletedThreadKeys = new Set(selectedArchivedThreadEntries.map((entry) => entry.key)); + let failedCount = 0; + let lastErrorMessage = "An error occurred."; + setBulkActionPending("delete"); + try { + for (const entry of selectedArchivedThreadEntries) { + const result = await deleteThread(entry.threadRef, { deletedThreadKeys }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + failedCount += 1; + lastErrorMessage = error instanceof Error ? error.message : "An error occurred."; + } + } + } + clearSelectedArchivedThreads(); + refreshArchivedThreads(); + if (failedCount > 0) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to delete ${failedCount} of ${totalCount} threads`, + description: lastErrorMessage, + }), + ); + } + } finally { + setBulkActionPending(null); + } + }, [ + bulkActionPending, + clearSelectedArchivedThreads, + deleteThread, + refreshArchivedThreads, + selectedArchivedThreadEntries, + ]); + + const requestBulkDeleteArchivedThreads = useCallback(() => { + if (selectedArchivedThreadEntries.length === 0 || bulkActionPending !== null) return; + if (confirmThreadDelete) { + setBulkDeleteDialogOpen(true); + return; + } + void executeBulkDeleteArchivedThreads(); + }, [ + bulkActionPending, + confirmThreadDelete, + executeBulkDeleteArchivedThreads, + selectedArchivedThreadEntries.length, + ]); + const handleArchivedThreadContextMenu = useCallback( async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { const api = readLocalApi(); @@ -1559,85 +1749,179 @@ export function ArchivedThreadsPanel() { /> ) : ( - archivedGroups.map(({ project, threads: projectThreads }) => ( - } - > - {projectThreads.map((thread) => ( - { - event.preventDefault(); - void (async () => { - const result = await settlePromise(() => - handleArchivedThreadContextMenu( - scopeThreadRef(thread.environmentId, thread.id), - { - x: event.clientX, - y: event.clientY, - }, - ), +
+
+
+ +
+
+ {selectedArchivedThreadCount > 0 ? ( + <> + - } - /> + + ) : null} +
+
+ +
+ {archivedGroups.map(({ project, threads: projectThreads }) => ( + } + > + {projectThreads.map((thread) => { + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const threadKey = scopedThreadKey(threadRef); + return ( + { + event.preventDefault(); + void (async () => { + const result = await settlePromise(() => + handleArchivedThreadContextMenu(threadRef, { + x: event.clientX, + y: event.clientY, + }), + ); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Archived thread action failed", + description: + error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); + }} + title={ + + } + description={ + <> + Archived {formatRelativeTimeLabel(thread.archivedAt ?? thread.createdAt)} + {" \u00b7 Created "} + {formatRelativeTimeLabel(thread.createdAt)} + + } + control={ + + } + /> + ); + })} + ))} - - )) +
+ + + + + + Delete {selectedArchivedThreadCount} archived thread + {selectedArchivedThreadCount === 1 ? "" : "s"}? + + + This permanently clears conversation history for the selected archived threads. + This cannot be undone. + + + + }>Cancel + + + + +
)} ); From 98fa67781da618b2d39ffe62726a4db5de87480e Mon Sep 17 00:00:00 2001 From: adityavardhansharma Date: Tue, 30 Jun 2026 15:21:23 +0100 Subject: [PATCH 2/5] fix(settings): confirm archived bulk actions --- .../components/settings/SettingsPanels.tsx | 56 +++++++++++++------ 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index dfbdf45cddc6..5483736208e6 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -44,11 +44,7 @@ import { TraitsPicker } from "../chat/TraitsPicker"; import { isElectron } from "../../env"; import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing"; import { useTheme } from "../../hooks/useTheme"; -import { - useClientSettings, - usePrimarySettings, - useUpdatePrimarySettings, -} from "../../hooks/useSettings"; +import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; import { useThreadActions } from "../../hooks/useThreadActions"; import { useDesktopUpdateState } from "../../state/desktopUpdate"; import { @@ -1445,12 +1441,12 @@ export function ProviderSettingsPanel() { export function ArchivedThreadsPanel() { const projects = useProjects(); - const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete); const { unarchiveThread, confirmAndDeleteThread, deleteThread } = useThreadActions(); const [selectedArchivedThreadKeys, setSelectedArchivedThreadKeys] = useState( () => new Set(), ); const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false); + const [bulkUnarchiveDialogOpen, setBulkUnarchiveDialogOpen] = useState(false); const [bulkActionPending, setBulkActionPending] = useState<"delete" | "unarchive" | null>(null); const environmentIds = useMemo( () => [...new Set(projects.map((project) => project.environmentId))], @@ -1662,17 +1658,13 @@ export function ArchivedThreadsPanel() { const requestBulkDeleteArchivedThreads = useCallback(() => { if (selectedArchivedThreadEntries.length === 0 || bulkActionPending !== null) return; - if (confirmThreadDelete) { - setBulkDeleteDialogOpen(true); - return; - } - void executeBulkDeleteArchivedThreads(); - }, [ - bulkActionPending, - confirmThreadDelete, - executeBulkDeleteArchivedThreads, - selectedArchivedThreadEntries.length, - ]); + setBulkDeleteDialogOpen(true); + }, [bulkActionPending, selectedArchivedThreadEntries.length]); + + const requestBulkUnarchiveArchivedThreads = useCallback(() => { + if (selectedArchivedThreadEntries.length === 0 || bulkActionPending !== null) return; + setBulkUnarchiveDialogOpen(true); + }, [bulkActionPending, selectedArchivedThreadEntries.length]); const handleArchivedThreadContextMenu = useCallback( async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { @@ -1789,7 +1781,7 @@ export function ArchivedThreadsPanel() { variant="outline" className="h-7 shrink-0 cursor-pointer gap-1.5 px-2.5" disabled={isArchiveBulkActionPending} - onClick={() => void executeBulkUnarchiveArchivedThreads()} + onClick={requestBulkUnarchiveArchivedThreads} > Unarchive ({selectedArchivedThreadCount}) @@ -1921,6 +1913,34 @@ export function ArchivedThreadsPanel() { + + + + + + Unarchive {selectedArchivedThreadCount} archived thread + {selectedArchivedThreadCount === 1 ? "" : "s"}? + + + This restores the selected archived threads to their project thread lists. + + + + }>Cancel + + + + )} From 9542e9574ba1213036cc5ba7110316845aa2889e Mon Sep 17 00:00:00 2001 From: adityavardhansharma Date: Tue, 30 Jun 2026 15:25:23 +0100 Subject: [PATCH 3/5] fix(settings): guard archived bulk actions --- .../components/settings/SettingsPanels.tsx | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 5483736208e6..d09ee3a7670f 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1448,6 +1448,7 @@ export function ArchivedThreadsPanel() { const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false); const [bulkUnarchiveDialogOpen, setBulkUnarchiveDialogOpen] = useState(false); const [bulkActionPending, setBulkActionPending] = useState<"delete" | "unarchive" | null>(null); + const bulkActionPendingRef = useRef<"delete" | "unarchive" | null>(null); const environmentIds = useMemo( () => [...new Set(projects.map((project) => project.environmentId))], [projects], @@ -1578,10 +1579,13 @@ export function ArchivedThreadsPanel() { }, []); const executeBulkUnarchiveArchivedThreads = useCallback(async () => { - if (selectedArchivedThreadEntries.length === 0 || bulkActionPending !== null) return; + if (selectedArchivedThreadEntries.length === 0 || bulkActionPendingRef.current !== null) { + return; + } const totalCount = selectedArchivedThreadEntries.length; let failedCount = 0; let lastErrorMessage = "An error occurred."; + bulkActionPendingRef.current = "unarchive"; setBulkActionPending("unarchive"); try { for (const entry of selectedArchivedThreadEntries) { @@ -1606,10 +1610,10 @@ export function ArchivedThreadsPanel() { ); } } finally { + bulkActionPendingRef.current = null; setBulkActionPending(null); } }, [ - bulkActionPending, clearSelectedArchivedThreads, refreshArchivedThreads, selectedArchivedThreadEntries, @@ -1617,11 +1621,14 @@ export function ArchivedThreadsPanel() { ]); const executeBulkDeleteArchivedThreads = useCallback(async () => { - if (selectedArchivedThreadEntries.length === 0 || bulkActionPending !== null) return; + if (selectedArchivedThreadEntries.length === 0 || bulkActionPendingRef.current !== null) { + return; + } const totalCount = selectedArchivedThreadEntries.length; const deletedThreadKeys = new Set(selectedArchivedThreadEntries.map((entry) => entry.key)); let failedCount = 0; let lastErrorMessage = "An error occurred."; + bulkActionPendingRef.current = "delete"; setBulkActionPending("delete"); try { for (const entry of selectedArchivedThreadEntries) { @@ -1646,10 +1653,10 @@ export function ArchivedThreadsPanel() { ); } } finally { + bulkActionPendingRef.current = null; setBulkActionPending(null); } }, [ - bulkActionPending, clearSelectedArchivedThreads, deleteThread, refreshArchivedThreads, @@ -1657,14 +1664,14 @@ export function ArchivedThreadsPanel() { ]); const requestBulkDeleteArchivedThreads = useCallback(() => { - if (selectedArchivedThreadEntries.length === 0 || bulkActionPending !== null) return; + if (selectedArchivedThreadEntries.length === 0 || bulkActionPendingRef.current !== null) return; setBulkDeleteDialogOpen(true); - }, [bulkActionPending, selectedArchivedThreadEntries.length]); + }, [selectedArchivedThreadEntries.length]); const requestBulkUnarchiveArchivedThreads = useCallback(() => { - if (selectedArchivedThreadEntries.length === 0 || bulkActionPending !== null) return; + if (selectedArchivedThreadEntries.length === 0 || bulkActionPendingRef.current !== null) return; setBulkUnarchiveDialogOpen(true); - }, [bulkActionPending, selectedArchivedThreadEntries.length]); + }, [selectedArchivedThreadEntries.length]); const handleArchivedThreadContextMenu = useCallback( async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { From fec911a2a6d1a619d1db6e8e5799ccb1d93ac125 Mon Sep 17 00:00:00 2001 From: adityavardhansharma Date: Tue, 30 Jun 2026 15:33:51 +0100 Subject: [PATCH 4/5] fix(settings): preserve failed archived bulk selections --- .../components/settings/SettingsPanels.tsx | 27 ++++++++++++++++--- apps/web/src/hooks/useThreadActions.ts | 11 +++++++- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index d09ee3a7670f..8f741a1d83dc 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1566,6 +1566,10 @@ export function ArchivedThreadsPanel() { setSelectedArchivedThreadKeys(new Set()); }, []); + const retainSelectedArchivedThreadKeys = useCallback((threadKeys: ReadonlySet) => { + setSelectedArchivedThreadKeys(new Set(threadKeys)); + }, []); + const toggleArchivedThreadSelection = useCallback((threadKey: string, checked: boolean) => { setSelectedArchivedThreadKeys((previous) => { const next = new Set(previous); @@ -1583,6 +1587,7 @@ export function ArchivedThreadsPanel() { return; } const totalCount = selectedArchivedThreadEntries.length; + const unsuccessfulThreadKeys = new Set(); let failedCount = 0; let lastErrorMessage = "An error occurred."; bulkActionPendingRef.current = "unarchive"; @@ -1591,6 +1596,7 @@ export function ArchivedThreadsPanel() { for (const entry of selectedArchivedThreadEntries) { const result = await unarchiveThread(entry.threadRef); if (result._tag === "Failure") { + unsuccessfulThreadKeys.add(entry.key); if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); failedCount += 1; @@ -1598,7 +1604,11 @@ export function ArchivedThreadsPanel() { } } } - clearSelectedArchivedThreads(); + if (unsuccessfulThreadKeys.size === 0) { + clearSelectedArchivedThreads(); + } else { + retainSelectedArchivedThreadKeys(unsuccessfulThreadKeys); + } refreshArchivedThreads(); if (failedCount > 0) { toastManager.add( @@ -1615,6 +1625,7 @@ export function ArchivedThreadsPanel() { } }, [ clearSelectedArchivedThreads, + retainSelectedArchivedThreadKeys, refreshArchivedThreads, selectedArchivedThreadEntries, unarchiveThread, @@ -1626,14 +1637,19 @@ export function ArchivedThreadsPanel() { } const totalCount = selectedArchivedThreadEntries.length; const deletedThreadKeys = new Set(selectedArchivedThreadEntries.map((entry) => entry.key)); + const unsuccessfulThreadKeys = new Set(); let failedCount = 0; let lastErrorMessage = "An error occurred."; bulkActionPendingRef.current = "delete"; setBulkActionPending("delete"); try { for (const entry of selectedArchivedThreadEntries) { - const result = await deleteThread(entry.threadRef, { deletedThreadKeys }); + const result = await deleteThread(entry.threadRef, { + deletedThreadKeys, + ignorePostDeleteCleanupFailure: true, + }); if (result._tag === "Failure") { + unsuccessfulThreadKeys.add(entry.key); if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); failedCount += 1; @@ -1641,7 +1657,11 @@ export function ArchivedThreadsPanel() { } } } - clearSelectedArchivedThreads(); + if (unsuccessfulThreadKeys.size === 0) { + clearSelectedArchivedThreads(); + } else { + retainSelectedArchivedThreadKeys(unsuccessfulThreadKeys); + } refreshArchivedThreads(); if (failedCount > 0) { toastManager.add( @@ -1659,6 +1679,7 @@ export function ArchivedThreadsPanel() { }, [ clearSelectedArchivedThreads, deleteThread, + retainSelectedArchivedThreadKeys, refreshArchivedThreads, selectedArchivedThreadEntries, ]); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 07655ad30d76..d9b54ac78809 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -148,7 +148,13 @@ export function useThreadActions() { ); const deleteThread = useCallback( - async (target: ScopedThreadRef, opts: { deletedThreadKeys?: ReadonlySet } = {}) => { + async ( + target: ScopedThreadRef, + opts: { + deletedThreadKeys?: ReadonlySet; + ignorePostDeleteCleanupFailure?: boolean; + } = {}, + ) => { const resolved = resolveThreadTarget(target); if (!resolved) { // Thread not in main store (e.g. archived thread) — dispatch delete directly. @@ -325,6 +331,9 @@ export function useThreadActions() { description: `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}`, }), ); + if (opts.ignorePostDeleteCleanupFailure) { + return deleteResult; + } return cleanupFailure; } return deleteResult; From afd57728541d4ae36fa811adb02495d405e7f27a Mon Sep 17 00:00:00 2001 From: adityavardhansharma Date: Tue, 30 Jun 2026 15:41:26 +0100 Subject: [PATCH 5/5] fix(settings): respect archived bulk delete confirmation --- apps/web/src/components/settings/SettingsPanels.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 8f741a1d83dc..1f62266b48f8 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1442,6 +1442,7 @@ export function ProviderSettingsPanel() { export function ArchivedThreadsPanel() { const projects = useProjects(); const { unarchiveThread, confirmAndDeleteThread, deleteThread } = useThreadActions(); + const confirmThreadDelete = usePrimarySettings((settings) => settings.confirmThreadDelete); const [selectedArchivedThreadKeys, setSelectedArchivedThreadKeys] = useState( () => new Set(), ); @@ -1686,8 +1687,12 @@ export function ArchivedThreadsPanel() { const requestBulkDeleteArchivedThreads = useCallback(() => { if (selectedArchivedThreadEntries.length === 0 || bulkActionPendingRef.current !== null) return; - setBulkDeleteDialogOpen(true); - }, [selectedArchivedThreadEntries.length]); + if (confirmThreadDelete) { + setBulkDeleteDialogOpen(true); + return; + } + void executeBulkDeleteArchivedThreads(); + }, [confirmThreadDelete, executeBulkDeleteArchivedThreads, selectedArchivedThreadEntries.length]); const requestBulkUnarchiveArchivedThreads = useCallback(() => { if (selectedArchivedThreadEntries.length === 0 || bulkActionPendingRef.current !== null) return;