authorgravatar for a.github@omega-id.comChristoph Herzog <a.github@omega-id.com> 2026-07-20 10:51:22+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-20 08:51:22+00:00
logdf65a6c3ad605feb2f326c0ad78d056c10476770
tree4336777a772ee5561f7c256823c77f2027ec4676
parent3235658c080bc12fcd1ffaa275aced98d225f2f6
signature Signed by PGP key B5690EEEBB952194

Archive selected threads from the context menu (#3895)


4 files changed, 180 insertions(+), 14 deletions(-)

apps/web/src/components/Sidebar.logic.test.ts+69
......@@ -1,5 +1,7 @@
11import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
22import {
3 archiveSelectedThreadEntries,
4 buildMultiSelectThreadContextMenuItems,
35 createThreadJumpHintVisibilityController,
46 getSidebarThreadIdsToPrewarm,
57 getVisibleSidebarThreadIds,
......@@ -38,6 +40,73 @@ import {
3840
3941const localEnvironmentId = EnvironmentId.make("environment-local");
4042
43describe("archiveSelectedThreadEntries", () => {
44 const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const;
45 const success = { _tag: "Success" } as const;
46 const failure = { _tag: "Failure" } as const;
47
48 it("records every entry after full success", async () => {
49 const outcome = await archiveSelectedThreadEntries({
50 entries,
51 archive: async (_entry, onArchived) => {
52 onArchived();
53 return success;
54 },
55 });
56
57 expect(outcome).toEqual({
58 archivedThreadKeys: ["one", "two", "three"],
59 mutationFailure: null,
60 followupFailures: [],
61 });
62 });
63
64 it("stops at a mutation failure and retains prior successes", async () => {
65 const archive = vi.fn(async (entry: (typeof entries)[number], onArchived: () => void) => {
66 if (entry.threadKey === "two") return failure;
67 onArchived();
68 return success;
69 });
70 const outcome = await archiveSelectedThreadEntries({ entries, archive });
71
72 expect(archive).toHaveBeenCalledTimes(2);
73 expect(outcome).toEqual({
74 archivedThreadKeys: ["one"],
75 mutationFailure: failure,
76 followupFailures: [],
77 });
78 });
79
80 it("continues after a post-archive failure", async () => {
81 const archive = vi.fn(async (entry: (typeof entries)[number], onArchived: () => void) => {
82 onArchived();
83 return entry.threadKey === "two" ? failure : success;
84 });
85 const outcome = await archiveSelectedThreadEntries({ entries, archive });
86
87 expect(archive).toHaveBeenCalledTimes(3);
88 expect(outcome).toEqual({
89 archivedThreadKeys: ["one", "two", "three"],
90 mutationFailure: null,
91 followupFailures: [failure],
92 });
93 });
94});
95
96describe("buildMultiSelectThreadContextMenuItems", () => {
97 it("offers bulk archive with the selected count", () => {
98 expect(
99 buildMultiSelectThreadContextMenuItems({ count: 3, hasRunningThread: false }),
100 ).toContainEqual({ id: "archive", label: "Archive (3)", disabled: false });
101 });
102
103 it("disables bulk archive when a selected thread is running", () => {
104 expect(
105 buildMultiSelectThreadContextMenuItems({ count: 2, hasRunningThread: true }),
106 ).toContainEqual({ id: "archive", label: "Archive (2)", disabled: true });
107 });
108});
109
41110describe("resolveSidebarStageBadgeLabel", () => {
42111 it("returns Nightly for nightly primary server versions", () => {
43112 expect(
apps/web/src/components/Sidebar.logic.ts+50
......@@ -1,4 +1,5 @@
11import * as React from "react";
2import type { ContextMenuItem } from "@t3tools/contracts";
23import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
34import {
45 getThreadSortTimestamp,
......@@ -36,6 +37,55 @@ type ScopedSidebarThread = ThreadSortInput & {
3637
3738export type ThreadTraversalDirection = "previous" | "next";
3839
40export async function archiveSelectedThreadEntries<
41 TEntry extends { readonly threadKey: string },
42 TResult extends { readonly _tag: "Success" | "Failure" },
43>(input: {
44 entries: readonly TEntry[];
45 archive: (entry: TEntry, onArchived: () => void) => Promise<TResult>;
46}): Promise<{
47 archivedThreadKeys: readonly string[];
48 mutationFailure: Extract<TResult, { readonly _tag: "Failure" }> | null;
49 followupFailures: readonly Extract<TResult, { readonly _tag: "Failure" }>[];
50}> {
51 const archivedThreadKeys: string[] = [];
52 const followupFailures: Extract<TResult, { readonly _tag: "Failure" }>[] = [];
53
54 for (const entry of input.entries) {
55 let didArchive = false;
56 const result = await input.archive(entry, () => {
57 didArchive = true;
58 });
59 if (didArchive || result._tag === "Success") {
60 archivedThreadKeys.push(entry.threadKey);
61 }
62 if (result._tag === "Success") continue;
63 const failure = result as Extract<TResult, { readonly _tag: "Failure" }>;
64 if (didArchive) {
65 followupFailures.push(failure);
66 continue;
67 }
68 return { archivedThreadKeys, mutationFailure: failure, followupFailures };
69 }
70
71 return { archivedThreadKeys, mutationFailure: null, followupFailures };
72}
73
74export function buildMultiSelectThreadContextMenuItems(input: {
75 count: number;
76 hasRunningThread: boolean;
77}): readonly ContextMenuItem<"mark-unread" | "archive" | "delete">[] {
78 return [
79 { id: "mark-unread", label: `Mark unread (${input.count})` },
80 {
81 id: "archive",
82 label: `Archive (${input.count})`,
83 disabled: input.hasRunningThread,
84 },
85 { id: "delete", label: `Delete (${input.count})`, destructive: true },
86 ];
87}
88
3989export interface ThreadStatusPill {
4090 label:
4191 | "Working"
apps/web/src/components/Sidebar.tsx+58-11
......@@ -185,6 +185,8 @@ import {
185185import { useThreadSelectionStore } from "../threadSelectionStore";
186186import { useOpenAddProjectCommandPalette } from "../commandPaletteContext";
187187import {
188 archiveSelectedThreadEntries,
189 buildMultiSelectThreadContextMenuItems,
188190 getSidebarThreadIdsToPrewarm,
189191 resolveAdjacentThreadId,
190192 isContextMenuPointerDown,
......@@ -1775,24 +1777,69 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
17751777 const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys];
17761778 if (threadKeys.length === 0) return;
17771779 const count = threadKeys.length;
1780 const selectedThreadEntries = threadKeys.flatMap((threadKey) => {
1781 const threadRef = parseScopedThreadKey(threadKey);
1782 const thread = threadRef ? readThreadShell(threadRef) : null;
1783 return threadRef && thread ? [{ threadKey, threadRef, thread }] : [];
1784 });
1785 const hasRunningThread = selectedThreadEntries.some(
1786 ({ thread }) => thread.session?.status === "running" && thread.session.activeTurnId != null,
1787 );
17781788
17791789 const clicked = await api.contextMenu.show(
1780 [
1781 { id: "mark-unread", label: `Mark unread (${count})` },
1782 { id: "delete", label: `Delete (${count})`, destructive: true },
1783 ],
1790 buildMultiSelectThreadContextMenuItems({ count, hasRunningThread }),
17841791 position,
17851792 );
17861793
17871794 if (clicked === "mark-unread") {
1788 for (const threadKey of threadKeys) {
1789 const thread = sidebarThreadByKeyRef.current.get(threadKey);
1790 markThreadUnread(threadKey, thread?.latestTurn?.completedAt);
1795 for (const { threadKey, thread } of selectedThreadEntries) {
1796 markThreadUnread(threadKey, thread.latestTurn?.completedAt);
17911797 }
17921798 clearSelection();
17931799 return;
17941800 }
17951801
1802 if (clicked === "archive") {
1803 if (appSettingsConfirmThreadArchive) {
1804 const confirmed = await api.dialogs.confirm(
1805 `Archive ${count} thread${count === 1 ? "" : "s"}?`,
1806 );
1807 if (!confirmed) return;
1808 }
1809
1810 const archiveOutcome = await archiveSelectedThreadEntries({
1811 entries: selectedThreadEntries,
1812 archive: ({ threadRef }, onArchived) => archiveThread(threadRef, { onArchived }),
1813 });
1814 for (const failure of archiveOutcome.followupFailures) {
1815 if (isAtomCommandInterrupted(failure)) continue;
1816 const error = squashAtomCommandFailure(failure);
1817 toastManager.add(
1818 stackedThreadToast({
1819 type: "error",
1820 title: "Thread archived, but navigation failed",
1821 description: error instanceof Error ? error.message : "An error occurred.",
1822 }),
1823 );
1824 }
1825 if (archiveOutcome.mutationFailure) {
1826 removeFromSelection(archiveOutcome.archivedThreadKeys);
1827 if (!isAtomCommandInterrupted(archiveOutcome.mutationFailure)) {
1828 const error = squashAtomCommandFailure(archiveOutcome.mutationFailure);
1829 toastManager.add(
1830 stackedThreadToast({
1831 type: "error",
1832 title: "Failed to archive threads",
1833 description: error instanceof Error ? error.message : "An error occurred.",
1834 }),
1835 );
1836 }
1837 return;
1838 }
1839 removeFromSelection(threadKeys);
1840 return;
1841 }
1842
17961843 if (clicked !== "delete") return;
17971844
17981845 if (appSettingsConfirmThreadDelete) {
......@@ -1806,10 +1853,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
18061853 }
18071854
18081855 const deletedThreadKeys = new Set(threadKeys);
1809 for (const threadKey of threadKeys) {
1810 const thread = sidebarThreadByKeyRef.current.get(threadKey);
1811 if (!thread) continue;
1812 const result = await deleteThread(scopeThreadRef(thread.environmentId, thread.id), {
1856 for (const { threadRef } of selectedThreadEntries) {
1857 const result = await deleteThread(threadRef, {
18131858 deletedThreadKeys,
18141859 });
18151860 if (result._tag === "Failure") {
......@@ -1829,7 +1874,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
18291874 removeFromSelection(threadKeys);
18301875 },
18311876 [
1877 appSettingsConfirmThreadArchive,
18321878 appSettingsConfirmThreadDelete,
1879 archiveThread,
18331880 clearSelection,
18341881 deleteThread,
18351882 markThreadUnread,
apps/web/src/hooks/useThreadActions.ts+3-3
......@@ -89,7 +89,7 @@ export function useThreadActions() {
8989 }, [router]);
9090
9191 const archiveThread = useCallback(
92 async (target: ScopedThreadRef) => {
92 async (target: ScopedThreadRef, opts: { onArchived?: () => void } = {}) => {
9393 const resolved = resolveThreadTarget(target);
9494 if (!resolved) return AsyncResult.success(undefined);
9595 const { thread, threadRef } = resolved;
......@@ -115,6 +115,8 @@ export function useThreadActions() {
115115 if (archiveResult._tag === "Failure") {
116116 return archiveResult;
117117 }
118 refreshArchivedThreadsForEnvironment(threadRef.environmentId);
119 opts.onArchived?.();
118120
119121 if (shouldNavigateToDraft) {
120122 const navigationResult = await settlePromise(() =>
......@@ -123,11 +125,9 @@ export function useThreadActions() {
123125 if (navigationResult._tag === "Failure") {
124126 return navigationResult;
125127 }
126 refreshArchivedThreadsForEnvironment(threadRef.environmentId);
127128 return archiveResult;
128129 }
129130
130 refreshArchivedThreadsForEnvironment(threadRef.environmentId);
131131 return archiveResult;
132132 },
133133 [archiveThreadMutation, getCurrentRouteThreadRef, resolveThreadTarget],