@assistant-ui/react-generative-ui 0.0.18 → 0.0.20

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 (152) hide show
  1. package/dist/JSONGenerativeUI.client.d.ts +15 -17
  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 +6 -9
  6. package/dist/JSONGenerativeUI.server.d.ts.map +1 -1
  7. package/dist/JSONGenerativeUI.shared.d.ts +42 -44
  8. package/dist/JSONGenerativeUI.shared.d.ts.map +1 -1
  9. package/dist/a2ui/convert.d.ts +7 -8
  10. package/dist/a2ui/convert.d.ts.map +1 -1
  11. package/dist/a2ui/convert.js +115 -16
  12. package/dist/a2ui/convert.js.map +1 -1
  13. package/dist/a2ui/reducer.d.ts +2 -5
  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 +67 -58
  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 +17 -20
  28. package/dist/actionRegistry.d.ts.map +1 -1
  29. package/dist/actionRegistry.js.map +1 -1
  30. package/dist/buildPresentParameters.d.ts +3 -6
  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 +2 -4
  35. package/dist/constants.d.ts.map +1 -1
  36. package/dist/constants.js +2 -1
  37. package/dist/constants.js.map +1 -1
  38. package/dist/convert/boundSpec.d.ts +7 -10
  39. package/dist/convert/boundSpec.d.ts.map +1 -1
  40. package/dist/convert/copyBounded.d.ts +3 -6
  41. package/dist/convert/copyBounded.d.ts.map +1 -1
  42. package/dist/convert/isElement.d.ts +2 -5
  43. package/dist/convert/isElement.d.ts.map +1 -1
  44. package/dist/convert/takeRun.d.ts +4 -7
  45. package/dist/convert/takeRun.d.ts.map +1 -1
  46. package/dist/defineGenerativeComponents.d.ts +2 -5
  47. package/dist/defineGenerativeComponents.d.ts.map +1 -1
  48. package/dist/generativeUIToJSX.d.ts +6 -9
  49. package/dist/generativeUIToJSX.d.ts.map +1 -1
  50. package/dist/index.d.ts +3 -13
  51. package/dist/index.d.ts.map +1 -0
  52. package/dist/index.server.d.ts +3 -13
  53. package/dist/index.server.d.ts.map +1 -0
  54. package/dist/index.shared.d.ts +15 -11
  55. package/dist/index.shared.d.ts.map +1 -0
  56. package/dist/ir.d.ts +45 -48
  57. package/dist/ir.d.ts.map +1 -1
  58. package/dist/renderGenerativeUI.d.ts +3 -6
  59. package/dist/renderGenerativeUI.d.ts.map +1 -1
  60. package/dist/scopeSchema.d.ts +6 -0
  61. package/dist/scopeSchema.d.ts.map +1 -0
  62. package/dist/scopeSchema.js +56 -0
  63. package/dist/scopeSchema.js.map +1 -0
  64. package/dist/slack/constants.d.ts +43 -45
  65. package/dist/slack/constants.d.ts.map +1 -1
  66. package/dist/slack/decodeBlockAction.d.ts +2 -5
  67. package/dist/slack/decodeBlockAction.d.ts.map +1 -1
  68. package/dist/slack/fromSlackBlocks.d.ts +2 -5
  69. package/dist/slack/fromSlackBlocks.d.ts.map +1 -1
  70. package/dist/slack/toSlackBlocks.d.ts +2 -5
  71. package/dist/slack/toSlackBlocks.d.ts.map +1 -1
  72. package/dist/slack/toSlackBlocks.js +1 -1
  73. package/dist/slack/types.d.ts +110 -113
  74. package/dist/slack/types.d.ts.map +1 -1
  75. package/dist/slack.d.ts +5 -5
  76. package/dist/slack.d.ts.map +1 -0
  77. package/dist/teams/constants.d.ts +15 -18
  78. package/dist/teams/constants.d.ts.map +1 -1
  79. package/dist/teams/decodeSubmitData.d.ts +2 -5
  80. package/dist/teams/decodeSubmitData.d.ts.map +1 -1
  81. package/dist/teams/toAdaptiveCard.d.ts +10 -13
  82. package/dist/teams/toAdaptiveCard.d.ts.map +1 -1
  83. package/dist/teams/toTeamsAttachments.d.ts +2 -5
  84. package/dist/teams/toTeamsAttachments.d.ts.map +1 -1
  85. package/dist/teams/types.d.ts +136 -138
  86. package/dist/teams/types.d.ts.map +1 -1
  87. package/dist/teams.d.ts +5 -5
  88. package/dist/teams.d.ts.map +1 -0
  89. package/dist/types.d.ts +45 -47
  90. package/dist/types.d.ts.map +1 -1
  91. package/dist/vocabulary/alert.d.ts +22 -25
  92. package/dist/vocabulary/alert.d.ts.map +1 -1
  93. package/dist/vocabulary/collectFormValues.d.ts +12 -13
  94. package/dist/vocabulary/collectFormValues.d.ts.map +1 -1
  95. package/dist/vocabulary/collectFormValues.js +3 -2
  96. package/dist/vocabulary/collectFormValues.js.map +1 -1
  97. package/dist/vocabulary/data.d.ts +46 -49
  98. package/dist/vocabulary/data.d.ts.map +1 -1
  99. package/dist/vocabulary/dispatch.d.ts +4 -7
  100. package/dist/vocabulary/dispatch.d.ts.map +1 -1
  101. package/dist/vocabulary/fact.d.ts +9 -12
  102. package/dist/vocabulary/fact.d.ts.map +1 -1
  103. package/dist/vocabulary/form.d.ts +8 -11
  104. package/dist/vocabulary/form.d.ts.map +1 -1
  105. package/dist/vocabulary/icon.d.ts +38 -41
  106. package/dist/vocabulary/icon.d.ts.map +1 -1
  107. package/dist/vocabulary/index.d.ts +2 -5
  108. package/dist/vocabulary/index.d.ts.map +1 -1
  109. package/dist/vocabulary/interactive.d.ts +88 -87
  110. package/dist/vocabulary/interactive.d.ts.map +1 -1
  111. package/dist/vocabulary/interactive.js +6 -4
  112. package/dist/vocabulary/interactive.js.map +1 -1
  113. package/dist/vocabulary/layout.d.ts +76 -79
  114. package/dist/vocabulary/layout.d.ts.map +1 -1
  115. package/dist/vocabulary/list.d.ts +11 -14
  116. package/dist/vocabulary/list.d.ts.map +1 -1
  117. package/dist/vocabulary/media.d.ts +22 -25
  118. package/dist/vocabulary/media.d.ts.map +1 -1
  119. package/dist/vocabulary/text.d.ts +54 -57
  120. package/dist/vocabulary/text.d.ts.map +1 -1
  121. package/dist/vocabulary/toTextContent.d.ts +1 -4
  122. package/dist/vocabulary/toTextContent.d.ts.map +1 -1
  123. package/package.json +12 -10
  124. package/src/JSONGenerativeUI.client.tsx +57 -15
  125. package/src/JSONGenerativeUI.test.tsx +298 -4
  126. package/src/a2ui/convert.test.ts +496 -0
  127. package/src/a2ui/convert.ts +170 -25
  128. package/src/a2ui/reducer.test.ts +18 -0
  129. package/src/a2ui/reducer.ts +16 -0
  130. package/src/a2ui/snapshot.test.ts +52 -0
  131. package/src/a2ui/snapshot.ts +52 -0
  132. package/src/a2ui/types.ts +16 -0
  133. package/src/a2ui.ts +2 -0
  134. package/src/actionRegistry.ts +5 -5
  135. package/src/buildPresentParameters.test.ts +246 -0
  136. package/src/buildPresentParameters.ts +18 -3
  137. package/src/constants.ts +2 -0
  138. package/src/scopeSchema.test.ts +72 -0
  139. package/src/scopeSchema.ts +71 -0
  140. package/src/slack/fromSlackBlocks.test.ts +4 -1
  141. package/src/slack/toSlackBlocks.test.ts +8 -4
  142. package/src/teams/toAdaptiveCard.test.ts +5 -3
  143. package/src/vocabulary/collectFormValues.test.ts +38 -0
  144. package/src/vocabulary/collectFormValues.ts +11 -2
  145. package/src/vocabulary/form.test.tsx +1 -0
  146. package/src/vocabulary/interactive.dispatch.test.tsx +17 -3
  147. package/src/vocabulary/interactive.test.tsx +33 -1
  148. package/src/vocabulary/interactive.tsx +12 -2
  149. package/src/vocabulary/layout.test.tsx +1 -0
  150. package/src/vocabulary/list.test.tsx +5 -4
  151. package/dist/node_modules/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts +0 -141
  152. package/dist/node_modules/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts.map +0 -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 => {
@@ -214,6 +222,21 @@ const mappedAction = (
214
222
  };
215
223
  };
