@assistant-ui/react-generative-ui 0.0.13 → 0.0.14

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 (44) hide show
  1. package/dist/JSONGenerativeUI.client.d.ts +5 -0
  2. package/dist/JSONGenerativeUI.client.d.ts.map +1 -1
  3. package/dist/JSONGenerativeUI.client.js +12 -3
  4. package/dist/JSONGenerativeUI.client.js.map +1 -1
  5. package/dist/slack/constants.d.ts +11 -4
  6. package/dist/slack/constants.d.ts.map +1 -1
  7. package/dist/slack/constants.js +11 -4
  8. package/dist/slack/constants.js.map +1 -1
  9. package/dist/slack/fromSlackBlocks.js +2 -2
  10. package/dist/slack/toSlackBlocks.d.ts.map +1 -1
  11. package/dist/slack/toSlackBlocks.js +111 -17
  12. package/dist/slack/toSlackBlocks.js.map +1 -1
  13. package/dist/slack/types.d.ts +7 -1
  14. package/dist/slack/types.d.ts.map +1 -1
  15. package/dist/teams/constants.d.ts +9 -2
  16. package/dist/teams/constants.d.ts.map +1 -1
  17. package/dist/teams/constants.js +10 -3
  18. package/dist/teams/constants.js.map +1 -1
  19. package/dist/teams/toAdaptiveCard.d.ts +10 -2
  20. package/dist/teams/toAdaptiveCard.d.ts.map +1 -1
  21. package/dist/teams/toAdaptiveCard.js +50 -13
  22. package/dist/teams/toAdaptiveCard.js.map +1 -1
  23. package/dist/teams/toTeamsAttachments.d.ts.map +1 -1
  24. package/dist/teams/toTeamsAttachments.js +18 -11
  25. package/dist/teams/toTeamsAttachments.js.map +1 -1
  26. package/dist/teams/types.d.ts +11 -2
  27. package/dist/teams/types.d.ts.map +1 -1
  28. package/dist/vocabulary/layout.js +2 -1
  29. package/dist/vocabulary/layout.js.map +1 -1
  30. package/package.json +3 -3
  31. package/src/JSONGenerativeUI.client.tsx +13 -5
  32. package/src/JSONGenerativeUI.test.tsx +21 -2
  33. package/src/slack/constants.ts +11 -3
  34. package/src/slack/toSlackBlocks.test.ts +374 -8
  35. package/src/slack/toSlackBlocks.ts +181 -24
  36. package/src/slack/types.ts +7 -1
  37. package/src/teams/constants.ts +10 -2
  38. package/src/teams/toAdaptiveCard.test.ts +204 -10
  39. package/src/teams/toAdaptiveCard.ts +79 -15
  40. package/src/teams/toTeamsAttachments.test.ts +47 -2
  41. package/src/teams/toTeamsAttachments.ts +17 -11
  42. package/src/teams/types.ts +11 -2
  43. package/src/vocabulary/layout.test.tsx +16 -1
  44. package/src/vocabulary/layout.tsx +4 -1
