1import {
2 CommandId,
3 EnvironmentId,
4 OrchestrationV2DispatchCommandError,
5 ThreadId,
6} from "@t3tools/contracts";
7import * as Cause from "effect/Cause";
8import { AsyncResult } from "effect/reactivity";
9import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
10
11import { useThreadActions } from "./useThreadActions";
12import { threadEnvironment } from "../state/threads";
13import { toastManager } from "../components/ui/toast";
14import { useThreadUndoNotice } from "./showThreadUndoNotice";
15import { archiveSelectedThreadEntries } from "../components/Sidebar.logic";
16
17const resourceQueries = vi.hoisted(() => ({
18 terminals: null as { threadId: string; hasRunningSubprocess: boolean }[] | null,
19 tabs: null as unknown[] | null,
20}));
21vi.mock("@t3tools/client-runtime/state/runtime", async (original) => ({
22 ...(await original<typeof import("@t3tools/client-runtime/state/runtime")>()),
23 executeAtomQuery: async (_registry: unknown, atom: string) => {
24 const value =
25 atom === "terminal-metadata" ? resourceQueries.terminals : { sessions: resourceQueries.tabs };
26 return (atom === "terminal-metadata" ? resourceQueries.terminals : resourceQueries.tabs) ===
27 null
28 ? { _tag: "Failure", cause: {} }
29 : { _tag: "Success", value };
30 },
31}));
32vi.mock("../state/terminal", () => ({
33 terminalEnvironment: { metadataSnapshot: () => "terminal-metadata" },
34}));
35vi.mock("../state/preview", () => ({ previewEnvironment: { list: () => "preview-list" } }));
36
37const confirmation = vi.hoisted(() => vi.fn(async (_message: string, _options?: unknown) => true));
38vi.mock("../localApi", () => ({ readLocalApi: () => ({ dialogs: { confirm: confirmation } }) }));
39const commands = vi.hoisted(() => ({
40 pin: vi.fn(),
41 unpin: vi.fn(),
42 archive: vi.fn(),
43 unarchive: vi.fn(),
44 settle: vi.fn(),
45 unsettle: vi.fn(),
46 snooze: vi.fn(),
47 unsnooze: vi.fn(),
48}));
49const router = vi.hoisted(() => ({
50 navigate: vi.fn(async () => {}),
51 state: { matches: [{ params: {} as Record<string, string> }] },
52}));
53const newThread = vi.hoisted(() =>
54 vi.fn<() => Promise<{ draftId: string; threadId: string } | null>>(async () => ({
55 draftId: "draft",
56 threadId: "draft-thread",
57 })),
58);
59vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() }));
60vi.mock("../state/session", async (original) => ({
61 ...(await original<typeof import("../state/session")>()),
62 readEnvironmentScope: () => true,
63}));
64vi.mock("react", async (original) => ({
65 ...(await original<typeof import("react")>()),
66 useCallback: (callback: unknown) => callback,
67 useMemo: (create: () => unknown) => create(),
68 useRef: (value: unknown) => ({ current: value }),
69}));
70vi.mock("@tanstack/react-router", () => ({ useRouter: () => router }));
71vi.mock("./useSettings", () => ({ useClientSettings: () => false }));
72vi.mock("./useHandleNewThread", () => ({ useNewThreadHandler: () => newThread }));
73vi.mock("../composerDraftStore", () => ({ useComposerDraftStore: () => vi.fn() }));
74vi.mock("../terminalUiStateStore", () => ({ useTerminalUiStateStore: () => vi.fn() }));
75vi.mock("../uiStateStore", () => ({ useUiStateStore: () => vi.fn() }));
76vi.mock("../lib/archivedThreadsState", () => ({ refreshArchivedThreadsForEnvironment: vi.fn() }));
77const threadShell = vi.hoisted(() => ({
78 title: "Thread",
79 pinOrderKey: "a0",
80 pinnedAt: null as string | null,
81 snoozedUntil: null as string | null,
82 settledOverride: null as "settled" | null,
83 projectId: "project",
84 environmentId: "undo-env",
85 session: null,
86}));
87vi.mock("../state/entities", async (original) => ({
88 ...(await original<typeof import("../state/entities")>()),
89 readEnvironmentSupportsPinning: () => true,
90 readEnvironmentSupportsPinReorder: () => true,
91 readEnvironmentSupportsSettlement: () => true,
92 readEnvironmentSupportsSnooze: () => true,
93 readThreadShell: () => threadShell,
94}));
95vi.mock("../state/use-atom-command", () => ({
96 useAtomCommand: (command: unknown) => {
97 switch (command) {
98 case threadEnvironment.pin:
99 return commands.pin;
100 case threadEnvironment.unpin:
101 return commands.unpin;
102 case threadEnvironment.archive:
103 return commands.archive;
104 case threadEnvironment.unarchive:
105 return commands.unarchive;
106 case threadEnvironment.settle:
107 return commands.settle;
108 case threadEnvironment.unsettle:
109 return commands.unsettle;
110 case threadEnvironment.snooze:
111 return commands.snooze;
112 case threadEnvironment.unsnooze:
113 return commands.unsnooze;
114 default:
115 return vi.fn();
116 }
117 },
118}));
119
120const target = {
121 environmentId: EnvironmentId.make("undo-env"),
122 threadId: ThreadId.make("thread"),
123};
124function currentUndo() {
125 const notice = useThreadUndoNotice.getState().notice;
126 expect(notice).not.toBeNull();
127 return notice!.undo;
128}
129
130beforeEach(() => {
131 resourceQueries.terminals = null;
132 resourceQueries.tabs = null;
133 vi.useFakeTimers();
134 confirmation.mockReset().mockResolvedValue(true);
135 for (const command of Object.values(commands)) {
136 command.mockReset().mockResolvedValue({ _tag: "Success", value: undefined });
137 }
138 router.navigate.mockClear();
139 newThread.mockReset().mockResolvedValue({ draftId: "draft", threadId: "draft-thread" });
140 router.state.matches[0]!.params = {};
141 threadShell.pinnedAt = null;
142 threadShell.snoozedUntil = null;
143 threadShell.settledOverride = null;
144});
145afterEach(() => {
146 vi.runAllTimers();
147 vi.useRealTimers();
148 vi.restoreAllMocks();
149});
150
151describe("unpin Undo", () => {
152 it("ignores an old notice across hook instances and still restores the latest unpin", async () => {
153 const sidebar = useThreadActions();
154 const header = useThreadActions();
155 await sidebar.unpinThread(target);
156 const staleUndo = currentUndo();
157 await header.pinThread(target, { orderKey: "a1" });
158 await header.unpinThread(target);
159 const latestUndo = currentUndo();
160 await staleUndo();
161 expect(commands.pin).toHaveBeenCalledTimes(1);
162 await latestUndo();
163 expect(commands.pin).toHaveBeenCalledTimes(2);
164 expect(commands.pin).toHaveBeenLastCalledWith({
165 environmentId: target.environmentId,
166 input: { threadId: target.threadId, orderKey: "a0" },
167 });
168 await latestUndo();
169 expect(commands.pin).toHaveBeenCalledTimes(2);
170 });
171});
172
173describe("archive Undo", () => {
174 it("keeps a rejected current thread in place and groups Undo only for successful candidates", async () => {
175 const entries = ["first", target.threadId, "last"].map((id) => ({
176 threadRef: { ...target, threadId: ThreadId.make(id) },
177 threadKey: `${target.environmentId}:${id}`,
178 }));
179 const changed = AsyncResult.failure(
180 Cause.fail(
181 new OrchestrationV2DispatchCommandError({
182 commandId: CommandId.make("archive-condition-rejected"),
183 commandType: "thread.archive",
184 reason: "archive-condition-changed",
185 message: "The thread changed before automatic archival.",
186 }),
187 ),
188 );
189 commands.archive.mockResolvedValueOnce(AsyncResult.success(undefined));
190 commands.archive.mockResolvedValueOnce(changed);
191 commands.archive.mockResolvedValueOnce(AsyncResult.success(undefined));
192 router.state.matches[0]!.params = {
193 environmentId: target.environmentId,
194 threadId: target.threadId,
195 };
196 const { archiveThread } = useThreadActions();
197 const outcome = await archiveSelectedThreadEntries({
198 entries,
199 archive: ({ threadRef }, onArchived) =>
200 archiveThread(threadRef, { onArchived, snapshotSequence: 7 }),
201 });
202
203 expect(outcome.archivedThreadKeys).toEqual([entries[0]!.threadKey, entries[2]!.threadKey]);
204 expect(newThread).not.toHaveBeenCalled();
205 expect(router.navigate).not.toHaveBeenCalled();
206 expect(useThreadUndoNotice.getState().notice).toMatchObject({ action: "Archived", count: 2 });
207
208 await currentUndo()();
209
210 expect(commands.unarchive.mock.calls.map(([request]) => request.input.threadId)).toEqual([
211 "last",
212 "first",
213 ]);
214 expect(router.navigate).not.toHaveBeenCalled();
215 });
216
217 it("preserves navigation and Undo stays put when the reader leaves during archive", async () => {
218 let completeArchive!: () => void;
219 const pending = new Promise<{ _tag: "Success"; value: undefined }>((resolve) => {
220 completeArchive = () => resolve({ _tag: "Success", value: undefined });
221 });
222 commands.archive.mockReturnValueOnce(pending);
223 router.state.matches[0]!.params = {
224 environmentId: target.environmentId,
225 threadId: target.threadId,
226 };
227 const archiving = useThreadActions().archiveThread(target);
228 expect(commands.archive).toHaveBeenCalledOnce();
229 router.state.matches[0]!.params = {
230 environmentId: target.environmentId,
231 threadId: "another-thread",
232 };
233 completeArchive();
234 await archiving;
235
236 expect(newThread).not.toHaveBeenCalled();
237 expect(router.navigate).not.toHaveBeenCalled();
238 await currentUndo()();
239 expect(commands.unarchive).toHaveBeenCalledOnce();
240 expect(router.navigate).not.toHaveBeenCalled();
241 expect(router.state.matches[0]!.params.threadId).toBe("another-thread");
242 });
243
244 it("keeps archive Undo without return navigation when leaving the archived thread failed", async () => {
245 router.state.matches[0]!.params = {
246 environmentId: target.environmentId,
247 threadId: target.threadId,
248 };
249 newThread.mockRejectedValueOnce(new Error("Navigation failed"));
250 const result = await useThreadActions().archiveThread(target);
251
252 expect(result._tag).toBe("Failure");
253 expect(newThread).toHaveBeenCalledOnce();
254 expect(useThreadUndoNotice.getState().notice).toMatchObject({ action: "Archived", count: 1 });
255 await currentUndo()();
256 expect(commands.unarchive).toHaveBeenCalledOnce();
257 expect(router.navigate).not.toHaveBeenCalled();
258 });
259
260 it("does not return on Undo when draft creation declined navigation", async () => {
261 router.state.matches[0]!.params = {
262 environmentId: target.environmentId,
263 threadId: target.threadId,
264 };
265 newThread.mockResolvedValueOnce(null);
266 const result = await useThreadActions().archiveThread(target);
267
268 expect(result._tag).toBe("Success");
269 expect(newThread).toHaveBeenCalledOnce();
270 await currentUndo()();
271 expect(commands.unarchive).toHaveBeenCalledOnce();
272 expect(router.navigate).not.toHaveBeenCalled();
273 });
274
275 it("preserves each archive snapshot guard and restores a bulk notice together", async () => {
276 const actions = useThreadActions();
277 const second = { ...target, threadId: ThreadId.make("second-thread") };
278 await actions.archiveThread(target, { snapshotSequence: 17 });
279 await actions.archiveThread(second, { snapshotSequence: 42 });
280
281 expect(commands.archive).toHaveBeenNthCalledWith(1, {
282 environmentId: target.environmentId,
283 input: { threadId: target.threadId, snapshotSequence: 17 },
284 });
285 expect(commands.archive).toHaveBeenNthCalledWith(2, {
286 environmentId: second.environmentId,
287 input: { threadId: second.threadId, snapshotSequence: 42 },
288 });
289 expect(useThreadUndoNotice.getState().notice).toMatchObject({ action: "Archived", count: 2 });
290 await currentUndo()();
291 expect(commands.unarchive).toHaveBeenCalledTimes(2);
292 expect(commands.unarchive).toHaveBeenCalledWith({
293 environmentId: target.environmentId,
294 input: { threadId: target.threadId },
295 });
296 expect(commands.unarchive).toHaveBeenCalledWith({
297 environmentId: second.environmentId,
298 input: { threadId: second.threadId },
299 });
300 });
301
302 it("unarchives and returns to the thread when archiving left it", async () => {
303 const add = vi.spyOn(toastManager, "add").mockReturnValue("toast");
304 router.state.matches[0]!.params = {
305 environmentId: target.environmentId,
306 threadId: target.threadId,
307 };
308 const actions = useThreadActions();
309 await actions.archiveThread(target);
310 expect(useThreadUndoNotice.getState().notice).toMatchObject({ action: "Archived", count: 1 });
311 expect(add).not.toHaveBeenCalled();
312 await currentUndo()();
313 expect(commands.unarchive).toHaveBeenCalledExactlyOnceWith({
314 environmentId: target.environmentId,
315 input: { threadId: target.threadId },
316 });
317 expect(router.navigate).toHaveBeenCalledWith(
318 expect.objectContaining({
319 to: "/$environmentId/$threadId",
320 params: { environmentId: target.environmentId, threadId: target.threadId },
321 }),
322 );
323 });
324
325 it("stays put when the archived thread was not open", async () => {
326 const actions = useThreadActions();
327 await actions.archiveThread(target);
328 await currentUndo()();
329 expect(commands.unarchive).toHaveBeenCalledOnce();
330 expect(router.navigate).not.toHaveBeenCalled();
331 });
332
333 it("shows no Undo when the archive failed", async () => {
334 commands.archive.mockResolvedValue({ _tag: "Failure", cause: new Error("nope") });
335 const add = vi.spyOn(toastManager, "add").mockReturnValue("toast");
336 await useThreadActions().archiveThread(target);
337 expect(add).not.toHaveBeenCalled();
338 });
339});
340
341describe("settle and snooze Undo", () => {
342 it.each([
343 { terminals: [] },
344 { terminals: [{ threadId: "thread", hasRunningSubprocess: false }] },
345 ])("settles empty or shell-only terminals without confirmation", async ({ terminals }) => {
346 resourceQueries.terminals = terminals;
347 resourceQueries.tabs = [];
348 await useThreadActions().settleThread(target);
349 expect(confirmation).not.toHaveBeenCalled();
350 expect(commands.settle).toHaveBeenCalledOnce();
351 });
352
353 it.each([
354 {
355 terminals: [{ threadId: "thread", hasRunningSubprocess: true }],
356 tabs: [],
357 text: "Undo cannot restart them.",
358 },
359 { terminals: [], tabs: [{}], text: "Undo cannot restore page state." },
360 { terminals: null, tabs: [], text: "could not be checked" },
361 ])("warns accurately before settlement: $text", async ({ terminals, tabs, text }) => {
362 resourceQueries.terminals = terminals;
363 resourceQueries.tabs = tabs;
364 confirmation.mockResolvedValue(false);
365 await useThreadActions().settleThread(target);
366 expect(confirmation).toHaveBeenCalledExactlyOnceWith(expect.stringContaining(text), {
367 variant: "destructive",
368 });
369 expect(commands.settle).not.toHaveBeenCalled();
370 });
371
372 it("ignores commands belonging to a different chat", async () => {
373 resourceQueries.terminals = [{ threadId: "other-thread", hasRunningSubprocess: true }];
374 resourceQueries.tabs = [];
375 await useThreadActions().settleThread(target);
376 expect(confirmation).not.toHaveBeenCalled();
377 expect(commands.settle).toHaveBeenCalledOnce();
378 });
379
380 it("cancels without changing the chat or invalidating its existing Undo", async () => {
381 const actions = useThreadActions();
382 await actions.unpinThread(target);
383 const undo = currentUndo();
384 confirmation.mockResolvedValue(false);
385
386 const result = await actions.settleThread(target);
387
388 expect(result._tag).toBe("Failure");
389 expect(commands.settle).not.toHaveBeenCalled();
390 await undo();
391 expect(commands.pin).toHaveBeenCalledOnce();
392 expect(confirmation).toHaveBeenCalledExactlyOnceWith(
393 expect.stringContaining("Undo cannot restore commands or page state"),
394 { variant: "destructive" },
395 );
396 });
397
398 it("waits for confirmation before dispatching settlement", async () => {
399 let confirm!: (allowed: boolean) => void;
400 confirmation.mockImplementation(
401 () =>
402 new Promise((resolve) => {
403 confirm = resolve;
404 }),
405 );
406 const pending = useThreadActions().settleThread(target);
407 expect(commands.settle).not.toHaveBeenCalled();
408
409 await vi.waitFor(() => expect(confirmation).toHaveBeenCalledOnce());
410 confirm(true);
411 await pending;
412
413 expect(commands.settle).toHaveBeenCalledOnce();
414 });
415
416 it("does not warn or settle again when the chat is already settled", async () => {
417 threadShell.settledOverride = "settled";
418
419 await useThreadActions().settleThread(target);
420
421 expect(confirmation).not.toHaveBeenCalled();
422 expect(commands.settle).not.toHaveBeenCalled();
423 });
424
425 it("warns once before a confirmed batch and never again during Undo", async () => {
426 const actions = useThreadActions();
427 const second = { ...target, threadId: ThreadId.make("second-thread") };
428 const result = await actions.confirmThreadSettlement([target, second]);
429 expect(result).toMatchObject({ _tag: "Success", value: true });
430 expect(commands.settle).not.toHaveBeenCalled();
431
432 await actions.settleThread(target, { confirmed: true });
433 await actions.settleThread(second, { confirmed: true });
434 await currentUndo()();
435
436 expect(confirmation).toHaveBeenCalledOnce();
437 expect(confirmation.mock.calls[0]?.[0]).toContain("Settle 2 chats?");
438 expect(commands.settle).toHaveBeenCalledTimes(2);
439 });
440
441 it("un-settles from the notice and expires the Undo after a manual un-settle", async () => {
442 const add = vi.spyOn(toastManager, "add").mockReturnValue("toast");
443 const actions = useThreadActions();
444 await actions.settleThread(target);
445 expect(useThreadUndoNotice.getState().notice).toMatchObject({ action: "Settled", count: 1 });
446 expect(add).not.toHaveBeenCalled();
447 const undo = currentUndo();
448 await actions.unsettleThread(target);
449 await undo();
450 expect(commands.unsettle).toHaveBeenCalledOnce();
451 });
452
453 it("re-pins and re-snoozes a thread that settling had cleared", async () => {
454 const snoozedUntil = "2030-01-01T09:00:00.000Z";
455 threadShell.pinnedAt = "2026-01-01T00:00:00.000Z";
456 threadShell.snoozedUntil = snoozedUntil;
457 const actions = useThreadActions();
458 await actions.settleThread(target);
459 await currentUndo()();
460 expect(commands.unsettle).toHaveBeenCalledOnce();
461 expect(commands.pin).toHaveBeenCalledExactlyOnceWith({
462 environmentId: target.environmentId,
463 input: { threadId: target.threadId, orderKey: "a0" },
464 });
465 expect(commands.snooze).toHaveBeenCalledExactlyOnceWith({
466 environmentId: target.environmentId,
467 input: { threadId: target.threadId, snoozedUntil },
468 });
469 });
470
471 it("expires an older unpin Undo when the thread is settled", async () => {
472 const actions = useThreadActions();
473 await actions.unpinThread(target);
474 const staleUnpinUndo = currentUndo();
475 await actions.settleThread(target);
476 await staleUnpinUndo();
477 expect(commands.pin).not.toHaveBeenCalled();
478 });
479
480 it("wakes the thread from the snooze notice", async () => {
481 const add = vi.spyOn(toastManager, "add").mockReturnValue("toast");
482 const actions = useThreadActions();
483 await actions.snoozeThread(target, new Date(Date.now() + 60_000).toISOString());
484 expect(useThreadUndoNotice.getState().notice).toMatchObject({ action: "Snoozed", count: 1 });
485 expect(add).not.toHaveBeenCalled();
486 await currentUndo()();
487 expect(commands.unsnooze).toHaveBeenCalledExactlyOnceWith({
488 environmentId: target.environmentId,
489 input: { threadId: target.threadId, reason: "user" },
490 });
491 });
492});