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

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 (137) hide show
  1. package/dist/JSONGenerativeUI.client.d.ts +14 -15
  2. package/dist/JSONGenerativeUI.client.d.ts.map +1 -1
  3. package/dist/JSONGenerativeUI.client.js +28 -8
  4. package/dist/JSONGenerativeUI.client.js.map +1 -1
  5. package/dist/JSONGenerativeUI.server.d.ts +5 -7
  6. package/dist/JSONGenerativeUI.server.d.ts.map +1 -1
  7. package/dist/JSONGenerativeUI.shared.d.ts +35 -36
  8. package/dist/JSONGenerativeUI.shared.d.ts.map +1 -1
  9. package/dist/a2ui/convert.d.ts +7 -7
  10. package/dist/a2ui/convert.d.ts.map +1 -1
  11. package/dist/a2ui/convert.js +148 -28
  12. package/dist/a2ui/convert.js.map +1 -1
  13. package/dist/a2ui/reducer.d.ts +1 -3
  14. package/dist/a2ui/reducer.d.ts.map +1 -1
  15. package/dist/a2ui/reducer.js +8 -0
  16. package/dist/a2ui/reducer.js.map +1 -1
  17. package/dist/a2ui/snapshot.d.ts +3 -0
  18. package/dist/a2ui/snapshot.d.ts.map +1 -0
  19. package/dist/a2ui/snapshot.js +33 -0
  20. package/dist/a2ui/snapshot.js.map +1 -0
  21. package/dist/a2ui/types.d.ts +50 -40
  22. package/dist/a2ui/types.d.ts.map +1 -1
  23. package/dist/a2ui/types.js.map +1 -1
  24. package/dist/a2ui.d.ts +5 -4
  25. package/dist/a2ui.d.ts.map +1 -0
  26. package/dist/a2ui.js +2 -1
  27. package/dist/actionRegistry.d.ts +12 -14
  28. package/dist/actionRegistry.d.ts.map +1 -1
  29. package/dist/actionRegistry.js.map +1 -1
  30. package/dist/buildPresentParameters.d.ts +2 -4
  31. package/dist/buildPresentParameters.d.ts.map +1 -1
  32. package/dist/buildPresentParameters.js +16 -4
  33. package/dist/buildPresentParameters.js.map +1 -1
  34. package/dist/constants.d.ts +0 -2
  35. package/dist/constants.d.ts.map +1 -1
  36. package/dist/convert/boundSpec.d.ts +0 -2
  37. package/dist/convert/boundSpec.d.ts.map +1 -1
  38. package/dist/convert/copyBounded.d.ts +2 -4
  39. package/dist/convert/copyBounded.d.ts.map +1 -1
  40. package/dist/convert/isElement.d.ts +1 -3
  41. package/dist/convert/isElement.d.ts.map +1 -1
  42. package/dist/convert/takeRun.d.ts +3 -5
  43. package/dist/convert/takeRun.d.ts.map +1 -1
  44. package/dist/defineGenerativeComponents.d.ts +1 -3
  45. package/dist/defineGenerativeComponents.d.ts.map +1 -1
  46. package/dist/generativeUIToJSX.d.ts +4 -6
  47. package/dist/generativeUIToJSX.d.ts.map +1 -1
  48. package/dist/index.d.ts +3 -13
  49. package/dist/index.d.ts.map +1 -0
  50. package/dist/index.server.d.ts +3 -13
  51. package/dist/index.server.d.ts.map +1 -0
  52. package/dist/index.shared.d.ts +15 -11
  53. package/dist/index.shared.d.ts.map +1 -0
  54. package/dist/ir.d.ts +17 -19
  55. package/dist/ir.d.ts.map +1 -1
  56. package/dist/renderGenerativeUI.d.ts +2 -4
  57. package/dist/renderGenerativeUI.d.ts.map +1 -1
  58. package/dist/scopeSchema.d.ts +6 -0
  59. package/dist/scopeSchema.d.ts.map +1 -0
  60. package/dist/scopeSchema.js +56 -0
  61. package/dist/scopeSchema.js.map +1 -0
  62. package/dist/slack/constants.d.ts +8 -9
  63. package/dist/slack/constants.d.ts.map +1 -1
  64. package/dist/slack/decodeBlockAction.d.ts +1 -3
  65. package/dist/slack/decodeBlockAction.d.ts.map +1 -1
  66. package/dist/slack/fromSlackBlocks.d.ts +1 -3
  67. package/dist/slack/fromSlackBlocks.d.ts.map +1 -1
  68. package/dist/slack/toSlackBlocks.d.ts +1 -3
  69. package/dist/slack/toSlackBlocks.d.ts.map +1 -1
  70. package/dist/slack/toSlackBlocks.js +1 -1
  71. package/dist/slack/types.d.ts +78 -80
  72. package/dist/slack/types.d.ts.map +1 -1
  73. package/dist/slack.d.ts +5 -5
  74. package/dist/slack.d.ts.map +1 -0
  75. package/dist/teams/constants.d.ts +1 -3
  76. package/dist/teams/constants.d.ts.map +1 -1
  77. package/dist/teams/decodeSubmitData.d.ts +1 -3
  78. package/dist/teams/decodeSubmitData.d.ts.map +1 -1
  79. package/dist/teams/toAdaptiveCard.d.ts +4 -6
  80. package/dist/teams/toAdaptiveCard.d.ts.map +1 -1
  81. package/dist/teams/toTeamsAttachments.d.ts +1 -3
  82. package/dist/teams/toTeamsAttachments.d.ts.map +1 -1
  83. package/dist/teams/types.d.ts +108 -109
  84. package/dist/teams/types.d.ts.map +1 -1
  85. package/dist/teams.d.ts +5 -5
  86. package/dist/teams.d.ts.map +1 -0
  87. package/dist/types.d.ts +36 -37
  88. package/dist/types.d.ts.map +1 -1
  89. package/dist/vocabulary/alert.d.ts +21 -23
  90. package/dist/vocabulary/alert.d.ts.map +1 -1
  91. package/dist/vocabulary/collectFormValues.d.ts +8 -10
  92. package/dist/vocabulary/collectFormValues.d.ts.map +1 -1
  93. package/dist/vocabulary/data.d.ts +45 -47
  94. package/dist/vocabulary/data.d.ts.map +1 -1
  95. package/dist/vocabulary/dispatch.d.ts +2 -4
  96. package/dist/vocabulary/dispatch.d.ts.map +1 -1
  97. package/dist/vocabulary/fact.d.ts +8 -10
  98. package/dist/vocabulary/fact.d.ts.map +1 -1
  99. package/dist/vocabulary/form.d.ts +7 -9
  100. package/dist/vocabulary/form.d.ts.map +1 -1
  101. package/dist/vocabulary/icon.d.ts +37 -39
  102. package/dist/vocabulary/icon.d.ts.map +1 -1
  103. package/dist/vocabulary/index.d.ts +1 -3
  104. package/dist/vocabulary/index.d.ts.map +1 -1
  105. package/dist/vocabulary/interactive.d.ts +87 -86
  106. package/dist/vocabulary/interactive.d.ts.map +1 -1
  107. package/dist/vocabulary/interactive.js +4 -4
  108. package/dist/vocabulary/interactive.js.map +1 -1
  109. package/dist/vocabulary/layout.d.ts +75 -77
  110. package/dist/vocabulary/layout.d.ts.map +1 -1
  111. package/dist/vocabulary/list.d.ts +10 -12
  112. package/dist/vocabulary/list.d.ts.map +1 -1
  113. package/dist/vocabulary/media.d.ts +21 -23
  114. package/dist/vocabulary/media.d.ts.map +1 -1
  115. package/dist/vocabulary/text.d.ts +53 -55
  116. package/dist/vocabulary/text.d.ts.map +1 -1
  117. package/dist/vocabulary/toTextContent.d.ts +0 -2
  118. package/dist/vocabulary/toTextContent.d.ts.map +1 -1
  119. package/package.json +4 -4
  120. package/src/JSONGenerativeUI.client.tsx +57 -15
  121. package/src/JSONGenerativeUI.test.tsx +298 -4
  122. package/src/a2ui/convert.test.ts +717 -0
  123. package/src/a2ui/convert.ts +229 -41
  124. package/src/a2ui/reducer.test.ts +18 -0
  125. package/src/a2ui/reducer.ts +16 -0
  126. package/src/a2ui/snapshot.test.ts +52 -0
  127. package/src/a2ui/snapshot.ts +52 -0
  128. package/src/a2ui/types.ts +16 -0
  129. package/src/a2ui.ts +2 -0
  130. package/src/actionRegistry.ts +5 -5
  131. package/src/buildPresentParameters.test.ts +246 -0
  132. package/src/buildPresentParameters.ts +18 -3
  133. package/src/scopeSchema.test.ts +72 -0
  134. package/src/scopeSchema.ts +71 -0
  135. package/src/vocabulary/interactive.dispatch.test.tsx +17 -3
  136. package/src/vocabulary/interactive.test.tsx +19 -0
  137. package/src/vocabulary/interactive.tsx +4 -1
