diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 574e33d4dab..17ffe074cb8 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { + archiveSelectedThreadEntries, + buildMultiSelectThreadContextMenuItems, createThreadJumpHintVisibilityController, getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, @@ -37,6 +39,73 @@ import { const localEnvironmentId = EnvironmentId.make("environment-local"); +describe("archiveSelectedThreadEntries", () => { + const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const; + const success = { _tag: "Success" } as const; + const failure = { _tag: "Failure" } as const; + + it("records every entry after full success", async () => { + const outcome = await archiveSelectedThreadEntries({ + entries, + archive: async (_entry, onArchived) => { + onArchived(); + return success; + }, + }); + + expect(outcome).toEqual({ + archivedThreadKeys: ["one", "two", "three"], + mutationFailure: null, + followupFailures: [], + }); + }); + + it("stops at a mutation failure and retains prior successes", async () => { + const archive = vi.fn(async (entry: (typeof entries)[number], onArchived: () => void) => { + if (entry.threadKey === "two") return failure; + onArchived(); + return success; + }); + const outcome = await archiveSelectedThreadEntries({ entries, archive }); + + expect(archive).toHaveBeenCalledTimes(2); + expect(outcome).toEqual({ + archivedThreadKeys: ["one"], + mutationFailure: failure, + followupFailures: [], + }); + }); + + it("continues after a post-archive failure", async () => { + const archive = vi.fn(async (entry: (typeof entries)[number], onArchived: () => void) => { + onArchived(); + return entry.threadKey === "two" ? failure : success; + }); + const outcome = await archiveSelectedThreadEntries({ entries, archive }); + + expect(archive).toHaveBeenCalledTimes(3); + expect(outcome).toEqual({ + archivedThreadKeys: ["one", "two", "three"], + mutationFailure: null, + followupFailures: [failure], + }); + }); +}); + +describe("buildMultiSelectThreadContextMenuItems", () => { + it("offers bulk archive with the selected count", () => { + expect( + buildMultiSelectThreadContextMenuItems({ count: 3, hasRunningThread: false }), + ).toContainEqual({ id: "archive", label: "Archive (3)", disabled: false }); + }); + + it("disables bulk archive when a selected thread is running", () => { + expect( + buildMultiSelectThreadContextMenuItems({ count: 2, hasRunningThread: true }), + ).toContainEqual({ id: "archive", label: "Archive (2)", disabled: true }); + }); +}); + describe("resolveSidebarStageBadgeLabel", () => { it("returns Nightly for nightly primary server versions", () => { expect( diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 4e7614ed551..7037288893c 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,4 +1,5 @@ import * as React from "react"; +import type { ContextMenuItem } from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import { getThreadSortTimestamp, @@ -26,6 +27,55 @@ type SidebarProject = { export type ThreadTraversalDirection = "previous" | "next"; +export async function archiveSelectedThreadEntries< + TEntry extends { readonly threadKey: string }, + TResult extends { readonly _tag: "Success" | "Failure" }, +>(input: { + entries: readonly TEntry[]; + archive: (entry: TEntry, onArchived: () => void) => Promise; +}): Promise<{ + archivedThreadKeys: readonly string[]; + mutationFailure: Extract | null; + followupFailures: readonly Extract[]; +}> { + const archivedThreadKeys: string[] = []; + const followupFailures: Extract[] = []; + + for (const entry of input.entries) { + let didArchive = false; + const result = await input.archive(entry, () => { + didArchive = true; + }); + if (didArchive || result._tag === "Success") { + archivedThreadKeys.push(entry.threadKey); + } + if (result._tag === "Success") continue; + const failure = result as Extract; + if (didArchive) { + followupFailures.push(failure); + continue; + } + return { archivedThreadKeys, mutationFailure: failure, followupFailures }; + } + + return { archivedThreadKeys, mutationFailure: null, followupFailures }; +} + +export function buildMultiSelectThreadContextMenuItems(input: { + count: number; + hasRunningThread: boolean; +}): readonly ContextMenuItem<"mark-unread" | "archive" | "delete">[] { + return [ + { id: "mark-unread", label: `Mark unread (${input.count})` }, + { + id: "archive", + label: `Archive (${input.count})`, + disabled: input.hasRunningThread, + }, + { id: "delete", label: `Delete (${input.count})`, destructive: true }, + ]; +} + export interface ThreadStatusPill { label: | "Working" diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 21525b56b77..65bffa92ad4 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -184,6 +184,8 @@ import { import { useThreadSelectionStore } from "../threadSelectionStore"; import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; import { + archiveSelectedThreadEntries, + buildMultiSelectThreadContextMenuItems, getSidebarThreadIdsToPrewarm, resolveAdjacentThreadId, isContextMenuPointerDown, @@ -1772,24 +1774,69 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys]; if (threadKeys.length === 0) return; const count = threadKeys.length; + const selectedThreadEntries = threadKeys.flatMap((threadKey) => { + const threadRef = parseScopedThreadKey(threadKey); + const thread = threadRef ? readThreadShell(threadRef) : null; + return threadRef && thread ? [{ threadKey, threadRef, thread }] : []; + }); + const hasRunningThread = selectedThreadEntries.some( + ({ thread }) => thread.session?.status === "running" && thread.session.activeTurnId != null, + ); const clicked = await api.contextMenu.show( - [ - { id: "mark-unread", label: `Mark unread (${count})` }, - { id: "delete", label: `Delete (${count})`, destructive: true }, - ], + buildMultiSelectThreadContextMenuItems({ count, hasRunningThread }), position, ); if (clicked === "mark-unread") { - for (const threadKey of threadKeys) { - const thread = sidebarThreadByKeyRef.current.get(threadKey); - markThreadUnread(threadKey, thread?.latestTurn?.completedAt); + for (const { threadKey, thread } of selectedThreadEntries) { + markThreadUnread(threadKey, thread.latestTurn?.completedAt); } clearSelection(); return; } + if (clicked === "archive") { + if (appSettingsConfirmThreadArchive) { + const confirmed = await api.dialogs.confirm( + `Archive ${count} thread${count === 1 ? "" : "s"}?`, + ); + if (!confirmed) return; + } + + const archiveOutcome = await archiveSelectedThreadEntries({ + entries: selectedThreadEntries, + archive: ({ threadRef }, onArchived) => archiveThread(threadRef, { onArchived }), + }); + for (const failure of archiveOutcome.followupFailures) { + if (isAtomCommandInterrupted(failure)) continue; + const error = squashAtomCommandFailure(failure); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Thread archived, but navigation failed", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + if (archiveOutcome.mutationFailure) { + removeFromSelection(archiveOutcome.archivedThreadKeys); + if (!isAtomCommandInterrupted(archiveOutcome.mutationFailure)) { + const error = squashAtomCommandFailure(archiveOutcome.mutationFailure); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to archive threads", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } + removeFromSelection(threadKeys); + return; + } + if (clicked !== "delete") return; if (appSettingsConfirmThreadDelete) { @@ -1803,10 +1850,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } const deletedThreadKeys = new Set(threadKeys); - for (const threadKey of threadKeys) { - const thread = sidebarThreadByKeyRef.current.get(threadKey); - if (!thread) continue; - const result = await deleteThread(scopeThreadRef(thread.environmentId, thread.id), { + for (const { threadRef } of selectedThreadEntries) { + const result = await deleteThread(threadRef, { deletedThreadKeys, }); if (result._tag === "Failure") { @@ -1826,7 +1871,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec removeFromSelection(threadKeys); }, [ + appSettingsConfirmThreadArchive, appSettingsConfirmThreadDelete, + archiveThread, clearSelection, deleteThread, markThreadUnread, diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 07655ad30d7..dbde5acce99 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -89,7 +89,7 @@ export function useThreadActions() { }, [router]); const archiveThread = useCallback( - async (target: ScopedThreadRef) => { + async (target: ScopedThreadRef, opts: { onArchived?: () => void } = {}) => { const resolved = resolveThreadTarget(target); if (!resolved) return AsyncResult.success(undefined); const { thread, threadRef } = resolved; @@ -115,6 +115,8 @@ export function useThreadActions() { if (archiveResult._tag === "Failure") { return archiveResult; } + refreshArchivedThreadsForEnvironment(threadRef.environmentId); + opts.onArchived?.(); if (shouldNavigateToDraft) { const navigationResult = await settlePromise(() => @@ -123,11 +125,9 @@ export function useThreadActions() { if (navigationResult._tag === "Failure") { return navigationResult; } - refreshArchivedThreadsForEnvironment(threadRef.environmentId); return archiveResult; } - refreshArchivedThreadsForEnvironment(threadRef.environmentId); return archiveResult; }, [archiveThreadMutation, getCurrentRouteThreadRef, resolveThreadTarget],