From 794ee7fdc77e2c4e3c6a96dad39f204c6530c7de Mon Sep 17 00:00:00 2001 From: Matthew Gibson <237165041+mmmattG@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:04:32 +0800 Subject: [PATCH 1/3] feat(clients): save your own snooze presets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Snooze menu offers five fixed choices. Users who often snooze for the same span, like three days or until Friday morning, have to go through Custom… every time. Users can now save two kinds of presets, which appear after the built-in choices in every Snooze menu: - A delay in minutes, hours, or days, counted from when you snooze. A day is 24 hours, the same as Custom… durations. - A weekday at a local time. It resolves to the next such day after today, never today itself, like the built-in "Next week". Presets are device-local, like the time format. Web and desktop keep them in client settings (Settings → General). Mobile keeps them in device preferences (Settings → Thread behavior). Shared logic in client-runtime resolves, labels, and validates them, so every surface agrees: row and bulk menus, desktop context menus, the chat header menu, and mobile row menus. Any choice that lands on the same instant as an earlier one now collapses into it. This general rule replaces the Sunday "Tomorrow"/"Next week" special case. Settings refuse rules that always duplicate a built-in or saved preset. Unconfigured users see exactly the menu they had before. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/mobile/src/features/home/HomeScreen.tsx | 4 + .../settings/SettingsThreadsRouteScreen.tsx | 2 + .../SnoozePresetsSettingsSection.tsx | 264 +++++++++++++++++ .../threads/ThreadNavigationSidebar.tsx | 4 + .../features/threads/thread-list-v2-items.tsx | 13 +- .../src/features/threads/threadListV2.test.ts | 24 ++ .../src/features/threads/threadListV2.ts | 5 +- .../threads/use-saved-snooze-presets.ts | 19 ++ .../src/persistence/mobile-preferences.ts | 15 +- .../web/src/components/Sidebar.snooze.test.ts | 10 + apps/web/src/components/Sidebar.snooze.ts | 18 +- apps/web/src/components/Sidebar.tsx | 19 +- .../settings/SettingsPanels.restore.test.tsx | 14 + .../components/settings/SettingsPanels.tsx | 5 + .../settings/SnoozePresetSettings.tsx | 268 ++++++++++++++++++ .../src/components/settings/settingsSearch.ts | 6 + apps/web/src/hooks/useThreadActionMenu.ts | 8 +- docs/user/thread-sidebar.md | 8 + .../src/state/snoozePresets.test.ts | 179 ++++++++++++ .../client-runtime/src/state/threadSettled.ts | 257 +++++++++++++---- packages/contracts/src/settings.test.ts | 33 +++ packages/contracts/src/settings.ts | 35 +++ 22 files changed, 1138 insertions(+), 72 deletions(-) create mode 100644 apps/mobile/src/features/settings/components/SnoozePresetsSettingsSection.tsx create mode 100644 apps/mobile/src/features/threads/use-saved-snooze-presets.ts create mode 100644 apps/web/src/components/settings/SnoozePresetSettings.tsx create mode 100644 packages/client-runtime/src/state/snoozePresets.test.ts diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 6538697da297..0d616e1cc1a7 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -58,6 +58,7 @@ import { type ThreadListV2ListItem, } from "../threads/threadListV2"; import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences"; +import { useSavedSnoozePresets } from "../threads/use-saved-snooze-presets"; import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu"; import { buildHomeProjectScopes, @@ -474,6 +475,7 @@ export function HomeScreen(props: HomeScreenProps) { toggleSnoozedShelf, toggleWorkingShelf, } = useThreadListV2ShelfPreferences(); + const savedSnoozePresets = useSavedSnoozePresets(); // The queued-start and snooze helpers need a clock while the list stays open. const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); // Snooze wake times are second-precise; a counter bumped exactly at the @@ -722,6 +724,7 @@ export function HomeScreen(props: HomeScreenProps) { snoozed={item.item.snoozed} pinned={item.item.pinned} snoozePresetMinute={item.snoozePresetMinute ?? ""} + savedSnoozePresets={savedSnoozePresets} snoozeWakeLabelText={item.snoozeWakeLabelText} timeLabel={item.timeLabel} showTrailingDivider={item.showTrailingDivider} @@ -785,6 +788,7 @@ export function HomeScreen(props: HomeScreenProps) { handleRegenerateThreadTitle, handleRenameThread, handleSettleThread, + savedSnoozePresets, handleSnoozeThread, handleUnpinThread, handleUnsnoozeThread, diff --git a/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx index dab724e236a3..551eaaf32eb4 100644 --- a/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx @@ -15,6 +15,7 @@ import { useAtomCommand } from "../../state/use-atom-command"; import { SettingsSection } from "./components/SettingsSection"; import { SettingsProjectOverridesSection } from "./components/SettingsProjectOverridesSection"; import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; +import { SnoozePresetsSettingsSection } from "./components/SnoozePresetsSettingsSection"; import { SettingsScreen } from "./components/SettingsScreen"; import { AndroidSettingsEnvironmentFilter, @@ -45,6 +46,7 @@ export function SettingsThreadsRouteScreen() { contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} > + diff --git a/apps/mobile/src/features/settings/components/SnoozePresetsSettingsSection.tsx b/apps/mobile/src/features/settings/components/SnoozePresetsSettingsSection.tsx new file mode 100644 index 000000000000..0f7d752d5761 --- /dev/null +++ b/apps/mobile/src/features/settings/components/SnoozePresetsSettingsSection.tsx @@ -0,0 +1,264 @@ +import { DateTimePicker } from "@expo/ui/community/datetime-picker"; +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import type { MenuAction } from "@react-native-menu/menu"; +import type { SnoozePresetRule, SnoozePresetUnit } from "@t3tools/contracts"; +import { + describeSnoozePresetRule, + localSnoozeTime, + parseSnoozePresetDraft, + SNOOZE_PRESET_WEEKDAYS, + snoozePresetRuleKey, + type SnoozePresetDraft, +} from "@t3tools/client-runtime/state/thread-settled"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { useState } from "react"; +import { Platform, Pressable, TextInput, View } from "react-native"; + +import { SymbolView } from "../../../components/AppSymbol"; +import { AppText as Text } from "../../../components/AppText"; +import { ControlPillMenu } from "../../../components/ControlPill"; +import { SegmentedControl } from "../../../components/SegmentedControl"; +import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences"; +import { SettingsSection } from "./SettingsSection"; + +const NO_PRESETS: ReadonlyArray = []; +const UNIT_LABELS: Record = { + minutes: "Minutes", + hours: "Hours", + days: "Days", +}; + +function timeOfDay(time: string): Date { + const [hours = 9, minutes = 0] = time.split(":").map(Number); + const date = new Date(); + date.setHours(hours, minutes, 0, 0); + return date; +} + +/** + * Device-local saved Snooze menu choices, the counterpart of web's + * Settings → General → Snooze presets. Mobile has no client-settings sync, + * so each device keeps its own list. + */ +export function SnoozePresetsSettingsSection() { + const preferences = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const loaded = AsyncResult.isSuccess(preferences); + const saved = loaded ? (preferences.value.snoozePresets ?? NO_PRESETS) : NO_PRESETS; + const [kind, setKind] = useState("delay"); + const [amount, setAmount] = useState("3"); + const [unit, setUnit] = useState("days"); + const [weekday, setWeekday] = useState(5); + const [time, setTime] = useState("09:00"); + const [timePickerOpen, setTimePickerOpen] = useState(false); + const [error, setError] = useState(null); + + const draft: SnoozePresetDraft = + kind === "delay" ? { kind, amount: amount.trim(), unit } : { kind, weekday, time }; + const add = () => { + const result = parseSnoozePresetDraft(draft, saved); + if ("error" in result) { + setError(result.error); + return; + } + setError(null); + setTimePickerOpen(false); + // Re-validate against the stored list in case it changed since render. + savePreferences({ + transform: (current) => { + const latest = current.snoozePresets ?? []; + const next = parseSnoozePresetDraft(draft, latest); + return "rule" in next ? { snoozePresets: [...latest, next.rule] } : {}; + }, + }); + }; + const remove = (key: string) => + savePreferences({ + transform: (current) => ({ + snoozePresets: (current.snoozePresets ?? []).filter( + (candidate) => snoozePresetRuleKey(candidate) !== key, + ), + }), + }); + + return ( + + + {saved.map((rule, index) => { + const key = snoozePresetRuleKey(rule); + const label = describeSnoozePresetRule(rule); + return ( + 0 + ? "min-h-14 flex-row items-center gap-3 border-t border-border-subtle px-4 py-3" + : "min-h-14 flex-row items-center gap-3 px-4 py-3" + } + > + + {label} + + remove(key)} + className="active:opacity-70" + > + + + + ); + })} + 0 ? "gap-3 border-t border-border-subtle px-4 py-3" : "gap-3 px-4 py-3" + } + > + { + setKind(value); + setTimePickerOpen(false); + setError(null); + }} + /> + + {kind === "delay" ? ( + <> + + Snooze for + { + setAmount(value); + setError(null); + }} + textAlign="right" + className="min-h-8 min-w-0 flex-1 font-sans text-base text-foreground" + /> + + ({ + id: value, + title, + state: value === unit ? "on" : undefined, + }))} + onSelect={(value) => { + if (value === "minutes" || value === "hours" || value === "days") setUnit(value); + setError(null); + }} + /> + + ) : ( + <> + option.weekday === weekday)?.label ?? "" + } + actions={SNOOZE_PRESET_WEEKDAYS.map((option) => ({ + id: String(option.weekday), + title: option.label, + state: option.weekday === weekday ? "on" : undefined, + }))} + onSelect={(value) => { + setWeekday(Number(value)); + setError(null); + }} + /> + setTimePickerOpen((open) => !open)} + className="min-h-14 flex-row items-center gap-3 border-t border-border-subtle px-4 py-3 active:opacity-70" + > + Time + + {timeOfDay(time).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })} + + + {timePickerOpen ? ( + setTimePickerOpen(false)} + onValueChange={(_, selected) => { + setTime(localSnoozeTime(selected)); + setError(null); + }} + /> + ) : null} + + )} + + Add preset + + + {error ? ( + + {error} + + ) : null} + + Saved presets appear after the built-in Snooze choices on this device. A delay counts from + when you snooze. A weekday means the next one after today, in this device's time zone. + + + ); +} + +function MenuRow(props: { + readonly label: string; + readonly value: string; + readonly actions: MenuAction[]; + readonly onSelect: (id: string) => void; +}) { + return ( + props.onSelect(nativeEvent.event)} + > + + {props.label} + + {props.value} + + + + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 27d23b0a0b15..7216c16e64a9 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -30,6 +30,7 @@ import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { useProjects, useNavigationThreadShells } from "../../state/entities"; import { useThreadSearch } from "../../state/queries"; import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; +import { useSavedSnoozePresets } from "./use-saved-snooze-presets"; import { usePendingThreadOrder } from "../../state/thread-order"; import { threadListEnvironmentsAtom } from "../../state/server"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; @@ -299,6 +300,7 @@ function ThreadNavigationSidebarPane( toggleSnoozedShelf, toggleWorkingShelf, } = useThreadListV2ShelfPreferences(); + const savedSnoozePresets = useSavedSnoozePresets(); // The queued-start and snooze helpers need a clock while the pane stays open. const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); // Snooze wake times are second-precise; a counter bumped exactly at the @@ -697,6 +699,7 @@ function ThreadNavigationSidebarPane( snoozed={item.item.snoozed} pinned={item.item.pinned} snoozePresetMinute={item.snoozePresetMinute ?? ""} + savedSnoozePresets={savedSnoozePresets} snoozeWakeLabelText={item.snoozeWakeLabelText} timeLabel={item.timeLabel} project={projectByKey.get(scopeKey) ?? null} @@ -827,6 +830,7 @@ function ThreadNavigationSidebarPane( showMoreSettled, sidebarScrollGesture, snoozeEnvironmentIds, + savedSnoozePresets, snoozeThread, toggleSettledShelf, toggleSnoozedShelf, 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 d8b0e8791b73..7bc797c17d39 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -17,7 +17,7 @@ import type { EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; -import type { EnvironmentMachineKind } from "@t3tools/contracts"; +import type { EnvironmentMachineKind, SnoozePresetRule } from "@t3tools/contracts"; import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; import type { MenuAction } from "@react-native-menu/menu"; import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react"; @@ -471,6 +471,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { row's menu offers snooze presets, so those menus refresh while mounted without invalidating every other row. */ readonly snoozePresetMinute: string; + /** This device's saved presets, listed after the built-in snooze choices. */ + readonly savedSnoozePresets: ReadonlyArray; readonly project: EnvironmentProject | null; readonly projectTitle?: string; /** Keep the environment's provider array stable across unrelated list updates. */ @@ -652,8 +654,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { snoozed: snoozedRow, }); const snoozePresets = useMemo( - () => (swipeActions.secondary === "snooze" ? resolveSnoozePresets(new Date()) : ([] as const)), - [props.snoozePresetMinute, swipeActions.secondary], + () => + swipeActions.secondary === "snooze" + ? resolveSnoozePresets(new Date(), { saved: props.savedSnoozePresets }) + : ([] as const), + [props.savedSnoozePresets, props.snoozePresetMinute, swipeActions.secondary], ); const snoozePresetActions = useMemo( () => [ @@ -827,6 +832,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({ event: nativeEvent.event, displayedPresets: snoozePresets, + savedPresets: props.savedSnoozePresets, now: new Date(), }); if (snoozeSelection._tag === "selected") { @@ -851,6 +857,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { handleUnpin, handleUnsettle, handleUnsnooze, + props.savedSnoozePresets, setCustomSnoozeOpen, snoozePresets, ], diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 7459b17672f9..6247dddc0ef7 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -71,6 +71,7 @@ describe("resolveThreadListV2SnoozeMenuSelection", () => { const selection = resolveThreadListV2SnoozeMenuSelection({ event: "snooze:evening", displayedPresets, + savedPresets: [], now: selectedAt, }); @@ -87,6 +88,7 @@ describe("resolveThreadListV2SnoozeMenuSelection", () => { resolveThreadListV2SnoozeMenuSelection({ event: "snooze:evening", displayedPresets, + savedPresets: [], now: new Date(2026, 4, 8, 18, 0, 1), }), ).toEqual({ _tag: "expired" }); @@ -98,6 +100,7 @@ describe("resolveThreadListV2SnoozeMenuSelection", () => { const selection = resolveThreadListV2SnoozeMenuSelection({ event: "snooze:hour", displayedPresets, + savedPresets: [], now: selectedAt, }); @@ -108,6 +111,27 @@ describe("resolveThreadListV2SnoozeMenuSelection", () => { ); } }); + + it("recomputes a saved preset from the tap time", () => { + const savedPresets = [{ kind: "delay", amount: 3, unit: "days" }] as const; + const displayedPresets = resolveSnoozePresets(new Date(2026, 4, 8, 10), { + saved: savedPresets, + }); + const selectedAt = new Date(2026, 4, 8, 10, 30); + const selection = resolveThreadListV2SnoozeMenuSelection({ + event: "snooze:saved:delay:4320m", + displayedPresets, + savedPresets, + now: selectedAt, + }); + + expect(selection._tag).toBe("selected"); + if (selection._tag === "selected") { + expect(selection.preset.snoozedUntil).toBe( + new Date(selectedAt.getTime() + 3 * 24 * 60 * 60 * 1_000).toISOString(), + ); + } + }); }); describe("resolveThreadListV2Status", () => { diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index f7327706ef57..d288a4570a72 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -23,7 +23,7 @@ import { sortPinnedThreadsByOrderKey, sortSettledThreads, } from "@t3tools/client-runtime/state/thread-sort"; -import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import type { EnvironmentId, ProjectId, SnoozePresetRule } from "@t3tools/contracts"; import type { ThreadListProvider } from "../../state/thread-list-environments"; import type { ThreadMoveAvailability } from "./threadOrder"; @@ -89,6 +89,7 @@ export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooz export function resolveThreadListV2SnoozeMenuSelection(input: { readonly event: string; readonly displayedPresets: ReadonlyArray; + readonly savedPresets: ReadonlyArray; readonly now: Date; }): | { readonly _tag: "selected"; readonly preset: SnoozePreset } @@ -96,7 +97,7 @@ export function resolveThreadListV2SnoozeMenuSelection(input: { | { readonly _tag: "not-snooze" } { if (!input.event.startsWith("snooze:")) return { _tag: "not-snooze" }; - const currentPreset = resolveSnoozePresets(input.now).find( + const currentPreset = resolveSnoozePresets(input.now, { saved: input.savedPresets }).find( (candidate) => input.event === `snooze:${candidate.id}`, ); if (currentPreset) return { _tag: "selected", preset: currentPreset }; diff --git a/apps/mobile/src/features/threads/use-saved-snooze-presets.ts b/apps/mobile/src/features/threads/use-saved-snooze-presets.ts new file mode 100644 index 000000000000..8c95a53689f0 --- /dev/null +++ b/apps/mobile/src/features/threads/use-saved-snooze-presets.ts @@ -0,0 +1,19 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { SnoozePresetRule } from "@t3tools/contracts"; +import { AsyncResult } from "effect/unstable/reactivity"; + +import { mobilePreferencesAtom } from "../../state/preferences"; + +const NO_SAVED_SNOOZE_PRESETS: ReadonlyArray = []; + +/** + * This device's saved Snooze menu choices. The array keeps its identity until + * the presets change, so lists can hand it to memoized rows without every + * row subscribing to preferences. + */ +export function useSavedSnoozePresets(): ReadonlyArray { + const preferences = useAtomValue(mobilePreferencesAtom); + return AsyncResult.isSuccess(preferences) + ? (preferences.value.snoozePresets ?? NO_SAVED_SNOOZE_PRESETS) + : NO_SAVED_SNOOZE_PRESETS; +} diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index ee96bce2d5ce..ac2387367e25 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -5,7 +5,11 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; -import type { ProviderInstanceId, SidebarProjectGroupingMode } from "@t3tools/contracts"; +import { + SnoozePresetRule, + type ProviderInstanceId, + type SidebarProjectGroupingMode, +} from "@t3tools/contracts"; import type { ComposerEnterBehavior } from "../lib/composerEnterBehavior"; import type { FollowUpBehavior } from "../lib/followUpBehavior"; import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme"; @@ -48,6 +52,8 @@ export interface Preferences { readonly provider: ProviderInstanceId; readonly model: string; }>; + /** Device-local mirror of the web `snoozePresets` client setting. */ + readonly snoozePresets?: ReadonlyArray; /** Fresh keys reset both shelves to collapsed when users update. */ readonly threadListSettledShelfExpanded?: boolean; readonly threadListSnoozedShelfExpanded?: boolean; @@ -91,6 +97,8 @@ export class MobilePreferencesStore extends Context.Service< } >()("@t3tools/mobile/persistence/MobilePreferencesStore") {} +const isSnoozePresetRule = Schema.is(SnoozePresetRule); + function sanitizePreferences(parsed: Preferences): Preferences { const preferences: { liveActivitiesEnabled?: boolean; @@ -112,6 +120,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { planModeEnabled?: boolean; workingShelfEnabled?: boolean; modelFavorites?: Preferences["modelFavorites"]; + snoozePresets?: Preferences["snoozePresets"]; threadListSettledShelfExpanded?: boolean; threadListSnoozedShelfExpanded?: boolean; threadListWorkingShelfExpanded?: boolean; @@ -199,6 +208,10 @@ function sanitizePreferences(parsed: Preferences): Preferences { favorite.model.trim().length > 0, ); } + if (Array.isArray(parsed.snoozePresets)) { + // Presets from a newer build that this one cannot read are dropped. + preferences.snoozePresets = parsed.snoozePresets.filter(isSnoozePresetRule); + } if (typeof parsed.threadListSettledShelfExpanded === "boolean") { preferences.threadListSettledShelfExpanded = parsed.threadListSettledShelfExpanded; } diff --git a/apps/web/src/components/Sidebar.snooze.test.ts b/apps/web/src/components/Sidebar.snooze.test.ts index 16e17e4217eb..56323e8ed36c 100644 --- a/apps/web/src/components/Sidebar.snooze.test.ts +++ b/apps/web/src/components/Sidebar.snooze.test.ts @@ -68,6 +68,16 @@ describe("resolveSnoozePresets", () => { expect(twelveHour.find((preset) => preset.id === "evening")!.whenLabel).toMatch(/PM/i); expect(twentyFourHour.find((preset) => preset.id === "evening")!.whenLabel).toBe("18:00"); }); + + it("lists saved presets after the built-ins in the selected clock format", () => { + const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10), "24-hour", [ + { kind: "weekday", weekday: 5, time: "16:30" }, + ]); + const friday = presets.at(-1)!; + expect(friday.id).toBe("saved:weekday:5:16:30"); + expect(friday.label).toBe("Friday"); + expect(friday.whenLabel).toMatch(/16:30$/); + }); }); describe("snoozeWakeDescription", () => { diff --git a/apps/web/src/components/Sidebar.snooze.ts b/apps/web/src/components/Sidebar.snooze.ts index e7b980279a4b..284f54d38bbe 100644 --- a/apps/web/src/components/Sidebar.snooze.ts +++ b/apps/web/src/components/Sidebar.snooze.ts @@ -1,4 +1,4 @@ -import type { TimestampFormat } from "@t3tools/contracts/settings"; +import type { SnoozePresetRule, TimestampFormat } from "@t3tools/contracts/settings"; import { resolveSnoozePresets as resolveSharedSnoozePresets, snoozeWakeLabel, @@ -15,21 +15,15 @@ function timeOfDayLabel(date: Date, timestampFormat: TimestampFormat): string { return formatShortTimestamp(date.toISOString(), timestampFormat); } +/** Built-in and saved Snooze menu choices, with times in the user's clock format. */ export function resolveSnoozePresets( now: Date, timestampFormat: TimestampFormat, + saved: ReadonlyArray = [], ): ReadonlyArray { - return resolveSharedSnoozePresets(now).map((preset) => { - const wake = parseTimestampDate(preset.snoozedUntil); - if (wake === null) return preset; - const time = timeOfDayLabel(wake, timestampFormat); - return { - ...preset, - whenLabel: - preset.id === "next-week" - ? `${wake.toLocaleDateString(undefined, { weekday: "short" })} ${time}` - : time, - }; + return resolveSharedSnoozePresets(now, { + saved, + formatTime: (date) => timeOfDayLabel(date, timestampFormat), }); } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index cf862c50f46d..d2b1a6d2dffe 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -137,7 +137,7 @@ import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useTerminalFocus } from "../hooks/useTerminalFocus"; import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; -import { useClientSettings } from "../hooks/useSettings"; +import { getClientSettings, useClientSettings } from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useLocalStorage } from "../hooks/useLocalStorage"; import { useNowMinute } from "../hooks/useNowMinute"; @@ -552,7 +552,10 @@ function SnoozeMenuButton(props: { // Presets resolve at open time so "In 1 hour" is relative to the click, // not to when the row mounted. const presets = useMemo( - () => (open ? resolveSnoozePresets(new Date(), timestampFormat) : []), + () => + open + ? resolveSnoozePresets(new Date(), timestampFormat, getClientSettings().snoozePresets) + : [], [open, timestampFormat], ); return ( @@ -4201,7 +4204,11 @@ export default function Sidebar() { const unpinMenuItem = buildBulkUnpinContextMenuItem({ pinnedCount: pinnedSelectedThreads.length, }); - const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat); + const snoozePresets = resolveSnoozePresets( + new Date(), + timestampFormat, + getClientSettings().snoozePresets, + ); const clicked = await settlePromise(() => api.contextMenu.show( [ @@ -4456,7 +4463,11 @@ export default function Sidebar() { const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); const isPinned = thread.pinnedAt != null; // Presets resolve at menu-open time (same as the popover). - const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat); + const snoozePresets = resolveSnoozePresets( + new Date(), + timestampFormat, + getClientSettings().snoozePresets, + ); const threadProjectGroup = projectGroupsRef.current.find((project) => project.memberProjectRefs.some( diff --git a/apps/web/src/components/settings/SettingsPanels.restore.test.tsx b/apps/web/src/components/settings/SettingsPanels.restore.test.tsx index 88c9cb1776e3..f9366a4ac20b 100644 --- a/apps/web/src/components/settings/SettingsPanels.restore.test.tsx +++ b/apps/web/src/components/settings/SettingsPanels.restore.test.tsx @@ -78,6 +78,20 @@ describe("restoring V2 settings", () => { expect(state.update.mock.calls[0]?.[0][key]).toBe(DEFAULT_UNIFIED_SETTINGS[key]); }); + it("clears saved snooze presets", async () => { + state.settings = { + ...DEFAULT_UNIFIED_SETTINGS, + snoozePresets: [{ kind: "delay", amount: 3, unit: "days" }], + }; + hooks.beginRender(); + const restore = useSettingsRestore(); + + expect(restore.changedSettingLabels).toEqual(["Snooze presets"]); + await restore.restoreDefaults(); + + expect(state.update.mock.calls[0]?.[0].snoozePresets).toEqual([]); + }); + it("does not reset settings after cancellation", async () => { state.settings = { ...DEFAULT_UNIFIED_SETTINGS, autoResumeLimitedThreads: true }; state.confirm.mockResolvedValue(false); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 1104f875f6c5..2f0ccd1d3980 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,6 +1,7 @@ import { SettingsGroup } from "./SettingsGroup"; import { Spinner } from "~/components/ui/spinner"; import { NotificationSettings } from "./NotificationSettings"; +import { SnoozePresetSettings } from "./SnoozePresetSettings"; import { ArchiveIcon, ArchiveX, CheckIcon, ChevronRightIcon, SettingsIcon } from "lucide-react"; import { Link, useNavigate } from "@tanstack/react-router"; import type { CSSProperties, ReactNode } from "react"; @@ -583,6 +584,7 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.autoResumeLimitedThreads !== DEFAULT_UNIFIED_SETTINGS.autoResumeLimitedThreads ? ["Auto-resume limited threads"] : []), + ...(settings.snoozePresets.length > 0 ? ["Snooze presets"] : []), ...(settings.snoozeLimitedThreads !== DEFAULT_UNIFIED_SETTINGS.snoozeLimitedThreads ? ["Snooze limited threads"] : []), @@ -706,6 +708,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.sidebarAutoSettleOnMerge, settings.autoResumeLimitedThreads, settings.snoozeLimitedThreads, + settings.snoozePresets, settings.sidebarProjectGroupingMode, settings.sidebarProjectSortOrder, settings.sidebarWorkingShelfEnabled, @@ -813,6 +816,7 @@ export function useSettingsRestore(onRestored?: () => void) { sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, autoResumeLimitedThreads: DEFAULT_UNIFIED_SETTINGS.autoResumeLimitedThreads, snoozeLimitedThreads: DEFAULT_UNIFIED_SETTINGS.snoozeLimitedThreads, + snoozePresets: DEFAULT_UNIFIED_SETTINGS.snoozePresets, responseStreamingMode: DEFAULT_UNIFIED_SETTINGS.responseStreamingMode, enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, continueThreadsAfterServerUpdate: DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate, @@ -2330,6 +2334,7 @@ export function GeneralSettingsPanel() { } /> + = { + minutes: "Minutes", + hours: "Hours", + days: "Days", +}; +const WEEKDAY_LABELS = Object.fromEntries( + SNOOZE_PRESET_WEEKDAYS.map(({ weekday, label }) => [String(weekday), label]), +); + +/** + * Saved Snooze menu choices for this device. Every snooze menu on web and + * desktop lists them after the built-in choices. + */ +export function SnoozePresetSettings() { + const saved = useClientSettings((settings) => settings.snoozePresets); + const timestampFormat = useClientSettings((settings) => settings.timestampFormat); + const hydrated = useClientSettingsHydrated(); + const updateSettings = useUpdateClientSettings(); + const [formOpen, setFormOpen] = useState(false); + const full = saved.length >= MAX_SNOOZE_PRESETS; + const formatTime = (date: Date) => formatShortTimestamp(date.toISOString(), timestampFormat); + + return ( + 0 ? ( + updateSettings({ snoozePresets: [] })} + /> + ) : null + } + control={ + + } + > + + Add preset + + + setFormOpen(false)} /> + + + } + > + {saved.length > 0 ? ( +
+ {saved.map((rule, index) => { + const key = snoozePresetRuleKey(rule); + const label = describeSnoozePresetRule(rule, formatTime); + return ( +
0 && "border-t border-border/60", + )} + > + {label} + +
+ ); + })} +
+ ) : null} +
+ ); +} + +function SnoozePresetForm(props: { readonly onAdded: () => void }) { + const id = useId(); + const updateSettings = useUpdateClientSettings(); + const [kind, setKind] = useState("delay"); + const [amount, setAmount] = useState("3"); + const [unit, setUnit] = useState("days"); + const [weekday, setWeekday] = useState(5); + const [time, setTime] = useState("09:00"); + const [error, setError] = useState(null); + + return ( +
{ + event.preventDefault(); + // Validate against the latest list, not the one this form opened with. + const current = getClientSettings().snoozePresets; + const result = parseSnoozePresetDraft( + kind === "delay" ? { kind, amount, unit } : { kind, weekday, time }, + current, + ); + if ("error" in result) { + setError(result.error); + return; + } + updateSettings({ snoozePresets: [...current, result.rule] }); + props.onAdded(); + }} + > + { + const value = next[0]; + if (value === "delay" || value === "weekday") setKind(value); + setError(null); + }} + > + After a delay + On a weekday + + {kind === "delay" ? ( +
+ { + setAmount(value === null ? "" : String(value)); + setError(null); + }} + > + + + + + + + + +
+ ) : ( +
+ + +
+ )} +

