@assistant-ui/react-generative-ui 0.0.6 → 0.0.8

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 (201) hide show
  1. package/dist/JSONGenerativeUI.client.d.ts +4 -2
  2. package/dist/JSONGenerativeUI.client.d.ts.map +1 -1
  3. package/dist/JSONGenerativeUI.client.js +9 -2
  4. package/dist/JSONGenerativeUI.client.js.map +1 -1
  5. package/dist/JSONGenerativeUI.server.d.ts +0 -1
  6. package/dist/JSONGenerativeUI.server.d.ts.map +1 -1
  7. package/dist/JSONGenerativeUI.shared.d.ts +8 -1
  8. package/dist/JSONGenerativeUI.shared.d.ts.map +1 -1
  9. package/dist/JSONGenerativeUI.shared.js.map +1 -1
  10. package/dist/actionRegistry.d.ts +32 -0
  11. package/dist/actionRegistry.d.ts.map +1 -0
  12. package/dist/actionRegistry.js +41 -0
  13. package/dist/actionRegistry.js.map +1 -0
  14. package/dist/buildPresentParameters.d.ts +5 -5
  15. package/dist/buildPresentParameters.d.ts.map +1 -1
  16. package/dist/buildPresentParameters.js +20 -6
  17. package/dist/buildPresentParameters.js.map +1 -1
  18. package/dist/constants.d.ts.map +1 -1
  19. package/dist/defineGenerativeComponents.d.ts +0 -1
  20. package/dist/defineGenerativeComponents.d.ts.map +1 -1
  21. package/dist/generativeUIToJSX.d.ts +15 -10
  22. package/dist/generativeUIToJSX.d.ts.map +1 -1
  23. package/dist/generativeUIToJSX.js +47 -17
  24. package/dist/generativeUIToJSX.js.map +1 -1
  25. package/dist/index.d.ts +8 -4
  26. package/dist/index.js +5 -2
  27. package/dist/index.server.d.ts +13 -0
  28. package/dist/index.server.js +10 -0
  29. package/dist/index.shared.d.ts +11 -0
  30. package/dist/index.shared.js +9 -0
  31. package/dist/ir.d.ts +115 -0
  32. package/dist/ir.d.ts.map +1 -0
  33. package/dist/ir.js +175 -0
  34. package/dist/ir.js.map +1 -0
  35. package/dist/node_modules/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts +3 -3
  36. package/dist/node_modules/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts.map +1 -1
  37. package/dist/renderGenerativeUI.d.ts +5 -5
  38. package/dist/renderGenerativeUI.d.ts.map +1 -1
  39. package/dist/renderGenerativeUI.js +18 -45
  40. package/dist/renderGenerativeUI.js.map +1 -1
  41. package/dist/slack/constants.d.ts +111 -0
  42. package/dist/slack/constants.d.ts.map +1 -0
  43. package/dist/slack/constants.js +168 -0
  44. package/dist/slack/constants.js.map +1 -0
  45. package/dist/slack/decodeBlockAction.d.ts +11 -0
  46. package/dist/slack/decodeBlockAction.d.ts.map +1 -0
  47. package/dist/slack/decodeBlockAction.js +40 -0
  48. package/dist/slack/decodeBlockAction.js.map +1 -0
  49. package/dist/slack/fromSlackBlocks.d.ts +30 -0
  50. package/dist/slack/fromSlackBlocks.d.ts.map +1 -0
  51. package/dist/slack/fromSlackBlocks.js +447 -0
  52. package/dist/slack/fromSlackBlocks.js.map +1 -0
  53. package/dist/slack/toSlackBlocks.d.ts +7 -0
  54. package/dist/slack/toSlackBlocks.d.ts.map +1 -0
  55. package/dist/slack/toSlackBlocks.js +649 -0
  56. package/dist/slack/toSlackBlocks.js.map +1 -0
  57. package/dist/slack/types.d.ts +178 -0
  58. package/dist/slack/types.d.ts.map +1 -0
  59. package/dist/slack/types.js +0 -0
  60. package/dist/slack.d.ts +5 -0
  61. package/dist/slack.js +4 -0
  62. package/dist/teams/boundSpec.d.ts +20 -0
  63. package/dist/teams/boundSpec.d.ts.map +1 -0
  64. package/dist/teams/boundSpec.js +67 -0
  65. package/dist/teams/boundSpec.js.map +1 -0
  66. package/dist/teams/constants.d.ts +57 -0
  67. package/dist/teams/constants.d.ts.map +1 -0
  68. package/dist/teams/constants.js +92 -0
  69. package/dist/teams/constants.js.map +1 -0
  70. package/dist/teams/decodeSubmitData.d.ts +25 -0
  71. package/dist/teams/decodeSubmitData.d.ts.map +1 -0
  72. package/dist/teams/decodeSubmitData.js +44 -0
  73. package/dist/teams/decodeSubmitData.js.map +1 -0
  74. package/dist/teams/toAdaptiveCard.d.ts +39 -0
  75. package/dist/teams/toAdaptiveCard.d.ts.map +1 -0
  76. package/dist/teams/toAdaptiveCard.js +496 -0
  77. package/dist/teams/toAdaptiveCard.js.map +1 -0
  78. package/dist/teams/toTeamsAttachments.d.ts +13 -0
  79. package/dist/teams/toTeamsAttachments.d.ts.map +1 -0
  80. package/dist/teams/toTeamsAttachments.js +95 -0
  81. package/dist/teams/toTeamsAttachments.js.map +1 -0
  82. package/dist/teams/types.d.ts +196 -0
  83. package/dist/teams/types.d.ts.map +1 -0
  84. package/dist/teams/types.js +0 -0
  85. package/dist/teams.d.ts +5 -0
  86. package/dist/teams.js +4 -0
  87. package/dist/types.d.ts +41 -34
  88. package/dist/types.d.ts.map +1 -1
  89. package/dist/vocabulary/alert.d.ts +28 -0
  90. package/dist/vocabulary/alert.d.ts.map +1 -0
  91. package/dist/vocabulary/alert.js +58 -0
  92. package/dist/vocabulary/alert.js.map +1 -0
  93. package/dist/vocabulary/collectFormValues.d.ts +23 -0
  94. package/dist/vocabulary/collectFormValues.d.ts.map +1 -0
  95. package/dist/vocabulary/collectFormValues.js +32 -0
  96. package/dist/vocabulary/collectFormValues.js.map +1 -0
  97. package/dist/vocabulary/data.d.ts +52 -0
  98. package/dist/vocabulary/data.d.ts.map +1 -0
  99. package/dist/vocabulary/data.js +294 -0
  100. package/dist/vocabulary/data.js.map +1 -0
  101. package/dist/vocabulary/dispatch.d.ts +11 -0
  102. package/dist/vocabulary/dispatch.d.ts.map +1 -0
  103. package/dist/vocabulary/dispatch.js +21 -0
  104. package/dist/vocabulary/dispatch.js.map +1 -0
  105. package/dist/vocabulary/fact.d.ts +15 -0
  106. package/dist/vocabulary/fact.d.ts.map +1 -0
  107. package/dist/vocabulary/fact.js +24 -0
  108. package/dist/vocabulary/fact.js.map +1 -0
  109. package/dist/vocabulary/form.d.ts +14 -0
  110. package/dist/vocabulary/form.d.ts.map +1 -0
  111. package/dist/vocabulary/form.js +23 -0
  112. package/dist/vocabulary/form.js.map +1 -0
  113. package/dist/vocabulary/icon.d.ts +44 -0
  114. package/dist/vocabulary/icon.d.ts.map +1 -0
  115. package/dist/vocabulary/icon.js +272 -0
  116. package/dist/vocabulary/icon.js.map +1 -0
  117. package/dist/vocabulary/index.d.ts +15 -0
  118. package/dist/vocabulary/index.d.ts.map +1 -0
  119. package/dist/vocabulary/index.js +36 -0
  120. package/dist/vocabulary/index.js.map +1 -0
  121. package/dist/vocabulary/interactive.d.ts +93 -0
  122. package/dist/vocabulary/interactive.d.ts.map +1 -0
  123. package/dist/vocabulary/interactive.js +172 -0
  124. package/dist/vocabulary/interactive.js.map +1 -0
  125. package/dist/vocabulary/layout.d.ts +82 -0
  126. package/dist/vocabulary/layout.d.ts.map +1 -0
  127. package/dist/vocabulary/layout.js +136 -0
  128. package/dist/vocabulary/layout.js.map +1 -0
  129. package/dist/vocabulary/list.d.ts +17 -0
  130. package/dist/vocabulary/list.d.ts.map +1 -0
  131. package/dist/vocabulary/list.js +48 -0
  132. package/dist/vocabulary/list.js.map +1 -0
  133. package/dist/vocabulary/media.d.ts +28 -0
  134. package/dist/vocabulary/media.d.ts.map +1 -0
  135. package/dist/vocabulary/media.js +42 -0
  136. package/dist/vocabulary/media.js.map +1 -0
  137. package/dist/vocabulary/text.d.ts +60 -0
  138. package/dist/vocabulary/text.d.ts.map +1 -0
  139. package/dist/vocabulary/text.js +48 -0
  140. package/dist/vocabulary/text.js.map +1 -0
  141. package/package.json +21 -13
  142. package/src/JSONGenerativeUI.client.tsx +10 -2
  143. package/src/JSONGenerativeUI.shared.ts +8 -0
  144. package/src/actionRegistry.test.ts +90 -0
  145. package/src/actionRegistry.ts +88 -0
  146. package/src/buildPresentParameters.ts +38 -16
  147. package/src/generativeUIToJSX.test.ts +186 -0
  148. package/src/generativeUIToJSX.ts +112 -21
  149. package/src/index.server.ts +2 -0
  150. package/src/index.shared.ts +61 -0
  151. package/src/index.ts +2 -26
  152. package/src/ir.test.ts +203 -0
  153. package/src/ir.ts +279 -0
  154. package/src/renderGenerativeUI.test.tsx +69 -2
  155. package/src/renderGenerativeUI.tsx +49 -82
  156. package/src/slack/constants.ts +217 -0
  157. package/src/slack/decodeBlockAction.test.ts +135 -0
  158. package/src/slack/decodeBlockAction.ts +66 -0
  159. package/src/slack/fromSlackBlocks.test.ts +1145 -0
  160. package/src/slack/fromSlackBlocks.ts +709 -0
  161. package/src/slack/toSlackBlocks.test.ts +1936 -0
  162. package/src/slack/toSlackBlocks.ts +1319 -0
  163. package/src/slack/types.ts +230 -0
  164. package/src/slack.ts +37 -0
  165. package/src/teams/boundSpec.ts +102 -0
  166. package/src/teams/constants.ts +127 -0
  167. package/src/teams/decodeSubmitData.test.ts +148 -0
  168. package/src/teams/decodeSubmitData.ts +57 -0
  169. package/src/teams/toAdaptiveCard.test.ts +1410 -0
  170. package/src/teams/toAdaptiveCard.ts +762 -0
  171. package/src/teams/toTeamsAttachments.test.ts +29 -0
  172. package/src/teams/toTeamsAttachments.ts +137 -0
  173. package/src/teams/types.ts +237 -0
  174. package/src/teams.ts +34 -0
  175. package/src/types.ts +57 -42
  176. package/src/vocabulary/alert.test.tsx +136 -0
  177. package/src/vocabulary/alert.tsx +66 -0
  178. package/src/vocabulary/collectFormValues.test.ts +127 -0
  179. package/src/vocabulary/collectFormValues.ts +58 -0
  180. package/src/vocabulary/data.test.tsx +567 -0
  181. package/src/vocabulary/data.tsx +494 -0
  182. package/src/vocabulary/dispatch.ts +22 -0
  183. package/src/vocabulary/fact.test.tsx +15 -0
  184. package/src/vocabulary/fact.tsx +22 -0
  185. package/src/vocabulary/form.test.tsx +112 -0
  186. package/src/vocabulary/form.tsx +34 -0
  187. package/src/vocabulary/icon.test.tsx +64 -0
  188. package/src/vocabulary/icon.tsx +174 -0
  189. package/src/vocabulary/index.test.ts +63 -0
  190. package/src/vocabulary/index.ts +33 -0
  191. package/src/vocabulary/interactive.dispatch.test.tsx +352 -0
  192. package/src/vocabulary/interactive.test.tsx +248 -0
  193. package/src/vocabulary/interactive.tsx +269 -0
  194. package/src/vocabulary/layout.test.tsx +289 -0
  195. package/src/vocabulary/layout.tsx +229 -0
  196. package/src/vocabulary/list.test.tsx +195 -0
  197. package/src/vocabulary/list.tsx +48 -0
  198. package/src/vocabulary/media.test.tsx +85 -0
  199. package/src/vocabulary/media.tsx +56 -0
  200. package/src/vocabulary/text.test.tsx +73 -0
  201. package/src/vocabulary/text.tsx +59 -0
