authorgravatar for saphid@gmail.comAlex Southwell <saphid@gmail.com> 2026-09-21 15:15:42+10:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-20 22:15:42-07:00
log6b0a04ade021588dd9f1afbfa0e75cccdb7b2da4
tree806f495be9990aafdaf73ac017fd1b3800b32dfd
parentf391b88c3754047c3c9434b76328699c083723b0
signature Signed by PGP key B5690EEEBB952194

fix(web): offer undo after unpinning a thread (#10744)


7 files changed, 313 insertions(+), 2 deletions(-)

apps/web/src/hooks/showUndoToast.test.ts created+95
......@@ -0,0 +1,95 @@
1import { AsyncResult } from "effect/unstable/reactivity";
2import * as Cause from "effect/Cause";
3import { afterEach, describe, expect, it, vi } from "vite-plus/test";
4
5import { toastManager } from "../components/ui/toast";
6import { showUndoToast } from "./showUndoToast";
7import * as ThreadUndo from "./threadUndo";
8
9afterEach(() => vi.restoreAllMocks());
10
11function 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
26function 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
32describe("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 @@
1import {
2 type AtomCommandResult,
3 isAtomCommandInterrupted,
4 squashAtomCommandFailure,
5} from "@t3tools/client-runtime/state/runtime";
6
7import { stackedThreadToast, toastManager } from "../components/ui/toast";
8import type * as ThreadUndo from "./threadUndo";
9
10/** Shows a single-use Undo while its thread action still owns the claim. */
11export 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 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import * as ThreadUndo from "./threadUndo";
4
5describe("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.
2const currentActions = new Map<string, symbol>();
3
4/** Claims one kind of thread action; a later claim of that kind expires its Undo. */
5export 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. */
19export 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";
4242import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
4343import { stackedThreadToast, toastManager } from "../components/ui/toast";
4444import { useClientSettings } from "./useSettings";
45import * as ThreadUndo from "./threadUndo";
46import { showUndoToast } from "./showUndoToast";
4547import { useAtomCommand } from "../state/use-atom-command";
4648
4749export class ThreadArchiveBlockedError extends Schema.TaggedError<ThreadArchiveBlockedError>()(
......@@ -582,6 +584,7 @@ export function useThreadActions() {
582584 const orderKey = readEnvironmentSupportsPinReorder(target.environmentId)
583585 ? (opts.orderKey ?? topOfPinnedRunOrderKey())
584586 : undefined;
587 ThreadUndo.invalidate("pin", scopedThreadKey(target));
585588 return pinThreadMutation({
586589 environmentId: target.environmentId,
587590 input: {
......@@ -605,12 +608,27 @@ export function useThreadActions() {
605608 ),
606609 );
607610 }
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({
609615 environmentId: target.environmentId,
610616 input: { threadId: target.threadId },
611617 });
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;
612630 },
613 [unpinThreadMutation],
631 [pinThread, unpinThreadMutation],
614632 );
615633
616634 const confirmAndUnpinThread = useCallback(
......@@ -648,6 +666,7 @@ export function useThreadActions() {
648666 ),
649667 );
650668 }
669 ThreadUndo.invalidate("pin", scopedThreadKey(target));
651670 return reorderPinnedThreadMutation({
652671 environmentId: target.environmentId,
653672 input: { threadId: target.threadId, orderKey },
apps/web/src/hooks/useThreadActions.undo.test.ts created+69
......@@ -0,0 +1,69 @@
1import { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { afterEach, describe, expect, it, vi } from "vite-plus/test";
3
4import { useThreadActions } from "./useThreadActions";
5import { threadEnvironment } from "../state/threads";
6import { toastManager } from "../components/ui/toast";
7
8const commands = vi.hoisted(() => ({ pin: vi.fn(), unpin: vi.fn() }));
9vi.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}));
15vi.mock("@tanstack/react-router", () => ({ useRouter: () => ({}) }));
16vi.mock("./useSettings", () => ({ useClientSettings: () => false }));
17vi.mock("./useHandleNewThread", () => ({ useNewThreadHandler: () => vi.fn() }));
18vi.mock("../composerDraftStore", () => ({ useComposerDraftStore: () => vi.fn() }));
19vi.mock("../terminalUiStateStore", () => ({ useTerminalUiStateStore: () => vi.fn() }));
20vi.mock("../uiStateStore", () => ({ useUiStateStore: () => vi.fn() }));
21vi.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}));
27vi.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
36afterEach(() => vi.restoreAllMocks());
37
38describe("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
2929
3030Pin a thread from its menu to keep it above your active work.
3131
32On web and desktop, unpinning offers Undo for five seconds to restore the thread's pinned position.
33
3234On web and desktop, you can also drag files from your computer onto any thread row:
3335the thread opens and the files are attached in its composer, ready for
3436your next message. The same per-message file limits apply as when attaching