@tai42/studio-sdk 13.3.0 → 14.0.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 (87) hide show
  1. package/dist/components/icons.d.ts +2 -0
  2. package/dist/components/icons.d.ts.map +1 -1
  3. package/dist/components/icons.js +2 -0
  4. package/dist/components/icons.js.map +1 -1
  5. package/dist/hooks/useApi.d.ts +199 -24
  6. package/dist/hooks/useApi.d.ts.map +1 -1
  7. package/dist/index.d.ts +4 -2
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +4 -2
  10. package/dist/index.js.map +1 -1
  11. package/dist/navigation/types.d.ts +2 -1
  12. package/dist/navigation/types.d.ts.map +1 -1
  13. package/dist/query-keys.d.ts +13 -0
  14. package/dist/query-keys.d.ts.map +1 -1
  15. package/dist/query-keys.js +13 -0
  16. package/dist/query-keys.js.map +1 -1
  17. package/dist/state-binding/AdapterMapping.d.ts +33 -0
  18. package/dist/state-binding/AdapterMapping.d.ts.map +1 -0
  19. package/dist/state-binding/AdapterMapping.js +205 -0
  20. package/dist/state-binding/AdapterMapping.js.map +1 -0
  21. package/dist/state-binding/BindingJqField.d.ts +36 -0
  22. package/dist/state-binding/BindingJqField.d.ts.map +1 -0
  23. package/dist/state-binding/BindingJqField.js +54 -0
  24. package/dist/state-binding/BindingJqField.js.map +1 -0
  25. package/dist/state-binding/InputInjectionRow.d.ts +17 -0
  26. package/dist/state-binding/InputInjectionRow.d.ts.map +1 -0
  27. package/dist/state-binding/InputInjectionRow.js +55 -0
  28. package/dist/state-binding/InputInjectionRow.js.map +1 -0
  29. package/dist/state-binding/StateAttachRow.d.ts +25 -0
  30. package/dist/state-binding/StateAttachRow.d.ts.map +1 -0
  31. package/dist/state-binding/StateAttachRow.js +101 -0
  32. package/dist/state-binding/StateAttachRow.js.map +1 -0
  33. package/dist/state-binding/StateBindingEditor.d.ts +35 -0
  34. package/dist/state-binding/StateBindingEditor.d.ts.map +1 -0
  35. package/dist/state-binding/StateBindingEditor.js +52 -0
  36. package/dist/state-binding/StateBindingEditor.js.map +1 -0
  37. package/dist/state-binding/StateBindingSection.d.ts +14 -0
  38. package/dist/state-binding/StateBindingSection.d.ts.map +1 -0
  39. package/dist/state-binding/StateBindingSection.js +17 -0
  40. package/dist/state-binding/StateBindingSection.js.map +1 -0
  41. package/dist/state-binding/SubjectScopeFields.d.ts +17 -0
  42. package/dist/state-binding/SubjectScopeFields.d.ts.map +1 -0
  43. package/dist/state-binding/SubjectScopeFields.js +8 -0
  44. package/dist/state-binding/SubjectScopeFields.js.map +1 -0
  45. package/dist/state-binding/UpdateRow.d.ts +19 -0
  46. package/dist/state-binding/UpdateRow.d.ts.map +1 -0
  47. package/dist/state-binding/UpdateRow.js +76 -0
  48. package/dist/state-binding/UpdateRow.js.map +1 -0
  49. package/dist/state-binding/adapter.d.ts +93 -0
  50. package/dist/state-binding/adapter.d.ts.map +1 -0
  51. package/dist/state-binding/adapter.js +251 -0
  52. package/dist/state-binding/adapter.js.map +1 -0
  53. package/dist/state-binding/build-catalog.d.ts +19 -0
  54. package/dist/state-binding/build-catalog.d.ts.map +1 -0
  55. package/dist/state-binding/build-catalog.js +43 -0
  56. package/dist/state-binding/build-catalog.js.map +1 -0
  57. package/dist/state-binding/catalog.d.ts +26 -0
  58. package/dist/state-binding/catalog.d.ts.map +1 -0
  59. package/dist/state-binding/catalog.js +71 -0
  60. package/dist/state-binding/catalog.js.map +1 -0
  61. package/dist/state-binding/index.d.ts +29 -0
  62. package/dist/state-binding/index.d.ts.map +1 -0
  63. package/dist/state-binding/index.js +18 -0
  64. package/dist/state-binding/index.js.map +1 -0
  65. package/dist/state-binding/types.d.ts +57 -0
  66. package/dist/state-binding/types.d.ts.map +1 -0
  67. package/dist/state-binding/types.js +2 -0
  68. package/dist/state-binding/types.js.map +1 -0
  69. package/package.json +5 -5
  70. package/src/components/icons.tsx +8 -0
  71. package/src/index.ts +63 -1
  72. package/src/navigation/types.ts +4 -1
  73. package/src/query-keys.ts +15 -0
  74. package/src/state-binding/AdapterMapping.tsx +468 -0
  75. package/src/state-binding/BindingJqField.tsx +134 -0
  76. package/src/state-binding/InputInjectionRow.tsx +148 -0
  77. package/src/state-binding/README.md +46 -0
  78. package/src/state-binding/StateAttachRow.tsx +281 -0
  79. package/src/state-binding/StateBindingEditor.tsx +158 -0
  80. package/src/state-binding/StateBindingSection.tsx +37 -0
  81. package/src/state-binding/SubjectScopeFields.tsx +49 -0
  82. package/src/state-binding/UpdateRow.tsx +194 -0
  83. package/src/state-binding/adapter.ts +276 -0
  84. package/src/state-binding/build-catalog.ts +59 -0
  85. package/src/state-binding/catalog.ts +87 -0
  86. package/src/state-binding/index.ts +61 -0
  87. package/src/state-binding/types.ts +62 -0
