@checkstack/automation-frontend 0.2.0 → 0.4.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 (46) hide show
  1. package/CHANGELOG.md +496 -0
  2. package/package.json +18 -13
  3. package/src/components/AutomationGroupCombobox.tsx +133 -0
  4. package/src/components/RunAsServiceAccountPicker.tsx +170 -0
  5. package/src/editor/ActionEditor.tsx +180 -90
  6. package/src/editor/ActionListEditor.tsx +27 -1
  7. package/src/editor/AddActionDialog.tsx +15 -45
  8. package/src/editor/AddConditionDialog.tsx +86 -0
  9. package/src/editor/AddTriggerDialog.tsx +97 -0
  10. package/src/editor/AutomationDefinitionEditor.tsx +41 -2
  11. package/src/editor/ConditionEditor.tsx +359 -70
  12. package/src/editor/ConditionsEditor.tsx +113 -44
  13. package/src/editor/ItemSheet.tsx +51 -0
  14. package/src/editor/RunReplayPicker.tsx +97 -0
  15. package/src/editor/ScriptServicesBooter.tsx +57 -0
  16. package/src/editor/ScriptTestRenderer.tsx +150 -0
  17. package/src/editor/SystemEntityPicker.test.ts +37 -0
  18. package/src/editor/SystemEntityPicker.tsx +109 -0
  19. package/src/editor/TriggersEditor.tsx +345 -137
  20. package/src/editor/action-helpers.test.ts +107 -0
  21. package/src/editor/action-helpers.ts +72 -0
  22. package/src/editor/action-leaf-cards.tsx +105 -1
  23. package/src/editor/condition-kind.test.ts +126 -0
  24. package/src/editor/condition-kind.ts +130 -0
  25. package/src/editor/item-summary.test.ts +171 -0
  26. package/src/editor/item-summary.ts +210 -0
  27. package/src/editor/picker-dialog.tsx +156 -0
  28. package/src/editor/registry-context.tsx +9 -2
  29. package/src/editor/script-actions.test.ts +184 -0
  30. package/src/editor/script-actions.ts +146 -0
  31. package/src/editor/system-entity-picker.logic.ts +23 -0
  32. package/src/editor/template-completion.test.ts +22 -3
  33. package/src/editor/template-completion.ts +16 -8
  34. package/src/editor/template-helpers.ts +4 -0
  35. package/src/editor/trigger-helpers.test.ts +28 -0
  36. package/src/editor/trigger-helpers.ts +17 -0
  37. package/src/editor/useScriptDiagnostics.ts +108 -0
  38. package/src/index.tsx +26 -24
  39. package/src/pages/AutomationEditPage.tsx +116 -48
  40. package/src/pages/AutomationListPage.tsx +172 -123
  41. package/src/pages/automation-grouping.test.ts +87 -0
  42. package/src/pages/automation-grouping.ts +65 -0
  43. package/src/script-context.test.ts +142 -1
  44. package/src/script-context.ts +115 -0
  45. package/tsconfig.json +15 -0
  46. package/src/components/AutomationMenuItems.tsx +0 -37
