@checkstack/automation-frontend 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +352 -0
  2. package/package.json +13 -9
  3. package/src/components/AutomationGroupCombobox.tsx +133 -0
  4. package/src/editor/ActionEditor.tsx +180 -90
  5. package/src/editor/ActionListEditor.tsx +27 -1
  6. package/src/editor/AddActionDialog.tsx +15 -45
  7. package/src/editor/AddConditionDialog.tsx +86 -0
  8. package/src/editor/AddTriggerDialog.tsx +97 -0
  9. package/src/editor/AutomationDefinitionEditor.tsx +41 -2
  10. package/src/editor/ConditionEditor.tsx +359 -70
  11. package/src/editor/ConditionsEditor.tsx +113 -44
  12. package/src/editor/ItemSheet.tsx +51 -0
  13. package/src/editor/RunReplayPicker.tsx +97 -0
  14. package/src/editor/ScriptServicesBooter.tsx +53 -0
  15. package/src/editor/ScriptTestRenderer.tsx +150 -0
  16. package/src/editor/SystemEntityPicker.test.ts +37 -0
  17. package/src/editor/SystemEntityPicker.tsx +109 -0
  18. package/src/editor/TriggersEditor.tsx +345 -137
  19. package/src/editor/action-helpers.test.ts +107 -0
  20. package/src/editor/action-helpers.ts +72 -0
  21. package/src/editor/action-leaf-cards.tsx +98 -1
  22. package/src/editor/condition-kind.test.ts +126 -0
  23. package/src/editor/condition-kind.ts +130 -0
  24. package/src/editor/item-summary.test.ts +171 -0
  25. package/src/editor/item-summary.ts +210 -0
  26. package/src/editor/picker-dialog.tsx +156 -0
  27. package/src/editor/registry-context.tsx +9 -2
  28. package/src/editor/script-actions.test.ts +184 -0
  29. package/src/editor/script-actions.ts +146 -0
  30. package/src/editor/system-entity-picker.logic.ts +23 -0
  31. package/src/editor/template-completion.test.ts +22 -3
  32. package/src/editor/template-completion.ts +16 -8
  33. package/src/editor/template-helpers.ts +4 -0
  34. package/src/editor/trigger-helpers.test.ts +28 -0
  35. package/src/editor/trigger-helpers.ts +17 -0
  36. package/src/editor/useScriptDiagnostics.ts +108 -0
  37. package/src/index.tsx +2 -0
  38. package/src/pages/AutomationEditPage.tsx +95 -47
  39. package/src/pages/AutomationListPage.tsx +172 -123
  40. package/src/pages/automation-grouping.test.ts +86 -0
  41. package/src/pages/automation-grouping.ts +65 -0
  42. package/src/script-context.test.ts +142 -1
  43. package/src/script-context.ts +115 -0
  44. package/tsconfig.json +12 -0
