diff --git a/apps/web/src/hooks/useThreadActions.permissions.test.ts b/apps/web/src/hooks/useThreadActions.permissions.test.ts index 58bc33cd0d64..649ec0c6bac1 100644 --- a/apps/web/src/hooks/useThreadActions.permissions.test.ts +++ b/apps/web/src/hooks/useThreadActions.permissions.test.ts @@ -55,7 +55,11 @@ vi.mock("react", () => ({ useRef: (current: unknown) => ({ current }), })); vi.mock("@tanstack/react-router", () => ({ - useRouter: () => ({ state: { location: { href: "/" }, matches: [] }, navigate: async () => {} }), + useRouter: () => ({ + state: { location: { href: "/" }, matches: [] }, + history: { subscribe: () => () => {} }, + navigate: async () => {}, + }), })); vi.mock("../state/session", () => ({ environmentSession: { sessionStateAtom: "session" }, diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index a057f0852732..cc9c27770886 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -588,10 +588,16 @@ export function useThreadActions() { sortOrder: sidebarThreadSortOrder, now: new Date().toISOString(), }); + let navigatedDuringDelete = false; + // The missing-thread redirect and the index draft replace this entry; any other history + // action while the delete is pending is the user's own choice, which must win. + const stopWatchingHistory = router.history.subscribe(({ action }) => { + if (action.type !== "REPLACE") navigatedDuringDelete = true; + }); const deleteResult = await deleteThreadMutation({ environmentId: threadRef.environmentId, input: { threadId: threadRef.threadId }, - }); + }).finally(stopWatchingHistory); if (deleteResult._tag === "Failure") { return deleteResult; } @@ -615,7 +621,7 @@ export function useThreadActions() { clearTerminalUiState(threadRef); clearDeletedThreadPanelState(threadRef); - if (shouldNavigateToFallback) { + if (shouldNavigateToFallback && !navigatedDuringDelete) { const fallbackThread = fallbackThreadId ? readThreadShell(scopeThreadRef(threadRef.environmentId, fallbackThreadId)) : null; diff --git a/apps/web/src/hooks/useThreadActions.undo.test.ts b/apps/web/src/hooks/useThreadActions.undo.test.ts index a2ce4229d9b0..98f69e9de5a0 100644 --- a/apps/web/src/hooks/useThreadActions.undo.test.ts +++ b/apps/web/src/hooks/useThreadActions.undo.test.ts @@ -19,10 +19,20 @@ const commands = vi.hoisted(() => ({ snooze: vi.fn(), unsnooze: vi.fn(), })); -const router = vi.hoisted(() => ({ - navigate: vi.fn(async () => {}), - state: { location: { href: "/" }, matches: [{ params: {} as Record }] }, -})); +const router = vi.hoisted(() => { + const listeners = new Set<(event: { action: { type: string } }) => void>(); + return { + navigate: vi.fn(async () => {}), + state: { location: { href: "/" }, matches: [{ params: {} as Record }] }, + history: { + listeners, + subscribe: (listener: (event: { action: { type: string } }) => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }, + }; +}); vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() })); vi.mock("../state/session", async (original) => ({ ...(await original()), @@ -251,6 +261,25 @@ describe("archive navigation", () => { }); }); +describe("delete navigation", () => { + it.each([ + { type: "REPLACE", by: "the app's missing-thread redirect", fallsBack: true }, + { type: "PUSH", by: "the user opening another thread", fallsBack: false }, + ])("falls back: $fallsBack after $by while deleting", async ({ type, fallsBack }) => { + router.state.matches[0]!.params = { + environmentId: target.environmentId, + threadId: target.threadId, + }; + commands.delete.mockImplementation(async () => { + for (const listener of router.history.listeners) listener({ action: { type } }); + return { _tag: "Success", value: undefined }; + }); + expect((await useThreadActions().deleteThread(target))._tag).toBe("Success"); + expect(router.navigate).toHaveBeenCalledTimes(fallsBack ? 1 : 0); + expect(router.history.listeners.size).toBe(0); + }); +}); + describe("settle and snooze Undo", () => { it("un-settles from the notice and expires the Undo after a manual un-settle", async () => { const add = vi.spyOn(toastManager, "add").mockReturnValue("toast");