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
4 changes: 3 additions & 1 deletion apps/web/src/components/CustomSnoozeDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import {
import { Button } from "./ui/button";
import { CalendarIcon } from "lucide-react";
import { Calendar } from "./ui/calendar";
import { weekStartsOn } from "../timestampFormat";
import { calendarWeekStartsOn } from "../timestampFormat";
import { useClientSettings } from "../hooks/useSettings";
import { Popover, PopoverTrigger, PopoverPopup } from "./ui/popover";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
Expand Down Expand Up @@ -63,6 +64,7 @@ function CustomSnoozeDialog() {
const [amount, setAmount] = useState("2");
const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours");
const [error, setError] = useState<string | null>(null);
const weekStartsOn = calendarWeekStartsOn(useClientSettings((s) => s.firstDayOfWeek));
const input: CustomSnoozeInput =
mode === "date" ? { mode, date: localSnoozeDate(date), time } : { mode, amount, unit };
return (
Expand Down
29 changes: 22 additions & 7 deletions apps/web/src/components/Sidebar.snooze.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ 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", () => {
const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10), "locale");
// Wednesday 2026-04-08 10:00 local.
const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10), "locale", "locale");
expect(presets.map((preset) => preset.id)).toEqual([
"hour",
"three-hours",
Expand All @@ -31,7 +32,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", "locale");
for (const preset of presets) {
expect(preset.whenLabel.toLowerCase()).not.toContain("tomorrow");
}
Expand All @@ -43,22 +44,36 @@ 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", "locale").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", "locale").map(
(preset) => preset.id,
),
).toEqual(["hour", "three-hours", "tomorrow", "next-week"]);
});

it("puts next week a full week out when today is Monday", () => {
const presets = resolveSnoozePresets(localDate(2026, 4, 6, 10), "locale");
// Monday 2026-04-06.
const presets = resolveSnoozePresets(localDate(2026, 4, 6, 10), "locale", "locale");
const nextWeek = new Date(presets.find((preset) => preset.id === "next-week")!.snoozedUntil);
expect(nextWeek.getDay()).toBe(1);
expect(nextWeek.getDate()).toBe(13);
});

it("starts next week on Sunday when the user picks Sunday", () => {
// Wednesday 2026-04-08: next week starts Sunday 2026-04-12.
const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10), "locale", "sunday");
const nextWeek = presets.find((preset) => preset.id === "next-week")!;
expect(new Date(nextWeek.snoozedUntil).getDate()).toBe(12);
expect(nextWeek.whenLabel).toMatch(/Sun/);
});

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", "locale");
const twentyFourHour = resolveSnoozePresets(localDate(2026, 4, 8, 10), "24-hour", "locale");

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

import { formatShortTimestamp, parseTimestampDate } from "../timestampFormat";
import { formatShortTimestamp, parseTimestampDate, snoozeWeekStartsOn } from "../timestampFormat";

export { snoozeWakeLabel, type SnoozePreset };

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