package/src/ir.ts ADDED
@@ -0,0 +1,279 @@
1
+ /**
2
+ * The generative-ui intermediate representation: a model-emitted, cross-platform
3
+ * UI tree. React-free, so converters and non-web runtimes consume it without
4
+ * pulling React. The flat `$type` shape is the canonical form; the legacy
5
+ * `component` shape is accepted as a backward-compatible alias.
6
+ *
7
+ * Reserved keys are partitioned off from component props so the component prop
8
+ * namespace stays fully free:
9
+ *
10
+ * - `$`-prefixed keys are framework-reserved (`$type`, `$key`, `$action`,
11
+ * `$status`). Components never declare `$`-prefixed props, so a component
12
+ * can use `type`, `status`, `variant`, etc. as ordinary props without
13
+ * colliding with the framework.
14
+ * - `children` is additionally reserved (the JSX convention).
15
+ * - every other key is an inline prop passed straight to the component.
16
+ */
17
+
18
+ import { TYPE_KEY } from "./constants";
19
+
20
+ export const TEXT_SIZES = ["sm", "md", "lg", "xl", "2xl", "3xl"] as const;
21
+ export type TextSize = (typeof TEXT_SIZES)[number];
22
+
23
+ export const IMAGE_SIZE_TOKENS = ["sm", "md", "lg"] as const;
24
+ export type ImageSize = (typeof IMAGE_SIZE_TOKENS)[number] | number;
25
+
26
+ export const WEIGHTS = ["normal", "medium", "semibold", "bold"] as const;
27
+ export type Weight = (typeof WEIGHTS)[number];
28
+
29
+ export const COLORS = [
30
+ "emphasis",
31
+ "secondary",
32
+ "alpha-70",
33
+ "white",
34
+ "white-70",
35
+ "white-50",
36
+ ] as const;
37
+ export type Color = (typeof COLORS)[number];
38
+
39
+ export const ALIGNS = ["start", "center", "end"] as const;
40
+ export type Align = (typeof ALIGNS)[number];
41
+
42
+ export const JUSTIFIES = ["start", "center", "end", "between"] as const;
43
+ export type Justify = (typeof JUSTIFIES)[number];
44
+
45
+ export const BUTTON_STYLES = [
46
+ "primary",
47
+ "secondary",
48
+ "outline",
49
+ "ghost",
50
+ "danger",
51
+ ] as const;
52
+ export type ButtonStyle = (typeof BUTTON_STYLES)[number];
53
+
54
+ export const ALERT_TONES = ["info", "success", "warning", "danger"] as const;
55
+ /** A severity level, from informational to destructive. */
56
+ export type AlertTone = (typeof ALERT_TONES)[number];
57
+
58
+ export const ICON_NAMES = [
59
+ "sun",
60
+ "moon",
61
+ "cloud",
62
+ "rain",
63
+ "snow",
64
+ "wind",
65
+ "play",
66
+ "pause",
67
+ "check",
68
+ "x",
69
+ "star",
70
+ "heart",
71
+ "arrow-right",
72
+ "arrow-up-right",
73
+ "chevron-right",
74
+ "calendar",
75
+ "clock",
76
+ "map-pin",
77
+ "plane",
78
+ "truck",
79
+ "credit-card",
80
+ "user",
81
+ "search",
82
+ "bell",
83
+ ] as const;
84
+ /** A name from the built-in icon set; the closed enum lets the model see exactly which icons exist. */
85
+ export type IconName = (typeof ICON_NAMES)[number];
86
+
87
+ /**
88
+ * Behavior payload carried by an interactive node. `type` is resolved by the host's action registry, not the renderer; keeping behavior as data keeps the tree serializable, so the same node renders on web while converters may bind the type to a native action id on other platforms.
89
+ */
90
+ export interface Action {
91
+ readonly type: string;
92
+ readonly [payload: string]: unknown;
93
+ }
94
+
95
+ /**
96
+ * Anything renderable as generative UI, as the model emits it. The renderer
97
+ * also accepts `number`, `boolean`, `null`, `undefined`, and arrays at the
98
+ * input boundary (numbers render as text, falsy/boolean as nothing, arrays as
99
+ * lists); {@link normalizeUINode} accepts that full range.
100
+ */
101
+ export type UINode = string | number | UIElement | LegacyComponentNode;
102
+
103
+ export type UIChildren = UINode | readonly UINode[];
104
+
105
+ /** The flat node shape: inline props keep the tree compact and natural for a
106
+ * model to emit, instead of a nested `{ type, props }` bag. Reserved keys are
107
+ * stripped before props reach the component (see the module header). */
108
+ export interface UIElement {
109
+ readonly $type: string;
110
+ readonly $key?: string | number;
111
+ readonly children?: UIChildren;
112
+ readonly $action?: Action;
113
+ readonly [prop: string]: unknown;
114
+ }
115
+
116
+ /**
117
+ * The legacy node shape: a `component` name plus a nested `props` bag. Kept
118
+ * for backward compatibility. New code authors the flat {@link UIElement}
119
+ * shape instead.
120
+ */
121
+ export interface LegacyComponentNode {
122
+ readonly component: string;
123
+ readonly props?: Record<string, unknown>;
124
+ readonly children?: UIChildren;
125
+ readonly key?: string;
126
+ }
127
+
128
+ export type UISpec = UINode | readonly UINode[];
129
+
130
+ /**
131
+ * A node normalized to a single canonical shape: a `type` string, an inline
132
+ * `props` bag, recursive `children`, an optional `key`, and an optional
133
+ * `action`. Renderers and platform converters consume this form, so they never
134
+ * branch on whether the model emitted the flat `$type` shape or the legacy
135
+ * `component` shape, and they never see the reserved `$`-prefixed keys leak
136
+ * into component props.
137
+ */
138
+ export interface NormalizedUIElement {
139
+ readonly type: string;
140
+ readonly props: Readonly<Record<string, unknown>>;
141
+ readonly children?: NormalizedUINode | undefined;
142
+ readonly key?: string | number | undefined;
143
+ readonly action?: Action | undefined;
144
+ }
145
+
146
+ export type NormalizedUINode =
147
+ | string
148
+ | number
149
+ | readonly NormalizedUINode[]
150
+ | NormalizedUIElement
151
+ | null;
152
+
153
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
154
+ typeof value === "object" && value !== null && !Array.isArray(value);
155
+
156
+ type LegacyNodeRecord = { component: string } & Record<string, unknown>;
157
+
158
+ const isLegacyNode = (
159
+ node: Record<string, unknown>,
160
+ ): node is LegacyNodeRecord => typeof node["component"] === "string";
161
+
162
+ const isTypeNode = (node: Record<string, unknown>): node is UIElement =>
163
+ typeof node[TYPE_KEY] === "string";
164
+
165
+ /** Bounds recursion so a runaway or adversarial model response cannot overflow
166
+ * the stack; past this depth (far beyond any real UI) we stop. */
167
+ const MAX_DEPTH = 64;
168
+
169
+ /**
170
+ * Steps a streaming partial path down into `key`. `partialPath` is the remaining
171
+ * segment of the parse meta's partial path relative to `node` (`undefined` once
172
+ * the walk leaves the partial frontier, i.e. everything below is complete).
173
+ */
174
+ function descend(
175
+ partialPath: readonly string[] | undefined,
176
+ key: string,
177
+ ): readonly string[] | undefined {
178
+ return partialPath?.[0] === key ? partialPath.slice(1) : undefined;
179
+ }
180
+
181
+ /**
182
+ * Normalizes a generative-ui input to {@link NormalizedUINode}. The flat
183
+ * `$type` shape and the legacy `component` shape both map to the same canonical
184
+ * element, with reserved keys (`$type`, `$key`, `$action`, `children`) stripped
185
+ * from the prop bag. A node that carries neither a `$type` nor a `component`
186
+ * string is not renderable and resolves to `null` rather than throwing, so a
187
+ * partially-streamed or malformed node degrades to "render nothing".
188
+ *
189
+ * `partialPath` carries streaming state from the tool-args parse meta: a node
190
+ * whose `$type` is still mid-arrival is held back (resolves to `null`) until it
191
+ * completes, and the path is threaded into `children` so a nested streaming
192
+ * node is held back while completed siblings render. Omit it for a
193
+ * non-streaming (converter) normalize.
194
+ */
195
+ export function normalizeUINode(
196
+ node: unknown,
197
+ partialPath?: readonly string[] | undefined,
198
+ depth = 0,
199
+ ): NormalizedUINode {
200
+ if (depth > MAX_DEPTH) return null;
201
+ if (node == null || typeof node === "boolean") return null;
202
+ if (typeof node === "string" || typeof node === "number") return node;
203
+ if (Array.isArray(node))
204
+ return node.map((child, index) =>
205
+ normalizeUINode(child, descend(partialPath, String(index)), depth + 1),
206
+ );
207
+ if (!isRecord(node)) return null;
208
+
209
+ // The flat `$type` shape is the canonical form; detect it first so a flat
210
+ // node that happens to use `component` as an ordinary prop is not swallowed
211
+ // by the legacy `component`-shape branch.
212
+ if (isTypeNode(node)) {
213
+ if (partialPath?.length === 1 && partialPath[0] === TYPE_KEY) return null;
214
+ const { [TYPE_KEY]: type, $key, $action, children, ...rest } = node;
215
+ // The `$`-prefixed namespace is framework-reserved (see the module header),
216
+ // so any model-supplied `$`-prefixed key is stripped from the prop bag the
217
+ // component sees. `$type`/`$key`/`$action` are pulled above; sweep the rest
218
+ // (e.g. a stray `$status`) so it never leaks to converters or components.
219
+ const props = stripReservedProps(rest);
220
+ return {
221
+ type,
222
+ props,
223
+ children: normalizeChildren(children, partialPath, depth),
224
+ key: $key,
225
+ action: $action,
226
+ };
227
+ }
228
+
229
+ if (isLegacyNode(node)) {
230
+ const props = stripReservedProps(
231
+ (node.props ?? {}) as Record<string, unknown>,
232
+ );
233
+ return {
234
+ type: node.component,
235
+ props,
236
+ children: normalizeChildren(node.children, partialPath, depth),
237
+ key: node.key as string | undefined,
238
+ };
239
+ }
240
+
241
+ return null;
242
+ }
243
+
244
+ function stripReservedProps(
245
+ props: Record<string, unknown>,
246
+ ): Record<string, unknown> {
247
+ let out: Record<string, unknown> | undefined;
248
+ for (const key of Object.keys(props)) {
249
+ if (key.startsWith("$")) {
250
+ out ??= { ...props };
251
+ delete out[key];
252
+ }
253
+ }
254
+ return out ?? props;
255
+ }
256
+
257
+ function normalizeChildren(
258
+ children: unknown,
259
+ partialPath: readonly string[] | undefined,
260
+ depth: number,
261
+ ): NormalizedUINode | undefined {
262
+ if (children === undefined) return undefined;
263
+ return normalizeUINode(children, descend(partialPath, "children"), depth + 1);
264
+ }
265
+
266
+ /**
267
+ * Normalizes the root of a {@link UISpec}, preserving whether the root was a
268
+ * single node or a list.
269
+ */
270
+ export function normalizeSpec(spec: UISpec): {
271
+ readonly root: NormalizedUINode | readonly NormalizedUINode[];
272
+ } {
273
+ if (Array.isArray(spec)) {
274
+ return {
275
+ root: (spec as readonly UINode[]).map((node) => normalizeUINode(node)),
276
+ };
277
+ }
278
+ return { root: normalizeUINode(spec) };
279
+ }
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect, vi } from "vitest";
2
2
  import { renderToStaticMarkup } from "react-dom/server";
