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 ( +
+ {label} +
+
+ + { + if (parsedSeconds !== null) { + onChange( + formatDurationSeconds( + Math.max(DURATION_SLIDER_STOPS[0], parsedSeconds), + ), + ); + } + }} + onChange={(event) => onChange(event.target.value)} + placeholder={placeholder} + spellCheck={false} + value={value} + /> +
+
+ ); +} diff --git a/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx b/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx index ad734a788..ac39bb2ee 100644 --- a/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx +++ b/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx @@ -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"; } diff --git a/desktop/src/features/workflows/ui/WorkflowStepCard.tsx b/desktop/src/features/workflows/ui/WorkflowStepCard.tsx index cae2f79cd..e80ee4892 100644 --- a/desktop/src/features/workflows/ui/WorkflowStepCard.tsx +++ b/desktop/src/features/workflows/ui/WorkflowStepCard.tsx @@ -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 ( -
- Duration - - onUpdate({ ...step, duration: event.target.value }) - } - placeholder="e.g. 5s, 1m, 1h" - value={step.duration ?? ""} - /> -
+ onUpdate({ ...step, duration })} + value={step.duration ?? ""} + /> ); case "send_message": return ( @@ -253,21 +250,15 @@ function StepConfigFields({ value={step.message ?? ""} />
-
- - Timeout (optional) - - - onUpdate({ ...step, timeout: event.target.value }) - } - placeholder="e.g. 24h" - value={step.timeout ?? ""} - /> -
+ onUpdate({ ...step, timeout })} + placeholder="24h" + value={step.timeout ?? ""} + /> ); case "add_reaction": @@ -380,19 +371,14 @@ export function WorkflowStepCard({
-
- - Timeout (seconds) - - + - onUpdate({ ...step, timeoutSecs: event.target.value }) - } - placeholder="e.g. 300" + label="Timeout (optional)" + onChange={(timeoutSecs) => onUpdate({ ...step, timeoutSecs })} + placeholder="5m" value={step.timeoutSecs ?? ""} />

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 { @@ -142,7 +164,9 @@ function actionFieldsForStep(step: StepFormState): Record { 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 { 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), }), ); diff --git a/desktop/src/features/workflows/ui/workflowStepDescription.test.mjs b/desktop/src/features/workflows/ui/workflowStepDescription.test.mjs index 7253692e7..e8fad1714 100644 --- a/desktop/src/features/workflows/ui/workflowStepDescription.test.mjs +++ b/desktop/src/features/workflows/ui/workflowStepDescription.test.mjs @@ -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({ diff --git a/desktop/src/features/workflows/ui/workflowStepDescription.ts b/desktop/src/features/workflows/ui/workflowStepDescription.ts index cddce5d9e..136ce0261 100644 --- a/desktop/src/features/workflows/ui/workflowStepDescription.ts +++ b/desktop/src/features/workflows/ui/workflowStepDescription.ts @@ -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;