import type { TimestampFormat } from "@t3tools/contracts/settings";
import type { FirstDayOfWeek, TimestampFormat } from "@t3tools/contracts/settings";
import {
AlarmClockIcon,
AlarmClockOffIcon,
Expand Down Expand Up @@ -1077,9 +1077,10 @@ function SnoozeMenuButton(props: {
onOpenChange: (open: boolean) => void;
onSnooze: (preset: Pick<SnoozePreset, "snoozedUntil">) => void;
timestampFormat: TimestampFormat;
firstDayOfWeek: FirstDayOfWeek;
}) {
const { open, onOpenChange, onSnooze, timestampFormat } = props;
const presets = open ? resolveSnoozePresets(new Date(), timestampFormat) : [];
const { open, onOpenChange, onSnooze, timestampFormat, firstDayOfWeek } = props;
const presets = open ? resolveSnoozePresets(new Date(), timestampFormat, firstDayOfWeek) : [];
return (
<Menu open={open} onOpenChange={onOpenChange}>
<Tooltip>
Expand Down Expand Up @@ -1570,6 +1571,7 @@ type SidebarThreadRowProps = {
projectDisplayName: string | null;
providerEntryByInstanceId: ReadonlyMap<string, SidebarProviderEntry>;
timestampFormat: TimestampFormat;
firstDayOfWeek: FirstDayOfWeek;
onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void;
onThreadActivate: (threadRef: ScopedThreadRef) => void;
onStartRename: (threadRef: ScopedThreadRef, title: string) => void;
Expand Down Expand Up @@ -2429,6 +2431,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
onOpenChange={setSnoozeMenuOpen}
onSnooze={handleSnoozePreset}
timestampFormat={props.timestampFormat}
firstDayOfWeek={props.firstDayOfWeek}
/>
) : null}
{props.settlementSupported ? (
Expand Down Expand Up @@ -2727,6 +2730,7 @@ export default function Sidebar() {
const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);
const timestampFormat = useClientSettings((s) => s.timestampFormat);
const firstDayOfWeek = useClientSettings((s) => s.firstDayOfWeek);
const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
const {
settleThread,
Expand Down Expand Up @@ -3936,7 +3940,7 @@ export default function Sidebar() {
const unpinMenuItem = buildBulkUnpinContextMenuItem({
pinnedCount: pinnedSelectedThreads.length,
});
const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat);
const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat, firstDayOfWeek);
const clicked = await settlePromise(() =>
api.contextMenu.show(
[
Expand Down Expand Up @@ -4229,7 +4233,7 @@ export default function Sidebar() {
const isSettled = settledThreadKeysRef.current.has(threadKey);
const isSnoozed = snoozedThreadKeysRef.current.has(threadKey);
const isPinned = thread.pinnedAt != null;
const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat);
const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat, firstDayOfWeek);
const threadProjectGroup =
projectGroupsRef.current.find((project) =>
project.memberProjectRefs.some(
Expand Down Expand Up @@ -4897,6 +4901,7 @@ export default function Sidebar() {
EMPTY_PROVIDER_ENTRIES
}
timestampFormat={timestampFormat}
firstDayOfWeek={firstDayOfWeek}
onThreadClick={handleThreadClick}
onThreadActivate={navigateToThread}
onStartRename={startThreadRename}
Expand Down
57 changes: 57 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
MIN_TERMINAL_FONT_SIZE,
type QuitConfirmationMode,
SidebarProjectSortOrder,
FirstDayOfWeek,
} from "@t3tools/contracts/settings";
import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";
import { createModelSelection } from "@t3tools/shared/model";
Expand Down Expand Up @@ -209,6 +210,13 @@ const CHAT_WIDTH_LABELS: Record<ChatWidth, string> = {
full: "Full",
};

const FIRST_DAY_OF_WEEK_LABELS: Record<FirstDayOfWeek, string> = {
locale: "System default",
sunday: "Sunday",
monday: "Monday",
};
const isFirstDayOfWeek = Schema.is(FirstDayOfWeek);

const DIFF_LAYOUT_LABELS: Record<DiffLayout, string> = {
stacked: "Stacked",
split: "Split",
Expand Down Expand Up @@ -556,6 +564,9 @@ export function useSettingsRestore(onRestored?: () => void) {
...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat
? ["Time format"]
: []),
...(settings.firstDayOfWeek !== DEFAULT_UNIFIED_SETTINGS.firstDayOfWeek
? ["First day of the week"]
: []),
...(settings.notificationMode !== DEFAULT_UNIFIED_SETTINGS.notificationMode
? ["Thread notifications"]
: []),
Expand Down Expand Up @@ -722,6 +733,7 @@ export function useSettingsRestore(onRestored?: () => void) {
chatWidth: DEFAULT_UNIFIED_SETTINGS.chatWidth,
mathRenderingEnabled: DEFAULT_UNIFIED_SETTINGS.mathRenderingEnabled,
timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
firstDayOfWeek: DEFAULT_UNIFIED_SETTINGS.firstDayOfWeek,
notificationMode: DEFAULT_UNIFIED_SETTINGS.notificationMode,
inAppNotificationsEnabled: DEFAULT_UNIFIED_SETTINGS.inAppNotificationsEnabled,
wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,
Expand Down Expand Up @@ -2486,6 +2498,51 @@ export function GeneralSettingsPanel() {
</Select>
}
/>
<SettingsRow
{...searchableSetting("first-day-of-week")}
description="Used for date pickers and the “Next week” snooze."
resetAction={
settings.firstDayOfWeek !== DEFAULT_UNIFIED_SETTINGS.firstDayOfWeek ? (
<SettingResetButton
label="first day of the week"
onClick={() =>
updateSettings({
firstDayOfWeek: DEFAULT_UNIFIED_SETTINGS.firstDayOfWeek,
})
}
/>
) : null
}
control={
<Select
value={settings.firstDayOfWeek}
onValueChange={(value) => {
if (isFirstDayOfWeek(value)) {
updateSettings({ firstDayOfWeek: value });
}
}}
>
<SelectTrigger
size="sm"
className="w-full sm:w-40"
aria-label="First day of the week"
>
<SelectValue>{FIRST_DAY_OF_WEEK_LABELS[settings.firstDayOfWeek]}</SelectValue>
</SelectTrigger>
<SelectPopup align="end" alignItemWithTrigger={false}>
<SelectItem hideIndicator value="locale">
{FIRST_DAY_OF_WEEK_LABELS.locale}
</SelectItem>
<SelectItem hideIndicator value="sunday">
{FIRST_DAY_OF_WEEK_LABELS.sunday}
</SelectItem>
<SelectItem hideIndicator value="monday">
{FIRST_DAY_OF_WEEK_LABELS.monday}
</SelectItem>
</SelectPopup>
</Select>
}
/>
<SettingsRow
serverScoped
settingKeys={["responseStreamingMode"]}
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 @@ -378,6 +378,12 @@ export const SETTINGS_SEARCH_ITEMS = [
to: "/settings/general",
searchTerms: ["timestamp clock locale system browser os 12 hour 24 hour"],
},
{
id: "first-day-of-week",
title: "First day of the week",
to: "/settings/general",
searchTerms: ["week start sunday monday calendar date picker snooze next week"],
},
{
id: "response-streaming",
title: "Response streaming",
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/hooks/useThreadActionMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export function useThreadActionMenu(input: {
const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);
const timestampFormat = useClientSettings((s) => s.timestampFormat);
const firstDayOfWeek = useClientSettings((s) => s.firstDayOfWeek);
const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({
onCopy: ({ path }) => {
toastManager.add({ type: "success", title: "Path copied", description: path });
Expand Down Expand Up @@ -127,7 +128,7 @@ export function useThreadActionMenu(input: {
titleRegeneration: readEnvironmentSupportsTitleRegeneration(threadRef.environmentId),
};
const isRegeneratingTitle = thread.titleRegeneration != null;
const snoozePresets = resolveSnoozePresets(now, timestampFormat);
const snoozePresets = resolveSnoozePresets(now, timestampFormat, firstDayOfWeek);
const items = buildThreadActionMenuItems({
canOperate: readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope),
branch: thread.branch ?? null,
Expand Down
14 changes: 12 additions & 2 deletions apps/web/src/timestampFormat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,18 @@ describe("resolveWeekStartsOn", () => {
it("follows the locale the desktop host reports", async () => {
vi.stubGlobal("window", { desktopBridge: { getSystemLocale: () => "en-GB" } });
vi.resetModules();
const { weekStartsOn } = await import("./timestampFormat");
expect(weekStartsOn).toBe(1);
const { calendarWeekStartsOn } = await import("./timestampFormat");
expect(calendarWeekStartsOn("locale")).toBe(1);
vi.unstubAllGlobals();
vi.resetModules();
});

it("lets an explicit first day of the week override the locale", async () => {
vi.stubGlobal("window", { desktopBridge: { getSystemLocale: () => "en-GB" } });
vi.resetModules();
const { calendarWeekStartsOn } = await import("./timestampFormat");
expect(calendarWeekStartsOn("sunday")).toBe(0);
expect(calendarWeekStartsOn("monday")).toBe(1);
vi.unstubAllGlobals();
vi.resetModules();
});
Expand Down
22 changes: 20 additions & 2 deletions apps/web/src/timestampFormat.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type TimestampFormat } from "@t3tools/contracts/settings";
import { type FirstDayOfWeek, type TimestampFormat } from "@t3tools/contracts/settings";

function getTimestampFormatOptions(
timestampFormat: TimestampFormat,
Expand Down Expand Up @@ -71,7 +71,25 @@ export function resolveWeekStartsOn(locale: string | undefined): WeekdayIndex |
}
}

export const weekStartsOn = resolveWeekStartsOn(timestampLocale);
const localeWeekStartsOn = resolveWeekStartsOn(timestampLocale);

/**
* Week start for calendars: the user's explicit choice, otherwise the same
* locale timestamps are shown in.
*/
export function calendarWeekStartsOn(firstDayOfWeek: FirstDayOfWeek): WeekdayIndex | undefined {
if (firstDayOfWeek === "sunday") return 0;
if (firstDayOfWeek === "monday") return 1;
return localeWeekStartsOn;
}

/**
* Week start for the "Next week" snooze. It means the start of the work week,
* so the automatic setting stays on Monday even where calendars start on Sunday.
*/
export function snoozeWeekStartsOn(firstDayOfWeek: FirstDayOfWeek): WeekdayIndex {
return firstDayOfWeek === "sunday" ? 0 : 1;
}

const timestampFormatterCache = new Map<string, Intl.DateTimeFormat>();

Expand Down
4 changes: 4 additions & 0 deletions docs/user/thread-sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,3 +236,7 @@ several selected threads together. Choose **Wake thread** to bring a thread back
A snoozed thread keeps working and stays hidden until its wake time, even while
its agent wakes itself for background work. It comes back early only when it needs
an approval or answer from you, when a run fails, or when you send it a message.

**Next week** wakes the thread at 9 AM on the first day of your work week, Monday by
default. On web and desktop, change it in **Settings → General → First day of the week**;
the same setting decides which day the **Custom…** calendar starts on.
19 changes: 16 additions & 3 deletions packages/client-runtime/src/state/threadSettled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,19 @@ function addSnoozeDays(base: Date, days: number): Date {
return next;
}

export function resolveSnoozePresets(now: Date): ReadonlyArray<SnoozePreset> {
/**
* 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". "Next week" wakes on the morning of
* `weekStartsOn` (a `Date#getDay` index, Monday by default). Calendar presets
* that land on the same instant collapse: the day before the week starts,
* "Tomorrow" and "Next week" are the same morning, so only "Tomorrow" is
* offered.
*/
export function resolveSnoozePresets(
now: Date,
options: { readonly weekStartsOn?: number } = {},
): ReadonlyArray<SnoozePreset> {
const inAnHour = DateTime.toDate(DateTime.makeUnsafe(now.getTime() + HOUR_MS));
const inThreeHours = DateTime.toDate(DateTime.makeUnsafe(now.getTime() + 3 * HOUR_MS));
const presets: SnoozePreset[] = [
Expand Down Expand Up @@ -236,8 +248,9 @@ export function resolveSnoozePresets(now: Date): ReadonlyArray<SnoozePreset> {
snoozedUntil: tomorrow.toISOString(),
});

const daysUntilMonday = (1 - now.getDay() + 7) % 7 || 7;
const nextWeek = snoozeAtHour(addSnoozeDays(now, daysUntilMonday), MORNING_HOUR);
const weekStartsOn = options.weekStartsOn ?? 1;
const daysUntilWeekStart = (weekStartsOn - now.getDay() + 7) % 7 || 7;
const nextWeek = snoozeAtHour(addSnoozeDays(now, daysUntilWeekStart), MORNING_HOUR);
if (nextWeek.getTime() !== tomorrow.getTime()) {
presets.push({
id: "next-week",
Expand Down
Loading
Loading