@@ -12,11 +12,29 @@ import {
12
12
  import { TriggersEditor } from "./TriggersEditor";
13
13
  import { ConditionsEditor } from "./ConditionsEditor";
14
14
  import { ActionListEditor } from "./ActionListEditor";
15
+ import { ValidationProvider, type DefinitionIssue } from "./editor-validation";
16
+ import { useScriptDiagnostics } from "./useScriptDiagnostics";
17
+ import { collectScriptActions } from "./script-actions";
18
+ import { ScriptServicesBooter } from "./ScriptServicesBooter";
15
19
 
16
20
  export interface AutomationDefinitionEditorProps {
17
21
  value: AutomationDefinition;
18
22
  onChange: (next: AutomationDefinition) => void;
19
23
  disabled?: boolean;
24
+ /**
25
+ * Id of the automation being edited, if it already exists (omitted for
26
+ * a brand-new, unsaved automation). Enables the script-test "Load from
27
+ * run" picker, which lists this automation's prior runs.
28
+ */
29
+ automationId?: string;
30
+ /**
31
+ * Structural validation issues (from the backend `validateDefinition`),
32
+ * merged here with the live, frontend-computed inline-script TYPE issues so
33
+ * both surface through the same per-card badges. Script type-checking lives
34
+ * inside this component because it needs the registry (trigger/action
35
+ * catalog) the `AutomationRegistryProvider` supplies.
36
+ */
37
+ structuralIssues?: DefinitionIssue[];
20
38
  }
21
39
 
22
40
  /**
@@ -31,7 +49,7 @@ export interface AutomationDefinitionEditorProps {
31
49
  export const AutomationDefinitionEditor: React.FC<
32
50
  AutomationDefinitionEditorProps
33
51
  > = (props) => (
34
- <AutomationRegistryProvider>
52
+ <AutomationRegistryProvider automationId={props.automationId}>
35
53
  <AutomationDefinitionProvider definition={props.value}>
36
54
  <EditorBody {...props} />
37
55
  </AutomationDefinitionProvider>
@@ -42,8 +60,26 @@ const EditorBody: React.FC<AutomationDefinitionEditorProps> = ({
42
60
  value,
43
61
  onChange,
44
62
  disabled,
63
+ structuralIssues = [],
45
64
  }) => {
46
- const { loading } = useAutomationRegistry();
65
+ const { loading, actions } = useAutomationRegistry();
66
+
67
+ // Live type-check of every inline script action (collapsed cards included),
68
+ // merged with the backend's structural issues so both drive the same
69
+ // per-card badges via `ValidationProvider`.
70
+ const scriptIssues = useScriptDiagnostics({ definition: value });
71
+ const issues = React.useMemo(
72
+ () => [...structuralIssues, ...scriptIssues],
73
+ [structuralIssues, scriptIssues],
74
+ );
75
+
76
+ // When the automation already contains inline scripts, boot the editor
77
+ // services up-front (a hidden editor) so those scripts validate the moment
78
+ // the automation opens - not only after a card is expanded.
79
+ const hasScriptActions = React.useMemo(
80
+ () => collectScriptActions({ definition: value, actions }).length > 0,
81
+ [value, actions],
82
+ );
47
83
 
48
84
  // Scope at the root path = `trigger.*` only. The conditions editor
49
85
  // uses `variableNodes` for the explicit "fx" tree and
@@ -55,6 +91,8 @@ const EditorBody: React.FC<AutomationDefinitionEditorProps> = ({
55
91
  });
56
92
 
57
93
  return (
94
+ <ValidationProvider issues={issues}>
95
+ {hasScriptActions && <ScriptServicesBooter />}
58
96
  <div className="space-y-4">
59
97
  {loading && (
60
98
  <Card className="border-dashed">
@@ -95,5 +133,6 @@ const EditorBody: React.FC<AutomationDefinitionEditorProps> = ({
95
133
  </CardContent>
96
134
  </Card>
97
135
  </div>
136
+ </ValidationProvider>
98
137
  );
99
138
  };
@@ -4,43 +4,38 @@ import {
4
4
  Button,
5
5
  Card,
6
6
  CardContent,
7
+ DurationInput,
8
+ Input,
9
+ Label,
7
10
  Select,
8
11
  SelectContent,
12
+ SelectGroup,
9
13
  SelectItem,
14
+ SelectLabel,
15
+ SelectSeparator,
10
16
  SelectTrigger,
11
17
  SelectValue,
12
18
  TemplateValueInput,
19
+ TimeOfDayInput,
20
+ Toggle,
13
21
  VariablePicker,
22
+ type DurationValue,
14
23
  type TemplateCompletionProvider,
15
24
  type VariableNode,
16
25
  } from "@checkstack/ui";
17
- import type { ConditionInput } from "@checkstack/automation-common";
18
-
19
- type CombinatorKind = "expr" | "and" | "or" | "not";
20
-
21
- function kindOf(condition: ConditionInput): CombinatorKind {
22
- if (typeof condition === "string") return "expr";
23
- if ("and" in condition) return "and";
24
- if ("or" in condition) return "or";
25
- return "not";
26
- }
27
-
28
- function defaultForKind(kind: CombinatorKind): ConditionInput {
29
- switch (kind) {
30
- case "expr": {
31
- return "";
32
- }
33
- case "and": {
34
- return { and: [""] };
35
- }
36
- case "or": {
37
- return { or: [""] };
38
- }
39
- case "not": {
40
- return { not: "" };
41
- }
42
- }
43
- }
26
+ import type {
27
+ ConditionInput,
28
+ Duration,
29
+ NumericStateCondition,
30
+ StateCondition,
31
+ TimeCondition,
32
+ } from "@checkstack/automation-common";
33
+ import {
34
+ defaultForKind,
35
+ kindOf,
36
+ type ConditionKind,
37
+ } from "./condition-kind";
38
+ import { SystemEntityPicker } from "./SystemEntityPicker";
44
39
 
45
40
  export interface ConditionEditorProps {
46
41
  value: ConditionInput;
@@ -48,30 +43,39 @@ export interface ConditionEditorProps {
48
43
  variableNodes: VariableNode[];
49
44
  /**
50
45
  * Staged completion provider (expression mode) for the inline
51
- * expression input. Conditions are bare expressions no `{{ }}`
52
- * wrapper so this is the `expressionCompletion` from
46
+ * expression input. Conditions are bare expressions - no `{{ }}`
47
+ * wrapper - so this is the `expressionCompletion` from
53
48
  * `useVariableScope`. The hierarchical `variableNodes` powers the
54
49
  * explicit "fx" picker.
55
50
  */
56
51
  completionProvider: TemplateCompletionProvider;
57
- /** Render without the wrapping card used when inlining inside an action card. */
52
+ /** Render without the wrapping card - used when inlining inside an action card. */
58
53
  bare?: boolean;
54
+ /**
55
+ * Suppress the inline kind `Select`. Used only for the top-level condition in
56
+ * the Conditions-section sheet, where the kind is chosen up front via
57
+ * `AddConditionDialog` (swap kind = delete + re-add, matching actions).
58
+ * Nested combinator children and the action `condition`-guard body do NOT
59
+ * set this, so their inline selector stays. The `expr` "fx" picker still
60
+ * renders when the kind is `expr`, even with the selector hidden.
61
+ */
62
+ hideKindSelector?: boolean;
59
63
  depth?: number;
60
64
  }
