| 1 | import { |
| 2 | CommandId, |
| 3 | EnvironmentId, |
| 4 | OrchestrationV2DispatchCommandError, |
| 5 | ThreadId, |
| 6 | } from "@t3tools/contracts"; |
| 7 | import * as Cause from "effect/Cause"; |
| 8 | import { AsyncResult } from "effect/reactivity"; |
| 9 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 10 | |
| 11 | import { useThreadActions } from "./useThreadActions"; |
| 12 | import { threadEnvironment } from "../state/threads"; |
| 13 | import { toastManager } from "../components/ui/toast"; |
| 14 | import { useThreadUndoNotice } from "./showThreadUndoNotice"; |
| 15 | import { archiveSelectedThreadEntries } from "../components/Sidebar.logic"; |
| 16 | |
| 17 | const resourceQueries = vi.hoisted(() => ({ |
| 18 | terminals: null as { threadId: string; hasRunningSubprocess: boolean }[] | null, |
| 19 | tabs: null as unknown[] | null, |
| 20 | })); |
| 21 | vi.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 | })); |
| 32 | vi.mock("../state/terminal", () => ({ |
| 33 | terminalEnvironment: { metadataSnapshot: () => "terminal-metadata" }, |
| 34 | })); |
| 35 | vi.mock("../state/preview", () => ({ previewEnvironment: { list: () => "preview-list" } })); |
| 36 | |
| 37 | const confirmation = vi.hoisted(() => vi.fn(async (_message: string, _options?: unknown) => true)); |
| 38 | vi.mock("../localApi", () => ({ readLocalApi: () => ({ dialogs: { confirm: confirmation } }) })); |
| 39 | const 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 | })); |
| 49 | const router = vi.hoisted(() => ({ |
| 50 | navigate: vi.fn(async () => {}), |
| 51 | state: { matches: [{ params: {} as Record<string, string> }] }, |
| 52 | })); |
| 53 | const newThread = vi.hoisted(() => |
| 54 | vi.fn<() => Promise<{ draftId: string; threadId: string } | null>>(async () => ({ |
| 55 | draftId: "draft", |
| 56 | threadId: "draft-thread", |
| 57 | })), |
| 58 | ); |
| 59 | vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() })); |
| 60 | vi.mock("../state/session", async (original) => ({ |
| 61 | ...(await original<typeof import("../state/session")>()), |
| 62 | readEnvironmentScope: () => true, |
| 63 | })); |
| 64 | vi.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 | })); |
| 70 | vi.mock("@tanstack/react-router", () => ({ useRouter: () => router })); |
| 71 | vi.mock("./useSettings", () => ({ useClientSettings: () => false })); |
| 72 | vi.mock("./useHandleNewThread", () => ({ useNewThreadHandler: () => newThread })); |
| 73 | vi.mock("../composerDraftStore", () => ({ useComposerDraftStore: () => vi.fn() })); |
| 74 | vi.mock("../terminalUiStateStore", () => ({ useTerminalUiStateStore: () => vi.fn() })); |
| 75 | vi.mock("../uiStateStore", () => ({ useUiStateStore: () => vi.fn() })); |
| 76 | vi.mock("../lib/archivedThreadsState", () => ({ refreshArchivedThreadsForEnvironment: vi.fn() })); |
| 77 | const 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 | })); |
| 87 | vi.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 | })); |
| 95 | vi.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 | |
| 120 | const target = { |
| 121 | environmentId: EnvironmentId.make("undo-env"), |
| 122 | threadId: ThreadId.make("thread"), |
| 123 | }; |
| 124 | function currentUndo() { |
| 125 | const notice = useThreadUndoNotice.getState().notice; |
| 126 | expect(notice).not.toBeNull(); |
| 127 | return notice!.undo; |
| 128 | } |
| 129 | |
| 130 | beforeEach(() => { |
| 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 | }); |
| 145 | afterEach(() => { |
| 146 | vi.runAllTimers(); |
| 147 | vi.useRealTimers(); |
| 148 | vi.restoreAllMocks(); |
| 149 | }); |
| 150 | |
| 151 | describe("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 | |
| 173 | describe("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 | |
| 341 | describe("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 | }); |