Add workflow duration sliders

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-14 10:31:25 -07:00
parent 321047feb9
commit 37ba923007
9 changed files with 418 additions and 52 deletions
@@ -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 (
<div className="space-y-1.5">
<FieldLabel htmlFor={id}>{label}</FieldLabel>
<div className="flex items-center gap-4">
<div className="relative flex h-9 min-w-0 flex-1 items-center">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 h-1.5 overflow-hidden rounded-full bg-muted"
>
<div
className="h-full rounded-full bg-primary"
style={{ width: `${progress}%` }}
/>
</div>
<input
aria-label={`${label} slider`}
aria-valuetext={formatDurationSeconds(sliderSeconds)}
className="absolute inset-x-0 h-9 w-full cursor-pointer appearance-none bg-transparent disabled:cursor-not-allowed disabled:opacity-50 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-primary [&::-moz-range-thumb]:bg-background [&::-moz-range-thumb]:shadow-sm [&::-moz-range-track]:bg-transparent [&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:bg-transparent [&::-webkit-slider-thumb]:mt-[-0.3125rem] [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-primary [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-sm focus-visible:outline-hidden focus-visible:[&::-moz-range-thumb]:ring-2 focus-visible:[&::-moz-range-thumb]:ring-ring focus-visible:[&::-moz-range-thumb]:ring-offset-2 focus-visible:[&::-webkit-slider-thumb]:ring-2 focus-visible:[&::-webkit-slider-thumb]:ring-ring focus-visible:[&::-webkit-slider-thumb]:ring-offset-2"
disabled={disabled}
max={DURATION_SLIDER_STOPS.length - 1}
min={0}
onChange={(event) => {
const seconds = DURATION_SLIDER_STOPS[Number(event.target.value)];
onChange(formatDurationSeconds(seconds));
}}
type="range"
value={sliderIndex}
/>
</div>
<Input
autoCapitalize="off"
autoCorrect="off"
className="w-28 shrink-0 text-center tabular-nums"
disabled={disabled}
id={id}
onBlur={() => {
if (parsedSeconds !== null) {
onChange(
formatDurationSeconds(
Math.max(DURATION_SLIDER_STOPS[0], parsedSeconds),
),
);
}
}}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
spellCheck={false}
value={value}
/>
</div>
</div>
);
}
@@ -443,6 +443,9 @@ export function WorkflowFormBuilder({
id: nextStepId(formState.steps),
action,
};
if (action === "delay") {
newStep.duration = "1s";
}
if (action === "call_webhook") {
newStep.method = "POST";
}
@@ -711,6 +714,9 @@ export function WorkflowFormBuilder({
labels={ACTION_LABELS}
onChange={(action) => {
const next = { ...selectedStep, action };
if (action === "delay" && !next.duration) {
next.duration = "1s";
}
if (action === "call_webhook" && !next.method) {
next.method = "POST";
}
@@ -6,12 +6,16 @@ import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { ChannelCombobox } from "./ChannelCombobox";
import { WorkflowDurationField } from "./WorkflowDurationField";
import { WorkflowEmojiField } from "./WorkflowEmojiField";
import { WorkflowTemplateTextarea } from "./WorkflowTemplateTextarea";
import { FieldLabel, FormSelect } from "./workflowFormPrimitives";
import { WorkflowWebhookHeadersEditor } from "./WorkflowWebhookHeadersEditor";
import type { StepFormState, TriggerType } from "./workflowFormTypes";
const DEFAULT_STEP_TIMEOUT_SECONDS = 5 * 60;
const DEFAULT_APPROVAL_TIMEOUT_SECONDS = 24 * 60 * 60;
function BackendSupportHint({ action }: { action: StepFormState["action"] }) {
switch (action) {
case "send_dm":
@@ -69,19 +73,12 @@ function StepConfigFields({
switch (step.action) {
case "delay":
return (
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-duration`}>Duration</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
id={`${prefix}-duration`}
onChange={(event) =>
onUpdate({ ...step, duration: event.target.value })
}
placeholder="e.g. 5s, 1m, 1h"
value={step.duration ?? ""}
/>
</div>
<WorkflowDurationField
disabled={disabled}
id={`${prefix}-duration`}
onChange={(duration) => onUpdate({ ...step, duration })}
value={step.duration ?? ""}
/>
);
case "send_message":
return (
@@ -253,21 +250,15 @@ function StepConfigFields({
value={step.message ?? ""}
/>
</div>
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-timeout`}>
Timeout (optional)
</FieldLabel>
<Input
autoCapitalize="off"
disabled={disabled}
id={`${prefix}-timeout`}
onChange={(event) =>
onUpdate({ ...step, timeout: event.target.value })
}
placeholder="e.g. 24h"
value={step.timeout ?? ""}
/>
</div>
<WorkflowDurationField
disabled={disabled}
fallbackSeconds={DEFAULT_APPROVAL_TIMEOUT_SECONDS}
id={`${prefix}-timeout`}
label="Timeout (optional)"
onChange={(timeout) => onUpdate({ ...step, timeout })}
placeholder="24h"
value={step.timeout ?? ""}
/>
</div>
);
case "add_reaction":
@@ -380,19 +371,14 @@ export function WorkflowStepCard({
<section className="space-y-4 border-t border-border/50 py-5">
<SectionHeading title="Step timeout" />
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-timeout-secs`}>
Timeout (seconds)
</FieldLabel>
<Input
autoCapitalize="off"
<div className="space-y-2">
<WorkflowDurationField
disabled={disabled}
fallbackSeconds={DEFAULT_STEP_TIMEOUT_SECONDS}
id={`${prefix}-timeout-secs`}
inputMode="numeric"
onChange={(event) =>
onUpdate({ ...step, timeoutSecs: event.target.value })
}
placeholder="e.g. 300"
label="Timeout (optional)"
onChange={(timeoutSecs) => onUpdate({ ...step, timeoutSecs })}
placeholder="5m"
value={step.timeoutSecs ?? ""}
/>
<p className="text-xs text-muted-foreground">
@@ -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);
});
@@ -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;
}
@@ -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/);
});
@@ -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<string, unknown> {
@@ -142,7 +164,9 @@ function actionFieldsForStep(step: StepFormState): Record<string, unknown> {
switch (step.action) {
case "delay":
if (step.duration) fields.duration = step.duration;
if (step.duration) {
fields.duration = serializeDuration(step.duration);
}
break;
case "send_message":
if (step.text) fields.text = step.text;
@@ -164,7 +188,7 @@ function actionFieldsForStep(step: StepFormState): Record<string, unknown> {
case "request_approval":
if (step.from) fields.from = step.from;
if (step.message) fields.message = step.message;
if (step.timeout) fields.timeout = step.timeout;
if (step.timeout) fields.timeout = serializeDuration(step.timeout);
break;
case "add_reaction":
if (step.emoji) fields.emoji = step.emoji;
@@ -281,9 +305,9 @@ export function yamlToFormState(
action: (step.action as ActionType) ?? ACTION_TYPES[0],
timeoutSecs:
step.timeout_secs !== undefined
? String(step.timeout_secs)
? presentDuration(step.timeout_secs)
: undefined,
duration: step.duration as string | undefined,
duration: presentDuration(step.duration),
text: step.text as string | undefined,
channel: step.channel as string | undefined,
to: step.to as string | undefined,
@@ -298,7 +322,7 @@ export function yamlToFormState(
topic: step.topic as string | undefined,
from: step.from as string | undefined,
message: step.message as string | undefined,
timeout: step.timeout as string | undefined,
timeout: presentDuration(step.timeout),
}),
);
@@ -31,7 +31,15 @@ test("describes configured workflow steps on the canvas", () => {
action: "delay",
duration: "5m",
}),
"5m",
"5 minutes",
);
assert.equal(
workflowStepDescription({
id: "long-delay",
action: "delay",
duration: "4w",
}),
"4 weeks",
);
assert.equal(
workflowStepDescription({
@@ -1,4 +1,8 @@
import { truncatePubkey } from "@/shared/lib/pubkey";
import {
formatDurationSecondsVerbose,
parseDurationSeconds,
} from "./workflowDuration";
import { ACTION_LABELS } from "./workflowFormTypes";
import type { StepFormState } from "./workflowFormTypes";
@@ -29,8 +33,14 @@ function configuredStepDetail(
channelLabel?: string,
): string | null {
switch (step.action) {
case "delay":
return step.duration?.trim() || null;
case "delay": {
const duration = step.duration?.trim();
if (!duration) return null;
const seconds = parseDurationSeconds(duration);
return compact(
seconds === null ? duration : formatDurationSecondsVerbose(seconds),
);
}
case "send_message": {
const text = quoted(step.text);
const channel = channelLabel ? `#${channelLabel}` : null;