3
+ import { isValidElement, type ReactElement } from "react";
3
4
  import { parsePartialJsonObject } from "assistant-stream/utils";
4
5
  import { z } from "zod";
5
6
  import { renderGenerativeUI } from "./renderGenerativeUI";
@@ -65,6 +66,28 @@ describe("renderGenerativeUI", () => {
65
66
  );
66
67
  });
67
68
 
69
+ it("uses stable $key for array items and keeps the positional fallback", () => {
70
+ const out = renderGenerativeUI(
71
+ [
72
+ { $type: "Text", $key: "1:Text", children: "first" },
73
+ { $type: "Text", children: "second" },
74
+ "plain",
75
+ { $type: "Text", $key: { id: "bad" }, children: "bad key" },
76
+ ],
77
+ library,
78
+ );
79
+
80
+ expect(Array.isArray(out)).toBe(true);
81
+ const elements = out as ReactElement[];
82
+ expect(elements.every(isValidElement)).toBe(true);
83
+ expect(elements.map((element) => element.key)).toEqual([
84
+ "model:1:Text",
85
+ "1:Text",
86
+ "2:#text",
87
+ "3:Text",
88
+ ]);
89
+ });
90
+
68
91
  it("passes a component's own `type` prop through without collision", () => {
69
92
  const html = renderToStaticMarkup(
70
93
  <>
@@ -189,6 +212,11 @@ describe("buildPresentParameters", () => {
189
212
  ]);
190
213
  // each component's description rides along on the $type enum.
191
214
  expect(schema.properties.$type.description).toContain("Card");
215
+ expect(schema.properties.$key).toEqual({
216
+ description:
217
+ "Stable identity for this UI node. Use it for list items that may reorder.",
218
+ anyOf: [{ type: "string" }, { type: "number" }],
219
+ });
192
220
  expect(schema.properties.children.$ref).toBe("#/$defs/children");
193
221
 
194
222
  // every component's props are merged into the one flat property bag.
@@ -205,12 +233,14 @@ describe("buildPresentParameters", () => {
205
233
  expect(schema.$defs.node.oneOf).toBeUndefined();
206
234
  });
207
235
 
208
- it("drops author-declared `$type`/`children` and keeps the discriminator", () => {
236
+ it("drops author-declared `$`-prefixed and `children` props, keeping framework fields", () => {
209
237
  const schema = buildPresentParameters({
210
238
  Reserved: {
211
239
  description: "Declares reserved keys that must not leak through.",
212
240
  properties: z.object({
213
241
  $type: z.number(),
242
+ $key: z.boolean(),
243
+ $action: z.string(),
214
244
  children: z.number(),
215
245
  label: z.string(),
216
246
  }),
@@ -219,13 +249,50 @@ describe("buildPresentParameters", () => {
219
249
  }) as any;
220
250
 
221
251
  // The discriminator is the framework enum, not the author's `$type`; the
222
- // author's `children` is dropped (the root `children` $ref owns that slot).
252
+ // author's `$`-prefixed props and `children` are dropped in favor of the
253
+ // framework fields.
223
254
  expect(schema.properties.$type.enum).toEqual(["Reserved"]);
255
+ expect(schema.properties.$key).toEqual({
256
+ description:
257
+ "Stable identity for this UI node. Use it for list items that may reorder.",
258
+ anyOf: [{ type: "string" }, { type: "number" }],
259
+ });
260
+ expect(schema.properties.$action).toBeUndefined();
224
261
  expect(schema.properties.children.$ref).toBe("#/$defs/children");
225
262
  expect(schema.properties.label).toBeDefined();
226
263
  expect(schema.required).toEqual(["$type"]);
227
264
  });
228
265
 
266
+ it("names every component that declares the same prop in the dev warning", () => {
267
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
268
+ try {
269
+ buildPresentParameters({
270
+ Select: {
271
+ description: "Selects an option.",
272
+ properties: z.object({ value: z.string() }),
273
+ render: () => null,
274
+ },
275
+ DatePicker: {
276
+ description: "Picks a date.",
277
+ properties: z.object({ value: z.string() }),
278
+ render: () => null,
279
+ },
280
+ Combobox: {
281
+ description: "Chooses from filtered options.",
282
+ properties: z.object({ value: z.string() }),
283
+ render: () => null,
284
+ },
285
+ });
286
+
287
+ expect(warn).toHaveBeenCalledTimes(1);
288
+ expect(warn).toHaveBeenCalledWith(
289
+ '[@assistant-ui/react-generative-ui] Prop "value" is declared by "Select", "DatePicker", and "Combobox"; keeping "Select"\'s schema. Rename or align the prop type to avoid an ambiguous schema.',
290
+ );
291
+ } finally {
292
+ warn.mockRestore();
293
+ }
294
+ });
295
+
229
296
  it("throws when a component's properties is not an object schema", () => {
230
297
  expect(() =>
231
298
  buildPresentParameters({
@@ -1,23 +1,26 @@
1
1
  import { getPartialJsonObjectMeta } from "assistant-stream/utils";
2
2
  import { Fragment, type ReactNode } from "react";
3
- import { TYPE_KEY } from "./constants";
4
- import type {
5
- GenerativeUIElement,
6
- GenerativeUILibrary,
7
- GenerativeUINode,
8
- GenerativeUIRenderContext,
9
- } from "./types";
3
+ import {
4
+ normalizeUINode,
5
+ type NormalizedUIElement,
6
+ type NormalizedUINode,
7
+ } from "./ir";
8
+ import type { GenerativeUILibrary, GenerativeUIRenderContext } from "./types";
10
9
 
11
10
  const DEFAULT_CONTEXT: GenerativeUIRenderContext = { status: "done" };
12
11
 
12
+ const isElement = (node: NormalizedUINode): node is NormalizedUIElement =>
13
+ typeof node === "object" && node !== null && !Array.isArray(node);
14
+
13
15
  /**
14
16
  * Renders a generative-ui tree against a {@link GenerativeUILibrary}.
15
17
  *
16
18
  * The model emits each node as a flat object `{ $type, ...props }`. We first
17
- * normalize that wire form into React-shaped elements (`{ type, props }`), then
18
- * render: each `type` is looked up in the library and its `props` are passed
19
- * to the component's `render(props, context)`, with `children` rendered
20
- * recursively so components can nest.
19
+ * normalize that wire form into the canonical {@link NormalizedUINode} (with
20
+ * `children` lifted to a reserved top-level key), then render: each `type` is
21
+ * looked up in the library and its `props` are passed to the component's
22
+ * `render(props, context)`, with `children` rendered recursively so components
23
+ * can nest.
21
24
  */
22
25
  export function renderGenerativeUI(
23
26
  node: unknown,
@@ -29,87 +32,32 @@ export function renderGenerativeUI(
29
32
  // string has not finished streaming.
30
33
  const meta = getPartialJsonObjectMeta(node as Record<symbol, unknown>);
31
34
  const partialPath = meta?.state === "partial" ? meta.partialPath : undefined;
32
- return renderNode(normalizeNode(node, partialPath), library, context);
33
- }
34
-
35
- /**
36
- * The deepest tree we normalize. The input comes from the model, so a runaway
37
- * or adversarial response could nest arbitrarily deep and overflow the stack;
38
- * past this depth we stop (far beyond any real UI). Bounding normalization
39
- * bounds rendering too, since it only walks the normalized tree.
40
- */
41
- const MAX_DEPTH = 64;
42
-
43
- /**
44
- * Converts the flat wire form into a normalized {@link GenerativeUINode}.
45
- *
46
- * `partialPath` is the remaining segment of the parse meta's partial path
47
- * relative to `node` (`undefined` once the walk leaves the partial frontier,
48
- * i.e. everything below is complete).
49
- */
50
- function normalizeNode(
51
- node: unknown,
52
- partialPath: readonly string[] | undefined,
53
- depth = 0,
54
- ): GenerativeUINode {
55
- if (depth > MAX_DEPTH) return null;
56
- if (node == null || typeof node === "boolean") return null;
57
- if (typeof node === "string" || typeof node === "number") return node;
58
- if (Array.isArray(node))
59
- return node.map((child, index) =>
60
- normalizeNode(child, descend(partialPath, String(index)), depth + 1),
61
- );
62
- if (typeof node !== "object") return null;
63
-
64
- const { [TYPE_KEY]: type, ...props } = node as Record<string, unknown>;
65
- // Args stream in incrementally; a node whose `$type` has not arrived yet
66
- // (or whose `$type` string is still mid-arrival) is not an error, it just
67
- // isn't renderable.
68
- if (typeof type !== "string") return null;
69
- if (partialPath?.length === 1 && partialPath[0] === TYPE_KEY) return null;
70
-
71
- if ("children" in props) {
72
- props["children"] = normalizeNode(
73
- props["children"],
74
- descend(partialPath, "children"),
75
- depth + 1,
76
- );
77
- }
78
- return { type, props } as GenerativeUIElement;
79
- }
80
-
81
- /** Steps the partial path down into `key`; siblings of the path are complete. */
82
- function descend(
83
- partialPath: readonly string[] | undefined,
84
- key: string,
85
- ): readonly string[] | undefined {
86
- return partialPath?.[0] === key ? partialPath.slice(1) : undefined;
35
+ return renderNode(normalizeUINode(node, partialPath), library, context);
87
36
  }
88
37
 
89
38
  function renderNode(
90
- node: GenerativeUINode,
39
+ node: NormalizedUINode,
91
40
  library: GenerativeUILibrary,
92
41
  context: GenerativeUIRenderContext,
93
42
  ): ReactNode {
94
43
  if (node == null || typeof node === "boolean") return null;
95
44
  if (typeof node === "string" || typeof node === "number") return node;
96
45
  if (Array.isArray(node)) {
97
- // The wire format has no per-node id, so the key is positional. Pairing the
98
- // index with the node's kind means that when the model splices or reorders
99
- // `children` and the kind at an index changes, the key changes and React
100
- // remounts instead of handing a streaming node's hook state to a different
101
- // component.
46
+ // Use a model-provided stable key when present. Otherwise, keep the
47
+ // positional fallback: pairing the index with the node's kind means that
48
+ // when the kind at an index changes, React remounts instead of handing a
49
+ // streaming node's hook state to a different component.
102
50
  return node.map((child, index) => (
103
- <Fragment key={`${index}:${nodeKind(child)}`}>
51
+ <Fragment key={nodeKey(child, index)}>
104
52
  {renderNode(child, library, context)}
105
53
  </Fragment>
106
54
  ));
107
55
  }
108
- return renderElement(node, library, context);
56
+ return isElement(node) ? renderElement(node, library, context) : null;
109
57
  }
110
58
 
111
59
  function renderElement(
112
- element: GenerativeUIElement,
60
+ element: NormalizedUIElement,
113
61
  library: GenerativeUILibrary,
114
62
  context: GenerativeUIRenderContext,
115
63
  ): ReactNode {
@@ -123,11 +71,23 @@ function renderElement(
123
71
  // complete rather than rendering from a partial parse.
124
72
  if (!entry.streamProperties && context.status === "streaming") return null;
125
73
 
126
- // Inject the framework props last so the model can never override them.
127
- const { children, ...rest } = element.props;
128
- const props: Record<string, unknown> = { ...rest, $status: context.status };
129
- if (children !== undefined) {
130
- props["children"] = renderNode(children, library, context);
74
+ // `children` is a reserved top-level key on the normalized element, not a
75
+ // prop, so it is not in `props`. Inject the framework props last so the model
76
+ // can never override them. `$action` is likewise reserved and stripped from
77
+ // the prop bag during normalization, so it is re-injected here for components
78
+ // that carry behavior (e.g. `Button`).
79
+ const props: Record<string, unknown> = {
80
+ ...element.props,
81
+ $status: context.status,
82
+ };
83
+ if (context.dispatch !== undefined) {
84
+ props["$dispatch"] = context.dispatch;
85
+ }
86
+ if (element.action !== undefined) {
87
+ props["$action"] = element.action;
88
+ }
89
+ if (element.children !== undefined) {
90
+ props["children"] = renderNode(element.children, library, context);
131
91
  }
132
92
 
133
93
  return <GenerativeUIComponentRenderer render={entry.render} props={props} />;
@@ -149,11 +109,18 @@ function GenerativeUIComponentRenderer({
149
109
 
150
110
  /** A coarse kind tag for a child, used in its list key so a node changing kind
151
111
  * at a given index forces a remount rather than a wrong-fiber reuse. */
152
- function nodeKind(node: GenerativeUINode): string {
112
+ function nodeKind(node: NormalizedUINode): string {
153
113
  if (node == null || typeof node === "boolean") return "";
154
114
  if (typeof node === "string" || typeof node === "number") return "#text";
155
115
  if (Array.isArray(node)) return "#array";
156
- return node.type;
116
+ return isElement(node) ? node.type : "";
117
+ }
118
+
119
+ function nodeKey(node: NormalizedUINode, index: number): string {
120
+ return isElement(node) &&
121
+ (typeof node.key === "string" || typeof node.key === "number")
122
+ ? `model:${node.key}`
123
+ : `${index}:${nodeKind(node)}`;
157
124
  }
158
125
 
159
126
  function reportUnknownComponent(type: string, available: string[]): void {