61
65
 
62
66
  /**
63
- * Recursive editor over the `ConditionInput` discriminated union
64
- * (`string | { and } | { or } | { not }`). Each level renders:
67
+ * Recursive editor over the `ConditionInput` union. Each level renders a
68
+ * kind selector plus the matching body:
65
69
  *
66
- * - A kind selector ("expression" | "and" | "or" | "not").
67
- * - The matching body:
68
- * expr TemplateValueInput with `{{` autocomplete + the
69
- * VariablePicker "fx" trigger inline.
70
- * and / or list of child conditions with add/remove buttons,
71
- * each child rendered through this same component.
72
- * not → single child condition.
70
+ * - expression - TemplateValueInput + the VariablePicker "fx" trigger.
71
+ * - and / or - list of child conditions (recursive).
72
+ * - not - single child condition.
73
+ * - numeric_state - value (path/template) + operator + threshold.
74
+ * - time - after / before (TimeOfDayInput) + weekday + timezone.
75
+ * - state - entity + status + optional dwell (DurationInput).
73
76
  *
74
- * No depth cap operator can nest arbitrarily, mirroring the runtime.
77
+ * The raw expression stays the escape hatch for anything the structured
78
+ * variants don't cover. No depth cap - operator can nest arbitrarily.
75
79
  */
