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";
179179import {
180180 archiveSelectedThreadEntries,
181181 buildMultiSelectThreadContextMenuItems,
182 deleteSelectedThreadEntries,
182183 getSidebarThreadIdsToPrewarm,
183184 resolveAdjacentThreadId,
184185 isContextMenuPointerDown,
......@@ -1926,21 +1927,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
19261927 if (!confirmed) return;
19271928 }
19281929
1929 // Only discount batch members after their deletions succeed.
1930 const deletedThreadKeys = new Set<string>();
1931 let firstError: unknown = null;
1932 for (const { threadKey, threadRef } of selectedThreadEntries) {
1933 const result = await deleteThread(threadRef, {
1934 deletedThreadKeys,
1935 });
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) {
1930 const { deletedThreadKeys, firstFailure } = await deleteSelectedThreadEntries({
1931 entries: selectedThreadEntries,
1932 delete: ({ threadRef }, deletedThreadKeys) =>
1933 deleteThread(threadRef, { deletedThreadKeys }),
1934 });
1935 if (firstFailure !== null) {
1936 const firstError = squashAtomCommandFailure(firstFailure);
19441937 toastManager.add(
19451938 stackedThreadToast({
19461939 type: "error",
apps/web/src/components/Sidebar.logic.test.ts+99
......@@ -1,5 +1,7 @@
11import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
22import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable";
3import * as Cause from "effect/Cause";
4import { AsyncResult } from "effect/unstable/reactivity";
35import {
46 animatePinnedLayoutChanges,
57 archiveSelectedThreadEntries,
......@@ -7,6 +9,7 @@ import {
79 buildBulkUnpinContextMenuItem,
810 buildMultiSelectThreadContextMenuItems,
911 createThreadJumpHintVisibilityController,
12 deleteSelectedThreadEntries,
1013 filterSidebarProjectScopeItems,
1114 getSidebarThreadIdsToPrewarm,
1215 resolveAdjacentThreadId,
......@@ -81,6 +84,102 @@ describe("animatePinnedLayoutChanges", () => {
8184 });
8285});
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
84183describe("archiveSelectedThreadEntries", () => {
85184 const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const;
86185 const success = { _tag: "Success" } as const;
apps/web/src/components/Sidebar.logic.ts+35
......@@ -1,7 +1,12 @@
11import * as React from "react";
22import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable";
3import {
4 isAtomCommandInterrupted,
5 type AtomCommandResult,
6} from "@t3tools/client-runtime/state/runtime";
37import type { ContextMenuItem } from "@t3tools/contracts";
48import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
9import type { AsyncResult } from "effect/unstable/reactivity";
510import {
611 activeThreadAnchorTimestampMs,
712 getThreadSortTimestamp,
......@@ -109,6 +114,36 @@ type LogicalSidebarProject = SidebarProject & {
109114
110115export 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
112147export async function archiveSelectedThreadEntries<
113148 TEntry extends { readonly threadKey: string },
114149 TResult extends { readonly _tag: "Success" | "Failure" },
apps/web/src/components/Sidebar.tsx+13-20
......@@ -140,6 +140,7 @@ import {
140140 animatePinnedLayoutChanges,
141141 buildBulkTitleRegenerationContextMenuItem,
142142 buildBulkUnpinContextMenuItem,
143 deleteSelectedThreadEntries,
143144 filterSidebarProjectScopeItems,
144145 formatWorkingDurationLabel,
145146 firstValidTimestampMs,
......@@ -3219,26 +3220,18 @@ export default function Sidebar() {
32193220 );
32203221 if (confirmed._tag === "Failure" || !confirmed.value) return;
32213222 }
3222 // Grown as deletions actually land, never seeded with the whole batch:
3223 // orphaned-worktree detection must only discount threads that are
3224 // really gone, or the first delete would treat still-alive batch mates
3225 // as deleted and remove a worktree they still point at.
3226 const deletedThreadKeys = new Set<string>();
3227 let firstError: unknown = null;
3228 for (const threadKey of threadKeys) {
3229 const thread = threadByKeyRef.current.get(threadKey);
3230 if (!thread) continue;
3231 const result = await deleteThread(scopeThreadRef(thread.environmentId, thread.id), {
3232 deletedThreadKeys,
3233 });
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) {
3223 const { deletedThreadKeys, firstFailure } = await deleteSelectedThreadEntries({
3224 entries: threadKeys.map((threadKey) => ({ threadKey })),
3225 delete: async ({ threadKey }, deletedThreadKeys) => {
3226 const thread = threadByKeyRef.current.get(threadKey);
3227 if (!thread) return null;
3228 return deleteThread(scopeThreadRef(thread.environmentId, thread.id), {
3229 deletedThreadKeys,
3230 });
3231 },
3232 });
3233 if (firstFailure !== null) {
3234 const firstError = squashAtomCommandFailure(firstFailure);
32423235 toastManager.add(
32433236 stackedThreadToast({
32443237 type: "error",