216
224
 
225
+ const choiceOptions = (value: unknown): { label: string; value: string }[] => {
226
+ if (!Array.isArray(value)) return [];
227
+ const options: { label: string; value: string }[] = [];
228
+ for (const option of value) {
229
+ if (
230
+ isRecord(option) &&
231
+ typeof option["label"] === "string" &&
232
+ typeof option["value"] === "string"
233
+ ) {
234
+ options.push({ label: option["label"], value: option["value"] });
235
+ }
236
+ }
237
+ return options;
238
+ };
239
+
217
240
  const mappedProps = (
218
241
  node: Record<string, unknown>,
219
242
  props: Record<string, unknown>,
@@ -245,7 +268,7 @@ const mappedProps = (
245
268
 
246
269
  if (component === "Image") {
247
270
  const src = stringProp(props, ["src", "url"]);
248
- const alt = stringProp(props, ["alt", "altText"]);
271
+ const alt = stringProp(props, ["alt", "altText", "description"]);
249
272
  const size = props["size"];
250
273
  const round = props["round"];
251
274
  return {
@@ -257,6 +280,17 @@ const mappedProps = (
257
280
  };
258
281
  }
259
282
 
283
+ if (component === "Icon") {
284
+ const name = stringProp(props, ["name"]);
285
+ if (!name || !ICON_NAME_SET.has(name)) return undefined;
286
+ const size = props["size"];
287
+ return {
288
+ $type: "Icon",
289
+ name,
290
+ ...(typeof size === "string" && ICON_SIZE_SET.has(size) ? { size } : {}),
291
+ };
292
+ }
293
+
260
294
  if (component === "Row") {
261
295
  const gap = props["gap"];
262
296
  const align = firstDefined(props, ["align", "alignment"]);
@@ -279,6 +313,18 @@ const mappedProps = (
279
313
  };
280
314
  }
281
315
 
316
+ if (component === "List") {
317
+ const direction = props["direction"];
318
+ const align = props["align"];
319
+ if (direction === "horizontal") {
320
+ return {
321
+ $type: "Row",
322
+ ...(typeof align === "string" ? { align } : {}),
323
+ };
324
+ }
325
+ return { $type: "ListView" };
326
+ }
327
+
282
328
  if (component === "Card") {
283
329
  const title = props["title"];
284
330
  const padding = props["padding"];
@@ -345,6 +391,55 @@ const mappedProps = (
345
391
  };
346
392
  }
347
393
 
394
+ if (component === "ChoicePicker") {
395
+ const options = choiceOptions(props["options"]);
396
+ const value = props["value"];
397
+ const defaultValue =
398
+ typeof value === "string"
399
+ ? value
400
+ : Array.isArray(value) && typeof value[0] === "string"
401
+ ? value[0]
402
+ : undefined;
403
+ const radioStyle =
404
+ props["variant"] === "radio" ||
405
+ props["variant"] === "single" ||
406
+ props["variant"] === "singleSelection" ||
407
+ props["displayStyle"] === "radio" ||
408
+ (props["variant"] === "mutuallyExclusive" &&
409
+ props["displayStyle"] !== "chips");
410
+ if (radioStyle) {
411
+ return {
412
+ $type: "RadioGroup",
413
+ options,
414
+ ...(label !== undefined ? { label } : {}),
415
+ ...(name !== undefined ? { name } : {}),
416
+ ...(defaultValue !== undefined ? { defaultValue } : {}),
417
+ };
418
+ }
419
+ const placeholder = stringProp(props, ["placeholder"]);
420
+ return {
421
+ $type: "Select",
422
+ options,
423
+ ...(placeholder !== undefined ? { placeholder } : {}),
424
+ ...(label !== undefined ? { label } : {}),
425
+ ...(name !== undefined ? { name } : {}),
426
+ };
427
+ }
428
+
429
+ if (component === "DateTimeInput") {
430
+ const value = stringProp(props, ["value"]);
431
+ const min = stringProp(props, ["min"]);
432
+ const max = stringProp(props, ["max"]);
433
+ return {
434
+ $type: "DatePicker",
435
+ ...(value !== undefined ? { value } : {}),
436
+ ...(min !== undefined ? { min } : {}),
437
+ ...(max !== undefined ? { max } : {}),
438
+ ...(label !== undefined ? { label } : {}),
439
+ ...(name !== undefined ? { name } : {}),
440
+ };
441
+ }
442
+
348
443
  return undefined;
349
444
  };
350
445
 
@@ -355,14 +450,23 @@ const convertTemplate = (
355
450
  context: ConversionContext,
356
451
  depth: number,
357
452
  visited: Set<string>,
453
+ retained?: UIElement,
454
+ mappedContainer?: UIElement,
358
455
  ): UIElement | null => {
359
456
  if (!reserveNode(context)) return null;
457
+ const horizontalList =
458
+ node["component"] === "List" &&
459
+ materialize(node["direction"], dataSource) === "horizontal";
460
+ const container = mappedContainer ??
461
+ retained ?? {
462
+ $type: horizontalList ? "Row" : "ListView",
463
+ };
360
464
  const list = resolvePointer(dataSource, templateChildren.template.path);
361
465
  if (!Array.isArray(list)) {
362
466
  context.warnings.push(
363
467
  `Template on component "${String(node["id"] ?? "")}" did not resolve to a list.`,
364
468
  );
365
- return { $type: "ListView", children: [] };
469
+ return { ...container, children: [] };
366
470
  }
367
471
  const itemCount = Math.min(list.length, TEMPLATE_ITEM_CAP);
368
472
  if (list.length > TEMPLATE_ITEM_CAP && !context.templateCapWarned) {
@@ -373,7 +477,7 @@ const convertTemplate = (
373
477
  }
374
478
  const children: UIElement[] = [];
375
479
  for (let index = 0; index < itemCount; index++) {
376
- if (!reserveNode(context)) break;
480
+ if (!retained && !horizontalList && !reserveNode(context)) break;
377
481
  const child = convertComponent(
378
482
  templateChildren.template.componentId,
379
483
  list[index],
@@ -381,12 +485,16 @@ const convertTemplate = (
381
485
  depth + 1,
382
486
  visited,
383
487
  );
384
- children.push({
385
- $type: "ListViewItem",
386
- ...(child ? { children: child } : {}),
387
- });
488
+ if (retained || horizontalList) {
489
+ if (child) children.push(child);
490
+ } else {
491
+ children.push({
492
+ $type: "ListViewItem",
493
+ ...(child ? { children: child } : {}),
494
+ });
495
+ }
388
496
  }
389
- return { $type: "ListView", children };
497
+ return { ...container, children };
390
498
  };
391
499
 
392
500
  function convertComponent(
@@ -416,24 +524,24 @@ function convertComponent(
416
524
 
417
525
  visited.add(componentId);
418
526
  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,
527
+ if (typeof component !== "string") {
528
+ context.warnings.push(
529
+ `Unknown A2UI component "${String(component ?? "")}" was skipped.`,
428
530
  );
531
+ return null;
429
532
  }
430
- if (typeof component !== "string" || !SUPPORTED_COMPONENTS.has(component)) {
533
+ const templateChildren = node["children"];
534
+ const hasTemplate = isTemplateChildren(templateChildren);
535
+ if (
536
+ !SUPPORTED_COMPONENTS.has(component) &&
537
+ !context.keepUnknownComponents &&
538
+ !hasTemplate
539
+ ) {
431
540
  context.warnings.push(
432
- `Unknown A2UI component "${String(component ?? "")}" was skipped.`,
541
+ `Unknown A2UI component "${component}" was skipped.`,
433
542
  );
434
543
  return null;
435
544
  }
436
- if (!reserveNode(context)) return null;
437
545
  const props: Record<string, unknown> = {};
438
546
  for (const [key, value] of Object.entries(node)) {
439
547
  if (key === "id" || key === "component" || key === "children") continue;
@@ -441,18 +549,54 @@ function convertComponent(
441
549
  if (resolved !== undefined) setOwnProperty(props, key, resolved);
442
550
  }
443
551
  const mapped = mappedProps(node, props, dataSource, context);
444
- if (!mapped) return null;
552
+ if (!mapped && SUPPORTED_COMPONENTS.has(component)) {
553
+ context.warnings.push(
554
+ `A2UI component "${component}" could not be mapped and was skipped.`,
555
+ );
556
+ return null;
557
+ }
558
+ const retained =
559
+ !mapped && context.keepUnknownComponents
560
+ ? {
561
+ $type: component,
562
+ ...Object.fromEntries(
563
+ Object.entries(props).filter(([key]) => !key.startsWith("$")),
564
+ ),
565
+ }
566
+ : undefined;
567
+ if (hasTemplate) {
568
+ return convertTemplate(
569
+ node,
570
+ templateChildren,
571
+ dataSource,
572
+ context,
573
+ depth,
574
+ visited,
575
+ retained,
576
+ component === "List" ? mapped : undefined,
577
+ );
578
+ }
579
+ if (!reserveNode(context)) return null;
580
+ const converted = mapped ?? retained;
581
+ if (!converted) return null;
445
582
  const children = childrenOf(node, dataSource, context, depth, visited);
583
+ const listChildren =
584
+ component === "List" && mapped?.$type === "ListView"
585
+ ? children.map((child) => ({ $type: "ListViewItem", children: child }))
586
+ : children;
446
587
  return {
447
- ...mapped,
448
- ...(children.length > 0 ? { children } : {}),
588
+ ...converted,
589
+ ...(listChildren.length > 0 ? { children: listChildren } : {}),
449
590
  };
450
591
  } finally {
451
592
  visited.delete(componentId);
452
593
  }
453
594
  }
454
595
 
455
- export function convertSurfaceToUISpec(surface: A2uiSurfaceState): {
596
+ export function convertSurfaceToUISpec(
597
+ surface: A2uiSurfaceState,
598
+ options: { readonly keepUnknownComponents?: boolean } = {},
599
+ ): {
456
600
  spec: UIElement | null;
457
601
  warnings: string[];
458
602
  } {
@@ -471,6 +615,7 @@ export function convertSurfaceToUISpec(surface: A2uiSurfaceState): {
471
615
  depthWarned: false,
472
616
  budgetWarned: false,
473
617
  templateCapWarned: false,
618
+ keepUnknownComponents: options.keepUnknownComponents === true,
474
619
  };
475
620
  try {
476
621
  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;