| author | |
| committer | |
| log | bd7f7ea0931127c26cfd283c9c2e98e0a425a06b |
| tree | bdbaa162cecdbb36af3f1f76277abbe2ae6aeec9 |
| parent | 39802c06117fae0b3da43624b0d54309c5437c72 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 171 | SidebarMenuSubItem, |
| 172 | 172 | useSidebar, |
| 173 | 173 | } from "./ui/sidebar"; |
| 174 | import { useThreadSelectionStore } from "../threadSelectionStore"; | |
| 174 | import { | |
| 175 | getThreadKeysToDeselectAfterDelete, | |
| 176 | useThreadSelectionStore, | |
| 177 | } from "../threadSelectionStore"; | |
| 175 | 178 | import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; |
| 176 | 179 | import { |
| 177 | 180 | archiveSelectedThreadEntries, |
| ... | ... | @@ -1910,26 +1913,35 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1910 | 1913 | if (!confirmed) return; |
| 1911 | 1914 | } |
| 1912 | 1915 | |
| 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) { | |
| 1915 | 1920 | const result = await deleteThread(threadRef, { |
| 1916 | 1921 | deletedThreadKeys, |
| 1917 | 1922 | }); |
| 1918 | 1923 | 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; | |
| 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 | 1947 | appSettingsConfirmThreadArchive, |
apps/web/src/components/Sidebar.tsx+28-15| ... | ... | @@ -25,6 +25,7 @@ import { |
| 25 | 25 | import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort"; |
| 26 | 26 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; |
| 27 | 27 | import { |
| 28 | parseScopedThreadKey, | |
| 28 | 29 | scopeProjectRef, |
| 29 | 30 | scopeThreadRef, |
| 30 | 31 | scopedThreadKey, |
| ... | ... | @@ -101,7 +102,10 @@ import { |
| 101 | 102 | type SidebarProjectSnapshot, |
| 102 | 103 | } from "../sidebarProjectGrouping"; |
| 103 | 104 | import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; |
| 104 | import { useThreadSelectionStore } from "../threadSelectionStore"; | |
| 105 | import { | |
| 106 | getThreadKeysToDeselectAfterDelete, | |
| 107 | useThreadSelectionStore, | |
| 108 | } from "../threadSelectionStore"; | |
| 105 | 109 | import { useThreadActions } from "../hooks/useThreadActions"; |
| 106 | 110 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 107 | 111 | import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; |
| ... | ... | @@ -112,6 +116,7 @@ import { useLocalStorage } from "../hooks/useLocalStorage"; |
| 112 | 116 | import { useNowMinute } from "../hooks/useNowMinute"; |
| 113 | 117 | import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; |
| 114 | 118 | import { |
| 119 | readThreadShell, | |
| 115 | 120 | useAllEnvironmentProjectSnapshotsReady, |
| 116 | 121 | useProjects, |
| 117 | 122 | useThreadShells, |
| ... | ... | @@ -3021,8 +3026,9 @@ export default function Sidebar() { |
| 3021 | 3026 | // right now. Selections can outlive their rows (settled-tail paging, |
| 3022 | 3027 | // thread deletion elsewhere) and the menu labels must count only what |
| 3023 | 3028 | // 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), | |
| 3026 | 3032 | ); |
| 3027 | 3033 | if (threadKeys.length === 0) return; |
| 3028 | 3034 | const count = threadKeys.length; |
| ... | ... | @@ -3218,6 +3224,7 @@ export default function Sidebar() { |
| 3218 | 3224 | // really gone, or the first delete would treat still-alive batch mates |
| 3219 | 3225 | // as deleted and remove a worktree they still point at. |
| 3220 | 3226 | const deletedThreadKeys = new Set<string>(); |
| 3227 | let firstError: unknown = null; | |
| 3221 | 3228 | for (const threadKey of threadKeys) { |
| 3222 | 3229 | const thread = threadByKeyRef.current.get(threadKey); |
| 3223 | 3230 | if (!thread) continue; |
| ... | ... | @@ -3225,21 +3232,27 @@ export default function Sidebar() { |
| 3225 | 3232 | deletedThreadKeys, |
| 3226 | 3233 | }); |
| 3227 | 3234 | 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; | |
| 3239 | 3238 | } |
| 3240 | 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 | 3258 | attemptSettle, |
apps/web/src/hooks/useThreadActions.test.ts+35-2| ... | ... | @@ -1,7 +1,40 @@ |
| 1 | 1 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 3 | 3 | |
| 4 | import { requestThreadUnpinConfirmation, ThreadArchiveBlockedError } from "./useThreadActions"; | |
| 4 | import { | |
| 5 | navigateAfterThreadDeletion, | |
| 6 | requestThreadUnpinConfirmation, | |
| 7 | ThreadArchiveBlockedError, | |
| 8 | } from "./useThreadActions"; | |
| 9 | import { toastManager } from "../components/ui/toast"; | |
| 10 | ||
| 11 | describe("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 | }); | |
| 5 | 38 | |
| 6 | 39 | describe("ThreadArchiveBlockedError", () => { |
| 7 | 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 | 144 | ); |
| 145 | 145 | } |
| 146 | 146 | |
| 147 | /** Report navigation separately so a completed deletion can still finish worktree cleanup. */ | |
| 148 | export 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 | ||
| 147 | 162 | export function useThreadActions() { |
| 148 | 163 | const closeTerminal = useAtomCommand(terminalEnvironment.close); |
| 149 | 164 | const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, { |
| ... | ... | @@ -383,39 +398,20 @@ export function useThreadActions() { |
| 383 | 398 | clearTerminalUiState(threadRef); |
| 384 | 399 | |
| 385 | 400 | 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({ | |
| 393 | 407 | to: "/$environmentId/$threadId", |
| 394 | 408 | params: buildThreadRouteParams( |
| 395 | 409 | scopeThreadRef(fallbackThread.environmentId, fallbackThread.id), |
| 396 | 410 | ), |
| 397 | 411 | 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 | ); | |
| 419 | 415 | } |
| 420 | 416 | |
| 421 | 417 | if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) { |
| ... | ... | @@ -444,9 +440,10 @@ export function useThreadActions() { |
| 444 | 440 | ? refreshResult |
| 445 | 441 | : null; |
| 446 | 442 | if (cleanupFailure) { |
| 443 | const removalFailed = removeResult._tag === "Failure"; | |
| 447 | 444 | 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", { | |
| 450 | 447 | threadId: threadRef.threadId, |
| 451 | 448 | projectCwd: threadProject.workspaceRoot, |
| 452 | 449 | worktreePath: orphanedWorktreePath, |
| ... | ... | @@ -455,11 +452,16 @@ export function useThreadActions() { |
| 455 | 452 | toastManager.add( |
| 456 | 453 | stackedThreadToast({ |
| 457 | 454 | 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, | |
| 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 | 466 | return deleteResult; |
| 465 | 467 | }, |
apps/web/src/threadSelectionStore.test.ts+57-1| ... | ... | @@ -1,7 +1,10 @@ |
| 1 | 1 | import { ThreadId } from "@t3tools/contracts"; |
| 2 | 2 | import { beforeEach, describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | |
| 4 | import { useThreadSelectionStore } from "./threadSelectionStore"; | |
| 4 | import { | |
| 5 | getThreadKeysToDeselectAfterDelete, | |
| 6 | useThreadSelectionStore, | |
| 7 | } from "./threadSelectionStore"; | |
| 5 | 8 | |
| 6 | 9 | const THREAD_A = ThreadId.make("thread-a"); |
| 7 | 10 | const THREAD_B = ThreadId.make("thread-b"); |
| ... | ... | @@ -16,6 +19,59 @@ describe("threadSelectionStore", () => { |
| 16 | 19 | useThreadSelectionStore.getState().clearSelection(); |
| 17 | 20 | }); |
| 18 | 21 | |
| 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 | 75 | describe("toggleThread", () => { |
| 20 | 76 | it("adds a thread to empty selection", () => { |
| 21 | 77 | useThreadSelectionStore.getState().toggleThread(THREAD_A); |
apps/web/src/threadSelectionStore.ts+9| ... | ... | @@ -34,6 +34,15 @@ interface ThreadSelectionStore extends ThreadSelectionState { |
| 34 | 34 | |
| 35 | 35 | const EMPTY_SET = new Set<string>(); |
| 36 | 36 | |
| 37 | /** Clear completed deletions and missing threads, retaining failed or unprocessed threads. */ | |
| 38 | export 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 | ||
| 37 | 46 | export const useThreadSelectionStore = create<ThreadSelectionStore>((set, get) => ({ |
| 38 | 47 | selectedThreadKeys: EMPTY_SET, |
| 39 | 48 | anchorThreadKey: null, |