@assistant-ui/react-generative-ui 0.0.21 → 0.0.23

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 (212) hide show
  1. package/LICENSE +1 -1
  2. package/dist/JSONGenerativeUI.client.d.ts.map +1 -1
  3. package/dist/JSONGenerativeUI.client.js +30 -13
  4. package/dist/JSONGenerativeUI.client.js.map +1 -1
  5. package/dist/RadioGroupScope.d.ts +6 -0
  6. package/dist/RadioGroupScope.d.ts.map +1 -0
  7. package/dist/RadioGroupScope.js +25 -0
  8. package/dist/RadioGroupScope.js.map +1 -0
  9. package/dist/a2ui/BindingContext.d.ts +11 -0
  10. package/dist/a2ui/BindingContext.d.ts.map +1 -0
  11. package/dist/a2ui/BindingContext.js +12 -0
  12. package/dist/a2ui/BindingContext.js.map +1 -0
  13. package/dist/a2ui/PresentRenderer.d.ts +11 -0
  14. package/dist/a2ui/PresentRenderer.d.ts.map +1 -0
  15. package/dist/a2ui/PresentRenderer.js +39 -0
  16. package/dist/a2ui/PresentRenderer.js.map +1 -0
  17. package/dist/a2ui/convert.d.ts +6 -0
  18. package/dist/a2ui/convert.d.ts.map +1 -1
  19. package/dist/a2ui/convert.js +363 -85
  20. package/dist/a2ui/convert.js.map +1 -1
  21. package/dist/a2ui/dataModel.d.ts +9 -0
  22. package/dist/a2ui/dataModel.d.ts.map +1 -0
  23. package/dist/a2ui/dataModel.js +44 -0
  24. package/dist/a2ui/dataModel.js.map +1 -0
  25. package/dist/a2ui/reducer.d.ts +5 -0
  26. package/dist/a2ui/reducer.d.ts.map +1 -1
  27. package/dist/a2ui/reducer.js +3 -3
  28. package/dist/a2ui/reducer.js.map +1 -1
  29. package/dist/a2ui/types.d.ts +8 -2
  30. package/dist/a2ui/types.d.ts.map +1 -1
  31. package/dist/a2ui/types.js.map +1 -1
  32. package/dist/a2ui/valueFunctions.d.ts +16 -0
  33. package/dist/a2ui/valueFunctions.d.ts.map +1 -0
  34. package/dist/a2ui/valueFunctions.js +332 -0
  35. package/dist/a2ui/valueFunctions.js.map +1 -0
  36. package/dist/actionRegistry.d.ts +1 -1
  37. package/dist/actionRegistry.js.map +1 -1
  38. package/dist/answeredValues.d.ts +16 -0
  39. package/dist/answeredValues.d.ts.map +1 -0
  40. package/dist/answeredValues.js +37 -0
  41. package/dist/answeredValues.js.map +1 -0
  42. package/dist/constants.d.ts +3 -0
  43. package/dist/constants.d.ts.map +1 -1
  44. package/dist/constants.js +4 -1
  45. package/dist/constants.js.map +1 -1
  46. package/dist/fieldReferences.d.ts +9 -0
  47. package/dist/fieldReferences.d.ts.map +1 -0
  48. package/dist/fieldReferences.js +53 -0
  49. package/dist/fieldReferences.js.map +1 -0
  50. package/dist/generativeUIToJSX.js +3 -3
  51. package/dist/generativeUIToJSX.js.map +1 -1
  52. package/dist/index.js +2 -2
  53. package/dist/index.server.js +2 -2
  54. package/dist/index.shared.js +2 -2
  55. package/dist/ir.d.ts +1 -1
  56. package/dist/ir.js.map +1 -1
  57. package/dist/renderGenerativeUI.d.ts.map +1 -1
  58. package/dist/renderGenerativeUI.js +12 -1
  59. package/dist/renderGenerativeUI.js.map +1 -1
  60. package/dist/slack/constants.d.ts +2 -0
  61. package/dist/slack/constants.d.ts.map +1 -1
  62. package/dist/slack/constants.js +3 -1
  63. package/dist/slack/constants.js.map +1 -1
  64. package/dist/slack/decodeBlockAction.d.ts +3 -1
  65. package/dist/slack/decodeBlockAction.d.ts.map +1 -1
  66. package/dist/slack/decodeBlockAction.js +96 -7
  67. package/dist/slack/decodeBlockAction.js.map +1 -1
  68. package/dist/slack/fromSlackBlocks.d.ts.map +1 -1
  69. package/dist/slack/fromSlackBlocks.js +43 -3
  70. package/dist/slack/fromSlackBlocks.js.map +1 -1
  71. package/dist/slack/toSlackBlocks.d.ts.map +1 -1
  72. package/dist/slack/toSlackBlocks.js +244 -20
  73. package/dist/slack/toSlackBlocks.js.map +1 -1
  74. package/dist/slack/types.d.ts +24 -1
  75. package/dist/slack/types.d.ts.map +1 -1
  76. package/dist/slack.d.ts +1 -1
  77. package/dist/slack.d.ts.map +1 -1
  78. package/dist/teams/constants.d.ts +1 -1
  79. package/dist/teams/constants.d.ts.map +1 -1
  80. package/dist/teams/constants.js +1 -1
  81. package/dist/teams/constants.js.map +1 -1
  82. package/dist/teams/decodeSubmitData.d.ts +4 -0
  83. package/dist/teams/decodeSubmitData.d.ts.map +1 -1
  84. package/dist/teams/decodeSubmitData.js +29 -3
  85. package/dist/teams/decodeSubmitData.js.map +1 -1
  86. package/dist/teams/temporalId.d.ts +5 -0
  87. package/dist/teams/temporalId.d.ts.map +1 -0
  88. package/dist/teams/temporalId.js +28 -0
  89. package/dist/teams/temporalId.js.map +1 -0
  90. package/dist/teams/toAdaptiveCard.d.ts +5 -4
  91. package/dist/teams/toAdaptiveCard.d.ts.map +1 -1
  92. package/dist/teams/toAdaptiveCard.js +136 -18
  93. package/dist/teams/toAdaptiveCard.js.map +1 -1
  94. package/dist/teams/toTeamsAttachments.d.ts.map +1 -1
  95. package/dist/teams/toTeamsAttachments.js.map +1 -1
  96. package/dist/teams/types.d.ts +23 -2
  97. package/dist/teams/types.d.ts.map +1 -1
  98. package/dist/teams.d.ts +1 -1
  99. package/dist/teams.d.ts.map +1 -1
  100. package/dist/temporal.d.ts +33 -0
  101. package/dist/temporal.d.ts.map +1 -0
  102. package/dist/temporal.js +152 -0
  103. package/dist/temporal.js.map +1 -0
  104. package/dist/vocabulary/alert.d.ts +9 -2
  105. package/dist/vocabulary/alert.d.ts.map +1 -1
  106. package/dist/vocabulary/alert.js +116 -19
  107. package/dist/vocabulary/alert.js.map +1 -1
  108. package/dist/vocabulary/collectFormValues.d.ts +2 -1
  109. package/dist/vocabulary/collectFormValues.d.ts.map +1 -1
  110. package/dist/vocabulary/collectFormValues.js +15 -4
  111. package/dist/vocabulary/collectFormValues.js.map +1 -1
  112. package/dist/vocabulary/data.d.ts +22 -5
  113. package/dist/vocabulary/data.d.ts.map +1 -1
  114. package/dist/vocabulary/data.js +278 -83
  115. package/dist/vocabulary/data.js.map +1 -1
  116. package/dist/vocabulary/dispatch.d.ts +3 -2
  117. package/dist/vocabulary/dispatch.d.ts.map +1 -1
  118. package/dist/vocabulary/dispatch.js +33 -4
  119. package/dist/vocabulary/dispatch.js.map +1 -1
  120. package/dist/vocabulary/fact.d.ts +8 -1
  121. package/dist/vocabulary/fact.d.ts.map +1 -1
  122. package/dist/vocabulary/fact.js +37 -11
  123. package/dist/vocabulary/fact.js.map +1 -1
  124. package/dist/vocabulary/form.d.ts.map +1 -1
  125. package/dist/vocabulary/form.js +3 -3
  126. package/dist/vocabulary/form.js.map +1 -1
  127. package/dist/vocabulary/formatValue.d.ts +7 -0
  128. package/dist/vocabulary/formatValue.d.ts.map +1 -0
  129. package/dist/vocabulary/formatValue.js +76 -0
  130. package/dist/vocabulary/formatValue.js.map +1 -0
  131. package/dist/vocabulary/icon.d.ts +1 -1
  132. package/dist/vocabulary/interactive.d.ts +143 -8
  133. package/dist/vocabulary/interactive.d.ts.map +1 -1
  134. package/dist/vocabulary/interactive.js +458 -107
  135. package/dist/vocabulary/interactive.js.map +1 -1
  136. package/dist/vocabulary/layout.d.ts.map +1 -1
  137. package/dist/vocabulary/layout.js +4 -4
  138. package/dist/vocabulary/layout.js.map +1 -1
  139. package/dist/vocabulary/list.js +2 -2
  140. package/dist/vocabulary/list.js.map +1 -1
  141. package/dist/vocabulary/text.d.ts +1 -1
  142. package/package.json +8 -5
  143. package/src/JSONGenerativeUI.client.tsx +84 -40
  144. package/src/JSONGenerativeUI.test.tsx +964 -68
  145. package/src/RadioGroupScope.server.test.tsx +24 -0
  146. package/src/RadioGroupScope.tsx +34 -0
  147. package/src/a2ui/BindingContext.ts +26 -0
  148. package/src/a2ui/PresentRenderer.tsx +83 -0
  149. package/src/a2ui/convert.test.ts +2054 -229
  150. package/src/a2ui/convert.ts +665 -134
  151. package/src/a2ui/dataModel.ts +69 -0
  152. package/src/a2ui/reducer.test.ts +35 -0
  153. package/src/a2ui/reducer.ts +5 -4
  154. package/src/a2ui/types.ts +9 -2
  155. package/src/a2ui/valueFunctions.test.ts +271 -0
  156. package/src/a2ui/valueFunctions.ts +523 -0
  157. package/src/actionRegistry.ts +1 -1
  158. package/src/answeredValues.test.ts +33 -0
  159. package/src/answeredValues.tsx +70 -0
  160. package/src/constants.ts +5 -0
  161. package/src/fieldReferences.test.ts +66 -0
  162. package/src/fieldReferences.ts +83 -0
  163. package/src/generativeUIToJSX.test.ts +18 -0
  164. package/src/generativeUIToJSX.ts +4 -6
  165. package/src/index.server.test.ts +106 -0
  166. package/src/ir.ts +1 -1
  167. package/src/renderGenerativeUI.test.tsx +14 -5
  168. package/src/renderGenerativeUI.tsx +30 -1
  169. package/src/slack/a2uiSlack.test.ts +198 -0
  170. package/src/slack/constants.ts +3 -0
  171. package/src/slack/datePicker.test.ts +612 -0
  172. package/src/slack/decodeBlockAction.test.ts +98 -0
  173. package/src/slack/decodeBlockAction.ts +182 -16
  174. package/src/slack/fromSlackBlocks.test.ts +222 -3
  175. package/src/slack/fromSlackBlocks.ts +82 -2
  176. package/src/slack/toSlackBlocks.test.ts +682 -3
  177. package/src/slack/toSlackBlocks.ts +463 -25
  178. package/src/slack/types.ts +28 -0
  179. package/src/slack.ts +2 -0
  180. package/src/teams/constants.ts +1 -1
  181. package/src/teams/decodeSubmitData.test.ts +249 -0
  182. package/src/teams/decodeSubmitData.ts +57 -4
  183. package/src/teams/temporal.test.ts +766 -0
  184. package/src/teams/temporalId.ts +56 -0
  185. package/src/teams/toAdaptiveCard.test.ts +219 -0
  186. package/src/teams/toAdaptiveCard.ts +259 -21
  187. package/src/teams/toTeamsAttachments.ts +4 -1
  188. package/src/teams/types.ts +21 -1
  189. package/src/teams.ts +1 -0
  190. package/src/temporal.test.ts +319 -0
  191. package/src/temporal.ts +259 -0
  192. package/src/vocabulary/alert.test.tsx +288 -7
  193. package/src/vocabulary/alert.tsx +168 -26
  194. package/src/vocabulary/collectFormValues.test.ts +225 -1
  195. package/src/vocabulary/collectFormValues.ts +54 -5
  196. package/src/vocabulary/data.test.tsx +248 -6
  197. package/src/vocabulary/data.tsx +398 -100
  198. package/src/vocabulary/datepicker.dom.test.tsx +1148 -0
  199. package/src/vocabulary/dispatch.ts +63 -2
  200. package/src/vocabulary/fact.test.tsx +67 -0
  201. package/src/vocabulary/fact.tsx +40 -9
  202. package/src/vocabulary/form.tsx +7 -2
  203. package/src/vocabulary/formatValue.test.ts +72 -0
  204. package/src/vocabulary/formatValue.ts +127 -0
  205. package/src/vocabulary/index.test.ts +2 -0
  206. package/src/vocabulary/interactive.dispatch.test.tsx +167 -79
  207. package/src/vocabulary/interactive.dom.test.tsx +1747 -0
  208. package/src/vocabulary/interactive.test.tsx +60 -4
  209. package/src/vocabulary/interactive.tsx +910 -155
  210. package/src/vocabulary/layout.test.tsx +6 -4
  211. package/src/vocabulary/layout.tsx +13 -2
  212. package/src/vocabulary/list.tsx +2 -2
