diff --git a/desktop/src/features/workflows/ui/WorkflowDurationField.tsx b/desktop/src/features/workflows/ui/WorkflowDurationField.tsx new file mode 100644 index 000000000..898eba3d8 --- /dev/null +++ b/desktop/src/features/workflows/ui/WorkflowDurationField.tsx @@ -0,0 +1,87 @@ +import { Input } from "@/shared/ui/input"; +import { FieldLabel } from "./workflowFormPrimitives"; +import { + DEFAULT_DURATION_SECONDS, + DURATION_SLIDER_STOPS, + durationSliderIndex, + formatDurationSeconds, + parseDurationSeconds, +} from "./workflowDuration"; + +export function WorkflowDurationField({ + disabled, + fallbackSeconds = DEFAULT_DURATION_SECONDS, + id, + label = "Duration", + onChange, + placeholder = "1s", + value, +}: { + disabled?: boolean; + fallbackSeconds?: number; + id: string; + label?: string; + onChange: (value: string) => void; + placeholder?: string; + value: string; +}) { + const parsedSeconds = parseDurationSeconds(value); + const sliderIndex = durationSliderIndex( + Math.max(DURATION_SLIDER_STOPS[0], parsedSeconds ?? fallbackSeconds), + ); + const progress = (sliderIndex / (DURATION_SLIDER_STOPS.length - 1)) * 100; + const sliderSeconds = DURATION_SLIDER_STOPS[sliderIndex]; + + return ( +
diff --git a/desktop/src/features/workflows/ui/workflowDuration.test.mjs b/desktop/src/features/workflows/ui/workflowDuration.test.mjs
new file mode 100644
index 000000000..378c9a96a
--- /dev/null
+++ b/desktop/src/features/workflows/ui/workflowDuration.test.mjs
@@ -0,0 +1,55 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ DURATION_SLIDER_STOPS,
+ durationSliderIndex,
+ formatDurationSeconds,
+ formatDurationSecondsVerbose,
+ parseDurationSeconds,
+} from "./workflowDuration.ts";
+
+test("parseDurationSeconds accepts compact and combined whole-second durations", () => {
+ assert.equal(parseDurationSeconds("5s"), 5);
+ assert.equal(parseDurationSeconds("90m"), 5_400);
+ assert.equal(parseDurationSeconds("1h 2s"), 3_602);
+ assert.equal(parseDurationSeconds("1H2M3S"), 3_723);
+ assert.equal(parseDurationSeconds("2d"), 172_800);
+ assert.equal(parseDurationSeconds("2w 3d 4h 5m 6s"), 1_483_506);
+ assert.equal(parseDurationSeconds("42"), 42);
+ assert.equal(parseDurationSeconds("0s"), 0);
+});
+
+test("parseDurationSeconds rejects empty, malformed, and fractional values", () => {
+ assert.equal(parseDurationSeconds(""), null);
+ assert.equal(parseDurationSeconds("1.5m"), null);
+ assert.equal(parseDurationSeconds("1m later"), null);
+});
+
+test("formatDurationSeconds produces compact labels with significant units", () => {
+ assert.equal(formatDurationSeconds(0), "0s");
+ assert.equal(formatDurationSeconds(5), "5s");
+ assert.equal(formatDurationSeconds(62), "1m 2s");
+ assert.equal(formatDurationSeconds(3_602), "1h 2s");
+ assert.equal(formatDurationSeconds(7_323), "2h 2m 3s");
+ assert.equal(formatDurationSeconds(172_800), "2d");
+ assert.equal(formatDurationSeconds(1_483_506), "2w 3d 4h 5m 6s");
+});
+
+test("formatDurationSecondsVerbose spells out units with correct plurals", () => {
+ assert.equal(formatDurationSecondsVerbose(0), "0 seconds");
+ assert.equal(formatDurationSecondsVerbose(300), "5 minutes");
+ assert.equal(formatDurationSecondsVerbose(604_800), "1 week");
+ assert.equal(
+ formatDurationSecondsVerbose(1_483_506),
+ "2 weeks 3 days 4 hours 5 minutes 6 seconds",
+ );
+});
+
+test("duration slider starts at one second, keeps fine short-delay stops, and reaches three hours", () => {
+ assert.deepEqual(DURATION_SLIDER_STOPS.slice(0, 3), [1, 2, 3]);
+ assert.equal(DURATION_SLIDER_STOPS.at(-1), 10_800);
+ assert.equal(DURATION_SLIDER_STOPS[durationSliderIndex(62)], 62);
+ assert.equal(DURATION_SLIDER_STOPS[durationSliderIndex(300)], 300);
+ assert.equal(DURATION_SLIDER_STOPS[durationSliderIndex(3_602)], 3_600);
+});
diff --git a/desktop/src/features/workflows/ui/workflowDuration.ts b/desktop/src/features/workflows/ui/workflowDuration.ts
new file mode 100644
index 000000000..2e1e32350
--- /dev/null
+++ b/desktop/src/features/workflows/ui/workflowDuration.ts
@@ -0,0 +1,127 @@
+const DURATION_PARTS_PATTERN =
+ /^\s*(?:(\d+)\s*w)?\s*(?:(\d+)\s*d)?\s*(?:(\d+)\s*h)?\s*(?:(\d+)\s*m)?\s*(?:(\d+)\s*s)?\s*$/i;
+
+const SECONDS_PER_MINUTE = 60;
+const SECONDS_PER_HOUR = 60 * SECONDS_PER_MINUTE;
+const SECONDS_PER_DAY = 24 * SECONDS_PER_HOUR;
+const SECONDS_PER_WEEK = 7 * SECONDS_PER_DAY;
+
+/** Parse compact durations such as `5s`, `1h 2s`, `2d`, or `3w`. */
+export function parseDurationSeconds(value: string): number | null {
+ const trimmed = value.trim();
+ if (!trimmed) return null;
+
+ if (/^\d+$/.test(trimmed)) {
+ const seconds = Number(trimmed);
+ return Number.isSafeInteger(seconds) ? seconds : null;
+ }
+
+ const match = DURATION_PARTS_PATTERN.exec(trimmed);
+ if (!match || match.slice(1).every((part) => part === undefined)) return null;
+
+ const weeks = Number(match[1] ?? 0);
+ const days = Number(match[2] ?? 0);
+ const hours = Number(match[3] ?? 0);
+ const minutes = Number(match[4] ?? 0);
+ const seconds = Number(match[5] ?? 0);
+ const total =
+ weeks * SECONDS_PER_WEEK +
+ days * SECONDS_PER_DAY +
+ hours * SECONDS_PER_HOUR +
+ minutes * SECONDS_PER_MINUTE +
+ seconds;
+
+ return Number.isSafeInteger(total) ? total : null;
+}
+
+/** Format whole seconds as a compact duration, omitting empty units. */
+export function formatDurationSeconds(totalSeconds: number): string {
+ if (!Number.isSafeInteger(totalSeconds) || totalSeconds < 0) return "";
+ if (totalSeconds === 0) return "0s";
+
+ const weeks = Math.floor(totalSeconds / SECONDS_PER_WEEK);
+ const days = Math.floor((totalSeconds % SECONDS_PER_WEEK) / SECONDS_PER_DAY);
+ const hours = Math.floor((totalSeconds % SECONDS_PER_DAY) / SECONDS_PER_HOUR);
+ const minutes = Math.floor(
+ (totalSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE,
+ );
+ const seconds = totalSeconds % SECONDS_PER_MINUTE;
+ const parts: string[] = [];
+
+ if (weeks > 0) parts.push(`${weeks}w`);
+ if (days > 0) parts.push(`${days}d`);
+ if (hours > 0) parts.push(`${hours}h`);
+ if (minutes > 0) parts.push(`${minutes}m`);
+ if (seconds > 0) parts.push(`${seconds}s`);
+
+ return parts.join(" ");
+}
+
+function verboseUnit(value: number, unit: string): string {
+ return `${value} ${unit}${value === 1 ? "" : "s"}`;
+}
+
+/** Format whole seconds with fully spelled-out units for summary UI. */
+export function formatDurationSecondsVerbose(totalSeconds: number): string {
+ if (!Number.isSafeInteger(totalSeconds) || totalSeconds < 0) return "";
+ if (totalSeconds === 0) return "0 seconds";
+
+ const weeks = Math.floor(totalSeconds / SECONDS_PER_WEEK);
+ const days = Math.floor((totalSeconds % SECONDS_PER_WEEK) / SECONDS_PER_DAY);
+ const hours = Math.floor((totalSeconds % SECONDS_PER_DAY) / SECONDS_PER_HOUR);
+ const minutes = Math.floor(
+ (totalSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE,
+ );
+ const seconds = totalSeconds % SECONDS_PER_MINUTE;
+ const parts: string[] = [];
+
+ if (weeks > 0) parts.push(verboseUnit(weeks, "week"));
+ if (days > 0) parts.push(verboseUnit(days, "day"));
+ if (hours > 0) parts.push(verboseUnit(hours, "hour"));
+ if (minutes > 0) parts.push(verboseUnit(minutes, "minute"));
+ if (seconds > 0) parts.push(verboseUnit(seconds, "second"));
+
+ return parts.join(" ");
+}
+
+function steppedRange(start: number, end: number, step: number): number[] {
+ const values: number[] = [];
+ for (let value = start; value <= end; value += step) values.push(value);
+ return values;
+}
+
+/**
+ * Slider stops favor the short delays people use most, then relax precision as
+ * the duration grows. The typed field still accepts exact values between stops.
+ */
+export const DURATION_SLIDER_STOPS = [
+ ...steppedRange(1, 120, 1),
+ ...steppedRange(125, 600, 5),
+ ...steppedRange(615, 1_800, 15),
+ ...steppedRange(1_860, 7_200, 60),
+ ...steppedRange(7_500, 10_800, 300),
+];
+
+export const DEFAULT_DURATION_SECONDS = 1;
+
+export function durationSliderIndex(totalSeconds: number): number {
+ if (totalSeconds <= DURATION_SLIDER_STOPS[0]) return 0;
+
+ const lastIndex = DURATION_SLIDER_STOPS.length - 1;
+ if (totalSeconds >= DURATION_SLIDER_STOPS[lastIndex]) return lastIndex;
+
+ let low = 0;
+ let high = lastIndex;
+ while (low <= high) {
+ const middle = Math.floor((low + high) / 2);
+ const value = DURATION_SLIDER_STOPS[middle];
+ if (value === totalSeconds) return middle;
+ if (value < totalSeconds) low = middle + 1;
+ else high = middle - 1;
+ }
+
+ return totalSeconds - DURATION_SLIDER_STOPS[high] <=
+ DURATION_SLIDER_STOPS[low] - totalSeconds
+ ? high
+ : low;
+}
diff --git a/desktop/src/features/workflows/ui/workflowFormTypes.test.mjs b/desktop/src/features/workflows/ui/workflowFormTypes.test.mjs
index 549c4adb4..0e78e70d2 100644
--- a/desktop/src/features/workflows/ui/workflowFormTypes.test.mjs
+++ b/desktop/src/features/workflows/ui/workflowFormTypes.test.mjs
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
-import { yamlToFormState } from "./workflowFormTypes.ts";
+import { formStateToYaml, yamlToFormState } from "./workflowFormTypes.ts";
test("keeps workflows with step conditions in the YAML editor", () => {
const result = yamlToFormState(`name: conditional_step
@@ -19,3 +19,66 @@ steps:
error: "Step conditions are only available in the YAML editor",
});
});
+
+test("presents saved delay durations nicely and serializes them for the backend", () => {
+ const parsed = yamlToFormState(`name: delayed_workflow
+trigger:
+ on: message_posted
+steps:
+ - id: wait
+ action: delay
+ duration: 3602s
+`);
+
+ assert.equal(parsed.ok, true);
+ if (!parsed.ok) return;
+
+ assert.equal(parsed.state.steps[0].duration, "1h 2s");
+ assert.match(formStateToYaml(parsed.state), /duration: 3602s/);
+});
+
+test("presents UI timeouts nicely and serializes numeric seconds", () => {
+ const parsed = yamlToFormState(`name: timeout_workflow
+trigger:
+ on: message_posted
+steps:
+ - id: notify
+ action: send_message
+ timeout_secs: 3602
+ text: hello
+ - id: approve
+ action: request_approval
+ from: manager
+ message: Ship it?
+ timeout: 2d
+`);
+
+ assert.equal(parsed.ok, true);
+ if (!parsed.ok) return;
+
+ assert.equal(parsed.state.steps[0].timeoutSecs, "1h 2s");
+ assert.equal(parsed.state.steps[1].timeout, "2d");
+
+ const serialized = formStateToYaml(parsed.state);
+ assert.match(serialized, /timeout_secs: 3602/);
+ assert.match(serialized, /timeout: 172800s/);
+});
+
+test("clamps explicit zero delays and timeouts to the one-second UI minimum", () => {
+ const parsed = yamlToFormState(`name: minimum_workflow
+trigger:
+ on: message_posted
+steps:
+ - id: wait
+ action: delay
+ timeout_secs: 0
+ duration: 0s
+`);
+
+ assert.equal(parsed.ok, true);
+ if (!parsed.ok) return;
+
+ const serialized = formStateToYaml(parsed.state);
+ assert.match(serialized, /timeout_secs: 1/);
+ assert.match(serialized, /duration: 1s/);
+});
diff --git a/desktop/src/features/workflows/ui/workflowFormTypes.ts b/desktop/src/features/workflows/ui/workflowFormTypes.ts
index ad681499a..c7c34b4fc 100644
--- a/desktop/src/features/workflows/ui/workflowFormTypes.ts
+++ b/desktop/src/features/workflows/ui/workflowFormTypes.ts
@@ -1,4 +1,8 @@
import { stringify as yamlStringify, parse as yamlParse } from "yaml";
+import {
+ formatDurationSeconds,
+ parseDurationSeconds,
+} from "./workflowDuration";
export const TRIGGER_TYPES = [
"message_posted",
@@ -128,10 +132,28 @@ function headersToRecord(
function parseTimeoutSecs(timeoutSecs: string | undefined): number | undefined {
if (!timeoutSecs) return undefined;
- const trimmed = timeoutSecs.trim();
- if (!/^\d+$/.test(trimmed)) return undefined;
- const parsed = Number(trimmed);
- return parsed > 0 ? parsed : undefined;
+ const parsed = parseDurationSeconds(timeoutSecs);
+ return parsed === null ? undefined : Math.max(1, parsed);
+}
+
+function presentDuration(duration: unknown): string | undefined {
+ if (typeof duration === "number") {
+ return Number.isSafeInteger(duration) && duration >= 0
+ ? formatDurationSeconds(duration)
+ : undefined;
+ }
+ if (typeof duration !== "string") return undefined;
+ const durationSeconds = parseDurationSeconds(duration.trim());
+ return durationSeconds === null
+ ? duration
+ : formatDurationSeconds(durationSeconds);
+}
+
+function serializeDuration(duration: string): string {
+ const durationSeconds = parseDurationSeconds(duration);
+ return durationSeconds === null
+ ? duration.trim()
+ : `${Math.max(1, durationSeconds)}s`;
}
function actionFieldsForStep(step: StepFormState): Record