| 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,6 +42,8 @@ import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; |
| 42 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; | 42 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| 43 | import { stackedThreadToast, toastManager } from "../components/ui/toast"; | 43 | import { stackedThreadToast, toastManager } from "../components/ui/toast"; |
| 44 | import { useClientSettings } from "./useSettings"; | 44 | import { useClientSettings } from "./useSettings"; |
| 45 | import * as ThreadUndo from "./threadUndo"; | ||
| 46 | import { showUndoToast } from "./showUndoToast"; | ||
| 45 | import { useAtomCommand } from "../state/use-atom-command"; | 47 | import { useAtomCommand } from "../state/use-atom-command"; |
| 46 | 48 | ||
| 47 | export class ThreadArchiveBlockedError extends Schema.TaggedError<ThreadArchiveBlockedError>()( | 49 | export class ThreadArchiveBlockedError extends Schema.TaggedError<ThreadArchiveBlockedError>()( |
| ... | @@ -582,6 +584,7 @@ export function useThreadActions() { | ... | @@ -582,6 +584,7 @@ export function useThreadActions() { |
| 582 | const orderKey = readEnvironmentSupportsPinReorder(target.environmentId) | 584 | const orderKey = readEnvironmentSupportsPinReorder(target.environmentId) |
| 583 | ? (opts.orderKey ?? topOfPinnedRunOrderKey()) | 585 | ? (opts.orderKey ?? topOfPinnedRunOrderKey()) |
| 584 | : undefined; | 586 | : undefined; |
| 587 | ThreadUndo.invalidate("pin", scopedThreadKey(target)); | ||
| 585 | return pinThreadMutation({ | 588 | return pinThreadMutation({ |
| 586 | environmentId: target.environmentId, | 589 | environmentId: target.environmentId, |
| 587 | input: { | 590 | input: { |
| ... | @@ -605,12 +608,27 @@ export function useThreadActions() { | ... | @@ -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 | environmentId: target.environmentId, | 615 | environmentId: target.environmentId, |
| 610 | input: { threadId: target.threadId }, | 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 | const confirmAndUnpinThread = useCallback( | 634 | const confirmAndUnpinThread = useCallback( |
| ... | @@ -648,6 +666,7 @@ export function useThreadActions() { | ... | @@ -648,6 +666,7 @@ export function useThreadActions() { |
| 648 | ), | 666 | ), |
| 649 | ); | 667 | ); |
| 650 | } | 668 | } |
| 669 | ThreadUndo.invalidate("pin", scopedThreadKey(target)); | ||
| 651 | return reorderPinnedThreadMutation({ | 670 | return reorderPinnedThreadMutation({ |
| 652 | environmentId: target.environmentId, | 671 | environmentId: target.environmentId, |
| 653 | input: { threadId: target.threadId, orderKey }, | 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,6 +29,8 @@ and worktree while you stay in the new thread composer. This requires a Git proj |
| 29 | 29 | ||
| 30 | Pin a thread from its menu to keep it above your active work. | 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 | On web and desktop, you can also drag files from your computer onto any thread row: | 34 | On web and desktop, you can also drag files from your computer onto any thread row: |
| 33 | the thread opens and the files are attached in its composer, ready for | 35 | the thread opens and the files are attached in its composer, ready for |
| 34 | your next message. The same per-message file limits apply as when attaching | 36 | your next message. The same per-message file limits apply as when attaching |