@@ -1,4 +1,4 @@
1
- import type { UIElement } from "../ir";
1
+ import { ICON_NAMES, type UIElement } from "../ir";
2
2
  import {
3
3
  A2UI_SURFACE_ID,
4
4
  type A2uiSurfaceState,
@@ -12,15 +12,22 @@ const NODE_BUDGET = 5000;
12
12
  const SUPPORTED_COMPONENTS = new Set([
13
13
  "Text",
14
14
  "Image",
15
+ "Icon",
15
16
  "Row",
16
17
  "Column",
18
+ "List",
17
19
  "Card",
18
20
  "Divider",
19
21
  "Button",
20
22
  "TextField",
21
23
  "CheckBox",
24
+ "ChoicePicker",
25
+ "DateTimeInput",
22
26
  ]);
23
27
 
28
+ const ICON_NAME_SET: ReadonlySet<string> = new Set(ICON_NAMES);
29
+ const ICON_SIZE_SET: ReadonlySet<string> = new Set(["sm", "md", "lg"]);
30
+
24
31
  const isRecord = (value: unknown): value is Record<string, unknown> =>
25
32
  typeof value === "object" && value !== null && !Array.isArray(value);
26
33
 
@@ -145,6 +152,7 @@ type ConversionContext = {
145
152
  depthWarned: boolean;
146
153
  budgetWarned: boolean;
147
154
  templateCapWarned: boolean;
155
+ readonly keepUnknownComponents: boolean;
148
156
  };
149
157
 
150
158
  const reserveNode = (context: ConversionContext): boolean => {
@@ -159,6 +167,21 @@ const reserveNode = (context: ConversionContext): boolean => {
159
167
  return true;
160
168
  };
161
169
 
170
+ const childReferences = (node: Record<string, unknown>): unknown[] => {
171
+ const children = node["children"];
172
+ const references: unknown[] = Array.isArray(children) ? [...children] : [];
173
+ if (node["child"] !== undefined) references.push(node["child"]);
174
+ if (node["component"] === "Modal") {
175
+ references.push(node["trigger"], node["content"]);
176
+ }
177
+ if (node["component"] === "Tabs" && Array.isArray(node["tabs"])) {
178
+ for (const tab of node["tabs"]) {
179
+ references.push(isRecord(tab) ? tab["child"] : undefined);
180
+ }
181
+ }
182
+ return references;
183
+ };
184
+
162
185
  const childrenOf = (
163
186
  node: Record<string, unknown>,
164
187
  dataSource: unknown,
@@ -166,10 +189,8 @@ const childrenOf = (
166
189
  depth: number,
167
190
  visited: Set<string>,
168
191
  ): UIElement[] => {
169
- const children = node["children"];
170
- if (!Array.isArray(children)) return [];
171
192
  const result: UIElement[] = [];
172
- for (const childId of children) {
193
+ for (const childId of childReferences(node)) {
173
194
  if (typeof childId !== "string") {
174
195
  context.warnings.push(
175
196
  `Component "${String(node["id"] ?? "")}" has a malformed child reference.`,
@@ -188,36 +209,70 @@ const childrenOf = (
188
209
  return result;
189
210
  };
190
211
 
212
+ const textLabel = (
213
+ node: Record<string, unknown>,
214
+ children: readonly UIElement[],
215
+ context: ConversionContext,
216
+ ): string | undefined => {
217
+ const references = childReferences(node);
218
+ const [reference] = references;
219
+ const [child] = children;
220
+ if (
221
+ references.length !== 1 ||
222
+ children.length !== 1 ||
223
+ typeof reference !== "string" ||
224
+ !child ||
225
+ context.surface.components.get(reference)?.["component"] !== "Text"
226
+ ) {
227
+ return undefined;
228
+ }
229
+ const text = child.$type === "Header" ? child["text"] : child["value"];
230
+ return typeof text === "string" ? text : undefined;
231
+ };
232
+
191
233
  const mappedAction = (
192
234
  node: Record<string, unknown>,
193
235
  props: Record<string, unknown>,
194
- dataSource: unknown,
195
236
  context: ConversionContext,
196
237
  ): UIElement["$action"] | undefined => {
197
238
  const action = props["action"];
239
+ const event =
240
+ isRecord(action) && isRecord(action["event"]) ? action["event"] : action;
198
241
  const actionName =
199
- typeof action === "string"
200
- ? action
201
- : isRecord(action) && typeof action["name"] === "string"
202
- ? action["name"]
242
+ typeof event === "string"
243
+ ? event
244
+ : isRecord(event) && typeof event["name"] === "string"
245
+ ? event["name"]
203
246
  : undefined;
204
247
  if (!actionName) return undefined;
205
- const rawContext = isRecord(action) ? action["context"] : undefined;
206
- const resolvedContext =
207
- rawContext === undefined ? undefined : materialize(rawContext, dataSource);
248
+ const actionContext = isRecord(event) ? event["context"] : undefined;
208
249
  return {
209
250
  type: "a2ui:action",
210
251
  name: actionName,
211
252
  surfaceId: context.surfaceId,
212
253
  sourceComponentId: typeof node["id"] === "string" ? node["id"] : "",
213
- ...(resolvedContext !== undefined ? { context: resolvedContext } : {}),
254
+ ...(actionContext !== undefined ? { context: actionContext } : {}),
214
255
  };
215
256
  };
216
257
 
258
+ const choiceOptions = (value: unknown): { label: string; value: string }[] => {
259
+ if (!Array.isArray(value)) return [];
260
+ const options: { label: string; value: string }[] = [];
261
+ for (const option of value) {
262
+ if (
263
+ isRecord(option) &&
264
+ typeof option["label"] === "string" &&
265
+ typeof option["value"] === "string"
266
+ ) {
267
+ options.push({ label: option["label"], value: option["value"] });
268
+ }
269
+ }
270
+ return options;
271
+ };
272
+
217
273
  const mappedProps = (
218
274
  node: Record<string, unknown>,
219
275
  props: Record<string, unknown>,
220
- dataSource: unknown,
221
276
  context: ConversionContext,
222
277
  ): UIElement | undefined => {
223
278
  const component = node["component"];
@@ -245,7 +300,7 @@ const mappedProps = (
245
300
 
246
301
  if (component === "Image") {
247
302
  const src = stringProp(props, ["src", "url"]);
248
- const alt = stringProp(props, ["alt", "altText"]);
303
+ const alt = stringProp(props, ["alt", "altText", "description"]);
249
304
  const size = props["size"];
250
305
  const round = props["round"];
251
306
  return {
@@ -257,6 +312,17 @@ const mappedProps = (
257
312
  };
258
313
  }
259
314
 
315
+ if (component === "Icon") {
316
+ const name = stringProp(props, ["name"]);
317
+ if (!name || !ICON_NAME_SET.has(name)) return undefined;
318
+ const size = props["size"];
319
+ return {
320
+ $type: "Icon",
321
+ name,
322
+ ...(typeof size === "string" && ICON_SIZE_SET.has(size) ? { size } : {}),
323
+ };
324
+ }
325
+
260
326
  if (component === "Row") {
261
327
  const gap = props["gap"];
262
328
  const align = firstDefined(props, ["align", "alignment"]);
@@ -279,6 +345,18 @@ const mappedProps = (
279
345
  };
280
346
  }
281
347
 
348
+ if (component === "List") {
349
+ const direction = props["direction"];
350
+ const align = props["align"];
351
+ if (direction === "horizontal") {
352
+ return {
353
+ $type: "Row",
354
+ ...(typeof align === "string" ? { align } : {}),
355
+ };
356
+ }
357
+ return { $type: "ListView" };
358
+ }
359
+
282
360
  if (component === "Card") {
283
361
  const title = props["title"];
284
362
  const padding = props["padding"];
@@ -303,7 +381,7 @@ const mappedProps = (
303
381
  const buttonStyle = props["buttonStyle"];
304
382
  const block = props["block"];
305
383
  const submit = props["submit"];
306
- const action = mappedAction(node, props, dataSource, context);
384
+ const action = mappedAction(node, props, context);
307
385
  return {
308
386
  $type: "Button",
309
387
  ...(label !== undefined ? { label } : {}),
@@ -345,6 +423,55 @@ const mappedProps = (
345
423
  };
346
424
  }
347
425
 
426
+ if (component === "ChoicePicker") {
427
+ const options = choiceOptions(props["options"]);
428
+ const value = props["value"];
429
+ const defaultValue =
430
+ 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) {
443
+ return {
444
+ $type: "RadioGroup",
445
+ options,
446
+ ...(label !== undefined ? { label } : {}),
447
+ ...(name !== undefined ? { name } : {}),
448
+ ...(defaultValue !== undefined ? { defaultValue } : {}),
449
+ };
450
+ }
451
+ const placeholder = stringProp(props, ["placeholder"]);
452
+ return {
453
+ $type: "Select",
454
+ options,
455
+ ...(placeholder !== undefined ? { placeholder } : {}),
456
+ ...(label !== undefined ? { label } : {}),
457
+ ...(name !== undefined ? { name } : {}),
458
+ };
459
+ }
460
+
461
+ if (component === "DateTimeInput") {
462
+ const value = stringProp(props, ["value"]);
463
+ const min = stringProp(props, ["min"]);
464
+ const max = stringProp(props, ["max"]);
465
+ return {
466
+ $type: "DatePicker",
467
+ ...(value !== undefined ? { value } : {}),
468
+ ...(min !== undefined ? { min } : {}),
469
+ ...(max !== undefined ? { max } : {}),
470
+ ...(label !== undefined ? { label } : {}),
471
+ ...(name !== undefined ? { name } : {}),
472
+ };
473
+ }
474
+
348
475
  return undefined;
349
476
  };
350
477
 
@@ -355,14 +482,23 @@ const convertTemplate = (
355
482
  context: ConversionContext,
356
483
  depth: number,
357
484
  visited: Set<string>,
485
+ retained?: UIElement,
486
+ mappedContainer?: UIElement,
358
487
  ): UIElement | null => {
359
488
  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
+ };
360
496
  const list = resolvePointer(dataSource, templateChildren.template.path);
361
497
  if (!Array.isArray(list)) {
362
498
  context.warnings.push(
363
499
  `Template on component "${String(node["id"] ?? "")}" did not resolve to a list.`,
364
500
  );
365
- return { $type: "ListView", children: [] };
501
+ return { ...container, children: [] };
366
502
  }
367
503
  const itemCount = Math.min(list.length, TEMPLATE_ITEM_CAP);
368
504
  if (list.length > TEMPLATE_ITEM_CAP && !context.templateCapWarned) {
@@ -373,7 +509,7 @@ const convertTemplate = (
373
509
  }
374
510
  const children: UIElement[] = [];
375
511
  for (let index = 0; index < itemCount; index++) {
376
- if (!reserveNode(context)) break;
512
+ if (!retained && !horizontalList && !reserveNode(context)) break;
377
513
  const child = convertComponent(
378
514
  templateChildren.template.componentId,
379
515
  list[index],
@@ -381,12 +517,16 @@ const convertTemplate = (
381
517
  depth + 1,
382
518
  visited,
383
519
  );
384
- children.push({
385
- $type: "ListViewItem",
386
- ...(child ? { children: child } : {}),
387
- });
520
+ if (retained || horizontalList) {
521
+ if (child) children.push(child);
522
+ } else {
523
+ children.push({
524
+ $type: "ListViewItem",
525
+ ...(child ? { children: child } : {}),
526
+ });
527
+ }
388
528
  }
389
- return { $type: "ListView", children };
529
+ return { ...container, children };
390
530
  };
391
531
 
392
532
  function convertComponent(
@@ -416,43 +556,90 @@ function convertComponent(
416
556
 
417
557
  visited.add(componentId);
418
558
  try {
419
- const templateChildren = node["children"];
420
- if (isTemplateChildren(templateChildren)) {
421
- return convertTemplate(
422
- node,
423
- templateChildren,
424
- dataSource,
425
- context,
426
- depth,
427
- visited,
559
+ if (typeof component !== "string") {
560
+ context.warnings.push(
561
+ `Unknown A2UI component "${String(component ?? "")}" was skipped.`,
428
562
  );
563
+ return null;
429
564
  }
430
- if (typeof component !== "string" || !SUPPORTED_COMPONENTS.has(component)) {
565
+ const templateChildren = node["children"];
566
+ const hasTemplate = isTemplateChildren(templateChildren);
567
+ if (
568
+ !SUPPORTED_COMPONENTS.has(component) &&
569
+ !context.keepUnknownComponents &&
570
+ !hasTemplate
571
+ ) {
431
572
  context.warnings.push(
432
- `Unknown A2UI component "${String(component ?? "")}" was skipped.`,
573
+ `Unknown A2UI component "${component}" was skipped.`,
433
574
  );
434
575
  return null;
435
576
  }
436
- if (!reserveNode(context)) return null;
437
577
  const props: Record<string, unknown> = {};
438
578
  for (const [key, value] of Object.entries(node)) {
439
- if (key === "id" || key === "component" || key === "children") continue;
579
+ if (
580
+ key === "id" ||
581
+ key === "component" ||
582
+ key === "children" ||
583
+ key === "child"
584
+ ) {
585
+ continue;
586
+ }
440
587
  const resolved = materialize(value, dataSource);
441
588
  if (resolved !== undefined) setOwnProperty(props, key, resolved);
442
589
  }
443
- const mapped = mappedProps(node, props, dataSource, context);
444
- if (!mapped) return null;
590
+ const mapped = mappedProps(node, props, context);
591
+ if (!mapped && SUPPORTED_COMPONENTS.has(component)) {
592
+ context.warnings.push(
593
+ `A2UI component "${component}" could not be mapped and was skipped.`,
594
+ );
595
+ return null;
596
+ }
597
+ const retained =
598
+ !mapped && context.keepUnknownComponents
599
+ ? {
600
+ $type: component,
601
+ ...Object.fromEntries(
602
+ Object.entries(props).filter(([key]) => !key.startsWith("$")),
603
+ ),
604
+ }
605
+ : undefined;
606
+ if (hasTemplate) {
607
+ return convertTemplate(
608
+ node,
609
+ templateChildren,
610
+ dataSource,
611
+ context,
612
+ depth,
613
+ visited,
614
+ retained,
615
+ component === "List" ? mapped : undefined,
616
+ );
617
+ }
618
+ if (!reserveNode(context)) return null;
619
+ const converted = mapped ?? retained;
620
+ if (!converted) return null;
445
621
  const children = childrenOf(node, dataSource, context, depth, visited);
622
+ if (mapped?.$type === "Button" && mapped["label"] === undefined) {
623
+ const label = textLabel(node, children, context);
624
+ if (label !== undefined) return { ...mapped, label };
625
+ }
626
+ const listChildren =
627
+ component === "List" && mapped?.$type === "ListView"
628
+ ? children.map((child) => ({ $type: "ListViewItem", children: child }))
629
+ : children;
446
630
  return {
447
- ...mapped,
448
- ...(children.length > 0 ? { children } : {}),
631
+ ...converted,
632
+ ...(listChildren.length > 0 ? { children: listChildren } : {}),
449
633
  };
450
634
  } finally {
451
635
  visited.delete(componentId);
452
636
  }
453
637
  }
454
638
 
455
- export function convertSurfaceToUISpec(surface: A2uiSurfaceState): {
639
+ export function convertSurfaceToUISpec(
640
+ surface: A2uiSurfaceState,
641
+ options: { readonly keepUnknownComponents?: boolean } = {},
642
+ ): {
456
643
  spec: UIElement | null;
457
644
  warnings: string[];
458
645
  } {
@@ -471,6 +658,7 @@ export function convertSurfaceToUISpec(surface: A2uiSurfaceState): {
471
658
  depthWarned: false,
472
659
  budgetWarned: false,
473
660
  templateCapWarned: false,
661
+ keepUnknownComponents: options.keepUnknownComponents === true,
474
662
  };
475
663
  try {
476
664
  const spec = convertComponent(
@@ -35,6 +35,7 @@ describe("applyA2uiOperations", () => {
35
35
  expect(initial.size).toBe(0);
36
36
  expect(created.warnings).toEqual([]);
37
37
  expect(created.state.get("main")).toMatchObject({
38
+ catalogId: "default",
38
39
  dataModel: { profile: { name: "Ada" } },
39
40
  });
40
41
  expect(created.state.get("main")?.components.get("root")).toEqual({
@@ -61,6 +62,7 @@ describe("applyA2uiOperations", () => {
61
62
  version: "v1.0",
62
63
  createSurface: {
63
64
  surfaceId: "inline",
65
+ catalogId: "basic",
64
66
  surfaceProperties: { ignored: true },
65
67
  sendDataModel: true,
66
68
  components: [
@@ -73,6 +75,7 @@ describe("applyA2uiOperations", () => {
73
75
 
74
76
  expect(result.warnings).toEqual([]);
75
77
  expect(result.state.get("inline")).toMatchObject({
78
+ catalogId: "basic",
76
79
  dataModel: { message: "hello" },
77
80
  });
78
81
  expect(result.state.get("inline")?.components.get("root")).toEqual({
@@ -82,6 +85,21 @@ describe("applyA2uiOperations", () => {
82
85
  });
83
86
  });
84
87
 
88
+ it("keeps a v1.0 catalog id from surface properties", () => {
89
+ const result = applyA2uiOperations(new Map(), [
90
+ {
91
+ version: "v1.0",
92
+ createSurface: {
93
+ surfaceId: "properties",
94
+ surfaceProperties: { catalogId: "basic" },
95
+ },
96
+ },
97
+ ]);
98
+
99
+ expect(result.warnings).toEqual([]);
100
+ expect(result.state.get("properties")?.catalogId).toBe("basic");
101
+ });
102
+
85
103
  it.each(["10001", "4294967294"])(
86
104
  "rejects array pointer index %s beyond the auto-vivification limit",
87
105
  (index) => {
@@ -28,6 +28,17 @@ const surfaceIdOf = (payload: Record<string, unknown>): string | undefined => {
28
28
  : undefined;
29
29
  };
30
30
 
31
+ const catalogIdOf = (payload: Record<string, unknown>): string | undefined => {
32
+ if (typeof payload["catalogId"] === "string") {
33
+ return payload["catalogId"];
34
+ }
35
+ const surfaceProperties = payload["surfaceProperties"];
36
+ return isRecord(surfaceProperties) &&
37
+ typeof surfaceProperties["catalogId"] === "string"
38
+ ? surfaceProperties["catalogId"]
39
+ : undefined;
40
+ };
41
+
31
42
  const withSurfaceId = (
32
43
  surface: A2uiSurfaceState,
33
44
  surfaceId: string,
@@ -50,6 +61,9 @@ const cloneSurface = (
50
61
  ): A2uiSurfaceState =>
51
62
  withSurfaceId(
52
63
  {
64
+ ...(surface.catalogId !== undefined
65
+ ? { catalogId: surface.catalogId }
66
+ : {}),
53
67
  components: new Map(surface.components),
54
68
  dataModel: surface.dataModel,
55
69
  },
@@ -274,8 +288,10 @@ export function applyA2uiOperations(
274
288
  }
275
289
 
276
290
  if (operationKey === "createSurface") {
291
+ const catalogId = catalogIdOf(payload);
277
292
  const surface = withSurfaceId(
278
293
  {
294
+ ...(catalogId !== undefined ? { catalogId } : {}),
279
295
  components: new Map(),
280
296
  dataModel:
281
297
  version === "v1.0" && payload["dataModel"] !== undefined
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { surfaceToOperations } from "./snapshot";
3
+ import { A2UI_SURFACE_ID, type A2uiSurfaceState } from "./types";
4
+
5
+ const surface = (dataModel: unknown): A2uiSurfaceState => ({
6
+ components: new Map([["root", { id: "root", component: "Text" }]]),
7
+ dataModel,
8
+ });
9
+
10
+ describe("surfaceToOperations", () => {
11
+ it("uses an explicit surface id and omits an undefined data model", () => {
12
+ const value = Object.defineProperty(surface(undefined), A2UI_SURFACE_ID, {
13
+ value: "stored",
14
+ });
15
+
16
+ expect(surfaceToOperations(value, "provided")).toEqual([
17
+ {
18
+ version: "v0.9",
19
+ createSurface: { surfaceId: "provided" },
20
+ },
21
+ {
22
+ version: "v0.9",
23
+ updateComponents: {
24
+ surfaceId: "provided",
25
+ components: [{ id: "root", component: "Text" }],
26
+ },
27
+ },
28
+ ]);
29
+ });
30
+
31
+ it("uses the reducer surface id when no explicit id is supplied", () => {
32
+ const value = Object.defineProperty(surface(null), A2UI_SURFACE_ID, {
33
+ value: "stored",
34
+ });
35
+
36
+ expect(surfaceToOperations(value)).toMatchObject([
37
+ { createSurface: { surfaceId: "stored" } },
38
+ { updateComponents: { surfaceId: "stored" } },
39
+ { updateDataModel: { surfaceId: "stored", contents: null } },
40
+ ]);
41
+ });
42
+
43
+ it("requires either an explicit or reducer surface id", () => {
44
+ expect(() => surfaceToOperations(surface({}))).toThrow(
45
+ "A2UI surfaces must have a surface id to be replayed.",
46
+ );
47
+ });
48
+ it("rejects an empty surface id", () => {
49
+ const value = { components: new Map(), dataModel: undefined };
50
+ expect(() => surfaceToOperations(value, "")).toThrow();
51
+ });
52
+ });
@@ -0,0 +1,52 @@
1
+ import {
2
+ A2UI_SURFACE_ID,
3
+ type A2uiSurfaceState,
4
+ type A2uiSurfaceSnapshotOperation,
5
+ type ComponentNode,
6
+ } from "./types";
7
+
8
+ const surfaceIdOf = (surface: A2uiSurfaceState): string | undefined =>
9
+ (surface as A2uiSurfaceState & { [A2UI_SURFACE_ID]?: string })[
10
+ A2UI_SURFACE_ID
11
+ ];
12
+
13
+ export function surfaceToOperations(
14
+ surface: A2uiSurfaceState,
15
+ surfaceId?: string,
16
+ ): readonly A2uiSurfaceSnapshotOperation[] {
17
+ const resolvedSurfaceId = surfaceId ?? surfaceIdOf(surface);
18
+ if (!resolvedSurfaceId) {
19
+ throw new Error("A2UI surfaces must have a surface id to be replayed.");
20
+ }
21
+ const operations: A2uiSurfaceSnapshotOperation[] = [
22
+ {
23
+ version: "v0.9",
24
+ createSurface: {
25
+ surfaceId: resolvedSurfaceId,
26
+ ...(surface.catalogId !== undefined
27
+ ? { catalogId: surface.catalogId }
28
+ : {}),
29
+ },
30
+ },
31
+ {
32
+ version: "v0.9",
33
+ updateComponents: {
34
+ surfaceId: resolvedSurfaceId,
35
+ components: [...surface.components.values()].map((component) => ({
36
+ ...component,
37
+ })) as ComponentNode[],
38
+ },
39
+ },
40
+ ];
41
+ if (surface.dataModel !== undefined) {
42
+ operations.push({
43
+ version: "v0.9",
44
+ updateDataModel: {
45
+ surfaceId: resolvedSurfaceId,
46
+ path: "/",
47
+ contents: surface.dataModel,
48
+ },
49
+ });
50
+ }
51
+ return operations;
52
+ }
package/src/a2ui/types.ts CHANGED
@@ -24,6 +24,7 @@ export interface A2uiCreateSurfaceV09Payload {
24
24
 
25
25
  export interface A2uiCreateSurfaceV10Payload {
26
26
  readonly surfaceId: string;
27
+ readonly catalogId?: string;
27
28
  readonly surfaceProperties?: unknown;
28
29
  readonly sendDataModel?: unknown;
29
30
  readonly components?: readonly ComponentNode[];
@@ -78,7 +79,22 @@ export type A2uiOperation =
78
79
  | A2uiUpdateDataModelOperation
79
80
  | A2uiDeleteSurfaceOperation;
80
81
 
82
+ export type A2uiSurfaceSnapshotOperation =
83
+ | {
84
+ readonly version: "v0.9";
85
+ readonly createSurface: A2uiCreateSurfaceV09Payload;
86
+ }
87
+ | {
88
+ readonly version: "v0.9";
89
+ readonly updateComponents: A2uiUpdateComponentsPayload;
90
+ }
91
+ | {
92
+ readonly version: "v0.9";
93
+ readonly updateDataModel: A2uiUpdateDataModelPayload;
94
+ };
95
+
81
96
  export type A2uiSurfaceState = {
97
+ catalogId?: string;
82
98
  components: Map<string, Record<string, unknown>>;
83
99
  dataModel: unknown;
84
100
  };
package/src/a2ui.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { applyA2uiOperations } from "./a2ui/reducer";
2
2
  export { convertSurfaceToUISpec } from "./a2ui/convert";
3
+ export { surfaceToOperations } from "./a2ui/snapshot";
3
4
  export type {
4
5
  A2uiCreateSurfaceOperation,
5
6
  A2uiCreateSurfaceV09Payload,
@@ -9,6 +10,7 @@ export type {
9
10
  A2uiOperation,
10
11
  A2uiOperationResult,
11
12
  A2uiState,
13
+ A2uiSurfaceSnapshotOperation,
12
14
  A2uiSurfaceState,
13
15
  A2uiTemplateChildren,
14
16
  A2uiUpdateComponentsOperation,
@@ -10,17 +10,17 @@ export type ActionDispatchContext = {
10
10
 
11
11
  /**
12
12
  * Resolves a single `$action.type`. Fire-and-forget actions return `void` or a
13
- * promise that resolves to it; human-in-the-loop actions return a value the
14
- * runtime uses to resume the run (see IR doc Open question #2 — the resume
15
- * value is left as `unknown` and documented per-action). The return value is
16
- * handed back to `dispatch`'s caller as `unknown`.
13
+ * promise that resolves to it. For a `prompt_user` tool whose call has no
14
+ * result yet, a non-`undefined` return value becomes the tool result. Whether
15
+ * that resumes the run depends on the runtime, including how it treats human
16
+ * tools. The return value is handed back to `dispatch`'s caller as `unknown`.
17
17
  */
18
18
  export type ActionHandler = (
19
19
  ctx: ActionDispatchContext,
20
20
  ) => unknown | Promise<unknown>;
21
21
 
22
22
  /**
23
- * The host-provided map from `$action.type` to {@link ActionHandler}. This is the single dispatch target for rendered generative UI: every interactive component fires its `$action` here through the injected `$dispatch` (a submit Button defers to its ancestor form's dispatch instead). The payload's `$input` takes one of two shapes: a single value for a standalone control's dispatch (`Select`, `Input`, `DatePicker`, `Checkbox`, `RadioGroup`), or an object keyed by field `name` for a `Form` or a `Card` with `asForm` set. Construct it with {@link createActionRegistry} and pass it to `JSONGenerativeUI`.
23
+ * The host-provided map from `$action.type` to {@link ActionHandler}. This is the single dispatch target for rendered generative UI: every interactive component fires its `$action` here through the injected `$dispatch` (a submit Button defers to its ancestor form's dispatch instead). The payload's `$input` takes one of two shapes: a single value for a standalone control's dispatch (`Select`, `Input`, `DatePicker`, `Checkbox`, `RadioGroup`), or an object keyed by field `name` for a `Form` or a `Card` with `asForm` set. When a registry is used in a `JSONGenerativeUI` tool, each dispatched payload, including `$input`, is recorded on the tool call on a best-effort basis. Recording depends on the runtime and interaction log limits, and never delays or prevents dispatch. Construct it with {@link createActionRegistry} and pass it to `JSONGenerativeUI`.
24
24
  */
25
25
  export type ActionRegistry = {
26
26
  dispatch(action: Action): unknown;