From b12969f63d58b78cc023b887c52875feb628897a Mon Sep 17 00:00:00 2001 From: Edge358 <58263644+Edge358@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:17:03 -0600 Subject: [PATCH] fix(web): keep later navigation when thread deletion completes Deleting the open thread decided whether to navigate before the delete request and navigated after it, so a thread the user opened in between was replaced. Watch router history while the request is pending and skip the fallback navigation if the user navigated. Coding assisted by Claude Fable 5.2 in Claude Code. --- .../useThreadActions.permissions.test.ts | 6 ++- apps/web/src/hooks/useThreadActions.ts | 10 ++++- .../src/hooks/useThreadActions.undo.test.ts | 37 +++++++++++++++++-- 3 files changed, 46 insertions(+), 7 deletions(-) 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 e91f170689a5..7d5c9f3d8234 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -587,10 +587,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; } @@ -614,7 +620,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 5ec8f1fc6430..3c1b352a1810 100644 --- a/apps/web/src/hooks/useThreadActions.undo.test.ts +++ b/apps/web/src/hooks/useThreadActions.undo.test.ts @@ -18,10 +18,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()), @@ -239,6 +249,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");