Skip to content
Source
Sign inSign up

useThreadActions.undo.test.ts

410 lines15.6 KB

1import { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import * as Cause from "effect/Cause";
3import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
4​
5import { useThreadActions } from "./useThreadActions";
6import { threadEnvironment } from "../state/threads";
7import { toastManager } from "../components/ui/toast";
8import { useThreadUndoNotice } from "./showThreadUndoNotice";
9import { announce } from "../lib/announce";
10​
11const commands = vi.hoisted(() => ({
12 pin: vi.fn(),
13 unpin: vi.fn(),
14 archive: vi.fn(),
15 unarchive: vi.fn(),
16 delete: vi.fn(),
17 settle: vi.fn(),
18 unsettle: vi.fn(),
19 snooze: vi.fn(),
20 unsnooze: vi.fn(),
21}));
22const router = vi.hoisted(() => {
23 const listeners = new Set<(event: { action: { type: string } }) => void>();
24 return {
25 navigate: vi.fn(async () => {}),
26 state: { location: { href: "/" }, matches: [{ params: {} as Record<string, string> }] },
27 history: {
28 listeners,
29 subscribe: (listener: (event: { action: { type: string } }) => void) => {
30 listeners.add(listener);
31 return () => listeners.delete(listener);
32 },
33 },
34 };
35});
36vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() }));
37vi.mock("../state/session", async (original) => ({
38 ...(await original<typeof import("../state/session")>()),
39 readEnvironmentScope: () => true,
40}));
41vi.mock("react", async (original) => ({
42 ...(await original<typeof import("react")>()),
43 useCallback: (callback: unknown) => callback,
44 useMemo: (create: () => unknown) => create(),
45 useRef: (value: unknown) => ({ current: value }),
46}));
47vi.mock("@tanstack/react-router", () => ({ useRouter: () => router }));
48vi.mock("./useSettings", () => ({ useClientSettings: () => false }));
49vi.mock("./useHandleNewThread", () => ({
50 useNewThreadHandler: () => async () => {
51 router.state.location.href = "/draft/project";
52 },
53}));
54vi.mock("../composerDraftStore", () => ({ useComposerDraftStore: () => vi.fn() }));
55vi.mock("../lib/composerDraftUploads", () => ({ releaseComposerDraftUploads: vi.fn() }));
56vi.mock("../terminalUiStateStore", () => ({ useTerminalUiStateStore: () => vi.fn() }));
57vi.mock("../uiStateStore", () => ({ useUiStateStore: () => vi.fn() }));
58vi.mock("../lib/announce", () => ({ announce: vi.fn(), cancelAnnouncement: vi.fn() }));
59vi.mock("../lib/archivedThreadsState", () => ({ refreshArchivedThreadsForEnvironment: vi.fn() }));
60const threadShell = vi.hoisted(() => ({
61 title: "Thread",
62 pinOrderKey: "a0",
63 pinnedAt: null as string | null,
64 snoozedUntil: null as string | null,
65 projectId: "project",
66 environmentId: "undo-env",
67 session: null,
68}));
69// Archived rows have no loaded shell, so deleting one takes the unresolved path.
70const shellLoaded = vi.hoisted(() => ({ current: true }));
71vi.mock("../state/entities", async (original) => ({
72 ...(await original<typeof import("../state/entities")>()),
73 readEnvironmentServerConfigLoaded: () => true,
74 readThreadShell: () => (shellLoaded.current ? threadShell : null),
75}));
76vi.mock("../state/use-atom-command", () => ({
77 useAtomCommand: (command: unknown) => {
78 switch (command) {
79 case threadEnvironment.pin:
80 return commands.pin;
81 case threadEnvironment.unpin:
82 return commands.unpin;
83 case threadEnvironment.archive:
84 return commands.archive;
85 case threadEnvironment.unarchive:
86 return commands.unarchive;
87 case threadEnvironment.delete:
88 return commands.delete;
89 case threadEnvironment.settle:
90 return commands.settle;
91 case threadEnvironment.unsettle:
92 return commands.unsettle;
93 case threadEnvironment.snooze:
94 return commands.snooze;
95 case threadEnvironment.unsnooze:
96 return commands.unsnooze;
97 default:
98 return vi.fn();
99 }
100 },
101}));
102​
103const target = {
104 environmentId: EnvironmentId.make("undo-env"),
105 threadId: ThreadId.make("thread"),
106};
107function currentUndo() {
108 const notice = useThreadUndoNotice.getState().notice;
109 expect(notice).not.toBeNull();
110 return notice!.undo;
112​
113beforeEach(() => {
114 vi.useFakeTimers();
115 for (const command of Object.values(commands)) {
116 command.mockReset().mockResolvedValue({ _tag: "Success", value: undefined });
117 }
118 router.navigate.mockClear();
119 router.state.location.href = "/";
120 router.state.matches[0]!.params = {};
121 threadShell.pinnedAt = null;
122 threadShell.snoozedUntil = null;
123 shellLoaded.current = true;
124});
125afterEach(() => {
126 vi.runAllTimers();
127 vi.useRealTimers();
128 vi.restoreAllMocks();
129});
130​
131describe("concurrent thread action Undo", () => {
132 it.each([
133 ["settleThread", "settle", "unsettle", "Settled"],
134 ["snoozeThread", "snooze", "unsnooze", "Snoozed"],
135 ["unpinThread", "unpin", "pin", "Unpinned"],
136 ["archiveThread", "archive", "unarchive", "Archived"],
137 ] as const)(
138 "%s retains one undo for concurrent successful requests",
139 async (handler, action, inverse, notice) => {
140 const actions = useThreadActions();
141 const run = () =>
142 handler === "snoozeThread"
143 ? actions.snoozeThread(target, "2099-01-01T09:00:00.000Z")
144 : actions[handler](target);
145 await Promise.all([run(), run()]);
146 expect(commands[action]).toHaveBeenCalledTimes(2);
147 expect(useThreadUndoNotice.getState().notice).toMatchObject({ action: notice, count: 1 });
148 await currentUndo()();
149 expect(commands[inverse]).toHaveBeenCalledOnce();
150 },
151 );
152});
153​
154describe("pin", () => {
155 it("announces that the thread was pinned", async () => {
156 vi.mocked(announce).mockClear();
157 await useThreadActions().pinThread(target);
158 expect(announce).toHaveBeenCalledExactlyOnceWith("Pinned 1 thread");
159 });
160});
161​
162describe("unpin Undo", () => {
163 it("restores the original snooze after restoring the pin", async () => {
164 threadShell.pinnedAt = "2026-01-01T00:00:00.000Z";
165 threadShell.snoozedUntil = "2030-01-01T09:00:00.000Z";
166 await useThreadActions().unpinThread(target);
167 vi.mocked(announce).mockClear();
168 await currentUndo()();
169 expect(announce).not.toHaveBeenCalledWith("Pinned 1 thread");
170 expect(commands.pin).toHaveBeenCalledExactlyOnceWith({
171 environmentId: target.environmentId,
172 input: { threadId: target.threadId, orderKey: "a0" },
173 });
174 expect(commands.snooze).toHaveBeenCalledExactlyOnceWith({
175 environmentId: target.environmentId,
176 input: { threadId: target.threadId, snoozedUntil: threadShell.snoozedUntil },
177 });
178 expect(commands.pin.mock.invocationCallOrder[0]).toBeLessThan(
179 commands.snooze.mock.invocationCallOrder[0]!,
180 );
181 });
182​
183 it("restores the pin without an already expired snooze", async () => {
184 threadShell.pinnedAt = "2026-01-01T00:00:00.000Z";
185 threadShell.snoozedUntil = "2000-01-01T09:00:00.000Z";
186 await useThreadActions().unpinThread(target);
187 await currentUndo()();
188 expect(commands.pin).toHaveBeenCalledOnce();
189 expect(commands.snooze).not.toHaveBeenCalled();
190 });
191​
192 it("ignores an old notice across hook instances and still restores the latest unpin", async () => {
193 const sidebar = useThreadActions();
194 const header = useThreadActions();
195 await sidebar.unpinThread(target);
196 const staleUndo = currentUndo();
197 await header.pinThread(target, { orderKey: "a1" });
198 await header.unpinThread(target);
199 const latestUndo = currentUndo();
200 await staleUndo();
201 expect(commands.pin).toHaveBeenCalledTimes(1);
202 await latestUndo();
203 expect(commands.pin).toHaveBeenCalledTimes(2);
204 expect(commands.pin).toHaveBeenLastCalledWith({
205 environmentId: target.environmentId,
206 input: { threadId: target.threadId, orderKey: "a0" },
207 });
208 await latestUndo();
209 expect(commands.pin).toHaveBeenCalledTimes(2);
210 });
211});
212​
213describe("archive Undo", () => {
214 it("unarchives and returns to the thread when archiving left it", async () => {
215 const add = vi.spyOn(toastManager, "add").mockReturnValue("toast");
216 router.state.matches[0]!.params = {
217 environmentId: target.environmentId,
218 threadId: target.threadId,
219 };
220 const actions = useThreadActions();
221 await actions.archiveThread(target);
222 expect(useThreadUndoNotice.getState().notice).toMatchObject({ action: "Archived", count: 1 });
223 expect(add).not.toHaveBeenCalled();
224 await currentUndo()();
225 expect(commands.unarchive).toHaveBeenCalledExactlyOnceWith({
226 environmentId: target.environmentId,
227 input: { threadId: target.threadId },
228 });
229 expect(router.navigate).toHaveBeenCalledWith(
230 expect.objectContaining({
231 to: "/$environmentId/$threadId",
232 params: { environmentId: target.environmentId, threadId: target.threadId },
233 }),
234 );
235 });
236});
237​
238describe("archive navigation", () => {
239 it.each([
240 { destination: "/undo-env/thread", expected: "/draft/project" },
241 { destination: "/undo-env/other-thread", expected: "/undo-env/other-thread" },
242 { destination: "/settings", expected: "/settings" },
243 ])("ends at $expected after opening $destination while archiving", async (route) => {
244 router.state.location.href = "/undo-env/thread";
245 router.state.matches[0]!.params = {
246 environmentId: target.environmentId,
247 threadId: target.threadId,
248 };
249 let finishArchive!: () => void;
250 commands.archive.mockReturnValue(
251 new Promise((resolve) => {
252 finishArchive = () => resolve({ _tag: "Success", value: undefined });
253 }),
254 );
255 const archived = useThreadActions().archiveThread(target);
256 router.state.location.href = route.destination;
257 finishArchive();
258 expect((await archived)._tag).toBe("Success");
259 expect(useThreadUndoNotice.getState().notice).toMatchObject({ action: "Archived" });
260 expect(router.state.location.href).toBe(route.expected);
261 });
262});
263​
264describe("delete navigation", () => {
265 it.each([
266 { type: "REPLACE", by: "the app's missing-thread redirect", fallsBack: true },
267 { type: "PUSH", by: "the user opening another thread", fallsBack: false },
268 ])("falls back: $fallsBack after $by while deleting", async ({ type, fallsBack }) => {
269 router.state.matches[0]!.params = {
270 environmentId: target.environmentId,
271 threadId: target.threadId,
272 };
273 commands.delete.mockImplementation(async () => {
274 for (const listener of router.history.listeners) listener({ action: { type } });
275 return { _tag: "Success", value: undefined };
276 });
277 expect((await useThreadActions().deleteThread(target))._tag).toBe("Success");
278 expect(router.navigate).toHaveBeenCalledTimes(fallsBack ? 1 : 0);
279 expect(router.history.listeners.size).toBe(0);
280 });
281});
282​
283describe("settle and snooze Undo", () => {
284 it("un-settles from the notice and expires the Undo after a manual un-settle", async () => {
285 const add = vi.spyOn(toastManager, "add").mockReturnValue("toast");
286 const actions = useThreadActions();
287 await actions.settleThread(target);
288 expect(useThreadUndoNotice.getState().notice).toMatchObject({ action: "Settled", count: 1 });
289 expect(add).not.toHaveBeenCalled();
290 const undo = currentUndo();
291 await actions.unsettleThread(target);
292 await undo();
293 expect(commands.unsettle).toHaveBeenCalledOnce();
294 });
295​
296 it("re-pins and re-snoozes a thread that settling had cleared", async () => {
297 const snoozedUntil = "2030-01-01T09:00:00.000Z";
298 threadShell.pinnedAt = "2026-01-01T00:00:00.000Z";
299 threadShell.snoozedUntil = snoozedUntil;
300 const actions = useThreadActions();
301 await actions.settleThread(target);
302 await currentUndo()();
303 expect(commands.unsettle).toHaveBeenCalledOnce();
304 expect(commands.pin).toHaveBeenCalledExactlyOnceWith({
305 environmentId: target.environmentId,
306 input: { threadId: target.threadId, orderKey: "a0" },
307 });
308 expect(commands.snooze).toHaveBeenCalledExactlyOnceWith({
309 environmentId: target.environmentId,
310 input: { threadId: target.threadId, snoozedUntil },
311 });
312 });
313​
314 it("stops settle restoration if restoring the pin fails", async () => {
315 threadShell.pinnedAt = "2026-01-01T00:00:00.000Z";
316 threadShell.snoozedUntil = "2030-01-01T09:00:00.000Z";
317 commands.pin.mockResolvedValue({ _tag: "Failure", cause: Cause.fail(new Error("Pin failed")) });
318 vi.spyOn(toastManager, "add").mockReturnValue("toast");
319 await useThreadActions().settleThread(target);
320 await currentUndo()();
321 expect(commands.unsettle).toHaveBeenCalledOnce();
322 expect(commands.pin).toHaveBeenCalledOnce();
323 expect(commands.snooze).not.toHaveBeenCalled();
324 });
325​
326 it("expires an older unpin Undo when the thread is settled", async () => {
327 const actions = useThreadActions();
328 await actions.unpinThread(target);
329 const staleUnpinUndo = currentUndo();
330 await actions.settleThread(target);
331 await staleUnpinUndo();
332 expect(commands.pin).not.toHaveBeenCalled();
333 });
334​
335 // regression: #12972 the sidebar undo notice must reverse a snooze
336 it("wakes the thread from the snooze notice", async () => {
337 const add = vi.spyOn(toastManager, "add").mockReturnValue("toast");
338 const actions = useThreadActions();
339 await actions.snoozeThread(target, "2100-01-01T00:00:00.000Z");
340 expect(useThreadUndoNotice.getState().notice).toMatchObject({ action: "Snoozed", count: 1 });
341 expect(add).not.toHaveBeenCalled();
342 await currentUndo()();
343 expect(commands.unsnooze).toHaveBeenCalledExactlyOnceWith({
344 environmentId: target.environmentId,
345 input: { threadId: target.threadId, reason: "user" },
346 });
347 });
348});
349​
350describe("archive and delete Undo expiry", () => {
351 const otherThread = { environmentId: target.environmentId, threadId: ThreadId.make("other") };
352 const otherEnvironment = {
353 environmentId: EnvironmentId.make("other-env"),
354 threadId: target.threadId,
355 };
356 const failure = { _tag: "Failure", cause: Cause.fail(new Error("Request failed")) };
357​
358 describe.each(["archiveThread", "deleteThread"] as const)("%s", (lifecycle) => {
359 it.each([
360 ["settleThread", "unsettle"],
361 ["snoozeThread", "unsnooze"],
362 ["unpinThread", "pin"],
363 ] as const)(
364 "expires an earlier %s Undo for that thread only, across hook instances",
365 async (handler, inverse) => {
366 const sidebar = useThreadActions();
367 const header = useThreadActions();
368 for (const ref of [otherThread, otherEnvironment, target]) {
369 await (handler === "snoozeThread"
370 ? sidebar.snoozeThread(ref, "2099-01-01T09:00:00.000Z")
371 : sidebar[handler](ref));
372 }
373 const staleUndo = currentUndo();
374 await header[lifecycle](target);
375 await staleUndo();
376 expect(commands[inverse]).toHaveBeenCalledTimes(2);
377 expect(commands[inverse]).not.toHaveBeenCalledWith(
378 expect.objectContaining({
379 environmentId: target.environmentId,
380 input: expect.objectContaining({ threadId: target.threadId }),
381 }),
382 );
383 },
384 );
385​
386 it("keeps the earlier Undo when the request fails", async () => {
387 const actions = useThreadActions();
388 await actions.settleThread(target);
389 commands[lifecycle === "archiveThread" ? "archive" : "delete"].mockResolvedValue(failure);
390 await actions[lifecycle](target);
391 expect(useThreadUndoNotice.getState().notice).toMatchObject({ action: "Settled" });
392 await currentUndo()();
393 expect(commands.unsettle).toHaveBeenCalledOnce();
394 });
395 });
396​
397 it.each([true, false])(
398 "expires the Archived Undo when deleting with shell loaded: %s",
399 async (loaded) => {
400 const actions = useThreadActions();
401 await actions.archiveThread(target);
402 const staleUndo = currentUndo();
403 shellLoaded.current = loaded;
404 await actions.deleteThread(target);
405 expect(useThreadUndoNotice.getState().notice).toBeNull();
406 await staleUndo();
407 expect(commands.unarchive).not.toHaveBeenCalled();
408 },
409 );
410});