diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 049cd2888962..e72ec09e41e0 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -32,6 +32,7 @@ import { GitCommitSheet } from "./features/threads/git/GitCommitSheet"; import { GitConfirmSheet } from "./features/threads/git/GitConfirmSheet"; import { GitOverviewSheet } from "./features/threads/git/GitOverviewSheet"; import { ThreadRouteScreen } from "./features/threads/ThreadRouteScreen"; +import { SnoozeForRouteScreen } from "./features/threads/SnoozeForRouteScreen"; import { ConnectionsRouteScreen } from "./features/connection/ConnectionsRouteScreen"; import { ConnectionsNewRouteScreen } from "./features/connection/ConnectionsNewRouteScreen"; import { HomeRouteScreen } from "./features/home/HomeRouteScreen"; @@ -376,6 +377,7 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([ "NewTaskSheet", "SettingsLegal", "SettingsSheet", + "SnoozeFor", "ThreadReviewComment", "ThreadSettingsSheet", ]); @@ -534,6 +536,22 @@ export const RootStack = createNativeStackNavigator({ sheetGrabberVisible: Platform.OS !== "android", }, }), + SnoozeFor: createNativeStackScreen({ + screen: SnoozeForRouteScreen, + linking: `${THREAD_LINKING_PREFIX}/snooze`, + options: { + gestureEnabled: true, + ...(Platform.OS === "android" + ? { presentation: "card" as const, headerShown: false } + : { + ...SHEET_SOLID_HEADER_OPTIONS, + presentation: "formSheet" as const, + sheetAllowedDetents: [0.55, 0.8], + sheetGrabberVisible: true, + title: "Snooze until", + }), + }, + }), ThreadFiles: createNativeStackScreen({ screen: ThreadFilesTreeScreen, linking: `${THREAD_LINKING_PREFIX}/files`, diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index c030e49c2b77..2b520bfc21a6 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -486,6 +486,16 @@ function AdaptiveWorkspaceLayoutContent( [navigation], ); + const handleSnoozeFor = useCallback( + (thread: EnvironmentThreadShell) => { + navigation.navigate("SnoozeFor", { + environmentId: String(thread.environmentId), + threadId: String(thread.id), + }); + }, + [navigation], + ); + const renderedSidebarWidth = useSharedValue( panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0, ); @@ -571,6 +581,7 @@ function AdaptiveWorkspaceLayoutContent( onOpenEnvironmentSettings={handleOpenEnvironmentSettings} onNewThreadInProject={handleNewThreadInProject} onNewThreadOnBranch={handleNewThreadOnBranch} + onSnoozeFor={handleSnoozeFor} onSelectThread={handleSelectThread} onSearchQueryChange={setPrimarySidebarSearchQuery} searchQuery={primarySidebarSearchQuery} diff --git a/apps/mobile/src/features/threads/SnoozeForRouteScreen.logic.test.ts b/apps/mobile/src/features/threads/SnoozeForRouteScreen.logic.test.ts new file mode 100644 index 000000000000..1aa62582c7e8 --- /dev/null +++ b/apps/mobile/src/features/threads/SnoozeForRouteScreen.logic.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + mergeAndroidPickerValue, + resolveAndroidMinimumDate, + resolveAndroidPickerValue, + resolveAndroidSnoozeValue, +} from "./SnoozeForRouteScreen.logic"; + +const wallTime = { + year: 2026, + month: 8, + day: 10, + hour: 13, + minute: 45, +} as const; + +describe("resolveAndroidPickerValue", () => { + it("seeds Material DatePicker with UTC midnight for the local calendar day", () => { + const originalTimezone = process.env.TZ; + try { + process.env.TZ = "America/Los_Angeles"; + + expect(resolveAndroidPickerValue(wallTime, "date").toISOString()).toBe( + "2026-08-10T00:00:00.000Z", + ); + const timeValue = resolveAndroidPickerValue(wallTime, "time"); + expect(timeValue.getHours()).toBe(13); + expect(timeValue.getMinutes()).toBe(45); + } finally { + if (originalTimezone === undefined) { + delete process.env.TZ; + } else { + process.env.TZ = originalTimezone; + } + } + }); +}); + +describe("resolveAndroidMinimumDate", () => { + it("allows the current local calendar day in western timezones", () => { + const originalTimezone = process.env.TZ; + try { + process.env.TZ = "America/Los_Angeles"; + const localEvening = new Date(2026, 7, 10, 20, 30); + + expect(localEvening.toISOString()).toContain("2026-08-11"); + expect(resolveAndroidMinimumDate(localEvening).toISOString()).toBe( + "2026-08-10T00:00:00.000Z", + ); + } finally { + if (originalTimezone === undefined) { + delete process.env.TZ; + } else { + process.env.TZ = originalTimezone; + } + } + }); +}); + +describe("mergeAndroidPickerValue", () => { + it("reads Material DatePicker calendar parts from UTC", () => { + const originalTimezone = process.env.TZ; + try { + process.env.TZ = "America/Los_Angeles"; + const selected = new Date(Date.UTC(2026, 7, 12)); + + const merged = mergeAndroidPickerValue(wallTime, selected, "date"); + + expect(selected.getDate()).toBe(11); + expect(merged).toEqual({ ...wallTime, day: 12 }); + } finally { + if (originalTimezone === undefined) { + delete process.env.TZ; + } else { + process.env.TZ = originalTimezone; + } + } + }); + + it("keeps the current local day when merging a selected time", () => { + const selected = new Date(2026, 7, 10, 18, 30); + + const merged = mergeAndroidPickerValue(wallTime, selected, "time"); + + expect(merged).toEqual({ ...wallTime, hour: 18, minute: 30 }); + }); + + it("preserves a nonexistent Android wall time so validation can reject it", () => { + const originalTimezone = process.env.TZ; + try { + process.env.TZ = "America/Los_Angeles"; + const current = { ...wallTime, month: 3, day: 8, hour: 1, minute: 30 }; + const selected = new Date(2000, 0, 15, 2, 30); + + const merged = mergeAndroidPickerValue(current, selected, "time"); + const result = resolveAndroidSnoozeValue(merged, { + now: new Date(2026, 2, 7, 12), + }); + + expect(merged).toEqual({ ...current, hour: 2, minute: 30 }); + expect(result).toEqual({ ok: false, error: "Choose a valid date and time." }); + } finally { + if (originalTimezone === undefined) { + delete process.env.TZ; + } else { + process.env.TZ = originalTimezone; + } + } + }); + + it("uses the still-future occurrence of a repeated Android wall time", () => { + const originalTimezone = process.env.TZ; + try { + process.env.TZ = "America/Los_Angeles"; + const current = { ...wallTime, month: 11, day: 1, hour: 0, minute: 30 }; + const selected = new Date(2000, 0, 15, 1, 30); + const firstOccurrence = new Date(2026, 10, 1, 1, 30); + const secondOccurrence = new Date(firstOccurrence.getTime() + 60 * 60 * 1_000); + + const merged = mergeAndroidPickerValue(current, selected, "time"); + const result = resolveAndroidSnoozeValue(merged, { + now: new Date(firstOccurrence.getTime() + 30 * 60 * 1_000), + }); + + expect(secondOccurrence.getHours()).toBe(1); + expect(result).toEqual({ ok: true, value: secondOccurrence }); + } finally { + if (originalTimezone === undefined) { + delete process.env.TZ; + } else { + process.env.TZ = originalTimezone; + } + } + }); +}); diff --git a/apps/mobile/src/features/threads/SnoozeForRouteScreen.logic.ts b/apps/mobile/src/features/threads/SnoozeForRouteScreen.logic.ts new file mode 100644 index 000000000000..5df00ad4e8ba --- /dev/null +++ b/apps/mobile/src/features/threads/SnoozeForRouteScreen.logic.ts @@ -0,0 +1,59 @@ +import { + resolveSnoozeWallTime, + type SnoozeWallTime, + type SnoozeWallTimeResult, +} from "@t3tools/client-runtime/state/thread-settled"; + +export type AndroidSnoozePicker = "date" | "time"; + +export function resolveAndroidPickerValue(value: SnoozeWallTime, part: AndroidSnoozePicker): Date { + if (part === "time") return new Date(2000, 0, 15, value.hour, value.minute); + return new Date(Date.UTC(value.year, value.month - 1, value.day)); +} + +export function resolveAndroidMinimumDate(now: Date): Date { + return new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate())); +} + +export function mergeAndroidPickerValue( + current: SnoozeWallTime, + selected: Date, + part: AndroidSnoozePicker, +): SnoozeWallTime { + if (part === "date") { + // Material DatePicker returns the chosen calendar day as UTC midnight. + return { + ...current, + year: selected.getUTCFullYear(), + month: selected.getUTCMonth() + 1, + day: selected.getUTCDate(), + }; + } + + return { ...current, hour: selected.getHours(), minute: selected.getMinutes() }; +} + +export function resolveAndroidSnoozeValue( + value: SnoozeWallTime, + options: { readonly now: Date }, +): SnoozeWallTimeResult { + return resolveSnoozeWallTime(value, options); +} + +export function formatAndroidSnoozeDate(value: SnoozeWallTime): string { + return new Date(Date.UTC(value.year, value.month - 1, value.day)).toLocaleDateString(undefined, { + weekday: "short", + month: "short", + day: "numeric", + year: "numeric", + timeZone: "UTC", + }); +} + +export function formatAndroidSnoozeTime(value: SnoozeWallTime): string { + return new Date(Date.UTC(2000, 0, 15, value.hour, value.minute)).toLocaleTimeString(undefined, { + hour: "numeric", + minute: "2-digit", + timeZone: "UTC", + }); +} diff --git a/apps/mobile/src/features/threads/SnoozeForRouteScreen.tsx b/apps/mobile/src/features/threads/SnoozeForRouteScreen.tsx new file mode 100644 index 000000000000..9cf35b60b194 --- /dev/null +++ b/apps/mobile/src/features/threads/SnoozeForRouteScreen.tsx @@ -0,0 +1,213 @@ +import DateTimePicker from "@expo/ui/community/datetime-picker"; +import { + resolveSnoozeForDefault, + snoozeWallTimeFromDate, + snoozeForTimeError, +} from "@t3tools/client-runtime/state/thread-settled"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import { useCallback, useRef, useState } from "react"; +import { Alert, Platform, Pressable, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; +import { AppText as Text } from "../../components/AppText"; +import { ConnectionSheetButton } from "../connection/ConnectionSheetButton"; +import { NativeStackScreenOptions } from "../../native/StackHeader"; +import { useThreadShell } from "../../state/entities"; +import { useThreadListActions } from "../home/useThreadListActions"; + +import { + formatAndroidSnoozeDate, + formatAndroidSnoozeTime, + mergeAndroidPickerValue, + resolveAndroidMinimumDate, + resolveAndroidPickerValue, + resolveAndroidSnoozeValue, + type AndroidSnoozePicker, +} from "./SnoozeForRouteScreen.logic"; + +type SnoozeForRouteParams = { + readonly environmentId: string; + readonly threadId: string; +}; + +export function SnoozeForRouteScreen({ route }: StaticScreenProps) { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const thread = useThreadShell({ + environmentId: EnvironmentId.make(route.params.environmentId), + threadId: ThreadId.make(route.params.threadId), + }); + const { snoozeThread } = useThreadListActions(); + const [values, setValues] = useState(() => { + const instant = resolveSnoozeForDefault(new Date()); + return { instant, androidWallTime: snoozeWallTimeFromDate(instant) }; + }); + const [androidPicker, setAndroidPicker] = useState(null); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const submittingRef = useRef(false); + + const handleValueChange = useCallback((selected: Date) => { + setValues((current) => ({ ...current, instant: selected })); + setError(null); + }, []); + + const handleSubmit = useCallback(async () => { + if (submittingRef.current) return; + + const now = new Date(); + let snoozeUntil = values.instant; + if (Platform.OS === "android") { + const result = resolveAndroidSnoozeValue(values.androidWallTime, { now }); + if (!result.ok) { + setError(result.error); + return; + } + snoozeUntil = result.value; + } else { + const validationError = snoozeForTimeError(snoozeUntil, { now }); + if (validationError !== null) { + setError(validationError); + return; + } + } + if (thread === null) { + Alert.alert( + "Could not snooze thread", + "This thread is no longer available. Return to the thread list and try again.", + ); + return; + } + + submittingRef.current = true; + setIsSubmitting(true); + let succeeded = false; + try { + succeeded = await snoozeThread(thread, snoozeUntil.toISOString()); + if (succeeded && navigation.isFocused()) { + navigation.goBack(); + } + } finally { + if (!succeeded) { + submittingRef.current = false; + setIsSubmitting(false); + } + } + }, [navigation, snoozeThread, thread, values]); + + return ( + + + {Platform.OS === "android" ? ( + navigation.goBack()} + /> + ) : null} + + + + + + + Wake this thread + + + The thread stays out of the inbox until this time, unless it needs you sooner. + + + + {Platform.OS === "ios" ? ( + handleValueChange(selected)} + style={{ alignSelf: "stretch" }} + value={values.instant} + /> + ) : ( + + setAndroidPicker("date")} + > + Date + + {formatAndroidSnoozeDate(values.androidWallTime)} + + + + setAndroidPicker("time")} + > + Time + + {formatAndroidSnoozeTime(values.androidWallTime)} + + + + )} + + {error !== null ? ( + + {error} + + ) : null} + + { + void handleSubmit(); + }} + tone="primary" + /> + + + + + {Platform.OS === "android" && androidPicker !== null ? ( + setAndroidPicker(null)} + onValueChange={(_event, selected) => { + setValues((current) => ({ + ...current, + androidWallTime: mergeAndroidPickerValue( + current.androidWallTime, + selected, + androidPicker, + ), + })); + setError(null); + setAndroidPicker(null); + }} + presentation="dialog" + value={resolveAndroidPickerValue(values.androidWallTime, androidPicker)} + /> + ) : null} + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index dce02cac1d4b..7caf418d934b 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -107,6 +107,7 @@ interface ThreadNavigationSidebarProps { readonly onOpenEnvironmentSettings: () => void; readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void; readonly onNewThreadInProject: (project: EnvironmentProject) => void; + readonly onSnoozeFor: (thread: EnvironmentThreadShell) => void; readonly onSearchQueryChange: (query: string) => void; readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onRequestVisibility: () => void; @@ -944,6 +945,7 @@ function ThreadNavigationSidebarPane( canMoveUp={pendingOrder === null && movePlanner(movedId, "up") !== null} canMoveDown={pendingOrder === null && movePlanner(movedId, "down") !== null} onSnoozeThread={snoozeThread} + onSnoozeFor={props.onSnoozeFor} onUnsnoozeThread={unsnoozeThread} onUnsettleThread={unsettleThread} onPinThread={pinThread} 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 a234147471c6..55ee4337a76b 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -11,6 +11,7 @@ import type { EnvironmentMachineKind } from "@t3tools/contracts"; import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort"; import type { MenuAction } from "@react-native-menu/menu"; +import { useNavigation } from "@react-navigation/native"; import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react"; import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; @@ -382,6 +383,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void; readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise; readonly onSnoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => void; + readonly onSnoozeFor?: (thread: EnvironmentThreadShell) => void; readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => void; readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; @@ -414,6 +416,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { typeof ThreadSwipeable >["simultaneousWithExternalGesture"]; }) { + const navigation = useNavigation(); const { width: windowWidth } = useWindowDimensions(); const { thread, @@ -508,12 +511,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { [props.snoozePresetMinute, swipeActions.secondary], ); const snoozePresetActions = useMemo( - () => - snoozePresets.map((preset) => ({ + () => [ + ...snoozePresets.map((preset) => ({ id: `snooze:${preset.id}`, title: preset.label, subtitle: preset.whenLabel, })), + { id: "snooze-for", title: "Until…" }, + ], [snoozePresets], ); // Pinned cards keep the full lifecycle menu; only the pin item flips to @@ -625,6 +630,16 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { if (nativeEvent.event === "archive") handleArchive(); if (nativeEvent.event === "regenerate-title") handleRegenerateTitle(); if (nativeEvent.event === "delete") handleDelete(); + if (nativeEvent.event === "snooze-for") { + if (props.onSnoozeFor) { + props.onSnoozeFor(thread); + } else { + navigation.navigate("SnoozeFor", { + environmentId: String(thread.environmentId), + threadId: String(thread.id), + }); + } + } const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({ event: nativeEvent.event, displayedPresets: snoozePresets, @@ -650,7 +665,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { handleUnpin, handleUnsettle, handleUnsnooze, + navigation, + props.onSnoozeFor, snoozePresets, + thread.environmentId, + thread.id, ], ); const primaryAction = useMemo(() => { diff --git a/apps/web/src/components/Sidebar.snooze.test.ts b/apps/web/src/components/Sidebar.snooze.test.ts index 16e17e4217eb..bfc7fd156ed1 100644 --- a/apps/web/src/components/Sidebar.snooze.test.ts +++ b/apps/web/src/components/Sidebar.snooze.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; -import { resolveSnoozePresets, snoozeWakeDescription } from "./Sidebar.snooze"; +import { + formatSnoozeForInput, + parseSnoozeForInput, + resolveSnoozePresets, + snoozeWakeDescription, +} from "./Sidebar.snooze"; // Local-time constructor so preset math is timezone-stable in tests. function localDate(year: number, month: number, day: number, hour: number, minute = 0): Date { @@ -93,4 +98,123 @@ describe("snoozeWakeDescription", () => { "18:00", ); }); + + it("uses calendar days across spring-forward and fall-back transitions", () => { + const originalTimezone = process.env.TZ; + try { + process.env.TZ = "America/Los_Angeles"; + expect( + snoozeWakeDescription( + localDate(2026, 3, 9, 0, 15).toISOString(), + localDate(2026, 3, 8, 12), + "24-hour", + ), + ).toMatch(/^tomorrow /); + expect( + snoozeWakeDescription( + localDate(2026, 11, 1, 23, 30).toISOString(), + localDate(2026, 11, 1, 12), + "24-hour", + ), + ).toMatch(/^\d{2}:\d{2}$/); + expect( + snoozeWakeDescription( + localDate(2026, 3, 15, 0, 15).toISOString(), + localDate(2026, 3, 8, 12), + "24-hour", + ), + ).toMatch(/^Mar 15, /); + } finally { + if (originalTimezone === undefined) { + delete process.env.TZ; + } else { + process.env.TZ = originalTimezone; + } + } + }); +}); + +describe("custom snooze browser input", () => { + it("round-trips local date and time without applying a UTC offset", () => { + const value = localDate(2026, 4, 8, 13, 45); + const input = formatSnoozeForInput(value); + const parsed = parseSnoozeForInput(input, { now: localDate(2026, 4, 8, 10) }); + + expect(input).toBe("2026-04-08T13:45"); + expect(parsed).toEqual({ ok: true, value }); + }); + + it("round-trips dates with more than four year digits", () => { + const value = localDate(10_000, 1, 1, 13, 45); + const input = formatSnoozeForInput(value); + + expect(input).toBe("10000-01-01T13:45"); + expect(parseSnoozeForInput(input, { now: localDate(9_999, 12, 31, 0) })).toEqual({ + ok: true, + value, + }); + }); + + it("rejects malformed, normalized, and expired local values", () => { + const now = localDate(2026, 4, 8, 10); + + expect(parseSnoozeForInput("", { now })).toEqual({ + ok: false, + error: "Choose a valid date and time.", + }); + expect(parseSnoozeForInput("2026-02-30T11:00", { now })).toEqual({ + ok: false, + error: "Choose a valid date and time.", + }); + expect(parseSnoozeForInput("2026-04-08T09:00", { now })).toEqual({ + ok: false, + error: "Choose a time in the future.", + }); + }); + + it("rejects a local wall time normalized through a daylight-saving gap", () => { + const normalized = localDate(2026, 3, 8, 2, 30); + // UTC and zones without a March DST transition have no gap to exercise. + if (normalized.getHours() === 2 && normalized.getMinutes() === 30) return; + + expect(parseSnoozeForInput("2026-03-08T02:30", { now: localDate(2026, 3, 7, 12) })).toEqual({ + ok: false, + error: "Choose a valid date and time.", + }); + }); + + it("chooses the future occurrence of a repeated local hour", () => { + const firstOccurrence = localDate(2026, 11, 1, 1, 30); + const secondOccurrence = new Date(firstOccurrence.getTime() + 60 * 60 * 1_000); + // UTC and zones without a one-hour November fallback have no repeat. + if (formatSnoozeForInput(secondOccurrence) !== "2026-11-01T01:30") return; + + expect( + parseSnoozeForInput("2026-11-01T01:30", { + now: new Date(firstOccurrence.getTime() + 30 * 60 * 1_000), + }), + ).toEqual({ ok: true, value: secondOccurrence }); + }); + + it("supports repeated local times from a 30-minute fallback", () => { + const originalTimezone = process.env.TZ; + try { + process.env.TZ = "Australia/Lord_Howe"; + const firstOccurrence = localDate(2026, 4, 5, 1, 45); + const secondOccurrence = new Date(firstOccurrence.getTime() + 30 * 60 * 1_000); + + expect(formatSnoozeForInput(secondOccurrence)).toBe("2026-04-05T01:45"); + expect( + parseSnoozeForInput("2026-04-05T01:45", { + now: new Date(firstOccurrence.getTime() + 15 * 60 * 1_000), + }), + ).toEqual({ ok: true, value: secondOccurrence }); + } finally { + if (originalTimezone === undefined) { + delete process.env.TZ; + } else { + process.env.TZ = originalTimezone; + } + } + }); }); diff --git a/apps/web/src/components/Sidebar.snooze.ts b/apps/web/src/components/Sidebar.snooze.ts index e7b980279a4b..4682e7faad0a 100644 --- a/apps/web/src/components/Sidebar.snooze.ts +++ b/apps/web/src/components/Sidebar.snooze.ts @@ -1,8 +1,10 @@ import type { TimestampFormat } from "@t3tools/contracts/settings"; import { + resolveSnoozeWallTime, resolveSnoozePresets as resolveSharedSnoozePresets, snoozeWakeLabel, type SnoozePreset, + type SnoozeWallTimeResult, } from "@t3tools/client-runtime/state/thread-settled"; import { formatShortTimestamp, parseTimestampDate } from "../timestampFormat"; @@ -10,6 +12,38 @@ import { formatShortTimestamp, parseTimestampDate } from "../timestampFormat"; export { snoozeWakeLabel, type SnoozePreset }; const DAY_MS = 24 * 60 * 60 * 1_000; +export type SnoozeForInputResult = SnoozeWallTimeResult; + +function padDatePart(value: number): string { + return String(value).padStart(2, "0"); +} + +/** Formats a Date for an HTML datetime-local input without losing local time. */ +export function formatSnoozeForInput(value: Date): string { + return [ + `${value.getFullYear()}-${padDatePart(value.getMonth() + 1)}-${padDatePart(value.getDate())}`, + `${padDatePart(value.getHours())}:${padDatePart(value.getMinutes())}`, + ].join("T"); +} + +/** Parses and resolves the browser's local calendar fields. */ +export function parseSnoozeForInput( + input: string, + options: { readonly now: Date }, +): SnoozeForInputResult { + const match = /^(\d{4,})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(input); + if (!match) return { ok: false, error: "Choose a valid date and time." }; + return resolveSnoozeWallTime( + { + year: Number(match[1]), + month: Number(match[2]), + day: Number(match[3]), + hour: Number(match[4]), + minute: Number(match[5]), + }, + options, + ); +} function timeOfDayLabel(date: Date, timestampFormat: TimestampFormat): string { return formatShortTimestamp(date.toISOString(), timestampFormat); @@ -45,9 +79,10 @@ export function snoozeWakeDescription( const wake = parseTimestampDate(snoozedUntil); if (wake === null) return ""; const time = timeOfDayLabel(wake, timestampFormat); - const startOfToday = new Date(now); - startOfToday.setHours(0, 0, 0, 0); - const dayDelta = Math.floor((wake.getTime() - startOfToday.getTime()) / DAY_MS); + const dayDelta = + (Date.UTC(wake.getFullYear(), wake.getMonth(), wake.getDate()) - + Date.UTC(now.getFullYear(), now.getMonth(), now.getDate())) / + DAY_MS; if (dayDelta === 0) return time; if (dayDelta === 1) return `tomorrow ${time}`; const weekday = wake.toLocaleDateString(undefined, { weekday: "short" }); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index cc3487b595ed..534a5e2ed3f9 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -103,6 +103,7 @@ import { isSameSidebarThreadRef, useSidebarPendingFileDropStore, } from "../sidebarPendingFileDropStore"; +import { openSnoozeForDialog } from "../snoozeForDialog"; import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; import { buildSidebarProjectSnapshots, @@ -201,12 +202,7 @@ import { type TerminalStatusIndicator, useLinkedThreadPullRequest, } from "./ThreadStatusIndicators"; -import { - resolveSnoozePresets, - snoozeWakeDescription, - snoozeWakeLabel, - type SnoozePreset, -} from "./Sidebar.snooze"; +import { resolveSnoozePresets, snoozeWakeDescription, snoozeWakeLabel } from "./Sidebar.snooze"; import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; @@ -233,6 +229,7 @@ import { SidebarContent, SidebarGroup, useSidebar } from "./ui/sidebar"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { SidebarHeaderIconButton, SidebarThreadHeader } from "./sidebar/SidebarThreadHeader"; import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; +import { Separator } from "./ui/separator"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; import { composerDraftHasUserContent, @@ -439,10 +436,11 @@ function SidebarThreadTooltip({ function SnoozePopoverButton(props: { open: boolean; onOpenChange: (open: boolean) => void; - onSnooze: (preset: SnoozePreset) => void; + onSnooze: (snoozedUntil: string) => void; + onSnoozeFor: () => void; timestampFormat: TimestampFormat; }) { - const { open, onOpenChange, onSnooze, timestampFormat } = props; + const { open, onOpenChange, onSnooze, onSnoozeFor, timestampFormat } = props; // Presets resolve at open time so "In 1 hour" is relative to the click, // not to when the row mounted. const presets = useMemo( @@ -479,7 +477,7 @@ function SnoozePopoverButton(props: { onClick={(event) => { event.stopPropagation(); onOpenChange(false); - onSnooze(preset); + onSnooze(preset.snoozedUntil); }} className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-foreground/90 hover:bg-accent hover:text-foreground" > @@ -489,6 +487,18 @@ function SnoozePopoverButton(props: { ))} + + ); @@ -999,7 +1009,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void; onSettle: (threadRef: ScopedThreadRef) => void; onUnsettle: (threadRef: ScopedThreadRef) => void; - onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void; + onSnooze: (threadRef: ScopedThreadRef, snoozedUntil: string) => void; + onSnoozeFor: (threadRef: ScopedThreadRef) => void; onUnsnooze: (threadRef: ScopedThreadRef) => void; onUnpin: (threadRef: ScopedThreadRef) => void; onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void; @@ -1020,6 +1031,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onRenameTitleChange, onSettle, onSnooze, + onSnoozeFor, onStartRename, onThreadActivate, onThreadClick, @@ -1332,12 +1344,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { }, [onUnpin, threadRef], ); - const handleSnoozePreset = useCallback( - (preset: SnoozePreset) => { - onSnooze(threadRef, preset); + const handleSnooze = useCallback( + (snoozedUntil: string) => { + onSnooze(threadRef, snoozedUntil); }, [onSnooze, threadRef], ); + const handleSnoozeFor = useCallback(() => onSnoozeFor(threadRef), [onSnoozeFor, threadRef]); // While the snooze popover is open the pointer leaves the row, which // would fade the hover actions out from under the open menu. Pin them and // suppress the row tooltip so its portal cannot overlap the popover. @@ -1868,7 +1881,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : null} @@ -3644,7 +3658,7 @@ export default function Sidebar() { const performSnooze = useCallback( async ( threadRef: ScopedThreadRef, - preset: SnoozePreset, + snoozedUntil: string, opts: { coSnoozingKeys?: ReadonlySet } = {}, ) => { const threadKey = scopedThreadKey(threadRef); @@ -3656,7 +3670,7 @@ export default function Sidebar() { // Snoozing the open thread moves you forward, same as settle — // both park the thread you're done with for now. const navigateAfterSnooze = planForwardNavigation(threadKey, opts.coSnoozingKeys); - const result = await snoozeThread(threadRef, preset.snoozedUntil); + const result = await snoozeThread(threadRef, snoozedUntil); if (result._tag === "Failure") { // Never navigate away from a thread that did not snooze. return isAtomCommandInterrupted(result) @@ -3676,42 +3690,49 @@ export default function Sidebar() { [planForwardNavigation, snoozeThread], ); const attemptSnooze = useCallback( - ( + async ( threadRef: ScopedThreadRef, - preset: SnoozePreset, + snoozedUntil: string, opts: { coSnoozingKeys?: ReadonlySet } = {}, ) => { - void (async () => { - const outcome = await performSnooze(threadRef, preset, opts); - if (outcome.status === "failure") { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to snooze thread", - description: - outcome.error instanceof Error ? outcome.error.message : "An error occurred.", - }), - ); - return; - } - if (outcome.status !== "success") return; - // Snooze hides the row, so the toast is the only confirmation — - // and the Undo is the escape hatch for a mis-click. + const outcome = await performSnooze(threadRef, snoozedUntil, opts); + if (outcome.status === "failure") { toastManager.add( stackedThreadToast({ - type: "success", - title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), timestampFormat)}`, - timeout: 5_000, - actionProps: { - children: "Undo", - onClick: () => attemptUnsnooze(threadRef), - }, + type: "error", + title: "Failed to snooze thread", + description: + outcome.error instanceof Error ? outcome.error.message : "An error occurred.", }), ); - })(); + return false; + } + if (outcome.status !== "success") return false; + // Snooze hides the row, so the toast is the only confirmation — + // and the Undo is the escape hatch for a mis-click. + toastManager.add( + stackedThreadToast({ + type: "success", + title: `Snoozed until ${snoozeWakeDescription(snoozedUntil, new Date(), timestampFormat)}`, + timeout: 5_000, + actionProps: { + children: "Undo", + onClick: () => attemptUnsnooze(threadRef), + }, + }), + ); + return true; }, [attemptUnsnooze, performSnooze, timestampFormat], ); + const openSnoozeForThread = useCallback( + (threadRef: ScopedThreadRef) => { + openSnoozeForDialog({ + onSnooze: (snoozedUntil) => attemptSnooze(threadRef, snoozedUntil), + }); + }, + [attemptSnooze], + ); const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); const handleMultiSelectContextMenu = useCallback( @@ -3764,6 +3785,61 @@ export default function Sidebar() { pinnedCount: pinnedSelectedThreads.length, }); const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat); + const snoozeSelectionUntil = async (snoozedUntil: string) => { + // Post-snooze navigation must skip threads snoozing in this same + // batch — they are all leaving the card block together. + const coSnoozingKeys = new Set(threadKeys); + clearSelection(); + const outcomes = await Promise.all( + selectedThreads.map(async (thread) => { + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const outcome = await performSnooze(threadRef, snoozedUntil, { coSnoozingKeys }); + return { outcome, threadRef }; + }), + ); + const snoozedThreadRefs = outcomes.flatMap(({ outcome, threadRef }) => + outcome.status === "success" ? [threadRef] : [], + ); + const failures = outcomes.flatMap(({ outcome }) => + outcome.status === "failure" ? [outcome.error] : [], + ); + + if (snoozedThreadRefs.length > 0) { + const snoozedCount = snoozedThreadRefs.length; + const failedCount = failures.length; + toastManager.add( + stackedThreadToast({ + type: failedCount > 0 ? "warning" : "success", + title: + failedCount > 0 + ? `Snoozed ${snoozedCount} of ${selectedThreads.length} threads` + : `Snoozed ${snoozedCount} thread${snoozedCount === 1 ? "" : "s"}`, + description: + failedCount > 0 + ? `${failedCount} thread${failedCount === 1 ? "" : "s"} couldn't be snoozed.` + : undefined, + timeout: 5_000, + actionProps: { + children: "Undo", + onClick: () => { + for (const threadRef of snoozedThreadRefs) attemptUnsnooze(threadRef); + }, + }, + }), + ); + } else if (failures.length > 0) { + const firstError = failures[0]; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to snooze threads", + description: firstError instanceof Error ? firstError.message : "An error occurred.", + }), + ); + } + // Partial successes already report failures and offer Undo for the completed subset. + return snoozedThreadRefs.length > 0; + }; const clicked = await settlePromise(() => api.contextMenu.show( [ @@ -3774,10 +3850,13 @@ export default function Sidebar() { { id: "snooze", label: `Snooze (${count})`, - children: snoozePresets.map((preset) => ({ - id: `snooze:${preset.id}`, - label: `${preset.label} (${preset.whenLabel})`, - })), + children: [ + ...snoozePresets.map((preset) => ({ + id: `snooze:${preset.id}`, + label: `${preset.label} (${preset.whenLabel})`, + })), + { id: "snooze-for", label: "Until…" }, + ], }, ] : []), @@ -3794,58 +3873,7 @@ export default function Sidebar() { (candidate) => `snooze:${candidate.id}` === clicked.value, ); if (preset) { - // Post-snooze navigation must skip threads snoozing in this same - // batch — they are all leaving the card block together. - const coSnoozingKeys = new Set(threadKeys); - clearSelection(); - const outcomes = await Promise.all( - selectedThreads.map(async (thread) => { - const threadRef = scopeThreadRef(thread.environmentId, thread.id); - const outcome = await performSnooze(threadRef, preset, { coSnoozingKeys }); - return { outcome, threadRef }; - }), - ); - const snoozedThreadRefs = outcomes.flatMap(({ outcome, threadRef }) => - outcome.status === "success" ? [threadRef] : [], - ); - const failures = outcomes.flatMap(({ outcome }) => - outcome.status === "failure" ? [outcome.error] : [], - ); - - if (snoozedThreadRefs.length > 0) { - const snoozedCount = snoozedThreadRefs.length; - const failedCount = failures.length; - toastManager.add( - stackedThreadToast({ - type: failedCount > 0 ? "warning" : "success", - title: - failedCount > 0 - ? `Snoozed ${snoozedCount} of ${selectedThreads.length} threads` - : `Snoozed ${snoozedCount} thread${snoozedCount === 1 ? "" : "s"}`, - description: - failedCount > 0 - ? `${failedCount} thread${failedCount === 1 ? "" : "s"} couldn't be snoozed.` - : undefined, - timeout: 5_000, - actionProps: { - children: "Undo", - onClick: () => { - for (const threadRef of snoozedThreadRefs) attemptUnsnooze(threadRef); - }, - }, - }), - ); - } else if (failures.length > 0) { - const firstError = failures[0]; - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to snooze threads", - description: - firstError instanceof Error ? firstError.message : "An error occurred.", - }), - ); - } + await snoozeSelectionUntil(preset.snoozedUntil); } return; } @@ -3857,6 +3885,13 @@ export default function Sidebar() { clearSelection(); return; } + if (clicked.value === "snooze-for") { + openSnoozeForDialog({ + threadCount: selectedThreads.length, + onSnooze: snoozeSelectionUntil, + }); + return; + } if (clicked.value === "regenerate-title") { for (const thread of regeneratableTitleThreads) { const result = await updateThreadMetadata({ @@ -4022,7 +4057,11 @@ export default function Sidebar() { const preset = snoozePresets.find( (candidate) => `snooze:${candidate.id}` === clicked.value, ); - if (preset) attemptSnooze(threadRef, preset); + if (preset) attemptSnooze(threadRef, preset.snoozedUntil); + return; + } + if (clicked.value === "snooze-for") { + openSnoozeForThread(threadRef); return; } switch (clicked.value) { @@ -4198,6 +4237,7 @@ export default function Sidebar() { markThreadUnread, openProjectSettings, projectByKey, + openSnoozeForThread, serverConfigs, startThreadRename, updateThreadMetadata, @@ -4675,6 +4715,7 @@ export default function Sidebar() { onSettle={attemptSettle} onUnsettle={attemptUnsettle} onSnooze={attemptSnooze} + onSnoozeFor={openSnoozeForThread} onUnsnooze={attemptUnsnooze} onUnpin={attemptUnpin} onAcknowledgeWoke={acknowledgeWoke} diff --git a/apps/web/src/components/SnoozeForDialog.test.tsx b/apps/web/src/components/SnoozeForDialog.test.tsx new file mode 100644 index 000000000000..0694476899ad --- /dev/null +++ b/apps/web/src/components/SnoozeForDialog.test.tsx @@ -0,0 +1,133 @@ +import type { ChangeEvent, FormEvent, ReactElement } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + closeSnoozeForDialog, + openSnoozeForDialog, + readSnoozeForDialogState, +} from "../snoozeForDialog"; +import { visitElements } from "../test/reactElementTree"; +import { reactHookHarness as hooks } from "../test/reactHookHarness"; + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + const { reactHookHarness } = await import("../test/reactHookHarness"); + return { + ...actual, + useRef: reactHookHarness.useRef, + useState: reactHookHarness.useState, + }; +}); + +vi.mock("react/compiler-runtime", async () => { + const { reactHookHarness } = await import("../test/reactHookHarness"); + return { c: reactHookHarness.useMemoCache }; +}); + +import { SnoozeForForm } from "./SnoozeForDialog"; + +const chosenTime = "2026-04-08T17:45"; + +function pendingSnooze() { + let resolve!: (succeeded: boolean) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +function renderForm() { + const request = readSnoozeForDialogState(); + if (request.status !== "open") throw new Error("Expected an open snooze dialog"); + hooks.beginRender(); + const tree = SnoozeForForm({ request }); + const form = visitElements(tree, (element) => element.type === "form") as ReactElement<{ + onSubmit: (event: FormEvent) => Promise; + }>; + const input = visitElements( + tree, + (element) => element.props.id === "snooze-for-time", + ) as ReactElement<{ + value: string; + disabled: boolean; + onChange: (event: ChangeEvent) => void; + }>; + return { + input: input.props, + error: visitElements(tree, (element) => element.props.id === "snooze-for-error")?.props + .children, + submit: () => form.props.onSubmit({ preventDefault() {} } as FormEvent), + }; +} + +function chooseTime() { + renderForm().input.onChange({ + currentTarget: { value: chosenTime }, + } as ChangeEvent); + return renderForm(); +} + +describe("custom snooze submission", () => { + beforeEach(() => { + hooks.reset(); + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 3, 8, 10)); + }); + + afterEach(() => { + closeSnoozeForDialog(); + vi.useRealTimers(); + }); + + it("waits for acceptance and ignores a second submission before React rerenders", async () => { + const pending = pendingSnooze(); + const onSnooze = vi.fn(() => pending.promise); + openSnoozeForDialog({ onSnooze }); + const form = chooseTime(); + + const submitted = form.submit(); + await form.submit(); + expect(onSnooze).toHaveBeenCalledExactlyOnceWith(new Date(2026, 3, 8, 17, 45).toISOString()); + expect(readSnoozeForDialogState().status).toBe("open"); + expect(renderForm().input.disabled).toBe(true); + + pending.resolve(true); + await submitted; + expect(readSnoozeForDialogState().status).toBe("idle"); + }); + + it.each([false, new Error("Server rejected the wake time")])( + "retains input after a rejected submission and allows retry: %s", + async (failure) => { + const onSnooze = vi.fn<() => Promise>(); + if (failure instanceof Error) onSnooze.mockRejectedValueOnce(failure); + else onSnooze.mockResolvedValueOnce(failure); + onSnooze.mockResolvedValueOnce(true); + openSnoozeForDialog({ onSnooze }); + + await chooseTime().submit(); + expect(readSnoozeForDialogState().status).toBe("open"); + const retry = renderForm(); + expect(retry.input.value).toBe(chosenTime); + expect(retry.input.disabled).toBe(false); + expect(retry.error).toEqual(expect.any(String)); + + await retry.submit(); + expect(onSnooze).toHaveBeenCalledTimes(2); + expect(readSnoozeForDialogState().status).toBe("idle"); + }, + ); + + it("does not close a newer dialog when a dismissed request finishes", async () => { + const pending = pendingSnooze(); + openSnoozeForDialog({ onSnooze: () => pending.promise }); + const submitted = chooseTime().submit(); + closeSnoozeForDialog(); + openSnoozeForDialog({ onSnooze: vi.fn(async () => true) }); + const newerRequest = readSnoozeForDialogState(); + + pending.resolve(true); + await submitted; + expect(readSnoozeForDialogState()).toBe(newerRequest); + }); +}); diff --git a/apps/web/src/components/SnoozeForDialog.tsx b/apps/web/src/components/SnoozeForDialog.tsx new file mode 100644 index 000000000000..017b11f96f39 --- /dev/null +++ b/apps/web/src/components/SnoozeForDialog.tsx @@ -0,0 +1,144 @@ +import { resolveSnoozeForDefault } from "@t3tools/client-runtime/state/thread-settled"; +import { type FormEvent, useEffect, useRef, useState, useSyncExternalStore } from "react"; + +import { + closeSnoozeForDialog, + readSnoozeForDialogState, + subscribeSnoozeForDialog, + type SnoozeForDialogState, +} from "../snoozeForDialog"; +import { formatSnoozeForInput, parseSnoozeForInput } from "./Sidebar.snooze"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; +import { Input } from "./ui/input"; +import { Label } from "./ui/label"; + +const FORM_ID = "snooze-for-form"; + +export function SnoozeForForm(props: { + readonly request: Extract; +}) { + const { request } = props; + const [input, setInput] = useState(() => + formatSnoozeForInput(resolveSnoozeForDefault(new Date())), + ); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const submittingRef = useRef(false); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (submittingRef.current) return; + // Re-read the clock here: a valid value can expire while the dialog is open. + const result = parseSnoozeForInput(input, { now: new Date() }); + if (!result.ok) { + setError(result.error); + return; + } + submittingRef.current = true; + setIsSubmitting(true); + setError(null); + let succeeded = false; + try { + succeeded = await request.onSnooze(result.value.toISOString()); + if (succeeded) { + closeSnoozeForDialog(request.id); + } else { + setError("Could not snooze. Check the selected time and try again."); + } + } catch (error) { + setError(error instanceof Error ? error.message : "Could not snooze. Try again."); + } finally { + if (!succeeded) { + submittingRef.current = false; + setIsSubmitting(false); + } + } + }; + + return ( + <> + + Snooze until + + Choose when {request.threadCount === 1 ? "this thread" : "these threads"} should return to + your inbox. + + + +
+ + { + setInput(event.currentTarget.value); + if (error) setError(null); + }} + /> + {error ? ( +

+ {error} +

+ ) : null} +
+
+ + + + + + ); +} + +export function SnoozeForDialogHost() { + const state = useSyncExternalStore( + subscribeSnoozeForDialog, + readSnoozeForDialogState, + readSnoozeForDialogState, + ); + const [closingRequest, setClosingRequest] = useState | null>(null); + + useEffect(() => { + if (state.status === "open") setClosingRequest(state); + }, [state]); + + const request = state.status === "open" ? state : closingRequest; + + return ( + { + if (!open) closeSnoozeForDialog(); + }} + onOpenChangeComplete={(open) => { + if (!open) setClosingRequest(null); + }} + > + + {request ? : null} + + + ); +} diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 1bdd04693759..f226c8fcc104 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -67,7 +67,8 @@ describe("buildThreadActionMenuItems", () => { (item) => item.id === "snooze", ); expect(snooze?.disabled).toBe(true); - expect(snooze?.children?.map((child) => child.id)).toEqual(["snooze:hour"]); + expect(snooze?.children?.map((child) => child.id)).toEqual(["snooze:hour", "snooze-for"]); + expect(snooze?.children?.at(-1)?.label).toBe("Until…"); }); it("disables title regeneration while one is in flight", () => { diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index 5ba266f7709d..31932ab205e4 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -15,6 +15,7 @@ export type ThreadActionMenuId = | "unsettle" | "snooze" | `snooze:${string}` + | "snooze-for" | "unsnooze" | "rename" | "regenerate-title" @@ -88,10 +89,13 @@ export function buildThreadActionMenuItems( label: "Snooze", icon: "clock", disabled: !state.canSnoozeNow, - children: state.snoozePresets.map((preset) => ({ - id: `snooze:${preset.id}` as const, - label: `${preset.label} (${preset.whenLabel})`, - })), + children: [ + ...state.snoozePresets.map((preset) => ({ + id: `snooze:${preset.id}` as const, + label: `${preset.label} (${preset.whenLabel})`, + })), + { id: "snooze-for" as const, label: "Until…" }, + ], }, ] : []), diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index a66ea21b9891..5780c650daed 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -34,6 +34,7 @@ import { selectProjectGroupingSettings, } from "../logicalProject"; import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping"; +import { openSnoozeForDialog } from "../snoozeForDialog"; import { useUiStateStore } from "../uiStateStore"; import { useCopyToClipboard } from "./useCopyToClipboard"; import { useNewThreadHandler } from "./useHandleNewThread"; @@ -151,20 +152,18 @@ export function useThreadActionMenu(input: { const clicked = await settlePromise(() => api.contextMenu.show(items, position)); if (clicked._tag === "Failure" || clicked.value === null) return; const action: ThreadActionMenuId = clicked.value; - if (action.startsWith("snooze:")) { - const preset = snoozePresets.find((candidate) => `snooze:${candidate.id}` === action); - if (!preset) return; - const result = await snoozeThread(threadRef, preset.snoozedUntil); + const snoozeUntil = async (snoozedUntil: string) => { + const result = await snoozeThread(threadRef, snoozedUntil); if (result._tag === "Failure") { if (!isAtomCommandInterrupted(result)) { failureToast("Failed to snooze thread", squashAtomCommandFailure(result)); } - return; + return false; } toastManager.add( stackedThreadToast({ type: "success", - title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), timestampFormat)}`, + title: `Snoozed until ${snoozeWakeDescription(snoozedUntil, new Date(), timestampFormat)}`, timeout: 5_000, actionProps: { children: "Undo", @@ -178,6 +177,18 @@ export function useThreadActionMenu(input: { }, }), ); + return true; + }; + if (action.startsWith("snooze:")) { + const preset = snoozePresets.find((candidate) => `snooze:${candidate.id}` === action); + if (!preset) return; + await snoozeUntil(preset.snoozedUntil); + return; + } + if (action === "snooze-for") { + openSnoozeForDialog({ + onSnooze: snoozeUntil, + }); return; } const reportFailure = async ( diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 89a1ff23cc83..17eb110d4cfe 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -25,6 +25,7 @@ import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstall import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; import { SnapShotCoordinator } from "../components/desktop/SnapShotCoordinator"; import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator"; +import { SnoozeForDialogHost } from "../components/SnoozeForDialog"; import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator"; import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; @@ -221,6 +222,7 @@ function RootRouteView() { + {primaryEnvironmentAuthenticated ? ( diff --git a/apps/web/src/snoozeForDialog.ts b/apps/web/src/snoozeForDialog.ts new file mode 100644 index 000000000000..62c06862d9fa --- /dev/null +++ b/apps/web/src/snoozeForDialog.ts @@ -0,0 +1,46 @@ +export interface SnoozeForDialogRequest { + readonly threadCount?: number; + readonly onSnooze: (snoozedUntil: string) => Promise; +} + +export type SnoozeForDialogState = + | { readonly status: "idle" } + | { + readonly status: "open"; + readonly id: number; + readonly threadCount: number; + readonly onSnooze: SnoozeForDialogRequest["onSnooze"]; + }; + +const idleState: SnoozeForDialogState = { status: "idle" }; +let state: SnoozeForDialogState = idleState; +let nextId = 1; +const listeners = new Set<() => void>(); + +function publish(next: SnoozeForDialogState): void { + state = next; + for (const listener of listeners) listener(); +} + +export function readSnoozeForDialogState(): SnoozeForDialogState { + return state; +} + +export function subscribeSnoozeForDialog(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function openSnoozeForDialog(request: SnoozeForDialogRequest): void { + publish({ + status: "open", + id: nextId++, + threadCount: request.threadCount ?? 1, + onSnooze: request.onSnooze, + }); +} + +export function closeSnoozeForDialog(requestId?: number): void { + if (requestId !== undefined && (state.status !== "open" || state.id !== requestId)) return; + publish(idleState); +} diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 9434e5996614..015d4accde27 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -125,3 +125,9 @@ On web and desktop, use **Agents** to follow work delegated to subagents. Expand a tool call in the conversation to see its full command and output. Summaries shorten shell wrappers and can still describe the latest call after it finishes; the call's own result shows its status. + +## Snoozing threads + +Snooze a thread with a preset, or choose **Until…** to set a specific date and time. The thread stays +out of your inbox until then, unless it finishes or needs you sooner. Use **Undo** after snoozing, +or expand the Snoozed section and choose **Wake** to bring it back immediately. diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index f5209a09e499..c73b81bce0c2 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -168,6 +168,8 @@ export function threadWokeAt( } const HOUR_MS = 60 * 60 * 1_000; +const MINUTE_MS = 60 * 1_000; +const SNOOZE_FOR_STEP_MS = 15 * 60 * 1_000; const EVENING_HOUR = 18; const MORNING_HOUR = 9; @@ -183,6 +185,95 @@ export interface SnoozePreset { readonly snoozedUntil: string; } +export interface SnoozeWallTime { + readonly year: number; + /** One-based calendar month. */ + readonly month: number; + readonly day: number; + readonly hour: number; + readonly minute: number; +} + +export type SnoozeWallTimeResult = + | { readonly ok: true; readonly value: Date } + | { readonly ok: false; readonly error: string }; + +/** + * Initial value for a custom snooze. Rounding the absolute instant avoids + * manufacturing a nonexistent local wall time at a daylight-saving edge. + */ +export function resolveSnoozeForDefault(now: Date): Date { + return new Date(Math.ceil((now.getTime() + HOUR_MS) / SNOOZE_FOR_STEP_MS) * SNOOZE_FOR_STEP_MS); +} + +/** Shared validation copy for custom snooze forms on every client. */ +export function snoozeForTimeError(value: Date, options: { readonly now: Date }): string | null { + if (Number.isNaN(value.getTime())) return "Choose a valid date and time."; + if (value.getTime() <= options.now.getTime()) return "Choose a time in the future."; + return null; +} + +export function snoozeWallTimeFromDate(value: Date): SnoozeWallTime { + return { + year: value.getFullYear(), + month: value.getMonth() + 1, + day: value.getDate(), + hour: value.getHours(), + minute: value.getMinutes(), + }; +} + +function matchesSnoozeWallTime(value: Date, wallTime: SnoozeWallTime): boolean { + return ( + value.getFullYear() === wallTime.year && + value.getMonth() === wallTime.month - 1 && + value.getDate() === wallTime.day && + value.getHours() === wallTime.hour && + value.getMinutes() === wallTime.minute + ); +} + +/** + * Resolves local calendar fields without accepting Date's DST-gap normalization. + * When clocks repeat, the latest still-future occurrence wins. + */ +export function resolveSnoozeWallTime( + wallTime: SnoozeWallTime, + options: { readonly now: Date }, +): SnoozeWallTimeResult { + const initial = new Date( + wallTime.year, + wallTime.month - 1, + wallTime.day, + wallTime.hour, + wallTime.minute, + 0, + 0, + ); + if (!matchesSnoozeWallTime(initial, wallTime)) { + return { ok: false, error: "Choose a valid date and time." }; + } + + const initialOffset = initial.getTimezoneOffset(); + const nearbyOffsets = new Set( + [-2, -1, 0, 1, 2].map((dayDelta) => + new Date(initial.getTime() + dayDelta * DAY_MS).getTimezoneOffset(), + ), + ); + const candidates = [...nearbyOffsets] + .map((offset) => new Date(initial.getTime() + (offset - initialOffset) * MINUTE_MS)) + .filter((candidate) => matchesSnoozeWallTime(candidate, wallTime)) + .sort((left, right) => left.getTime() - right.getTime()); + const future = candidates.findLast( + (candidate) => snoozeForTimeError(candidate, options) === null, + ); + if (future) return { ok: true, value: future }; + return { + ok: false, + error: snoozeForTimeError(initial, options) ?? "Choose a valid date and time.", + }; +} + function snoozeTimeOfDayLabel(date: Date): string { return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); } diff --git a/packages/client-runtime/src/state/threadSnoozed.test.ts b/packages/client-runtime/src/state/threadSnoozed.test.ts index 8a62103950bf..5ae521aac172 100644 --- a/packages/client-runtime/src/state/threadSnoozed.test.ts +++ b/packages/client-runtime/src/state/threadSnoozed.test.ts @@ -8,6 +8,8 @@ import { effectiveSnoozed, hasQueuedTurnStart, resolveSnoozePresets, + resolveSnoozeForDefault, + snoozeForTimeError, snoozeWakeLabel, threadRaisedHandWhileSnoozed, threadWokeAt, @@ -371,3 +373,26 @@ describe("resolveSnoozePresets", () => { expect(tomorrow.getDay()).toBe(1); }); }); + +describe("custom snooze times", () => { + it("defaults to at least an hour ahead on a 15-minute boundary", () => { + const now = localDate(2026, 4, 8, 10, 7); + const value = resolveSnoozeForDefault(now); + + expect(value.getTime()).toBeGreaterThanOrEqual(now.getTime() + 60 * 60 * 1_000); + expect(value.getMinutes() % 15).toBe(0); + expect(value.getSeconds()).toBe(0); + expect(value.getMilliseconds()).toBe(0); + }); + + it("rejects invalid, equal, and past values", () => { + const now = localDate(2026, 4, 8, 10); + + expect(snoozeForTimeError(new Date(Number.NaN), { now })).toBe("Choose a valid date and time."); + expect(snoozeForTimeError(new Date(now), { now })).toBe("Choose a time in the future."); + expect(snoozeForTimeError(localDate(2026, 4, 8, 9), { now })).toBe( + "Choose a time in the future.", + ); + expect(snoozeForTimeError(localDate(2026, 4, 8, 11), { now })).toBeNull(); + }); +});