@checkstack/ui 1.11.0 → 1.12.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 (45) hide show
  1. package/.storybook/main.ts +43 -0
  2. package/CHANGELOG.md +181 -0
  3. package/package.json +4 -4
  4. package/scripts/generate-stdlib-types.ts +23 -0
  5. package/src/components/ActionCard.tsx +96 -8
  6. package/src/components/CodeEditor/CodeEditor.tsx +95 -14
  7. package/src/components/CodeEditor/TypefoxEditor.tsx +279 -123
  8. package/src/components/CodeEditor/generated/builtin-modules.json +1 -0
  9. package/src/components/CodeEditor/importSpecifiers.test.ts +286 -0
  10. package/src/components/CodeEditor/importSpecifiers.ts +267 -0
  11. package/src/components/CodeEditor/index.ts +24 -0
  12. package/src/components/CodeEditor/monacoTsService.ts +217 -0
  13. package/src/components/CodeEditor/popoutTitle.test.ts +37 -0
  14. package/src/components/CodeEditor/popoutTitle.ts +31 -0
  15. package/src/components/CodeEditor/scriptDiagnostics.test.ts +135 -0
  16. package/src/components/CodeEditor/scriptDiagnostics.ts +172 -0
  17. package/src/components/CodeEditor/types.ts +59 -0
  18. package/src/components/CodeEditor/validateScripts.ts +132 -0
  19. package/src/components/Dialog.tsx +32 -11
  20. package/src/components/DurationInput.tsx +121 -0
  21. package/src/components/DynamicForm/DynamicForm.tsx +25 -1
  22. package/src/components/DynamicForm/FormField.tsx +109 -1
  23. package/src/components/DynamicForm/MultiTypeEditorField.tsx +67 -2
  24. package/src/components/DynamicForm/SecretEnvEditor.tsx +315 -0
  25. package/src/components/DynamicForm/index.ts +6 -0
  26. package/src/components/DynamicForm/secretEnv.logic.test.ts +126 -0
  27. package/src/components/DynamicForm/secretEnv.logic.ts +87 -0
  28. package/src/components/DynamicForm/types.ts +72 -1
  29. package/src/components/DynamicForm/utils.ts +32 -0
  30. package/src/components/Popover.tsx +6 -1
  31. package/src/components/ScriptTestPanel.logic.test.ts +139 -0
  32. package/src/components/ScriptTestPanel.logic.ts +137 -0
  33. package/src/components/ScriptTestPanel.tsx +394 -0
  34. package/src/components/Sheet.tsx +21 -6
  35. package/src/components/TimeOfDayInput.tsx +116 -0
  36. package/src/components/comboboxInteraction.ts +39 -0
  37. package/src/components/portalContainer.ts +24 -0
  38. package/src/index.ts +4 -0
  39. package/stories/ActionCard.stories.tsx +60 -0
  40. package/stories/CodeEditor.stories.tsx +47 -2
  41. package/stories/DurationInput.stories.tsx +59 -0
  42. package/stories/ScriptTestPanel.stories.tsx +106 -0
  43. package/stories/SecretEnvEditor.stories.tsx +80 -0
  44. package/stories/TimeOfDayInput.stories.tsx +34 -0
  45. package/tsconfig.json +1 -0
