| 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,6 +179,7 @@ import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; |
| 179 | import { | 179 | import { |
| 180 | archiveSelectedThreadEntries, | 180 | archiveSelectedThreadEntries, |
| 181 | buildMultiSelectThreadContextMenuItems, | 181 | buildMultiSelectThreadContextMenuItems, |
| 182 | deleteSelectedThreadEntries, | ||
| 182 | getSidebarThreadIdsToPrewarm, | 183 | getSidebarThreadIdsToPrewarm, |
| 183 | resolveAdjacentThreadId, | 184 | resolveAdjacentThreadId, |
| 184 | isContextMenuPointerDown, | 185 | isContextMenuPointerDown, |
| ... | @@ -1926,21 +1927,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1926,21 +1927,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1926 | if (!confirmed) return; | 1927 | if (!confirmed) return; |
| 1927 | } | 1928 | } |
| 1928 | 1929 | ||
| 1929 | // Only discount batch members after their deletions succeed. | 1930 | const { deletedThreadKeys, firstFailure } = await deleteSelectedThreadEntries({ |
| 1930 | const deletedThreadKeys = new Set<string>(); | 1931 | entries: selectedThreadEntries, |
| 1931 | let firstError: unknown = null; | 1932 | delete: ({ threadRef }, deletedThreadKeys) => |
| 1932 | for (const { threadKey, threadRef } of selectedThreadEntries) { | 1933 | deleteThread(threadRef, { deletedThreadKeys }), |
| 1933 | const result = await deleteThread(threadRef, { | 1934 | }); |
| 1934 | deletedThreadKeys, | 1935 | if (firstFailure !== null) { |
| 1935 | }); | 1936 | const firstError = squashAtomCommandFailure(firstFailure); |
| 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) { | ||
| 1944 | toastManager.add( | 1937 | toastManager.add( |
| 1945 | stackedThreadToast({ | 1938 | stackedThreadToast({ |
| 1946 | type: "error", | 1939 | type: "error", |
apps/web/src/components/Sidebar.logic.test.ts+99| ... | @@ -1,5 +1,7 @@ | ... | @@ -1,5 +1,7 @@ |
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 2 | import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable"; | 2 | import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable"; |
| 3 | import * as Cause from "effect/Cause"; | ||
| 4 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 3 | import { | 5 | import { |
| 4 | animatePinnedLayoutChanges, | 6 | animatePinnedLayoutChanges, |
| 5 | archiveSelectedThreadEntries, | 7 | archiveSelectedThreadEntries, |
| ... | @@ -7,6 +9,7 @@ import { | ... | @@ -7,6 +9,7 @@ import { |
| 7 | buildBulkUnpinContextMenuItem, | 9 | buildBulkUnpinContextMenuItem, |
| 8 | buildMultiSelectThreadContextMenuItems, | 10 | buildMultiSelectThreadContextMenuItems, |
| 9 | createThreadJumpHintVisibilityController, | 11 | createThreadJumpHintVisibilityController, |
| 12 | deleteSelectedThreadEntries, | ||
| 10 | filterSidebarProjectScopeItems, | 13 | filterSidebarProjectScopeItems, |
| 11 | getSidebarThreadIdsToPrewarm, | 14 | getSidebarThreadIdsToPrewarm, |
| 12 | resolveAdjacentThreadId, | 15 | resolveAdjacentThreadId, |
| ... | @@ -81,6 +84,102 @@ describe("animatePinnedLayoutChanges", () => { | ... | @@ -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 | describe("archiveSelectedThreadEntries", () => { | 183 | describe("archiveSelectedThreadEntries", () => { |
| 85 | const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const; | 184 | const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const; |
| 86 | const success = { _tag: "Success" } as const; | 185 | const success = { _tag: "Success" } as const; |
apps/web/src/components/Sidebar.logic.ts+35| ... | @@ -1,7 +1,12 @@ | ... | @@ -1,7 +1,12 @@ |
| 1 | import * as React from "react"; | 1 | import * as React from "react"; |
| 2 | import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable"; | 2 | import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable"; |
| 3 | import { | ||
| 4 | isAtomCommandInterrupted, | ||
| 5 | type AtomCommandResult, | ||
| 6 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 3 | import type { ContextMenuItem } from "@t3tools/contracts"; | 7 | import type { ContextMenuItem } from "@t3tools/contracts"; |
| 4 | import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; | 8 | import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; |
| 9 | import type { AsyncResult } from "effect/unstable/reactivity"; | ||
| 5 | import { | 10 | import { |
| 6 | activeThreadAnchorTimestampMs, | 11 | activeThreadAnchorTimestampMs, |
| 7 | getThreadSortTimestamp, | 12 | getThreadSortTimestamp, |
| ... | @@ -109,6 +114,36 @@ type LogicalSidebarProject = SidebarProject & { | ... | @@ -109,6 +114,36 @@ type LogicalSidebarProject = SidebarProject & { |
| 109 | 114 | ||
| 110 | export type ThreadTraversalDirection = "previous" | "next"; | 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 | export async function archiveSelectedThreadEntries< | 147 | export async function archiveSelectedThreadEntries< |
| 113 | TEntry extends { readonly threadKey: string }, | 148 | TEntry extends { readonly threadKey: string }, |
| 114 | TResult extends { readonly _tag: "Success" | "Failure" }, | 149 | TResult extends { readonly _tag: "Success" | "Failure" }, |
apps/web/src/components/Sidebar.tsx+13-20| ... | @@ -140,6 +140,7 @@ import { | ... | @@ -140,6 +140,7 @@ import { |
| 140 | animatePinnedLayoutChanges, | 140 | animatePinnedLayoutChanges, |
| 141 | buildBulkTitleRegenerationContextMenuItem, | 141 | buildBulkTitleRegenerationContextMenuItem, |
| 142 | buildBulkUnpinContextMenuItem, | 142 | buildBulkUnpinContextMenuItem, |
| 143 | deleteSelectedThreadEntries, | ||
| 143 | filterSidebarProjectScopeItems, | 144 | filterSidebarProjectScopeItems, |
| 144 | formatWorkingDurationLabel, | 145 | formatWorkingDurationLabel, |
| 145 | firstValidTimestampMs, | 146 | firstValidTimestampMs, |
| ... | @@ -3219,26 +3220,18 @@ export default function Sidebar() { | ... | @@ -3219,26 +3220,18 @@ export default function Sidebar() { |
| 3219 | ); | 3220 | ); |
| 3220 | if (confirmed._tag === "Failure" || !confirmed.value) return; | 3221 | if (confirmed._tag === "Failure" || !confirmed.value) return; |
| 3221 | } | 3222 | } |
| 3222 | // Grown as deletions actually land, never seeded with the whole batch: | 3223 | const { deletedThreadKeys, firstFailure } = await deleteSelectedThreadEntries({ |
| 3223 | // orphaned-worktree detection must only discount threads that are | 3224 | entries: threadKeys.map((threadKey) => ({ threadKey })), |
| 3224 | // really gone, or the first delete would treat still-alive batch mates | 3225 | delete: async ({ threadKey }, deletedThreadKeys) => { |
| 3225 | // as deleted and remove a worktree they still point at. | 3226 | const thread = threadByKeyRef.current.get(threadKey); |
| 3226 | const deletedThreadKeys = new Set<string>(); | 3227 | if (!thread) return null; |
| 3227 | let firstError: unknown = null; | 3228 | return deleteThread(scopeThreadRef(thread.environmentId, thread.id), { |
| 3228 | for (const threadKey of threadKeys) { | 3229 | deletedThreadKeys, |
| 3229 | const thread = threadByKeyRef.current.get(threadKey); | 3230 | }); |
| 3230 | if (!thread) continue; | 3231 | }, |
| 3231 | const result = await deleteThread(scopeThreadRef(thread.environmentId, thread.id), { | 3232 | }); |
| 3232 | deletedThreadKeys, | 3233 | if (firstFailure !== null) { |
| 3233 | }); | 3234 | const firstError = squashAtomCommandFailure(firstFailure); |
| 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) { | ||
| 3242 | toastManager.add( | 3235 | toastManager.add( |
| 3243 | stackedThreadToast({ | 3236 | stackedThreadToast({ |
| 3244 | type: "error", | 3237 | type: "error", |