@@ -1,13 +1,18 @@
1
1
  import { ICON_NAMES, type UIElement } from "../ir";
2
+ import { classifyTemporal } from "../temporal";
3
+ import { A2UI_SURFACE_ID, type A2uiSurfaceState } from "./types";
2
4
  import {
3
- A2UI_SURFACE_ID,
4
- type A2uiSurfaceState,
5
- type A2uiTemplateChildren,
6
- } from "./types";
5
+ evaluateA2uiValueFunction,
6
+ type ExpressionPart,
7
+ } from "./valueFunctions";
8
+ import { MAX_AUTO_VIVIFY_ARRAY_INDEX } from "./reducer";
9
+ import type { A2uiBinding } from "./BindingContext";
10
+ import { decodePointer, resolvePointer } from "./dataModel";
7
11
 
8
12
  const DEPTH_CAP = 32;
9
13
  const TEMPLATE_ITEM_CAP = 100;
10
14
  const NODE_BUDGET = 5000;
15
+ const EVALUATION_BUDGET = 20_000;
11
16
 
12
17
  const SUPPORTED_COMPONENTS = new Set([
13
18
  "Text",
@@ -23,6 +28,13 @@ const SUPPORTED_COMPONENTS = new Set([
23
28
  "CheckBox",
24
29
  "ChoicePicker",
25
30
  "DateTimeInput",
31
+ "Slider",
32
+ ]);
33
+
34
+ const CHILD_LIST_COMPONENTS: ReadonlySet<string> = new Set([
35
+ "Row",
36
+ "Column",
37
+ "List",
26
38
  ]);
27
39
 
28
40
  const ICON_NAME_SET: ReadonlySet<string> = new Set(ICON_NAMES);
@@ -54,66 +66,130 @@ const setOwnProperty = (
54
66
  }
55
67
  };
56
68
 
69
+ const FUNCTION_CALL_KEYS: ReadonlySet<string> = new Set([
70
+ "call",
71
+ "args",
72
+ "returnType",
73
+ "catalogId",
74
+ ]);
75
+
76
+ const isFunctionCall = (
77
+ value: unknown,
78
+ ): value is {
79
+ readonly call: string;
80
+ readonly args?: Record<string, unknown>;
81
+ } =>
82
+ isPlainObject(value) &&
83
+ typeof value["call"] === "string" &&
84
+ (value["args"] === undefined || isPlainObject(value["args"])) &&
85
+ Object.keys(value).every((key) => FUNCTION_CALL_KEYS.has(key));
86
+
57
87
  const isBinding = (value: unknown): value is { readonly path: string } =>
58
88
  isPlainObject(value) &&
59
89
  Object.keys(value).length === 1 &&
60
90
  typeof value["path"] === "string";
61
91
 
62
- const isTemplateChildren = (value: unknown): value is A2uiTemplateChildren =>
63
- isPlainObject(value) &&
64
- Object.keys(value).length === 1 &&
65
- isPlainObject(value["template"]) &&
66
- Object.keys(value["template"]).length === 2 &&
67
- typeof value["template"]["componentId"] === "string" &&
68
- typeof value["template"]["path"] === "string";
69
-
70
- const decodePointer = (path: string): string[] | undefined => {
71
- if (path === "" || path === "/") return [];
72
- if (!path.startsWith("/")) return undefined;
73
- return path
74
- .slice(1)
75
- .split("/")
76
- .map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"));
77
- };
92
+ type ChildTemplate = { readonly componentId: string; readonly path: string };
78
93
 
79
- const resolvePointer = (source: unknown, path: string): unknown => {
80
- const segments = decodePointer(path);
81
- if (!segments) return undefined;
82
- let current = source;
83
- for (const segment of segments) {
84
- if (Array.isArray(current)) {
85
- if (!/^(0|[1-9]\d*)$/.test(segment)) return undefined;
86
- current = current[Number(segment)];
87
- continue;
88
- }
89
- if (!isRecord(current) || !Object.hasOwn(current, segment)) {
90
- return undefined;
91
- }
92
- current = current[segment];
93
- }
94
- return current;
94
+ const childTemplateOf = (children: unknown): ChildTemplate | undefined => {
95
+ const template =
96
+ isPlainObject(children) &&
97
+ Object.keys(children).length === 1 &&
98
+ isPlainObject(children["template"])
99
+ ? children["template"]
100
+ : children;
101
+ return isPlainObject(template) &&
102
+ Object.keys(template).length === 2 &&
103
+ typeof template["componentId"] === "string" &&
104
+ typeof template["path"] === "string"
105
+ ? { componentId: template["componentId"], path: template["path"] }
106
+ : undefined;
95
107
  };
96
108
 
97
109
  const bindingPath = (value: unknown): string | undefined =>
98
110
  isBinding(value) ? value.path : undefined;
99
111
 
100
- const lastPointerSegment = (path: string | undefined): string | undefined => {
101
- if (!path) return undefined;
102
- const segments = decodePointer(path);
103
- return segments?.at(-1);
112
+ type Scope = { readonly data: unknown; readonly path: string };
113
+
114
+ const pointerIn = (scope: Scope, path: string): string =>
115
+ (path.startsWith("/") ? "" : scope.path) +
116
+ decodePointer(path)
117
+ .map((segment) => `/${segment.replaceAll("~", "~0").replaceAll("/", "~1")}`)
118
+ .join("");
119
+
120
+ const setIn = (
121
+ value: unknown,
122
+ segments: readonly string[],
123
+ leaf: unknown,
124
+ ): unknown => {
125
+ const [head, ...rest] = segments;
126
+ if (head === undefined) return leaf;
127
+ if (
128
+ /^(0|[1-9]\d*)$/.test(head) &&
129
+ (Array.isArray(value) || value === undefined || value === null)
130
+ ) {
131
+ const list = Array.isArray(value) ? value : [];
132
+ const index = Number(head);
133
+ if (index >= list.length && index > MAX_AUTO_VIVIFY_ARRAY_INDEX) {
134
+ return value;
135
+ }
136
+ const copy = [...list];
137
+ copy[index] = setIn(copy[index], rest, leaf);
138
+ return copy;
139
+ }
140
+ const copy: Record<string, unknown> = isRecord(value) ? { ...value } : {};
141
+ setOwnProperty(
142
+ copy,
143
+ head,
144
+ setIn(Object.hasOwn(copy, head) ? copy[head] : undefined, rest, leaf),
145
+ );
146
+ return copy;
104
147
  };
105
148
 
106
- const materialize = (value: unknown, source: unknown): unknown => {
107
- if (isBinding(value)) return resolvePointer(source, value.path);
108
- if (Array.isArray(value)) {
109
- return value
110
- .map((entry) => materialize(entry, source))
111
- .filter((entry) => entry !== undefined);
149
+ const withFieldReferences = (
150
+ value: unknown,
151
+ pointer: string,
152
+ fields: ReadonlyMap<string, unknown>,
153
+ ): unknown => {
154
+ if (fields.has(pointer)) return fields.get(pointer);
155
+ let result = value;
156
+ for (const [name, field] of fields) {
157
+ if (name.startsWith(`${pointer}/`)) {
158
+ result = setIn(result, decodePointer(name.slice(pointer.length)), field);
159
+ }
112
160
  }
113
- if (!isPlainObject(value)) return value;
161
+ return result;
162
+ };
163
+
164
+ const materializeEntries = (
165
+ value: Record<string, unknown>,
166
+ source: unknown,
167
+ context: ConversionContext,
168
+ evaluate: boolean,
169
+ depth: number,
170
+ positional = false,
171
+ ): Record<string, unknown> => {
114
172
  const result: Record<string, unknown> = {};
115
173
  for (const [key, entry] of Object.entries(value)) {
116
- const resolved = materialize(entry, source);
174
+ // An action's functionCall runs when the button fires, so only its arguments resolve here.
175
+ const resolved =
176
+ key === "functionCall" && isFunctionCall(entry)
177
+ ? {
178
+ ...entry,
179
+ ...(entry.args !== undefined
180
+ ? {
181
+ args: materializeEntries(
182
+ entry.args,
183
+ source,
184
+ context,
185
+ evaluate,
186
+ depth,
187
+ true,
188
+ ),
189
+ }
190
+ : {}),
191
+ }
192
+ : materialize(entry, source, context, evaluate, depth, positional);
117
193
  if (resolved !== undefined) {
118
194
  setOwnProperty(result, key, resolved);
119
195
  }
@@ -121,6 +197,73 @@ const materialize = (value: unknown, source: unknown): unknown => {
121
197
  return result;
122
198
  };
123
199
 
200
+ function materialize(
201
+ value: unknown,
202
+ source: unknown,
203
+ context: ConversionContext,
204
+ evaluate = true,
205
+ depth = 0,
206
+ positional = false,
207
+ ): unknown {
208
+ if (isBinding(value)) {
209
+ const pointer = pointerIn(
210
+ { data: source, path: context.scopePath },
211
+ value.path,
212
+ );
213
+ const resolved = resolvePointer(context.surface.dataModel, pointer);
214
+ context.dependencies?.set(pointer, resolved);
215
+ return resolved;
216
+ }
217
+ if (Array.isArray(value)) {
218
+ const entries = value.map((entry) =>
219
+ materialize(entry, source, context, evaluate, depth, positional),
220
+ );
221
+ return positional
222
+ ? entries
223
+ : entries.filter((entry) => entry !== undefined);
224
+ }
225
+ if (!isPlainObject(value)) return value;
226
+ if (!evaluate || !isFunctionCall(value)) {
227
+ return materializeEntries(
228
+ value,
229
+ source,
230
+ context,
231
+ evaluate,
232
+ depth,
233
+ positional,
234
+ );
235
+ }
236
+ if (!spendEvaluation(context)) return undefined;
237
+ if (depth >= DEPTH_CAP) {
238
+ if (!context.functionDepthWarned) {
239
+ context.warnings.push(
240
+ `A2UI function nesting cap of ${DEPTH_CAP} was reached.`,
241
+ );
242
+ context.functionDepthWarned = true;
243
+ }
244
+ return undefined;
245
+ }
246
+ return evaluateA2uiValueFunction(
247
+ value.call,
248
+ materializeEntries(
249
+ value.args ?? {},
250
+ source,
251
+ context,
252
+ true,
253
+ depth + 1,
254
+ true,
255
+ ),
256
+ {
257
+ resolve: (part) =>
258
+ spendEvaluation(context)
259
+ ? materialize(part, source, context, true, depth + 1)
260
+ : undefined,
261
+ warn: (message) => context.warnings.push(message),
262
+ templates: context.templates,
263
+ },
264
+ );
265
+ }
266
+
124
267
  const firstDefined = (
125
268
  props: Record<string, unknown>,
126
269
  keys: readonly string[],
@@ -146,13 +289,164 @@ const sourceSurfaceId = (surface: A2uiSurfaceState): string =>
146
289
 
147
290
  type ConversionContext = {
148
291
  readonly surface: A2uiSurfaceState;
292
+ readonly stepModel: unknown;
149
293
  readonly surfaceId: string;
150
294
  readonly warnings: string[];
151
295
  emittedNodes: number;
152
296
  depthWarned: boolean;
153
297
  budgetWarned: boolean;
154
298
  templateCapWarned: boolean;
299
+ evaluations: number;
300
+ evaluationBudgetWarned: boolean;
301
+ functionDepthWarned: boolean;
302
+ readonly templates: Map<string, ExpressionPart[] | null>;
303
+ readonly inputFields: Map<string, unknown>;
304
+ readonly textFields: Map<string, boolean>;
305
+ readonly boundActionEntries: {
306
+ readonly target: Record<string, unknown>;
307
+ readonly key: string;
308
+ readonly pointer: string;
309
+ }[];
310
+ readonly boundUserMessages: {
311
+ readonly action: Record<string, unknown>;
312
+ readonly pointer: string;
313
+ }[];
155
314
  readonly keepUnknownComponents: boolean;
315
+ readonly bindings: Map<string, A2uiBinding> | undefined;
316
+ readonly cache: Map<string, CachedComponent> | undefined;
317
+ dependencies: Map<string, unknown> | undefined;
318
+ scopePath: string;
319
+ };
320
+
321
+ type CachedComponent = {
322
+ readonly props: Record<string, unknown>;
323
+ readonly mapped: UIElement | undefined;
324
+ readonly dependencies: ReadonlyMap<string, unknown>;
325
+ readonly evaluations: number;
326
+ children?: readonly UIElement[];
327
+ result?: UIElement;
328
+ };
329
+
330
+ const BOUND_KEYS = ["text", "value", "binding"] as const;
331
+
332
+ const INPUT_COMPONENTS: ReadonlySet<string> = new Set([
333
+ "TextField",
334
+ "CheckBox",
335
+ "ChoicePicker",
336
+ "DateTimeInput",
337
+ "Slider",
338
+ ]);
339
+
340
+ type DateInputType = "date" | "datetime" | "time";
341
+
342
+ const dateInputType = (value: string): DateInputType | undefined => {
343
+ const kind = classifyTemporal(value).kind;
344
+ if (kind === "date" || kind === "time") return kind;
345
+ return kind === "floating" || kind === "instant" ? "datetime" : undefined;
346
+ };
347
+
348
+ const finiteNumber = (value: unknown): number | undefined =>
349
+ typeof value === "number" && Number.isFinite(value) ? value : undefined;
350
+
351
+ const decimalPlaces = (value: number): number => {
352
+ const [mantissa = "", exponent = "0"] = String(value).split("e");
353
+ return Math.max(0, (mantissa.split(".")[1]?.length ?? 0) - Number(exponent));
354
+ };
355
+
356
+ // A spec slider without `steps` is continuous, so its step leaves at least a hundred divisions and keeps every bound value on the grid.
357
+ const sliderStep = (
358
+ min: number,
359
+ max: number,
360
+ value: number | undefined,
361
+ steps: unknown,
362
+ ): number | undefined => {
363
+ if (max <= min) return undefined;
364
+ if (typeof steps === "number" && Number.isInteger(steps) && steps >= 1) {
365
+ return (max - min) / steps;
366
+ }
367
+ const precision = Math.max(
368
+ decimalPlaces(min),
369
+ value === undefined ? 0 : decimalPlaces(value),
370
+ );
371
+ return Math.min(
372
+ 10 ** Math.floor(Math.log10((max - min) / 100)),
373
+ 10 ** -precision,
374
+ );
375
+ };
376
+
377
+ const recordBindings = (
378
+ node: Record<string, unknown>,
379
+ props: Record<string, unknown>,
380
+ mapped: UIElement,
381
+ scope: Scope,
382
+ context: ConversionContext,
383
+ ) => {
384
+ const component = node["component"];
385
+ const name = mapped["name"];
386
+ const boundKey = BOUND_KEYS.find((key) => node[key] !== undefined);
387
+ const value = boundKey === undefined ? undefined : props[boundKey];
388
+ if (INPUT_COMPONENTS.has(String(component)) && typeof name === "string") {
389
+ context.textFields.set(
390
+ name,
391
+ (!context.textFields.has(name) || context.bindings !== undefined) &&
392
+ (component === "TextField" || component === "DateTimeInput"),
393
+ );
394
+ const field = (fallback: unknown) =>
395
+ fallback === undefined ? { $field: name } : { $field: name, fallback };
396
+ // A single-choice picker collects one string, while the spec binds it to a string list.
397
+ const listValued =
398
+ component === "ChoicePicker" &&
399
+ mapped.$type !== "CheckboxGroup" &&
400
+ typeof value !== "string";
401
+ context.inputFields.set(
402
+ name,
403
+ listValued
404
+ ? [field(Array.isArray(value) ? value[0] : undefined)]
405
+ : field(value),
406
+ );
407
+ }
408
+ const action = mapped.$action;
409
+ const raw = node["action"];
410
+ if (!action || !isRecord(raw)) return;
411
+ const functionCall = action.type === "a2ui:functionCall";
412
+ const event = isRecord(raw["event"]) ? raw["event"] : raw;
413
+ const userMessage = event["userMessage"];
414
+ if (!functionCall && isBinding(userMessage)) {
415
+ context.boundUserMessages.push({
416
+ action,
417
+ pointer: pointerIn(scope, userMessage.path),
418
+ });
419
+ }
420
+ const rawEntries = functionCall
421
+ ? isRecord(raw["functionCall"])
422
+ ? raw["functionCall"]["args"]
423
+ : undefined
424
+ : event["context"];
425
+ const target = functionCall ? action["args"] : action["context"];
426
+ if (!isRecord(rawEntries) || !isRecord(target)) return;
427
+ for (const [key, entry] of Object.entries(rawEntries)) {
428
+ if (isBinding(entry)) {
429
+ context.boundActionEntries.push({
430
+ target,
431
+ key,
432
+ pointer: pointerIn(scope, entry.path),
433
+ });
434
+ }
435
+ }
436
+ };
437
+
438
+ const spendEvaluation = (context: ConversionContext): boolean => {
439
+ if (context.evaluations < EVALUATION_BUDGET) {
440
+ context.evaluations++;
441
+ return true;
442
+ }
443
+ if (!context.evaluationBudgetWarned) {
444
+ context.warnings.push(
445
+ `A2UI function evaluation budget of ${EVALUATION_BUDGET} was reached.`,
446
+ );
447
+ context.evaluationBudgetWarned = true;
448
+ }
449
+ return false;
156
450
  };
157
451
 
158
452
  const reserveNode = (context: ConversionContext): boolean => {
@@ -184,7 +478,7 @@ const childReferences = (node: Record<string, unknown>): unknown[] => {
184
478
 
185
479
  const childrenOf = (
186
480
  node: Record<string, unknown>,
187
- dataSource: unknown,
481
+ scope: Scope,
188
482
  context: ConversionContext,
189
483
  depth: number,
190
484
  visited: Set<string>,
@@ -197,13 +491,7 @@ const childrenOf = (
197
491
  );
198
492
  continue;
199
493
  }
200
- const child = convertComponent(
201
- childId,
202
- dataSource,
203
- context,
204
- depth + 1,
205
- visited,
206
- );
494
+ const child = convertComponent(childId, scope, context, depth + 1, visited);
207
495
  if (child) result.push(child);
208
496
  }
209
497
  return result;
@@ -236,6 +524,11 @@ const mappedAction = (
236
524
  context: ConversionContext,
237
525
  ): UIElement["$action"] | undefined => {
238
526
  const action = props["action"];
527
+ if (action === undefined) return undefined;
528
+ const source = {
529
+ surfaceId: context.surfaceId,
530
+ sourceComponentId: typeof node["id"] === "string" ? node["id"] : "",
531
+ };
239
532
  const event =
240
533
  isRecord(action) && isRecord(action["event"]) ? action["event"] : action;
241
534
  const actionName =
@@ -244,15 +537,31 @@ const mappedAction = (
244
537
  : isRecord(event) && typeof event["name"] === "string"
245
538
  ? event["name"]
246
539
  : undefined;
247
- if (!actionName) return undefined;
248
- const actionContext = isRecord(event) ? event["context"] : undefined;
249
- return {
250
- type: "a2ui:action",
251
- name: actionName,
252
- surfaceId: context.surfaceId,
253
- sourceComponentId: typeof node["id"] === "string" ? node["id"] : "",
254
- ...(actionContext !== undefined ? { context: actionContext } : {}),
255
- };
540
+ if (actionName) {
541
+ const actionContext = isRecord(event) ? event["context"] : undefined;
542
+ const userMessage = isRecord(event) ? event["userMessage"] : undefined;
543
+ return {
544
+ type: "a2ui:action",
545
+ name: actionName,
546
+ ...source,
547
+ ...(typeof userMessage === "string" ? { userMessage } : {}),
548
+ ...(actionContext !== undefined ? { context: actionContext } : {}),
549
+ };
550
+ }
551
+ const functionCall = isRecord(action) ? action["functionCall"] : undefined;
552
+ if (isRecord(functionCall) && typeof functionCall["call"] === "string") {
553
+ const args = functionCall["args"];
554
+ return {
555
+ type: "a2ui:functionCall",
556
+ call: functionCall["call"],
557
+ ...source,
558
+ ...(isRecord(args) ? { args } : {}),
559
+ };
560
+ }
561
+ context.warnings.push(
562
+ `Component "${String(node["id"] ?? "")}" has a malformed action.`,
563
+ );
564
+ return undefined;
256
565
  };
257
566
 
258
567
  const choiceOptions = (value: unknown): { label: string; value: string }[] => {
@@ -274,6 +583,7 @@ const mappedProps = (
274
583
  node: Record<string, unknown>,
275
584
  props: Record<string, unknown>,
276
585
  context: ConversionContext,
586
+ scope: Scope,
277
587
  ): UIElement | undefined => {
278
588
  const component = node["component"];
279
589
 
@@ -378,38 +688,63 @@ const mappedProps = (
378
688
 
379
689
  if (component === "Button") {
380
690
  const label = stringProp(props, ["label", "text"]);
381
- const buttonStyle = props["buttonStyle"];
691
+ const variantStyle =
692
+ props["variant"] === "primary"
693
+ ? "primary"
694
+ : props["variant"] === "borderless"
695
+ ? "ghost"
696
+ : undefined;
697
+ const buttonStyle =
698
+ typeof props["buttonStyle"] === "string"
699
+ ? props["buttonStyle"]
700
+ : variantStyle;
382
701
  const block = props["block"];
383
702
  const submit = props["submit"];
384
703
  const action = mappedAction(node, props, context);
385
704
  return {
386
705
  $type: "Button",
387
706
  ...(label !== undefined ? { label } : {}),
388
- ...(typeof buttonStyle === "string" ? { buttonStyle } : {}),
707
+ ...(buttonStyle !== undefined ? { buttonStyle } : {}),
389
708
  ...(typeof block === "boolean" ? { block } : {}),
390
709
  ...(typeof submit === "boolean" ? { submit } : {}),
391
710
  ...(action ? { $action: action } : {}),
392
711
  };
393
712
  }
394
713
 
395
- const binding = firstDefined(node, ["text", "value", "binding"]);
396
- const name = lastPointerSegment(bindingPath(binding));
714
+ const bound = bindingPath(firstDefined(node, BOUND_KEYS));
715
+ const name = bound === undefined ? undefined : pointerIn(scope, bound);
397
716
  const label = stringProp(props, ["label"]);
398
717
 
399
718
  if (component === "TextField") {
400
719
  const placeholder = stringProp(props, ["placeholder"]);
720
+ const inputType =
721
+ props["variant"] === "obscured"
722
+ ? "password"
723
+ : props["variant"] === "number"
724
+ ? "number"
725
+ : undefined;
401
726
  const multiline =
402
727
  typeof props["multiline"] === "boolean"
403
728
  ? props["multiline"]
404
- : props["textFieldType"] === "longText"
729
+ : props["variant"] === "longText" ||
730
+ props["textFieldType"] === "longText"
405
731
  ? true
406
732
  : undefined;
733
+ const initial = firstDefined(props, ["value", "text"]);
734
+ const defaultValue =
735
+ typeof initial === "string"
736
+ ? initial
737
+ : typeof initial === "number" && Number.isFinite(initial)
738
+ ? String(initial)
739
+ : undefined;
407
740
  return {
408
741
  $type: "Input",
742
+ ...(inputType !== undefined ? { inputType } : {}),
409
743
  ...(placeholder !== undefined ? { placeholder } : {}),
410
744
  ...(multiline !== undefined ? { multiline } : {}),
411
745
  ...(label !== undefined ? { label } : {}),
412
746
  ...(name !== undefined ? { name } : {}),
747
+ ...(defaultValue !== undefined ? { defaultValue } : {}),
413
748
  };
414
749
  }
415
750
 
@@ -423,47 +758,106 @@ const mappedProps = (
423
758
  };
424
759
  }
425
760
 
761
+ if (component === "Slider") {
762
+ const min = finiteNumber(props["min"]) ?? 0;
763
+ const max = finiteNumber(props["max"]);
764
+ if (max === undefined || max < min) return undefined;
765
+ const value = finiteNumber(props["value"]);
766
+ const step = sliderStep(
767
+ min,
768
+ max,
769
+ name === undefined
770
+ ? value
771
+ : finiteNumber(resolvePointer(context.stepModel, name)),
772
+ props["steps"],
773
+ );
774
+ return {
775
+ $type: "Slider",
776
+ min,
777
+ max,
778
+ ...(step !== undefined ? { step } : {}),
779
+ ...(value !== undefined ? { defaultValue: value } : {}),
780
+ ...(label !== undefined ? { label } : {}),
781
+ ...(name !== undefined ? { name } : {}),
782
+ };
783
+ }
784
+
426
785
  if (component === "ChoicePicker") {
427
786
  const options = choiceOptions(props["options"]);
428
787
  const value = props["value"];
429
- const defaultValue =
788
+ const selected =
430
789
  typeof value === "string"
431
- ? value
432
- : Array.isArray(value) && typeof value[0] === "string"
433
- ? value[0]
434
- : undefined;
435
- const radioStyle =
436
- props["variant"] === "radio" ||
437
- props["variant"] === "single" ||
438
- props["variant"] === "singleSelection" ||
439
- props["displayStyle"] === "radio" ||
440
- (props["variant"] === "mutuallyExclusive" &&
441
- props["displayStyle"] !== "chips");
442
- if (radioStyle) {
790
+ ? [value]
791
+ : Array.isArray(value)
792
+ ? value.filter((entry): entry is string => typeof entry === "string")
793
+ : [];
794
+ if (props["variant"] === "multipleSelection") {
443
795
  return {
444
- $type: "RadioGroup",
796
+ $type: "CheckboxGroup",
445
797
  options,
446
798
  ...(label !== undefined ? { label } : {}),
447
799
  ...(name !== undefined ? { name } : {}),
448
- ...(defaultValue !== undefined ? { defaultValue } : {}),
800
+ ...(selected.length > 0 ? { defaultValue: selected } : {}),
449
801
  };
450
802
  }
451
- const placeholder = stringProp(props, ["placeholder"]);
803
+ const [defaultValue] = selected;
452
804
  return {
453
- $type: "Select",
805
+ $type: "RadioGroup",
454
806
  options,
455
- ...(placeholder !== undefined ? { placeholder } : {}),
456
807
  ...(label !== undefined ? { label } : {}),
457
808
  ...(name !== undefined ? { name } : {}),
809
+ ...(defaultValue !== undefined ? { defaultValue } : {}),
458
810
  };
459
811
  }
460
812
 
461
813
  if (component === "DateTimeInput") {
462
814
  const value = stringProp(props, ["value"]);
463
- const min = stringProp(props, ["min"]);
464
- const max = stringProp(props, ["max"]);
815
+ const shapeValue =
816
+ name === undefined ? value : resolvePointer(context.stepModel, name);
817
+ const shapeType =
818
+ typeof shapeValue === "string" ? dateInputType(shapeValue) : undefined;
819
+ let inputType: DateInputType = shapeType ?? "date";
820
+ if ("enableDate" in props || "enableTime" in props) {
821
+ inputType =
822
+ props["enableTime"] === true
823
+ ? props["enableDate"] === true
824
+ ? "datetime"
825
+ : "time"
826
+ : "date";
827
+ }
828
+ if (
829
+ typeof shapeValue === "string" &&
830
+ shapeValue !== "" &&
831
+ shapeType !== inputType
832
+ ) {
833
+ return {
834
+ $type: "Input",
835
+ ...(value !== undefined ? { defaultValue: value } : {}),
836
+ ...(label !== undefined ? { label } : {}),
837
+ ...(name !== undefined ? { name } : {}),
838
+ };
839
+ }
840
+ // The spec types a bound as a date, a time or a date-time whatever the
841
+ // enabled flags are, so a date bound on a datetime control is the range of
842
+ // that whole local day rather than a mismatch.
843
+ const bound = (edge: "min" | "max"): string | undefined => {
844
+ const boundValue = stringProp(props, [edge]);
845
+ if (boundValue === undefined) return undefined;
846
+ const boundType = dateInputType(boundValue);
847
+ if (boundType === inputType) return boundValue;
848
+ if (inputType === "datetime" && boundType === "date") {
849
+ return `${boundValue}T${edge === "min" ? "00:00" : "23:59:59.999"}`;
850
+ }
851
+ context.warnings.push(
852
+ `A2UI DateTimeInput "${edge}" of "${boundValue}" is not a ${inputType} value and was dropped.`,
853
+ );
854
+ return undefined;
855
+ };
856
+ const min = bound("min");
857
+ const max = bound("max");
465
858
  return {
466
859
  $type: "DatePicker",
860
+ inputType,
467
861
  ...(value !== undefined ? { value } : {}),
468
862
  ...(min !== undefined ? { min } : {}),
469
863
  ...(max !== undefined ? { max } : {}),
@@ -477,23 +871,21 @@ const mappedProps = (
477
871
 
478
872
  const convertTemplate = (
479
873
  node: Record<string, unknown>,
480
- templateChildren: A2uiTemplateChildren,
481
- dataSource: unknown,
874
+ template: ChildTemplate,
875
+ scope: Scope,
482
876
  context: ConversionContext,
483
877
  depth: number,
484
878
  visited: Set<string>,
485
- retained?: UIElement,
486
- mappedContainer?: UIElement,
879
+ converted: UIElement | undefined,
487
880
  ): UIElement | null => {
488
881
  if (!reserveNode(context)) return null;
489
- const horizontalList =
490
- node["component"] === "List" &&
491
- materialize(node["direction"], dataSource) === "horizontal";
492
- const container = mappedContainer ??
493
- retained ?? {
494
- $type: horizontalList ? "Row" : "ListView",
495
- };
496
- const list = resolvePointer(dataSource, templateChildren.template.path);
882
+ const container = converted ?? { $type: "ListView" };
883
+ const itemized = container.$type === "ListView";
884
+ const list = resolvePointer(
885
+ context.surface.dataModel,
886
+ pointerIn(scope, template.path),
887
+ );
888
+ const listPointer = pointerIn(scope, template.path);
497
889
  if (!Array.isArray(list)) {
498
890
  context.warnings.push(
499
891
  `Template on component "${String(node["id"] ?? "")}" did not resolve to a list.`,
@@ -509,21 +901,21 @@ const convertTemplate = (
509
901
  }
510
902
  const children: UIElement[] = [];
511
903
  for (let index = 0; index < itemCount; index++) {
512
- if (!retained && !horizontalList && !reserveNode(context)) break;
904
+ if (itemized && !reserveNode(context)) break;
513
905
  const child = convertComponent(
514
- templateChildren.template.componentId,
515
- list[index],
906
+ template.componentId,
907
+ { data: list[index], path: `${listPointer}/${index}` },
516
908
  context,
517
909
  depth + 1,
518
910
  visited,
519
911
  );
520
- if (retained || horizontalList) {
521
- if (child) children.push(child);
522
- } else {
912
+ if (itemized) {
523
913
  children.push({
524
914
  $type: "ListViewItem",
525
915
  ...(child ? { children: child } : {}),
526
916
  });
917
+ } else if (child) {
918
+ children.push(child);
527
919
  }
528
920
  }
529
921
  return { ...container, children };
@@ -531,7 +923,7 @@ const convertTemplate = (
531
923
 
532
924
  function convertComponent(
533
925
  componentId: string,
534
- dataSource: unknown,
926
+ scope: Scope,
535
927
  context: ConversionContext,
536
928
  depth: number,
537
929
  visited: Set<string>,
@@ -562,32 +954,98 @@ function convertComponent(
562
954
  );
563
955
  return null;
564
956
  }
565
- const templateChildren = node["children"];
566
- const hasTemplate = isTemplateChildren(templateChildren);
957
+ const template = childTemplateOf(node["children"]);
567
958
  if (
568
959
  !SUPPORTED_COMPONENTS.has(component) &&
569
960
  !context.keepUnknownComponents &&
570
- !hasTemplate
961
+ !template
571
962
  ) {
572
963
  context.warnings.push(
573
964
  `Unknown A2UI component "${component}" was skipped.`,
574
965
  );
575
966
  return null;
576
967
  }
577
- const props: Record<string, unknown> = {};
578
- for (const [key, value] of Object.entries(node)) {
579
- if (
580
- key === "id" ||
581
- key === "component" ||
582
- key === "children" ||
583
- key === "child"
584
- ) {
585
- continue;
968
+ const cacheKey = context.cache
969
+ ? JSON.stringify([componentId, scope.path, depth])
970
+ : "";
971
+ let cached = context.cache?.get(cacheKey);
972
+ if (
973
+ cached &&
974
+ (context.evaluations + cached.evaluations > EVALUATION_BUDGET ||
975
+ [...cached.dependencies].some(
976
+ ([path, value]) =>
977
+ !Object.is(resolvePointer(context.surface.dataModel, path), value),
978
+ ))
979
+ )
980
+ cached = undefined;
981
+ let props: Record<string, unknown>;
982
+ let mapped: UIElement | undefined;
983
+ if (cached) {
984
+ props = cached.props;
985
+ mapped = cached.mapped;
986
+ context.evaluations += cached.evaluations;
987
+ } else {
988
+ props = {};
989
+ const dependencies = new Map<string, unknown>();
990
+ const evaluations = context.evaluations;
991
+ context.dependencies = dependencies;
992
+ context.scopePath = scope.path;
993
+ for (const [key, value] of Object.entries(node)) {
994
+ if (
995
+ key === "id" ||
996
+ key === "component" ||
997
+ key === "children" ||
998
+ key === "child"
999
+ )
1000
+ continue;
1001
+ const resolved = materialize(
1002
+ value,
1003
+ scope.data,
1004
+ context,
1005
+ key !== "checks",
1006
+ );
1007
+ if (resolved !== undefined) setOwnProperty(props, key, resolved);
586
1008
  }
587
- const resolved = materialize(value, dataSource);
588
- if (resolved !== undefined) setOwnProperty(props, key, resolved);
1009
+ context.dependencies = undefined;
1010
+ mapped = mappedProps(node, props, context, scope);
1011
+ if (context.cache && context.warnings.length === 0 && !mapped?.$action) {
1012
+ cached = {
1013
+ props,
1014
+ mapped,
1015
+ dependencies,
1016
+ evaluations: context.evaluations - evaluations,
1017
+ };
1018
+ context.cache.set(cacheKey, cached);
1019
+ }
1020
+ }
1021
+ if (
1022
+ mapped &&
1023
+ context.bindings &&
1024
+ typeof mapped["name"] === "string" &&
1025
+ INPUT_COMPONENTS.has(component)
1026
+ ) {
1027
+ const field = mapped.$type === "Checkbox" ? "checked" : "value";
1028
+ const defaultField =
1029
+ mapped.$type === "Checkbox" ? "defaultChecked" : "defaultValue";
1030
+ const fallback =
1031
+ mapped.$type === "Checkbox"
1032
+ ? false
1033
+ : mapped.$type === "CheckboxGroup"
1034
+ ? []
1035
+ : mapped.$type === "Slider"
1036
+ ? mapped["min"]
1037
+ : "";
1038
+ const { [defaultField]: initialValue, ...rest } = mapped;
1039
+ mapped = { ...rest, [field]: initialValue ?? mapped[field] ?? fallback };
1040
+ context.bindings.set(mapped["name"] as string, {
1041
+ value: mapped[field],
1042
+ arrayValue:
1043
+ component === "ChoicePicker" &&
1044
+ mapped.$type !== "CheckboxGroup" &&
1045
+ typeof props["value"] !== "string",
1046
+ });
589
1047
  }
590
- const mapped = mappedProps(node, props, context);
1048
+
591
1049
  if (!mapped && SUPPORTED_COMPONENTS.has(component)) {
592
1050
  context.warnings.push(
593
1051
  `A2UI component "${component}" could not be mapped and was skipped.`,
@@ -603,22 +1061,22 @@ function convertComponent(
603
1061
  ),
604
1062
  }
605
1063
  : undefined;
606
- if (hasTemplate) {
1064
+ if (template) {
607
1065
  return convertTemplate(
608
1066
  node,
609
- templateChildren,
610
- dataSource,
1067
+ template,
1068
+ scope,
611
1069
  context,
612
1070
  depth,
613
1071
  visited,
614
- retained,
615
- component === "List" ? mapped : undefined,
1072
+ CHILD_LIST_COMPONENTS.has(component) ? mapped : retained,
616
1073
  );
617
1074
  }
618
1075
  if (!reserveNode(context)) return null;
619
1076
  const converted = mapped ?? retained;
620
1077
  if (!converted) return null;
621
- const children = childrenOf(node, dataSource, context, depth, visited);
1078
+ recordBindings(node, props, converted, scope, context);
1079
+ const children = childrenOf(node, scope, context, depth, visited);
622
1080
  if (mapped?.$type === "Button" && mapped["label"] === undefined) {
623
1081
  const label = textLabel(node, children, context);
624
1082
  if (label !== undefined) return { ...mapped, label };
@@ -627,10 +1085,21 @@ function convertComponent(
627
1085
  component === "List" && mapped?.$type === "ListView"
628
1086
  ? children.map((child) => ({ $type: "ListViewItem", children: child }))
629
1087
  : children;
630
- return {
1088
+ if (
1089
+ cached?.result &&
1090
+ cached.children?.length === listChildren.length &&
1091
+ cached.children.every((child, index) => child === listChildren[index])
1092
+ )
1093
+ return cached.result;
1094
+ const result = {
631
1095
  ...converted,
632
1096
  ...(listChildren.length > 0 ? { children: listChildren } : {}),
633
1097
  };
1098
+ if (cached) {
1099
+ cached.children = listChildren;
1100
+ cached.result = result;
1101
+ }
1102
+ return result;
634
1103
  } finally {
635
1104
  visited.delete(componentId);
636
1105
  }
@@ -638,11 +1107,41 @@ function convertComponent(
638
1107
 
639
1108
  export function convertSurfaceToUISpec(
640
1109
  surface: A2uiSurfaceState,
641
- options: { readonly keepUnknownComponents?: boolean } = {},
1110
+ options: {
1111
+ readonly keepUnknownComponents?: boolean;
1112
+ } = {},
642
1113
  ): {
643
1114
  spec: UIElement | null;
644
1115
  warnings: string[];
645
1116
  } {
1117
+ return convertSurface(surface, options.keepUnknownComponents === true);
1118
+ }
1119
+
1120
+ export const createLiveSurfaceConverter = (surface: A2uiSurfaceState) => {
1121
+ const cache = new Map<string, CachedComponent>();
1122
+ const templates = new Map<string, ExpressionPart[] | null>();
1123
+ return (dataModel: unknown) => {
1124
+ const bindings = new Map<string, A2uiBinding>();
1125
+ const converted = convertSurface(
1126
+ { ...surface, dataModel, [A2UI_SURFACE_ID]: sourceSurfaceId(surface) },
1127
+ false,
1128
+ bindings,
1129
+ cache,
1130
+ templates,
1131
+ surface.dataModel,
1132
+ );
1133
+ return { ...converted, bindings };
1134
+ };
1135
+ };
1136
+
1137
+ function convertSurface(
1138
+ surface: A2uiSurfaceState & { [A2UI_SURFACE_ID]?: string },
1139
+ keepUnknownComponents: boolean,
1140
+ bindings?: Map<string, A2uiBinding>,
1141
+ cache?: Map<string, CachedComponent>,
1142
+ templates = new Map<string, ExpressionPart[] | null>(),
1143
+ stepModel: unknown = surface.dataModel,
1144
+ ): { spec: UIElement | null; warnings: string[] } {
646
1145
  const warnings: string[] = [];
647
1146
  if (!surface.components.has("root")) {
648
1147
  return {
@@ -652,22 +1151,54 @@ export function convertSurfaceToUISpec(
652
1151
  }
653
1152
  const context: ConversionContext = {
654
1153
  surface,
1154
+ stepModel,
655
1155
  surfaceId: sourceSurfaceId(surface),
656
1156
  warnings,
657
1157
  emittedNodes: 0,
658
1158
  depthWarned: false,
659
1159
  budgetWarned: false,
660
1160
  templateCapWarned: false,
661
- keepUnknownComponents: options.keepUnknownComponents === true,
1161
+ evaluations: 0,
1162
+ evaluationBudgetWarned: false,
1163
+ functionDepthWarned: false,
1164
+ templates,
1165
+ inputFields: new Map(),
1166
+ textFields: new Map(),
1167
+ boundActionEntries: [],
1168
+ boundUserMessages: [],
1169
+ keepUnknownComponents,
1170
+ bindings,
1171
+ cache,
1172
+ dependencies: undefined,
1173
+ scopePath: "",
662
1174
  };
663
1175
  try {
664
1176
  const spec = convertComponent(
665
1177
  "root",
666
- surface.dataModel,
1178
+ { data: surface.dataModel, path: "" },
667
1179
  context,
668
1180
  0,
669
1181
  new Set(),
670
1182
  );
1183
+ for (const { target, key, pointer } of context.boundActionEntries) {
1184
+ const value = withFieldReferences(
1185
+ target[key],
1186
+ pointer,
1187
+ context.inputFields,
1188
+ );
1189
+ if (value !== undefined) setOwnProperty(target, key, value);
1190
+ }
1191
+ for (const { action, pointer } of context.boundUserMessages) {
1192
+ if (!context.textFields.get(pointer)) continue;
1193
+ const fallback = action["userMessage"];
1194
+ setOwnProperty(
1195
+ action,
1196
+ "userMessage",
1197
+ typeof fallback === "string"
1198
+ ? { $field: pointer, fallback }
1199
+ : { $field: pointer },
1200
+ );
1201
+ }
671
1202
  return { spec, warnings };
672
1203
  } catch {
673
1204
  warnings.push("A2UI surface conversion encountered malformed input.");