+ {kind === "delay" + ? "Counts from when you snooze. A day is 24 hours." + : "The next one after today, never today itself."} +

+ {error ? ( +

+ {error} +

+ ) : null} + +
+ ); +} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index ae55a327d163..4d0a654ba429 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -286,6 +286,12 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/general", searchTerms: ["sort projects sidebar manual created recent"], }, + { + id: "snooze-presets", + title: "Snooze presets", + to: "/settings/general", + searchTerms: ["snooze menu choices quick later delay days weekday custom remind"], + }, { id: "snooze-limited-threads", title: "Snooze limited threads", diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 793ce7e974e1..5d61ba3bd7d3 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -39,7 +39,7 @@ import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping"; import { threadRuntimeCanArchive } from "@t3tools/client-runtime/state/models"; import { useCopyToClipboard } from "./useCopyToClipboard"; import { useNewThreadHandler } from "./useHandleNewThread"; -import { useClientSettings } from "./useSettings"; +import { getClientSettings, useClientSettings } from "./useSettings"; import { useThreadActions } from "./useThreadActions"; function failureToast(title: string, error: unknown) { @@ -140,7 +140,11 @@ export function useThreadActionMenu(input: { titleRegeneration: readEnvironmentSupportsTitleRegeneration(threadRef.environmentId), }; const isRegeneratingTitle = thread.titleRegeneration != null; - const snoozePresets = resolveSnoozePresets(now, timestampFormat); + const snoozePresets = resolveSnoozePresets( + now, + timestampFormat, + getClientSettings().snoozePresets, + ); const items = buildThreadActionMenuItems({ branch: thread.branch ?? null, projectFilter: null, diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index eb7c4e6a9ffd..a37d30fae923 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -223,3 +223,11 @@ Choose **Snooze → Custom…** from a thread's menu to pick a date and time in 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. + +To keep a choice you use often in the Snooze menu, add a snooze preset in +**Settings → General** on web and desktop, or **Settings → Thread behavior** on +mobile. A preset is either a delay, such as 3 days, or a weekday at a time, such +as Friday at 9:00 AM. Delays work like Custom… durations. A weekday preset means +the next one after today, so on a Friday, Friday means next week. Presets follow +the built-in choices, use the device's time zone, and are saved on each device +separately. A choice that lands at the same time as another appears once. diff --git a/packages/client-runtime/src/state/snoozePresets.test.ts b/packages/client-runtime/src/state/snoozePresets.test.ts new file mode 100644 index 000000000000..4a733ae6f6ba --- /dev/null +++ b/packages/client-runtime/src/state/snoozePresets.test.ts @@ -0,0 +1,179 @@ +// @effect-diagnostics globalDate:off -- Tests exercise local calendar snooze presets. +import type { SnoozePresetRule } from "@t3tools/contracts/settings"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + describeSnoozePresetRule, + localSnoozeTime, + parseSnoozePresetDraft, + resolveSnoozePresetRule, + resolveSnoozePresets, +} from "./threadSettled.ts"; + +const DAY_MS = 24 * 60 * 60 * 1_000; +// Wednesday 2026-04-08 10:00 local. +const WEDNESDAY = new Date(2026, 3, 8, 10); +const IN_THREE_DAYS: SnoozePresetRule = { kind: "delay", amount: 3, unit: "days" }; +const FRIDAY_MORNING: SnoozePresetRule = { kind: "weekday", weekday: 5, time: "09:00" }; + +const weekday = (date: Date) => date.toLocaleDateString(undefined, { weekday: "short" }); + +afterEach(() => vi.unstubAllEnvs()); + +describe("saved snooze presets", () => { + it("leave the built-in choices alone when nothing is saved", () => { + expect(resolveSnoozePresets(WEDNESDAY, { saved: [] })).toEqual(resolveSnoozePresets(WEDNESDAY)); + }); + + it("follow the built-ins, soonest first, with the day in the time column", () => { + const presets = resolveSnoozePresets(WEDNESDAY, { + saved: [IN_THREE_DAYS, FRIDAY_MORNING], + formatTime: localSnoozeTime, + }); + expect(presets.map((preset) => preset.id)).toEqual([ + "hour", + "three-hours", + "evening", + "tomorrow", + "next-week", + "saved:weekday:5:09:00", + "saved:delay:4320m", + ]); + const friday = presets.at(-2)!; + expect(friday.label).toBe("Friday"); + expect(friday.snoozedUntil).toBe(new Date(2026, 3, 10, 9).toISOString()); + expect(friday.whenLabel).toBe(`${weekday(new Date(2026, 3, 10))} 09:00`); + const threeDays = presets.at(-1)!; + expect(threeDays.label).toBe("In 3 days"); + expect(threeDays.snoozedUntil).toBe(new Date(WEDNESDAY.getTime() + 3 * DAY_MS).toISOString()); + }); + + it("put a weekday preset a full week out on that same weekday, and say so", () => { + // Friday 2026-04-10 07:00: "Friday 9:00" is next Friday, not today. + const presets = resolveSnoozePresets(new Date(2026, 3, 10, 7), { + saved: [FRIDAY_MORNING], + formatTime: localSnoozeTime, + }); + const friday = presets.find((preset) => preset.id === "saved:weekday:5:09:00")!; + expect(friday.snoozedUntil).toBe(new Date(2026, 3, 17, 9).toISOString()); + expect(friday.whenLabel).not.toContain(weekday(new Date(2026, 3, 17))); + expect(friday.whenLabel).toMatch(/17/); + }); + + it("collapse into an earlier choice that lands on the same instant", () => { + const sameAsBuiltIns: SnoozePresetRule[] = [ + { kind: "delay", amount: 60, unit: "minutes" }, + { kind: "weekday", weekday: 1, time: "09:00" }, + ]; + expect( + resolveSnoozePresets(WEDNESDAY, { saved: sameAsBuiltIns }).map((preset) => preset.id), + ).toEqual(["hour", "three-hours", "evening", "tomorrow", "next-week"]); + // Monday 2026-04-06: a saved "Tuesday 9:00" is just Tomorrow. + expect( + resolveSnoozePresets(new Date(2026, 3, 6, 10), { + saved: [{ kind: "weekday", weekday: 2, time: "09:00" }], + }).map((preset) => preset.id), + ).toEqual(["hour", "three-hours", "evening", "tomorrow", "next-week"]); + }); + + it("format built-in and saved times with the caller's clock", () => { + const presets = resolveSnoozePresets(WEDNESDAY, { + saved: [IN_THREE_DAYS], + formatTime: () => "TIME", + }); + expect(presets.find((preset) => preset.id === "evening")!.whenLabel).toBe("TIME"); + expect(presets.find((preset) => preset.id === "next-week")!.whenLabel).toMatch(/ TIME$/); + expect(presets.find((preset) => preset.id === "saved:delay:4320m")!.whenLabel).toMatch( + / TIME$/, + ); + }); +}); + +describe("resolveSnoozePresetRule across DST", () => { + it("counts a delay day as 24 elapsed hours", () => { + vi.stubEnv("TZ", "America/Los_Angeles"); + const before = new Date(2027, 2, 13, 12); + const wake = resolveSnoozePresetRule({ kind: "delay", amount: 1, unit: "days" }, before); + expect(wake.getTime() - before.getTime()).toBe(DAY_MS); + expect(wake.getHours()).toBe(13); + }); + + it("rolls a weekday time skipped by spring forward to the next valid time", () => { + vi.stubEnv("TZ", "America/Los_Angeles"); + const wake = resolveSnoozePresetRule( + { kind: "weekday", weekday: 0, time: "02:30" }, + new Date("2027-03-10T20:00:00Z"), + ); + expect(wake.toISOString()).toBe("2027-03-14T10:30:00.000Z"); + expect(localSnoozeTime(wake)).toBe("03:30"); + }); + + it("resolves a weekday time repeated by fall back to its first occurrence", () => { + vi.stubEnv("TZ", "America/Los_Angeles"); + const wake = resolveSnoozePresetRule( + { kind: "weekday", weekday: 0, time: "01:30" }, + new Date("2027-11-03T20:00:00Z"), + ); + expect(wake.toISOString()).toBe("2027-11-07T08:30:00.000Z"); + }); + + it("keeps the wall-clock time for a weekday after the change", () => { + vi.stubEnv("TZ", "America/Los_Angeles"); + const wake = resolveSnoozePresetRule(FRIDAY_MORNING, new Date(2027, 2, 13, 12)); + expect(wake.getDay()).toBe(5); + expect(localSnoozeTime(wake)).toBe("09:00"); + }); +}); + +describe("parseSnoozePresetDraft", () => { + it("accepts whole-number delays and weekday times", () => { + expect(parseSnoozePresetDraft({ kind: "delay", amount: "3", unit: "days" }, [])).toEqual({ + rule: IN_THREE_DAYS, + }); + expect(parseSnoozePresetDraft({ kind: "weekday", weekday: 5, time: "09:00" }, [])).toEqual({ + rule: FRIDAY_MORNING, + }); + }); + + it.each(["", "0", "-1", "1.5", "1000", "abc"])("rejects the delay amount %j", (amount) => { + expect(parseSnoozePresetDraft({ kind: "delay", amount, unit: "hours" }, [])).toHaveProperty( + "error", + ); + }); + + it("refuses rules that always duplicate a built-in choice", () => { + for (const draft of [ + { kind: "delay", amount: "60", unit: "minutes" }, + { kind: "delay", amount: "3", unit: "hours" }, + { kind: "weekday", weekday: 1, time: "09:00" }, + ] as const) { + expect(parseSnoozePresetDraft(draft, [])).toEqual({ + error: "That is already a built-in choice.", + }); + } + }); + + it("refuses an equivalent of a saved preset and a full list", () => { + expect( + parseSnoozePresetDraft({ kind: "delay", amount: "72", unit: "hours" }, [IN_THREE_DAYS]), + ).toEqual({ error: "That preset is already saved." }); + const full = Array.from({ length: 8 }, (_, index): SnoozePresetRule => ({ + kind: "delay", + amount: index + 10, + unit: "days", + })); + expect( + parseSnoozePresetDraft({ kind: "delay", amount: "2", unit: "days" }, full), + ).toHaveProperty("error"); + }); +}); + +describe("describeSnoozePresetRule", () => { + it("names the rule for the settings list", () => { + expect(describeSnoozePresetRule({ kind: "delay", amount: 1, unit: "days" })).toBe("In 1 day"); + expect(describeSnoozePresetRule({ kind: "delay", amount: 45, unit: "minutes" })).toBe( + "In 45 minutes", + ); + expect(describeSnoozePresetRule(FRIDAY_MORNING, localSnoozeTime)).toBe("Friday at 09:00"); + }); +}); diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 104414a9201d..ed676692bcb3 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -1,5 +1,12 @@ // @effect-diagnostics globalDate:off -- UI snooze presets use local calendar boundaries and Intl labels. +import { + MAX_SNOOZE_PRESET_AMOUNT, + MAX_SNOOZE_PRESETS, + SnoozePresetRule, + type SnoozePresetUnit, +} from "@t3tools/contracts/settings"; import * as DateTime from "effect/DateTime"; +import * as Schema from "effect/Schema"; interface SettlementRunLike { readonly turnId?: unknown; @@ -213,23 +220,68 @@ export function threadWokeAt( const HOUR_MS = 60 * 60 * 1_000; const EVENING_HOUR = 18; const MORNING_HOUR = 9; +const SNOOZE_UNIT_MS: Record = { + minutes: 60_000, + hours: HOUR_MS, + days: DAY_MS, +}; +const WEEKDAY_NAMES = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", +] as const; +// The schema bounds weekdays to 0-6; the fallback only satisfies the index type. +function weekdayName(weekday: number): string { + return WEEKDAY_NAMES[weekday] ?? ""; +} +/** Weekday choices for preset forms, Monday first. */ +export const SNOOZE_PRESET_WEEKDAYS = ([1, 2, 3, 4, 5, 6, 0] as const).map((weekday) => ({ + weekday, + label: WEEKDAY_NAMES[weekday], +})); -export type SnoozePresetId = "hour" | "three-hours" | "evening" | "tomorrow" | "next-week"; +type BuiltInSnoozePresetId = "hour" | "three-hours" | "evening" | "tomorrow" | "next-week"; +/** A built-in choice, or `saved:` for a preset saved in settings. */ +export type SnoozePresetId = BuiltInSnoozePresetId | `saved:${string}`; export interface SnoozePreset { readonly id: SnoozePresetId; readonly label: string; - /** Menu-row time column. Complements the label instead of repeating it: - "Tomorrow" pairs with "9:00 AM", not "tomorrow 9:00 AM". */ + /** Menu-row time column. Built-ins complement the label instead of + repeating it: "Tomorrow" pairs with "9:00 AM", not "tomorrow 9:00 AM". + Saved presets name a rule, so their column carries the day. */ readonly whenLabel: string; /** ISO wake time. */ readonly snoozedUntil: string; } +export interface SnoozePresetOptions { + /** Presets saved in settings, listed after the built-in choices. */ + readonly saved?: ReadonlyArray; + /** Formats a time of day. Web passes the user's clock preference. */ + readonly formatTime?: (date: Date) => string; +} + +// The built-in choices that are plain rules. Saving a rule with one of these +// keys would only add a duplicate, so settings refuse it. +const BUILT_IN_SNOOZE_RULES = { + hour: { kind: "delay", amount: 1, unit: "hours" }, + "three-hours": { kind: "delay", amount: 3, unit: "hours" }, + "next-week": { kind: "weekday", weekday: 1, time: "09:00" }, +} as const satisfies Partial>; + function snoozeTimeOfDayLabel(date: Date): string { return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); } +function snoozeWeekdayLabel(date: Date): string { + return date.toLocaleDateString(undefined, { weekday: "short" }); +} + function snoozeAtHour(base: Date, hour: number): Date { const next = DateTime.toDate(DateTime.makeUnsafe(base)); next.setHours(hour, 0, 0, 0); @@ -245,61 +297,171 @@ function addSnoozeDays(base: Date, days: number): Date { return next; } +function parseSnoozePresetTime(time: string): { readonly hours: number; readonly minutes: number } { + const [hours = 0, minutes = 0] = time.split(":").map(Number); + return { hours, minutes }; +} + +/** + * Wake time for a preset rule, in the device's local time zone. A delay is + * elapsed time, so a day is 24 hours even across a DST change, matching + * Custom… durations. A weekday is the next such day after today at that + * wall-clock time; a time skipped by a DST change rolls forward to the next + * valid one, and a repeated time resolves to its first occurrence. + */ +export function resolveSnoozePresetRule(rule: SnoozePresetRule, now: Date): Date { + if (rule.kind === "delay") { + return DateTime.toDate( + DateTime.makeUnsafe(now.getTime() + rule.amount * SNOOZE_UNIT_MS[rule.unit]), + ); + } + // Never today: on a Friday, "Friday" means next week's, like "Next week" on + // a Monday. Today's later hours belong to the delay presets. + const daysAhead = (rule.weekday - now.getDay() + 7) % 7 || 7; + const wake = addSnoozeDays(now, daysAhead); + const { hours, minutes } = parseSnoozePresetTime(rule.time); + wake.setHours(hours, minutes, 0, 0); + return wake; +} + +/** Rules with the same key always resolve to the same wake time. */ +export function snoozePresetRuleKey(rule: SnoozePresetRule): string { + return rule.kind === "delay" + ? `delay:${(rule.amount * SNOOZE_UNIT_MS[rule.unit]) / 60_000}m` + : `weekday:${rule.weekday}:${rule.time}`; +} + +function delayLabel(rule: Extract): string { + const unit = rule.amount === 1 ? rule.unit.slice(0, -1) : rule.unit; + return `In ${rule.amount} ${unit}`; +} + +/** How settings list a saved rule: "In 3 days", "Friday at 9:00 AM". */ +export function describeSnoozePresetRule( + rule: SnoozePresetRule, + formatTime: (date: Date) => string = snoozeTimeOfDayLabel, +): string { + if (rule.kind === "delay") return delayLabel(rule); + const { hours, minutes } = parseSnoozePresetTime(rule.time); + // Any date works: only the time of day is formatted. + const time = formatTime(new Date(2000, 0, 1, hours, minutes)); + return `${weekdayName(rule.weekday)} at ${time}`; +} + +// Saved presets name a rule rather than a day, so their time column carries +// the day: "3:45 PM" today, "Fri 9:00 AM" this week, "Oct 16, 9:00 AM" when +// a weekday preset lands a full week out. +function savedPresetWhenLabel(wake: Date, now: Date, formatTime: (date: Date) => string): string { + const time = formatTime(wake); + const dayDelta = Math.round( + (snoozeAtHour(wake, 0).getTime() - snoozeAtHour(now, 0).getTime()) / DAY_MS, + ); + if (dayDelta === 0) return time; + if (dayDelta < 7) return `${snoozeWeekdayLabel(wake)} ${time}`; + return `${wake.toLocaleDateString(undefined, { month: "short", day: "numeric" })}, ${time}`; +} + /** * Shared "snooze until" choices for every client. "This evening" only * appears while it is meaningfully before evening; after that the calendar - * choices start at "Tomorrow". Calendar presets that land on the same - * instant collapse: on Sundays "Tomorrow" and "Next week" are both Monday - * morning, so only "Tomorrow" is offered. + * choices start at "Tomorrow". Saved presets follow the built-ins, soonest + * first. A choice that lands on the same instant as an earlier one collapses + * into it: on Sundays "Next week" is Monday morning just like "Tomorrow", and + * a saved "Monday at 9:00 AM" is always one of the two. */ -export function resolveSnoozePresets(now: Date): ReadonlyArray { - const inAnHour = DateTime.toDate(DateTime.makeUnsafe(now.getTime() + HOUR_MS)); - const inThreeHours = DateTime.toDate(DateTime.makeUnsafe(now.getTime() + 3 * HOUR_MS)); +export function resolveSnoozePresets( + now: Date, + options: SnoozePresetOptions = {}, +): ReadonlyArray { + const formatTime = options.formatTime ?? snoozeTimeOfDayLabel; + const preset = ( + id: SnoozePresetId, + label: string, + wake: Date, + whenLabel = formatTime(wake), + ): SnoozePreset => ({ id, label, whenLabel, snoozedUntil: wake.toISOString() }); + const presets: SnoozePreset[] = [ - { - id: "hour", - label: "In 1 hour", - whenLabel: snoozeTimeOfDayLabel(inAnHour), - snoozedUntil: inAnHour.toISOString(), - }, - { - id: "three-hours", - label: "In 3 hours", - whenLabel: snoozeTimeOfDayLabel(inThreeHours), - snoozedUntil: inThreeHours.toISOString(), - }, + preset("hour", "In 1 hour", resolveSnoozePresetRule(BUILT_IN_SNOOZE_RULES.hour, now)), + preset( + "three-hours", + "In 3 hours", + resolveSnoozePresetRule(BUILT_IN_SNOOZE_RULES["three-hours"], now), + ), ]; - const evening = snoozeAtHour(now, EVENING_HOUR); if (evening.getTime() - now.getTime() > HOUR_MS) { - presets.push({ - id: "evening", - label: "This evening", - whenLabel: snoozeTimeOfDayLabel(evening), - snoozedUntil: evening.toISOString(), - }); + presets.push(preset("evening", "This evening", evening)); + } + presets.push(preset("tomorrow", "Tomorrow", snoozeAtHour(addSnoozeDays(now, 1), MORNING_HOUR))); + const nextWeek = resolveSnoozePresetRule(BUILT_IN_SNOOZE_RULES["next-week"], now); + presets.push( + preset( + "next-week", + "Next week", + nextWeek, + `${snoozeWeekdayLabel(nextWeek)} ${formatTime(nextWeek)}`, + ), + ); + + const saved = (options.saved ?? []) + .map((rule) => ({ rule, wake: resolveSnoozePresetRule(rule, now) })) + .sort((left, right) => left.wake.getTime() - right.wake.getTime()); + for (const { rule, wake } of saved) { + presets.push( + preset( + `saved:${snoozePresetRuleKey(rule)}`, + rule.kind === "delay" ? delayLabel(rule) : weekdayName(rule.weekday), + wake, + savedPresetWhenLabel(wake, now, formatTime), + ), + ); } - const tomorrow = snoozeAtHour(addSnoozeDays(now, 1), MORNING_HOUR); - presets.push({ - id: "tomorrow", - label: "Tomorrow", - whenLabel: snoozeTimeOfDayLabel(tomorrow), - snoozedUntil: tomorrow.toISOString(), + const seenWakeTimes = new Set(); + return presets.filter((candidate) => { + if (seenWakeTimes.has(candidate.snoozedUntil)) return false; + seenWakeTimes.add(candidate.snoozedUntil); + return true; }); +} - const daysUntilMonday = (1 - now.getDay() + 7) % 7 || 7; - const nextWeek = snoozeAtHour(addSnoozeDays(now, daysUntilMonday), MORNING_HOUR); - if (nextWeek.getTime() !== tomorrow.getTime()) { - presets.push({ - id: "next-week", - label: "Next week", - whenLabel: `${nextWeek.toLocaleDateString(undefined, { weekday: "short" })} ${snoozeTimeOfDayLabel(nextWeek)}`, - snoozedUntil: nextWeek.toISOString(), - }); - } +/** Form input for a new saved preset, before validation. */ +export type SnoozePresetDraft = + | { readonly kind: "delay"; readonly amount: string; readonly unit: SnoozePresetUnit } + | { readonly kind: "weekday"; readonly weekday: number; readonly time: string }; + +const isSnoozePresetRule = Schema.is(SnoozePresetRule); - return presets; +/** + * Validates a new saved preset against the settings schema and the presets + * already saved. A rule that always duplicates a built-in or saved choice is + * refused here; ones that only coincide on some days collapse in the menu. + */ +export function parseSnoozePresetDraft( + draft: SnoozePresetDraft, + saved: ReadonlyArray, +): { readonly rule: SnoozePresetRule } | { readonly error: string } { + if (saved.length >= MAX_SNOOZE_PRESETS) { + return { error: `You can save up to ${MAX_SNOOZE_PRESETS} presets. Remove one first.` }; + } + const candidate = draft.kind === "delay" ? { ...draft, amount: Number(draft.amount) } : draft; + if (!isSnoozePresetRule(candidate)) { + return { + error: + draft.kind === "delay" + ? `Enter a whole number from 1 to ${MAX_SNOOZE_PRESET_AMOUNT}.` + : "Choose a day and a time.", + }; + } + const key = snoozePresetRuleKey(candidate); + if (Object.values(BUILT_IN_SNOOZE_RULES).some((rule) => snoozePresetRuleKey(rule) === key)) { + return { error: "That is already a built-in choice." }; + } + if (saved.some((rule) => snoozePresetRuleKey(rule) === key)) { + return { error: "That preset is already saved." }; + } + return { rule: candidate }; } /** @@ -323,7 +485,7 @@ export type CustomSnoozeInput = | { readonly mode: "duration"; readonly amount: string; - readonly unit: "minutes" | "hours" | "days"; + readonly unit: SnoozePresetUnit; }; /** Resolve local calendar input or elapsed time, rejecting past and invalid dates. */ @@ -332,8 +494,7 @@ export function resolveCustomSnooze(input: CustomSnoozeInput, now: Date): string if (input.mode === "duration") { const amount = Number(input.amount); if (!Number.isFinite(amount) || amount <= 0) return null; - const unitMs = { minutes: 60_000, hours: HOUR_MS, days: 24 * HOUR_MS }[input.unit]; - wake = new Date(now.getTime() + amount * unitMs); + wake = new Date(now.getTime() + amount * SNOOZE_UNIT_MS[input.unit]); } else { if (!/^\d{4}-\d{2}-\d{2}$/.test(input.date) || !/^\d{2}:\d{2}$/.test(input.time)) return null; wake = new Date(`${input.date}T${input.time}:00`); diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index f4a1432f4319..9b9a4b137157 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -681,6 +681,39 @@ describe("ClientSettings follow-up behavior", () => { }); }); +describe("ClientSettings snooze presets", () => { + it("defaults to no saved presets and round-trips both kinds", () => { + expect(decodeClientSettings({}).snoozePresets).toEqual([]); + const snoozePresets = [ + { kind: "delay", amount: 3, unit: "days" }, + { kind: "weekday", weekday: 1, time: "09:00" }, + ]; + expect(decodeClientSettings({ snoozePresets }).snoozePresets).toEqual(snoozePresets); + expect(decodeClientSettingsPatch({ snoozePresets }).snoozePresets).toEqual(snoozePresets); + }); + + it("drops entries this build cannot read instead of failing the settings", () => { + const decoded = decodeClientSettings({ + timestampFormat: "24-hour", + snoozePresets: [ + { kind: "delay", amount: 3, unit: "days" }, + { kind: "later-today", time: "16:00" }, + { kind: "weekday", weekday: 7, time: "09:00" }, + { kind: "weekday", weekday: 5, time: "9:00" }, + { kind: "delay", amount: 1.5, unit: "hours" }, + ], + }); + expect(decoded.timestampFormat).toBe("24-hour"); + expect(decoded.snoozePresets).toEqual([{ kind: "delay", amount: 3, unit: "days" }]); + }); + + it("rejects invalid presets in a patch", () => { + expect(() => + decodeClientSettingsPatch({ snoozePresets: [{ kind: "delay", amount: 0, unit: "days" }] }), + ).toThrow(); + }); +}); + describe("ClientSettings composer collapse", () => { it("collapses on scroll by default and accepts opting out", () => { expect(decodeClientSettings({}).composerCollapseOnScroll).toBe(true); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 082459efc3df..2dda94e6f86e 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -4,6 +4,7 @@ import * as Duration from "effect/Duration"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; import { + ForwardCompatibleArray, ForwardCompatibleNullable, ForwardCompatibleOptional, OmittedWhenNull, @@ -45,6 +46,34 @@ export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]) export type TimestampFormat = typeof TimestampFormat.Type; const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; +/** + * A saved Snooze menu choice. Both kinds resolve in the device's local time + * zone when the menu opens: + * - `delay`: elapsed time from the moment you choose it; a day is 24 hours, + * matching Custom… durations. + * - `weekday`: the next such weekday after today (never today) at a local + * wall-clock time. Weekday 0 is Sunday. + */ +export const SnoozePresetUnit = Schema.Literals(["minutes", "hours", "days"]); +export type SnoozePresetUnit = typeof SnoozePresetUnit.Type; +export const MAX_SNOOZE_PRESET_AMOUNT = 999; +export const SnoozePresetRule = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("delay"), + amount: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: MAX_SNOOZE_PRESET_AMOUNT })), + unit: SnoozePresetUnit, + }), + Schema.Struct({ + kind: Schema.Literal("weekday"), + weekday: Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 6 })), + /** 24-hour local "HH:MM". */ + time: Schema.String.check(Schema.isPattern(/^([01]\d|2[0-3]):[0-5]\d$/)), + }), +]); +export type SnoozePresetRule = typeof SnoozePresetRule.Type; +/** Keeps the Snooze menu scannable: built-ins plus this many saved choices. */ +export const MAX_SNOOZE_PRESETS = 8; + export const DiffLayout = Schema.Literals(["stacked", "split"]); export type DiffLayout = typeof DiffLayout.Type; const DEFAULT_DIFF_LAYOUT: DiffLayout = "stacked"; @@ -485,6 +514,11 @@ export const ClientSettingsSchema = Schema.Struct({ timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), + // Saved Snooze menu choices, shown alongside the built-in ones. Entries + // from a newer build that this one cannot read are dropped, not fatal. + snoozePresets: ForwardCompatibleArray(SnoozePresetRule).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), snapShotEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), snapShotIncludeAccessibility: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), @@ -1795,6 +1829,7 @@ export const ClientSettingsPatch = Schema.Struct({ sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), timestampFormat: Schema.optionalKey(TimestampFormat), + snoozePresets: Schema.optionalKey(Schema.Array(SnoozePresetRule)), snapShotEnabled: Schema.optionalKey(Schema.Boolean), snapShotIncludeAccessibility: Schema.optionalKey(Schema.Boolean), snapShotShortcut: Schema.optionalKey(SnapShotShortcut), From 076bdfb2a2e6df367c4b711e85f50e6f5fb1d8ac Mon Sep 17 00:00:00 2001 From: Matthew Gibson <237165041+mmmattG@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:57:34 +0800 Subject: [PATCH 2/3] feat(clients): keep snooze presets on the environment so mobile shows them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Presets were device-local: a web client setting plus a mobile preference with its own editor. Setting "In 3 days" on desktop did nothing on your phone, and mobile needed a second editor for the same list. Presets are now a shared server setting, next to "Snooze limited threads": - Web and desktop edit them in Settings → General. The row is server-scoped, so the scope picker, mixed-value indicator, and Restore defaults all work as they do for sibling rows. - Clients write them to every shared-settings target, like the other thread preferences. - Every client reads the presets of the thread's environment. Mobile shows them in its row menus but has no editor, matching how it treats other shared preferences such as the writing style. Its device-local mirror and editor are gone. - Each client still resolves a rule in its own time zone. A rule like "Friday at 9:00" is wall-clock, so storing it on the server changes nothing about when it fires. - A new `snoozePresets` capability keeps writes and mismatch checks away from older servers, which would silently drop the key. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/mobile/src/features/home/HomeScreen.tsx | 10 +- .../settings/SettingsThreadsRouteScreen.tsx | 2 - .../SnoozePresetsSettingsSection.tsx | 264 ------------------ .../threads/ThreadNavigationSidebar.tsx | 10 +- .../features/threads/thread-list-v2-items.tsx | 2 +- .../threads/use-saved-snooze-presets.ts | 19 -- .../src/persistence/mobile-preferences.ts | 15 +- .../state/thread-list-environments.test.ts | 20 ++ .../src/state/thread-list-environments.ts | 27 +- .../src/environment/ServerEnvironment.ts | 1 + apps/server/src/serverSettings.test.ts | 25 ++ apps/web/src/components/Sidebar.tsx | 24 +- .../settings/SnoozePresetSettings.tsx | 65 +++-- apps/web/src/hooks/useThreadActionMenu.ts | 5 +- apps/web/src/state/entities.ts | 18 +- docs/user/thread-sidebar.md | 13 +- .../src/state/sharedSettings.test.ts | 13 + .../src/state/sharedSettings.ts | 36 +-- packages/contracts/src/environment.ts | 3 + packages/contracts/src/settings.test.ts | 46 +-- packages/contracts/src/settings.ts | 69 ++--- 21 files changed, 264 insertions(+), 423 deletions(-) delete mode 100644 apps/mobile/src/features/settings/components/SnoozePresetsSettingsSection.tsx delete mode 100644 apps/mobile/src/features/threads/use-saved-snooze-presets.ts diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 0d616e1cc1a7..acad20df8dd3 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -36,6 +36,7 @@ import { useThreadSearch } from "../../state/queries"; import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; import { usePendingThreadOrder } from "../../state/thread-order"; import { threadListEnvironmentsAtom } from "../../state/server"; +import { NO_SNOOZE_PRESETS } from "../../state/thread-list-environments"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useQueuedThreadKeys } from "../../state/use-thread-outbox"; import { @@ -58,7 +59,6 @@ import { type ThreadListV2ListItem, } from "../threads/threadListV2"; import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences"; -import { useSavedSnoozePresets } from "../threads/use-saved-snooze-presets"; import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu"; import { buildHomeProjectScopes, @@ -475,7 +475,6 @@ export function HomeScreen(props: HomeScreenProps) { toggleSnoozedShelf, toggleWorkingShelf, } = useThreadListV2ShelfPreferences(); - const savedSnoozePresets = useSavedSnoozePresets(); // The queued-start and snooze helpers need a clock while the list stays open. const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); // Snooze wake times are second-precise; a counter bumped exactly at the @@ -503,6 +502,7 @@ export function HomeScreen(props: HomeScreenProps) { pinReorderEnvironmentIds, activeReorderEnvironmentIds, titleRegenerationEnvironmentIds, + snoozePresetsByEnvironmentId, } = listEnvironments; const resolveProviderInstance = useThreadRowProviderInstanceResolver(providersByEnvironmentId); const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick); @@ -724,7 +724,9 @@ export function HomeScreen(props: HomeScreenProps) { snoozed={item.item.snoozed} pinned={item.item.pinned} snoozePresetMinute={item.snoozePresetMinute ?? ""} - savedSnoozePresets={savedSnoozePresets} + savedSnoozePresets={ + snoozePresetsByEnvironmentId.get(thread.environmentId) ?? NO_SNOOZE_PRESETS + } snoozeWakeLabelText={item.snoozeWakeLabelText} timeLabel={item.timeLabel} showTrailingDivider={item.showTrailingDivider} @@ -788,7 +790,7 @@ export function HomeScreen(props: HomeScreenProps) { handleRegenerateThreadTitle, handleRenameThread, handleSettleThread, - savedSnoozePresets, + snoozePresetsByEnvironmentId, handleSnoozeThread, handleUnpinThread, handleUnsnoozeThread, diff --git a/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx index 551eaaf32eb4..dab724e236a3 100644 --- a/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx @@ -15,7 +15,6 @@ import { useAtomCommand } from "../../state/use-atom-command"; import { SettingsSection } from "./components/SettingsSection"; import { SettingsProjectOverridesSection } from "./components/SettingsProjectOverridesSection"; import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; -import { SnoozePresetsSettingsSection } from "./components/SnoozePresetsSettingsSection"; import { SettingsScreen } from "./components/SettingsScreen"; import { AndroidSettingsEnvironmentFilter, @@ -46,7 +45,6 @@ export function SettingsThreadsRouteScreen() { contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} > - diff --git a/apps/mobile/src/features/settings/components/SnoozePresetsSettingsSection.tsx b/apps/mobile/src/features/settings/components/SnoozePresetsSettingsSection.tsx deleted file mode 100644 index 0f7d752d5761..000000000000 --- a/apps/mobile/src/features/settings/components/SnoozePresetsSettingsSection.tsx +++ /dev/null @@ -1,264 +0,0 @@ -import { DateTimePicker } from "@expo/ui/community/datetime-picker"; -import { useAtomSet, useAtomValue } from "@effect/atom-react"; -import type { MenuAction } from "@react-native-menu/menu"; -import type { SnoozePresetRule, SnoozePresetUnit } from "@t3tools/contracts"; -import { - describeSnoozePresetRule, - localSnoozeTime, - parseSnoozePresetDraft, - SNOOZE_PRESET_WEEKDAYS, - snoozePresetRuleKey, - type SnoozePresetDraft, -} from "@t3tools/client-runtime/state/thread-settled"; -import { AsyncResult } from "effect/unstable/reactivity"; -import { useState } from "react"; -import { Platform, Pressable, TextInput, View } from "react-native"; - -import { SymbolView } from "../../../components/AppSymbol"; -import { AppText as Text } from "../../../components/AppText"; -import { ControlPillMenu } from "../../../components/ControlPill"; -import { SegmentedControl } from "../../../components/SegmentedControl"; -import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences"; -import { SettingsSection } from "./SettingsSection"; - -const NO_PRESETS: ReadonlyArray = []; -const UNIT_LABELS: Record = { - minutes: "Minutes", - hours: "Hours", - days: "Days", -}; - -function timeOfDay(time: string): Date { - const [hours = 9, minutes = 0] = time.split(":").map(Number); - const date = new Date(); - date.setHours(hours, minutes, 0, 0); - return date; -} - -/** - * Device-local saved Snooze menu choices, the counterpart of web's - * Settings → General → Snooze presets. Mobile has no client-settings sync, - * so each device keeps its own list. - */ -export function SnoozePresetsSettingsSection() { - const preferences = useAtomValue(mobilePreferencesAtom); - const savePreferences = useAtomSet(updateMobilePreferencesAtom); - const loaded = AsyncResult.isSuccess(preferences); - const saved = loaded ? (preferences.value.snoozePresets ?? NO_PRESETS) : NO_PRESETS; - const [kind, setKind] = useState("delay"); - const [amount, setAmount] = useState("3"); - const [unit, setUnit] = useState("days"); - const [weekday, setWeekday] = useState(5); - const [time, setTime] = useState("09:00"); - const [timePickerOpen, setTimePickerOpen] = useState(false); - const [error, setError] = useState(null); - - const draft: SnoozePresetDraft = - kind === "delay" ? { kind, amount: amount.trim(), unit } : { kind, weekday, time }; - const add = () => { - const result = parseSnoozePresetDraft(draft, saved); - if ("error" in result) { - setError(result.error); - return; - } - setError(null); - setTimePickerOpen(false); - // Re-validate against the stored list in case it changed since render. - savePreferences({ - transform: (current) => { - const latest = current.snoozePresets ?? []; - const next = parseSnoozePresetDraft(draft, latest); - return "rule" in next ? { snoozePresets: [...latest, next.rule] } : {}; - }, - }); - }; - const remove = (key: string) => - savePreferences({ - transform: (current) => ({ - snoozePresets: (current.snoozePresets ?? []).filter( - (candidate) => snoozePresetRuleKey(candidate) !== key, - ), - }), - }); - - return ( - - - {saved.map((rule, index) => { - const key = snoozePresetRuleKey(rule); - const label = describeSnoozePresetRule(rule); - return ( - 0 - ? "min-h-14 flex-row items-center gap-3 border-t border-border-subtle px-4 py-3" - : "min-h-14 flex-row items-center gap-3 px-4 py-3" - } - > - - {label} - - remove(key)} - className="active:opacity-70" - > - - - - ); - })} - 0 ? "gap-3 border-t border-border-subtle px-4 py-3" : "gap-3 px-4 py-3" - } - > - { - setKind(value); - setTimePickerOpen(false); - setError(null); - }} - /> - - {kind === "delay" ? ( - <> - - Snooze for - { - setAmount(value); - setError(null); - }} - textAlign="right" - className="min-h-8 min-w-0 flex-1 font-sans text-base text-foreground" - /> - - ({ - id: value, - title, - state: value === unit ? "on" : undefined, - }))} - onSelect={(value) => { - if (value === "minutes" || value === "hours" || value === "days") setUnit(value); - setError(null); - }} - /> - - ) : ( - <> - option.weekday === weekday)?.label ?? "" - } - actions={SNOOZE_PRESET_WEEKDAYS.map((option) => ({ - id: String(option.weekday), - title: option.label, - state: option.weekday === weekday ? "on" : undefined, - }))} - onSelect={(value) => { - setWeekday(Number(value)); - setError(null); - }} - /> - setTimePickerOpen((open) => !open)} - className="min-h-14 flex-row items-center gap-3 border-t border-border-subtle px-4 py-3 active:opacity-70" - > - Time - - {timeOfDay(time).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })} - - - {timePickerOpen ? ( - setTimePickerOpen(false)} - onValueChange={(_, selected) => { - setTime(localSnoozeTime(selected)); - setError(null); - }} - /> - ) : null} - - )} - - Add preset - - - {error ? ( - - {error} - - ) : null} - - Saved presets appear after the built-in Snooze choices on this device. A delay counts from - when you snooze. A weekday means the next one after today, in this device's time zone. - - - ); -} - -function MenuRow(props: { - readonly label: string; - readonly value: string; - readonly actions: MenuAction[]; - readonly onSelect: (id: string) => void; -}) { - return ( - props.onSelect(nativeEvent.event)} - > - - {props.label} - - {props.value} - - - - - ); -} diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 7216c16e64a9..d629b2cfa2ad 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -30,9 +30,9 @@ import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { useProjects, useNavigationThreadShells } from "../../state/entities"; import { useThreadSearch } from "../../state/queries"; import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; -import { useSavedSnoozePresets } from "./use-saved-snooze-presets"; import { usePendingThreadOrder } from "../../state/thread-order"; import { threadListEnvironmentsAtom } from "../../state/server"; +import { NO_SNOOZE_PRESETS } from "../../state/thread-list-environments"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; import { useQueuedThreadKeys } from "../../state/use-thread-outbox"; import { useWorkspaceState } from "../../state/workspace"; @@ -300,7 +300,6 @@ function ThreadNavigationSidebarPane( toggleSnoozedShelf, toggleWorkingShelf, } = useThreadListV2ShelfPreferences(); - const savedSnoozePresets = useSavedSnoozePresets(); // The queued-start and snooze helpers need a clock while the pane stays open. const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); // Snooze wake times are second-precise; a counter bumped exactly at the @@ -326,6 +325,7 @@ function ThreadNavigationSidebarPane( pinReorderEnvironmentIds, activeReorderEnvironmentIds, titleRegenerationEnvironmentIds, + snoozePresetsByEnvironmentId, } = listEnvironments; const resolveProviderInstance = useThreadRowProviderInstanceResolver(providersByEnvironmentId); const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick); @@ -699,7 +699,9 @@ function ThreadNavigationSidebarPane( snoozed={item.item.snoozed} pinned={item.item.pinned} snoozePresetMinute={item.snoozePresetMinute ?? ""} - savedSnoozePresets={savedSnoozePresets} + savedSnoozePresets={ + snoozePresetsByEnvironmentId.get(thread.environmentId) ?? NO_SNOOZE_PRESETS + } snoozeWakeLabelText={item.snoozeWakeLabelText} timeLabel={item.timeLabel} project={projectByKey.get(scopeKey) ?? null} @@ -830,7 +832,7 @@ function ThreadNavigationSidebarPane( showMoreSettled, sidebarScrollGesture, snoozeEnvironmentIds, - savedSnoozePresets, + snoozePresetsByEnvironmentId, snoozeThread, toggleSettledShelf, toggleSnoozedShelf, 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 7bc797c17d39..918bf3a8a403 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -471,7 +471,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { row's menu offers snooze presets, so those menus refresh while mounted without invalidating every other row. */ readonly snoozePresetMinute: string; - /** This device's saved presets, listed after the built-in snooze choices. */ + /** The thread environment's saved presets, listed after the built-in snooze choices. */ readonly savedSnoozePresets: ReadonlyArray; readonly project: EnvironmentProject | null; readonly projectTitle?: string; diff --git a/apps/mobile/src/features/threads/use-saved-snooze-presets.ts b/apps/mobile/src/features/threads/use-saved-snooze-presets.ts deleted file mode 100644 index 8c95a53689f0..000000000000 --- a/apps/mobile/src/features/threads/use-saved-snooze-presets.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import type { SnoozePresetRule } from "@t3tools/contracts"; -import { AsyncResult } from "effect/unstable/reactivity"; - -import { mobilePreferencesAtom } from "../../state/preferences"; - -const NO_SAVED_SNOOZE_PRESETS: ReadonlyArray = []; - -/** - * This device's saved Snooze menu choices. The array keeps its identity until - * the presets change, so lists can hand it to memoized rows without every - * row subscribing to preferences. - */ -export function useSavedSnoozePresets(): ReadonlyArray { - const preferences = useAtomValue(mobilePreferencesAtom); - return AsyncResult.isSuccess(preferences) - ? (preferences.value.snoozePresets ?? NO_SAVED_SNOOZE_PRESETS) - : NO_SAVED_SNOOZE_PRESETS; -} diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index ac2387367e25..ee96bce2d5ce 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -5,11 +5,7 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; -import { - SnoozePresetRule, - type ProviderInstanceId, - type SidebarProjectGroupingMode, -} from "@t3tools/contracts"; +import type { ProviderInstanceId, SidebarProjectGroupingMode } from "@t3tools/contracts"; import type { ComposerEnterBehavior } from "../lib/composerEnterBehavior"; import type { FollowUpBehavior } from "../lib/followUpBehavior"; import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme"; @@ -52,8 +48,6 @@ export interface Preferences { readonly provider: ProviderInstanceId; readonly model: string; }>; - /** Device-local mirror of the web `snoozePresets` client setting. */ - readonly snoozePresets?: ReadonlyArray; /** Fresh keys reset both shelves to collapsed when users update. */ readonly threadListSettledShelfExpanded?: boolean; readonly threadListSnoozedShelfExpanded?: boolean; @@ -97,8 +91,6 @@ export class MobilePreferencesStore extends Context.Service< } >()("@t3tools/mobile/persistence/MobilePreferencesStore") {} -const isSnoozePresetRule = Schema.is(SnoozePresetRule); - function sanitizePreferences(parsed: Preferences): Preferences { const preferences: { liveActivitiesEnabled?: boolean; @@ -120,7 +112,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { planModeEnabled?: boolean; workingShelfEnabled?: boolean; modelFavorites?: Preferences["modelFavorites"]; - snoozePresets?: Preferences["snoozePresets"]; threadListSettledShelfExpanded?: boolean; threadListSnoozedShelfExpanded?: boolean; threadListWorkingShelfExpanded?: boolean; @@ -208,10 +199,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { favorite.model.trim().length > 0, ); } - if (Array.isArray(parsed.snoozePresets)) { - // Presets from a newer build that this one cannot read are dropped. - preferences.snoozePresets = parsed.snoozePresets.filter(isSnoozePresetRule); - } if (typeof parsed.threadListSettledShelfExpanded === "boolean") { preferences.threadListSettledShelfExpanded = parsed.threadListSettledShelfExpanded; } diff --git a/apps/mobile/src/state/thread-list-environments.test.ts b/apps/mobile/src/state/thread-list-environments.test.ts index fcb31285d5ae..f639bb372371 100644 --- a/apps/mobile/src/state/thread-list-environments.test.ts +++ b/apps/mobile/src/state/thread-list-environments.test.ts @@ -190,6 +190,26 @@ describe("thread list environment projection", () => { } }); + it("shares each environment's snooze presets, stable until they change", () => { + const h = harness(); + try { + const threeDays = { kind: "delay", amount: 3, unit: "days" } as const; + expect(h.read().snoozePresetsByEnvironmentId.get(ID)).toBeUndefined(); + h.write({ ...config, settings: { ...config.settings, snoozePresets: [threeDays] } }); + const withPreset = h.read(); + expect(withPreset.snoozePresetsByEnvironmentId.get(ID)).toEqual([threeDays]); + expect(withPreset.snoozePresetsByEnvironmentId.get(OTHER_ID)).toBeUndefined(); + // A fresh settings broadcast with the same presets keeps row props stable. + h.write({ ...config, settings: { ...config.settings, snoozePresets: [{ ...threeDays }] } }); + expect(h.read()).toBe(withPreset); + h.write(config); + expect(h.read().snoozePresetsByEnvironmentId.get(ID)).toBeUndefined(); + expect(h.notifications().list).toBe(2); + } finally { + h.registry.dispose(); + } + }); + it("restores detected machine kind after removing an override", () => { const h = harness(); try { diff --git a/apps/mobile/src/state/thread-list-environments.ts b/apps/mobile/src/state/thread-list-environments.ts index 278ce0ec9e23..fadcbaf15d87 100644 --- a/apps/mobile/src/state/thread-list-environments.ts +++ b/apps/mobile/src/state/thread-list-environments.ts @@ -4,7 +4,9 @@ import { type EnvironmentMachineKind, type ServerConfig, type ServerProvider, + type SnoozePresetRule, } from "@t3tools/contracts"; +import { snoozePresetRuleKey } from "@t3tools/client-runtime/state/thread-settled"; import { Atom } from "effect/unstable/reactivity"; export type ThreadListProvider = Pick< @@ -22,6 +24,9 @@ const capabilityKeys = [ "threadTitleRegeneration", ] as const; +/** Rows fall back to this when an environment has no saved snooze presets. */ +export const NO_SNOOZE_PRESETS: ReadonlyArray = []; + function selectEnvironment(config: ServerConfig) { return { providers: config.providers.map( @@ -35,9 +40,20 @@ function selectEnvironment(config: ServerConfig) { ), machineKind: resolveEnvironmentMachineKind(config), capabilities: config.environment.capabilities, + snoozePresets: config.settings.snoozePresets ?? NO_SNOOZE_PRESETS, }; } +function sameSnoozePresets( + left: ReadonlyArray, + right: ReadonlyArray, +) { + return ( + left.length === right.length && + left.every((rule, index) => snoozePresetRuleKey(rule) === snoozePresetRuleKey(right[index]!)) + ); +} + type ListEnvironment = ReturnType; function sameProviders( @@ -69,9 +85,11 @@ function collectEnvironments(environments: ReadonlyMap(); const activeReorderEnvironmentIds = new Set(); const titleRegenerationEnvironmentIds = new Set(); - for (const [id, { providers, machineKind, capabilities }] of environments) { + const snoozePresetsByEnvironmentId = new Map>(); + for (const [id, { providers, machineKind, capabilities, snoozePresets }] of environments) { providersByEnvironmentId.set(id, providers); machineByEnvironmentId.set(id, machineKind); + if (snoozePresets.length > 0) snoozePresetsByEnvironmentId.set(id, snoozePresets); if (capabilities.threadSettlement === true) settlementEnvironmentIds.add(id); if (capabilities.threadSnooze === true) snoozeEnvironmentIds.add(id); if (capabilities.threadAutoSettleOptOut === true) autoSettleOptOutEnvironmentIds.add(id); @@ -90,10 +108,11 @@ function collectEnvironments(environments: ReadonlyMap>, ) { @@ -109,9 +128,13 @@ export function createThreadListEnvironmentsAtom( if (prior && sameProviders(prior.providers, selected.providers)) { selected.providers = prior.providers; } + if (prior && sameSnoozePresets(prior.snoozePresets, selected.snoozePresets)) { + selected.snoozePresets = prior.snoozePresets; + } const unchanged = prior && prior.providers === selected.providers && + prior.snoozePresets === selected.snoozePresets && prior.machineKind === selected.machineKind && capabilityKeys.every( (key) => (prior.capabilities[key] === true) === (selected.capabilities[key] === true), diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index ef66b6d5419a..4603be8049c4 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -232,6 +232,7 @@ export const make = Effect.gen(function* () { threadRestartContinuation: true, projectSettingsOverrides: true, threadSnooze: true, + snoozePresets: true, environmentThemes: true, usageLimitSources: true, usagePriceOverrides: true, diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 05c685259f73..1f93339e0776 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -541,6 +541,31 @@ it.layer(NodeServices.layer)("server settings", (it) => { ).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("persists snooze presets and replaces the list on update", () => + Effect.scoped( + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const changes = yield* serverSettings.subscribeChanges; + const readPersisted = fileSystem + .readFileString(serverConfig.settingsPath) + .pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(ServerSettings)))); + const threeDays = { kind: "delay", amount: 3, unit: "days" } as const; + const fridayMorning = { kind: "weekday", weekday: 5, time: "09:00" } as const; + + yield* serverSettings.updateSettings({ snoozePresets: [threeDays, fridayMorning] }); + const change = Option.getOrUndefined(yield* Stream.runHead(changes)); + assert.deepStrictEqual(change?.snoozePresets, [threeDays, fridayMorning]); + assert.deepStrictEqual((yield* readPersisted).snoozePresets, [threeDays, fridayMorning]); + + // Removing a preset sends the shorter list; it must not merge by index. + yield* serverSettings.updateSettings({ snoozePresets: [fridayMorning] }); + assert.deepStrictEqual((yield* readPersisted).snoozePresets, [fridayMorning]); + }), + ).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("persists and broadcasts thread settlement settings", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d2b1a6d2dffe..c1c4d6304ec5 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -52,6 +52,7 @@ import { scopedThreadKey, } from "@t3tools/client-runtime/environment"; import { + type EnvironmentId, type EnvironmentMachineKind, type ScopedThreadRef, type ThreadId, @@ -137,7 +138,7 @@ import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useTerminalFocus } from "../hooks/useTerminalFocus"; import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; -import { getClientSettings, useClientSettings } from "../hooks/useSettings"; +import { useClientSettings } from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useLocalStorage } from "../hooks/useLocalStorage"; import { useNowMinute } from "../hooks/useNowMinute"; @@ -148,6 +149,7 @@ import { usePrimaryEnvironmentId, } from "../state/environments"; import { + readEnvironmentSnoozePresets, readThreadShell, useAllEnvironmentProjectSnapshotsReady, useProjects, @@ -546,17 +548,22 @@ function SnoozeMenuButton(props: { open: boolean; onOpenChange: (open: boolean) => void; onSnooze: (preset: Pick) => void; + environmentId: EnvironmentId; timestampFormat: TimestampFormat; }) { - const { open, onOpenChange, onSnooze, timestampFormat } = props; + const { open, onOpenChange, onSnooze, environmentId, 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( () => open - ? resolveSnoozePresets(new Date(), timestampFormat, getClientSettings().snoozePresets) + ? resolveSnoozePresets( + new Date(), + timestampFormat, + readEnvironmentSnoozePresets(environmentId), + ) : [], - [open, timestampFormat], + [environmentId, open, timestampFormat], ); return ( @@ -2071,6 +2078,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { open={snoozeMenuOpen} onOpenChange={setSnoozeMenuOpen} onSnooze={handleSnoozePreset} + environmentId={thread.environmentId} timestampFormat={props.timestampFormat} /> ) : null} @@ -4204,10 +4212,14 @@ export default function Sidebar() { const unpinMenuItem = buildBulkUnpinContextMenuItem({ pinnedCount: pinnedSelectedThreads.length, }); + // Environments normally share presets. Any that differ all appear, and + // the same preset from several environments collapses into one row. const snoozePresets = resolveSnoozePresets( new Date(), timestampFormat, - getClientSettings().snoozePresets, + [...new Set(selectedThreads.map((thread) => thread.environmentId))].flatMap( + (environmentId) => readEnvironmentSnoozePresets(environmentId), + ), ); const clicked = await settlePromise(() => api.contextMenu.show( @@ -4466,7 +4478,7 @@ export default function Sidebar() { const snoozePresets = resolveSnoozePresets( new Date(), timestampFormat, - getClientSettings().snoozePresets, + readEnvironmentSnoozePresets(thread.environmentId), ); const threadProjectGroup = projectGroupsRef.current.find((project) => diff --git a/apps/web/src/components/settings/SnoozePresetSettings.tsx b/apps/web/src/components/settings/SnoozePresetSettings.tsx index 59c0447ec7ce..1ba47cd8bc7f 100644 --- a/apps/web/src/components/settings/SnoozePresetSettings.tsx +++ b/apps/web/src/components/settings/SnoozePresetSettings.tsx @@ -3,6 +3,7 @@ import { useId, useState } from "react"; import { MAX_SNOOZE_PRESET_AMOUNT, MAX_SNOOZE_PRESETS, + type SnoozePresetRule, type SnoozePresetUnit, } from "@t3tools/contracts/settings"; import { @@ -13,12 +14,6 @@ import { type SnoozePresetDraft, } from "@t3tools/client-runtime/state/thread-settled"; -import { - getClientSettings, - useClientSettings, - useClientSettingsHydrated, - useUpdateClientSettings, -} from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; import { formatShortTimestamp } from "../../timestampFormat"; import { Button } from "../ui/button"; @@ -36,6 +31,8 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { SettingResetButton, SettingsRow } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; +import { useSettingsScope } from "./SettingsScopeContext"; +import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; const UNIT_LABELS: Record = { minutes: "Minutes", @@ -47,23 +44,36 @@ const WEEKDAY_LABELS = Object.fromEntries( ); /** - * Saved Snooze menu choices for this device. Every snooze menu on web and - * desktop lists them after the built-in choices. + * Saved Snooze menu choices. They live in the environment's shared settings, + * so every client of the environment, mobile included, lists them after the + * built-in choices. Mobile shows them but has no editor. */ export function SnoozePresetSettings() { - const saved = useClientSettings((settings) => settings.snoozePresets); - const timestampFormat = useClientSettings((settings) => settings.timestampFormat); - const hydrated = useClientSettingsHydrated(); - const updateSettings = useUpdateClientSettings(); + const saved = useScopedSettings((settings) => settings.snoozePresets); + const timestampFormat = useScopedSettings((settings) => settings.timestampFormat); + const updateSettings = useUpdateScopedSettings(); + const { connectedEnvironments } = useSettingsScope(); const [formOpen, setFormOpen] = useState(false); + // Older servers drop the key, so an edit would silently vanish there. + const outdated = connectedEnvironments.some( + (environment) => environment.serverConfig?.environment.capabilities.snoozePresets !== true, + ); const full = saved.length >= MAX_SNOOZE_PRESETS; const formatTime = (date: Date) => formatShortTimestamp(date.toISOString(), timestampFormat); return ( 0 ? ( } + render={