From 9e3162c4a86e49e153f0897713c8c1cdc5157fbb Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Fri, 30 Oct 2026 11:31:24 +0530 Subject: [PATCH] feat(web): unarchive or delete archived threads by project --- .../settings/ArchivedThreadsPanel.tsx | 292 +++++++++++------- .../SettingsPanels.archive.browser.test.tsx | 275 +++++++++++++++++ .../useThreadActions.permissions.test.ts | 12 + apps/web/src/hooks/useThreadActions.test.ts | 47 ++- apps/web/src/hooks/useThreadActions.ts | 53 +++- 5 files changed, 558 insertions(+), 121 deletions(-) create mode 100644 apps/web/src/components/settings/SettingsPanels.archive.browser.test.tsx diff --git a/apps/web/src/components/settings/ArchivedThreadsPanel.tsx b/apps/web/src/components/settings/ArchivedThreadsPanel.tsx index 8aacb39c50320..1af33cb1591f9 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, EllipsisIcon, 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,24 @@ import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsL import { searchableSetting } from "./settingsSearch"; import { ProjectFavicon } from "../ProjectFavicon"; +import { useRef, useState } from "react"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; +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 { unarchiveThread, unarchiveThreads, confirmAndDeleteThread, deleteArchivedThreads } = + useThreadActions(); + // null locks the whole archive; thread keys allow unrelated rows to run independently. + const pendingActionsRef = useRef(new Set()); + const [pendingKeys, setPendingKeys] = useState>(() => new Set()); const { snapshots: archivedSnapshots, error: archiveError, @@ -122,52 +137,94 @@ export function ArchivedThreadsPanel() { return { groups: sortedGroups, hasMultipleEnvironments }; })(); - const handleArchivedThreadContextMenu = async ( - threadRef: ScopedThreadRef, - position: { x: number; y: number }, + 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.", + }), + ); + }; + + const runAction = async ( + key: string | null, + action: ArchiveAction, + run: () => Promise | null>, ) => { - const api = readLocalApi(); - if (!api) return; - const clicked = await api.contextMenu.show( - [ - { id: "unarchive", label: "Unarchive" }, - { id: "delete", label: "Delete", destructive: true }, - ], - position, + // Context menus can outlive their render, so checks and cleanup use the live set. + const pending = pendingActionsRef.current; + if (pending.has(null) || pending.has(key) || (key === null && pending.size > 0)) return; + pending.add(key); + setPendingKeys(new Set(pending)); + const result = await settlePromise(run).finally(() => { + pending.delete(key); + setPendingKeys(new Set(pending)); + }); + reportFailure( + result._tag === "Failure" ? result : result.value, + `Failed to ${action} thread${key === null ? "s" : ""}`, ); + }; - 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 = (action: ArchiveAction, thread: ArchivedThread) => { + const target = scopeThreadRef(thread.environmentId, thread.id); + return runAction(scopedThreadKey(target), action, async () => { + const result = await (action === "unarchive" + ? unarchiveThread(target) + : confirmAndDeleteThread(target, thread.title)); + if (result._tag === "Success") refreshArchivedThreads(); + return result; + }); + }; - 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 handleBulkAction = ( + action: ArchiveAction, + threads: readonly ArchivedThread[], + title = "", + ) => { + if (threads.length === 0) return; + return runAction(null, action, async () => { + if (action === "delete") { + const api = readLocalApi(); + if ( + !api || + !(await api.dialogs.confirm( + `Delete ${threads.length} archived thread${threads.length === 1 ? "" : "s"}${title}?\nThis permanently clears conversation history for these threads.`, + { variant: "destructive" }, + )) + ) + return null; } - } + const targets = threads.map((thread) => scopeThreadRef(thread.environmentId, thread.id)); + return action === "unarchive" ? unarchiveThreads(targets) : deleteArchivedThreads(targets); + }); + }; + + const handleContextMenu = async (thread: ArchivedThread, position: { x: number; y: number }) => { + const pending = pendingActionsRef.current; + if ( + pending.has(null) || + 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 ( @@ -206,79 +263,80 @@ export function ArchivedThreadsPanel() { id={index === 0 ? searchableSetting("archive").id : undefined} title={title} icon={} + headerAction={ + + 0} + aria-label={`Archived thread actions for ${title}`} + /> + } + > + + + + {ARCHIVE_ACTIONS.map(({ action, label, Icon }) => ( + void handleBulkAction(action, projectThreads, ` in ${title}`)} + > + + {label} all in project + + ))} + + + } > - {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/components/settings/SettingsPanels.archive.browser.test.tsx b/apps/web/src/components/settings/SettingsPanels.archive.browser.test.tsx new file mode 100644 index 0000000000000..58f11f2dc7540 --- /dev/null +++ b/apps/web/src/components/settings/SettingsPanels.archive.browser.test.tsx @@ -0,0 +1,275 @@ +import { EnvironmentId, OrchestrationV2ArchivedShellSnapshot } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import * as Schema from "effect/Schema"; +import { AsyncResult } from "effect/reactivity"; +import { type ComponentProps } from "react"; +import { act, render } from "../../../test/render"; +import { expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + unarchiveThread: vi.fn(), + unarchiveThreads: vi.fn(), + confirmAndDeleteThread: vi.fn(), + deleteArchivedThreads: vi.fn(), + confirm: vi.fn(), + showContextMenu: vi.fn(), + toast: vi.fn(), +})); + +vi.mock(import("../../hooks/useThreadActions"), () => ({ + useThreadActions: (() => ({ + unarchiveThread: state.unarchiveThread, + unarchiveThreads: state.unarchiveThreads, + confirmAndDeleteThread: state.confirmAndDeleteThread, + deleteArchivedThreads: state.deleteArchivedThreads, + })) as never, +})); +vi.mock(import("./SettingsScopeContext"), async (original) => ({ + ...(await original()), + useSettingsScope: (() => ({ + scope: { kind: "all", environmentIds: [environmentId] }, + groups: [], + environments: [], + })) as never, +})); +vi.mock(import("../../lib/archivedThreadsState"), async (original) => ({ + ...(await original()), + useArchivedThreadSnapshots: () => ({ + snapshots: [{ environmentId, snapshot }], + error: null, + isLoading: false, + refresh: vi.fn(), + }), +})); +vi.mock(import("../../localApi"), async (original) => ({ + ...(await original()), + readLocalApi: (() => ({ + dialogs: { confirm: state.confirm }, + contextMenu: { show: state.showContextMenu }, + })) as never, +})); +vi.mock(import("../ui/toast"), async (original) => { + const actual = await original(); + return { ...actual, toastManager: { ...actual.toastManager, add: state.toast } }; +}); +vi.mock(import("../ProjectFavicon"), async (original) => ({ + ...(await original()), + ProjectFavicon: () => , +})); +vi.mock(import("./settingsLayout"), async (original) => { + const actual = await original(); + return { + ...actual, + SettingsPageContainer: ({ children }: ComponentProps) => ( +
{children}
+ ), + SettingsRow: ({ title, control, onContextMenu }: ComponentProps) => ( +
+ {title} + {control} +
+ ), + }; +}); + +import { makeThreadProjectionFixture } from "../../test-fixtures"; +import { ArchivedThreadsPanel } from "./ArchivedThreadsPanel"; + +const environmentId = EnvironmentId.make("local"); +const now = "2026-10-20T06:00:00.000Z"; +const snapshot = Schema.decodeUnknownSync(OrchestrationV2ArchivedShellSnapshot)({ + schemaVersion: 1, + snapshotSequence: 1, + projects: [ + { + id: "project-1", + title: "Project", + workspaceRoot: "/project", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + ], + threads: [1, 2].map((index) => ({ + ...makeThreadProjectionFixture().thread, + id: `thread-${index}`, + projectId: "project-1", + title: `Thread ${index}`, + latestRunId: null, + activeRunId: null, + status: "completed", + pendingRuntimeRequest: null, + latestVisibleMessage: null, + latestUserMessageAt: null, + hasActionableProposedPlan: false, + itemCount: 0, + visibleItemCount: 0, + archivedAt: DateTime.makeUnsafe(now), + })), +}); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +function setup() { + vi.resetAllMocks(); + state.unarchiveThread.mockResolvedValue(AsyncResult.success(undefined)); + state.confirmAndDeleteThread.mockResolvedValue(AsyncResult.success(undefined)); + state.unarchiveThreads.mockResolvedValue(null); + state.deleteArchivedThreads.mockResolvedValue(null); + state.confirm.mockResolvedValue(true); + state.showContextMenu.mockResolvedValue("unarchive"); +} + +async function runBulkAction(container: HTMLElement, label: string) { + const button = [...container.querySelectorAll("button")].find( + (item) => item.textContent === label, + ); + if (button) { + await act(async () => button.click()); + return; + } + await act(async () => { + container + .querySelector('button[aria-label="Archived thread actions for Project"]')! + .click(); + }); + const item = [...document.querySelectorAll('[role="menuitem"]')].find( + (entry) => entry.textContent?.trim() === label, + ); + expect(item, label).toBeDefined(); + await act(async () => item!.click()); +} + +function openContextMenu(container: HTMLElement) { + container + .querySelector('[data-slot="settings-row"]')! + .dispatchEvent(new MouseEvent("contextmenu", { bubbles: true })); +} + +it.each([ + ["Unarchive all in project", "unarchiveThreads"], + ["Delete all in project", "deleteArchivedThreads"], +] as const)( + "blocks context-menu actions during %s and releases them afterward", + async (label, method) => { + setup(); + await using screen = await render(); + const { container } = screen; + const batch = deferred(); + state[method].mockReturnValueOnce(batch.promise); + + await runBulkAction(container, label); + expect(state[method]).toHaveBeenCalledOnce(); + await act(async () => openContextMenu(container)); + expect(state.showContextMenu).not.toHaveBeenCalled(); + expect(state.unarchiveThread).not.toHaveBeenCalled(); + expect(state.confirmAndDeleteThread).not.toHaveBeenCalled(); + + await act(async () => batch.resolve(null)); + await act(async () => openContextMenu(container)); + expect(state.unarchiveThread).toHaveBeenCalledOnce(); + }, +); + +it.each(["unarchive", "delete"])( + "rejects %s from a menu opened before a bulk action", + async (choice) => { + setup(); + await using screen = await render(); + const { container } = screen; + const menu = deferred(); + const batch = deferred(); + state.showContextMenu.mockReturnValueOnce(menu.promise); + state.unarchiveThreads.mockReturnValueOnce(batch.promise); + + await act(async () => openContextMenu(container)); + expect(state.showContextMenu).toHaveBeenCalledOnce(); + await runBulkAction(container, "Unarchive all in project"); + await act(async () => menu.resolve(choice)); + expect(state.unarchiveThread).not.toHaveBeenCalled(); + expect(state.confirmAndDeleteThread).not.toHaveBeenCalled(); + await act(async () => batch.resolve(null)); + }, +); + +it("locks while confirming bulk deletion and releases the lock on cancellation", async () => { + setup(); + await using screen = await render(); + const { container } = screen; + const confirmation = deferred(); + state.confirm.mockReturnValueOnce(confirmation.promise); + + await runBulkAction(container, "Delete all in project"); + await act(async () => openContextMenu(container)); + expect(state.confirm).toHaveBeenCalledOnce(); + expect(state.showContextMenu).not.toHaveBeenCalled(); + expect(state.deleteArchivedThreads).not.toHaveBeenCalled(); + + await act(async () => confirmation.resolve(false)); + await act(async () => openContextMenu(container)); + expect(state.unarchiveThread).toHaveBeenCalledOnce(); + expect(state.deleteArchivedThreads).not.toHaveBeenCalled(); +}); + +it("releases the lock and reports a failed bulk action", async () => { + setup(); + await using screen = await render(); + const { container } = screen; + state.unarchiveThreads.mockRejectedValueOnce(new Error("Disconnected")); + + await runBulkAction(container, "Unarchive all in project"); + expect(state.toast).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Failed to unarchive threads", + description: "Disconnected", + }), + ); + await act(async () => openContextMenu(container)); + expect(state.unarchiveThread).toHaveBeenCalledOnce(); +}); + +it.each([ + ["Unarchive Thread 1", "unarchiveThread"], + ["Delete Thread 1", "confirmAndDeleteThread"], +] as const)("keeps %s locked independently and recovers after failure", async (label, method) => { + setup(); + await using screen = await render(); + const { container } = screen; + const pending = deferred>(); + state[method].mockReturnValueOnce(pending.promise); + const button = container.querySelector(`button[aria-label="${label}"]`)!; + + await act(async () => button.click()); + await act(async () => { + button.click(); + button + .closest('[data-slot="settings-row"]')! + .dispatchEvent(new MouseEvent("contextmenu", { bubbles: true })); + container + .querySelector('button[aria-label="Archived thread actions for Project"]')! + .click(); + }); + expect(state[method]).toHaveBeenCalledOnce(); + expect(state.showContextMenu).not.toHaveBeenCalled(); + expect(document.querySelector('[role="menu"]')).toBeNull(); + + await act(async () => openContextMenu(container)); + expect(state.showContextMenu).toHaveBeenCalledOnce(); + const calls = state[method].mock.calls.length; + await act(async () => + pending.resolve(AsyncResult.failure(Cause.fail(new Error("Disconnected")))), + ); + expect(state.toast).toHaveBeenCalledWith( + expect.objectContaining({ description: "Disconnected" }), + ); + await act(async () => button.click()); + expect(state[method]).toHaveBeenCalledTimes(calls + 1); +}); diff --git a/apps/web/src/hooks/useThreadActions.permissions.test.ts b/apps/web/src/hooks/useThreadActions.permissions.test.ts index 814c25e04da15..1da5e88de0a92 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.test.ts b/apps/web/src/hooks/useThreadActions.test.ts index ff0a8a61b8f4c..5d8586554e231 100644 --- a/apps/web/src/hooks/useThreadActions.test.ts +++ b/apps/web/src/hooks/useThreadActions.test.ts @@ -1,7 +1,52 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/reactivity"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { navigateAfterThreadDeletion } from "./useThreadActions"; +import { navigateAfterThreadDeletion, runArchivedThreadBatch } from "./useThreadActions"; import { toastManager } from "../components/ui/toast"; +import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState"; + +vi.mock("../lib/archivedThreadsState", () => ({ + refreshArchivedThreadsForEnvironment: vi.fn(), +})); + +describe("runArchivedThreadBatch", () => { + afterEach(() => vi.clearAllMocks()); + + const local = EnvironmentId.make("environment-local"); + const remote = EnvironmentId.make("environment-remote"); + const targets = [ + scopeThreadRef(local, ThreadId.make("thread-1")), + scopeThreadRef(local, ThreadId.make("thread-2")), + scopeThreadRef(remote, ThreadId.make("thread-3")), + ]; + + it("keeps going after a failure and refreshes each environment once", async () => { + const firstFailure = AsyncResult.failure(Cause.fail(new Error("first"))); + const results = [ + firstFailure, + AsyncResult.failure(Cause.fail(new Error("second"))), + AsyncResult.success(undefined), + ]; + const run = vi.fn(async () => results.shift()!); + + await expect(runArchivedThreadBatch(targets, run)).resolves.toBe(firstFailure); + + expect(run).toHaveBeenCalledTimes(3); + expect(vi.mocked(refreshArchivedThreadsForEnvironment).mock.calls).toEqual([[local], [remote]]); + }); + + it("stops at an interruption without reporting it", async () => { + const run = vi.fn(async () => AsyncResult.failure(Cause.interrupt())); + + await expect(runArchivedThreadBatch(targets, run)).resolves.toBeNull(); + + expect(run).toHaveBeenCalledOnce(); + expect(vi.mocked(refreshArchivedThreadsForEnvironment).mock.calls).toEqual([[local]]); + }); +}); describe("navigateAfterThreadDeletion", () => { afterEach(() => vi.restoreAllMocks()); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 32020316496ed..005525f7780cd 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -4,7 +4,12 @@ import { scopeThreadRef, scopedThreadKey, } from "@t3tools/client-runtime/environment"; -import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { + type AtomCommandResult, + isAtomCommandInterrupted, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; import { threadRuntimeCanArchive } from "@t3tools/client-runtime/state/models"; import { @@ -196,6 +201,25 @@ export async function navigateAfterThreadDeletion(navigate: () => Promise) } } +export async function runArchivedThreadBatch( + targets: readonly ScopedThreadRef[], + run: (target: ScopedThreadRef) => Promise>, +) { + let firstFailure: AsyncResult.Failure | null = null; + const reachedEnvironmentIds = new Set(); + for (const target of targets) { + reachedEnvironmentIds.add(target.environmentId); + const result = await run(target); + if (result._tag === "Success") continue; + if (isAtomCommandInterrupted(result)) break; + if (firstFailure === null) firstFailure = result; + } + for (const environmentId of reachedEnvironmentIds) { + refreshArchivedThreadsForEnvironment(environmentId); + } + return firstFailure; +} + function useMarkThreadUnread() { const markThreadUnreadMutation = useAtomCommand(threadEnvironment.markUnread, { reportFailure: false, @@ -359,6 +383,27 @@ export function useThreadActions() { return result; }; + const unarchiveThreads = (targets: readonly ScopedThreadRef[]) => + runArchivedThreadBatch(targets, (target) => { + ThreadUndo.invalidate("archive", scopedThreadKey(target)); + return unarchiveThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, + }); + }); + + const deleteArchivedThreads = (targets: readonly ScopedThreadRef[]) => + runArchivedThreadBatch(targets, async (target) => { + const permissionFailure = threadOperationFailure(target); + if (permissionFailure) return permissionFailure; + const result = await deleteThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, + }); + if (result._tag === "Success") clearThreadPreviewState(target); + return result; + }); + const archiveThread = async (target: ScopedThreadRef, opts: { onArchived?: () => void } = {}) => { const resolved = resolveThreadTarget(target); if (!resolved) return AsyncResult.success(undefined); @@ -899,14 +944,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( [ @@ -930,7 +975,9 @@ export function useThreadActions() { return { archiveThread, unarchiveThread, + unarchiveThreads, deleteThread, + deleteArchivedThreads, confirmAndDeleteThread, settleThread, unsettleThread,