From 6e84248bb06c3d968ce95defbbe2df8f08d242c3 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Fri, 30 Oct 2026 11:14:37 +0530 Subject: [PATCH] feat(web): delete archived threads from the archive page --- .../settings/ArchivedThreadsPanel.tsx | 224 +++++++++--------- .../useThreadActions.permissions.test.ts | 12 + apps/web/src/hooks/useThreadActions.ts | 4 +- 3 files changed, 120 insertions(+), 120 deletions(-) diff --git a/apps/web/src/components/settings/ArchivedThreadsPanel.tsx b/apps/web/src/components/settings/ArchivedThreadsPanel.tsx index 8aacb39c50320..a694ba5750596 100644 --- a/apps/web/src/components/settings/ArchivedThreadsPanel.tsx +++ b/apps/web/src/components/settings/ArchivedThreadsPanel.tsx @@ -1,10 +1,10 @@ import { settingsScopeEnvironmentLabels } from "./settingsScopeAxis"; import { Spinner } from "~/components/ui/spinner"; -import { ArchiveIcon, ArchiveX } from "lucide-react"; -import { type ScopedThreadRef } from "@t3tools/contracts"; -import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { ArchiveIcon, ArchiveX, Trash2Icon } from "lucide-react"; +import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; import { presentThreadShell } from "@t3tools/client-runtime/state/shell"; import { + type AtomCommandResult, isAtomCommandInterrupted, settlePromise, squashAtomCommandFailure, @@ -20,9 +20,20 @@ import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsL import { searchableSetting } from "./settingsSearch"; import { ProjectFavicon } from "../ProjectFavicon"; +import { useRef, useState } from "react"; +import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; + +const ARCHIVE_ACTIONS = [ + { action: "unarchive", label: "Unarchive", Icon: ArchiveX }, + { action: "delete", label: "Delete", Icon: Trash2Icon }, +] as const; +type ArchiveAction = (typeof ARCHIVE_ACTIONS)[number]["action"]; + export function ArchivedThreadsPanel() { const { scope, groups: projectGroups, environments } = useSettingsScope(); const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); + const pendingActionsRef = useRef(new Set()); + const [pendingKeys, setPendingKeys] = useState>(() => new Set()); const { snapshots: archivedSnapshots, error: archiveError, @@ -122,52 +133,57 @@ export function ArchivedThreadsPanel() { return { groups: sortedGroups, hasMultipleEnvironments }; })(); - const handleArchivedThreadContextMenu = async ( - threadRef: ScopedThreadRef, - position: { x: number; y: number }, - ) => { - const api = readLocalApi(); - if (!api) return; - const clicked = await api.contextMenu.show( - [ - { id: "unarchive", label: "Unarchive" }, - { id: "delete", label: "Delete", destructive: true }, - ], - position, + type ArchivedThread = (typeof archivedGroups)[number]["threads"][number]; + + const reportFailure = (result: AtomCommandResult | null, title: string) => { + if (result?._tag !== "Failure" || isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title, + description: error instanceof Error ? error.message : "An error occurred.", + }), ); + }; - if (clicked === "unarchive") { - const result = await unarchiveThread(threadRef); - if (result._tag === "Success") { - refreshArchivedThreads(); - } else if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to unarchive thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - return; - } + const handleThreadAction = async (action: ArchiveAction, thread: ArchivedThread) => { + const target = scopeThreadRef(thread.environmentId, thread.id); + const key = scopedThreadKey(target); + // Context menus can outlive their render, so checks and cleanup use the live set. + const pending = pendingActionsRef.current; + if (pending.has(key)) return; + pending.add(key); + setPendingKeys(new Set(pending)); + const result = await settlePromise(async () => { + const result = await (action === "unarchive" + ? unarchiveThread(target) + : confirmAndDeleteThread(target, thread.title)); + if (result._tag === "Success") refreshArchivedThreads(); + return result; + }).finally(() => { + pending.delete(key); + setPendingKeys(new Set(pending)); + }); + reportFailure(result._tag === "Failure" ? result : result.value, `Failed to ${action} thread`); + }; - if (clicked === "delete") { - const result = await confirmAndDeleteThread(threadRef); - if (result._tag === "Success") { - refreshArchivedThreads(); - } else if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to delete thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - } + const handleContextMenu = async (thread: ArchivedThread, position: { x: number; y: number }) => { + const pending = pendingActionsRef.current; + if (pending.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)))) return; + const result = await settlePromise(async () => { + const clicked = await readLocalApi()?.contextMenu.show( + ARCHIVE_ACTIONS.map(({ action, label }) => ({ + id: action, + label, + destructive: action === "delete", + })), + position, + ); + if (clicked === "unarchive" || clicked === "delete") + await handleThreadAction(clicked, thread); + }); + reportFailure(result, "Archived thread action failed"); }; return ( @@ -207,78 +223,50 @@ export function ArchivedThreadsPanel() { title={title} icon={} > - {projectThreads.map((thread) => ( - { - event.preventDefault(); - void (async () => { - const result = await settlePromise(() => - handleArchivedThreadContextMenu( - scopeThreadRef(thread.environmentId, thread.id), - { - 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={thread.title} - description={ - - {hasMultipleEnvironments ? `${thread.environmentLabel} · ` : null} - Archived - {" \u00b7 Created "} - - - } - control={ - - } - /> - ))} + {projectThreads.map((thread) => { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + return ( + { + event.preventDefault(); + void handleContextMenu(thread, { x: event.clientX, y: event.clientY }); + }} + title={thread.title} + description={ + + {hasMultipleEnvironments ? `${thread.environmentLabel} · ` : null} + Archived + {" \u00b7 Created "} + + + } + control={ +
+ {ARCHIVE_ACTIONS.map(({ action, label, Icon }) => ( + + void handleThreadAction(action, thread)} + /> + } + > + + + {label} + + ))} +
+ } + /> + ); + })} )) )} diff --git a/apps/web/src/hooks/useThreadActions.permissions.test.ts b/apps/web/src/hooks/useThreadActions.permissions.test.ts index fd27270f3380b..dfca32683c3a0 100644 --- a/apps/web/src/hooks/useThreadActions.permissions.test.ts +++ b/apps/web/src/hooks/useThreadActions.permissions.test.ts @@ -282,6 +282,18 @@ describe("thread action permissions", () => { }, ); + it("names an archived thread in its delete confirmation and preserves it on cancel", async () => { + state.threads = []; + state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope); + state.confirm.mockResolvedValue(false); + await useThreadActions().confirmAndDeleteThread(target, "Archived release notes"); + expect(state.confirm).toHaveBeenCalledWith( + expect.stringContaining('Delete thread "Archived release notes"?'), + { variant: "destructive" }, + ); + expect(state.requests).toEqual([]); + }); + it("deletes an archived thread only with its own environment's grant", async () => { state.threads = []; const actions = useThreadActions(); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index c41873d59400f..412a249201623 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -910,14 +910,14 @@ export function useThreadActions() { (result) => result._tag === "Success", ); - const confirmAndDeleteThread = async (target: ScopedThreadRef) => { + const confirmAndDeleteThread = async (target: ScopedThreadRef, knownTitle?: string) => { const permissionFailure = threadOperationFailure(target); if (permissionFailure) return permissionFailure; const localApi = readLocalApi(); const resolved = resolveThreadTarget(target); if (confirmThreadDelete && localApi) { - const title = resolved?.thread.title ?? "this thread"; + const title = resolved?.thread.title ?? knownTitle ?? "this thread"; const confirmationResult = await settlePromise(() => localApi.dialogs.confirm( [