diff --git a/apps/mobile/src/features/home/thread-undo-notice.ts b/apps/mobile/src/features/home/thread-undo-notice.ts index 5fa1385297af1..ce5089bcd6263 100644 --- a/apps/mobile/src/features/home/thread-undo-notice.ts +++ b/apps/mobile/src/features/home/thread-undo-notice.ts @@ -2,7 +2,7 @@ import * as ThreadUndo from "@t3tools/client-runtime/state/thread-undo"; import { useSyncExternalStore } from "react"; type UndoNotice = { - readonly action: "Settled" | "Snoozed" | "Unpinned"; + readonly action: "Settled" | "Snoozed" | "Rescheduled" | "Unpinned"; readonly claim: ReturnType; readonly undo: () => Promise; }; diff --git a/apps/mobile/src/features/home/useThreadListActions.test.ts b/apps/mobile/src/features/home/useThreadListActions.test.ts index 135cf4e1d85a7..02ac1ad58dc10 100644 --- a/apps/mobile/src/features/home/useThreadListActions.test.ts +++ b/apps/mobile/src/features/home/useThreadListActions.test.ts @@ -517,6 +517,21 @@ describe("thread action undo", () => { }); }); + it("restores the previous wake time when undoing a reschedule", async () => { + const thread = makeThread({ + snoozedAt: "2026-09-02T00:00:00.000Z", + snoozedUntil: "2099-01-01T09:00:00.000Z", + }); + await useThreadListActions().snoozeThread(thread, "2099-01-02T09:00:00.000Z"); + expect(readThreadUndoNotice()?.action).toBe("Rescheduled"); + await readThreadUndoNotice()!.undo(); + expect(state.requests.map(({ action }) => action)).toEqual(["snooze", "snooze"]); + expect(state.requests[1]?.input).toEqual({ + threadId: thread.id, + snoozedUntil: "2099-01-01T09:00:00.000Z", + }); + }); + it.each([null, "2099-01-01T09:00:00.000Z", "2000-01-01T09:00:00.000Z"])( "restores an unpinned thread's previous position and unexpired snooze (%s)", async (snoozedUntil) => { diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index b100b6aa5e6da..e8b27e026a90f 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -2,7 +2,7 @@ import type { ThreadMoveDestination } from "../threads/threadOrder"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import * as ThreadUndo from "@t3tools/client-runtime/state/thread-undo"; import { showThreadUndoNotice } from "./thread-undo-notice"; -import { canSnooze } from "@t3tools/client-runtime/state/thread-settled"; +import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Haptics from "expo-haptics"; @@ -256,7 +256,7 @@ export function useThreadListActions(): { const archiveThread = (thread: EnvironmentThreadShell) => executeAction("archive", thread); const offerUndo = ( thread: EnvironmentThreadShell, - action: "Settled" | "Snoozed" | "Unpinned", + action: "Settled" | "Snoozed" | "Rescheduled" | "Unpinned", claim: ReturnType, plan: ThreadUndo.ThreadUndoPlan, ) => @@ -284,8 +284,12 @@ export function useThreadListActions(): { return true; } case "snooze": { - const failureTitle = - action === "Unpinned" ? "Could not undo unpin" : "Could not undo settle"; + const failureTitle = { + Settled: "Could not undo settle", + Snoozed: "Could not undo snooze", + Rescheduled: "Could not undo reschedule", + Unpinned: "Could not undo unpin", + }[action]; if (!checkThreadOperationPermission(thread, failureTitle)) return false; const result = await snoozeMutation({ environmentId: thread.environmentId, @@ -355,30 +359,37 @@ export function useThreadListActions(): { selectionHaptic(); const claim = ThreadUndo.begin("snooze", key); - const result = await withThreadDismissal( - key, - () => - snoozeMutation({ - environmentId: thread.environmentId, - input: { - threadId: thread.id, - snoozedUntil, - }, - }), - (result) => result._tag === "Success", - ); + const snooze = () => + snoozeMutation({ + environmentId: thread.environmentId, + input: { + threadId: thread.id, + snoozedUntil, + }, + }); + // A reschedule keeps the row on the snoozed shelf, so it must not + // play the exit animation that a fresh snooze uses. + const rescheduling = effectiveSnoozed(thread, { now: new Date().toISOString() }); + const result = rescheduling + ? await snooze() + : await withThreadDismissal(key, snooze, (result) => result._tag === "Success"); if (result._tag === "Failure") { claim.finish(); const error = Cause.squash(result.cause); Dialog.alert( - "Could not snooze thread", + rescheduling ? "Could not reschedule thread" : "Could not snooze thread", error instanceof Error && error.message.trim().length > 0 ? error.message : "The thread could not be snoozed.", ); return false; } - offerUndo(thread, "Snoozed", claim, ThreadUndo.planThreadUndo("snooze")); + offerUndo( + thread, + rescheduling ? "Rescheduled" : "Snoozed", + claim, + ThreadUndo.planThreadUndo("snooze", rescheduling ? thread : null), + ); return true; })(); snoozeInFlightThreadKeys.current.delete(key); diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 01bd49b028468..bbc22bb96c815 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -188,7 +188,7 @@ const SLIM_MENU_ACTIONS: MenuAction[] = [ ]; const SNOOZED_MENU_ACTIONS: MenuAction[] = [ - { id: "unsnooze", title: "Wake thread", image: "clock" }, + { id: "unsnooze", title: "Wake thread", image: "bell.badge" }, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ]; @@ -864,8 +864,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { snoozable: canSnooze(thread, { now: snoozeGateNow }), snoozed: snoozedRow, }); + // Snoozed rows keep the presets too: their menu offers Reschedule. const snoozePresets = resolveThreadListV2SnoozePresets({ secondary: swipeActions.secondary, + snoozed: snoozedRow, now: props.snoozePresetMinute || snoozeGateNow, presets: props.snoozePresets, }); @@ -976,7 +978,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { if (snoozedRow) { return layoutThreadListV2RowMenuActions({ nested, - direct: [...direct, SNOOZED_MENU_ACTIONS[0]!], + direct: [ + ...direct, + SNOOZED_MENU_ACTIONS[0]!, + { id: "snooze", title: "Reschedule", image: "clock", subactions: snoozePresetActions }, + ], reorder: [], pin: [], secondary: [ @@ -1124,7 +1130,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { if (swipeActions.primary === "unsnooze") { return { accessibilityLabel: `Wake ${thread.title} now`, - icon: "clock" as const, + icon: "bell.badge" as const, label: "Wake", onPress: handleUnsnooze, }; diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 7324f039cd11e..837a630c32fd0 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -92,6 +92,7 @@ describe("resolveThreadListV2SnoozePresets", () => { const presets = resolveThreadListV2SnoozePresets({ secondary: "snooze", + snoozed: false, now: laterMinute, presets: undefined, }); @@ -1450,6 +1451,8 @@ describe("threadListV2ListItemsAreEqual", () => { }; expect(rows(earlier).snoozeWakeLabelText).toBe("2h"); expect(rows(later).snoozeWakeLabelText).toBe("1h"); + // Snoozed rows show the snooze presets under Reschedule, so they keep the minute clock. + expect(rows(later).snoozePresetMinute).toBe(isoAt(BASE_MS + MINUTE_MS)); expect(threadListV2ListItemsAreEqual(rows(earlier), rows(later))).toBe(false); }); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 61652bd0c3599..ed6e5c9104d4d 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -84,10 +84,11 @@ export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooz */ export function resolveThreadListV2SnoozePresets(input: { readonly secondary: "snooze" | null; + readonly snoozed: boolean; readonly now: string; readonly presets: ReadonlyArray | undefined; }): ReadonlyArray { - return input.secondary === "snooze" + return input.secondary === "snooze" || input.snoozed ? resolveSnoozePresets(new Date(input.now), presetOptions(input.presets)) : []; } @@ -586,11 +587,11 @@ export function buildThreadListV2ListItems(input: { ? snoozeWakeLabel(item.thread.snoozedUntil, { now: input.snoozeLabelNow }) : undefined; // Put the clock on items so recycler equality limits ticks to rows displaying snooze preset times. + // Snoozed rows show the same presets under Reschedule. const snoozePresetMinute = - !item.snoozed && input.snoozeLabelNow !== undefined && (input.snoozeEnvironmentIds?.has(item.thread.environmentId) ?? true) && - canSnooze(item.thread, { now: input.snoozeLabelNow }) + (item.snoozed || canSnooze(item.thread, { now: input.snoozeLabelNow })) ? input.snoozeLabelNow : undefined; const move = diff --git a/apps/web/src/components/Sidebar.snooze.test.ts b/apps/web/src/components/Sidebar.snooze.test.ts new file mode 100644 index 0000000000000..90558bbb16f67 --- /dev/null +++ b/apps/web/src/components/Sidebar.snooze.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { rescheduleUndoTarget } from "./Sidebar.snooze"; + +describe("rescheduleUndoTarget", () => { + const now = new Date("2026-04-08T10:00:00Z"); + const awakeShell = { + snoozedUntil: null, + snoozedAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + session: null, + latestTurn: null, + }; + + it("returns the current wake time while the thread is still snoozed", () => { + expect( + rescheduleUndoTarget( + { ...awakeShell, snoozedUntil: "2026-04-09T09:00:00Z", snoozedAt: "2026-04-08T09:00:00Z" }, + now, + ), + ).toBe("2026-04-09T09:00:00Z"); + }); + + it("returns null for an awake thread so undo wakes instead", () => { + expect(rescheduleUndoTarget(awakeShell, now)).toBeNull(); + expect(rescheduleUndoTarget(null, now)).toBeNull(); + }); + + it("treats an already-passed wake time as awake", () => { + expect( + rescheduleUndoTarget( + { ...awakeShell, snoozedUntil: "2026-04-08T09:00:00Z", snoozedAt: "2026-04-07T09:00:00Z" }, + now, + ), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/components/Sidebar.snooze.ts b/apps/web/src/components/Sidebar.snooze.ts index 888f3a67ee3cb..9756e9ac2b19c 100644 --- a/apps/web/src/components/Sidebar.snooze.ts +++ b/apps/web/src/components/Sidebar.snooze.ts @@ -1,10 +1,12 @@ import type { EnvironmentId } from "@t3tools/contracts"; import type { TimestampFormat } from "@t3tools/contracts/settings"; import { + effectiveSnoozed, pickSnoozePreset as pickSharedSnoozePreset, resolveSnoozePresets as resolveSharedSnoozePresets, snoozeWakeLabel, type SnoozePreset, + type ThreadSnoozeShell, } from "@t3tools/client-runtime/state/thread-settled"; import { appAtomRegistry } from "../rpc/atomRegistry"; @@ -55,3 +57,13 @@ function snoozeOptions(timestampFormat: TimestampFormat, environmentId: Environm formatTime: (date: Date) => formatShortTimestamp(date.toISOString(), timestampFormat), }; } + +/** + * The wake time an Undo should restore when a snooze is a reschedule: the + * current wake time of an already-snoozed thread, or null when the thread + * is awake and Undo should wake it instead. + */ +export function rescheduleUndoTarget(shell: ThreadSnoozeShell | null, now: Date): string | null { + if (shell === null || !effectiveSnoozed(shell, { now: now.toISOString() })) return null; + return shell.snoozedUntil ?? null; +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 3314537973cbe..783b3592807e7 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2241,7 +2241,10 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve return { status: "skipped" } as const; } snoozingThreadKeysRef.current.add(threadKey); - const navigateAfterSnooze = planForwardNavigation(threadKey, opts.coSnoozingKeys); + // Rescheduling an already-snoozed thread parks nothing new, so you stay put. + const navigateAfterSnooze = snoozedThreadKeysRef.current.has(threadKey) + ? null + : planForwardNavigation(threadKey, opts.coSnoozingKeys); const result = await snoozeThread(threadRef, preset.snoozedUntil); snoozingThreadKeysRef.current.delete(threadKey); if (result._tag === "Failure") { diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index bd9dfdcdcc4b6..ff9eda5c2b99d 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -41,6 +41,7 @@ describe("buildThreadActionMenuItems", () => { "unpin", "unsettle", "unsnooze", + "snooze", "rename", "regenerate-title", "auto-settle", @@ -65,6 +66,18 @@ describe("buildThreadActionMenuItems", () => { }, ); + it("offers reschedule beside wake on a snoozed thread, with the same presets", () => { + const items = buildThreadActionMenuItems({ ...baseState, isSnoozed: true }); + const wakeIndex = items.findIndex((item) => item.id === "unsnooze"); + expect(items[wakeIndex]).toMatchObject({ label: "Wake thread" }); + expect(items[wakeIndex + 1]).toMatchObject({ id: "snooze", label: "Reschedule" }); + expect(items[wakeIndex + 1]?.children?.map((child) => child.id)).toEqual([ + "snooze:hour", + "snooze:custom", + ]); + expect(buildThreadActionMenuItems(baseState).map((item) => item.id)).not.toContain("unsnooze"); + }); + it("disables archive and turning into a project while the thread is running", () => { const items = buildThreadActionMenuItems({ ...baseState, diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index 1e3eddd0b9389..fb20c68ad463b 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -143,28 +143,31 @@ export function buildThreadActionMenuItems( : { id: "settle" as const, label: "Settle thread", icon: "circle-check" }, ] : []), + // A snoozed thread keeps the preset submenu as "Reschedule" so the wake + // time can move without waking the thread first. ...(state.supports.snooze ? [ - state.isSnoozed - ? { id: "unsnooze" as const, label: "Wake thread", icon: "clock" } - : { - id: "snooze" as const, - label: "Snooze", - icon: "clock", - disabled: !state.canSnoozeNow, - children: [ - ...state.snoozePresets.map((preset) => ({ - id: `snooze:${preset.id}` as const, - label: preset.label, - detail: preset.whenLabel, - })), - { - id: "snooze:custom" as const, - label: "Custom…", - separatorBefore: state.snoozePresets.length > 0, - }, - ], + ...(state.isSnoozed + ? [{ id: "unsnooze" as const, label: "Wake thread", icon: "bell-ring" }] + : []), + { + id: "snooze" as const, + label: state.isSnoozed ? "Reschedule" : "Snooze", + icon: "clock", + disabled: !state.canSnoozeNow, + children: [ + ...state.snoozePresets.map((preset) => ({ + id: `snooze:${preset.id}` as const, + label: preset.label, + detail: preset.whenLabel, + })), + { + id: "snooze:custom" as const, + label: "Custom…", + separatorBefore: state.snoozePresets.length > 0, }, + ], + }, ] : []), { id: "rename", label: "Rename thread", icon: "pencil", separatorBefore: true }, diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index 22c0c575b4c84..229b23ccfc2dc 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -14,6 +14,17 @@ const ICON_PATHS: Record Promise>; failureTitle: string; claim: ReturnType; @@ -31,6 +31,7 @@ export const useThreadUndoNotice = create<{ notice: UndoNotice | null }>(() => ( const UNDONE_ACTION = { Settled: "settle", Snoozed: "snooze", + Rescheduled: "reschedule", Unpinned: "unpin", Archived: "archive", Discarded: "discard", diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 9c5ae260923cd..253323515bfce 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -23,6 +23,7 @@ import { AsyncResult } from "effect/reactivity"; import { useRouter } from "@tanstack/react-router"; import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic"; +import { rescheduleUndoTarget } from "../components/Sidebar.snooze"; import { useComposerDraftStore } from "../composerDraftStore"; import { environmentSession, readEnvironmentScope } from "../state/session"; import { appAtomRegistry } from "../rpc/atomRegistry"; @@ -934,7 +935,8 @@ export function useThreadActions() { } const resolved = resolveThreadTarget(target); // oxlint-disable-next-line react/purity -- runs in an event handler (oxc#27309) - if (resolved && !canSnooze(resolved.thread, { now: new Date().toISOString() })) { + const now = new Date(); + if (resolved && !canSnooze(resolved.thread, { now: now.toISOString() })) { return AsyncResult.failure( Cause.fail( new ThreadSnoozeBlockedError({ @@ -944,6 +946,7 @@ export function useThreadActions() { ), ); } + const previousWake = rescheduleUndoTarget(resolved?.thread ?? null, now); const action = ThreadUndo.begin("snooze", scopedThreadKey(target)); const result = await snoozeThreadMutation({ environmentId: target.environmentId, @@ -953,11 +956,14 @@ export function useThreadActions() { action.finish(); return result; } + const undoPlan = ThreadUndo.planThreadUndo("snooze", { snoozedUntil: previousWake }); showThreadUndoNotice({ - action: "Snoozed", + action: previousWake ? "Rescheduled" : "Snoozed", claim: action, - undo: () => runUndoPlan(target, ThreadUndo.planThreadUndo("snooze")), - failureTitle: "Failed to wake thread", + undo: () => runUndoPlan(target, undoPlan), + failureTitle: previousWake + ? "Failed to restore the previous wake time" + : "Failed to wake thread", }); return result; }; diff --git a/apps/web/src/hooks/useThreadActions.undo.test.ts b/apps/web/src/hooks/useThreadActions.undo.test.ts index aa6bb76437c2c..a36a8c9785df8 100644 --- a/apps/web/src/hooks/useThreadActions.undo.test.ts +++ b/apps/web/src/hooks/useThreadActions.undo.test.ts @@ -109,6 +109,9 @@ const target = { environmentId: EnvironmentId.make("undo-env"), threadId: ThreadId.make("thread"), }; +const RESCHEDULE_NOW_MS = Date.parse("2026-04-08T10:00:00.000Z"); +const wakeIn = (offsetMs: number) => new Date(RESCHEDULE_NOW_MS + offsetMs).toISOString(); + function currentUndo() { const notice = useThreadUndoNotice.getState().notice; expect(notice).not.toBeNull(); @@ -381,6 +384,63 @@ describe("settle and snooze Undo", () => { input: { threadId: target.threadId, reason: "user" }, }); }); + + it("restores the previous wake time when undoing a reschedule", async () => { + vi.setSystemTime(RESCHEDULE_NOW_MS); + const previousWake = wakeIn(60_000); + const nextWake = wakeIn(120_000); + threadShell.snoozedUntil = previousWake; + const actions = useThreadActions(); + await actions.snoozeThread(target, nextWake); + expect(useThreadUndoNotice.getState().notice).toMatchObject({ + action: "Rescheduled", + count: 1, + }); + await currentUndo()(); + expect(commands.unsnooze).not.toHaveBeenCalled(); + expect(commands.snooze).toHaveBeenLastCalledWith({ + environmentId: target.environmentId, + input: { threadId: target.threadId, snoozedUntil: previousWake }, + }); + }); + + it("wakes the thread when undoing a reschedule after the previous wake time passed", async () => { + vi.setSystemTime(RESCHEDULE_NOW_MS); + const previousWake = wakeIn(60_000); + threadShell.snoozedUntil = previousWake; + const actions = useThreadActions(); + await actions.snoozeThread(target, wakeIn(3_600_000)); + const undo = currentUndo(); + commands.snooze.mockClear(); + vi.setSystemTime(RESCHEDULE_NOW_MS + 120_000); + await undo(); + expect(commands.snooze).not.toHaveBeenCalled(); + expect(commands.unsnooze).toHaveBeenCalledExactlyOnceWith({ + environmentId: target.environmentId, + input: { threadId: target.threadId, reason: "user" }, + }); + }); + + it("ignores an older reschedule Undo once a newer wake time was chosen", async () => { + vi.setSystemTime(RESCHEDULE_NOW_MS); + const firstWake = wakeIn(60_000); + const secondWake = wakeIn(120_000); + const thirdWake = wakeIn(180_000); + threadShell.snoozedUntil = firstWake; + const actions = useThreadActions(); + await actions.snoozeThread(target, secondWake); + const staleUndo = currentUndo(); + threadShell.snoozedUntil = secondWake; + await actions.snoozeThread(target, thirdWake); + commands.snooze.mockClear(); + await staleUndo(); + expect(commands.snooze).not.toHaveBeenCalled(); + await currentUndo()(); + expect(commands.snooze).toHaveBeenCalledExactlyOnceWith({ + environmentId: target.environmentId, + input: { threadId: target.threadId, snoozedUntil: secondWake }, + }); + }); }); describe("archive and delete Undo expiry", () => { diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 905f3a5bfbe9c..c3e94a1393900 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -333,7 +333,8 @@ selected, the options apply to every connected environment, and the mobile app u Choose **Snooze → Custom…** from a thread's menu to pick a date and time in your local time zone, or a duration in minutes, hours, or days. Durations start when you confirm; one day means 24 hours. On web and desktop, you can also snooze -several selected threads together. Choose **Wake thread** to bring a thread back early. +several selected threads together. Choose **Wake thread** to bring a thread back early, or +**Reschedule** from a snoozed thread's menu to move its wake time without waking it. A snoozed thread keeps working and stays hidden until its wake time, even while its agent wakes itself for background work. It comes back early for an approval, diff --git a/packages/client-runtime/src/state/threadUndo.ts b/packages/client-runtime/src/state/threadUndo.ts index 20ea56400c284..b7899e280697d 100644 --- a/packages/client-runtime/src/state/threadUndo.ts +++ b/packages/client-runtime/src/state/threadUndo.ts @@ -54,7 +54,12 @@ export function planThreadUndo( readonly snoozedUntil?: string | null; } | null, ): ThreadUndoPlan { - if (action === "snooze") return [{ action: "unsnooze" }]; + if (action === "snooze") { + // Undoing a reschedule restores the previous wake time rather than waking. + return thread?.snoozedUntil != null + ? [{ action: "snooze", snoozedUntil: thread.snoozedUntil }] + : [{ action: "unsnooze" }]; + } const pin: ThreadUndoStep = { action: "pin", ...(thread?.pinOrderKey != null ? { orderKey: thread.pinOrderKey } : {}), @@ -84,12 +89,15 @@ export async function executeThreadUndo( execute: (step: ThreadUndoStep) => Promise, succeeded: (result: Result) => boolean, ): Promise { - let result = await execute(plan[0]); + // A snooze can expire before Undo runs, and the server rejects an expired restore. + const expired = (step: ThreadUndoStep) => + // @effect-diagnostics-next-line globalDate:off + step.action === "snooze" && Date.parse(step.snoozedUntil) <= Date.now(); + // A reschedule whose previous wake time passed would have woken the thread by now. + let result = await execute(expired(plan[0]) ? { action: "unsnooze" } : plan[0]); for (const step of plan.slice(1)) { if (!succeeded(result)) return result; - // A snooze can expire before Undo runs, and the server rejects an expired restore. - // @effect-diagnostics-next-line globalDate:off - if (step.action === "snooze" && Date.parse(step.snoozedUntil) <= Date.now()) continue; + if (expired(step)) continue; result = await execute(step); } return result;