authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-06 02:15:13-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 02:15:13-07:00
logdd64072917193195479f6907cfb081267bff7301
tree68d8875412cf9f96f4c327240b03cfadc20e5c90
parent7544d3d2c8e0145018d9adb7a1a650333b75362a
signature Signed by PGP key B5690EEEBB952194

refactor(web): share bulk thread deletion between sidebars (#10106)


4 files changed, 155 insertions(+), 35 deletions(-)

apps/web/src/components/LegacySidebar.tsx+8-15
...@@ -179,6 +179,7 @@ import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus";...@@ -179,6 +179,7 @@ import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus";
179import {179import {
180 archiveSelectedThreadEntries,180 archiveSelectedThreadEntries,
181 buildMultiSelectThreadContextMenuItems,181 buildMultiSelectThreadContextMenuItems,
182 deleteSelectedThreadEntries,
182 getSidebarThreadIdsToPrewarm,183 getSidebarThreadIdsToPrewarm,
183 resolveAdjacentThreadId,184 resolveAdjacentThreadId,
184 isContextMenuPointerDown,185 isContextMenuPointerDown,
...@@ -1926,21 +1927,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1926,21 +1927,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1926 if (!confirmed) return;1927 if (!confirmed) return;
1927 }1928 }
19281929
1929 // Only discount batch members after their deletions succeed.1930 const { deletedThreadKeys, firstFailure } = await deleteSelectedThreadEntries({
1930 const deletedThreadKeys = new Set<string>();1931 entries: selectedThreadEntries,
1931 let firstError: unknown = null;1932 delete: ({ threadRef }, deletedThreadKeys) =>
1932 for (const { threadKey, threadRef } of selectedThreadEntries) {1933 deleteThread(threadRef, { deletedThreadKeys }),
1933 const result = await deleteThread(threadRef, {1934 });
1934 deletedThreadKeys,1935 if (firstFailure !== null) {
1935 });1936 const firstError = squashAtomCommandFailure(firstFailure);
1936 if (result._tag === "Failure") {
1937 if (isAtomCommandInterrupted(result)) break;
1938 firstError ??= squashAtomCommandFailure(result);
1939 continue;
1940 }
1941 deletedThreadKeys.add(threadKey);
1942 }
1943 if (firstError !== null) {
1944 toastManager.add(1937 toastManager.add(
1945 stackedThreadToast({1938 stackedThreadToast({
1946 type: "error",1939 type: "error",
apps/web/src/components/Sidebar.logic.test.ts+99
...@@ -1,5 +1,7 @@...@@ -1,5 +1,7 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable";2import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable";
3import * as Cause from "effect/Cause";
4import { AsyncResult } from "effect/unstable/reactivity";
3import {5import {
4 animatePinnedLayoutChanges,6 animatePinnedLayoutChanges,
5 archiveSelectedThreadEntries,7 archiveSelectedThreadEntries,
...@@ -7,6 +9,7 @@ import {...@@ -7,6 +9,7 @@ import {
7 buildBulkUnpinContextMenuItem,9 buildBulkUnpinContextMenuItem,
8 buildMultiSelectThreadContextMenuItems,10 buildMultiSelectThreadContextMenuItems,
9 createThreadJumpHintVisibilityController,11 createThreadJumpHintVisibilityController,
12 deleteSelectedThreadEntries,
10 filterSidebarProjectScopeItems,13 filterSidebarProjectScopeItems,
11 getSidebarThreadIdsToPrewarm,14 getSidebarThreadIdsToPrewarm,
12 resolveAdjacentThreadId,15 resolveAdjacentThreadId,
...@@ -81,6 +84,102 @@ describe("animatePinnedLayoutChanges", () => {...@@ -81,6 +84,102 @@ describe("animatePinnedLayoutChanges", () => {
81 });84 });
82});85});
8386
87describe("deleteSelectedThreadEntries", () => {
88 const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const;
89 const success = AsyncResult.success(undefined);
90 const failure = AsyncResult.failure(Cause.fail(new Error("Delete failed")));
91 const interrupted = AsyncResult.failure(Cause.interrupt());
92
93 it("waits for each delete and excludes only earlier successes from worktree checks", async () => {
94 let resolveDelete!: (result: typeof success) => void;
95 const pendingDelete = new Promise<typeof success>((resolve) => {
96 resolveDelete = resolve;
97 });
98 const worktreeChecks: { threadKey: string; deletedThreadKeys: string[] }[] = [];
99 const deletion = deleteSelectedThreadEntries({
100 entries,
101 delete: async ({ threadKey }, deletedThreadKeys) => {
102 worktreeChecks.push({ threadKey, deletedThreadKeys: [...deletedThreadKeys] });
103 return threadKey === "one" ? pendingDelete : success;
104 },
105 });
106
107 expect(worktreeChecks).toEqual([{ threadKey: "one", deletedThreadKeys: [] }]);
108 resolveDelete(success);
109 const outcome = await deletion;
110
111 expect(worktreeChecks).toEqual([
112 { threadKey: "one", deletedThreadKeys: [] },
113 { threadKey: "two", deletedThreadKeys: ["one"] },
114 { threadKey: "three", deletedThreadKeys: ["one", "two"] },
115 ]);
116 expect(outcome).toEqual({
117 deletedThreadKeys: new Set(["one", "two", "three"]),
118 firstFailure: null,
119 });
120 });
121
122 it("continues after ordinary failures and keeps the first failure", async () => {
123 const laterFailure = AsyncResult.failure(Cause.fail(new Error("Later failure")));
124 const deletedKeysAtLastEntry: string[][] = [];
125 const outcome = await deleteSelectedThreadEntries({
126 entries: [...entries, { threadKey: "four" }],
127 delete: async ({ threadKey }, deletedThreadKeys) => {
128 if (threadKey === "one") return failure;
129 if (threadKey === "three") return laterFailure;
130 if (threadKey === "four") deletedKeysAtLastEntry.push([...deletedThreadKeys]);
131 return success;
132 },
133 });
134
135 expect(deletedKeysAtLastEntry).toEqual([["two"]]);
136 expect(outcome).toEqual({
137 deletedThreadKeys: new Set(["two", "four"]),
138 firstFailure: failure,
139 });
140 });
141
142 it.each([
143 { firstResult: success, deletedThreadKeys: new Set(["one"]), firstFailure: null },
144 { firstResult: failure, deletedThreadKeys: new Set<string>(), firstFailure: failure },
145 ])("stops on interruption and preserves earlier results %#", async (testCase) => {
146 const attemptedThreadKeys: string[] = [];
147 const outcome = await deleteSelectedThreadEntries({
148 entries,
149 delete: async ({ threadKey }) => {
150 attemptedThreadKeys.push(threadKey);
151 return threadKey === "one" ? testCase.firstResult : interrupted;
152 },
153 });
154
155 expect(attemptedThreadKeys).toEqual(["one", "two"]);
156 expect(outcome).toEqual({
157 deletedThreadKeys: testCase.deletedThreadKeys,
158 firstFailure: testCase.firstFailure,
159 });
160 });
161
162 it("does not count a skipped entry as deleted", async () => {
163 const visibleEntries = new Set(entries.map(({ threadKey }) => threadKey));
164 const worktreeChecks: string[][] = [];
165 const outcome = await deleteSelectedThreadEntries({
166 entries,
167 delete: async ({ threadKey }, deletedThreadKeys) => {
168 if (!visibleEntries.has(threadKey)) return null;
169 worktreeChecks.push([...deletedThreadKeys]);
170 visibleEntries.delete("two");
171 return success;
172 },
173 });
174
175 expect(worktreeChecks).toEqual([[], ["one"]]);
176 expect(outcome).toEqual({
177 deletedThreadKeys: new Set(["one", "three"]),
178 firstFailure: null,
179 });
180 });
181});
182
84describe("archiveSelectedThreadEntries", () => {183describe("archiveSelectedThreadEntries", () => {
85 const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const;184 const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const;
86 const success = { _tag: "Success" } as const;185 const success = { _tag: "Success" } as const;
apps/web/src/components/Sidebar.logic.ts+35
...@@ -1,7 +1,12 @@...@@ -1,7 +1,12 @@
1import * as React from "react";1import * as React from "react";
2import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable";2import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable";
3import {
4 isAtomCommandInterrupted,
5 type AtomCommandResult,
6} from "@t3tools/client-runtime/state/runtime";
3import type { ContextMenuItem } from "@t3tools/contracts";7import type { ContextMenuItem } from "@t3tools/contracts";
4import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";8import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
9import type { AsyncResult } from "effect/unstable/reactivity";
5import {10import {
6 activeThreadAnchorTimestampMs,11 activeThreadAnchorTimestampMs,
7 getThreadSortTimestamp,12 getThreadSortTimestamp,
...@@ -109,6 +114,36 @@ type LogicalSidebarProject = SidebarProject & {...@@ -109,6 +114,36 @@ type LogicalSidebarProject = SidebarProject & {
109114
110export type ThreadTraversalDirection = "previous" | "next";115export type ThreadTraversalDirection = "previous" | "next";
111116
117/**
118 * Shared-worktree checks must exclude only successful deletions, never the
119 * whole batch. A null result skips an entry that the caller can no longer find.
120 */
121export async function deleteSelectedThreadEntries<
122 TEntry extends { readonly threadKey: string },
123>(input: {
124 entries: readonly TEntry[];
125 delete: (
126 entry: TEntry,
127 deletedThreadKeys: ReadonlySet<string>,
128 ) => Promise<AtomCommandResult<unknown, unknown> | null>;
129}) {
130 const deletedThreadKeys = new Set<string>();
131 let firstFailure: AsyncResult.Failure<unknown, unknown> | null = null;
132
133 for (const entry of input.entries) {
134 const result = await input.delete(entry, deletedThreadKeys);
135 if (result === null) continue;
136 if (result._tag === "Failure") {
137 if (isAtomCommandInterrupted(result)) break;
138 firstFailure ??= result;
139 continue;
140 }
141 deletedThreadKeys.add(entry.threadKey);
142 }
143
144 return { deletedThreadKeys, firstFailure };
145}
146
112export async function archiveSelectedThreadEntries<147export async function archiveSelectedThreadEntries<
113 TEntry extends { readonly threadKey: string },148 TEntry extends { readonly threadKey: string },
114 TResult extends { readonly _tag: "Success" | "Failure" },149 TResult extends { readonly _tag: "Success" | "Failure" },
apps/web/src/components/Sidebar.tsx+13-20
...@@ -140,6 +140,7 @@ import {...@@ -140,6 +140,7 @@ import {
140 animatePinnedLayoutChanges,140 animatePinnedLayoutChanges,
141 buildBulkTitleRegenerationContextMenuItem,141 buildBulkTitleRegenerationContextMenuItem,
142 buildBulkUnpinContextMenuItem,142 buildBulkUnpinContextMenuItem,
143 deleteSelectedThreadEntries,
143 filterSidebarProjectScopeItems,144 filterSidebarProjectScopeItems,
144 formatWorkingDurationLabel,145 formatWorkingDurationLabel,
145 firstValidTimestampMs,146 firstValidTimestampMs,
...@@ -3219,26 +3220,18 @@ export default function Sidebar() {...@@ -3219,26 +3220,18 @@ export default function Sidebar() {
3219 );3220 );
3220 if (confirmed._tag === "Failure" || !confirmed.value) return;3221 if (confirmed._tag === "Failure" || !confirmed.value) return;
3221 }3222 }
3222 // Grown as deletions actually land, never seeded with the whole batch:3223 const { deletedThreadKeys, firstFailure } = await deleteSelectedThreadEntries({
3223 // orphaned-worktree detection must only discount threads that are3224 entries: threadKeys.map((threadKey) => ({ threadKey })),
3224 // really gone, or the first delete would treat still-alive batch mates3225 delete: async ({ threadKey }, deletedThreadKeys) => {
3225 // as deleted and remove a worktree they still point at.3226 const thread = threadByKeyRef.current.get(threadKey);
3226 const deletedThreadKeys = new Set<string>();3227 if (!thread) return null;
3227 let firstError: unknown = null;3228 return deleteThread(scopeThreadRef(thread.environmentId, thread.id), {
3228 for (const threadKey of threadKeys) {3229 deletedThreadKeys,
3229 const thread = threadByKeyRef.current.get(threadKey);3230 });
3230 if (!thread) continue;3231 },
3231 const result = await deleteThread(scopeThreadRef(thread.environmentId, thread.id), {3232 });
3232 deletedThreadKeys,3233 if (firstFailure !== null) {
3233 });3234 const firstError = squashAtomCommandFailure(firstFailure);
3234 if (result._tag === "Failure") {
3235 if (isAtomCommandInterrupted(result)) break;
3236 firstError ??= squashAtomCommandFailure(result);
3237 continue;
3238 }
3239 deletedThreadKeys.add(threadKey);
3240 }
3241 if (firstError !== null) {
3242 toastManager.add(3235 toastManager.add(
3243 stackedThreadToast({3236 stackedThreadToast({
3244 type: "error",3237 type: "error",