diff --git a/apps/web/src/hooks/showUndoToast.test.ts b/apps/web/src/hooks/showUndoToast.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a3798d5dc700453e0b3ebd2f4bd330d103b6b97e --- /dev/null +++ b/apps/web/src/hooks/showUndoToast.test.ts @@ -0,0 +1,95 @@ +import { AsyncResult } from "effect/unstable/reactivity"; +import * as Cause from "effect/Cause"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { toastManager } from "../components/ui/toast"; +import { showUndoToast } from "./showUndoToast"; +import * as ThreadUndo from "./threadUndo"; + +afterEach(() => vi.restoreAllMocks()); + +function setup() { + const add = vi.spyOn(toastManager, "add").mockReturnValue("undo-toast"); + const close = vi.spyOn(toastManager, "close").mockImplementation(() => {}); + const undo = vi.fn(async () => AsyncResult.success(undefined)); + const claim = ThreadUndo.begin("pin", "env/thread"); + const options = { + title: "Thread unpinned", + description: "Thread", + failureTitle: "Restore failed", + undo, + claim, + }; + return { add, close, undo, claim, options }; +} + +function click(add: ReturnType["add"], index = 0) { + const handler = add.mock.calls[index]?.[0].actionProps?.onClick; + if (!handler) throw new Error("Undo action is missing"); + return handler({} as Parameters[0]); +} + +describe("showUndoToast", () => { + it("ignores a stale toast and lets the latest action run only once", async () => { + const { add, close, undo, options } = setup(); + showUndoToast(options); + ThreadUndo.invalidate("pin", "env/thread"); + showUndoToast({ ...options, claim: ThreadUndo.begin("pin", "env/thread") }); + await click(add); + expect(undo).not.toHaveBeenCalled(); + await click(add, 1); + await click(add, 1); + expect(undo).toHaveBeenCalledOnce(); + expect(close).toHaveBeenCalledExactlyOnceWith("undo-toast"); + }); + + it("releases the claim on close and rejects a later click", async () => { + const { add, undo, claim, options } = setup(); + showUndoToast(options); + add.mock.calls[0]?.[0].onClose?.(); + expect(claim.isCurrent()).toBe(false); + await click(add); + expect(undo).not.toHaveBeenCalled(); + }); + + it("does not show a toast for a late completion after a newer action", () => { + const { add, options } = setup(); + ThreadUndo.invalidate("pin", "env/thread"); + showUndoToast(options); + expect(add).not.toHaveBeenCalled(); + }); + + it("reports a failed restore and releases its claim", async () => { + const { add, claim, options } = setup(); + showUndoToast({ + ...options, + undo: async () => AsyncResult.failure(Cause.fail(new Error("offline"))), + }); + await click(add); + expect(add).toHaveBeenLastCalledWith( + expect.objectContaining({ type: "error", title: "Restore failed", description: "offline" }), + ); + expect(claim.isCurrent()).toBe(false); + }); + + it("reports a rejected restore promise", async () => { + const { add, options } = setup(); + showUndoToast({ + ...options, + undo: async () => { + throw new Error("disconnected"); + }, + }); + await click(add); + expect(add).toHaveBeenLastCalledWith( + expect.objectContaining({ type: "error", description: "disconnected" }), + ); + }); + + it("does not report interrupted restores as errors", async () => { + const { add, options } = setup(); + showUndoToast({ ...options, undo: async () => AsyncResult.failure(Cause.interrupt()) }); + await click(add); + expect(add).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/src/hooks/showUndoToast.ts b/apps/web/src/hooks/showUndoToast.ts new file mode 100644 index 0000000000000000000000000000000000000000..3b9393f098cc756d5aff2d707b4242920425b6db --- /dev/null +++ b/apps/web/src/hooks/showUndoToast.ts @@ -0,0 +1,61 @@ +import { + type AtomCommandResult, + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; + +import { stackedThreadToast, toastManager } from "../components/ui/toast"; +import type * as ThreadUndo from "./threadUndo"; + +/** Shows a single-use Undo while its thread action still owns the claim. */ +export function showUndoToast({ + title, + description, + undo, + failureTitle, + claim, +}: { + title: string; + description: string | undefined; + undo: () => Promise>; + failureTitle: string; + claim: ReturnType; +}) { + if (!claim.isCurrent()) return; + let undoStarted = false; + const reportFailure = (error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: failureTitle, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }; + const toastId = toastManager.add({ + ...stackedThreadToast({ + type: "success", + title, + description, + timeout: 5_000, + actionProps: { + children: "Undo", + onClick: async () => { + if (undoStarted || !claim.isCurrent()) return; + undoStarted = true; + claim.finish(); + toastManager.close(toastId); + try { + const result = await undo(); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + reportFailure(squashAtomCommandFailure(result)); + } + } catch (error) { + reportFailure(error); + } + }, + }, + }), + onClose: claim.finish, + }); +} diff --git a/apps/web/src/hooks/threadUndo.test.ts b/apps/web/src/hooks/threadUndo.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..58762c6254498e46e47f261fb2cfe10ef7214cb8 --- /dev/null +++ b/apps/web/src/hooks/threadUndo.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vite-plus/test"; + +import * as ThreadUndo from "./threadUndo"; + +describe("thread action ownership", () => { + it("keeps claims for different action kinds independent", () => { + const pin = ThreadUndo.begin("pin", "env/shared"); + const archive = ThreadUndo.begin("archive", "env/shared"); + ThreadUndo.invalidate("pin", "env/shared"); + expect(pin.isCurrent()).toBe(false); + expect(archive.isCurrent()).toBe(true); + archive.finish(); + }); + + it("does not revive the first Undo after a later pin and unpin", () => { + const firstUnpin = ThreadUndo.begin("pin", "env/thread"); + ThreadUndo.invalidate("pin", "env/thread"); + const secondUnpin = ThreadUndo.begin("pin", "env/thread"); + expect(firstUnpin.isCurrent()).toBe(false); + expect(secondUnpin.isCurrent()).toBe(true); + firstUnpin.finish(); + expect(secondUnpin.isCurrent()).toBe(true); + secondUnpin.finish(); + expect(firstUnpin.isCurrent()).toBe(false); + }); + + it("rejects a late unpin completion after a newer pin started", () => { + const pendingUnpin = ThreadUndo.begin("pin", "env/late"); + ThreadUndo.invalidate("pin", "env/late"); + expect(pendingUnpin.isCurrent()).toBe(false); + }); + + it("expires an Undo without invalidating another environment or thread", () => { + const first = ThreadUndo.begin("pin", "one/thread"); + const otherEnvironment = ThreadUndo.begin("pin", "two/thread"); + const otherThread = ThreadUndo.begin("pin", "one/other"); + first.finish(); + expect(first.isCurrent()).toBe(false); + expect(otherEnvironment.isCurrent()).toBe(true); + expect(otherThread.isCurrent()).toBe(true); + otherEnvironment.finish(); + otherThread.finish(); + }); +}); diff --git a/apps/web/src/hooks/threadUndo.ts b/apps/web/src/hooks/threadUndo.ts new file mode 100644 index 0000000000000000000000000000000000000000..a9e5c179fc87fbe183e16b42bb48eb3d718fa56a --- /dev/null +++ b/apps/web/src/hooks/threadUndo.ts @@ -0,0 +1,21 @@ +// Shared across hook instances so sidebar, header and menu actions invalidate each other. +const currentActions = new Map(); + +/** Claims one kind of thread action; a later claim of that kind expires its Undo. */ +export function begin(kind: string, threadKey: string) { + const key = JSON.stringify([kind, threadKey]); + const token = Symbol(); + currentActions.set(key, token); + const isCurrent = () => currentActions.get(key) === token; + return { + isCurrent, + finish: () => { + if (isCurrent()) currentActions.delete(key); + }, + }; +} + +/** Expires only this action kind, leaving unrelated thread actions intact. */ +export function invalidate(kind: string, threadKey: string) { + currentActions.delete(JSON.stringify([kind, threadKey])); +} diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 55ff9992ce57b4d9dea5f046e57d08ef95090cf1..9a8dfe48c5869ee18aba41a0a92b33b40518a209 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -42,6 +42,8 @@ import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { useClientSettings } from "./useSettings"; +import * as ThreadUndo from "./threadUndo"; +import { showUndoToast } from "./showUndoToast"; import { useAtomCommand } from "../state/use-atom-command"; export class ThreadArchiveBlockedError extends Schema.TaggedError()( @@ -582,6 +584,7 @@ export function useThreadActions() { const orderKey = readEnvironmentSupportsPinReorder(target.environmentId) ? (opts.orderKey ?? topOfPinnedRunOrderKey()) : undefined; + ThreadUndo.invalidate("pin", scopedThreadKey(target)); return pinThreadMutation({ environmentId: target.environmentId, input: { @@ -605,12 +608,27 @@ export function useThreadActions() { ), ); } - return unpinThreadMutation({ + const thread = readThreadShell(target); + const orderKey = thread?.pinOrderKey ?? undefined; + const action = ThreadUndo.begin("pin", scopedThreadKey(target)); + const result = await unpinThreadMutation({ environmentId: target.environmentId, input: { threadId: target.threadId }, }); + if (result._tag === "Success" && action.isCurrent()) { + showUndoToast({ + title: "Thread unpinned", + description: thread?.title, + claim: action, + undo: () => pinThread(target, orderKey === undefined ? {} : { orderKey }), + failureTitle: "Failed to undo unpin", + }); + } else { + action.finish(); + } + return result; }, - [unpinThreadMutation], + [pinThread, unpinThreadMutation], ); const confirmAndUnpinThread = useCallback( @@ -648,6 +666,7 @@ export function useThreadActions() { ), ); } + ThreadUndo.invalidate("pin", scopedThreadKey(target)); return reorderPinnedThreadMutation({ environmentId: target.environmentId, input: { threadId: target.threadId, orderKey }, diff --git a/apps/web/src/hooks/useThreadActions.undo.test.ts b/apps/web/src/hooks/useThreadActions.undo.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6185a292c57746c58c485d54d78c2493a0169b7f --- /dev/null +++ b/apps/web/src/hooks/useThreadActions.undo.test.ts @@ -0,0 +1,69 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { useThreadActions } from "./useThreadActions"; +import { threadEnvironment } from "../state/threads"; +import { toastManager } from "../components/ui/toast"; + +const commands = vi.hoisted(() => ({ pin: vi.fn(), unpin: vi.fn() })); +vi.mock("react", async (original) => ({ + ...(await original()), + useCallback: (callback: unknown) => callback, + useMemo: (create: () => unknown) => create(), + useRef: (value: unknown) => ({ current: value }), +})); +vi.mock("@tanstack/react-router", () => ({ useRouter: () => ({}) })); +vi.mock("./useSettings", () => ({ useClientSettings: () => false })); +vi.mock("./useHandleNewThread", () => ({ useNewThreadHandler: () => vi.fn() })); +vi.mock("../composerDraftStore", () => ({ useComposerDraftStore: () => vi.fn() })); +vi.mock("../terminalUiStateStore", () => ({ useTerminalUiStateStore: () => vi.fn() })); +vi.mock("../uiStateStore", () => ({ useUiStateStore: () => vi.fn() })); +vi.mock("../state/entities", async (original) => ({ + ...(await original()), + readEnvironmentSupportsPinning: () => true, + readEnvironmentSupportsPinReorder: () => true, + readThreadShell: () => ({ title: "Thread", pinOrderKey: "a0" }), +})); +vi.mock("../state/use-atom-command", () => ({ + useAtomCommand: (command: unknown) => + command === threadEnvironment.pin + ? commands.pin + : command === threadEnvironment.unpin + ? commands.unpin + : vi.fn(), +})); + +afterEach(() => vi.restoreAllMocks()); + +describe("unpin Undo", () => { + it("ignores an old toast across hook instances and still restores the latest unpin", async () => { + commands.pin.mockResolvedValue({ _tag: "Success", value: undefined }); + commands.unpin.mockResolvedValue({ _tag: "Success", value: undefined }); + const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); + vi.spyOn(toastManager, "close").mockImplementation(() => {}); + const sidebar = useThreadActions(); + const header = useThreadActions(); + const target = { + environmentId: EnvironmentId.make("undo-env"), + threadId: ThreadId.make("thread"), + }; + await sidebar.unpinThread(target); + const staleUndo = add.mock.calls[0]?.[0].actionProps?.onClick; + await header.pinThread(target, { orderKey: "a1" }); + await header.unpinThread(target); + const latestUndo = add.mock.calls[1]?.[0].actionProps?.onClick; + expect(staleUndo).toBeTypeOf("function"); + expect(latestUndo).toBeTypeOf("function"); + const event = {} as Parameters>[0]; + staleUndo?.(event); + expect(commands.pin).toHaveBeenCalledTimes(1); + latestUndo?.(event); + expect(commands.pin).toHaveBeenCalledTimes(2); + expect(commands.pin).toHaveBeenLastCalledWith({ + environmentId: target.environmentId, + input: { threadId: target.threadId, orderKey: "a0" }, + }); + latestUndo?.(event); + expect(commands.pin).toHaveBeenCalledTimes(2); + }); +}); diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 650954d7a586afee1a2e718d6e3d6859de0f5e61..e921b4c57a4ae2fe2b601335f85d3257105bda37 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -29,6 +29,8 @@ and worktree while you stay in the new thread composer. This requires a Git proj Pin a thread from its menu to keep it above your active work. +On web and desktop, unpinning offers Undo for five seconds to restore the thread's pinned position. + On web and desktop, you can also drag files from your computer onto any thread row: the thread opens and the files are attached in its composer, ready for your next message. The same per-message file limits apply as when attaching