| author | |
| committer | |
| log | dd64072917193195479f6907cfb081267bff7301 |
| tree | 68d8875412cf9f96f4c327240b03cfadc20e5c90 |
| parent | 7544d3d2c8e0145018d9adb7a1a650333b75362a |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 179 | import { |
| 180 | 180 | archiveSelectedThreadEntries, |
| 181 | 181 | buildMultiSelectThreadContextMenuItems, |
| 182 | deleteSelectedThreadEntries, | |
| 182 | 183 | getSidebarThreadIdsToPrewarm, |
| 183 | 184 | resolveAdjacentThreadId, |
| 184 | 185 | isContextMenuPointerDown, |
| ... | ... | @@ -1926,21 +1927,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1926 | 1927 | if (!confirmed) return; |
| 1927 | 1928 | } |
| 1928 | 1929 | |
| 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); | |
| 1944 | 1937 | toastManager.add( |
| 1945 | 1938 | stackedThreadToast({ |
| 1946 | 1939 | type: "error", |
apps/web/src/components/Sidebar.logic.test.ts+99| ... | ... | @@ -1,5 +1,7 @@ |
| 1 | 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 2 | 2 | import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable"; |
| 3 | import * as Cause from "effect/Cause"; | |
| 4 | import { AsyncResult } from "effect/unstable/reactivity"; | |
| 3 | 5 | import { |
| 4 | 6 | animatePinnedLayoutChanges, |
| 5 | 7 | archiveSelectedThreadEntries, |
| ... | ... | @@ -7,6 +9,7 @@ import { |
| 7 | 9 | buildBulkUnpinContextMenuItem, |
| 8 | 10 | buildMultiSelectThreadContextMenuItems, |
| 9 | 11 | createThreadJumpHintVisibilityController, |
| 12 | deleteSelectedThreadEntries, | |
| 10 | 13 | filterSidebarProjectScopeItems, |
| 11 | 14 | getSidebarThreadIdsToPrewarm, |
| 12 | 15 | resolveAdjacentThreadId, |
| ... | ... | @@ -81,6 +84,102 @@ describe("animatePinnedLayoutChanges", () => { |
| 81 | 84 | }); |
| 82 | 85 | }); |
| 83 | 86 | |
| 87 | describe("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 | ||
| 84 | 183 | describe("archiveSelectedThreadEntries", () => { |
| 85 | 184 | const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const; |
| 86 | 185 | const success = { _tag: "Success" } as const; |
apps/web/src/components/Sidebar.logic.ts+35| ... | ... | @@ -1,7 +1,12 @@ |
| 1 | 1 | import * as React from "react"; |
| 2 | 2 | import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable"; |
| 3 | import { | |
| 4 | isAtomCommandInterrupted, | |
| 5 | type AtomCommandResult, | |
| 6 | } from "@t3tools/client-runtime/state/runtime"; | |
| 3 | 7 | import type { ContextMenuItem } from "@t3tools/contracts"; |
| 4 | 8 | import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; |
| 9 | import type { AsyncResult } from "effect/unstable/reactivity"; | |
| 5 | 10 | import { |
| 6 | 11 | activeThreadAnchorTimestampMs, |
| 7 | 12 | getThreadSortTimestamp, |
| ... | ... | @@ -109,6 +114,36 @@ type LogicalSidebarProject = SidebarProject & { |
| 109 | 114 | |
| 110 | 115 | export type ThreadTraversalDirection = "previous" | "next"; |
| 111 | 116 | |
| 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 | */ | |
| 121 | export 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 | ||
| 112 | 147 | export async function archiveSelectedThreadEntries< |
| 113 | 148 | TEntry extends { readonly threadKey: string }, |
| 114 | 149 | TResult extends { readonly _tag: "Success" | "Failure" }, |
apps/web/src/components/Sidebar.tsx+13-20| ... | ... | @@ -140,6 +140,7 @@ import { |
| 140 | 140 | animatePinnedLayoutChanges, |
| 141 | 141 | buildBulkTitleRegenerationContextMenuItem, |
| 142 | 142 | buildBulkUnpinContextMenuItem, |
| 143 | deleteSelectedThreadEntries, | |
| 143 | 144 | filterSidebarProjectScopeItems, |
| 144 | 145 | formatWorkingDurationLabel, |
| 145 | 146 | firstValidTimestampMs, |
| ... | ... | @@ -3219,26 +3220,18 @@ export default function Sidebar() { |
| 3219 | 3220 | ); |
| 3220 | 3221 | if (confirmed._tag === "Failure" || !confirmed.value) return; |
| 3221 | 3222 | } |
| 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); | |
| 3242 | 3235 | toastManager.add( |
| 3243 | 3236 | stackedThreadToast({ |
| 3244 | 3237 | type: "error", |