Skip to content

Commit 93a9e07

Browse files
t3dotggclaude
andauthored
feat(threads): choose your own snooze options in settings (#26208)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent e2a8ef0 commit 93a9e07

22 files changed

Lines changed: 909 additions & 126 deletions

‎apps/mobile/src/features/home/HomeScreen.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,7 @@ export function HomeScreen(props: HomeScreenProps) {
432432
machineByEnvironmentId,
433433
settlementEnvironmentIds,
434434
snoozeEnvironmentIds,
435+
snoozePresetsByEnvironmentId,
435436
pinningEnvironmentIds,
436437
autoSettleOptOutEnvironmentIds,
437438
pinReorderEnvironmentIds,
@@ -602,6 +603,7 @@ export function HomeScreen(props: HomeScreenProps) {
602603
settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}
603604
onSettleThread={handleSettleThread}
604605
snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
606+
snoozePresets={snoozePresetsByEnvironmentId.get(thread.environmentId)}
605607
pinningSupported={pinningEnvironmentIds.has(thread.environmentId)}
606608
autoSettleOptOutSupported={autoSettleOptOutEnvironmentIds.has(thread.environmentId)}
607609
reorderSupported={

‎apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,7 @@ function ThreadNavigationSidebarPane(
281281
machineByEnvironmentId,
282282
settlementEnvironmentIds,
283283
snoozeEnvironmentIds,
284+
snoozePresetsByEnvironmentId,
284285
pinningEnvironmentIds,
285286
autoSettleOptOutEnvironmentIds,
286287
pinReorderEnvironmentIds,
@@ -588,6 +589,7 @@ function ThreadNavigationSidebarPane(
588589
settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}
589590
onSettleThread={settleThread}
590591
snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
592+
snoozePresets={snoozePresetsByEnvironmentId.get(thread.environmentId)}
591593
pinningSupported={pinningEnvironmentIds.has(thread.environmentId)}
592594
autoSettleOptOutSupported={autoSettleOptOutEnvironmentIds.has(thread.environmentId)}
593595
reorderSupported={

‎apps/mobile/src/features/threads/thread-list-v2-items.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
resolveThreadWorkingStartedAt,
2727
} from "@t3tools/client-runtime/state/models";
2828
import { AuthOrchestrationOperateScope, type EnvironmentMachineKind } from "@t3tools/contracts";
29+
import type { SnoozePresetSetting } from "@t3tools/contracts/settings";
2930
import { canSnooze } from "@t3tools/client-runtime/state/thread-settled";
3031
import { threadChipCopyTarget } from "@t3tools/client-runtime/thread-reference";
3132
import type { MenuAction } from "@react-native-menu/menu";
@@ -611,6 +612,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
611612
readonly onSetThreadAutoSettle: (thread: EnvironmentThreadShell, enabled: boolean) => void;
612613
readonly settlementSupported: boolean;
613614
readonly snoozeSupported: boolean;
615+
readonly snoozePresets: ReadonlyArray<SnoozePresetSetting> | undefined;
614616
readonly pinningSupported: boolean;
615617
readonly autoSettleOptOutSupported: boolean;
616618
readonly titleRegenerationSupported: boolean;
@@ -762,6 +764,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
762764
const snoozePresets = resolveThreadListV2SnoozePresets({
763765
secondary: swipeActions.secondary,
764766
now: props.snoozePresetMinute || snoozeGateNow,
767+
presets: props.snoozePresets,
765768
});
766769
const snoozePresetActions = useMemo<MenuAction[]>(
767770
() => [
@@ -941,6 +944,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
941944
event: nativeEvent.event,
942945
displayedPresets: snoozePresets,
943946
now: new Date(),
947+
presets: props.snoozePresets,
944948
});
945949
if (snoozeSelection._tag === "selected") {
946950
handleSnooze(snoozeSelection.preset.snoozedUntil);
@@ -969,6 +973,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
969973
handleUnsnooze,
970974
setCustomSnoozeOpen,
971975
snoozePresets,
976+
props.snoozePresets,
972977
],
973978
);
974979
const primaryAction = useMemo(() => {

‎apps/mobile/src/features/threads/threadListV2.test.ts‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,19 +59,21 @@ function makeThread(
5959
const NOW = "2026-06-02T00:00:00.000Z";
6060

6161
describe("resolveThreadListV2SnoozeMenuSelection", () => {
62-
it("recomputes presets that remain available instead of using old timestamps", () => {
63-
const displayedPresets = resolveSnoozePresets(new Date(2026, 4, 8, 10));
62+
it("recomputes the environment's presets instead of using old timestamps", () => {
63+
const presets = [{ kind: "after", minutes: 360 }] as const;
64+
const displayedPresets = resolveSnoozePresets(new Date(2026, 4, 8, 10), { presets });
6465
const selectedAt = new Date(2026, 4, 8, 10, 30);
6566
const selection = resolveThreadListV2SnoozeMenuSelection({
66-
event: "snooze:hour",
67+
event: "snooze:after:360",
6768
displayedPresets,
6869
now: selectedAt,
70+
presets,
6971
});
7072

7173
expect(selection._tag).toBe("selected");
7274
if (selection._tag === "selected") {
7375
expect(selection.preset.snoozedUntil).toBe(
74-
new Date(selectedAt.getTime() + 60 * 60 * 1_000).toISOString(),
76+
new Date(selectedAt.getTime() + 6 * 60 * 60 * 1_000).toISOString(),
7577
);
7678
}
7779
});
@@ -83,13 +85,17 @@ describe("resolveThreadListV2SnoozePresets", () => {
8385
vi.useFakeTimers({ now: new Date(2026, 4, 8, 10) });
8486
const laterMinute = new Date(2026, 4, 8, 19, 15).toISOString();
8587

86-
const presets = resolveThreadListV2SnoozePresets({ secondary: "snooze", now: laterMinute });
88+
const presets = resolveThreadListV2SnoozePresets({
89+
secondary: "snooze",
90+
now: laterMinute,
91+
presets: undefined,
92+
});
8793

88-
expect(presets.find((preset) => preset.id === "hour")?.snoozedUntil).toBe(
94+
expect(presets.find((preset) => preset.id === "after:60")?.snoozedUntil).toBe(
8995
new Date(2026, 4, 8, 20, 15).toISOString(),
9096
);
9197
// The evening preset is gone by 19:15; a reading from 10:00 would still offer it.
92-
expect(presets.map((preset) => preset.id)).not.toContain("evening");
98+
expect(presets.map((preset) => preset.id)).not.toContain("at:today:18:00");
9399
});
94100
});
95101

‎apps/mobile/src/features/threads/threadListV2.ts‎

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
canSnooze,
33
effectiveSnoozed,
44
hasQueuedTurnStart,
5+
pickSnoozePreset,
56
QUEUED_TURN_START_GRACE_MS,
67
resolveSnoozePresets,
78
snoozeWakeLabel,
@@ -28,6 +29,7 @@ import {
2829
sortSettledThreads,
2930
} from "@t3tools/client-runtime/state/thread-sort";
3031
import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
32+
import type { SnoozePresetSetting } from "@t3tools/contracts/settings";
3133

3234
import type { ThreadListProvider } from "../../state/thread-list-environments";
3335
import type { ThreadMoveAvailability } from "./threadOrder";
@@ -80,32 +82,34 @@ export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooz
8082
export function resolveThreadListV2SnoozePresets(input: {
8183
readonly secondary: "snooze" | null;
8284
readonly now: string;
85+
readonly presets: ReadonlyArray<SnoozePresetSetting> | undefined;
8386
}): ReadonlyArray<SnoozePreset> {
84-
return input.secondary === "snooze" ? resolveSnoozePresets(new Date(input.now)) : [];
87+
return input.secondary === "snooze"
88+
? resolveSnoozePresets(new Date(input.now), presetOptions(input.presets))
89+
: [];
90+
}
91+
92+
function presetOptions(presets: ReadonlyArray<SnoozePresetSetting> | undefined) {
93+
return presets ? { presets } : {};
8594
}
8695

8796
export function resolveThreadListV2SnoozeMenuSelection(input: {
8897
readonly event: string;
8998
readonly displayedPresets: ReadonlyArray<SnoozePreset>;
9099
readonly now: Date;
100+
readonly presets: ReadonlyArray<SnoozePresetSetting> | undefined;
91101
}):
92102
| { readonly _tag: "selected"; readonly preset: SnoozePreset }
93103
| { readonly _tag: "expired" }
94104
| { readonly _tag: "not-snooze" } {
95105
if (!input.event.startsWith("snooze:")) return { _tag: "not-snooze" };
96-
97-
const currentPreset = resolveSnoozePresets(input.now).find(
98-
(candidate) => input.event === `snooze:${candidate.id}`,
99-
);
100-
if (currentPreset) return { _tag: "selected", preset: currentPreset };
101-
102-
const displayedPreset = input.displayedPresets.find(
103-
(candidate) => input.event === `snooze:${candidate.id}`,
104-
);
105-
if (displayedPreset && Date.parse(displayedPreset.snoozedUntil) > input.now.getTime()) {
106-
return { _tag: "selected", preset: displayedPreset };
107-
}
108-
return { _tag: "expired" };
106+
const preset = pickSnoozePreset({
107+
shown: input.displayedPresets,
108+
id: input.event.slice("snooze:".length),
109+
now: input.now,
110+
...presetOptions(input.presets),
111+
});
112+
return preset ? { _tag: "selected", preset } : { _tag: "expired" };
109113
}
110114

111115
export function resolveThreadListV2SwipeActions(input: {

‎apps/mobile/src/state/thread-list-environments.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ function selectEnvironment(config: ServerConfig) {
3636
),
3737
machineKind: resolveEnvironmentMachineKind(config),
3838
capabilities: config.environment.capabilities,
39+
snoozePresets: config.settings.snoozePresets,
3940
};
4041
}
4142

@@ -71,8 +72,10 @@ function collectEnvironments(environments: ReadonlyMap<EnvironmentId, ListEnviro
7172
const pinReorderEnvironmentIds = new Set<EnvironmentId>();
7273
const activeReorderEnvironmentIds = new Set<EnvironmentId>();
7374
const titleRegenerationEnvironmentIds = new Set<EnvironmentId>();
74-
for (const [id, { providers, machineKind, capabilities }] of environments) {
75+
const snoozePresetsByEnvironmentId = new Map<EnvironmentId, ListEnvironment["snoozePresets"]>();
76+
for (const [id, { providers, machineKind, capabilities, snoozePresets }] of environments) {
7577
providersByEnvironmentId.set(id, providers);
78+
snoozePresetsByEnvironmentId.set(id, snoozePresets);
7679
machineByEnvironmentId.set(id, machineKind);
7780
if (capabilities.threadSettlement === true) settlementEnvironmentIds.add(id);
7881
if (capabilities.threadSnooze === true) snoozeEnvironmentIds.add(id);
@@ -92,6 +95,7 @@ function collectEnvironments(environments: ReadonlyMap<EnvironmentId, ListEnviro
9295
pinReorderEnvironmentIds,
9396
activeReorderEnvironmentIds,
9497
titleRegenerationEnvironmentIds,
98+
snoozePresetsByEnvironmentId,
9599
};
96100
}
97101

@@ -110,10 +114,14 @@ export function createThreadListEnvironmentsAtom(
110114
if (prior && sameProviders(prior.providers, selected.providers)) {
111115
selected.providers = prior.providers;
112116
}
117+
if (prior && JSON.stringify(prior.snoozePresets) === JSON.stringify(selected.snoozePresets)) {
118+
selected.snoozePresets = prior.snoozePresets;
119+
}
113120
const unchanged =
114121
prior &&
115122
prior.providers === selected.providers &&
116123
prior.machineKind === selected.machineKind &&
124+
prior.snoozePresets === selected.snoozePresets &&
117125
capabilityKeys.every(
118126
(key) => (prior.capabilities[key] === true) === (selected.capabilities[key] === true),
119127
);
Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,57 @@
1+
import type { EnvironmentId } from "@t3tools/contracts";
12
import type { TimestampFormat } from "@t3tools/contracts/settings";
23
import {
4+
pickSnoozePreset as pickSharedSnoozePreset,
35
resolveSnoozePresets as resolveSharedSnoozePresets,
46
snoozeWakeLabel,
57
type SnoozePreset,
68
} from "@t3tools/client-runtime/state/thread-settled";
79

8-
import { formatShortTimestamp, parseTimestampDate } from "../timestampFormat";
10+
import { appAtomRegistry } from "../rpc/atomRegistry";
11+
import { serverEnvironment } from "../state/server";
12+
import { formatShortTimestamp } from "../timestampFormat";
13+
import { toastManager } from "./ui/toast";
914

1015
export { snoozeWakeLabel, type SnoozePreset };
1116

12-
function timeOfDayLabel(date: Date, timestampFormat: TimestampFormat): string {
13-
return formatShortTimestamp(date.toISOString(), timestampFormat);
14-
}
15-
17+
/** The snooze menu for a thread, from its environment's presets, read when the menu opens. */
1618
export function resolveSnoozePresets(
1719
now: Date,
1820
timestampFormat: TimestampFormat,
21+
environmentId: EnvironmentId,
1922
): ReadonlyArray<SnoozePreset> {
20-
return resolveSharedSnoozePresets(now).map((preset) => {
21-
const wake = parseTimestampDate(preset.snoozedUntil);
22-
if (wake === null) return preset;
23-
const time = timeOfDayLabel(wake, timestampFormat);
24-
return {
25-
...preset,
26-
whenLabel:
27-
preset.id === "next-week"
28-
? `${wake.toLocaleDateString(undefined, { weekday: "short" })} ${time}`
29-
: time,
30-
};
23+
return resolveSharedSnoozePresets(now, snoozeOptions(timestampFormat, environmentId));
24+
}
25+
26+
/** A shown menu choice as picked; see the shared `pickSnoozePreset`. */
27+
export function pickSnoozePreset(
28+
shown: ReadonlyArray<SnoozePreset>,
29+
id: string,
30+
timestampFormat: TimestampFormat,
31+
environmentId: EnvironmentId,
32+
): SnoozePreset | undefined {
33+
const picked = pickSharedSnoozePreset({
34+
shown,
35+
id,
36+
now: new Date(),
37+
...snoozeOptions(timestampFormat, environmentId),
3138
});
39+
if (!picked) {
40+
toastManager.add({
41+
type: "warning",
42+
title: "Could not snooze thread",
43+
description: "That snooze time has passed. Choose another time.",
44+
});
45+
}
46+
return picked;
47+
}
48+
49+
function snoozeOptions(timestampFormat: TimestampFormat, environmentId: EnvironmentId) {
50+
const presets = appAtomRegistry.get(
51+
serverEnvironment.settingsValueAtom(environmentId),
52+
)?.snoozePresets;
53+
return {
54+
...(presets ? { presets } : {}),
55+
formatTime: (date: Date) => formatShortTimestamp(date.toISOString(), timestampFormat),
56+
};
3257
}

‎apps/web/src/components/Sidebar.tsx‎

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,12 @@ import {
205205
} from "./Sidebar.drag";
206206
import { SidebarPointerSensor } from "./Sidebar.pointer";
207207
import { createSidebarListMotion } from "./Sidebar.motion";
208-
import { resolveSnoozePresets, snoozeWakeLabel, type SnoozePreset } from "./Sidebar.snooze";
208+
import {
209+
pickSnoozePreset,
210+
resolveSnoozePresets,
211+
snoozeWakeLabel,
212+
type SnoozePreset,
213+
} from "./Sidebar.snooze";
209214
import { ProjectFavicon } from "./ProjectFavicon";
210215
import { deriveProviderEntriesByEnvironment } from "../providerInstances";
211216
import { useStableCallback } from "../hooks/useStableCallback";
@@ -2135,8 +2140,14 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
21352140
const unpinMenuItem = buildBulkUnpinContextMenuItem({
21362141
pinnedCount: pinnedSelectedThreads.length,
21372142
});
2143+
// Presets sync across environments, so the first selected thread's stand for all.
21382144
// oxlint-disable-next-line react/purity -- runs in an event handler (oxc#27309)
2139-
const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat);
2145+
const menuOpenedAt = new Date();
2146+
const snoozePresets = resolveSnoozePresets(
2147+
menuOpenedAt,
2148+
timestampFormat,
2149+
selectedThreads[0]!.environmentId,
2150+
);
21402151
const clicked = await settlePromise(() =>
21412152
api.contextMenu.show(
21422153
[
@@ -2163,7 +2174,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
21632174
{
21642175
id: "snooze:custom",
21652176
label: "Custom…",
2166-
separatorBefore: true,
2177+
separatorBefore: snoozePresets.length > 0,
21672178
disabled: !canOperateThreads(selectedThreads),
21682179
},
21692180
],
@@ -2211,7 +2222,12 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
22112222
const preset =
22122223
clicked.value === "snooze:custom"
22132224
? await requestCustomSnooze()
2214-
: snoozePresets.find((candidate) => `snooze:${candidate.id}` === clicked.value);
2225+
: pickSnoozePreset(
2226+
snoozePresets,
2227+
clicked.value.slice("snooze:".length),
2228+
timestampFormat,
2229+
selectedThreads[0]!.environmentId,
2230+
);
22152231
if (preset) {
22162232
const coSnoozingKeys = new Set(threadKeys);
22172233
clearSelection();
@@ -2428,7 +2444,11 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
24282444
const isSettled = settledThreadKeysRef.current.has(threadKey);
24292445
const isSnoozed = snoozedThreadKeysRef.current.has(threadKey);
24302446
const isPinned = thread.pinnedAt != null;
2431-
const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat);
2447+
const snoozePresets = resolveSnoozePresets(
2448+
new Date(),
2449+
timestampFormat,
2450+
thread.environmentId,
2451+
);
24322452
const threadProjectGroup =
24332453
projectGroupsRef.current.find((project) =>
24342454
project.memberProjectRefs.some(
@@ -2479,7 +2499,12 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
24792499
const preset =
24802500
clicked.value === "snooze:custom"
24812501
? await requestCustomSnooze()
2482-
: snoozePresets.find((candidate) => `snooze:${candidate.id}` === clicked.value);
2502+
: pickSnoozePreset(
2503+
snoozePresets,
2504+
clicked.value.slice("snooze:".length),
2505+
timestampFormat,
2506+
thread.environmentId,
2507+
);
24832508
if (preset) attemptSnooze(threadRef, preset);
24842509
return;
24852510
}

0 commit comments

Comments
 (0)