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..1f62266b48f8 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, @@ -59,7 +66,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 +93,10 @@ import { import { ProviderInstanceCard } from "./ProviderInstanceCard"; import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; import { + buildArchivedThreadSelectionKeys, buildProviderInstanceUpdatePatch, formatDiagnosticsDescription, + pruneArchivedThreadSelection, } from "./SettingsPanels.logic"; import { SettingResetButton, @@ -1422,7 +1441,15 @@ export function ProviderSettingsPanel() { export function ArchivedThreadsPanel() { const projects = useProjects(); - const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); + const { unarchiveThread, confirmAndDeleteThread, deleteThread } = useThreadActions(); + const confirmThreadDelete = usePrimarySettings((settings) => settings.confirmThreadDelete); + 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 bulkActionPendingRef = useRef<"delete" | "unarchive" | null>(null); const environmentIds = useMemo( () => [...new Set(projects.map((project) => project.environmentId))], [projects], @@ -1484,6 +1511,194 @@ 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 retainSelectedArchivedThreadKeys = useCallback((threadKeys: ReadonlySet) => { + setSelectedArchivedThreadKeys(new Set(threadKeys)); + }, []); + + 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 || bulkActionPendingRef.current !== null) { + return; + } + const totalCount = selectedArchivedThreadEntries.length; + const unsuccessfulThreadKeys = new Set(); + let failedCount = 0; + let lastErrorMessage = "An error occurred."; + bulkActionPendingRef.current = "unarchive"; + setBulkActionPending("unarchive"); + try { + 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; + lastErrorMessage = error instanceof Error ? error.message : "An error occurred."; + } + } + } + if (unsuccessfulThreadKeys.size === 0) { + clearSelectedArchivedThreads(); + } else { + retainSelectedArchivedThreadKeys(unsuccessfulThreadKeys); + } + refreshArchivedThreads(); + if (failedCount > 0) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to unarchive ${failedCount} of ${totalCount} threads`, + description: lastErrorMessage, + }), + ); + } + } finally { + bulkActionPendingRef.current = null; + setBulkActionPending(null); + } + }, [ + clearSelectedArchivedThreads, + retainSelectedArchivedThreadKeys, + refreshArchivedThreads, + selectedArchivedThreadEntries, + unarchiveThread, + ]); + + const executeBulkDeleteArchivedThreads = useCallback(async () => { + if (selectedArchivedThreadEntries.length === 0 || bulkActionPendingRef.current !== null) { + return; + } + 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, + ignorePostDeleteCleanupFailure: true, + }); + if (result._tag === "Failure") { + unsuccessfulThreadKeys.add(entry.key); + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + failedCount += 1; + lastErrorMessage = error instanceof Error ? error.message : "An error occurred."; + } + } + } + if (unsuccessfulThreadKeys.size === 0) { + clearSelectedArchivedThreads(); + } else { + retainSelectedArchivedThreadKeys(unsuccessfulThreadKeys); + } + refreshArchivedThreads(); + if (failedCount > 0) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to delete ${failedCount} of ${totalCount} threads`, + description: lastErrorMessage, + }), + ); + } + } finally { + bulkActionPendingRef.current = null; + setBulkActionPending(null); + } + }, [ + clearSelectedArchivedThreads, + deleteThread, + retainSelectedArchivedThreadKeys, + refreshArchivedThreads, + selectedArchivedThreadEntries, + ]); + + const requestBulkDeleteArchivedThreads = useCallback(() => { + if (selectedArchivedThreadEntries.length === 0 || bulkActionPendingRef.current !== null) return; + if (confirmThreadDelete) { + setBulkDeleteDialogOpen(true); + return; + } + void executeBulkDeleteArchivedThreads(); + }, [confirmThreadDelete, executeBulkDeleteArchivedThreads, selectedArchivedThreadEntries.length]); + + const requestBulkUnarchiveArchivedThreads = useCallback(() => { + if (selectedArchivedThreadEntries.length === 0 || bulkActionPendingRef.current !== null) return; + setBulkUnarchiveDialogOpen(true); + }, [selectedArchivedThreadEntries.length]); + const handleArchivedThreadContextMenu = useCallback( async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { const api = readLocalApi(); @@ -1559,85 +1774,207 @@ 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 + + + + + + + + + + Unarchive {selectedArchivedThreadCount} archived thread + {selectedArchivedThreadCount === 1 ? "" : "s"}? + + + This restores the selected archived threads to their project thread lists. + + + + }>Cancel + + + + +
)} ); 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;