@checkstack/automation-frontend 0.2.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 (35) hide show
  1. package/CHANGELOG.md +664 -0
  2. package/package.json +38 -0
  3. package/src/components/AutomationMenuItems.tsx +37 -0
  4. package/src/editor/ActionEditor.tsx +367 -0
  5. package/src/editor/ActionListEditor.tsx +203 -0
  6. package/src/editor/AddActionDialog.tsx +225 -0
  7. package/src/editor/AutomationDefinitionContext.tsx +37 -0
  8. package/src/editor/AutomationDefinitionEditor.tsx +99 -0
  9. package/src/editor/ConditionEditor.tsx +218 -0
  10. package/src/editor/ConditionsEditor.tsx +89 -0
  11. package/src/editor/ItemPicker.tsx +147 -0
  12. package/src/editor/TriggersEditor.tsx +269 -0
  13. package/src/editor/action-composite-cards.tsx +390 -0
  14. package/src/editor/action-helpers.ts +365 -0
  15. package/src/editor/action-leaf-cards.tsx +426 -0
  16. package/src/editor/editor-validation.test.ts +95 -0
  17. package/src/editor/editor-validation.tsx +200 -0
  18. package/src/editor/registry-context.tsx +192 -0
  19. package/src/editor/template-completion.test.ts +412 -0
  20. package/src/editor/template-completion.ts +664 -0
  21. package/src/editor/template-helpers.test.ts +145 -0
  22. package/src/editor/template-helpers.ts +95 -0
  23. package/src/editor/trigger-helpers.test.ts +58 -0
  24. package/src/editor/trigger-helpers.ts +67 -0
  25. package/src/editor/useConnectionOptionResolvers.ts +80 -0
  26. package/src/editor/yaml-markers.ts +116 -0
  27. package/src/index.tsx +95 -0
  28. package/src/pages/AutomationEditPage.tsx +567 -0
  29. package/src/pages/AutomationListPage.tsx +304 -0
  30. package/src/pages/RunDetailPage.tsx +333 -0
  31. package/src/pages/RunsPage.tsx +233 -0
  32. package/src/pages/TemplatePlaygroundPage.tsx +224 -0
  33. package/src/script-context.test.ts +247 -0
  34. package/src/script-context.ts +218 -0
  35. package/tsconfig.json +29 -0
