Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/mobile/src/features/home/thread-undo-notice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof ThreadUndo.begin>;
readonly undo: () => Promise<void>;
};
Expand Down
15 changes: 15 additions & 0 deletions apps/mobile/src/features/home/useThreadListActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
47 changes: 29 additions & 18 deletions apps/mobile/src/features/home/useThreadListActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<typeof ThreadUndo.begin>,
plan: ThreadUndo.ThreadUndoPlan,
) =>
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
12 changes: 9 additions & 3 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
];

Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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: [
Expand Down Expand Up @@ -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,
};
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ describe("resolveThreadListV2SnoozePresets", () => {

const presets = resolveThreadListV2SnoozePresets({
secondary: "snooze",
snoozed: false,
now: laterMinute,
presets: undefined,
});
Expand Down Expand Up @@ -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);
});

Expand Down
7 changes: 4 additions & 3 deletions apps/mobile/src/features/threads/threadListV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SnoozePresetSetting> | undefined;
}): ReadonlyArray<SnoozePreset> {
return input.secondary === "snooze"
return input.secondary === "snooze" || input.snoozed
? resolveSnoozePresets(new Date(input.now), presetOptions(input.presets))
: [];
}
Expand Down Expand Up @@ -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 =
Expand Down
38 changes: 38 additions & 0 deletions apps/web/src/components/Sidebar.snooze.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
12 changes: 12 additions & 0 deletions apps/web/src/components/Sidebar.snooze.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
}
5 changes: 4 additions & 1 deletion apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/components/threadActionMenu.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ describe("buildThreadActionMenuItems", () => {
"unpin",
"unsettle",
"unsnooze",
"snooze",
"rename",
"regenerate-title",
"auto-settle",
Expand All @@ -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,
Expand Down
41 changes: 22 additions & 19 deletions apps/web/src/components/threadActionMenu.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/contextMenuFallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<stri
{ tag: "line", attrs: { x1: "12", x2: "15", y1: "14", y2: "11" } },
{ tag: "circle", attrs: { cx: "12", cy: "14", r: "8" } },
],
"bell-ring": [
{ tag: "path", attrs: { d: "M10.268 21a2 2 0 0 0 3.464 0" } },
{ tag: "path", attrs: { d: "M22 8c0-2.3-.8-4.3-2-6" } },
{
tag: "path",
attrs: {
d: "M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326",
},
},
{ tag: "path", attrs: { d: "M4 2C2.8 3.7 2 5.7 2 8" } },
],
"chevron-right": [{ tag: "path", attrs: { d: "m9 19 7-7-7-7" } }],
"circle-check": [
{ tag: "circle", attrs: { cx: "12", cy: "12", r: "10" } },
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/hooks/showThreadUndoNotice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { primaryServerKeybindingsAtom } from "../state/server";
import * as ThreadUndo from "@t3tools/client-runtime/state/thread-undo";

type UndoOptions = {
action: "Settled" | "Snoozed" | "Unpinned" | "Archived" | "Discarded";
action: "Settled" | "Snoozed" | "Rescheduled" | "Unpinned" | "Archived" | "Discarded";
undo: () => Promise<AtomCommandResult<unknown, unknown>>;
failureTitle: string;
claim: ReturnType<typeof ThreadUndo.begin>;
Expand All @@ -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",
Expand Down
Loading
Loading