@tai42/studio-sdk 13.2.2 → 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
@@ -0,0 +1,134 @@
1
+ /**
2
+ * The jq input every field a binding owns renders through — the Subject/Scope
3
+ * expressions, a custom injection or update jq, an adapter escape hatch, an op-id.
4
+ *
5
+ * It reuses the form door pattern: when a host has injected an expression editor
6
+ * through {@link ExpressionFieldContext} (the visual jq editor from
7
+ * `@tai42/jq-studio`), that door renders here; with no door it falls back to a
8
+ * plain multiline textarea, so this package keeps NO edge to the jq subgraph.
9
+ *
10
+ * The attached templates' template jq are offered as an autocomplete source: an
11
+ * `Insert` control per jq name appends the call `tjq_<name>({…})` to the
12
+ * expression — the attachment-qualified `tjq_<attachment>__<name>` form when a name is
13
+ * ambiguous across attachments.
14
+ */
15
+ import { useContext, type ReactNode } from 'react';
16
+
17
+ import { Button } from '../components/primitives';
18
+ import { PlusIcon } from '../components/icons';
19
+ import { Field } from '../components/field';
20
+ import { Textarea } from '../components/inputs';
21
+ import { ExpressionFieldContext } from '../schema-form/context';
22
+ import { generateTemplateCall } from './adapter';
23
+
24
+ /** One template jq offered as an insertable autocomplete entry. */
25
+ export interface TemplateJqSuggestion {
26
+ /** The jq reference, e.g. `active` or the attachment-qualified `tally.bump`. */
27
+ readonly ref: string;
28
+ readonly purpose: 'input' | 'update';
29
+ readonly description?: string;
30
+ }
31
+
32
+ export interface BindingJqFieldProps {
33
+ readonly label: string;
34
+ readonly value: string;
35
+ readonly onChange: (value: string) => void;
36
+ readonly description?: ReactNode;
37
+ readonly error?: string;
38
+ readonly placeholder?: string;
39
+ /** Template jq offered as `tjq_<name>({…})` inserts under the field. */
40
+ readonly suggestions?: readonly TemplateJqSuggestion[];
41
+ }
42
+
43
+ /** Append a `tjq_<name>({…})` template call to the current expression (space-separated when non-empty). */
44
+ export function appendTjq(current: string, ref: string): string {
45
+ const call = generateTemplateCall(ref, []);
46
+ const trimmed = current.trim();
47
+ return trimmed === '' ? call : `${trimmed} ${call}`;
48
+ }
49
+
50
+ export function BindingJqField({
51
+ label,
52
+ value,
53
+ onChange,
54
+ description,
55
+ error,
56
+ placeholder,
57
+ suggestions = [],
58
+ }: BindingJqFieldProps): ReactNode {
59
+ const Door = useContext(ExpressionFieldContext);
60
+ // A stable hook for e2e: the injected jq editor owns its own DOM, so the wrapper
61
+ // carries the field's identity (`binding-jq-<label>`) for the driver to locate.
62
+ const testId = `binding-jq-${label.trim().replace(/\s+/g, '-').toLowerCase()}`;
63
+ // Only `input`-purpose jq are callable from an expression; an `update` jq returns an
64
+ // op batch and is picked solely by an update's own select — never offered as an insert.
65
+ const inputSuggestions = suggestions.filter((suggestion) => suggestion.purpose === 'input');
66
+
67
+ return (
68
+ <div
69
+ data-testid={testId}
70
+ style={{ display: 'flex', flexDirection: 'column', gap: 'var(--tai-space-1)' }}
71
+ >
72
+ {Door !== undefined ? (
73
+ <Door
74
+ label={label}
75
+ value={value}
76
+ onChange={onChange}
77
+ description={description}
78
+ error={error}
79
+ multiline
80
+ />
81
+ ) : (
82
+ <Field
83
+ label={label}
84
+ description={typeof description === 'string' ? description : undefined}
85
+ error={error}
86
+ >
87
+ <Textarea
88
+ value={value}
89
+ placeholder={placeholder}
90
+ onChange={(event) => {
91
+ onChange(event.target.value);
92
+ }}
93
+ aria-invalid={error !== undefined ? true : undefined}
94
+ />
95
+ </Field>
96
+ )}
97
+ {inputSuggestions.length > 0 ? (
98
+ <div
99
+ role="group"
100
+ aria-label={`Insert template jq into ${label}`}
101
+ style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--tai-space-1)' }}
102
+ >
103
+ {inputSuggestions.map((suggestion) => (
104
+ <Button
105
+ key={suggestion.ref}
106
+ type="button"
107
+ variant="secondary"
108
+ // A compact outlined pill: the leading plus reads it as an insert
109
+ // affordance so the monospace call text is a control, not stray code.
110
+ style={{
111
+ minHeight: 'auto',
112
+ padding: 'var(--tai-space-1) var(--tai-space-2)',
113
+ borderRadius: 'var(--tai-radius-full)',
114
+ fontFamily: 'var(--tai-font-mono)',
115
+ fontSize: 'var(--tai-text-sm)',
116
+ }}
117
+ title={
118
+ suggestion.description !== undefined && suggestion.description !== ''
119
+ ? `${suggestion.purpose} — ${suggestion.description}`
120
+ : suggestion.purpose
121
+ }
122
+ onClick={() => {
123
+ onChange(appendTjq(value, suggestion.ref));
124
+ }}
125
+ >
126
+ <PlusIcon />
127
+ {generateTemplateCall(suggestion.ref, [])}
128
+ </Button>
129
+ ))}
130
+ </div>
131
+ ) : null}
132
+ </div>
133
+ );
134
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The input-injection list of one attached state. Each row injects a value into the
3
+ * run input BEFORE the run: either a template `input` jq (picked by name) or a
4
+ * custom jq over the record, placed under the `into` run-input field.
5
+ */
6
+ import type { ReactNode } from 'react';
7
+
8
+ import { Button } from '../components/primitives';
9
+ import { Field } from '../components/field';
10
+ import { Select } from '../components/select';
11
+ import { TextInput } from '../components/inputs';
12
+ import { CloseIcon } from '../components/icons';
13
+ import { BindingJqField, type TemplateJqSuggestion } from './BindingJqField';
14
+ import type { ResolvedTemplateJq } from './catalog';
15
+ import type { StateInjection } from './types';
16
+
17
+ function optionLabel(entry: ResolvedTemplateJq): string {
18
+ return entry.description !== undefined && entry.description !== ''
19
+ ? `${entry.ref} — ${entry.description}`
20
+ : entry.ref;
21
+ }
22
+
23
+ const CUSTOM = '__custom__';
24
+
25
+ function InjectionRow({
26
+ injection,
27
+ index,
28
+ inputJq,
29
+ suggestions,
30
+ onChange,
31
+ onRemove,
32
+ }: {
33
+ readonly injection: StateInjection;
34
+ readonly index: number;
35
+ readonly inputJq: readonly ResolvedTemplateJq[];
36
+ readonly suggestions?: readonly TemplateJqSuggestion[];
37
+ readonly onChange: (injection: StateInjection) => void;
38
+ readonly onRemove: () => void;
39
+ }): ReactNode {
40
+ const isCustom = injection.jq !== null;
41
+ const selectValue = isCustom ? CUSTOM : (injection.template_jq ?? '');
42
+
43
+ return (
44
+ <div
45
+ data-testid={`injection-row-${String(index)}`}
46
+ style={{
47
+ display: 'flex',
48
+ gap: 'var(--tai-space-2)',
49
+ alignItems: 'flex-start',
50
+ flexWrap: 'wrap',
51
+ }}
52
+ >
53
+ <Field label={`Injection source ${String(index + 1)}`} hideLabel>
54
+ <Select
55
+ placeholder="Choose a template jq"
56
+ value={selectValue}
57
+ onValueChange={(next) => {
58
+ if (next === CUSTOM) onChange({ template_jq: null, jq: '', into: injection.into });
59
+ else onChange({ template_jq: next, jq: null, into: injection.into });
60
+ }}
61
+ groups={[
62
+ {
63
+ label: 'Template jq',
64
+ options: inputJq.map((entry) => ({ value: entry.ref, label: optionLabel(entry) })),
65
+ },
66
+ { label: 'Custom', options: [{ value: CUSTOM, label: 'Custom jq' }] },
67
+ ]}
68
+ />
69
+ </Field>
70
+ {isCustom ? (
71
+ <BindingJqField
72
+ label={`Custom injection jq ${String(index + 1)}`}
73
+ value={injection.jq ?? ''}
74
+ onChange={(next) => {
75
+ onChange({ template_jq: null, jq: next, into: injection.into });
76
+ }}
77
+ suggestions={suggestions}
78
+ />
79
+ ) : null}
80
+ <Field label={`Into field ${String(index + 1)}`} hideLabel>
81
+ <TextInput
82
+ placeholder="Input field"
83
+ value={injection.into}
84
+ onChange={(event) => {
85
+ onChange({ ...injection, into: event.target.value });
86
+ }}
87
+ />
88
+ </Field>
89
+ <Button
90
+ type="button"
91
+ variant="ghost"
92
+ aria-label={`Remove input ${String(index + 1)}`}
93
+ onClick={onRemove}
94
+ >
95
+ <CloseIcon aria-hidden="true" />
96
+ </Button>
97
+ </div>
98
+ );
99
+ }
100
+
101
+ export interface InjectionListProps {
102
+ readonly injections: readonly StateInjection[];
103
+ readonly onChange: (injections: readonly StateInjection[]) => void;
104
+ readonly inputJq: readonly ResolvedTemplateJq[];
105
+ readonly suggestions?: readonly TemplateJqSuggestion[];
106
+ }
107
+
108
+ export function InjectionList({
109
+ injections,
110
+ onChange,
111
+ inputJq,
112
+ suggestions,
113
+ }: InjectionListProps): ReactNode {
114
+ const add = (): void => {
115
+ const first = inputJq[0];
116
+ const next: StateInjection =
117
+ first !== undefined
118
+ ? { template_jq: first.ref, jq: null, into: '' }
119
+ : { template_jq: null, jq: '', into: '' };
120
+ onChange([...injections, next]);
121
+ };
122
+
123
+ return (
124
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--tai-space-2)' }}>
125
+ {injections.map((injection, index) => (
126
+ <InjectionRow
127
+ // Rows are positional; the index is their stable identity within a save.
128
+ key={index}
129
+ injection={injection}
130
+ index={index}
131
+ inputJq={inputJq}
132
+ suggestions={suggestions}
133
+ onChange={(next) => {
134
+ onChange(injections.map((current, position) => (position === index ? next : current)));
135
+ }}
136
+ onRemove={() => {
137
+ onChange(injections.filter((_, position) => position !== index));
138
+ }}
139
+ />
140
+ ))}
141
+ <div>
142
+ <Button type="button" variant="ghost" onClick={add}>
143
+ Add input
144
+ </Button>
145
+ </div>
146
+ </div>
147
+ );
148
+ }
@@ -0,0 +1,46 @@
1
+ # State-binding editor
2
+
3
+ The one binding shape every door (preset, channel route, schedule, hook) and every
4
+ flow node authors, plus its pure helpers. Lives in the SDK so any plugin door reuses
5
+ it; holds no edge to jq (the jq fields render through the host's ambient
6
+ `ExpressionFieldContext`, else a plain textarea) and paints from SDK tokens only.
7
+
8
+ ## The canonical jq contracts
9
+
10
+ - **template `input` jq** — input is the record (`.`), declared params bound as
11
+ `$params`; returns the injected value.
12
+ - **custom injection jq** — over `{ record, input }`; returns the value placed at `into`.
13
+ - **adapter** — over `{ output, input }`; CONSTRUCTS the update jq's declared input
14
+ object.
15
+ - **template `update` jq** — over `{ record, input }` where `input` is the adapter's
16
+ output; returns a template-relative op batch.
17
+ - **custom `update` jq** — over `{ record, output, input }`; returns `[{op, path, value}]`.
18
+ - **`subject_expr`** — a jq yielding the record KEY: a bare key string, or a full subject object.
19
+ - **`scope_expr`** — optional; a boolean jq predicate. When it is false, the state is skipped for the run.
20
+
21
+ ## The adapter's parseable shape (round-trip)
22
+
23
+ Every form is rebuilt from STORED data, never UI-only state. `compileAdapter` emits a
24
+ canonical, parseable object so a stored adapter reopens as the form it was authored in:
25
+
26
+ ```
27
+ { <key>: (<value>), … }
28
+ ```
29
+
30
+ - `<key>` — a bare identifier, or a JSON string when it is not one.
31
+ - `<value>` — one of: a field path (`.output…` / `.input…`), a JSON literal, or a raw
32
+ jq expression, each wrapped in `(…)`.
33
+
34
+ `parseAdapter` inverts it: `parseAdapter(compileAdapter(rows).jq) === rows` for every
35
+ row kind whose literal is already canonical JSON. A shape it does not recognise returns
36
+ `null`, and the editor opens the raw-jq escape hatch instead.
37
+
38
+ A template update that must ride inside ONE jq field is written as the call
39
+ `tjq_<name>(<adapter object>)` (`generateTemplateCall` / `parseTemplateCall`); a field
40
+ whose expression is exactly that call reopens as the mapping form, anything else as raw
41
+ jq.
42
+
43
+ A bound state or template absent on the server renders the raw jq read-only under a
44
+ `template not attached` error, never a blank form. When the edited binding names a
45
+ state the target's own (`inherited`) binding also names, the state card warns
46
+ `Overrides the preset's subject` and shows the inherited subject/scope.
@@ -0,0 +1,281 @@
1
+ /**
2
+ * One attached state of a binding: the state selector, the templates it attaches
3
+ * (attach-on-use — a not-yet-attached pick shows the "attached on save" hint), the subject
4
+ * and scope expressions, the input injections, and the updates.
5
+ */
6
+ import { useMemo, type ReactNode } from 'react';
7
+
8
+ import { Button } from '../components/primitives';
9
+ import { CodeBlock } from '../components/code-block';
10
+ import { Checkbox } from '../components/checkbox';
11
+ import { Field } from '../components/field';
12
+ import { Select } from '../components/select';
13
+ import { AlertTriangleIcon, CloseIcon } from '../components/icons';
14
+ import { InjectionList } from './InputInjectionRow';
15
+ import { UpdateList } from './UpdateRow';
16
+ import { SubjectScopeFields } from './SubjectScopeFields';
17
+ import type { TemplateJqSuggestion } from './BindingJqField';
18
+ import { resolveTemplateJq } from './catalog';
19
+ import type {
20
+ BindingSourceSchemas,
21
+ BindingStateOption,
22
+ BindingTemplateOption,
23
+ StateAttach,
24
+ } from './types';
25
+
26
+ const ATTACH_ON_USE_HINT = 'This template will be attached to the state when you save.';
27
+
28
+ /** The subject/scope of the target's own (inherited) binding for a state this one also names. */
29
+ export interface InheritedSubject {
30
+ readonly subject_expr: string;
31
+ readonly scope_expr: string | null;
32
+ }
33
+
34
+ /** The stored binding's raw jq, for the unresolved-catalog read-only fallback. */
35
+ function rawBindingLines(attach: StateAttach): string {
36
+ const lines = [`subject: ${attach.subject_expr || '(unset)'}`];
37
+ if (attach.scope_expr !== null && attach.scope_expr !== '')
38
+ lines.push(`scope: ${attach.scope_expr}`);
39
+ for (const injection of attach.input_injections) {
40
+ lines.push(
41
+ `input ${injection.into || '(unset)'} <- ${injection.template_jq ?? injection.jq ?? ''}`,
42
+ );
43
+ }
44
+ for (const update of attach.updates) {
45
+ const ref = update.template_jq ?? update.jq ?? '';
46
+ lines.push(
47
+ `update ${ref}${update.adapter !== null && update.adapter !== '' ? ` adapter ${update.adapter}` : ''}`,
48
+ );
49
+ }
50
+ return lines.join('\n');
51
+ }
52
+
53
+ export interface StateAttachRowProps {
54
+ readonly attach: StateAttach;
55
+ readonly onChange: (attach: StateAttach) => void;
56
+ readonly onRemove: () => void;
57
+ readonly statesCatalog: readonly BindingStateOption[];
58
+ readonly templatesCatalog: readonly BindingTemplateOption[];
59
+ readonly sources?: BindingSourceSchemas;
60
+ readonly subjectError?: string;
61
+ /** The target's own binding subject for this state, when it also binds it (precedence). */
62
+ readonly inherited?: InheritedSubject;
63
+ }
64
+
65
+ export function StateAttachRow({
66
+ attach,
67
+ onChange,
68
+ onRemove,
69
+ statesCatalog,
70
+ templatesCatalog,
71
+ sources,
72
+ subjectError,
73
+ inherited,
74
+ }: StateAttachRowProps): ReactNode {
75
+ const stateOption = statesCatalog.find((option) => option.name === attach.state);
76
+ const attachedTemplates = stateOption?.attachedTemplates ?? [];
77
+ // A bound state or template absent on the server cannot resolve its pickers: show
78
+ // the raw jq read-only rather than a blank form.
79
+ const stateMissing = attach.state !== '' && stateOption === undefined;
80
+ const missingTemplates = attach.templates.filter(
81
+ (name) => !templatesCatalog.some((template) => template.name === name),
82
+ );
83
+ const unresolved = stateMissing || missingTemplates.length > 0;
84
+
85
+ const inputJq = useMemo(
86
+ () => resolveTemplateJq(attach.templates, templatesCatalog, 'input'),
87
+ [attach.templates, templatesCatalog],
88
+ );
89
+ const updateJq = useMemo(
90
+ () => resolveTemplateJq(attach.templates, templatesCatalog, 'update'),
91
+ [attach.templates, templatesCatalog],
92
+ );
93
+ // An `update` jq returns an op batch and is never callable from an expression (it is
94
+ // picked only by an update's own select), so the `tjq_…` insert suggestions offered to
95
+ // EVERY jq field are the attached templates' `input`-purpose jq only.
96
+ const suggestions: TemplateJqSuggestion[] = useMemo(
97
+ () =>
98
+ inputJq.map((entry) => ({
99
+ ref: entry.ref,
100
+ purpose: entry.purpose,
101
+ description: entry.description,
102
+ })),
103
+ [inputJq],
104
+ );
105
+
106
+ const toggleTemplate = (name: string, checked: boolean): void => {
107
+ const next = checked
108
+ ? [...attach.templates, name]
109
+ : attach.templates.filter((template) => template !== name);
110
+ onChange({ ...attach, templates: next });
111
+ };
112
+
113
+ return (
114
+ <div
115
+ data-testid={`state-attach-${attach.state}`}
116
+ style={{
117
+ display: 'flex',
118
+ flexDirection: 'column',
119
+ gap: 'var(--tai-space-3)',
120
+ padding: 'var(--tai-space-3)',
121
+ border: '1px solid var(--tai-color-border)',
122
+ borderRadius: 'var(--tai-radius-md)',
123
+ }}
124
+ >
125
+ <div
126
+ style={{
127
+ display: 'flex',
128
+ gap: 'var(--tai-space-2)',
129
+ alignItems: 'flex-end',
130
+ flexWrap: 'wrap',
131
+ }}
132
+ >
133
+ <Field label="State">
134
+ <Select
135
+ aria-label="State"
136
+ placeholder="Choose a state"
137
+ value={attach.state}
138
+ onValueChange={(next) => {
139
+ // A new state resets the template picks (its attachments differ).
140
+ onChange({ ...attach, state: next, templates: [] });
141
+ }}
142
+ options={statesCatalog.map((option) => ({ value: option.name, label: option.name }))}
143
+ />
144
+ </Field>
145
+ <Button
146
+ type="button"
147
+ variant="ghost"
148
+ aria-label={`Remove state ${attach.state === '' ? '(unset)' : attach.state}`}
149
+ onClick={onRemove}
150
+ >
151
+ <CloseIcon aria-hidden="true" />
152
+ </Button>
153
+ </div>
154
+
155
+ {inherited !== undefined && attach.state !== '' ? (
156
+ <div
157
+ role="status"
158
+ style={{
159
+ display: 'flex',
160
+ gap: 'var(--tai-space-2)',
161
+ alignItems: 'flex-start',
162
+ padding: 'var(--tai-space-2)',
163
+ borderRadius: 'var(--tai-radius-md)',
164
+ background: 'var(--tai-color-warn-tint)',
165
+ color: 'var(--tai-color-warn-text)',
166
+ }}
167
+ >
168
+ <AlertTriangleIcon aria-hidden="true" />
169
+ <div>
170
+ <strong>Overrides the preset&rsquo;s subject</strong>
171
+ <div style={{ display: 'flex', gap: 'var(--tai-space-2)', flexWrap: 'wrap' }}>
172
+ <span
173
+ style={{ color: 'var(--tai-color-text-muted)', fontSize: 'var(--tai-text-sm)' }}
174
+ >
175
+ Preset default:
176
+ </span>
177
+ <span style={{ fontFamily: 'var(--tai-font-mono)', fontSize: 'var(--tai-text-sm)' }}>
178
+ {`subject: ${inherited.subject_expr}`}
179
+ {inherited.scope_expr !== null && inherited.scope_expr !== ''
180
+ ? `, scope: ${inherited.scope_expr}`
181
+ : ''}
182
+ </span>
183
+ </div>
184
+ </div>
185
+ </div>
186
+ ) : null}
187
+
188
+ {attach.state !== '' && unresolved ? (
189
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--tai-space-2)' }}>
190
+ <p role="alert" style={{ margin: 0, color: 'var(--tai-color-err-text)' }}>
191
+ Template not attached.
192
+ </p>
193
+ <CodeBlock code={rawBindingLines(attach)} language="text" />
194
+ </div>
195
+ ) : null}
196
+
197
+ {attach.state !== '' && !unresolved ? (
198
+ <>
199
+ <Field
200
+ label="Templates"
201
+ group
202
+ description="One or more templates this binding attaches to the state."
203
+ >
204
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--tai-space-1)' }}>
205
+ {templatesCatalog.length === 0 ? (
206
+ <p style={{ margin: 0, color: 'var(--tai-color-text-muted)' }}>
207
+ No templates available.
208
+ </p>
209
+ ) : (
210
+ templatesCatalog.map((template) => {
211
+ const checked = attach.templates.includes(template.name);
212
+ const willAttach = checked && !attachedTemplates.includes(template.name);
213
+ return (
214
+ <div
215
+ key={template.name}
216
+ style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}
217
+ >
218
+ <Checkbox
219
+ checked={checked}
220
+ label={template.name}
221
+ onCheckedChange={(next) => {
222
+ toggleTemplate(template.name, next);
223
+ }}
224
+ />
225
+ {willAttach ? (
226
+ <span
227
+ style={{
228
+ color: 'var(--tai-color-text-muted)',
229
+ fontSize: 'var(--tai-text-sm)',
230
+ }}
231
+ >
232
+ {ATTACH_ON_USE_HINT}
233
+ </span>
234
+ ) : null}
235
+ </div>
236
+ );
237
+ })
238
+ )}
239
+ </div>
240
+ </Field>
241
+
242
+ <SubjectScopeFields
243
+ subjectExpr={attach.subject_expr}
244
+ scopeExpr={attach.scope_expr}
245
+ subjectError={subjectError}
246
+ suggestions={suggestions}
247
+ onSubjectChange={(next) => {
248
+ onChange({ ...attach, subject_expr: next });
249
+ }}
250
+ onScopeChange={(next) => {
251
+ onChange({ ...attach, scope_expr: next });
252
+ }}
253
+ />
254
+
255
+ <Field label="Inputs" group>
256
+ <InjectionList
257
+ injections={attach.input_injections}
258
+ inputJq={inputJq}
259
+ suggestions={suggestions}
260
+ onChange={(next) => {
261
+ onChange({ ...attach, input_injections: [...next] });
262
+ }}
263
+ />
264
+ </Field>
265
+
266
+ <Field label="Updates" group>
267
+ <UpdateList
268
+ updates={attach.updates}
269
+ updateJq={updateJq}
270
+ sources={sources}
271
+ suggestions={suggestions}
272
+ onChange={(next) => {
273
+ onChange({ ...attach, updates: [...next] });
274
+ }}
275
+ />
276
+ </Field>
277
+ </>
278
+ ) : null}
279
+ </div>
280
+ );
281
+ }