@@ -0,0 +1,225 @@
1
+ import React from "react";
2
+ import { Plus, Search, Zap } from "lucide-react";
3
+ import {
4
+ Button,
5
+ Dialog,
6
+ DialogContent,
7
+ DialogHeader,
8
+ DialogTitle,
9
+ DynamicIcon,
10
+ Input,
11
+ TabPanel,
12
+ Tabs,
13
+ } from "@checkstack/ui";
14
+ import { ACTION_KIND_META, type ActionKind } from "./action-helpers";
15
+ import { useAutomationRegistry } from "./registry-context";
16
+
17
+ /**
18
+ * Building blocks shown in the "Blocks" tab — every action kind except the
19
+ * generic provider `action`. Concrete provider actions are chosen directly
20
+ * from the "Actions" tab (which presets the step's `action`), so the kind is
21
+ * always decided up front rather than swapped on an existing step.
22
+ */
23
+ const BLOCK_KINDS: ActionKind[] = [
24
+ "choose",
25
+ "parallel",
26
+ "repeat",
27
+ "sequence",
28
+ "condition",
29
+ "delay",
30
+ "stop",
31
+ "variables",
32
+ "wait_for_trigger",
33
+ ];
34
+
35
+ const TAB_ITEMS = [
36
+ { id: "actions", label: "Actions" },
37
+ { id: "blocks", label: "Blocks" },
38
+ ];
39
+
40
+ /** Shared row used by both tabs — icon, name, description, and a `+` affordance. */
41
+ const PickerRow: React.FC<{
42
+ icon: React.ReactNode;
43
+ title: string;
44
+ description?: string;
45
+ onClick: () => void;
46
+ }> = ({ icon, title, description, onClick }) => (
47
+ <button
48
+ type="button"
49
+ onClick={onClick}
50
+ className="flex w-full items-start gap-3 rounded-md border border-border/60 bg-card px-3 py-2.5 text-left transition-colors hover:border-border hover:bg-accent/50"
51
+ >
52
+ <span className="mt-0.5 shrink-0 text-muted-foreground">{icon}</span>
53
+ <div className="min-w-0 flex-1">
54
+ <div className="text-sm font-medium">{title}</div>
55
+ {description && (
56
+ <div className="text-xs text-muted-foreground">{description}</div>
57
+ )}
58
+ </div>
59
+ <Plus className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
60
+ </button>
61
+ );
62
+
63
+ export interface AddActionDialogProps {
64
+ /** Insert a building-block step of the given kind. */
65
+ onAddKind: (kind: ActionKind) => void;
66
+ /** Insert a provider-action step with its `action` preset to this id. */
67
+ onAddAction: (qualifiedId: string) => void;
68
+ disabled?: boolean;
69
+ }
70
+
71
+ /**
72
+ * Home-Assistant-style step picker. The operator decides the step's type up
73
+ * front: pick a concrete provider action (grouped by category) from the
74
+ * "Actions" tab, or a structural building block from the "Blocks" tab. A
75
+ * single search filters whichever tab is active.
76
+ */
77
+ export const AddActionDialog: React.FC<AddActionDialogProps> = ({
78
+ onAddKind,
79
+ onAddAction,
80
+ disabled,
81
+ }) => {
82
+ const { actions } = useAutomationRegistry();
83
+ const [open, setOpen] = React.useState(false);
84
+ const [tab, setTab] = React.useState("actions");
85
+ const [query, setQuery] = React.useState("");
86
+
87
+ // Reset transient state whenever the dialog closes so it reopens clean.
88
+ React.useEffect(() => {
89
+ if (!open) {
90
+ setQuery("");
91
+ setTab("actions");
92
+ }
93
+ }, [open]);
94
+
95
+ const q = query.trim().toLowerCase();
96
+
97
+ const filteredActions = React.useMemo(() => {
98
+ if (!q) return actions;
99
+ return actions.filter(
100
+ (action) =>
101
+ action.displayName.toLowerCase().includes(q) ||
102
+ action.qualifiedId.toLowerCase().includes(q) ||
103
+ action.description?.toLowerCase().includes(q) ||
104
+ action.category.toLowerCase().includes(q),
105
+ );
106
+ }, [actions, q]);
107
+
108
+ const groupedActions = React.useMemo(() => {
109
+ const groups = new Map<string, typeof actions>();
110
+ for (const action of filteredActions) {
111
+ const list = groups.get(action.category) ?? [];
112
+ list.push(action);
113
+ groups.set(action.category, list);
114
+ }
115
+ return [...groups.entries()].toSorted(([a], [b]) => a.localeCompare(b));
116
+ }, [filteredActions]);
117
+
118
+ const filteredBlocks = React.useMemo(() => {
119
+ if (!q) return BLOCK_KINDS;
120
+ return BLOCK_KINDS.filter((kind) => {
121
+ const meta = ACTION_KIND_META[kind];
122
+ return (
123
+ meta.label.toLowerCase().includes(q) ||
124
+ meta.description.toLowerCase().includes(q)
125
+ );
126
+ });
127
+ }, [q]);
128
+
129
+ return (
130
+ <>
131
+ <Button
132
+ type="button"
133
+ variant="outline"
134
+ size="sm"
135
+ disabled={disabled}
136
+ className="h-7 text-xs"
137
+ onClick={() => setOpen(true)}
138
+ >
139
+ <Plus className="mr-1 h-3 w-3" />
140
+ Add step
141
+ </Button>
142
+ <Dialog open={open} onOpenChange={setOpen}>
143
+ <DialogContent size="lg">
144
+ <DialogHeader>
145
+ <DialogTitle>Add action</DialogTitle>
146
+ </DialogHeader>
147
+
148
+ <div className="space-y-3">
149
+ <div className="relative">
150
+ <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
151
+ <Input
152
+ autoFocus
153
+ value={query}
154
+ onChange={(event) => setQuery(event.target.value)}
155
+ placeholder="Search actions and blocks…"
156
+ className="pl-9"
157
+ />
158
+ </div>
159
+
160
+ <Tabs items={TAB_ITEMS} activeTab={tab} onTabChange={setTab} />
161
+
162
+ <div className="max-h-[50vh] overflow-y-auto pr-1">
163
+ <TabPanel id="actions" activeTab={tab}>
164
+ {groupedActions.length === 0 ? (
165
+ <p className="px-1 py-6 text-center text-xs italic text-muted-foreground">
166
+ No matching actions. Looking for choose / repeat / delay?
167
+ Check the Blocks tab.
168
+ </p>
169
+ ) : (
170
+ <div className="space-y-3">
171
+ {groupedActions.map(([category, list]) => (
172
+ <div key={category} className="space-y-1">
173
+ <p className="px-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
174
+ {category}
175
+ </p>
176
+ {list.map((action) => (
177
+ <PickerRow
178
+ key={action.qualifiedId}
179
+ icon={<Zap className="h-4 w-4" />}
180
+ title={action.displayName}
181
+ description={action.description}
182
+ onClick={() => {
183
+ onAddAction(action.qualifiedId);
184
+ setOpen(false);
185
+ }}
186
+ />
187
+ ))}
188
+ </div>
189
+ ))}
190
+ </div>
191
+ )}
192
+ </TabPanel>
193
+
194
+ <TabPanel id="blocks" activeTab={tab}>
195
+ {filteredBlocks.length === 0 ? (
196
+ <p className="px-1 py-6 text-center text-xs italic text-muted-foreground">
197
+ No matching blocks.
198
+ </p>
199
+ ) : (
200
+ <div className="space-y-1">
201
+ {filteredBlocks.map((kind) => {
202
+ const meta = ACTION_KIND_META[kind];
203
+ return (
204
+ <PickerRow
205
+ key={kind}
206
+ icon={<DynamicIcon name={meta.icon} className="h-4 w-4" />}
207
+ title={meta.label}
208
+ description={meta.description}
209
+ onClick={() => {
210
+ onAddKind(kind);
211
+ setOpen(false);
212
+ }}
213
+ />
214
+ );
215
+ })}
216
+ </div>
217
+ )}
218
+ </TabPanel>
219
+ </div>
220
+ </div>
221
+ </DialogContent>
222
+ </Dialog>
223
+ </>
224
+ );
225
+ };
@@ -0,0 +1,37 @@
1
+ import React from "react";
2
+ import type { AutomationDefinition } from "@checkstack/automation-common";
3
+
4
+ /**
5
+ * Threads the live `AutomationDefinition` through the editor tree so
6
+ * recursively-rendered cards can resolve their variable scope against
7
+ * the latest state without prop-drilling. Updated by the top-level
8
+ * `AutomationDefinitionEditor` on every edit.
9
+ */
10
+ interface AutomationDefinitionContextValue {
11
+ definition: AutomationDefinition;
12
+ }
13
+
14
+ const AutomationDefinitionContext =
15
+ React.createContext<AutomationDefinitionContextValue | null>(null);
16
+
17
+ export const AutomationDefinitionProvider: React.FC<{
18
+ definition: AutomationDefinition;
19
+ children: React.ReactNode;
20
+ }> = ({ definition, children }) => {
21
+ const value = React.useMemo(() => ({ definition }), [definition]);
22
+ return (
23
+ <AutomationDefinitionContext.Provider value={value}>
24
+ {children}
25
+ </AutomationDefinitionContext.Provider>
26
+ );
27
+ };
28
+
29
+ export function useAutomationDefinitionContext(): AutomationDefinitionContextValue {
30
+ const ctx = React.useContext(AutomationDefinitionContext);
31
+ if (!ctx) {
32
+ throw new Error(
33
+ "useAutomationDefinitionContext must be used inside <AutomationDefinitionProvider>",
34
+ );
35
+ }
36
+ return ctx;
37
+ }
@@ -0,0 +1,99 @@
1
+ import React from "react";
2
+ import { Card, CardContent, CardHeader, CardTitle } from "@checkstack/ui";
3
+ import type { AutomationDefinition } from "@checkstack/automation-common";
4
+ import {
5
+ AutomationDefinitionProvider,
6
+ } from "./AutomationDefinitionContext";
7
+ import {
8
+ AutomationRegistryProvider,
9
+ useAutomationRegistry,
10
+ useVariableScope,
11
+ } from "./registry-context";
12
+ import { TriggersEditor } from "./TriggersEditor";
13
+ import { ConditionsEditor } from "./ConditionsEditor";
14
+ import { ActionListEditor } from "./ActionListEditor";
15
+
16
+ export interface AutomationDefinitionEditorProps {
17
+ value: AutomationDefinition;
18
+ onChange: (next: AutomationDefinition) => void;
19
+ disabled?: boolean;
20
+ }
21
+
22
+ /**
23
+ * Top-level visual editor for an `AutomationDefinition`. Composes the
24
+ * three sections (triggers, pre-run conditions, actions) and threads
25
+ * the live definition through the registry + definition contexts so
26
+ * every nested template field can resolve its scope independently.
27
+ *
28
+ * Pure composition — no internal state. The parent (`AutomationEditPage`)
29
+ * owns the definition and decides when to save.
30
+ */
31
+ export const AutomationDefinitionEditor: React.FC<
32
+ AutomationDefinitionEditorProps
33
+ > = (props) => (
34
+ <AutomationRegistryProvider>
35
+ <AutomationDefinitionProvider definition={props.value}>
36
+ <EditorBody {...props} />
37
+ </AutomationDefinitionProvider>
38
+ </AutomationRegistryProvider>
39
+ );
40
+
41
+ const EditorBody: React.FC<AutomationDefinitionEditorProps> = ({
42
+ value,
43
+ onChange,
44
+ disabled,
45
+ }) => {
46
+ const { loading } = useAutomationRegistry();
47
+
48
+ // Scope at the root path = `trigger.*` only. The conditions editor
49
+ // uses `variableNodes` for the explicit "fx" tree and
50
+ // `expressionCompletion` for the staged inline autocomplete (bare
51
+ // expression mode — pre-run conditions aren't `{{ }}`-wrapped).
52
+ const { variableNodes, expressionCompletion } = useVariableScope({
53
+ definition: value,
54
+ path: [{ slot: "root", index: 0 }],
55
+ });
56
+
57
+ return (
58
+ <div className="space-y-4">
59
+ {loading && (
60
+ <Card className="border-dashed">
61
+ <CardContent className="p-3">
62
+ <p className="text-xs italic text-muted-foreground">
63
+ Loading registry…
64
+ </p>
65
+ </CardContent>
66
+ </Card>
67
+ )}
68
+
69
+ <TriggersEditor
70
+ value={value.triggers}
71
+ onChange={(triggers) => onChange({ ...value, triggers })}
72
+ disabled={disabled}
73
+ />
74
+
75
+ <ConditionsEditor
76
+ value={value.conditions}
77
+ onChange={(conditions) => onChange({ ...value, conditions })}
78
+ variableNodes={variableNodes}
79
+ completionProvider={expressionCompletion}
80
+ disabled={disabled}
81
+ />
82
+
83
+ <Card>
84
+ <CardHeader className="border-b">
85
+ <CardTitle className="text-base">Actions</CardTitle>
86
+ </CardHeader>
87
+ <CardContent className="space-y-2 p-3">
88
+ <ActionListEditor
89
+ value={value.actions}
90
+ onChange={(actions) => onChange({ ...value, actions })}
91
+ parentPath={[]}
92
+ slotForChildren={{ slot: "root" }}
93
+ disabled={disabled}
94
+ />
95
+ </CardContent>
96
+ </Card>
97
+ </div>
98
+ );
99
+ };
@@ -0,0 +1,218 @@
1
+ import React from "react";
2
+ import { Plus, Trash2 } from "lucide-react";
3
+ import {
4
+ Button,
5
+ Card,
6
+ CardContent,
7
+ Select,
8
+ SelectContent,
9
+ SelectItem,
10
+ SelectTrigger,
11
+ SelectValue,
12
+ TemplateValueInput,
13
+ VariablePicker,
14
+ type TemplateCompletionProvider,
15
+ type VariableNode,
16
+ } 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
+ }
44
+
45
+ export interface ConditionEditorProps {
46
+ value: ConditionInput;
47
+ onChange: (next: ConditionInput) => void;
48
+ variableNodes: VariableNode[];
49
+ /**
50
+ * Staged completion provider (expression mode) for the inline
51
+ * expression input. Conditions are bare expressions — no `{{ }}`
52
+ * wrapper — so this is the `expressionCompletion` from
53
+ * `useVariableScope`. The hierarchical `variableNodes` powers the
54
+ * explicit "fx" picker.
55
+ */
56
+ completionProvider: TemplateCompletionProvider;
57
+ /** Render without the wrapping card — used when inlining inside an action card. */
58
+ bare?: boolean;
59
+ depth?: number;
60
+ }
61
+
62
+ /**
63
+ * Recursive editor over the `ConditionInput` discriminated union
64
+ * (`string | { and } | { or } | { not }`). Each level renders:
65
+ *
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.
73
+ *
74
+ * No depth cap — operator can nest arbitrarily, mirroring the runtime.
75
+ */
76
+ export const ConditionEditor: React.FC<ConditionEditorProps> = ({
77
+ value,
78
+ onChange,
79
+ variableNodes,
80
+ completionProvider,
81
+ bare,
82
+ depth = 0,
83
+ }) => {
84
+ const kind = kindOf(value);
85
+
86
+ const body = (
87
+ <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>
118
+
119
+ {kind === "expr" && (
120
+ <TemplateValueInput
121
+ value={typeof value === "string" ? value : ""}
122
+ onChange={(next) => onChange(next)}
123
+ placeholder="trigger.payload.severity == &quot;high&quot;"
124
+ completionProvider={completionProvider}
125
+ />
126
+ )}
127
+
128
+ {(kind === "and" || kind === "or") && (
129
+ <CombinatorList
130
+ kind={kind}
131
+ children={
132
+ kind === "and"
133
+ ? (value as { and: ConditionInput[] }).and
134
+ : (value as { or: ConditionInput[] }).or
135
+ }
136
+ onChange={(nextChildren) =>
137
+ onChange(
138
+ kind === "and"
139
+ ? { and: nextChildren }
140
+ : { or: nextChildren },
141
+ )
142
+ }
143
+ variableNodes={variableNodes}
144
+ completionProvider={completionProvider}
145
+ depth={depth + 1}
146
+ />
147
+ )}
148
+
149
+ {kind === "not" && (
150
+ <ConditionEditor
151
+ value={(value as { not: ConditionInput }).not}
152
+ onChange={(next) => onChange({ not: next })}
153
+ variableNodes={variableNodes}
154
+ completionProvider={completionProvider}
155
+ bare
156
+ depth={depth + 1}
157
+ />
158
+ )}
159
+ </div>
160
+ );
161
+
162
+ if (bare) return body;
163
+ return (
164
+ <Card>
165
+ <CardContent className="p-3">{body}</CardContent>
166
+ </Card>
167
+ );
168
+ };
169
+
170
+ const CombinatorList: React.FC<{
171
+ kind: "and" | "or";
172
+ children: ConditionInput[];
173
+ onChange: (next: ConditionInput[]) => void;
174
+ variableNodes: VariableNode[];
175
+ completionProvider: TemplateCompletionProvider;
176
+ depth: number;
177
+ }> = ({ children: items, onChange, variableNodes, completionProvider, depth }) => (
178
+ <div className="space-y-2 border-l border-border pl-3">
179
+ {items.map((child, index) => (
180
+ <div key={index} className="flex items-start gap-2">
181
+ <div className="flex-1">
182
+ <ConditionEditor
183
+ value={child}
184
+ onChange={(next) => {
185
+ const nextChildren = [...items];
186
+ nextChildren[index] = next;
187
+ onChange(nextChildren);
188
+ }}
189
+ variableNodes={variableNodes}
190
+ completionProvider={completionProvider}
191
+ bare
192
+ depth={depth}
193
+ />
194
+ </div>
195
+ <Button
196
+ type="button"
197
+ variant="ghost"
198
+ size="icon"
199
+ className="h-7 w-7 text-destructive hover:bg-destructive/10"
200
+ onClick={() => onChange(items.filter((_, i) => i !== index))}
201
+ aria-label="Remove condition"
202
+ >
203
+ <Trash2 className="h-3 w-3" />
204
+ </Button>
205
+ </div>
206
+ ))}
207
+ <Button
208
+ type="button"
209
+ variant="outline"
210
+ size="sm"
211
+ onClick={() => onChange([...items, ""])}
212
+ className="h-7 text-xs"
213
+ >
214
+ <Plus className="mr-1 h-3 w-3" />
215
+ Add clause
216
+ </Button>
217
+ </div>
218
+ );
@@ -0,0 +1,89 @@
1
+ import React from "react";
2
+ import { Plus, Trash2 } from "lucide-react";
3
+ import {
4
+ Button,
5
+ Card,
6
+ CardContent,
7
+ CardHeader,
8
+ CardTitle,
9
+ type TemplateCompletionProvider,
10
+ type VariableNode,
11
+ } from "@checkstack/ui";
12
+ import type { ConditionInput } from "@checkstack/automation-common";
13
+ import { ConditionEditor } from "./ConditionEditor";
14
+
15
+ /**
16
+ * Top-level pre-run conditions. Every condition in this list must pass
17
+ * for the actions section to even start — same semantics as Home
18
+ * Assistant's `condition:` block.
19
+ *
20
+ * Each condition reuses the recursive `ConditionEditor`, so combinators
21
+ * (`and` / `or` / `not`) nest the same way they do inside an inline
22
+ * `choose: when` clause.
23
+ *
24
+ * Variable scope at this point is just `trigger.*` — no upstream
25
+ * variables or artifacts exist before the action list starts. We get
26
+ * that scope from the same `useVariableScope` hook by passing the root
27
+ * path; the parent computes it once and threads it down here.
28
+ */
29
+ export const ConditionsEditor: React.FC<{
30
+ value: ConditionInput[];
31
+ onChange: (next: ConditionInput[]) => void;
32
+ variableNodes: VariableNode[];
33
+ completionProvider: TemplateCompletionProvider;
34
+ disabled?: boolean;
35
+ }> = ({ value, onChange, variableNodes, completionProvider, disabled }) => (
36
+ <Card>
37
+ <CardHeader className="border-b">
38
+ <div className="flex items-center justify-between">
39
+ <CardTitle className="text-base">Conditions</CardTitle>
40
+ <Button
41
+ type="button"
42
+ variant="outline"
43
+ size="sm"
44
+ onClick={() => onChange([...value, ""])}
45
+ disabled={disabled}
46
+ >
47
+ <Plus className="mr-1 h-3 w-3" />
48
+ Add condition
49
+ </Button>
50
+ </div>
51
+ </CardHeader>
52
+ <CardContent className="space-y-2 p-3">
53
+ {value.length === 0 ? (
54
+ <p className="text-xs italic text-muted-foreground">
55
+ No pre-run gating. Add a condition to require it before the
56
+ actions run.
57
+ </p>
58
+ ) : (
59
+ value.map((condition, index) => (
60
+ <div key={index} className="flex items-start gap-2">
61
+ <div className="flex-1">
62
+ <ConditionEditor
63
+ value={condition}
64
+ onChange={(next) => {
65
+ const list = [...value];
66
+ list[index] = next;
67
+ onChange(list);
68
+ }}
69
+ variableNodes={variableNodes}
70
+ completionProvider={completionProvider}
71
+ />
72
+ </div>
73
+ <Button
74
+ type="button"
75
+ variant="ghost"
76
+ size="icon"
77
+ className="h-7 w-7 text-destructive hover:bg-destructive/10"
78
+ onClick={() => onChange(value.filter((_, i) => i !== index))}
79
+ disabled={disabled}
80
+ aria-label="Remove condition"
81
+ >
82
+ <Trash2 className="h-3 w-3" />
83
+ </Button>
84
+ </div>
85
+ ))
86
+ )}
87
+ </CardContent>
88
+ </Card>
89
+ );