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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 31 additions & 7 deletions apps/web/src/components/Sidebar.snooze.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { DEFAULT_SNOOZE_HOURS } from "@t3tools/contracts/settings";
import { describe, expect, it } from "vite-plus/test";

import { resolveSnoozePresets, snoozeWakeDescription } from "./Sidebar.snooze";
Expand All @@ -10,7 +11,7 @@ function localDate(year: number, month: number, day: number, hour: number, minut
describe("resolveSnoozePresets", () => {
it("offers one hour, three hours, evening, tomorrow, and next week in the morning", () => {
// Wednesday 2026-04-08 10:00 local.
const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10), "locale");
const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10), "locale", DEFAULT_SNOOZE_HOURS);
expect(presets.map((preset) => preset.id)).toEqual([
"hour",
"three-hours",
Expand All @@ -33,7 +34,7 @@ describe("resolveSnoozePresets", () => {
});

it("whenLabel complements the label instead of repeating it", () => {
const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10), "locale");
const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10), "locale", DEFAULT_SNOOZE_HOURS);
for (const preset of presets) {
// Day words live in the label column; the time column is time-only
// (plus a weekday for next week, which names a different day).
Expand All @@ -47,23 +48,46 @@ describe("resolveSnoozePresets", () => {

it("drops the evening preset once evening is near or past", () => {
expect(
resolveSnoozePresets(localDate(2026, 4, 8, 17, 30), "locale").map((preset) => preset.id),
resolveSnoozePresets(localDate(2026, 4, 8, 17, 30), "locale", DEFAULT_SNOOZE_HOURS).map(
(preset) => preset.id,
),
).toEqual(["hour", "three-hours", "tomorrow", "next-week"]);
expect(
resolveSnoozePresets(localDate(2026, 4, 8, 21), "locale").map((preset) => preset.id),
resolveSnoozePresets(localDate(2026, 4, 8, 21), "locale", DEFAULT_SNOOZE_HOURS).map(
(preset) => preset.id,
),
).toEqual(["hour", "three-hours", "tomorrow", "next-week"]);
});

it("puts next week a full week out when today is Monday", () => {
// Monday 2026-04-06.
const presets = resolveSnoozePresets(localDate(2026, 4, 6, 10), "locale");
const presets = resolveSnoozePresets(localDate(2026, 4, 6, 10), "locale", DEFAULT_SNOOZE_HOURS);
const nextWeek = new Date(presets.find((preset) => preset.id === "next-week")!.snoozedUntil);
expect(nextWeek.getDay()).toBe(1);
expect(nextWeek.getDate()).toBe(13);
});
it("labels each calendar preset with its chosen hour", () => {
const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10), "24-hour", {
morningHour: 5,
eveningHour: 21,
});

expect(presets.find((preset) => preset.id === "evening")!.whenLabel).toBe("21:00");
expect(presets.find((preset) => preset.id === "tomorrow")!.whenLabel).toBe("05:00");
expect(presets.find((preset) => preset.id === "next-week")!.whenLabel).toMatch(/Mon 05:00/);
});

it("formats preset times with the selected clock preference", () => {
const twelveHour = resolveSnoozePresets(localDate(2026, 4, 8, 10), "12-hour");
const twentyFourHour = resolveSnoozePresets(localDate(2026, 4, 8, 10), "24-hour");
const twelveHour = resolveSnoozePresets(
localDate(2026, 4, 8, 10),
"12-hour",
DEFAULT_SNOOZE_HOURS,
);
const twentyFourHour = resolveSnoozePresets(
localDate(2026, 4, 8, 10),
"24-hour",
DEFAULT_SNOOZE_HOURS,
);

expect(twelveHour.find((preset) => preset.id === "evening")!.whenLabel).toMatch(/PM/i);
expect(twentyFourHour.find((preset) => preset.id === "evening")!.whenLabel).toBe("18:00");
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/components/Sidebar.snooze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { TimestampFormat } from "@t3tools/contracts/settings";
import {
resolveSnoozePresets as resolveSharedSnoozePresets,
snoozeWakeLabel,
type SnoozeHours,
type SnoozePreset,
} from "@t3tools/client-runtime/state/thread-settled";

Expand All @@ -18,8 +19,9 @@ function timeOfDayLabel(date: Date, timestampFormat: TimestampFormat): string {
export function resolveSnoozePresets(
now: Date,
timestampFormat: TimestampFormat,
snoozeHours: SnoozeHours,
): ReadonlyArray<SnoozePreset> {
return resolveSharedSnoozePresets(now).map((preset) => {
return resolveSharedSnoozePresets(now, snoozeHours).map((preset) => {
const wake = parseTimestampDate(preset.snoozedUntil);
if (wake === null) return preset;
const time = timeOfDayLabel(wake, timestampFormat);
Expand Down
43 changes: 37 additions & 6 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,11 @@ import {
type ThreadId,
} from "@t3tools/contracts";

import type { TimestampFormat } from "@t3tools/contracts/settings";
import type {
SnoozeEveningHour,
SnoozeMorningHour,
TimestampFormat,
} from "@t3tools/contracts/settings";
import {
AlarmClockIcon,
AlarmClockOffIcon,
Expand Down Expand Up @@ -547,13 +551,22 @@ function SnoozeMenuButton(props: {
onOpenChange: (open: boolean) => void;
onSnooze: (preset: Pick<SnoozePreset, "snoozedUntil">) => void;
timestampFormat: TimestampFormat;
snoozeMorningHour: SnoozeMorningHour;
snoozeEveningHour: SnoozeEveningHour;
}) {
const { open, onOpenChange, onSnooze, timestampFormat } = props;
const { open, onOpenChange, onSnooze, timestampFormat, snoozeMorningHour, snoozeEveningHour } =
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, timestampFormat],
() =>
open
? resolveSnoozePresets(new Date(), timestampFormat, {
morningHour: snoozeMorningHour,
eveningHour: snoozeEveningHour,
})
: [],
[open, timestampFormat, snoozeMorningHour, snoozeEveningHour],
);
return (
<Menu open={open} onOpenChange={onOpenChange}>
Expand Down Expand Up @@ -1116,6 +1129,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
projectDisplayName: string | null;
providerEntryByInstanceId: ReadonlyMap<string, ProviderInstanceEntry>;
timestampFormat: TimestampFormat;
snoozeMorningHour: SnoozeMorningHour;
snoozeEveningHour: SnoozeEveningHour;
onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void;
onThreadActivate: (threadRef: ScopedThreadRef) => void;
onStartRename: (threadRef: ScopedThreadRef, title: string) => void;
Expand Down Expand Up @@ -2069,6 +2084,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
onOpenChange={setSnoozeMenuOpen}
onSnooze={handleSnoozePreset}
timestampFormat={props.timestampFormat}
snoozeMorningHour={props.snoozeMorningHour}
snoozeEveningHour={props.snoozeEveningHour}
/>
) : null}
{props.settlementSupported ? (
Expand Down Expand Up @@ -2332,6 +2349,8 @@ export default function Sidebar() {
const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);
const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
const timestampFormat = useClientSettings((s) => s.timestampFormat);
const snoozeMorningHour = useClientSettings((s) => s.snoozeMorningHour);
const snoozeEveningHour = useClientSettings((s) => s.snoozeEveningHour);
const workingShelfEnabled = useClientSettings((s) => s.sidebarWorkingShelfEnabled);
const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
const {
Expand Down Expand Up @@ -4201,7 +4220,10 @@ export default function Sidebar() {
const unpinMenuItem = buildBulkUnpinContextMenuItem({
pinnedCount: pinnedSelectedThreads.length,
});
const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat);
const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat, {
morningHour: snoozeMorningHour,
eveningHour: snoozeEveningHour,
});
const clicked = await settlePromise(() =>
api.contextMenu.show(
[
Expand Down Expand Up @@ -4366,6 +4388,8 @@ export default function Sidebar() {
settleThreads,
updateThreadMetadata,
timestampFormat,
snoozeMorningHour,
snoozeEveningHour,
],
);

Expand Down Expand Up @@ -4456,7 +4480,10 @@ 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, {
morningHour: snoozeMorningHour,
eveningHour: snoozeEveningHour,
});
const threadProjectGroup =
projectGroupsRef.current.find((project) =>
project.memberProjectRefs.some(
Expand Down Expand Up @@ -4704,6 +4731,8 @@ export default function Sidebar() {
startThreadRename,
updateThreadMetadata,
timestampFormat,
snoozeMorningHour,
snoozeEveningHour,
],
);

Expand Down Expand Up @@ -5191,6 +5220,8 @@ export default function Sidebar() {
EMPTY_PROVIDER_ENTRIES
}
timestampFormat={timestampFormat}
snoozeMorningHour={snoozeMorningHour}
snoozeEveningHour={snoozeEveningHour}
onThreadClick={handleThreadClick}
onThreadActivate={navigateToThread}
onStartRename={startThreadRename}
Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.restore.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,23 @@ describe("restoring V2 settings", () => {
expect(state.update.mock.calls[0]?.[0][key]).toBe(DEFAULT_UNIFIED_SETTINGS[key]);
});

it.each([
["snoozeMorningHour", 5],
["snoozeEveningHour", 21],
] as const)("restores both snooze times when %s changed", async (key, hour) => {
state.settings = { ...DEFAULT_UNIFIED_SETTINGS, [key]: hour };
hooks.beginRender();
const restore = useSettingsRestore();

expect(restore.changedSettingLabels).toEqual(["Snooze times"]);
await restore.restoreDefaults();

expect(state.update.mock.calls[0]?.[0]).toMatchObject({
snoozeMorningHour: 9,
snoozeEveningHour: 18,
});
});

it("does not reset settings after cancellation", async () => {
state.settings = { ...DEFAULT_UNIFIED_SETTINGS, autoResumeLimitedThreads: true };
state.confirm.mockResolvedValue(false);
Expand Down
96 changes: 95 additions & 1 deletion apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
type ProviderInstanceId,
type ScopedThreadRef,
type SidebarProjectGroupingMode,
type TimestampFormat,
type UnifiedSettings,
} from "@t3tools/contracts";
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import { presentThreadShell } from "@t3tools/client-runtime/state/shell";
Expand Down Expand Up @@ -41,6 +43,7 @@ import {
MIN_PANEL_ANIMATION_DURATION_MS,
MIN_PROMPT_FONT_SIZE,
MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
MIN_SNOOZE_EVENING_HOUR,
type ResponseStreamingMode,
MIN_TERMINAL_FONT_SIZE,
type QuitConfirmationMode,
Expand Down Expand Up @@ -100,7 +103,7 @@ import { ensureLocalApi, readLocalApi } from "../../localApi";
import { isMacPlatform } from "../../lib/utils";
import { EMPTY_SERVER_PROVIDERS } from "../../state/server";
import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState";
import { formatRelativeTimeLabel } from "../../timestampFormat";
import { formatRelativeTimeLabel, formatShortTimestamp } from "../../timestampFormat";
import { Button } from "../ui/button";
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
import {
Expand Down Expand Up @@ -197,6 +200,57 @@ const TIMESTAMP_FORMAT_LABELS = {
"24-hour": "24-hour",
} as const;

const SNOOZE_MORNING_HOURS = Array.from({ length: 24 }, (_, hour) => hour);
const SNOOZE_EVENING_HOURS = SNOOZE_MORNING_HOURS.filter((hour) => hour >= MIN_SNOOZE_EVENING_HOUR);

function hourOfDayLabel(hour: number, timestampFormat: TimestampFormat): string {
return formatShortTimestamp(new Date(2000, 0, 1, hour).toISOString(), timestampFormat);
}

function hasChangedSnoozeTimes(
settings: Pick<UnifiedSettings, "snoozeMorningHour" | "snoozeEveningHour">,
): boolean {
return (
settings.snoozeMorningHour !== DEFAULT_UNIFIED_SETTINGS.snoozeMorningHour ||
settings.snoozeEveningHour !== DEFAULT_UNIFIED_SETTINGS.snoozeEveningHour
);
}

function SnoozeHourSelect(props: {
label: string;
hours: ReadonlyArray<number>;
hour: number;
timestampFormat: TimestampFormat;
onHourChange: (hour: number) => void;
}) {
return (
<Select
value={String(props.hour)}
onValueChange={(value) => {
if (value !== null) props.onHourChange(Number(value));
}}
>
<SelectTrigger
size="sm"
className="w-full sm:w-40"
aria-label={`Snooze ${props.label.toLowerCase()} time`}
>
<SelectValue>
<span className="text-muted-foreground">{props.label}</span>{" "}
{hourOfDayLabel(props.hour, props.timestampFormat)}
</SelectValue>
</SelectTrigger>
<SelectPopup align="end" alignItemWithTrigger={false}>
{props.hours.map((hour) => (
<SelectItem hideIndicator key={hour} value={String(hour)}>
{hourOfDayLabel(hour, props.timestampFormat)}
</SelectItem>
))}
</SelectPopup>
</Select>
);
}

const CHAT_WIDTH_LABELS: Record<ChatWidth, string> = {
comfortable: "Comfortable",
wide: "Wide",
Expand Down Expand Up @@ -586,6 +640,7 @@ export function useSettingsRestore(onRestored?: () => void) {
...(settings.snoozeLimitedThreads !== DEFAULT_UNIFIED_SETTINGS.snoozeLimitedThreads
? ["Snooze limited threads"]
: []),
...(hasChangedSnoozeTimes(settings) ? ["Snooze times"] : []),
...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []),
...(settings.persistComposerContextStrip !==
DEFAULT_UNIFIED_SETTINGS.persistComposerContextStrip
Expand Down Expand Up @@ -706,6 +761,8 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.sidebarAutoSettleOnMerge,
settings.autoResumeLimitedThreads,
settings.snoozeLimitedThreads,
settings.snoozeMorningHour,
settings.snoozeEveningHour,
settings.sidebarProjectGroupingMode,
settings.sidebarProjectSortOrder,
settings.sidebarWorkingShelfEnabled,
Expand Down Expand Up @@ -813,6 +870,8 @@ export function useSettingsRestore(onRestored?: () => void) {
sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,
autoResumeLimitedThreads: DEFAULT_UNIFIED_SETTINGS.autoResumeLimitedThreads,
snoozeLimitedThreads: DEFAULT_UNIFIED_SETTINGS.snoozeLimitedThreads,
snoozeMorningHour: DEFAULT_UNIFIED_SETTINGS.snoozeMorningHour,
snoozeEveningHour: DEFAULT_UNIFIED_SETTINGS.snoozeEveningHour,
responseStreamingMode: DEFAULT_UNIFIED_SETTINGS.responseStreamingMode,
enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks,
continueThreadsAfterServerUpdate: DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate,
Expand Down Expand Up @@ -2362,6 +2421,41 @@ export function GeneralSettingsPanel() {
/>
}
/>
<SettingsRow
{...searchableSetting("snooze-times")}
description="When threads snoozed until This evening, Tomorrow, or Next week wake."
resetAction={
hasChangedSnoozeTimes(settings) ? (
<SettingResetButton
label="snooze times"
onClick={() =>
updateSettings({
snoozeMorningHour: DEFAULT_UNIFIED_SETTINGS.snoozeMorningHour,
snoozeEveningHour: DEFAULT_UNIFIED_SETTINGS.snoozeEveningHour,
})
}
/>
) : null
}
control={
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
<SnoozeHourSelect
label="Morning"
hours={SNOOZE_MORNING_HOURS}
hour={settings.snoozeMorningHour}
timestampFormat={settings.timestampFormat}
onHourChange={(hour) => updateSettings({ snoozeMorningHour: hour })}
/>
<SnoozeHourSelect
label="Evening"
hours={SNOOZE_EVENING_HOURS}
hour={settings.snoozeEveningHour}
timestampFormat={settings.timestampFormat}
onHourChange={(hour) => updateSettings({ snoozeEveningHour: hour })}
/>
</div>
}
/>

<SettingsRow
{...searchableSetting("working-shelf")}
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,12 @@ export const SETTINGS_SEARCH_ITEMS = [
to: "/settings/general",
searchTerms: ["usage quota rate limit reset wake recover continue"],
},
{
id: "snooze-times",
title: "Snooze times",
to: "/settings/general",
searchTerms: ["remind this evening tomorrow next week morning hour wake up"],
},
{
id: "auto-resume-limited-threads",
title: "Auto-resume limited threads",
Expand Down
Loading
Loading