@@ -0,0 +1,133 @@
1
+ import React from "react";
2
+ import { Tag, X } from "lucide-react";
3
+ import {
4
+ Input,
5
+ Popover,
6
+ PopoverAnchor,
7
+ PopoverContent,
8
+ comboboxAnchorProps,
9
+ isAnchorInteraction,
10
+ } from "@checkstack/ui";
11
+
12
+ export interface AutomationGroupComboboxProps {
13
+ /** Current group value (controlled). Empty string means "Ungrouped". */
14
+ value: string;
15
+ /** Called whenever the value changes (typing, picking, or clearing). */
16
+ onValueChange: (next: string) => void;
17
+ /** Distinct existing group values to suggest. */
18
+ suggestions: string[];
19
+ id?: string;
20
+ disabled?: boolean;
21
+ placeholder?: string;
22
+ }
23
+
24
+ /**
25
+ * Type-new-or-pick-existing group field. Composes `Popover` + `Input` (no
26
+ * Combobox primitive exists) and mirrors `PackageNameCombobox`'s structure:
27
+ * a scrollable suggestion list filtered by the current input.
28
+ *
29
+ * Free-typed new groups are allowed (the input value IS the group). Selecting
30
+ * a suggestion fills the value and closes the list. Reuses the shared
31
+ * `comboboxInteraction` dismiss-guard so the very click that opens the list
32
+ * does not immediately close it (Radix treats the anchor as "outside").
33
+ */
34
+ export const AutomationGroupCombobox: React.FC<AutomationGroupComboboxProps> = ({
35
+ value,
36
+ onValueChange,
37
+ suggestions,
38
+ id,
39
+ disabled,
40
+ placeholder,
41
+ }) => {
42
+ const [open, setOpen] = React.useState(false);
43
+ const inputRef = React.useRef<HTMLInputElement>(null);
44
+
45
+ const query = value.trim().toLowerCase();
46
+ const filtered = React.useMemo(() => {
47
+ const seen = new Set<string>();
48
+ return suggestions.filter((s) => {
49
+ const key = s.toLowerCase();
50
+ if (seen.has(key)) return false;
51
+ seen.add(key);
52
+ return query.length === 0 || key.includes(query);
53
+ });
54
+ }, [suggestions, query]);
55
+
56
+ // Don't offer a suggestion that exactly equals the current value (nothing
57
+ // to pick — the user already has it).
58
+ const showList =
59
+ filtered.length > 0 &&
60
+ !(filtered.length === 1 && filtered[0]?.toLowerCase() === query);
61
+
62
+ const handlePick = (group: string) => {
63
+ onValueChange(group);
64
+ setOpen(false);
65
+ inputRef.current?.focus();
66
+ };
67
+
68
+ return (
69
+ <Popover open={open && showList && !disabled} onOpenChange={setOpen}>
70
+ <PopoverAnchor asChild>
71
+ <div className="relative">
72
+ <Tag className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
73
+ <Input
74
+ ref={inputRef}
75
+ id={id}
76
+ value={value}
77
+ disabled={disabled}
78
+ onChange={(e) => {
79
+ onValueChange(e.target.value);
80
+ setOpen(true);
81
+ }}
82
+ onFocus={() => setOpen(true)}
83
+ placeholder={placeholder ?? "Ungrouped"}
84
+ autoComplete="off"
85
+ className="pl-8 pr-8"
86
+ {...comboboxAnchorProps}
87
+ />
88
+ {value.length > 0 && !disabled && (
89
+ <button
90
+ type="button"
91
+ aria-label="Clear group"
92
+ onClick={() => {
93
+ onValueChange("");
94
+ setOpen(false);
95
+ inputRef.current?.focus();
96
+ }}
97
+ className="absolute right-2 top-1/2 -translate-y-1/2 rounded-sm text-muted-foreground hover:text-foreground"
98
+ >
99
+ <X className="h-3.5 w-3.5" />
100
+ </button>
101
+ )}
102
+ </div>
103
+ </PopoverAnchor>
104
+ <PopoverContent
105
+ align="start"
106
+ className="w-[--radix-popover-trigger-width] p-0"
107
+ onOpenAutoFocus={(e) => e.preventDefault()}
108
+ onCloseAutoFocus={(e) => e.preventDefault()}
109
+ onPointerDownOutside={(e) => {
110
+ if (isAnchorInteraction(e.target)) e.preventDefault();
111
+ }}
112
+ onFocusOutside={(e) => {
113
+ if (isAnchorInteraction(e.target)) e.preventDefault();
114
+ }}
115
+ >
116
+ <div className="max-h-60 overflow-y-auto py-1">
117
+ {filtered.map((group) => (
118
+ <button
119
+ key={group}
120
+ type="button"
121
+ onMouseDown={(e) => e.preventDefault()}
122
+ onClick={() => handlePick(group)}
123
+ className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground"
124
+ >
125
+ <Tag className="h-3 w-3 shrink-0 text-muted-foreground" />
126
+ <span className="flex-1 truncate">{group}</span>
127
+ </button>
128
+ ))}
129
+ </div>
130
+ </PopoverContent>
131
+ </Popover>
132
+ );
133
+ };
@@ -0,0 +1,170 @@
1
+ import React from "react";
2
+ import { Link } from "react-router-dom";
3
+ import { ExternalLink, KeyRound, ShieldCheck } from "lucide-react";
4
+ import { usePluginClient } from "@checkstack/frontend-api";
5
+ import { AuthApi, authRoutes } from "@checkstack/auth-common";
6
+ import { resolveRoute, APP_DOC_SLUGS, docsPath } from "@checkstack/common";
7
+ import {
8
+ Label,
9
+ Select,
10
+ SelectContent,
11
+ SelectItem,
12
+ SelectTrigger,
13
+ SelectValue,
14
+ InfoBanner,
15
+ InfoBannerIcon,
16
+ InfoBannerContent,
17
+ InfoBannerDescription,
18
+ EmptyState,
19
+ Button,
20
+ } from "@checkstack/ui";
21
+
22
+ /**
23
+ * Deep-links into the in-app user guide. The backend serves the Astro Starlight
24
+ * docs build same-origin at `/checkstack/*`, so these are same-origin paths
25
+ * opened in a new tab. They are static doc pages, not app routes - hence a plain
26
+ * anchor (below), never a react-router `<Link>`. Slugs are centralised in
27
+ * `APP_DOC_SLUGS` and guarded by `docs-links.test.ts` against renames.
28
+ */
29
+ const DOCS_TEAMS_AND_ACCESS = docsPath(APP_DOC_SLUGS.teamsAndAccess);
30
+ const DOCS_SERVICE_ACCOUNTS = docsPath(APP_DOC_SLUGS.apiKeys);
31
+
32
+ /** Route to the Applications admin tab in auth settings. */
33
+ const APPLICATIONS_ADMIN_ROUTE = `${resolveRoute(
34
+ authRoutes.routes.settings,
35
+ )}?tab=applications`;
36
+
37
+ export interface RunAsServiceAccountPickerProps {
38
+ /** Selected application id, or empty string when nothing is chosen yet. */
39
+ value: string;
40
+ /** Called with the chosen application id. */
41
+ onValueChange: (next: string) => void;
42
+ /** When false, the picker is read-only (matches the page's `canManage`). */
43
+ disabled?: boolean;
44
+ /** Show the inline "required" validation hint. */
45
+ showError?: boolean;
46
+ }
47
+
48
+ /**
49
+ * "Run as (Service Account)" picker for the automation editor.
50
+ *
51
+ * Populated from the auth plugin's `getBindableApplications` query, which
52
+ * server-side filters to exactly the applications THIS user is allowed to
53
+ * bind (their access rules are a subset of the caller's). The automation
54
+ * then runs with the chosen application's permissions, so picking one is
55
+ * required.
56
+ *
57
+ * Teaching UX:
58
+ * - An info banner explains what a service account is and why it is
59
+ * required.
60
+ * - When no applications are bindable, an `EmptyState` points the user to
61
+ * the Applications admin and the docs instead of leaving them stuck.
62
+ */
63
+ export const RunAsServiceAccountPicker: React.FC<
64
+ RunAsServiceAccountPickerProps
65
+ > = ({ value, onValueChange, disabled, showError }) => {
66
+ const authClient = usePluginClient(AuthApi);
67
+ const { data: applications = [], isLoading } =
68
+ authClient.getBindableApplications.useQuery({});
69
+
70
+ const hasNoApplications = !isLoading && applications.length === 0;
71
+
72
+ return (
73
+ <div className="space-y-2">
74
+ <div className="space-y-1">
75
+ <Label htmlFor="runAs">Run as (Service Account)</Label>
76
+ <Select
77
+ value={value || undefined}
78
+ onValueChange={onValueChange}
79
+ disabled={disabled || hasNoApplications || isLoading}
80
+ >
81
+ <SelectTrigger
82
+ id="runAs"
83
+ aria-invalid={showError ? true : undefined}
84
+ className={showError ? "border-destructive" : undefined}
85
+ >
86
+ <SelectValue
87
+ placeholder={
88
+ isLoading ? "Loading service accounts..." : "Select a service account"
89
+ }
90
+ />
91
+ </SelectTrigger>
92
+ <SelectContent>
93
+ {applications.map((app) => (
94
+ <SelectItem key={app.id} value={app.id}>
95
+ <span className="flex flex-col text-left">
96
+ <span className="truncate">{app.name}</span>
97
+ {app.description ? (
98
+ <span className="text-xs text-muted-foreground truncate">
99
+ {app.description}
100
+ </span>
101
+ ) : null}
102
+ </span>
103
+ </SelectItem>
104
+ ))}
105
+ </SelectContent>
106
+ </Select>
107
+ {showError && !hasNoApplications && (
108
+ <p className="text-xs text-destructive">
109
+ A service account is required.
110
+ </p>
111
+ )}
112
+ </div>
113
+
114
+ {hasNoApplications ? (
115
+ <EmptyState
116
+ icon={<KeyRound className="h-7 w-7" />}
117
+ title="No service accounts available"
118
+ description={
119
+ <>
120
+ An automation runs with a service account's permissions, but you
121
+ do not have any to bind yet. Create or get access to one in the
122
+ Applications admin, then come back here.
123
+ </>
124
+ }
125
+ actions={
126
+ <>
127
+ <Link to={APPLICATIONS_ADMIN_ROUTE}>
128
+ <Button size="sm" variant="outline">
129
+ Open Applications admin
130
+ </Button>
131
+ </Link>
132
+ <a
133
+ href={DOCS_SERVICE_ACCOUNTS}
134
+ target="_blank"
135
+ rel="noreferrer"
136
+ >
137
+ <Button size="sm" variant="ghost">
138
+ Learn more
139
+ <ExternalLink className="ml-1 h-3.5 w-3.5" />
140
+ </Button>
141
+ </a>
142
+ </>
143
+ }
144
+ />
145
+ ) : (
146
+ <InfoBanner variant="info">
147
+ <InfoBannerIcon>
148
+ <ShieldCheck className="h-4 w-4" />
149
+ </InfoBannerIcon>
150
+ <InfoBannerContent>
151
+ <InfoBannerDescription>
152
+ A service account is an application the automation runs as. It
153
+ executes with that account's permissions (assigned via roles), so
154
+ picking one is required.{" "}
155
+ <a
156
+ href={DOCS_TEAMS_AND_ACCESS}
157
+ target="_blank"
158
+ rel="noreferrer"
159
+ className="inline-flex items-center gap-0.5 underline underline-offset-2 hover:no-underline"
160
+ >
161
+ Learn more
162
+ <ExternalLink className="h-3 w-3" />
163
+ </a>
164
+ </InfoBannerDescription>
165
+ </InfoBannerContent>
166
+ </InfoBanner>
167
+ )}
168
+ </div>
169
+ );
170
+ };
@@ -1,6 +1,11 @@
1
1
  import React from "react";
