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 {
171171 SidebarMenuSubItem,
172172 useSidebar,
173173} from "./ui/sidebar";
174import { useThreadSelectionStore } from "../threadSelectionStore";
174import {
175 getThreadKeysToDeselectAfterDelete,
176 useThreadSelectionStore,
177} from "../threadSelectionStore";
175178import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus";
176179import {
177180 archiveSelectedThreadEntries,
......@@ -1910,26 +1913,35 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
19101913 if (!confirmed) return;
19111914 }
19121915
1913 const deletedThreadKeys = new Set(threadKeys);
1914 for (const { threadRef } of selectedThreadEntries) {
1916 // Only discount batch members after their deletions succeed.
1917 const deletedThreadKeys = new Set<string>();
1918 let firstError: unknown = null;
1919 for (const { threadKey, threadRef } of selectedThreadEntries) {
19151920 const result = await deleteThread(threadRef, {
19161921 deletedThreadKeys,
19171922 });
19181923 if (result._tag === "Failure") {
1919 if (!isAtomCommandInterrupted(result)) {
1920 const error = squashAtomCommandFailure(result);
1921 toastManager.add(
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;
1924 if (isAtomCommandInterrupted(result)) break;
1925 firstError ??= squashAtomCommandFailure(result);
1926 continue;
19301927 }
1928 deletedThreadKeys.add(threadKey);
19311929 }
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 );
19331945 },
19341946 [
19351947 appSettingsConfirmThreadArchive,
apps/web/src/components/Sidebar.tsx+28-15
......@@ -25,6 +25,7 @@ import {
2525import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort";
2626import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
2727import {
28 parseScopedThreadKey,
2829 scopeProjectRef,
2930 scopeThreadRef,
3031 scopedThreadKey,
......@@ -101,7 +102,10 @@ import {
101102 type SidebarProjectSnapshot,
102103} from "../sidebarProjectGrouping";
103104import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
104import { useThreadSelectionStore } from "../threadSelectionStore";
105import {
106 getThreadKeysToDeselectAfterDelete,
107 useThreadSelectionStore,
108} from "../threadSelectionStore";
105109import { useThreadActions } from "../hooks/useThreadActions";
106110import { useHandleNewThread } from "../hooks/useHandleNewThread";
107111import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus";
......@@ -112,6 +116,7 @@ import { useLocalStorage } from "../hooks/useLocalStorage";
112116import { useNowMinute } from "../hooks/useNowMinute";
113117import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
114118import {
119 readThreadShell,
115120 useAllEnvironmentProjectSnapshotsReady,
116121 useProjects,
117122 useThreadShells,
......@@ -3021,8 +3026,9 @@ export default function Sidebar() {
30213026 // right now. Selections can outlive their rows (settled-tail paging,
30223027 // thread deletion elsewhere) and the menu labels must count only what
30233028 // the actions will touch.
3024 const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys].filter(
3025 (threadKey) => threadByKeyRef.current.has(threadKey),
3029 const selectedThreadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys];
3030 const threadKeys = selectedThreadKeys.filter((threadKey) =>
3031 threadByKeyRef.current.has(threadKey),
30263032 );
30273033 if (threadKeys.length === 0) return;
30283034 const count = threadKeys.length;
......@@ -3218,6 +3224,7 @@ export default function Sidebar() {
32183224 // really gone, or the first delete would treat still-alive batch mates
32193225 // as deleted and remove a worktree they still point at.
32203226 const deletedThreadKeys = new Set<string>();
3227 let firstError: unknown = null;
32213228 for (const threadKey of threadKeys) {
32223229 const thread = threadByKeyRef.current.get(threadKey);
32233230 if (!thread) continue;
......@@ -3225,21 +3232,27 @@ export default function Sidebar() {
32253232 deletedThreadKeys,
32263233 });
32273234 if (result._tag === "Failure") {
3228 if (!isAtomCommandInterrupted(result)) {
3229 const error = squashAtomCommandFailure(result);
3230 toastManager.add(
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;
3235 if (isAtomCommandInterrupted(result)) break;
3236 firstError ??= squashAtomCommandFailure(result);
3237 continue;
32393238 }
32403239 deletedThreadKeys.add(threadKey);
32413240 }
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 );
32433256 },
32443257 [
32453258 attemptSettle,
apps/web/src/hooks/useThreadActions.test.ts+35-2
......@@ -1,7 +1,40 @@
11import { 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
639describe("ThreadArchiveBlockedError", () => {
740 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: {
144144 );
145145}
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
147162export function useThreadActions() {
148163 const closeTerminal = useAtomCommand(terminalEnvironment.close);
149164 const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, {
......@@ -383,39 +398,20 @@ export function useThreadActions() {
383398 clearTerminalUiState(threadRef);
384399
385400 if (shouldNavigateToFallback) {
386 if (fallbackThreadId) {
387 const fallbackThread = readThreadShell(
388 scopeThreadRef(threadRef.environmentId, fallbackThreadId),
389 );
390 if (fallbackThread) {
391 const navigationResult = await settlePromise(() =>
392 router.navigate({
401 const fallbackThread = fallbackThreadId
402 ? readThreadShell(scopeThreadRef(threadRef.environmentId, fallbackThreadId))
403 : null;
404 await navigateAfterThreadDeletion(() =>
405 fallbackThread
406 ? router.navigate({
393407 to: "/$environmentId/$threadId",
394408 params: buildThreadRouteParams(
395409 scopeThreadRef(fallbackThread.environmentId, fallbackThread.id),
396410 ),
397411 replace: true,
398 }),
399 );
400 if (navigationResult._tag === "Failure") {
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 }
412 })
413 : router.navigate({ to: "/", replace: true }),
414 );
419415 }
420416
421417 if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) {
......@@ -444,9 +440,10 @@ export function useThreadActions() {
444440 ? refreshResult
445441 : null;
446442 if (cleanupFailure) {
443 const removalFailed = removeResult._tag === "Failure";
447444 const error = squashAtomCommandFailure(cleanupFailure);
448 const message = error instanceof Error ? error.message : "Unknown error removing worktree.";
449 console.error("Failed to remove orphaned worktree after thread deletion", {
445 const message = error instanceof Error ? error.message : "An error occurred.";
446 console.error("Worktree cleanup failed after thread deletion", {
450447 threadId: threadRef.threadId,
451448 projectCwd: threadProject.workspaceRoot,
452449 worktreePath: orphanedWorktreePath,
......@@ -455,11 +452,16 @@ export function useThreadActions() {
455452 toastManager.add(
456453 stackedThreadToast({
457454 type: "error",
458 title: "Thread deleted, but worktree removal failed",
459 description: `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}`,
455 title: removalFailed
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,
460461 }),
461462 );
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.
463465 }
464466 return deleteResult;
465467 },
apps/web/src/threadSelectionStore.test.ts+57-1
......@@ -1,7 +1,10 @@
11import { ThreadId } from "@t3tools/contracts";
22import { beforeEach, describe, expect, it } from "vite-plus/test";
33
4import { useThreadSelectionStore } from "./threadSelectionStore";
4import {
5 getThreadKeysToDeselectAfterDelete,
6 useThreadSelectionStore,
7} from "./threadSelectionStore";
58
69const THREAD_A = ThreadId.make("thread-a");
710const THREAD_B = ThreadId.make("thread-b");
......@@ -16,6 +19,59 @@ describe("threadSelectionStore", () => {
1619 useThreadSelectionStore.getState().clearSelection();
1720 });
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
1975 describe("toggleThread", () => {
2076 it("adds a thread to empty selection", () => {
2177 useThreadSelectionStore.getState().toggleThread(THREAD_A);
apps/web/src/threadSelectionStore.ts+9
......@@ -34,6 +34,15 @@ interface ThreadSelectionStore extends ThreadSelectionState {
3434
3535const 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
3746export const useThreadSelectionStore = create<ThreadSelectionStore>((set, get) => ({
3847 selectedThreadKeys: EMPTY_SET,
3948 anchorThreadKey: null,