| author | |
| committer | |
| log | 6b0a04ade021588dd9f1afbfa0e75cccdb7b2da4 |
| tree | 806f495be9990aafdaf73ac017fd1b3800b32dfd |
| parent | f391b88c3754047c3c9434b76328699c083723b0 |
| signature | Signed by PGP key B5690EEEBB952194 |
7 files changed, 313 insertions(+), 2 deletions(-)
apps/web/src/hooks/showUndoToast.test.ts created+95| ... | ... | @@ -0,0 +1,95 @@ |
| 1 | import { AsyncResult } from "effect/unstable/reactivity"; | |
| 2 | import * as Cause from "effect/Cause"; | |
| 3 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 4 | ||
| 5 | import { toastManager } from "../components/ui/toast"; | |
| 6 | import { showUndoToast } from "./showUndoToast"; | |
| 7 | import * as ThreadUndo from "./threadUndo"; | |
| 8 | ||
| 9 | afterEach(() => vi.restoreAllMocks()); | |
| 10 | ||
| 11 | function setup() { | |
| 12 | const add = vi.spyOn(toastManager, "add").mockReturnValue("undo-toast"); | |
| 13 | const close = vi.spyOn(toastManager, "close").mockImplementation(() => {}); | |
| 14 | const undo = vi.fn(async () => AsyncResult.success(undefined)); | |
| 15 | const claim = ThreadUndo.begin("pin", "env/thread"); | |
| 16 | const options = { | |
| 17 | title: "Thread unpinned", | |
| 18 | description: "Thread", | |
| 19 | failureTitle: "Restore failed", | |
| 20 | undo, | |
| 21 | claim, | |
| 22 | }; | |
| 23 | return { add, close, undo, claim, options }; | |
| 24 | } | |
| 25 | ||
| 26 | function click(add: ReturnType<typeof setup>["add"], index = 0) { | |
| 27 | const handler = add.mock.calls[index]?.[0].actionProps?.onClick; | |
| 28 | if (!handler) throw new Error("Undo action is missing"); | |
| 29 | return handler({} as Parameters<typeof handler>[0]); | |
| 30 | } | |
| 31 | ||
| 32 | describe("showUndoToast", () => { | |
| 33 | it("ignores a stale toast and lets the latest action run only once", async () => { | |
| 34 | const { add, close, undo, options } = setup(); | |
| 35 | showUndoToast(options); | |
| 36 | ThreadUndo.invalidate("pin", "env/thread"); | |
| 37 | showUndoToast({ ...options, claim: ThreadUndo.begin("pin", "env/thread") }); | |
| 38 | await click(add); | |
| 39 | expect(undo).not.toHaveBeenCalled(); | |
| 40 | await click(add, 1); | |
| 41 | await click(add, 1); | |
| 42 | expect(undo).toHaveBeenCalledOnce(); | |
| 43 | expect(close).toHaveBeenCalledExactlyOnceWith("undo-toast"); | |
| 44 | }); | |
| 45 | ||
| 46 | it("releases the claim on close and rejects a later click", async () => { | |
| 47 | const { add, undo, claim, options } = setup(); | |
| 48 | showUndoToast(options); | |
| 49 | add.mock.calls[0]?.[0].onClose?.(); | |
| 50 | expect(claim.isCurrent()).toBe(false); | |
| 51 | await click(add); | |
| 52 | expect(undo).not.toHaveBeenCalled(); | |
| 53 | }); | |
| 54 | ||
| 55 | it("does not show a toast for a late completion after a newer action", () => { | |
| 56 | const { add, options } = setup(); | |
| 57 | ThreadUndo.invalidate("pin", "env/thread"); | |
| 58 | showUndoToast(options); | |
| 59 | expect(add).not.toHaveBeenCalled(); | |
| 60 | }); | |
| 61 | ||
| 62 | it("reports a failed restore and releases its claim", async () => { | |
| 63 | const { add, claim, options } = setup(); | |
| 64 | showUndoToast({ | |
| 65 | ...options, | |
| 66 | undo: async () => AsyncResult.failure(Cause.fail(new Error("offline"))), | |
| 67 | }); | |
| 68 | await click(add); | |
| 69 | expect(add).toHaveBeenLastCalledWith( | |
| 70 | expect.objectContaining({ type: "error", title: "Restore failed", description: "offline" }), | |
| 71 | ); | |
| 72 | expect(claim.isCurrent()).toBe(false); | |
| 73 | }); | |
| 74 | ||
| 75 | it("reports a rejected restore promise", async () => { | |
| 76 | const { add, options } = setup(); | |
| 77 | showUndoToast({ | |
| 78 | ...options, | |
| 79 | undo: async () => { | |
| 80 | throw new Error("disconnected"); | |
| 81 | }, | |
| 82 | }); | |
| 83 | await click(add); | |
| 84 | expect(add).toHaveBeenLastCalledWith( | |
| 85 | expect.objectContaining({ type: "error", description: "disconnected" }), | |
| 86 | ); | |
| 87 | }); | |
| 88 | ||
| 89 | it("does not report interrupted restores as errors", async () => { | |
| 90 | const { add, options } = setup(); | |
| 91 | showUndoToast({ ...options, undo: async () => AsyncResult.failure(Cause.interrupt()) }); | |
| 92 | await click(add); | |
| 93 | expect(add).toHaveBeenCalledOnce(); | |
| 94 | }); | |
| 95 | }); |
apps/web/src/hooks/showUndoToast.ts created+61| ... | ... | @@ -0,0 +1,61 @@ |
| 1 | import { | |
| 2 | type AtomCommandResult, | |
| 3 | isAtomCommandInterrupted, | |
| 4 | squashAtomCommandFailure, | |
| 5 | } from "@t3tools/client-runtime/state/runtime"; | |
| 6 | ||
| 7 | import { stackedThreadToast, toastManager } from "../components/ui/toast"; | |
| 8 | import type * as ThreadUndo from "./threadUndo"; | |
| 9 | ||
| 10 | /** Shows a single-use Undo while its thread action still owns the claim. */ | |
| 11 | export function showUndoToast({ | |
| 12 | title, | |
| 13 | description, | |
| 14 | undo, | |
| 15 | failureTitle, | |
| 16 | claim, | |
| 17 | }: { | |
| 18 | title: string; | |
| 19 | description: string | undefined; | |
| 20 | undo: () => Promise<AtomCommandResult<unknown, unknown>>; | |
| 21 | failureTitle: string; | |
| 22 | claim: ReturnType<typeof ThreadUndo.begin>; | |
| 23 | }) { | |
| 24 | if (!claim.isCurrent()) return; | |
| 25 | let undoStarted = false; | |
| 26 | const reportFailure = (error: unknown) => { | |
| 27 | toastManager.add( | |
| 28 | stackedThreadToast({ | |
| 29 | type: "error", | |
| 30 | title: failureTitle, | |
| 31 | description: error instanceof Error ? error.message : "An error occurred.", | |
| 32 | }), | |
| 33 | ); | |
| 34 | }; | |
| 35 | const toastId = toastManager.add({ | |
| 36 | ...stackedThreadToast({ | |
| 37 | type: "success", | |
| 38 | title, | |
| 39 | description, | |
| 40 | timeout: 5_000, | |
| 41 | actionProps: { | |
| 42 | children: "Undo", | |
| 43 | onClick: async () => { | |
| 44 | if (undoStarted || !claim.isCurrent()) return; | |
| 45 | undoStarted = true; | |
| 46 | claim.finish(); | |
| 47 | toastManager.close(toastId); | |
| 48 | try { | |
| 49 | const result = await undo(); | |
| 50 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | |
| 51 | reportFailure(squashAtomCommandFailure(result)); | |
| 52 | } | |
| 53 | } catch (error) { | |
| 54 | reportFailure(error); | |
| 55 | } | |
| 56 | }, | |
| 57 | }, | |
| 58 | }), | |
| 59 | onClose: claim.finish, | |
| 60 | }); | |
| 61 | } |
apps/web/src/hooks/threadUndo.test.ts created+44| ... | ... | @@ -0,0 +1,44 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import * as ThreadUndo from "./threadUndo"; | |
| 4 | ||
| 5 | describe("thread action ownership", () => { | |
| 6 | it("keeps claims for different action kinds independent", () => { | |
| 7 | const pin = ThreadUndo.begin("pin", "env/shared"); | |
| 8 | const archive = ThreadUndo.begin("archive", "env/shared"); | |
| 9 | ThreadUndo.invalidate("pin", "env/shared"); | |
| 10 | expect(pin.isCurrent()).toBe(false); | |
| 11 | expect(archive.isCurrent()).toBe(true); | |
| 12 | archive.finish(); | |
| 13 | }); | |
| 14 | ||
| 15 | it("does not revive the first Undo after a later pin and unpin", () => { | |
| 16 | const firstUnpin = ThreadUndo.begin("pin", "env/thread"); | |
| 17 | ThreadUndo.invalidate("pin", "env/thread"); | |
| 18 | const secondUnpin = ThreadUndo.begin("pin", "env/thread"); | |
| 19 | expect(firstUnpin.isCurrent()).toBe(false); | |
| 20 | expect(secondUnpin.isCurrent()).toBe(true); | |
| 21 | firstUnpin.finish(); | |
| 22 | expect(secondUnpin.isCurrent()).toBe(true); | |
| 23 | secondUnpin.finish(); | |
| 24 | expect(firstUnpin.isCurrent()).toBe(false); | |
| 25 | }); | |
| 26 | ||
| 27 | it("rejects a late unpin completion after a newer pin started", () => { | |
| 28 | const pendingUnpin = ThreadUndo.begin("pin", "env/late"); | |
| 29 | ThreadUndo.invalidate("pin", "env/late"); | |
| 30 | expect(pendingUnpin.isCurrent()).toBe(false); | |
| 31 | }); | |
| 32 | ||
| 33 | it("expires an Undo without invalidating another environment or thread", () => { | |
| 34 | const first = ThreadUndo.begin("pin", "one/thread"); | |
| 35 | const otherEnvironment = ThreadUndo.begin("pin", "two/thread"); | |
| 36 | const otherThread = ThreadUndo.begin("pin", "one/other"); | |
| 37 | first.finish(); | |
| 38 | expect(first.isCurrent()).toBe(false); | |
| 39 | expect(otherEnvironment.isCurrent()).toBe(true); | |
| 40 | expect(otherThread.isCurrent()).toBe(true); | |
| 41 | otherEnvironment.finish(); | |
| 42 | otherThread.finish(); | |
| 43 | }); | |
| 44 | }); |
apps/web/src/hooks/threadUndo.ts created+21| ... | ... | @@ -0,0 +1,21 @@ |
| 1 | // Shared across hook instances so sidebar, header and menu actions invalidate each other. | |
| 2 | const currentActions = new Map<string, symbol>(); | |
| 3 | ||
| 4 | /** Claims one kind of thread action; a later claim of that kind expires its Undo. */ | |
| 5 | export function begin(kind: string, threadKey: string) { | |
| 6 | const key = JSON.stringify([kind, threadKey]); | |
| 7 | const token = Symbol(); | |
| 8 | currentActions.set(key, token); | |
| 9 | const isCurrent = () => currentActions.get(key) === token; | |
| 10 | return { | |
| 11 | isCurrent, | |
| 12 | finish: () => { | |
| 13 | if (isCurrent()) currentActions.delete(key); | |
| 14 | }, | |
| 15 | }; | |
| 16 | } | |
| 17 | ||
| 18 | /** Expires only this action kind, leaving unrelated thread actions intact. */ | |
| 19 | export function invalidate(kind: string, threadKey: string) { | |
| 20 | currentActions.delete(JSON.stringify([kind, threadKey])); | |
| 21 | } |
apps/web/src/hooks/useThreadActions.ts+21-2| ... | ... | @@ -42,6 +42,8 @@ import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; |
| 42 | 42 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| 43 | 43 | import { stackedThreadToast, toastManager } from "../components/ui/toast"; |
| 44 | 44 | import { useClientSettings } from "./useSettings"; |
| 45 | import * as ThreadUndo from "./threadUndo"; | |
| 46 | import { showUndoToast } from "./showUndoToast"; | |
| 45 | 47 | import { useAtomCommand } from "../state/use-atom-command"; |
| 46 | 48 | |
| 47 | 49 | export class ThreadArchiveBlockedError extends Schema.TaggedError<ThreadArchiveBlockedError>()( |
| ... | ... | @@ -582,6 +584,7 @@ export function useThreadActions() { |
| 582 | 584 | const orderKey = readEnvironmentSupportsPinReorder(target.environmentId) |
| 583 | 585 | ? (opts.orderKey ?? topOfPinnedRunOrderKey()) |
| 584 | 586 | : undefined; |
| 587 | ThreadUndo.invalidate("pin", scopedThreadKey(target)); | |
| 585 | 588 | return pinThreadMutation({ |
| 586 | 589 | environmentId: target.environmentId, |
| 587 | 590 | input: { |
| ... | ... | @@ -605,12 +608,27 @@ export function useThreadActions() { |
| 605 | 608 | ), |
| 606 | 609 | ); |
| 607 | 610 | } |
| 608 | return unpinThreadMutation({ | |
| 611 | const thread = readThreadShell(target); | |
| 612 | const orderKey = thread?.pinOrderKey ?? undefined; | |
| 613 | const action = ThreadUndo.begin("pin", scopedThreadKey(target)); | |
| 614 | const result = await unpinThreadMutation({ | |
| 609 | 615 | environmentId: target.environmentId, |
| 610 | 616 | input: { threadId: target.threadId }, |
| 611 | 617 | }); |
| 618 | if (result._tag === "Success" && action.isCurrent()) { | |
| 619 | showUndoToast({ | |
| 620 | title: "Thread unpinned", | |
| 621 | description: thread?.title, | |
| 622 | claim: action, | |
| 623 | undo: () => pinThread(target, orderKey === undefined ? {} : { orderKey }), | |
| 624 | failureTitle: "Failed to undo unpin", | |
| 625 | }); | |
| 626 | } else { | |
| 627 | action.finish(); | |
| 628 | } | |
| 629 | return result; | |
| 612 | 630 | }, |
| 613 | [unpinThreadMutation], | |
| 631 | [pinThread, unpinThreadMutation], | |
| 614 | 632 | ); |
| 615 | 633 | |
| 616 | 634 | const confirmAndUnpinThread = useCallback( |
| ... | ... | @@ -648,6 +666,7 @@ export function useThreadActions() { |
| 648 | 666 | ), |
| 649 | 667 | ); |
| 650 | 668 | } |
| 669 | ThreadUndo.invalidate("pin", scopedThreadKey(target)); | |
| 651 | 670 | return reorderPinnedThreadMutation({ |
| 652 | 671 | environmentId: target.environmentId, |
| 653 | 672 | input: { threadId: target.threadId, orderKey }, |
apps/web/src/hooks/useThreadActions.undo.test.ts created+69| ... | ... | @@ -0,0 +1,69 @@ |
| 1 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 2 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { useThreadActions } from "./useThreadActions"; | |
| 5 | import { threadEnvironment } from "../state/threads"; | |
| 6 | import { toastManager } from "../components/ui/toast"; | |
| 7 | ||
| 8 | const commands = vi.hoisted(() => ({ pin: vi.fn(), unpin: vi.fn() })); | |
| 9 | vi.mock("react", async (original) => ({ | |
| 10 | ...(await original<typeof import("react")>()), | |
| 11 | useCallback: (callback: unknown) => callback, | |
| 12 | useMemo: (create: () => unknown) => create(), | |
| 13 | useRef: (value: unknown) => ({ current: value }), | |
| 14 | })); | |
| 15 | vi.mock("@tanstack/react-router", () => ({ useRouter: () => ({}) })); | |
| 16 | vi.mock("./useSettings", () => ({ useClientSettings: () => false })); | |
| 17 | vi.mock("./useHandleNewThread", () => ({ useNewThreadHandler: () => vi.fn() })); | |
| 18 | vi.mock("../composerDraftStore", () => ({ useComposerDraftStore: () => vi.fn() })); | |
| 19 | vi.mock("../terminalUiStateStore", () => ({ useTerminalUiStateStore: () => vi.fn() })); | |
| 20 | vi.mock("../uiStateStore", () => ({ useUiStateStore: () => vi.fn() })); | |
| 21 | vi.mock("../state/entities", async (original) => ({ | |
| 22 | ...(await original<typeof import("../state/entities")>()), | |
| 23 | readEnvironmentSupportsPinning: () => true, | |
| 24 | readEnvironmentSupportsPinReorder: () => true, | |
| 25 | readThreadShell: () => ({ title: "Thread", pinOrderKey: "a0" }), | |
| 26 | })); | |
| 27 | vi.mock("../state/use-atom-command", () => ({ | |
| 28 | useAtomCommand: (command: unknown) => | |
| 29 | command === threadEnvironment.pin | |
| 30 | ? commands.pin | |
| 31 | : command === threadEnvironment.unpin | |
| 32 | ? commands.unpin | |
| 33 | : vi.fn(), | |
| 34 | })); | |
| 35 | ||
| 36 | afterEach(() => vi.restoreAllMocks()); | |
| 37 | ||
| 38 | describe("unpin Undo", () => { | |
| 39 | it("ignores an old toast across hook instances and still restores the latest unpin", async () => { | |
| 40 | commands.pin.mockResolvedValue({ _tag: "Success", value: undefined }); | |
| 41 | commands.unpin.mockResolvedValue({ _tag: "Success", value: undefined }); | |
| 42 | const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); | |
| 43 | vi.spyOn(toastManager, "close").mockImplementation(() => {}); | |
| 44 | const sidebar = useThreadActions(); | |
| 45 | const header = useThreadActions(); | |
| 46 | const target = { | |
| 47 | environmentId: EnvironmentId.make("undo-env"), | |
| 48 | threadId: ThreadId.make("thread"), | |
| 49 | }; | |
| 50 | await sidebar.unpinThread(target); | |
| 51 | const staleUndo = add.mock.calls[0]?.[0].actionProps?.onClick; | |
| 52 | await header.pinThread(target, { orderKey: "a1" }); | |
| 53 | await header.unpinThread(target); | |
| 54 | const latestUndo = add.mock.calls[1]?.[0].actionProps?.onClick; | |
| 55 | expect(staleUndo).toBeTypeOf("function"); | |
| 56 | expect(latestUndo).toBeTypeOf("function"); | |
| 57 | const event = {} as Parameters<NonNullable<typeof staleUndo>>[0]; | |
| 58 | staleUndo?.(event); | |
| 59 | expect(commands.pin).toHaveBeenCalledTimes(1); | |
| 60 | latestUndo?.(event); | |
| 61 | expect(commands.pin).toHaveBeenCalledTimes(2); | |
| 62 | expect(commands.pin).toHaveBeenLastCalledWith({ | |
| 63 | environmentId: target.environmentId, | |
| 64 | input: { threadId: target.threadId, orderKey: "a0" }, | |
| 65 | }); | |
| 66 | latestUndo?.(event); | |
| 67 | expect(commands.pin).toHaveBeenCalledTimes(2); | |
| 68 | }); | |
| 69 | }); |
docs/user/thread-sidebar.md+2| ... | ... | @@ -29,6 +29,8 @@ and worktree while you stay in the new thread composer. This requires a Git proj |
| 29 | 29 | |
| 30 | 30 | Pin a thread from its menu to keep it above your active work. |
| 31 | 31 | |
| 32 | On web and desktop, unpinning offers Undo for five seconds to restore the thread's pinned position. | |
| 33 | ||
| 32 | 34 | On web and desktop, you can also drag files from your computer onto any thread row: |
| 33 | 35 | the thread opens and the files are attached in its composer, ready for |
| 34 | 36 | your next message. The same per-message file limits apply as when attaching |