@@ -0,0 +1,132 @@
1
+ // Headless TypeScript / JavaScript script validator.
2
+ //
3
+ // Type-checks user scripts against their generated `context` types WITHOUT a
4
+ // mounted editor, so an automation's collapsed-card scripts (or any script the
5
+ // user isn't currently looking at) still surface type errors. It drives the
6
+ // SAME standalone TS worker the editor uses, via off-screen models.
7
+ //
8
+ // Browser-only (imports `monaco` + the worker accessor). The pure mapping /
9
+ // offset logic lives in `scriptDiagnostics.ts` and is unit-tested there; this
10
+ // module is the thin async glue and is intentionally not unit-tested (no DOM /
11
+ // worker under bun).
12
+ //
13
+ // Why prepend instead of an extra-lib: a global `context` extra-lib would
14
+ // collide with any open editor's own `declare const context` (extra-libs are
15
+ // global to the shared service). Prepending the type defs onto each validated
16
+ // source keeps `context` scoped to that one off-screen file. See
17
+ // `buildValidationSource`.
18
+ import * as monaco from "@codingame/monaco-vscode-editor-api";
19
+ import {
20
+ getJavaScriptWorker,
21
+ getTypeScriptWorker,
22
+ } from "@codingame/monaco-vscode-standalone-typescript-language-features";
23
+ import {
24
+ areVscodeServicesReady,
25
+ ensureStandaloneStdlib,
26
+ } from "./monacoTsService";
27
+ import {
28
+ buildValidationSource,
29
+ mapWorkerDiagnostics,
30
+ type RawTsDiagnostic,
31
+ type ScriptDiagnostic,
32
+ } from "./scriptDiagnostics";
33
+
34
+ export type { ScriptDiagnostic } from "./scriptDiagnostics";
35
+
36
+ export interface ScriptValidationInput {
37
+ /** Caller-chosen identity; the returned map is keyed by this. */
38
+ id: string;
39
+ /** The user's script source (without any generated type prefix). */
40
+ source: string;
41
+ /** Generated `context.d.ts` (+ ambient augmentations) for this script. */
42
+ typeDefinitions: string;
43
+ language: "typescript" | "javascript";
44
+ }
45
+
46
+ // Monotonic across overlapping validation passes so two passes never reuse the
47
+ // same off-screen model URI (which would race on create/dispose).
48
+ let runCounter = 0;
49
+
50
+ /**
51
+ * Validate each script against its `typeDefinitions`. Returns a map keyed by
52
+ * input `id` to that script's diagnostics (empty array = clean). Never throws:
53
+ * any worker/setup failure resolves to no diagnostics so validation can never
54
+ * break the editor it augments.
55
+ *
56
+ * Runs serially. The inline-prepend strategy removes the shared-state
57
+ * constraint that would otherwise force serialization, but validation is not
58
+ * latency-critical and serial keeps worker load predictable.
59
+ */
60
+ export async function validateTypeScriptSources({
61
+ sources,
62
+ }: {
63
+ sources: ScriptValidationInput[];
64
+ }): Promise<Map<string, ScriptDiagnostic[]>> {
65
+ const results = new Map<string, ScriptDiagnostic[]>();
66
+ if (sources.length === 0) {
67
+ return results;
68
+ }
69
+ // CRITICAL: only proceed once an editor has initialized the monaco-vscode
70
+ // services. Initializing them here would collide with the editor wrapper's
71
+ // one-time init ("Services are already initialized") and break the editor.
72
+ if (!areVscodeServicesReady()) {
73
+ return results;
74
+ }
75
+ try {
76
+ await ensureStandaloneStdlib();
77
+ } catch {
78
+ return results;
79
+ }
80
+
81
+ for (const input of sources) {
82
+ try {
83
+ results.set(input.id, await validateOne(input));
84
+ } catch {
85
+ results.set(input.id, []);
86
+ }
87
+ }
88
+ return results;
89
+ }
90
+
91
+ async function validateOne(
92
+ input: ScriptValidationInput,
93
+ ): Promise<ScriptDiagnostic[]> {
94
+ const { text, prependedLineCount } = buildValidationSource({
95
+ typeDefinitions: input.typeDefinitions,
96
+ source: input.source,
97
+ });
98
+ const ext = input.language === "javascript" ? "js" : "ts";
99
+ const uri = monaco.Uri.parse(
100
+ `file:///script-validation/${runCounter++}.${ext}`,
101
+ );
102
+ monaco.editor.getModel(uri)?.dispose();
103
+ const model = monaco.editor.createModel(text, input.language, uri);
104
+ try {
105
+ const getWorker =
106
+ input.language === "javascript"
107
+ ? await getJavaScriptWorker()
108
+ : await getTypeScriptWorker();
109
+ const client = await getWorker(uri);
110
+ const fileName = uri.toString();
111
+ const [syntactic, semantic] = await Promise.all([
112
+ client.getSyntacticDiagnostics(fileName),
113
+ client.getSemanticDiagnostics(fileName),
114
+ ]);
115
+ const diagnostics: RawTsDiagnostic[] = [...syntactic, ...semantic].map(
116
+ (diagnostic) => ({
117
+ start: diagnostic.start,
118
+ length: diagnostic.length,
119
+ messageText: diagnostic.messageText,
120
+ category: diagnostic.category,
121
+ code: diagnostic.code,
122
+ }),
123
+ );
124
+ return mapWorkerDiagnostics({
125
+ diagnostics,
126
+ validationText: text,
127
+ prependedLineCount,
128
+ });
129
+ } finally {
130
+ model.dispose();
131
+ }
132
+ }
@@ -4,6 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
4
4
  import { X } from "lucide-react";
