authorgravatar for mark_degraaff@outlook.comm-de-graaff <mark_degraaff@outlook.com> 2026-09-05 13:20:48+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-05 06:20:48-05:00
logbd7f7ea0931127c26cfd283c9c2e98e0a425a06b
treebdbaa162cecdbb36af3f1f76277abbe2ae6aeec9
parent39802c06117fae0b3da43624b0d54309c5437c72
signature Signed by PGP key B5690EEEBB952194

fix(web): keep bulk thread deletion going after failures (#4615)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Yash Singh <saiansh2525@gmail.com>

6 files changed, 191 insertions(+), 66 deletions(-)

apps/web/src/components/LegacySidebar.tsx+27-15
...@@ -171,7 +171,10 @@ import {...@@ -171,7 +171,10 @@ import {
171 SidebarMenuSubItem,171 SidebarMenuSubItem,
172 useSidebar,172 useSidebar,
173} from "./ui/sidebar";173} from "./ui/sidebar";
174import { useThreadSelectionStore } from "../threadSelectionStore";174import {
175 getThreadKeysToDeselectAfterDelete,
176 useThreadSelectionStore,
177} from "../threadSelectionStore";
175import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus";178import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus";
176import {179import {
177 archiveSelectedThreadEntries,180 archiveSelectedThreadEntries,
...@@ -1910,26 +1913,35 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1910,26 +1913,35 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1910 if (!confirmed) return;1913 if (!confirmed) return;
1911 }1914 }
19121915
1913 const deletedThreadKeys = new Set(threadKeys);1916 // Only discount batch members after their deletions succeed.
1914 for (const { threadRef } of selectedThreadEntries) {1917 const deletedThreadKeys = new Set<string>();
1918 let firstError: unknown = null;
1919 for (const { threadKey, threadRef } of selectedThreadEntries) {
1915 const result = await deleteThread(threadRef, {1920 const result = await deleteThread(threadRef, {
1916 deletedThreadKeys,1921 deletedThreadKeys,
1917 });1922 });
1918 if (result._tag === "Failure") {1923 if (result._tag === "Failure") {
1919 if (!isAtomCommandInterrupted(result)) {1924 if (isAtomCommandInterrupted(result)) break;
1920 const error = squashAtomCommandFailure(result);1925 firstError ??= squashAtomCommandFailure(result);
1921 toastManager.add(1926 continue;
1922 stackedThreadToast({
1923 type: "error",
1924 title: "Failed to delete threads",
1925 description: error instanceof Error ? error.message : "An error occurred.",
1926 }),
1927 );
1928 }
1929 return;
1930 }1927 }
1928 deletedThreadKeys.add(threadKey);
1931 }1929 }
1932 removeFromSelection(threadKeys);1930 if (firstError !== null) {
1931 toastManager.add(
1932 stackedThreadToast({
1933 type: "error",
1934 title: "Failed to delete threads",
1935 description: firstError instanceof Error ? firstError.message : "An error occurred.",
1936 }),
1937 );
1938 }
1939 removeFromSelection(
1940 getThreadKeysToDeselectAfterDelete(threadKeys, deletedThreadKeys, (threadKey) => {
1941 const threadRef = parseScopedThreadKey(threadKey);
1942 return threadRef !== null && readThreadShell(threadRef) !== null;
1943 }),
1944 );
1933 },1945 },
1934 [1946 [
1935 appSettingsConfirmThreadArchive,1947 appSettingsConfirmThreadArchive,
apps/web/src/components/Sidebar.tsx+28-15
...@@ -25,6 +25,7 @@ import {...@@ -25,6 +25,7 @@ import {
25import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort";25import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort";
26import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";26import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
27import {27import {
28 parseScopedThreadKey,
28 scopeProjectRef,29 scopeProjectRef,
29 scopeThreadRef,30 scopeThreadRef,
30 scopedThreadKey,31 scopedThreadKey,
...@@ -101,7 +102,10 @@ import {...@@ -101,7 +102,10 @@ import {
101 type SidebarProjectSnapshot,102 type SidebarProjectSnapshot,
102} from "../sidebarProjectGrouping";103} from "../sidebarProjectGrouping";
103import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";104import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
104import { useThreadSelectionStore } from "../threadSelectionStore";105import {
106 getThreadKeysToDeselectAfterDelete,
107 useThreadSelectionStore,
108} from "../threadSelectionStore";
105import { useThreadActions } from "../hooks/useThreadActions";109import { useThreadActions } from "../hooks/useThreadActions";
106import { useHandleNewThread } from "../hooks/useHandleNewThread";110import { useHandleNewThread } from "../hooks/useHandleNewThread";
107import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus";111import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus";
...@@ -112,6 +116,7 @@ import { useLocalStorage } from "../hooks/useLocalStorage";...@@ -112,6 +116,7 @@ import { useLocalStorage } from "../hooks/useLocalStorage";
112import { useNowMinute } from "../hooks/useNowMinute";116import { useNowMinute } from "../hooks/useNowMinute";
113import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";117import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
114import {118import {
119 readThreadShell,
115 useAllEnvironmentProjectSnapshotsReady,120 useAllEnvironmentProjectSnapshotsReady,
116 useProjects,121 useProjects,
117 useThreadShells,122 useThreadShells,
...@@ -3021,8 +3026,9 @@ export default function Sidebar() {...@@ -3021,8 +3026,9 @@ export default function Sidebar() {
3021 // right now. Selections can outlive their rows (settled-tail paging,3026 // right now. Selections can outlive their rows (settled-tail paging,
3022 // thread deletion elsewhere) and the menu labels must count only what3027 // thread deletion elsewhere) and the menu labels must count only what
3023 // the actions will touch.3028 // the actions will touch.
3024 const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys].filter(3029 const selectedThreadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys];
3025 (threadKey) => threadByKeyRef.current.has(threadKey),3030 const threadKeys = selectedThreadKeys.filter((threadKey) =>
3031 threadByKeyRef.current.has(threadKey),
3026 );3032 );
3027 if (threadKeys.length === 0) return;3033 if (threadKeys.length === 0) return;
3028 const count = threadKeys.length;3034 const count = threadKeys.length;
...@@ -3218,6 +3224,7 @@ export default function Sidebar() {...@@ -3218,6 +3224,7 @@ export default function Sidebar() {
3218 // really gone, or the first delete would treat still-alive batch mates3224 // really gone, or the first delete would treat still-alive batch mates
3219 // as deleted and remove a worktree they still point at.3225 // as deleted and remove a worktree they still point at.
3220 const deletedThreadKeys = new Set<string>();3226 const deletedThreadKeys = new Set<string>();
3227 let firstError: unknown = null;
3221 for (const threadKey of threadKeys) {3228 for (const threadKey of threadKeys) {
3222 const thread = threadByKeyRef.current.get(threadKey);3229 const thread = threadByKeyRef.current.get(threadKey);
3223 if (!thread) continue;3230 if (!thread) continue;
...@@ -3225,21 +3232,27 @@ export default function Sidebar() {...@@ -3225,21 +3232,27 @@ export default function Sidebar() {
3225 deletedThreadKeys,3232 deletedThreadKeys,
3226 });3233 });
3227 if (result._tag === "Failure") {3234 if (result._tag === "Failure") {
3228 if (!isAtomCommandInterrupted(result)) {3235 if (isAtomCommandInterrupted(result)) break;
3229 const error = squashAtomCommandFailure(result);3236 firstError ??= squashAtomCommandFailure(result);
3230 toastManager.add(3237 continue;
3231 stackedThreadToast({
3232 type: "error",
3233 title: "Failed to delete threads",
3234 description: error instanceof Error ? error.message : "An error occurred.",
3235 }),
3236 );
3237 }
3238 return;
3239 }3238 }
3240 deletedThreadKeys.add(threadKey);3239 deletedThreadKeys.add(threadKey);
3241 }3240 }
3242 removeFromSelection(threadKeys);3241 if (firstError !== null) {
3242 toastManager.add(
3243 stackedThreadToast({
3244 type: "error",
3245 title: "Failed to delete threads",
3246 description: firstError instanceof Error ? firstError.message : "An error occurred.",
3247 }),
3248 );
3249 }
3250 removeFromSelection(
3251 getThreadKeysToDeselectAfterDelete(selectedThreadKeys, deletedThreadKeys, (threadKey) => {
3252 const threadRef = parseScopedThreadKey(threadKey);
3253 return threadRef !== null && readThreadShell(threadRef) !== null;
3254 }),
3255 );
3243 },3256 },
3244 [3257 [
3245 attemptSettle,3258 attemptSettle,
apps/web/src/hooks/useThreadActions.test.ts+35-2
...@@ -1,7 +1,40 @@...@@ -1,7 +1,40 @@
1import { EnvironmentId, ThreadId } from "@t3tools/contracts";1import { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";2import { afterEach, describe, expect, it, vi } from "vite-plus/test";
33
4import { requestThreadUnpinConfirmation, ThreadArchiveBlockedError } from "./useThreadActions";4import {
5 navigateAfterThreadDeletion,
6 requestThreadUnpinConfirmation,
7 ThreadArchiveBlockedError,
8} from "./useThreadActions";
9import { toastManager } from "../components/ui/toast";
10
11describe("navigateAfterThreadDeletion", () => {
12 afterEach(() => vi.restoreAllMocks());
13
14 it("reports a rejected navigation without failing the completed deletion", async () => {
15 const addToast = vi.spyOn(toastManager, "add").mockReturnValue("navigation-error");
16
17 await expect(
18 navigateAfterThreadDeletion(() => Promise.reject(new Error("route unavailable"))),
19 ).resolves.toBeUndefined();
20
21 expect(addToast).toHaveBeenCalledOnce();
22 expect(addToast).toHaveBeenCalledWith(
23 expect.objectContaining({
24 title: "Thread deleted, but navigation failed",
25 description: "route unavailable",
26 }),
27 );
28 });
29
30 it("does not report an error after successful navigation", async () => {
31 const addToast = vi.spyOn(toastManager, "add");
32
33 await navigateAfterThreadDeletion(() => Promise.resolve());
34
35 expect(addToast).not.toHaveBeenCalled();
36 });
37});
538
6describe("ThreadArchiveBlockedError", () => {39describe("ThreadArchiveBlockedError", () => {
7 it("keeps the blocked thread context with the fixed message", () => {40 it("keeps the blocked thread context with the fixed message", () => {
apps/web/src/hooks/useThreadActions.ts+35-33
...@@ -144,6 +144,21 @@ export async function requestThreadUnpinConfirmation(input: {...@@ -144,6 +144,21 @@ export async function requestThreadUnpinConfirmation(input: {
144 );144 );
145}145}
146146
147/** Report navigation separately so a completed deletion can still finish worktree cleanup. */
148export async function navigateAfterThreadDeletion(navigate: () => Promise<void>) {
149 const result = await settlePromise(navigate);
150 if (result._tag === "Failure") {
151 const error = squashAtomCommandFailure(result);
152 toastManager.add(
153 stackedThreadToast({
154 type: "error",
155 title: "Thread deleted, but navigation failed",
156 description: error instanceof Error ? error.message : "An error occurred.",
157 }),
158 );
159 }
160}
161
147export function useThreadActions() {162export function useThreadActions() {
148 const closeTerminal = useAtomCommand(terminalEnvironment.close);163 const closeTerminal = useAtomCommand(terminalEnvironment.close);
149 const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, {164 const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, {
...@@ -383,39 +398,20 @@ export function useThreadActions() {...@@ -383,39 +398,20 @@ export function useThreadActions() {
383 clearTerminalUiState(threadRef);398 clearTerminalUiState(threadRef);
384399
385 if (shouldNavigateToFallback) {400 if (shouldNavigateToFallback) {
386 if (fallbackThreadId) {401 const fallbackThread = fallbackThreadId
387 const fallbackThread = readThreadShell(402 ? readThreadShell(scopeThreadRef(threadRef.environmentId, fallbackThreadId))
388 scopeThreadRef(threadRef.environmentId, fallbackThreadId),403 : null;
389 );404 await navigateAfterThreadDeletion(() =>
390 if (fallbackThread) {405 fallbackThread
391 const navigationResult = await settlePromise(() =>406 ? router.navigate({
392 router.navigate({
393 to: "/$environmentId/$threadId",407 to: "/$environmentId/$threadId",
394 params: buildThreadRouteParams(408 params: buildThreadRouteParams(
395 scopeThreadRef(fallbackThread.environmentId, fallbackThread.id),409 scopeThreadRef(fallbackThread.environmentId, fallbackThread.id),
396 ),410 ),
397 replace: true,411 replace: true,
398 }),412 })
399 );413 : router.navigate({ to: "/", replace: true }),
400 if (navigationResult._tag === "Failure") {414 );
401 return navigationResult;
402 }
403 } else {
404 const navigationResult = await settlePromise(() =>
405 router.navigate({ to: "/", replace: true }),
406 );
407 if (navigationResult._tag === "Failure") {
408 return navigationResult;
409 }
410 }
411 } else {
412 const navigationResult = await settlePromise(() =>
413 router.navigate({ to: "/", replace: true }),
414 );
415 if (navigationResult._tag === "Failure") {
416 return navigationResult;
417 }
418 }
419 }415 }
420416
421 if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) {417 if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) {
...@@ -444,9 +440,10 @@ export function useThreadActions() {...@@ -444,9 +440,10 @@ export function useThreadActions() {
444 ? refreshResult440 ? refreshResult
445 : null;441 : null;
446 if (cleanupFailure) {442 if (cleanupFailure) {
443 const removalFailed = removeResult._tag === "Failure";
447 const error = squashAtomCommandFailure(cleanupFailure);444 const error = squashAtomCommandFailure(cleanupFailure);
448 const message = error instanceof Error ? error.message : "Unknown error removing worktree.";445 const message = error instanceof Error ? error.message : "An error occurred.";
449 console.error("Failed to remove orphaned worktree after thread deletion", {446 console.error("Worktree cleanup failed after thread deletion", {
450 threadId: threadRef.threadId,447 threadId: threadRef.threadId,
451 projectCwd: threadProject.workspaceRoot,448 projectCwd: threadProject.workspaceRoot,
452 worktreePath: orphanedWorktreePath,449 worktreePath: orphanedWorktreePath,
...@@ -455,11 +452,16 @@ export function useThreadActions() {...@@ -455,11 +452,16 @@ export function useThreadActions() {
455 toastManager.add(452 toastManager.add(
456 stackedThreadToast({453 stackedThreadToast({
457 type: "error",454 type: "error",
458 title: "Thread deleted, but worktree removal failed",455 title: removalFailed
459 description: `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}`,456 ? "Failed to delete worktree"
457 : "Worktree deleted, but Git status refresh failed",
458 description: removalFailed
459 ? `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}`
460 : message,
460 }),461 }),
461 );462 );
462 return cleanupFailure;463 // The thread was deleted. Cleanup has its own toast; returning its
464 // failure would make callers incorrectly report a thread deletion error.
463 }465 }
464 return deleteResult;466 return deleteResult;
465 },467 },
apps/web/src/threadSelectionStore.test.ts+57-1
...@@ -1,7 +1,10 @@...@@ -1,7 +1,10 @@
1import { ThreadId } from "@t3tools/contracts";1import { ThreadId } from "@t3tools/contracts";
2import { beforeEach, describe, expect, it } from "vite-plus/test";2import { beforeEach, describe, expect, it } from "vite-plus/test";
33
4import { useThreadSelectionStore } from "./threadSelectionStore";4import {
5 getThreadKeysToDeselectAfterDelete,
6 useThreadSelectionStore,
7} from "./threadSelectionStore";
58
6const THREAD_A = ThreadId.make("thread-a");9const THREAD_A = ThreadId.make("thread-a");
7const THREAD_B = ThreadId.make("thread-b");10const THREAD_B = ThreadId.make("thread-b");
...@@ -16,6 +19,59 @@ describe("threadSelectionStore", () => {...@@ -16,6 +19,59 @@ describe("threadSelectionStore", () => {
16 useThreadSelectionStore.getState().clearSelection();19 useThreadSelectionStore.getState().clearSelection();
17 });20 });
1821
22 describe("bulk deletion cleanup", () => {
23 it("clears missing selection rows and completed deletions while retaining a failed thread", () => {
24 const store = useThreadSelectionStore.getState();
25 store.toggleThread(THREAD_A);
26 store.toggleThread(THREAD_B);
27 store.toggleThread(THREAD_C);
28 const selected = [...useThreadSelectionStore.getState().selectedThreadKeys];
29 // A deleted successfully but its shell has not refreshed; B failed;
30 // C was deleted elsewhere and never entered this client's delete loop.
31 const existingThreads = new Set([THREAD_A, THREAD_B]);
32 store.removeFromSelection(
33 getThreadKeysToDeselectAfterDelete(selected, new Set([THREAD_A]), (key) =>
34 existingThreads.has(ThreadId.make(key)),
35 ),
36 );
37
38 expect([...useThreadSelectionStore.getState().selectedThreadKeys]).toEqual([THREAD_B]);
39 expect(useThreadSelectionStore.getState().anchorThreadKey).toBeNull();
40 });
41
42 it("exits selection mode when the last selected thread disappeared elsewhere", () => {
43 const store = useThreadSelectionStore.getState();
44 store.toggleThread(THREAD_A);
45 store.removeFromSelection(
46 getThreadKeysToDeselectAfterDelete([THREAD_A], new Set(), () => false),
47 );
48
49 expect(useThreadSelectionStore.getState().hasSelection()).toBe(false);
50 expect(useThreadSelectionStore.getState().anchorThreadKey).toBeNull();
51 });
52
53 it("keeps unprocessed and hidden live threads and selections added while deletion was pending", () => {
54 const store = useThreadSelectionStore.getState();
55 store.toggleThread(THREAD_A);
56 store.toggleThread(THREAD_B);
57 store.toggleThread(THREAD_C);
58 const selected = [...useThreadSelectionStore.getState().selectedThreadKeys];
59 store.toggleThread(THREAD_D);
60 // Only A completed before interruption. B is unprocessed; C still
61 // has a shell even though its row is outside the rendered page.
62 store.removeFromSelection(
63 getThreadKeysToDeselectAfterDelete(selected, new Set([THREAD_A]), () => true),
64 );
65
66 expect([...useThreadSelectionStore.getState().selectedThreadKeys]).toEqual([
67 THREAD_B,
68 THREAD_C,
69 THREAD_D,
70 ]);
71 expect(useThreadSelectionStore.getState().anchorThreadKey).toBe(THREAD_D);
72 });
73 });
74
19 describe("toggleThread", () => {75 describe("toggleThread", () => {
20 it("adds a thread to empty selection", () => {76 it("adds a thread to empty selection", () => {
21 useThreadSelectionStore.getState().toggleThread(THREAD_A);77 useThreadSelectionStore.getState().toggleThread(THREAD_A);
apps/web/src/threadSelectionStore.ts+9
...@@ -34,6 +34,15 @@ interface ThreadSelectionStore extends ThreadSelectionState {...@@ -34,6 +34,15 @@ interface ThreadSelectionStore extends ThreadSelectionState {
3434
35const EMPTY_SET = new Set<string>();35const EMPTY_SET = new Set<string>();
3636
37/** Clear completed deletions and missing threads, retaining failed or unprocessed threads. */
38export function getThreadKeysToDeselectAfterDelete(
39 selectedThreadKeys: readonly string[],
40 deletedThreadKeys: ReadonlySet<string>,
41 hasThread: (threadKey: string) => boolean,
42): string[] {
43 return selectedThreadKeys.filter((key) => deletedThreadKeys.has(key) || !hasThread(key));
44}
45
37export const useThreadSelectionStore = create<ThreadSelectionStore>((set, get) => ({46export const useThreadSelectionStore = create<ThreadSelectionStore>((set, get) => ({
38 selectedThreadKeys: EMPTY_SET,47 selectedThreadKeys: EMPTY_SET,
39 anchorThreadKey: null,48 anchorThreadKey: null,