@@ -108,14 +108,14 @@ function reservedSafeId(
108
108
  if (reserved) {
109
109
  warn(
110
110
  context,
111
- "clamped",
111
+ "fallback",
112
112
  component,
113
113
  `the input id "${RESERVED_INPUT_ID}" collides with the submit envelope's reserved key and was renamed to "${candidate}".`,
114
114
  );
115
115
  } else if (candidate !== base) {
116
116
  warn(
117
117
  context,
118
- "clamped",
118
+ "fallback",
119
119
  component,
120
120
  `the input id "${base}" was already used on this card and was renamed to "${candidate}".`,
121
121
  );
@@ -191,6 +191,15 @@ const choicesFrom = (
191
191
  const choice = toChoice(item);
192
192
  if (choice !== undefined) choices.push(choice);
193
193
  }
194
+ const dropped = items.length - choices.length;
195
+ if (dropped > 0) {
196
+ warn(
197
+ context,
198
+ "dropped",
199
+ component,
200
+ `${dropped} ${dropped === 1 ? "option was" : "options were"} dropped for want of a string value.`,
201
+ );
202
+ }
194
203
  return choices;
195
204
  };
196
205
 
@@ -211,7 +220,7 @@ function convertButtons(
211
220
  if (buttons.length > PRIMARY_ACTION_CAP) {
212
221
  warn(
213
222
  context,
214
- "clamped",
223
+ "fallback",
215
224
  "Button",
216
225
  `actions beyond ${PRIMARY_ACTION_CAP} were set to secondary mode.`,
217
226
  );
@@ -302,6 +311,17 @@ function convertTable(
302
311
  );
303
312
  }
304
313
 
314
+ const unlabeled = rawColumns.filter(
315
+ (column) => !isRecord(column) || typeof column["label"] !== "string",
316
+ ).length;
317
+ if (unlabeled > 0) {
318
+ warn(
319
+ context,
320
+ "dropped",
321
+ "Table",
322
+ `${unlabeled} column ${unlabeled === 1 ? "header was" : "headers were"} left blank for want of a string label.`,
323
+ );
324
+ }
305
325
  const hasColumns = rawColumns.length > 0;
306
326
  const columns: TeamsTableColumnDefinition[] = rawColumns.map(() => ({
307
327
  width: 1 as const,
@@ -349,6 +369,29 @@ function convertListViewItem(
349
369
  };
350
370
  }
351
371
 
372
+ /**
373
+ * Converts a child whose output is thrown away, and reports whether anything
374
+ * was lost with it. A scratch context keeps the ids it claims out of the card,
375
+ * where they would rename a control that survives; only its `dropped` warnings
376
+ * are forwarded, because those describe the tree the caller wrote, while a
377
+ * clamp or a rename would describe content never delivered.
378
+ */
379
+ export const discardedChild = (
380
+ child: NormalizedUINode,
381
+ context: ConversionContext,
382
+ depth: number,
383
+ ): boolean => {
384
+ const scratch: ConversionContext = {
385
+ ...context,
386
+ warnings: [],
387
+ usedInputIds: new Set(context.usedInputIds),
388
+ };
389
+ const produced = convertSequence(child, scratch, depth).length > 0;
390
+ const lost = scratch.warnings.filter((warning) => warning.code === "dropped");
391
+ context.warnings.push(...lost);
392
+ return produced || lost.length > 0;
393
+ };
394
+
352
395
  export function convertElement(
353
396
  element: NormalizedUIElement,
354
397
  context: ConversionContext,
@@ -539,7 +582,7 @@ export function convertElement(
539
582
  case "Row": {
540
583
  const children = normalizedList(element.children);
541
584
  if (children.length > 3) {
542
- warn(context, "clamped", "Row", "Teams recommends at most 3 columns.");
585
+ warn(context, "advisory", "Row", "Teams recommends at most 3 columns.");
543
586
  }
544
587
  const columns: TeamsColumn[] = children.map((child) => ({
545
588
  type: "Column",
@@ -550,13 +593,21 @@ export function convertElement(
550
593
  }
551
594
  case "ListView": {
552
595
  const containers: TeamsContainer[] = [];
596
+ let discarded = 0;
553
597
  for (const child of normalizedList(element.children)) {
554
- if (!isElement(child)) continue;
555
- if (child.type === "ListViewItem") {
598
+ if (isElement(child) && child.type === "ListViewItem") {
556
599
  containers.push(convertListViewItem(child, context, depth + 1));
557
- } else {
558
- convertElement(child, context, depth + 1);
600
+ continue;
559
601
  }
602
+ if (discardedChild(child, context, depth + 1)) discarded += 1;
603
+ }
604
+ if (discarded > 0) {
605
+ warn(
606
+ context,
607
+ "dropped",
608
+ "ListView",
609
+ `${discarded} non-item ${discarded === 1 ? "child was" : "children were"} dropped.`,
610
+ );
560
611
  }
561
612
  return containers.map((container, index) =>
562
613
  index > 0 ? { ...container, separator: true } : container,
@@ -586,10 +637,23 @@ export function convertElement(
586
637
  "Carousel",
587
638
  "A carousel was rendered as sequential cards because it is not at the root.",
588
639
  );
589
- const cards = normalizedList(element.children).filter(
590
- (child): child is NormalizedUIElement =>
591
- isElement(child) && child.type === "Card",
592
- );
640
+ const cards: NormalizedUIElement[] = [];
641
+ let droppedCards = 0;
642
+ for (const child of normalizedList(element.children)) {
643
+ if (isElement(child) && child.type === "Card") {
644
+ cards.push(child);
645
+ continue;
646
+ }
647
+ if (discardedChild(child, context, depth + 1)) droppedCards += 1;
648
+ }
649
+ if (droppedCards > 0) {
650
+ warn(
651
+ context,
652
+ "dropped",
653
+ "Carousel",
654
+ `${droppedCards} non-card ${droppedCards === 1 ? "child was" : "children were"} dropped.`,
655
+ );
656
+ }
593
657
  return cards.flatMap((card) => convertElement(card, context, depth + 1));
594
658
  }
595
659
  case "Chart":
@@ -716,9 +780,9 @@ export function convertRootToCard(
716
780
  if (size > PAYLOAD_SOFT_CAP) {
717
781
  warn(
718
782
  context,
719
- "clamped",
783
+ "advisory",
720
784
  "Root",
721
- `the card is ${size} bytes, exceeding Teams' 100 KB bot message limit.`,
785
+ `the card is ${size} bytes, over the ${PAYLOAD_SOFT_CAP}-byte soft budget kept below Teams' 100 KB bot message limit.`,
722
786
  );
723
787
  }
724
788
  return card;
@@ -726,7 +790,7 @@ export function convertRootToCard(
726
790
 
727
791
  /**
728
792
  * Converts a generative-ui tree into a Microsoft Teams Adaptive Card and
729
- * non-fatal downgrade warnings. Sizes, weights, and colors map to Adaptive
793
+ * non-fatal conversion warnings. Sizes, weights, and colors map to Adaptive
730
794
  * Card's semantic enums rather than raw values. An Input/Select/RadioGroup/
731
795
  * Checkbox/DatePicker whose id would be the reserved {@link RESERVED_INPUT_ID}
732
796
  * is renamed with a warning (see `decodeSubmitData`). Never throws: an
@@ -19,11 +19,56 @@ describe("toTeamsAttachments payload budget", () => {
19
19
  }
20
20
  expect(
21
21
  warnings.some(
22
- (warning) => warning.code === "clamped" && warning.component === "Root",
22
+ (warning) =>
23
+ warning.code === "advisory" && warning.component === "Root",
23
24
  ),
24
25
  ).toBe(false);
25
26
  expect(warnings).toContainEqual(
26
- expect.objectContaining({ code: "clamped", component: "Carousel" }),
27
+ expect.objectContaining({
28
+ code: "advisory",
29
+ component: "Carousel",
30
+ detail: expect.stringContaining(
31
+ `over the ${PAYLOAD_SOFT_CAP}-byte soft budget`,
32
+ ),
33
+ }),
27
34
  );
28
35
  });
29
36
  });
37
+
38
+ describe("toTeamsAttachments root carousel children", () => {
39
+ it("reports a renderable non-card child instead of dropping it silently", () => {
40
+ const { attachments, warnings } = toTeamsAttachments({
41
+ $type: "Carousel",
42
+ children: [
43
+ { $type: "Text", value: "lost" },
44
+ { $type: "Card", title: "kept" },
45
+ ],
46
+ });
47
+ expect(attachments).toHaveLength(1);
48
+ expect(warnings).toContainEqual({
49
+ code: "dropped",
50
+ component: "Carousel",
51
+ detail: "1 non-card child was dropped.",
52
+ });
53
+ });
54
+
55
+ it("forwards a discarded child's own dropped warning", () => {
56
+ const { warnings } = toTeamsAttachments({
57
+ $type: "Carousel",
58
+ children: [{ $type: "Mystery" }, { $type: "Card", title: "kept" }],
59
+ });
60
+ expect(warnings).toContainEqual({
61
+ code: "dropped",
62
+ component: "Mystery",
63
+ detail: "Unknown component type was dropped.",
64
+ });
65
+ });
66
+
67
+ it("stays silent for a child that renders nothing anyway", () => {
68
+ const { warnings } = toTeamsAttachments({
69
+ $type: "Carousel",
70
+ children: [{ $type: "Spacer" }, { $type: "Card", title: "kept" }],
71
+ });
72
+ expect(warnings).toEqual([]);
73
+ });
74
+ });
@@ -12,8 +12,8 @@ import {
12
12
  utf8ByteLength,
13
13
  } from "./constants";
14
14
  import {
15
- convertElement,
16
15
  convertRootToCard,
16
+ discardedChild,
17
17
  type ConversionContext,
18
18
  } from "./toAdaptiveCard";
19
19
  import type {
@@ -47,10 +47,9 @@ const clampArray = <T>(
47
47
 
48
48
  /**
49
49
  * Recognizes a root that is a single `Carousel` element, returning its `Card`
50
- * children. Every child is routed through {@link convertElement} first (its
51
- * result is discarded) so an unknown or unsupported child still produces its
52
- * usual "dropped" warning instead of being filtered out silently; non-card
53
- * children are then dropped. `undefined` means the root is not exclusively a
50
+ * children. A non-card child is routed through {@link discardedChild}, which
51
+ * reports it and forwards whatever it lost, rather than being filtered out
52
+ * silently. `undefined` means the root is not exclusively a
54
53
  * root-level carousel, so the caller falls back to converting the whole tree
55
54
  * as one card.
56
55
  */
@@ -61,13 +60,20 @@ function rootCarouselCards(
61
60
  const element = Array.isArray(root) && root.length === 1 ? root[0] : root;
62
61
  if (!isElement(element) || element.type !== "Carousel") return undefined;
63
62
  const cards: NormalizedUIElement[] = [];
63
+ let discarded = 0;
64
64
  for (const child of normalizedList(element.children)) {
65
- if (!isElement(child)) continue;
66
- if (child.type === "Card") {
65
+ if (isElement(child) && child.type === "Card") {
67
66
  cards.push(child);
68
- } else {
69
- convertElement(child, context, 1);
67
+ continue;
70
68
  }
69
+ if (discardedChild(child, context, 1)) discarded += 1;
70
+ }
71
+ if (discarded > 0) {
72
+ context.warnings.push({
73
+ code: "dropped",
74
+ component: "Carousel",
75
+ detail: `${discarded} non-card ${discarded === 1 ? "child was" : "children were"} dropped.`,
76
+ });
71
77
  }
72
78
  return cards;
73
79
  }
@@ -113,9 +119,9 @@ export function toTeamsAttachments(
113
119
  const size = utf8ByteLength(JSON.stringify(attachments));
114
120
  if (size > PAYLOAD_SOFT_CAP) {
115
121
  warnings.push({
116
- code: "clamped",
122
+ code: "advisory",
117
123
  component: "Carousel",
118
- detail: `the carousel attachments total ${size} bytes, exceeding Teams' 100 KB bot message limit.`,
124
+ detail: `the carousel attachments total ${size} bytes, over the ${PAYLOAD_SOFT_CAP}-byte soft budget kept below Teams' 100 KB bot message limit.`,
119
125
  });
120
126
  }
121
127
  return { attachments, attachmentLayout: "carousel", warnings };
@@ -213,9 +213,18 @@ export interface TeamsCardAttachment {
213
213
  readonly content: TeamsAdaptiveCard;
214
214
  }
215
215
 
216
- /** A non-fatal downgrade reported during Teams conversion. */
216
+ /**
217
+ * A non-fatal note reported during Teams conversion.
218
+ *
219
+ * `clamped` removed content to fit a limit, `dropped` discarded a node or one
220
+ * of its props and may leave a placeholder note behind, and `fallback`
221
+ * emitted the node in a different form than requested. `advisory` changed
222
+ * nothing at all and notes content Teams may still render poorly or refuse,
223
+ * such as a payload over the byte budget or a `Row` past the recommended
224
+ * column count.
225
+ */
217
226
  export interface TeamsConversionWarning {
218
- readonly code: "clamped" | "dropped" | "fallback";
227
+ readonly code: "clamped" | "dropped" | "fallback" | "advisory";
219
228
  readonly component: string;
220
229
  readonly detail: string;
221
230
  }
@@ -38,7 +38,22 @@ describe("layoutVocabulary", () => {
38
38
  background: "var(--muted)",
39
39
  }),
40
40
  ).toBe(
41
- '<section data-aui="card" data-aui-padding="2" data-aui-background="var(--muted)" style="background:var(--muted);color:white"><header data-aui="card-title">T</header></section>',
41
+ '<section data-aui="card" data-aui-padding="2" data-aui-background="var(--muted)" data-aui-surface="" style="background:var(--muted);color:white"><header data-aui="card-title">T</header></section>',
42
+ );
43
+ });
44
+
45
+ it("Card flags itself as a surface only when a background or a footer makes it one", () => {
46
+ expect(render({ $type: "Card", title: "Plain" })).not.toContain(
47
+ "data-aui-surface",
48
+ );
49
+ expect(render({ $type: "Card", background: "var(--muted)" })).toContain(
50
+ "data-aui-surface",
51
+ );
52
+ expect(render({ $type: "Card", confirm: { label: "Yes" } })).toContain(
53
+ "data-aui-surface",
54
+ );
55
+ expect(render({ $type: "Card", cancel: { label: "No" } })).toContain(
56
+ "data-aui-surface",
42
57
  );
43
58
  });
44
59
 
@@ -24,7 +24,7 @@ const cardFooterButtonSchema = z.object({
24
24
  export const layoutVocabulary = {
25
25
  Card: {
26
26
  description:
27
- "A bordered container grouping related content. Optionally titled. Set `asForm` to collect named child control values on submit, and `confirm`/`cancel` to add a footer with action buttons.",
27
+ "A titled section of related content, the default way to break a response into parts. It renders as plain content, so several in a row read as one answer rather than a stack of boxes; it takes on a framed surface only when `background` is set, when `confirm`/`cancel` add a footer, or when it is a slot in a `Carousel`. Set `asForm` to collect named child control values on submit.",
28
28
  properties: z.object({
29
29
  title: z.string().optional().describe("Optional card title."),
30
30
  padding: z
@@ -96,6 +96,9 @@ export const layoutVocabulary = {
96
96
  data-aui="card"
97
97
  data-aui-padding={padding}
98
98
  data-aui-background={background}
99
+ data-aui-surface={
100
+ background !== undefined || footer !== null ? "" : undefined
101
+ }
99
102
  style={
100
103
  background !== undefined
101
104
  ? { background, color: "white" }