5
5
  import { cn } from "../utils";
6
6
  import { usePerformance } from "./PerformanceProvider";
7
+ import { PortalContainerContext } from "./portalContainer";
7
8
 
8
9
  const Dialog = DialogPrimitive.Root;
9
10
 
@@ -64,12 +65,23 @@ const DialogContent = React.forwardRef<
64
65
  DialogContentProps & DialogContentExtraProps
65
66
  >(({ className, children, size, hideCloseButton, ...props }, ref) => {
66
67
  const { isLowPower } = usePerformance();
68
+ // Expose the content element so popovers/comboboxes inside the dialog portal
69
+ // INTO it, otherwise the modal scroll-lock blocks their internal scrolling.
70
+ const [content, setContent] = React.useState<HTMLDivElement | null>(null);
71
+ const setRefs = React.useCallback(
72
+ (node: HTMLDivElement | null) => {
73
+ setContent(node);
74
+ if (typeof ref === "function") ref(node);
75
+ else if (ref) ref.current = node;
76
+ },
77
+ [ref],
78
+ );
67
79
  return (
68
80
  <DialogPortal>
69
81
  <DialogOverlay />
70
82
  <div className="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none">
71
83
  <DialogPrimitive.Content
72
- ref={ref}
84
+ ref={setRefs}
73
85
  className={cn(
74
86
  "pointer-events-auto relative",
75
87
  dialogContentVariants({ size }),
@@ -79,16 +91,25 @@ const DialogContent = React.forwardRef<
79
91
  )}
80
92
  {...props}
81
93
  >
82
- <div className="-mx-2 px-2 flex flex-col gap-6">{children}</div>
83
- {!hideCloseButton && (
84
- <DialogPrimitive.Close
85
- className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
86
- aria-label="Close"
87
- >
88
- <X className="h-4 w-4" />
89
- <span className="sr-only">Close</span>
90
- </DialogPrimitive.Close>
91
- )}
94
+ <PortalContainerContext.Provider value={content}>
95
+ {/* `min-h-0 flex-1` lets this wrapper fill the height when a
96
+ consumer makes `DialogContent` a tall flex column (e.g. the
97
+ CodeEditor popout, so a `fillHeight` editor fills the body).
98
+ Inert for the default (non-flex) dialog: `flex-1` only affects
99
+ flex items, and `min-h-0` is the block default. */}
100
+ <div className="-mx-2 px-2 flex min-h-0 flex-1 flex-col gap-6">
101
+ {children}
102
+ </div>
103
+ {!hideCloseButton && (
104
+ <DialogPrimitive.Close
105
+ className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
106
+ aria-label="Close"
107
+ >
108
+ <X className="h-4 w-4" />
109
+ <span className="sr-only">Close</span>
110
+ </DialogPrimitive.Close>
111
+ )}
112
+ </PortalContainerContext.Provider>
92
113
  </DialogPrimitive.Content>
93
114
  </div>
94
115
  </DialogPortal>
@@ -0,0 +1,121 @@
1
+ import React from "react";
2
+ import { Input } from "./Input";
3
+ import {
4
+ Select,
5
+ SelectContent,
6
+ SelectItem,
7
+ SelectTrigger,
8
+ SelectValue,
9
+ } from "./Select";
10
+
11
+ /**
12
+ * The duration units this input edits. Mirrors the single-unit object
13
+ * form the automation `Duration` schema accepts
14
+ * (`{ seconds } | { minutes } | { hours }`).
15
+ */
16
+ export type DurationUnit = "seconds" | "minutes" | "hours";
17
+
18
+ /**
19
+ * A single-unit duration value: `{ seconds: 30 }`, `{ minutes: 5 }`, etc.
20
+ * This is exactly the object form the backend `Duration` schema accepts,
21
+ * so a `DurationInput` round-trips losslessly to/from the stored value.
22
+ */
23
+ export type DurationValue = Partial<Record<DurationUnit, number>>;
24
+
25
+ export interface DurationInputProps {
26
+ /** Current duration, or undefined when unset. */
27
+ value: DurationValue | undefined;
28
+ onChange: (next: DurationValue | undefined) => void;
29
+ /** Default unit when the operator first types a value. Defaults to "minutes". */
30
+ defaultUnit?: DurationUnit;
31
+ disabled?: boolean;
32
+ id?: string;
33
+ className?: string;
34
+ }
35
+
36
+ const UNIT_LABELS: Record<DurationUnit, string> = {
37
+ seconds: "seconds",
38
+ minutes: "minutes",
39
+ hours: "hours",
40
+ };
41
+
42
+ /** Read the (unit, amount) pair out of a single-unit duration value. */
43
+ function decompose(
44
+ value: DurationValue | undefined,
45
+ fallbackUnit: DurationUnit,
46
+ ): { unit: DurationUnit; amount: number | undefined } {
47
+ if (value) {
48
+ for (const unit of ["seconds", "minutes", "hours"] as const) {
49
+ const amount = value[unit];
50
+ if (amount !== undefined) return { unit, amount };
51
+ }
52
+ }
53
+ return { unit: fallbackUnit, amount: undefined };
54
+ }
55
+
56
+ /**
57
+ * Number + unit picker for a single-unit duration (`for:` dwells,
58
+ * threshold windows, poll intervals). Emits the object form the backend
59
+ * `Duration` schema accepts, so it round-trips losslessly through YAML.
60
+ *
61
+ * No animations or heavy effects, so no `usePerformance` gating is needed
62
+ * - it is a plain numeric input plus a unit `Select`.
63
+ */
64
+ export const DurationInput: React.FC<DurationInputProps> = ({
65
+ value,
66
+ onChange,
67
+ defaultUnit = "minutes",
68
+ disabled,
69
+ id,
70
+ className,
71
+ }) => {
72
+ const { unit, amount } = decompose(value, defaultUnit);
73
+
74
+ const emit = (nextUnit: DurationUnit, nextAmount: number | undefined) => {
75
+ const cleared: DurationValue | undefined = undefined;
76
+ if (nextAmount === undefined || !Number.isFinite(nextAmount)) {
77
+ onChange(cleared);
78
+ return;
79
+ }
80
+ onChange({ [nextUnit]: Math.max(0, Math.floor(nextAmount)) });
81
+ };
82
+
83
+ return (
84
+ <div className={`flex items-center gap-2 ${className ?? ""}`}>
85
+ <Input
86
+ id={id}
87
+ type="number"
88
+ min={1}
89
+ inputMode="numeric"
90
+ className="w-24"
91
+ value={amount ?? ""}
92
+ disabled={disabled}
93
+ placeholder="0"
94
+ onChange={(event) =>
95
+ emit(
96
+ unit,
97
+ event.target.value === ""
98
+ ? undefined
99
+ : Number(event.target.value),
100
+ )
101
+ }
102
+ />
103
+ <Select
104
+ value={unit}
105
+ disabled={disabled}
106
+ onValueChange={(nextUnit) => emit(nextUnit as DurationUnit, amount)}
107
+ >
108
+ <SelectTrigger className="w-32">
109
+ <SelectValue />
110
+ </SelectTrigger>
111
+ <SelectContent>
112
+ {(["seconds", "minutes", "hours"] as const).map((u) => (
113
+ <SelectItem key={u} value={u}>
114
+ {UNIT_LABELS[u]}
115
+ </SelectItem>
116
+ ))}
117
+ </SelectContent>
118
+ </Select>
119
+ </div>
120
+ );
121
+ };
@@ -3,7 +3,12 @@ import React from "react";
3
3
  import { EmptyState } from "../../index";
4
4
 
5
5
  import type { DynamicFormProps } from "./types";
6
- import { extractDefaults, isValueEmpty, isFieldHiddenByCondition } from "./utils";
6
+ import {
7
+ extractDefaults,
8
+ isValueEmpty,
9
+ isFieldHiddenByCondition,
10
+ findSecretEnvSibling,
11
+ } from "./utils";
7
12
  import { FormField } from "./FormField";
8
13
 
9
14
  /**
@@ -22,6 +27,11 @@ export const DynamicForm: React.FC<DynamicFormProps> = ({
22
27
  typeDefinitions,
23
28
  shellEnvVars,
24
29
  starterTemplates,
30
+ scriptTestRenderer,
31
+ secretNames,
32
+ acquireTypes,
33
+ acquireResetKey,
34
+ importablePackages,
25
35
  }) => {
26
36
  // Track previous validity to avoid redundant callbacks
27
37
  const prevValidRef = React.useRef<boolean | undefined>(undefined);
@@ -88,6 +98,14 @@ export const DynamicForm: React.FC<DynamicFormProps> = ({
88
98
  );
89
99
  }
90
100
 
101
+ // The script field and its `x-secret-env` field are top-level siblings in
102
+ // an action config, so resolve the mapping once at the root and forward it
103
+ // to every field; a testable script field passes it to the test panel.
104
+ const rootSecretEnv = findSecretEnvSibling({
105
+ properties: schema.properties,
106
+ values: value,
107
+ });
108
+
91
109
  return (
92
110
  <div className="space-y-6">
93
111
  {Object.entries(schema.properties)
@@ -121,6 +139,12 @@ export const DynamicForm: React.FC<DynamicFormProps> = ({
121
139
  typeDefinitions={typeDefinitions}
122
140
  shellEnvVars={shellEnvVars}
123
141
  starterTemplates={starterTemplates}
142
+ scriptTestRenderer={scriptTestRenderer}
143
+ secretNames={secretNames}
144
+ acquireTypes={acquireTypes}
145
+ acquireResetKey={acquireResetKey}
146
+ importablePackages={importablePackages}
147
+ siblingSecretEnv={rootSecretEnv}
124
148
  onChange={(val) => onChange({ ...value, [key]: val })}
125
149
  />
126
150
  );
@@ -14,13 +14,20 @@ import {
14
14
  Toggle,
15
15
  ColorPicker,
16
16
  TemplateValueInput,
17
+ DurationInput,
18
+ type DurationValue,
17
19
  } from "../../index";
18
20
 
19
21
  import type { FormFieldProps, JsonSchemaProperty } from "./types";
20
- import { getCleanDescription, NONE_SENTINEL } from "./utils";
22
+ import {
23
+ getCleanDescription,
24
+ NONE_SENTINEL,
25
+ findSecretEnvSibling,
26
+ } from "./utils";
21
27
  import { DynamicOptionsField } from "./DynamicOptionsField";
22
28
  import { JsonField } from "./JsonField";
23
29
  import { MultiTypeEditorField } from "./MultiTypeEditorField";
30
+ import { SecretEnvEditor } from "./SecretEnvEditor";
24
31
 
25
32
  /**
26
33
  * Recursive field renderer that handles all supported JSON Schema types.
@@ -38,6 +45,12 @@ export const FormField: React.FC<FormFieldProps> = ({
38
45
  typeDefinitions,
39
46
  shellEnvVars,
40
47
  starterTemplates,
48
+ scriptTestRenderer,
49
+ secretNames,
50
+ acquireTypes,
51
+ acquireResetKey,
52
+ importablePackages,
53
+ siblingSecretEnv,
41
54
  onChange,
42
55
  }) => {
43
56
  const description = propSchema.description || "";
@@ -74,6 +87,34 @@ export const FormField: React.FC<FormFieldProps> = ({
74
87
  return <></>;
75
88
  }
76
89
 
90
+ // Duration field — render the DurationInput (single-unit duration
91
+ // object). Marked via `x-duration: true` or `format: "duration"`. This
92
+ // branch is intentionally additive and sits before the generic union /
93
+ // object handlers so a `for:` / threshold-window config renders the
94
+ // widget rather than the raw oneOf discriminator picker.
95
+ const isDuration =
96
+ propSchema["x-duration"] === true || propSchema.format === "duration";
97
+ if (isDuration) {
98
+ const cleanDesc = getCleanDescription(description);
99
+ return (
100
+ <div className="space-y-2">
101
+ <div>
102
+ <Label htmlFor={id}>
103
+ {label} {isRequired && "*"}
104
+ </Label>
105
+ {cleanDesc && (
106
+ <p className="text-sm text-muted-foreground mt-0.5">{cleanDesc}</p>
107
+ )}
108
+ </div>
109
+ <DurationInput
110
+ id={id}
111
+ value={value as DurationValue | undefined}
112
+ onChange={(next) => onChange(next)}
113
+ />
114
+ </div>
115
+ );
116
+ }
117
+
77
118
  // Enum handling
78
119
  if (propSchema.enum) {
79
120
  const cleanDesc = getCleanDescription(description);
@@ -137,6 +178,16 @@ export const FormField: React.FC<FormFieldProps> = ({
137
178
  typeDefinitions={typeDefinitions}
138
179
  shellEnvVars={shellEnvVars}
139
180
  starterTemplates={starterTemplates}
181
+ scriptTestRenderer={
182
+ propSchema["x-script-testable"] === true
183
+ ? scriptTestRenderer
184
+ : undefined
185
+ }
186
+ acquireTypes={acquireTypes}
187
+ acquireResetKey={acquireResetKey}
188
+ importablePackages={importablePackages}
189
+ fieldId={id}
190
+ siblingSecretEnv={siblingSecretEnv}
140
191
  onChange={onChange as (val: string | undefined) => void}
141
192
  />
142
193
  );
@@ -331,6 +382,34 @@ export const FormField: React.FC<FormFieldProps> = ({
331
382
  }
332
383
 
333
384
  // Dictionary/Record (headers)
385
+ // Secret -> env mapping: a dedicated editor (env name + secret-name
386
+ // picker) instead of the raw JSON record fallback.
387
+ if (
388
+ propSchema.type === "object" &&
389
+ propSchema.additionalProperties &&
390
+ propSchema["x-secret-env"]
391
+ ) {
392
+ const cleanDesc = getCleanDescription(description);
393
+ return (
394
+ <div className="space-y-2">
395
+ <div>
396
+ <Label htmlFor={id}>
397
+ {label} {isRequired && "*"}
398
+ </Label>
399
+ {cleanDesc && (
400
+ <p className="text-sm text-muted-foreground mt-0.5">{cleanDesc}</p>
401
+ )}
402
+ </div>
403
+ <SecretEnvEditor
404
+ id={id}
405
+ value={(value as Record<string, string> | undefined) ?? {}}
406
+ secretNames={secretNames}
407
+ onChange={(next) => onChange(next)}
408
+ />
409
+ </div>
410
+ );
411
+ }
412
+
334
413
  if (propSchema.type === "object" && propSchema.additionalProperties) {
335
414
  const cleanDesc = getCleanDescription(description);
336
415
  return (
@@ -355,6 +434,12 @@ export const FormField: React.FC<FormFieldProps> = ({
355
434
 
356
435
  // Object (Nested Form)
357
436
  if (propSchema.type === "object" && propSchema.properties) {
437
+ // Resolve the secret→env sibling within THIS object so a nested
438
+ // testable script field forwards the right mapping to the test panel.
439
+ const nestedSecretEnv = findSecretEnvSibling({
440
+ properties: propSchema.properties,
441
+ values: value as Record<string, unknown> | undefined,
442
+ });
358
443
  return (
359
444
  <div className="space-y-4 p-4 border rounded-lg bg-muted/30">
360
445
  <p className="text-sm font-semibold">{label}</p>
@@ -373,6 +458,12 @@ export const FormField: React.FC<FormFieldProps> = ({
373
458
  typeDefinitions={typeDefinitions}
374
459
  shellEnvVars={shellEnvVars}
375
460
  starterTemplates={starterTemplates}
461
+ scriptTestRenderer={scriptTestRenderer}
462
+ secretNames={secretNames}
463
+ acquireTypes={acquireTypes}
464
+ acquireResetKey={acquireResetKey}
465
+ importablePackages={importablePackages}
466
+ siblingSecretEnv={nestedSecretEnv}
376
467
  onChange={(val) =>
377
468
  onChange({ ...(value as Record<string, unknown>), [key]: val })
378
469
  }
@@ -485,6 +576,11 @@ export const FormField: React.FC<FormFieldProps> = ({
485
576
  typeDefinitions={typeDefinitions}
486
577
  shellEnvVars={shellEnvVars}
487
578
  starterTemplates={starterTemplates}
579
+ scriptTestRenderer={scriptTestRenderer}
580
+ secretNames={secretNames}
581
+ acquireTypes={acquireTypes}
582
+ acquireResetKey={acquireResetKey}
583
+ importablePackages={importablePackages}
488
584
  onChange={(val) => {
489
585
  const next = [...(items as unknown[])];
490
586
  next[index] = val;
@@ -543,6 +639,12 @@ export const FormField: React.FC<FormFieldProps> = ({
543
639
  const displayDiscriminatorField =
544
640
  discriminatorField.charAt(0).toUpperCase() + discriminatorField.slice(1);
545
641
 
642
+ // Secret→env sibling within the selected variant's object.
643
+ const variantSecretEnv = findSecretEnvSibling({
644
+ properties: selectedVariant.properties,
645
+ values: currentValue,
646
+ });
647
+
546
648
  return (
547
649
  <div className="space-y-3 p-3 border rounded-lg bg-background">
548
650
  {/* Discriminator selector */}
@@ -617,6 +719,12 @@ export const FormField: React.FC<FormFieldProps> = ({
617
719
  typeDefinitions={typeDefinitions}
618
720
  shellEnvVars={shellEnvVars}
619
721
  starterTemplates={starterTemplates}
722
+ scriptTestRenderer={scriptTestRenderer}
723
+ secretNames={secretNames}
724
+ acquireTypes={acquireTypes}
725
+ acquireResetKey={acquireResetKey}
726
+ importablePackages={importablePackages}
727
+ siblingSecretEnv={variantSecretEnv}
620
728
  onChange={(val) => onChange({ ...currentValue, [key]: val })}
621
729
  />
622
730
  ))}
@@ -1,7 +1,11 @@
1
1
  import React from "react";
2
2
  import { Label } from "../Label";
3
3
  import { Textarea } from "../Textarea";
4
- import { CodeEditor, type TemplateProperty } from "../CodeEditor";
4
+ import {
5
+ CodeEditor,
6
+ type TemplateProperty,
7
+ type AcquireTypes,
8
+ } from "../CodeEditor";
5
9
  import {
6
10
  Select,
7
11
  SelectContent,
@@ -17,7 +21,11 @@ import {
17
21
  parseFormData,
18
22
  EDITOR_TYPE_LABELS,
19
23
  } from "./utils";
20
- import type { EditorStarterTemplates, ShellEnvVar } from "./types";
24
+ import type {
25
+ EditorStarterTemplates,
26
+ ScriptTestRenderer,
27
+ ShellEnvVar,
28
+ } from "./types";
21
29
  import { pickStarterForEmptyField } from "./starterTemplateSelector";
22
30
 
23
31
  export interface MultiTypeEditorFieldProps {
@@ -54,6 +62,34 @@ export interface MultiTypeEditorFieldProps {
54
62
  * working example instead of a blank canvas.
55
63
  */
56
64
  starterTemplates?: EditorStarterTemplates;
65
+ /**
66
+ * Optional renderer for the inline script-test panel. When supplied (the
67
+ * field is `x-script-testable`), it is rendered beneath the editor for
68
+ * `typescript` / `shell` modes, with the currently-selected language as
69
+ * `kind`. The owning page wires it to the `testScript` RPC.
70
+ */
71
+ scriptTestRenderer?: ScriptTestRenderer;
72
+ /**
73
+ * Optional lazy type-acquisition resolver, forwarded to the TS/JS
74
+ * `CodeEditor` so imported npm packages autocomplete (lazy ATA).
75
+ */
76
+ acquireTypes?: AcquireTypes;
77
+ /** Install identity (lockfile hash); resets acquired types on a new install. */
78
+ acquireResetKey?: string;
79
+ /**
80
+ * Importable installed package names (`@types/*`-free), forwarded to the
81
+ * TS/JS `CodeEditor` so the import specifier itself autocompletes.
82
+ */
83
+ importablePackages?: string[];
84
+ /** Form key of this field, forwarded to {@link scriptTestRenderer}. */
85
+ fieldId?: string;
86
+ /**
87
+ * Current value of the sibling `x-secret-env` mapping (located by
88
+ * annotation in the parent config object), forwarded to
89
+ * {@link scriptTestRenderer} so the test panel injects placeholders /
90
+ * overrides for the same secrets the action declares.
91
+ */
92
+ siblingSecretEnv?: Record<string, string>;
57
93
  /** Callback when value changes */
58
94
  onChange: (value: string | undefined) => void;
59
95
  }
@@ -74,6 +110,12 @@ export const MultiTypeEditorField: React.FC<MultiTypeEditorFieldProps> = ({
74
110
  typeDefinitions,
75
111
  shellEnvVars,
76
112
  starterTemplates,
113
+ scriptTestRenderer,
114
+ acquireTypes,
115
+ acquireResetKey,
116
+ importablePackages,
117
+ fieldId,
118
+ siblingSecretEnv,
77
119
  onChange,
78
120
  }) => {
79
121
  // Detect initial editor type from value
@@ -352,6 +394,9 @@ export const MultiTypeEditorField: React.FC<MultiTypeEditorFieldProps> = ({
352
394
  language="javascript"
353
395
  minHeight="150px"
354
396
  typeDefinitions={typeDefinitions}
397
+ acquireTypes={acquireTypes}
398
+ acquireResetKey={acquireResetKey}
399
+ importablePackages={importablePackages}
355
400
  />
356
401
  )}
357
402
 
@@ -363,6 +408,9 @@ export const MultiTypeEditorField: React.FC<MultiTypeEditorFieldProps> = ({
363
408
  language="typescript"
364
409
  minHeight="150px"
365
410
  typeDefinitions={typeDefinitions}
411
+ acquireTypes={acquireTypes}
412
+ acquireResetKey={acquireResetKey}
413
+ importablePackages={importablePackages}
366
414
  />
367
415
  )}
368
416
 
@@ -376,6 +424,23 @@ export const MultiTypeEditorField: React.FC<MultiTypeEditorFieldProps> = ({
376
424
  shellEnvVars={shellEnvVars}
377
425
  />
378
426
  )}
427
+
428
+ {/* Inline script-test panel for testable code fields. Shell maps to
429
+ the `shell` test kind; both typescript and javascript map to the
430
+ `typescript` runner kind (the ESM runner handles both). */}
431
+ {scriptTestRenderer !== undefined &&
432
+ (selectedType === "typescript" ||
433
+ selectedType === "javascript" ||
434
+ selectedType === "shell") &&
435
+ scriptTestRenderer({
436
+ fieldId: fieldId ?? id,
437
+ kind: selectedType === "shell" ? "shell" : "typescript",
438
+ script: value ?? "",
439
+ secretEnv:
440
+ siblingSecretEnv && Object.keys(siblingSecretEnv).length > 0
441
+ ? siblingSecretEnv
442
+ : undefined,
443
+ })}
379
444
  </div>
380
445
  );
381
446
  };