2
2
  import {
3
+ Accordion,
4
+ AccordionContent,
5
+ AccordionItem,
6
+ AccordionTrigger,
3
7
  ActionCard,
8
+ type ActionCardMenuItem,
4
9
  Checkbox,
5
10
  cn,
6
11
  Input,
@@ -20,6 +25,7 @@ import type {
20
25
  StopInput,
21
26
  VariablesInput,
22
27
  WaitForTriggerInput,
28
+ WaitUntilInput,
23
29
  } from "@checkstack/automation-common";
24
30
  import {
25
31
  ACTION_KIND_META,
@@ -28,6 +34,8 @@ import {
28
34
  collectActionIds,
29
35
  defaultActionId,
30
36
  } from "./action-helpers";
37
+ import { summarizeAction } from "./item-summary";
38
+ import { ItemSheet } from "./ItemSheet";
31
39
  import { useAutomationRegistry, useVariableScope } from "./registry-context";
32
40
  import { useActionIssues } from "./editor-validation";
33
41
  import {
@@ -37,6 +45,7 @@ import {
37
45
  StopActionBody,
38
46
  VariablesActionBody,
39
47
  WaitForTriggerActionBody,
48
+ WaitUntilActionBody,
40
49
  } from "./action-leaf-cards";
41
50
  import {
42
51
  ChooseActionBody,
@@ -49,6 +58,8 @@ export interface ActionEditorProps {
49
58
  value: ActionInput;
50
59
  onChange: (next: ActionInput) => void;
51
60
  onDelete: () => void;
61
+ /** Clone this action (fresh ids) directly after itself. Omit to hide. */
62
+ onDuplicate?: () => void;
52
63
  path: ActionPath;
53
64
  definition: AutomationDefinition;
54
65
  dragHandleProps?: React.HTMLAttributes<HTMLButtonElement>;
@@ -91,6 +102,7 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
91
102
  value,
92
103
  onChange,
93
104
  onDelete,
105
+ onDuplicate,
94
106
  path,
95
107
  definition,
96
108
  dragHandleProps,
@@ -100,6 +112,7 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
100
112
  const { actions } = useAutomationRegistry();
101
113
  const kind = actionKindOf(value);
102
114
  const meta = ACTION_KIND_META[kind];
115
+ const [sheetOpen, setSheetOpen] = React.useState(false);
103
116
  const {
104
117
  templateProperties,
105
118
  variableNodes,
@@ -116,101 +129,73 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
116
129
  actions.find((a) => a.qualifiedId === qualified)?.displayName,
117
130
  );
118
131
 
119
- const description =
120
- value.description ?? (value.id ? `id: ${value.id}` : undefined);
132
+ // Collapsed-row summary derived from the config; falls back to the
133
+ // operator's description/id note when there's no derivable summary.
134
+ const summary =
135
+ summarizeAction(value) ??
136
+ value.description ??
137
+ (value.id ? `id: ${value.id}` : undefined);
121
138
 
122
139
  const enabledValue = value.enabled !== false;
123
140
  const issues = useActionIssues(path);
141
+ // `id` auto-fills on blur, so it isn't a meaningful "advanced was
142
+ // customised" signal; open the metadata disclosure only when the operator
143
+ // set a description or flipped the failure behaviour.
144
+ const hasAdvancedMeta =
145
+ value.description !== undefined || value.continue_on_error === true;
124
146
 
125
- return (
126
- <ActionCard
127
- id={stableId}
128
- title={title}
129
- description={description}
130
- category={meta.label}
131
- icon={meta.icon}
132
- enabled={enabledValue}
133
- onEnabledChange={
134
- disabled
135
- ? undefined
136
- : (next) => onChange({ ...value, enabled: next })
137
- }
138
- onDelete={disabled ? undefined : onDelete}
139
- dragHandleProps={disabled ? undefined : dragHandleProps}
140
- errors={issues}
141
- >
142
- <div className="space-y-4">
143
- {/* Action settings — identity and failure behaviour. Grouped in a
144
- quiet panel and labelled with small uppercase eyebrows so the
145
- action's own configuration below reads as the primary content of
146
- the card rather than competing with it. The action's kind is
147
- fixed at creation; to change it, add a new step and delete this
148
- one. */}
149
- <div className="rounded-lg border border-border/60 bg-muted/20 p-3 space-y-3">
150
- <div className="grid gap-x-4 gap-y-3 sm:grid-cols-2">
151
- <MetaField label="Id" htmlFor={`${stableId}-id`}>
152
- <Input
153
- id={`${stableId}-id`}
154
- value={value.id ?? ""}
155
- onChange={(event) =>
156
- onChange({ ...value, id: event.target.value || undefined })
157
- }
158
- onBlur={() => {
159
- // Never leave the id blank: re-fill a unique, log-friendly
160
- // default so the action stays referenceable
161
- // (artifacts.<id>.<name>) and parseable in run logs.
162
- if (value.id) return;
163
- const taken = collectActionIds(definition.actions);
164
- onChange({ ...value, id: defaultActionId(value, taken) });
165
- }}
166
- placeholder="Generated on blur"
167
- disabled={disabled}
168
- className="h-8 text-xs"
169
- />
170
- </MetaField>
147
+ // Validation issues (structural + inline-script type errors) surface only as
148
+ // the card's error badge - never auto-open the sheet. Auto-opening would pop
149
+ // multiple sheets at once when several cards have errors (e.g. every script
150
+ // action after a trigger change), so the badge is the single, calm signal and
151
+ // the operator clicks in for detail.
171
152
 
172
- <MetaField label="Description" htmlFor={`${stableId}-desc`}>
173
- <Input
174
- id={`${stableId}-desc`}
175
- value={value.description ?? ""}
176
- onChange={(event) =>
177
- onChange({
178
- ...value,
179
- description: event.target.value || undefined,
180
- })
181
- }
182
- placeholder="Optional note"
183
- disabled={disabled}
184
- className="h-8 text-xs"
185
- />
186
- </MetaField>
187
- </div>
188
-
189
- <MetaField label="On failure">
190
- <div
191
- className={cn(
192
- "flex h-8 items-center gap-2 select-none",
193
- disabled ? "cursor-not-allowed" : "cursor-pointer",
194
- )}
195
- onClick={() =>
196
- !disabled &&
197
- onChange({
198
- ...value,
199
- continue_on_error: value.continue_on_error !== true,
200
- })
201
- }
202
- >
203
- <Checkbox
204
- checked={value.continue_on_error === true}
205
- disabled={disabled}
206
- />
207
- <Label className="text-xs font-normal text-muted-foreground cursor-pointer">
208
- Continue on error
209
- </Label>
210
- </div>
211
- </MetaField>
212
- </div>
153
+ const menuItems: ActionCardMenuItem[] = disabled
154
+ ? []
155
+ : [
156
+ {
157
+ label: enabledValue ? "Disable" : "Enable",
158
+ icon: enabledValue ? "PowerOff" : "Power",
159
+ onClick: () => onChange({ ...value, enabled: !enabledValue }),
160
+ },
161
+ ...(onDuplicate
162
+ ? [
163
+ {
164
+ label: "Duplicate",
165
+ icon: "Copy" as const,
166
+ onClick: onDuplicate,
167
+ },
168
+ ]
169
+ : []),
170
+ {
171
+ label: "Delete",
172
+ icon: "Trash2",
173
+ onClick: onDelete,
174
+ variant: "destructive",
175
+ },
176
+ ];
213
177
 
178
+ return (
179
+ <>
180
+ <ActionCard
181
+ id={stableId}
182
+ title={title}
183
+ summary={summary}
184
+ category={meta.label}
185
+ icon={meta.icon}
186
+ enabled={enabledValue}
187
+ onOpenSheet={() => setSheetOpen(true)}
188
+ actions={menuItems}
189
+ dragHandleProps={disabled ? undefined : dragHandleProps}
190
+ errors={issues}
191
+ />
192
+ <ItemSheet
193
+ open={sheetOpen}
194
+ onOpenChange={setSheetOpen}
195
+ title={title}
196
+ description={meta.label}
197
+ >
198
+ <div className="space-y-4">
214
199
  <ActionBody
215
200
  kind={kind}
216
201
  value={value}
@@ -224,8 +209,102 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
224
209
  shellEnvVars={shellEnvVars}
225
210
  disabled={disabled}
226
211
  />
212
+
213
+ {/* Per-action metadata — id, description, failure behaviour — tucked
214
+ behind a collapsed disclosure so the action's own configuration
215
+ above reads as the primary content of the card. The action's kind
216
+ is fixed at creation; to change it, add a new step and delete this
217
+ one. Enable/disable lives on the card header, not here. */}
218
+ <Accordion
219
+ type="single"
220
+ collapsible
221
+ defaultValue={hasAdvancedMeta ? "advanced" : undefined}
222
+ className="w-full"
223
+ >
224
+ <AccordionItem value="advanced" className="border-b-0">
225
+ <AccordionTrigger className="py-2 text-xs hover:no-underline">
226
+ <span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
227
+ Advanced
228
+ </span>
229
+ </AccordionTrigger>
230
+ <AccordionContent className="pb-0">
231
+ <div className="rounded-lg border border-border/60 bg-muted/20 p-3 space-y-3">
232
+ <div className="grid gap-x-4 gap-y-3 sm:grid-cols-2">
233
+ <MetaField label="Id" htmlFor={`${stableId}-id`}>
234
+ <Input
235
+ id={`${stableId}-id`}
236
+ value={value.id ?? ""}
237
+ onChange={(event) =>
238
+ onChange({
239
+ ...value,
240
+ id: event.target.value || undefined,
241
+ })
242
+ }
243
+ onBlur={() => {
244
+ // Never leave the id blank: re-fill a unique,
245
+ // log-friendly default so the action stays
246
+ // referenceable (artifacts.<id>.<name>) and parseable
247
+ // in run logs.
248
+ if (value.id) return;
249
+ const taken = collectActionIds(definition.actions);
250
+ onChange({
251
+ ...value,
252
+ id: defaultActionId(value, taken),
253
+ });
254
+ }}
255
+ placeholder="Generated on blur"
256
+ disabled={disabled}
257
+ className="h-8 text-xs"
258
+ />
259
+ </MetaField>
260
+
261
+ <MetaField label="Description" htmlFor={`${stableId}-desc`}>
262
+ <Input
263
+ id={`${stableId}-desc`}
264
+ value={value.description ?? ""}
265
+ onChange={(event) =>
266
+ onChange({
267
+ ...value,
268
+ description: event.target.value || undefined,
269
+ })
270
+ }
271
+ placeholder="Optional note"
272
+ disabled={disabled}
273
+ className="h-8 text-xs"
274
+ />
275
+ </MetaField>
276
+ </div>
277
+
278
+ <MetaField label="On failure">
279
+ <div
280
+ className={cn(
281
+ "flex h-8 items-center gap-2 select-none",
282
+ disabled ? "cursor-not-allowed" : "cursor-pointer",
283
+ )}
284
+ onClick={() =>
285
+ !disabled &&
286
+ onChange({
287
+ ...value,
288
+ continue_on_error: value.continue_on_error !== true,
289
+ })
290
+ }
291
+ >
292
+ <Checkbox
293
+ checked={value.continue_on_error === true}
294
+ disabled={disabled}
295
+ />
296
+ <Label className="text-xs font-normal text-muted-foreground cursor-pointer">
297
+ Continue on error
298
+ </Label>
299
+ </div>
300
+ </MetaField>
301
+ </div>
302
+ </AccordionContent>
303
+ </AccordionItem>
304
+ </Accordion>
227
305
  </div>
228
- </ActionCard>
306
+ </ItemSheet>
307
+ </>
229
308
  );
230
309
  };
231
310
 
@@ -343,6 +422,17 @@ const ActionBody: React.FC<{
343
422
  />
344
423
  );
345
424
  }
425
+ case "wait_until": {
426
+ return (
427
+ <WaitUntilActionBody
428
+ value={value as WaitUntilInput}
429
+ onChange={(next) => onChange(next)}
430
+ variableNodes={variableNodes}
431
+ completionProvider={expressionCompletion}
432
+ disabled={disabled}
433
+ />
434
+ );
435
+ }
346
436
  case "sequence": {
347
437
  return (
348
438
  <SequenceActionBody