package/src/index.ts CHANGED
@@ -140,7 +140,14 @@ export { isFeatureDisabled, featureDisabledMessage, FeatureDisabled } from './fe
140
140
  export type { FeatureDisabledProps } from './feature-disabled';
141
141
 
142
142
  // -- Cross-feature query keys ------------------------------------------------
143
- export { toolsListKey, extensionsQueryKey, subMcpKey, tokensPayloadKey } from './query-keys';
143
+ export {
144
+ toolsListKey,
145
+ extensionsQueryKey,
146
+ subMcpKey,
147
+ tokensPayloadKey,
148
+ statesListKey,
149
+ stateTemplatesKey,
150
+ } from './query-keys';
144
151
 
145
152
  // -- Extension-combo editing helpers -----------------------------------------
146
153
  export { comboElementNames, extensionElementName } from './extension-combos';
@@ -345,6 +352,7 @@ export {
345
352
  ChevronRightIcon,
346
353
  CheckIcon,
347
354
  MinusIcon,
355
+ PlusIcon,
348
356
  EditIcon,
349
357
  UnplugIcon,
350
358
  CopyIcon,
@@ -400,6 +408,60 @@ export type {
400
408
  export { SchemaEditor, lintSchemaText } from './schema-editor';
401
409
  export type { SchemaEditorProps, SchemaEditorChange, SchemaLintResult } from './schema-editor';
402
410
 
411
+ // -- State-binding editor (the one binding shape every door + flow node authors) --
412
+ export {
413
+ StateBindingEditor,
414
+ StateBindingSection,
415
+ StateAttachRow,
416
+ InjectionList,
417
+ UpdateList,
418
+ AdapterMapping,
419
+ SubjectScopeFields,
420
+ BindingJqField,
421
+ appendTjq,
422
+ resolveTemplateJq,
423
+ findByRef,
424
+ resolveCallName,
425
+ templatesCatalogFromList,
426
+ statesCatalogFromList,
427
+ fieldPathsFromSchema,
428
+ compileAdapter,
429
+ rowValueJq,
430
+ fieldPathToJq,
431
+ jqKey,
432
+ defaultRowsForInput,
433
+ parseAdapter,
434
+ parseFieldPath,
435
+ generateTemplateCall,
436
+ parseTemplateCall,
437
+ encodeTemplateSegment,
438
+ } from './state-binding';
439
+ export type {
440
+ StateBindingEditorProps,
441
+ StateBindingSectionProps,
442
+ StateAttachRowProps,
443
+ InheritedSubject,
444
+ InjectionListProps,
445
+ UpdateListProps,
446
+ AdapterMappingProps,
447
+ SubjectScopeFieldsProps,
448
+ BindingJqFieldProps,
449
+ TemplateJqSuggestion,
450
+ ResolvedTemplateJq,
451
+ MappingRow,
452
+ MappingSource,
453
+ FieldSource,
454
+ LiteralSource,
455
+ JqSource,
456
+ FieldRoot,
457
+ AdapterCompileResult,
458
+ BindingStateOption,
459
+ BindingTemplateOption,
460
+ BindingTemplateJqOption,
461
+ BindingSourceSchemas,
462
+ SchemaFieldPath,
463
+ } from './state-binding';
464
+
403
465
  // -- MCP context widgets (elicitation / progress / completions / output) -----
404
466
  export { ProgressBar } from './components/progress-bar';
405
467
  export type { ProgressBarProps } from './components/progress-bar';
@@ -32,9 +32,12 @@ export interface RouteSearchByToken {
32
32
  // for deep-linking and reload.
33
33
  states: {
34
34
  state?: string;
35
- tab?: 'declaration' | 'modules' | 'records' | 'consumers';
35
+ tab?: 'declaration' | 'templates' | 'records' | 'consumers';
36
36
  subject?: string;
37
37
  target?: string;
38
+ // `template` opens one state-template document (its Template / Jq tabs) in place
39
+ // of the state list — a read-only view of the whole uploaded document.
40
+ template?: string;
38
41
  };
39
42
  extensions: Record<string, never>;
40
43
  interactions: Record<string, never>;
package/src/query-keys.ts CHANGED
@@ -32,3 +32,18 @@ export const subMcpKey = ['sub-mcp'] as const;
32
32
 
33
33
  /** The api-key payloads; read by settings AND the hooks execution-key picker. */
34
34
  export const tokensPayloadKey = ['auth-tokens-payload'] as const;
35
+
36
+ /**
37
+ * The state catalog (`GET /api/states`). The states feature owns it, and every door
38
+ * screen that hosts the state-binding editor (a preset, a route, a hook, a schedule)
39
+ * reads the same catalog to populate the binding's state picker — so both key the one
40
+ * authoritative tuple and a single fetch serves both.
41
+ */
42
+ export const statesListKey = ['states', 'list'] as const;
43
+
44
+ /**
45
+ * The state-template document catalog (`GET /api/state-templates`). Read by the states
46
+ * feature AND every state-binding door screen (the templates a binding attaches),
47
+ * keyed here so the two never fetch the same catalog under diverging tuples.
48
+ */
49
+ export const stateTemplatesKey = ['states', 'state-templates'] as const;
@@ -0,0 +1,468 @@
1
+ /**
2
+ * The adapter mapping form for a NAMED update. It renders one row per declared
3
+ * input key of the update jq; each row picks ONE of three value sources — a picked
4
+ * field (from the run's output / input schema, when a schema is known), a hardcoded
5
+ * literal, or a jq expression — and the whole form compiles into ONE adapter jq over
6
+ * `{ output, input }` that constructs the update jq's declared input object. Each
7
+ * row shows its own jq on demand, and the whole compiled adapter is shown on demand.
8
+ *
9
+ * A raw-jq escape hatch (`Write jq`) authors the adapter directly when the mapping needs
10
+ * more than the row form expresses. An update that declares NO input needs no adapter, so
11
+ * it shows a tool-output passthrough and stores `null` (the escape hatch stays available).
12
+ *
13
+ * The stored value always equals what the form shows (WYSIWYG): an accepted default is
14
+ * emitted on mount, so it is never left as the empty string the platform's save refuses.
15
+ * The emitted adapter is a non-empty jq or `null`, never `''`.
16
+ */
17
+ import { useEffect, useState, type ReactNode } from 'react';
18
+
19
+ import { Badge } from '../components/badge';
20
+ import { Button, Skeleton } from '../components/primitives';
21
+ import { Field } from '../components/field';
22
+ import { Select } from '../components/select';
23
+ import { TextInput } from '../components/inputs';
24
+ import { BindingJqField, type TemplateJqSuggestion } from './BindingJqField';
25
+ import {
26
+ compileAdapter,
27
+ parseAdapter,
28
+ rowValueJq,
29
+ type FieldRoot,
30
+ type MappingRow,
31
+ type MappingSource,
32
+ } from './adapter';
33
+ import type { BindingSourceSchemas, SchemaFieldPath } from './types';
34
+
35
+ const ROOT_OPTIONS: readonly { value: FieldRoot; label: string }[] = [
36
+ { value: 'output', label: 'Run output' },
37
+ { value: 'input', label: 'Run input' },
38
+ ];
39
+
40
+ const SOURCE_OPTIONS = [
41
+ { value: 'field', label: 'Field' },
42
+ { value: 'literal', label: 'Literal' },
43
+ { value: 'jq', label: 'Jq' },
44
+ ];
45
+
46
+ export interface AdapterMappingProps {
47
+ /** The declared input keys of the update jq — one row each. Empty ⇒ raw jq only. */
48
+ readonly declaredInput: readonly string[];
49
+ /** The current adapter jq (compiled or authored) stored on the update; `''` ⇒ none stored. */
50
+ readonly value: string;
51
+ /** Emits the adapter to store: a non-empty jq, or `null` for no adapter (never `''`). */
52
+ readonly onChange: (adapterJq: string | null) => void;
53
+ /** The output / input field paths a picker offers. */
54
+ readonly sources?: BindingSourceSchemas;
55
+ /** Template jq offered as `tjq_<name>({…})` inserts in the jq escape hatch. */
56
+ readonly suggestions?: readonly TemplateJqSuggestion[];
57
+ }
58
+
59
+ function fieldsFor(
60
+ root: FieldRoot,
61
+ sources: BindingSourceSchemas | undefined,
62
+ ): readonly SchemaFieldPath[] {
63
+ if (root === 'output') return sources?.output ?? [];
64
+ return sources?.input ?? [];
65
+ }
66
+
67
+ /** The value control for one row's picked-field source: a Select where a schema is known, else a path input. */
68
+ function FieldSourceControl({
69
+ root,
70
+ path,
71
+ onRootChange,
72
+ onPathChange,
73
+ fields,
74
+ loading = false,
75
+ error,
76
+ }: {
77
+ readonly root: FieldRoot;
78
+ readonly path: readonly string[];
79
+ readonly onRootChange: (root: FieldRoot) => void;
80
+ readonly onPathChange: (path: readonly string[]) => void;
81
+ readonly fields: readonly SchemaFieldPath[];
82
+ readonly loading?: boolean;
83
+ readonly error?: string;
84
+ }): ReactNode {
85
+ const pathInput = (
86
+ <TextInput
87
+ aria-label="Field path"
88
+ placeholder="path.into.value"
89
+ value={path.join('.')}
90
+ onChange={(event) => {
91
+ const text = event.target.value.trim();
92
+ onPathChange(text === '' ? [] : text.split('.'));
93
+ }}
94
+ />
95
+ );
96
+ return (
97
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--tai-space-1)' }}>
98
+ <div style={{ display: 'flex', gap: 'var(--tai-space-2)', flexWrap: 'wrap' }}>
99
+ <Field label="Field root" hideLabel>
100
+ <Select
101
+ value={root}
102
+ onValueChange={(next) => {
103
+ onRootChange(next as FieldRoot);
104
+ }}
105
+ options={ROOT_OPTIONS.map((option) => ({ value: option.value, label: option.label }))}
106
+ />
107
+ </Field>
108
+ {loading ? (
109
+ <Skeleton height={32} width={220} />
110
+ ) : fields.length > 0 ? (
111
+ <Field label="Field" hideLabel>
112
+ <Select
113
+ placeholder="Choose a field"
114
+ value={path.length === 0 ? '' : JSON.stringify(path)}
115
+ onValueChange={(next) => {
116
+ onPathChange(next === '' ? [] : (JSON.parse(next) as string[]));
117
+ }}
118
+ options={[
119
+ { value: '', label: '(whole value)' },
120
+ ...fields.map((field) => ({
121
+ value: JSON.stringify(field.path),
122
+ label: field.label,
123
+ })),
124
+ ]}
125
+ />
126
+ </Field>
127
+ ) : (
128
+ pathInput
129
+ )}
130
+ </div>
131
+ {error !== undefined && !loading ? (
132
+ <span
133
+ role="status"
134
+ style={{ color: 'var(--tai-color-text-muted)', fontSize: 'var(--tai-text-sm)' }}
135
+ >
136
+ {error}
137
+ </span>
138
+ ) : null}
139
+ </div>
140
+ );
141
+ }
142
+
143
+ /** One row per declared input key, each defaulting to the whole run output. */
144
+ function defaultRows(declaredInput: readonly string[]): MappingRow[] {
145
+ return declaredInput.map((target): MappingRow => ({
146
+ target,
147
+ source: { kind: 'field', root: 'output', path: [] },
148
+ }));
149
+ }
150
+
151
+ function MappingRowEditor({
152
+ row,
153
+ onChange,
154
+ sources,
155
+ suggestions,
156
+ }: {
157
+ readonly row: MappingRow;
158
+ readonly onChange: (row: MappingRow) => void;
159
+ readonly sources?: BindingSourceSchemas;
160
+ readonly suggestions?: readonly TemplateJqSuggestion[];
161
+ }): ReactNode {
162
+ const [showJq, setShowJq] = useState(false);
163
+ const source = row.source;
164
+ const preview = rowValueJq(source);
165
+
166
+ const setSource = (next: MappingSource): void => {
167
+ onChange({ ...row, source: next });
168
+ };
169
+
170
+ const changeKind = (kind: string): void => {
171
+ if (kind === 'field') setSource({ kind: 'field', root: 'output', path: [] });
172
+ else if (kind === 'literal') setSource({ kind: 'literal', json: '' });
173
+ else setSource({ kind: 'jq', expr: '' });
174
+ };
175
+
176
+ return (
177
+ <div
178
+ data-testid={`adapter-row-${row.target}`}
179
+ style={{
180
+ display: 'flex',
181
+ flexDirection: 'column',
182
+ gap: 'var(--tai-space-1)',
183
+ padding: 'var(--tai-space-2)',
184
+ border: '1px solid var(--tai-color-border)',
185
+ borderRadius: 'var(--tai-radius-md)',
186
+ }}
187
+ >
188
+ <div
189
+ style={{
190
+ display: 'flex',
191
+ alignItems: 'center',
192
+ gap: 'var(--tai-space-2)',
193
+ flexWrap: 'wrap',
194
+ }}
195
+ >
196
+ <Badge variant="neutral">{row.target}</Badge>
197
+ <Field label={`Source for ${row.target}`} hideLabel>
198
+ <Select
199
+ aria-label={`Source for ${row.target}`}
200
+ value={source.kind}
201
+ onValueChange={changeKind}
202
+ options={SOURCE_OPTIONS}
203
+ />
204
+ </Field>
205
+ </div>
206
+
207
+ {source.kind === 'field' ? (
208
+ <FieldSourceControl
209
+ root={source.root}
210
+ path={source.path}
211
+ fields={fieldsFor(source.root, sources)}
212
+ loading={sources?.loading}
213
+ error={sources?.error}
214
+ onRootChange={(nextRoot) => {
215
+ setSource({ kind: 'field', root: nextRoot, path: [] });
216
+ }}
217
+ onPathChange={(nextPath) => {
218
+ setSource({ kind: 'field', root: source.root, path: nextPath });
219
+ }}
220
+ />
221
+ ) : source.kind === 'literal' ? (
222
+ <Field
223
+ label={`Literal for ${row.target}`}
224
+ hideLabel
225
+ error={preview.ok ? undefined : preview.error}
226
+ >
227
+ <TextInput
228
+ aria-label={`Literal for ${row.target}`}
229
+ placeholder='"a string", 42, true, or {"k":1}'
230
+ value={source.json}
231
+ onChange={(event) => {
232
+ setSource({ kind: 'literal', json: event.target.value });
233
+ }}
234
+ />
235
+ </Field>
236
+ ) : (
237
+ <BindingJqField
238
+ label={`Jq for ${row.target}`}
239
+ value={source.expr}
240
+ onChange={(next) => {
241
+ setSource({ kind: 'jq', expr: next });
242
+ }}
243
+ error={preview.ok ? undefined : preview.error}
244
+ suggestions={suggestions}
245
+ />
246
+ )}
247
+
248
+ <div>
249
+ <Button
250
+ type="button"
251
+ variant="ghost"
252
+ aria-expanded={showJq}
253
+ onClick={() => {
254
+ setShowJq((open) => !open);
255
+ }}
256
+ >
257
+ {showJq ? 'Hide jq' : 'Show jq'}
258
+ </Button>
259
+ {showJq ? (
260
+ <pre
261
+ data-testid={`adapter-row-jq-${row.target}`}
262
+ style={{ margin: 0, fontFamily: 'var(--tai-font-mono)', whiteSpace: 'pre-wrap' }}
263
+ >
264
+ {preview.ok ? preview.jq : preview.error}
265
+ </pre>
266
+ ) : null}
267
+ </div>
268
+ </div>
269
+ );
270
+ }
271
+
272
+ export function AdapterMapping({
273
+ declaredInput,
274
+ value,
275
+ onChange,
276
+ sources,
277
+ suggestions,
278
+ }: AdapterMappingProps): ReactNode {
279
+ const canMap = declaredInput.length > 0;
280
+ // Rebuild the form from STORED data: a stored adapter that parses back to rows reopens
281
+ // as the mapping form; anything else opens in the raw-jq escape hatch. With no declared
282
+ // input the update needs no adapter, so it opens on the tool-output passthrough (a
283
+ // stored jq still reopens the escape hatch).
284
+ const recovered = value.trim() === '' ? null : parseAdapter(value);
285
+ const [mode, setMode] = useState<'fields' | 'jq'>(() => {
286
+ if (value.trim() === '') return 'fields';
287
+ return canMap && recovered !== null ? 'fields' : 'jq';
288
+ });
289
+ const [rows, setRows] = useState<MappingRow[]>(() => recovered ?? defaultRows(declaredInput));
290
+ // Set when a toggle to fields mode finds a jq the row form cannot represent: the
291
+ // note explains why the view stayed on the raw jq (see `toFields`).
292
+ const [unmappable, setUnmappable] = useState(false);
293
+
294
+ const emit = (nextRows: readonly MappingRow[]): void => {
295
+ // WYSIWYG: store exactly what the form shows. A compile failure has no valid adapter,
296
+ // so emit `null` (no adapter) — never the empty string the platform's save refuses.
297
+ const compiled = compileAdapter(nextRows);
298
+ onChange(compiled.ok ? compiled.jq : null);
299
+ };
300
+
301
+ // WYSIWYG on mount: with no stored value the form shows a default — the compiled
302
+ // declared-input rows when mappable, else the tool-output passthrough — so store it AT
303
+ // ONCE. An operator who accepts the shown default must not leave an empty adapter the
304
+ // platform's save-time compile check refuses. Re-runs when the declared input changes
305
+ // the shown default; the guard preserves a stored/authored value untouched.
306
+ const declaredKey = declaredInput.join('');
307
+ useEffect(() => {
308
+ if (mode !== 'fields' || value.trim() !== '') return;
309
+ const seeded = canMap ? compileAdapter(defaultRows(declaredInput)) : null;
310
+ onChange(seeded?.ok ? seeded.jq : null);
311
+ // eslint-disable-next-line react-hooks/exhaustive-deps
312
+ }, [declaredKey]);
313
+
314
+ // Toggle to the row form by re-deriving rows from the CURRENT stored jq, never from
315
+ // the rows held since mount (those go stale while the user edits the raw jq, so
316
+ // compiling them would silently overwrite the authored jq). A non-empty jq the row
317
+ // form cannot represent keeps the raw view and surfaces a note instead of clobbering
318
+ // it; a clean round-trip re-emits nothing, so an authored jq is preserved byte-for-byte.
319
+ // With no declared input there is nothing to map — the toggle returns to the passthrough.
320
+ const toFields = (): void => {
321
+ if (!canMap) {
322
+ setMode('fields');
323
+ setUnmappable(false);
324
+ onChange(null);
325
+ return;
326
+ }
327
+ const parsed = value.trim() === '' ? null : parseAdapter(value);
328
+ if (value.trim() !== '' && parsed === null) {
329
+ setUnmappable(true);
330
+ return;
331
+ }
332
+ const nextRows = parsed ?? defaultRows(declaredInput);
333
+ setRows(nextRows);
334
+ setMode('fields');
335
+ setUnmappable(false);
336
+ const compiled = compileAdapter(nextRows);
337
+ if (compiled.ok && compiled.jq !== value) onChange(compiled.jq);
338
+ };
339
+
340
+ // The raw-jq edit path clears a stale unmappable note so a re-mapping attempt sees the
341
+ // edited jq, then forwards the edit — normalizing an empty/whitespace box to `null` (no
342
+ // adapter) so the empty string is never stored.
343
+ const onJqChange = (next: string): void => {
344
+ if (unmappable) setUnmappable(false);
345
+ onChange(next.trim() === '' ? null : next);
346
+ };
347
+
348
+ const setRow = (index: number, next: MappingRow): void => {
349
+ const nextRows = rows.map((row, position) => (position === index ? next : row));
350
+ setRows(nextRows);
351
+ emit(nextRows);
352
+ };
353
+
354
+ const compiled = compileAdapter(rows);
355
+ const [showCompiled, setShowCompiled] = useState(false);
356
+
357
+ // An adapter is refused empty ONLY when the update declares inputs to fill; a
358
+ // no-declared-input update legitimately stores no adapter (the passthrough).
359
+ const rawError =
360
+ canMap && mode === 'jq' && value.trim() === ''
361
+ ? 'Map the declared inputs or write an adapter jq — an empty adapter is refused.'
362
+ : undefined;
363
+
364
+ return (
365
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--tai-space-2)' }}>
366
+ <div style={{ display: 'flex', gap: 'var(--tai-space-2)' }}>
367
+ <Button
368
+ type="button"
369
+ variant={mode === 'fields' ? 'primary' : 'ghost'}
370
+ aria-pressed={mode === 'fields'}
371
+ onClick={toFields}
372
+ >
373
+ {canMap ? 'Map fields' : 'Use tool output'}
374
+ </Button>
375
+ <Button
376
+ type="button"
377
+ variant={mode === 'jq' ? 'primary' : 'ghost'}
378
+ aria-pressed={mode === 'jq'}
379
+ onClick={() => {
380
+ // Seed the escape hatch with the compiled adapter so nothing is lost — only
381
+ // when there are rows to compile; a no-declared-input update opens empty.
382
+ if (canMap && value.trim() === '' && compiled.ok) onChange(compiled.jq);
383
+ setMode('jq');
384
+ }}
385
+ >
386
+ Write jq
387
+ </Button>
388
+ </div>
389
+
390
+ {unmappable ? (
391
+ <p
392
+ role="status"
393
+ style={{
394
+ margin: 0,
395
+ color: 'var(--tai-color-text-muted)',
396
+ fontSize: 'var(--tai-text-sm)',
397
+ }}
398
+ >
399
+ This adapter jq cannot be shown as fields. Edit it here.
400
+ </p>
401
+ ) : null}
402
+
403
+ {mode === 'fields' ? (
404
+ canMap ? (
405
+ <>
406
+ {rows.map((row, index) => (
407
+ <MappingRowEditor
408
+ key={row.target}
409
+ row={row}
410
+ sources={sources}
411
+ suggestions={suggestions}
412
+ onChange={(next) => {
413
+ setRow(index, next);
414
+ }}
415
+ />
416
+ ))}
417
+ {!compiled.ok ? (
418
+ <p role="alert" style={{ margin: 0, color: 'var(--tai-color-err-text)' }}>
419
+ {compiled.error}
420
+ </p>
421
+ ) : null}
422
+ <div>
423
+ <Button
424
+ type="button"
425
+ variant="ghost"
426
+ aria-expanded={showCompiled}
427
+ onClick={() => {
428
+ setShowCompiled((open) => !open);
429
+ }}
430
+ >
431
+ {showCompiled ? 'Hide adapter jq' : 'Show adapter jq'}
432
+ </Button>
433
+ {showCompiled ? (
434
+ <pre
435
+ data-testid="adapter-compiled-jq"
436
+ style={{ margin: 0, fontFamily: 'var(--tai-font-mono)', whiteSpace: 'pre-wrap' }}
437
+ >
438
+ {compiled.ok ? compiled.jq : compiled.error}
439
+ </pre>
440
+ ) : null}
441
+ </div>
442
+ </>
443
+ ) : (
444
+ <p
445
+ role="status"
446
+ style={{
447
+ margin: 0,
448
+ color: 'var(--tai-color-text-muted)',
449
+ fontSize: 'var(--tai-text-sm)',
450
+ }}
451
+ >
452
+ This update runs with the tool output as its input.
453
+ </p>
454
+ )
455
+ ) : (
456
+ <BindingJqField
457
+ label="Adapter"
458
+ description="Maps the run's output into what this update expects as `.input`."
459
+ value={value}
460
+ onChange={onJqChange}
461
+ suggestions={suggestions}
462
+ placeholder="{ total: .output.total }"
463
+ error={rawError}
464
+ />
465
+ )}
466
+ </div>
467
+ );
468
+ }