76
80
  export const ConditionEditor: React.FC<ConditionEditorProps> = ({
77
81
  value,
@@ -79,42 +83,69 @@ export const ConditionEditor: React.FC<ConditionEditorProps> = ({
79
83
  variableNodes,
80
84
  completionProvider,
81
85
  bare,
86
+ hideKindSelector,
82
87
  depth = 0,
83
88
  }) => {
84
89
  const kind = kindOf(value);
85
90
 
86
91
  const body = (
87
92
  <div className="space-y-2">
88
- <div className="flex items-center gap-2">
89
- <Select
90
- value={kind}
91
- onValueChange={(next) =>
92
- onChange(defaultForKind(next as CombinatorKind))
93
- }
94
- >
95
- <SelectTrigger className="h-7 w-32 text-xs">
96
- <SelectValue />
97
- </SelectTrigger>
98
- <SelectContent>
99
- <SelectItem value="expr">expression</SelectItem>
100
- <SelectItem value="and">and</SelectItem>
101
- <SelectItem value="or">or</SelectItem>
102
- <SelectItem value="not">not</SelectItem>
103
- </SelectContent>
104
- </Select>
105
- {kind === "expr" && (
106
- <VariablePicker
107
- scope={variableNodes}
108
- onSelect={(path) => {
109
- // Conditions are bare expressions — insert the raw path,
110
- // not a `{{ … }}`-wrapped reference.
111
- const before = typeof value === "string" ? value : "";
112
- const sep = before.length > 0 && !before.endsWith(" ") ? " " : "";
113
- onChange(`${before}${sep}${path}`);
114
- }}
115
- />
116
- )}
117
- </div>
93
+ {/* The top row holds the kind selector and the `expr` "fx" picker. Render
94
+ it when EITHER is present; omit it entirely (e.g. hidden selector on a
95
+ non-expr top-level condition) so no empty row remains. */}
96
+ {(!hideKindSelector || kind === "expr") && (
97
+ <div className="flex items-center gap-2">
98
+ {!hideKindSelector && (
99
+ <Select
100
+ value={kind}
101
+ onValueChange={(next) =>
102
+ onChange(defaultForKind(next as ConditionKind))
103
+ }
104
+ >
105
+ <SelectTrigger className="h-7 w-40 text-xs">
106
+ <SelectValue />
107
+ </SelectTrigger>
108
+ <SelectContent>
109
+ {/* Structured kinds are the common case and lead the list. */}
110
+ <SelectGroup>
111
+ <SelectLabel>Structured</SelectLabel>
112
+ <SelectItem value="numeric_state">numeric state</SelectItem>
113
+ <SelectItem value="time">time of day</SelectItem>
114
+ <SelectItem value="state">system state</SelectItem>
115
+ </SelectGroup>
116
+ <SelectSeparator />
117
+ <SelectGroup>
118
+ <SelectLabel>Logical</SelectLabel>
119
+ <SelectItem value="and">and</SelectItem>
120
+ <SelectItem value="or">or</SelectItem>
121
+ <SelectItem value="not">not</SelectItem>
122
+ </SelectGroup>
123
+ <SelectSeparator />
124
+ {/* Raw expression stays the escape hatch for anything the
125
+ structured variants don't cover — de-emphasised at the
126
+ bottom, but still reachable. */}
127
+ <SelectGroup>
128
+ <SelectLabel>Advanced</SelectLabel>
129
+ <SelectItem value="expr">expression</SelectItem>
130
+ </SelectGroup>
131
+ </SelectContent>
132
+ </Select>
133
+ )}
134
+ {kind === "expr" && (
135
+ <VariablePicker
136
+ scope={variableNodes}
137
+ onSelect={(path) => {
138
+ // Conditions are bare expressions - insert the raw path,
139
+ // not a `{{ … }}`-wrapped reference.
140
+ const before = typeof value === "string" ? value : "";
141
+ const sep =
142
+ before.length > 0 && !before.endsWith(" ") ? " " : "";
143
+ onChange(`${before}${sep}${path}`);
144
+ }}
145
+ />
146
+ )}
147
+ </div>
148
+ )}
118
149
 
119
150
  {kind === "expr" && (
120
151
  <TemplateValueInput
@@ -125,6 +156,23 @@ export const ConditionEditor: React.FC<ConditionEditorProps> = ({
125
156
  />
126
157
  )}
127
158
 
159
+ {kind === "numeric_state" && (
160
+ <NumericStateBody
161
+ value={value as NumericStateCondition}
162
+ onChange={onChange}
163
+ variableNodes={variableNodes}
164
+ completionProvider={completionProvider}
165
+ />
166
+ )}
167
+
168
+ {kind === "time" && (
169
+ <TimeBody value={value as TimeCondition} onChange={onChange} />
170
+ )}
171
+
172
+ {kind === "state" && (
173
+ <StateBody value={value as StateCondition} onChange={onChange} />
174
+ )}
175
+
128
176
  {(kind === "and" || kind === "or") && (
129
177
  <CombinatorList
130
178
  kind={kind}
@@ -167,6 +215,247 @@ export const ConditionEditor: React.FC<ConditionEditorProps> = ({
167
215
  );
168
216
  };
169
217
 
218
+ // ─── numeric_state ─────────────────────────────────────────────────────
219
+
220
+ type NumericOp = "above" | "below" | "between";
221
+
222
+ function numericOpOf(c: NumericStateCondition["numeric_state"]): NumericOp {
223
+ if (c.above !== undefined && c.below !== undefined) return "between";
224
+ if (c.below !== undefined) return "below";
225
+ return "above";
226
+ }
227
+
228
+ const NumericStateBody: React.FC<{
229
+ value: NumericStateCondition;
230
+ onChange: (next: ConditionInput) => void;
231
+ variableNodes: VariableNode[];
232
+ completionProvider: TemplateCompletionProvider;
233
+ }> = ({ value, onChange, variableNodes, completionProvider }) => {
234
+ const ns = value.numeric_state;
235
+ const op = numericOpOf(ns);
236
+ const patch = (next: Partial<NumericStateCondition["numeric_state"]>) =>
237
+ onChange({ numeric_state: { ...ns, ...next } });
238
+
239
+ return (
240
+ <div className="space-y-2 border-l border-border pl-3">
241
+ <div className="space-y-1">
242
+ <Label className="text-xs">Value (path or template)</Label>
243
+ <div className="flex items-center gap-2">
244
+ <div className="flex-1">
245
+ <TemplateValueInput
246
+ value={typeof ns.value === "string" ? ns.value : String(ns.value)}
247
+ onChange={(next) => patch({ value: next })}
248
+ placeholder="health.system.p95_latency_ms"
249
+ completionProvider={completionProvider}
250
+ />
251
+ </div>
252
+ <VariablePicker
253
+ scope={variableNodes}
254
+ onSelect={(path) => patch({ value: path })}
255
+ />
256
+ </div>
257
+ </div>
258
+ <div className="flex items-center gap-2">
259
+ <Select
260
+ value={op}
261
+ onValueChange={(next) => {
262
+ // Reset to a clean shape for the chosen operator.
263
+ if (next === "above") {
264
+ patch({ above: ns.above ?? 0, below: undefined });
265
+ } else if (next === "below") {
266
+ patch({ above: undefined, below: ns.below ?? 0 });
267
+ } else {
268
+ patch({ above: ns.above ?? 0, below: ns.below ?? 0 });
269
+ }
270
+ }}
271
+ >
272
+ <SelectTrigger className="h-8 w-32 text-xs">
273
+ <SelectValue />
274
+ </SelectTrigger>
275
+ <SelectContent>
276
+ <SelectItem value="above">above (&gt;)</SelectItem>
277
+ <SelectItem value="below">below (&lt;)</SelectItem>
278
+ <SelectItem value="between">between</SelectItem>
279
+ </SelectContent>
280
+ </Select>
281
+ {op !== "below" && (
282
+ <Input
283
+ type="number"
284
+ className="w-28"
285
+ placeholder="above"
286
+ value={ns.above ?? ""}
287
+ onChange={(event) =>
288
+ patch({
289
+ above:
290
+ event.target.value === ""
291
+ ? undefined
292
+ : Number(event.target.value),
293
+ })
294
+ }
295
+ />
296
+ )}
297
+ {op !== "above" && (
298
+ <Input
299
+ type="number"
300
+ className="w-28"
301
+ placeholder="below"
302
+ value={ns.below ?? ""}
303
+ onChange={(event) =>
304
+ patch({
305
+ below:
306
+ event.target.value === ""
307
+ ? undefined
308
+ : Number(event.target.value),
309
+ })
310
+ }
311
+ />
312
+ )}
313
+ </div>
314
+ </div>
315
+ );
316
+ };
317
+
318
+ // ─── time ──────────────────────────────────────────────────────────────
319
+
320
+ const WEEKDAYS: Array<{ value: number; label: string }> = [
321
+ { value: 0, label: "Sun" },
322
+ { value: 1, label: "Mon" },
323
+ { value: 2, label: "Tue" },
324
+ { value: 3, label: "Wed" },
325
+ { value: 4, label: "Thu" },
326
+ { value: 5, label: "Fri" },
327
+ { value: 6, label: "Sat" },
328
+ ];
329
+
330
+ const TimeBody: React.FC<{
331
+ value: TimeCondition;
332
+ onChange: (next: ConditionInput) => void;
333
+ }> = ({ value, onChange }) => {
334
+ const t = value.time;
335
+ const patch = (next: Partial<TimeCondition["time"]>) =>
336
+ onChange({ time: { ...t, ...next } });
337
+ const weekdays = t.weekday ?? [];
338
+
339
+ const toggleDay = (day: number) => {
340
+ const next = weekdays.includes(day)
341
+ ? weekdays.filter((d) => d !== day)
342
+ : [...weekdays, day].toSorted((a, b) => a - b);
343
+ patch({ weekday: next.length > 0 ? next : undefined });
344
+ };
345
+
346
+ return (
347
+ <div className="space-y-3 border-l border-border pl-3">
348
+ <div className="flex flex-wrap items-end gap-3">
349
+ <div className="space-y-1">
350
+ <Label className="text-xs">After</Label>
351
+ <TimeOfDayInput
352
+ value={t.after}
353
+ onChange={(next) => patch({ after: next })}
354
+ />
355
+ </div>
356
+ <div className="space-y-1">
357
+ <Label className="text-xs">Before</Label>
358
+ <TimeOfDayInput
359
+ value={t.before}
360
+ onChange={(next) => patch({ before: next })}
361
+ />
362
+ </div>
363
+ </div>
364
+ <div className="space-y-1">
365
+ <Label className="text-xs">Weekdays (any when none selected)</Label>
366
+ <div className="flex flex-wrap gap-1">
367
+ {WEEKDAYS.map((d) => {
368
+ const active = weekdays.includes(d.value);
369
+ return (
370
+ <Button
371
+ key={d.value}
372
+ type="button"
373
+ variant={active ? "primary" : "outline"}
374
+ size="sm"
375
+ className="h-7 w-11 text-xs"
376
+ onClick={() => toggleDay(d.value)}
377
+ aria-pressed={active}
378
+ >
379
+ {d.label}
380
+ </Button>
381
+ );
382
+ })}
383
+ </div>
384
+ </div>
385
+ <div className="space-y-1">
386
+ <Label className="text-xs">Timezone (IANA, defaults to UTC)</Label>
387
+ <Input
388
+ className="max-w-xs font-mono text-xs"
389
+ value={t.timezone ?? ""}
390
+ placeholder="Europe/Berlin"
391
+ onChange={(event) =>
392
+ patch({ timezone: event.target.value || undefined })
393
+ }
394
+ />
395
+ </div>
396
+ </div>
397
+ );
398
+ };
399
+
400
+ // ─── state ─────────────────────────────────────────────────────────────
401
+
402
+ const StateBody: React.FC<{
403
+ value: StateCondition;
404
+ onChange: (next: ConditionInput) => void;
405
+ }> = ({ value, onChange }) => {
406
+ const s = value.state;
407
+ const patch = (next: Partial<StateCondition["state"]>) =>
408
+ onChange({ state: { ...s, ...next } });
409
+ const hasDwell = s.for !== undefined;
410
+
411
+ return (
412
+ <div className="space-y-2 border-l border-border pl-3">
413
+ <div className="grid gap-2 sm:grid-cols-2">
414
+ <div className="space-y-1">
415
+ <Label className="text-xs">System (entity)</Label>
416
+ <SystemEntityPicker
417
+ value={s.entity}
418
+ onChange={(next) => patch({ entity: next })}
419
+ />
420
+ </div>
421
+ <div className="space-y-1">
422
+ <Label className="text-xs">Status</Label>
423
+ <Select
424
+ value={s.status}
425
+ onValueChange={(next) =>
426
+ patch({ status: next as StateCondition["state"]["status"] })
427
+ }
428
+ >
429
+ <SelectTrigger className="h-8 text-xs">
430
+ <SelectValue />
431
+ </SelectTrigger>
432
+ <SelectContent>
433
+ <SelectItem value="healthy">healthy</SelectItem>
434
+ <SelectItem value="degraded">degraded</SelectItem>
435
+ <SelectItem value="unhealthy">unhealthy</SelectItem>
436
+ </SelectContent>
437
+ </Select>
438
+ </div>
439
+ </div>
440
+ <div className="flex items-center gap-2">
441
+ <Toggle
442
+ checked={hasDwell}
443
+ onCheckedChange={(checked) =>
444
+ patch({ for: checked ? { minutes: 30 } : undefined })
445
+ }
446
+ />
447
+ <Label className="text-xs">Require held for a minimum duration</Label>
448
+ </div>
449
+ {hasDwell && (
450
+ <DurationInput
451
+ value={s.for as DurationValue | undefined}
452
+ onChange={(next) => patch({ for: (next as Duration) ?? undefined })}
453
+ />
454
+ )}
455
+ </div>
456
+ );
457
+ };
458
+
170
459
  const CombinatorList: React.FC<{
171
460
  kind: "and" | "or";
172
461
  children: ConditionInput[];