Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@ import {
import { Menu } from "../../components/ui/Menu";
import type { ComposerEditorSelection } from "../../components/ComposerEditor";
import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView";
import { SegmentedControl } from "../../components/ui/SegmentedControl";
import { ListFilterMenu } from "../layout/workspace/ListFilterMenu";
import { agentModelLabel, useEnvironmentAgents } from "../../state/agents";
import { ThemedSwitch } from "../../components/ui/ThemedSwitch";
Expand Down Expand Up @@ -90,6 +89,7 @@ import {
scheduledTaskDraftRouting,
scheduleFromDraft,
unbindDraftFromThread,
type ScheduleDraft,
type ScheduledTaskDraft as Draft,
} from "./scheduledTaskDraft";
import { settingsTargetsForProject } from "./settings-environment-filter.logic";
Expand Down Expand Up @@ -138,6 +138,15 @@ const THREAD_MODE_LABELS: Record<ScheduledTaskThreadMode, string> = {
};
const AGENT_DESTINATION_LABEL = "An agent's own thread";

const SCHEDULE_MODE_LABELS: Record<ScheduleDraft["mode"], string> = {
fixed_time: "At a time",
once: "Once",
interval: "Interval",
cron: "Cron",
webhook: "On webhook",
};
const SCHEDULE_MODES = Object.keys(SCHEDULE_MODE_LABELS) as ReadonlyArray<ScheduleDraft["mode"]>;

const REPEAT_PRESET_LABELS: Record<RepeatPreset, string> = {
daily: "Every day",
weekdays: "Weekdays",
Expand All @@ -151,7 +160,7 @@ function describeSchedule(
timeZone?: string,
): string {
if (task.schedule.type === "cron") {
return describeRepeatSchedule(task.schedule, (iso) => iso, localTimeZone());
return describeRepeatSchedule(task.schedule, (iso) => iso, localTimeZone(), formatTime);
}
if (task.schedule.type === "webhook") return "On webhook";
if (task.schedule.type === "once") {
Expand Down Expand Up @@ -1222,43 +1231,45 @@ function TaskForm({
</SettingsSection>

<SettingsSection title="Schedule">
<View className="px-4 py-3">
<SegmentedControl
variant="card"
options={[
{ value: "fixed_time" as const, label: "At a time" },
// Older servers reject one-time schedules on save.
...(offersOnce || draft.schedule.mode === "once"
? [{ value: "once" as const, label: "Once" }]
: []),
{ value: "interval" as const, label: "Interval" },
{ value: "cron" as const, label: "Cron" },
...(webhookDenial === undefined || draft.schedule.mode === "webhook"
? [{ value: "webhook" as const, label: "On webhook" }]
: []),
]}
selected={draft.schedule.mode}
onSelect={(mode) => {
setTimePickerOpen(false);
setRunAtPicker(null);
setDraft({
...draft,
prompt:
mode === "webhook" && !draft.prompt.trim()
? DEFAULT_WEBHOOK_PROMPT
: draft.prompt,
schedule: {
...draft.schedule,
mode,
runAt:
mode === "once" && !draft.schedule.runAt
? new Date(Math.ceil(Date.now() / 3_600_000) * 3_600_000).toISOString()
: draft.schedule.runAt,
},
});
}}
/>
</View>
<SelectRow
label="Runs"
value={SCHEDULE_MODE_LABELS[draft.schedule.mode]}
actions={[
"fixed_time" as const,
// Older servers reject one-time schedules on save.
...(offersOnce || draft.schedule.mode === "once" ? ["once" as const] : []),
"interval" as const,
"cron" as const,
...(webhookDenial === undefined || draft.schedule.mode === "webhook"
? ["webhook" as const]
: []),
].map((mode) => ({
id: mode,
title: SCHEDULE_MODE_LABELS[mode],
state: draft.schedule.mode === mode ? ("on" as const) : undefined,
}))}
onSelect={(id) => {
const mode = SCHEDULE_MODES.find((entry) => entry === id);
if (mode === undefined) return;
setTimePickerOpen(false);
setRunAtPicker(null);
setDraft({
...draft,
prompt:
mode === "webhook" && !draft.prompt.trim()
? DEFAULT_WEBHOOK_PROMPT
: draft.prompt,
schedule: {
...draft.schedule,
mode,
runAt:
mode === "once" && !draft.schedule.runAt
? new Date(Math.ceil(Date.now() / 3_600_000) * 3_600_000).toISOString()
: draft.schedule.runAt,
},
});
}}
/>
{draft.schedule.mode === "cron" ? (
<CronScheduleFields
repeat={draft.schedule.repeat}
Expand Down
9 changes: 9 additions & 0 deletions packages/client-runtime/src/scheduledTaskCron.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ describe("scheduled task repeat presets", () => {
expect(
describeSchedule({ type: "cron", expression: "0 7 * * 1-5", timezone: "UTC" }, at, "UTC"),
).toBe("Weekdays at 07:00");
const twelveHour = (time: string) => `${Number(time.slice(0, 2)) % 12 || 12}:${time.slice(3)}`;
expect(
describeSchedule(
{ type: "cron", expression: "30 14 * * 1-5", timezone: "UTC" },
at,
"UTC",
twelveHour,
),
).toBe("Weekdays at 2:30");
expect(
describeSchedule(
{ type: "cron", expression: "30 6 * * 0", timezone: "Asia/Bangkok" },
Expand Down
9 changes: 5 additions & 4 deletions packages/client-runtime/src/scheduledTaskCron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export function describeSchedule(
schedule: ScheduledTaskSchedule,
formatInstant: (iso: string) => string,
viewerTimeZone: string = localTimeZone(),
formatTimeOfDay: (time: string) => string = (time) => time,
): string {
switch (schedule.type) {
case "once":
Expand All @@ -89,7 +90,7 @@ export function describeSchedule(
days.length === 0 || days.length === 7
? "Every day"
: days.map((day) => WEEKDAY_NAMES[day]).join(", ");
return `${on} at ${schedule.timeOfDay}`;
return `${on} at ${formatTimeOfDay(schedule.timeOfDay)}`;
}
case "webhook":
return "On webhook";
Expand All @@ -100,11 +101,11 @@ export function describeSchedule(
const zone = schedule.timezone === viewerTimeZone ? "" : ` (${schedule.timezone})`;
switch (form.preset) {
case "daily":
return `Every day at ${form.time}${zone}`;
return `Every day at ${formatTimeOfDay(form.time)}${zone}`;
case "weekdays":
return `Weekdays at ${form.time}${zone}`;
return `Weekdays at ${formatTimeOfDay(form.time)}${zone}`;
case "weekly":
return `Every ${WEEKDAY_NAMES[form.weekday]} at ${form.time}${zone}`;
return `Every ${WEEKDAY_NAMES[form.weekday]} at ${formatTimeOfDay(form.time)}${zone}`;
case "hourly":
return `Every hour at :${form.time.slice(3)}${zone}`;
case "custom":
Expand Down
22 changes: 21 additions & 1 deletion packages/client-runtime/src/scheduledTaskTimeZone.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { expect, it } from "vite-plus/test";
import { expect, it, vi } from "vite-plus/test";

import { scheduleTimeZoneLabel } from "./scheduledTaskTimeZone.ts";

Expand All @@ -10,3 +10,23 @@ it("uses short names instead of full IANA zones", () => {
expect(scheduleTimeZoneLabel("America/Los_Angeles", "2027-01-15T17:00:00Z")).toBe("PST");
expect(scheduleTimeZoneLabel("America/Los_Angeles", "2027-07-15T16:00:00Z")).toBe("PDT");
});

it("keeps the offset when the zone name part drops it", () => {
const formatToParts = Intl.DateTimeFormat.prototype.formatToParts;
// Mimics an Intl that returns "GMT+7" as a "GMT" name followed by a "+7" literal.
using _parts = vi
.spyOn(Intl.DateTimeFormat.prototype, "formatToParts")
.mockImplementation(function (this: Intl.DateTimeFormat, date) {
return formatToParts.call(this, date).flatMap((part) =>
part.type === "timeZoneName" && /^GMT[+-]/.test(part.value)
? [
{ type: "timeZoneName" as const, value: "GMT" },
{ type: "literal" as const, value: part.value.slice(3) },
]
: [part],
);
});
expect(scheduleTimeZoneLabel("Asia/Bangkok", "2026-11-16T02:00:00Z")).toBe("GMT+7");
expect(scheduleTimeZoneLabel("Asia/Kolkata", "2026-11-16T03:30:00Z")).toBe("GMT+5:30");
expect(scheduleTimeZoneLabel("Europe/London", "2027-01-15T09:00:00Z")).toBe("GMT");
});
42 changes: 40 additions & 2 deletions packages/client-runtime/src/scheduledTaskTimeZone.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,38 @@
import * as DateTime from "effect/DateTime";

const MINUTE_MS = 60_000;

/** Offset from UTC in minutes, read from numeric date parts rather than the zone name. */
function zoneOffsetMinutes(timeZone: string, at: number): number {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
hourCycle: "h23",
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
}).formatToParts(at);
const part = (type: Intl.DateTimeFormatPartTypes) =>
Number(parts.find((entry) => entry.type === type)?.value);
const wallClock = Date.UTC(
part("year"),
part("month") - 1,
part("day"),
part("hour"),
part("minute"),
);
return Math.round((wallClock - Math.floor(at / MINUTE_MS) * MINUTE_MS) / MINUTE_MS);
}

function offsetLabel(minutes: number): string {
if (minutes === 0) return "GMT";
const absolute = Math.abs(minutes);
const hours = Math.floor(absolute / 60);
const rest = absolute % 60;
return `GMT${minutes < 0 ? "-" : "+"}${hours}${rest === 0 ? "" : `:${String(rest).padStart(2, "0")}`}`;
}

/**
* Short label for the server zone a fixed-time schedule runs in. With a run time it names
* that instant's offset (PST/PDT); without one it uses the generic name (PT).
Expand All @@ -11,12 +44,17 @@ export function scheduleTimeZoneLabel(
if (timeZone === undefined) return undefined;
if (timeZone === "Etc/UTC" || timeZone === "UTC" || timeZone === "GMT") return "UTC";
try {
return new Intl.DateTimeFormat("en-US", {
const at = runAt
? DateTime.toEpochMillis(DateTime.makeUnsafe(runAt))
: DateTime.toEpochMillis(DateTime.nowUnsafe());
const name = new Intl.DateTimeFormat("en-US", {
timeZone,
timeZoneName: runAt ? "short" : "shortGeneric",
})
.formatToParts(runAt ? DateTime.toEpochMillis(DateTime.makeUnsafe(runAt)) : undefined)
.formatToParts(at)
.find((part) => part.type === "timeZoneName")?.value;
// Hermes on iOS can return "GMT+7" as a bare "GMT" name, which would claim the wrong zone.
return name === "GMT" ? offsetLabel(zoneOffsetMinutes(timeZone, at)) : name;
} catch {
return undefined;
}
Expand Down
Loading