@elabs-ai/components-ai 4.1.0 → 4.2.0

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 (84) hide show
  1. package/README.md +2 -2
  2. package/dist/index.d.ts +43 -16
  3. package/dist/index.js +1297 -926
  4. package/dist/index.js.map +1 -1
  5. package/package.json +9 -9
  6. package/src/__contract__/audio-visualizer.contract.test.tsx +49 -0
  7. package/src/__contract__/chat-shell.contract.test.tsx +49 -0
  8. package/src/__contract__/grouped-parts.contract.test.tsx +49 -0
  9. package/src/__contract__/image.contract.test.tsx +49 -0
  10. package/src/__contract__/markdown-view.contract.test.tsx +49 -0
  11. package/src/__contract__/message-feedback.contract.test.tsx +49 -0
  12. package/src/__contract__/message-form.contract.test.tsx +49 -0
  13. package/src/__contract__/message-table.contract.test.tsx +49 -0
  14. package/src/__contract__/model-provider-logo.contract.test.tsx +49 -0
  15. package/src/__contract__/persona.contract.test.tsx +49 -0
  16. package/src/__contract__/prompt-input-effort.contract.test.tsx +49 -0
  17. package/src/__contract__/prompt-input-mode.contract.test.tsx +49 -0
  18. package/src/_chat-shell-rail.tsx +2 -2
  19. package/src/_lazy-cjk.test.ts +43 -0
  20. package/src/_lazy-cjk.ts +73 -0
  21. package/src/_lazy-math.test.ts +63 -0
  22. package/src/_lazy-math.ts +90 -0
  23. package/src/_streamdown-i18n.ts +73 -21
  24. package/src/_streamdown-safety.ts +2 -2
  25. package/src/_theme-scope-store.test.ts +83 -0
  26. package/src/_theme-scope-store.ts +103 -0
  27. package/src/agent-event.tsx +1 -1
  28. package/src/agent.tsx +18 -13
  29. package/src/artifact.tsx +2 -2
  30. package/src/asset-preview.test.tsx +40 -0
  31. package/src/asset-preview.tsx +83 -12
  32. package/src/attachments.tsx +7 -4
  33. package/src/code-block.test.tsx +100 -1
  34. package/src/code-block.tsx +166 -103
  35. package/src/commit.tsx +30 -41
  36. package/src/confirmation.tsx +1 -1
  37. package/src/context-panel.tsx +1 -1
  38. package/src/conversation.stories.tsx +23 -0
  39. package/src/conversation.test.tsx +53 -0
  40. package/src/conversation.tsx +32 -7
  41. package/src/diff-view.test.tsx +52 -2
  42. package/src/diff-view.tsx +89 -34
  43. package/src/environment-variables.tsx +20 -37
  44. package/src/file-tree.test.tsx +21 -0
  45. package/src/file-tree.tsx +12 -2
  46. package/src/inline-citation.tsx +5 -5
  47. package/src/jsx-preview.tsx +151 -43
  48. package/src/markdown-view.test.tsx +7 -3
  49. package/src/markdown-view.tsx +8 -1
  50. package/src/message-form.stories.tsx +36 -3
  51. package/src/message-form.test.tsx +8 -2
  52. package/src/message-form.tsx +15 -7
  53. package/src/message-table.stories.tsx +2 -2
  54. package/src/message-table.test.tsx +7 -0
  55. package/src/message-table.tsx +8 -4
  56. package/src/message.test.tsx +73 -3
  57. package/src/message.tsx +36 -12
  58. package/src/model-provider-logo.test.tsx +57 -3
  59. package/src/model-provider-logo.tsx +45 -11
  60. package/src/open-in-chat.tsx +50 -29
  61. package/src/package-info.tsx +12 -12
  62. package/src/prompt-input-slash.stories.tsx +1 -1
  63. package/src/prompt-input.test.tsx +67 -1
  64. package/src/prompt-input.tsx +42 -4
  65. package/src/queue.tsx +4 -4
  66. package/src/reasoning.tsx +17 -6
  67. package/src/sandbox.tsx +3 -3
  68. package/src/schema-display.test.tsx +56 -0
  69. package/src/schema-display.tsx +68 -37
  70. package/src/session-header.tsx +1 -1
  71. package/src/snippet.test.tsx +6 -2
  72. package/src/snippet.tsx +14 -34
  73. package/src/speech-input.test.tsx +109 -0
  74. package/src/speech-input.tsx +31 -3
  75. package/src/stack-trace.tsx +20 -36
  76. package/src/streamdown-i18n.test.tsx +23 -0
  77. package/src/test-results.tsx +47 -27
  78. package/src/token-usage.tsx +6 -6
  79. package/src/tool.test.tsx +65 -0
  80. package/src/tool.tsx +59 -23
  81. package/src/transcription.tsx +1 -1
  82. package/src/voice-selector.tsx +5 -5
  83. package/src/web-preview.test.tsx +51 -1
  84. package/src/web-preview.tsx +39 -8
@@ -46,7 +46,23 @@ interface JSXPreviewContextValue {
46
46
 
47
47
  const JSXPreviewContext = createContext<JSXPreviewContextValue | null>(null);
48
48
 
49
- const TAG_REGEX = /<\/?([a-zA-Z][a-zA-Z0-9]*)\s*([^>]*?)(\/)?>/;
49
+ /**
50
+ * HTML elements that must never render from agent/model-authored JSX: script
51
+ * execution, style/link/meta/base injection, and embeddable third-party
52
+ * surfaces (iframe/object/embed) or a form that could post data out. Merged
53
+ * with `react-jsx-parser`'s own default (`script`) via `blacklistedTags`.
54
+ */
55
+ const JSX_PREVIEW_BLACKLISTED_TAGS = [
56
+ "script",
57
+ "style",
58
+ "iframe",
59
+ "form",
60
+ "object",
61
+ "embed",
62
+ "link",
63
+ "meta",
64
+ "base",
65
+ ];
50
66
 
51
67
  export const useJSXPreview = () => {
52
68
  const context = useContext(JSXPreviewContext);
@@ -56,37 +72,101 @@ export const useJSXPreview = () => {
56
72
  return context;
57
73
  };
58
74
 
59
- const matchJsxTag = (code: string) => {
60
- if (code.trim() === "") {
61
- return null;
62
- }
75
+ const TAG_NAME_START = /[a-zA-Z]/;
76
+ const TAG_NAME_CHAR = /[a-zA-Z0-9]/;
63
77
 
64
- const match = code.match(TAG_REGEX);
78
+ interface JsxTagMatch {
79
+ tagName: string;
80
+ type: "opening" | "closing" | "self-closing";
81
+ endIndex: number;
82
+ }
65
83
 
66
- if (!match || match.index === undefined) {
67
- return null;
68
- }
84
+ /**
85
+ * Finds the next complete JSX tag at or after `fromIndex`, tracking brace
86
+ * depth and quoted strings so a `>` inside an attribute expression (e.g.
87
+ * `onClick={() => foo()}`) is never mistaken for the tag's own closing
88
+ * bracket (the previous plain regex did exactly that). Walks the ORIGINAL
89
+ * string by absolute index — no per-iteration substring slicing — so one
90
+ * pass over the whole input is linear.
91
+ */
92
+ function findNextJsxTag(code: string, fromIndex: number): JsxTagMatch | null {
93
+ const len = code.length;
94
+ let i = fromIndex;
95
+
96
+ while (i < len) {
97
+ if (code[i] !== "<") {
98
+ i++;
99
+ continue;
100
+ }
69
101
 
70
- const [fullMatch = "", tagName = "", attributes = "", selfClosing] = match;
102
+ let j = i + 1;
103
+ const closing = code[j] === "/";
104
+ if (closing) j++;
71
105
 
72
- let type: "self-closing" | "closing" | "opening";
73
- if (selfClosing) {
74
- type = "self-closing";
75
- } else if (fullMatch.startsWith("</")) {
76
- type = "closing";
77
- } else {
78
- type = "opening";
106
+ const nameStart = j;
107
+ if (!TAG_NAME_START.test(code[nameStart] ?? "")) {
108
+ i++;
109
+ continue;
110
+ }
111
+ j++;
112
+ while (j < len && TAG_NAME_CHAR.test(code[j] ?? "")) {
113
+ j++;
114
+ }
115
+ const tagName = code.slice(nameStart, j);
116
+
117
+ let depth = 0;
118
+ let quote: string | null = null;
119
+ let k = j;
120
+ let closedAt = -1;
121
+ let selfClosing = false;
122
+
123
+ while (k < len) {
124
+ const ch = code[k];
125
+ if (quote) {
126
+ if (ch === quote) quote = null;
127
+ k++;
128
+ continue;
129
+ }
130
+ if (ch === '"' || ch === "'") {
131
+ quote = ch;
132
+ k++;
133
+ continue;
134
+ }
135
+ if (ch === "{") {
136
+ depth++;
137
+ k++;
138
+ continue;
139
+ }
140
+ if (ch === "}") {
141
+ depth = Math.max(0, depth - 1);
142
+ k++;
143
+ continue;
144
+ }
145
+ if (depth === 0 && ch === ">") {
146
+ let p = k - 1;
147
+ while (p > j && code[p] === " ") p--;
148
+ selfClosing = code[p] === "/";
149
+ closedAt = k + 1;
150
+ break;
151
+ }
152
+ k++;
153
+ }
154
+
155
+ // No closing '>' yet for this tag at this depth — it is incomplete, not
156
+ // a match; the caller treats everything from here on as trailing text.
157
+ if (closedAt === -1) {
158
+ return null;
159
+ }
160
+
161
+ return {
162
+ endIndex: closedAt,
163
+ tagName,
164
+ type: closing ? "closing" : selfClosing ? "self-closing" : "opening",
165
+ };
79
166
  }
80
167
 
81
- return {
82
- attributes: attributes.trim(),
83
- endIndex: match.index + fullMatch.length,
84
- startIndex: match.index,
85
- tag: fullMatch,
86
- tagName,
87
- type,
88
- };
89
- };
168
+ return null;
169
+ }
90
170
 
91
171
  const stripIncompleteTag = (text: string) => {
92
172
  // Find the last '<' that isn't part of a complete tag
@@ -106,32 +186,28 @@ const stripIncompleteTag = (text: string) => {
106
186
 
107
187
  const completeJsxTag = (code: string) => {
108
188
  const stack: string[] = [];
109
- let result = "";
110
- let currentPosition = 0;
189
+ let position = 0;
111
190
 
112
- while (currentPosition < code.length) {
113
- const match = matchJsxTag(code.slice(currentPosition));
191
+ while (position < code.length) {
192
+ const match = findNextJsxTag(code, position);
114
193
  if (!match) {
115
- // No more tags found, strip any trailing incomplete tag
116
- result += stripIncompleteTag(code.slice(currentPosition));
194
+ // No more complete tags ahead; everything past `position` is trailing
195
+ // text (possibly a mid-typed tag), handled by stripIncompleteTag below.
117
196
  break;
118
197
  }
119
- const { tagName, type, endIndex } = match;
120
198
 
121
- // Include any text content before this tag
122
- result += code.slice(currentPosition, currentPosition + endIndex);
123
-
124
- if (type === "opening") {
125
- stack.push(tagName);
126
- } else if (type === "closing") {
199
+ if (match.type === "opening") {
200
+ stack.push(match.tagName);
201
+ } else if (match.type === "closing") {
127
202
  stack.pop();
128
203
  }
129
204
 
130
- currentPosition += endIndex;
205
+ position = match.endIndex;
131
206
  }
132
207
 
133
208
  return (
134
- result +
209
+ code.slice(0, position) +
210
+ stripIncompleteTag(code.slice(position)) +
135
211
  [...stack]
136
212
  .reverse()
137
213
  .map((tag) => `</${tag}>`)
@@ -139,6 +215,35 @@ const completeJsxTag = (code: string) => {
139
215
  );
140
216
  };
141
217
 
218
+ /** How often `completeJsxTag` may re-run while content is still streaming in. */
219
+ const STREAM_RECOMPUTE_THROTTLE_MS = 80;
220
+
221
+ /**
222
+ * Tag-balances `jsx` like `completeJsxTag`, but while `isStreaming` is true it
223
+ * skips a fresh (expensive, whole-string) recompute more often than once per
224
+ * `STREAM_RECOMPUTE_THROTTLE_MS` — otherwise every incoming token re-scans the
225
+ * entire accumulated string from scratch. Settled input (`isStreaming` false)
226
+ * always recomputes immediately so the final render is exact.
227
+ */
228
+ function useCompletedJsx(jsx: string, isStreaming: boolean): string {
229
+ const cacheRef = useRef({ jsx, result: completeJsxTag(jsx), time: 0 });
230
+
231
+ return useMemo(() => {
232
+ const cache = cacheRef.current;
233
+ if (jsx === cache.jsx) return cache.result;
234
+
235
+ const now = Date.now();
236
+ if (isStreaming && now - cache.time < STREAM_RECOMPUTE_THROTTLE_MS) {
237
+ return cache.result;
238
+ }
239
+
240
+ const result = completeJsxTag(jsx);
241
+ cacheRef.current = { jsx, result, time: now };
242
+ return result;
243
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- cacheRef is a stable ref, not reactive state
244
+ }, [jsx, isStreaming]);
245
+ }
246
+
142
247
  export type JSXPreviewProps = ComponentProps<"div"> & {
143
248
  jsx: string;
144
249
  /** Content is arriving incrementally — build up, suppress transient errors. */
@@ -177,8 +282,9 @@ export const JSXPreview = memo(
177
282
 
178
283
  // The tag-balanced best-effort of the current input. When it differs from the
179
284
  // raw jsx, the input ends mid-tag → it is INCOMPLETE (still streaming / being
180
- // typed), not invalid. This is the timer-free "not-ready" signal.
181
- const completed = useMemo(() => completeJsxTag(jsx), [jsx]);
285
+ // typed), not invalid. This is the timer-free "not-ready" signal. Throttled
286
+ // while streaming so every incoming token doesn't re-scan the whole string.
287
+ const completed = useCompletedJsx(jsx, isStreaming);
182
288
  const isIncomplete = jsx.trim() !== "" && completed !== jsx;
183
289
 
184
290
  const processedJsx = useMemo(() => {
@@ -293,7 +399,9 @@ export const JSXPreviewContent = memo(({ className, ...props }: JSXPreviewConten
293
399
  return (
294
400
  <div className={cn("jsx-preview-content", className)} {...props}>
295
401
  <JsxParser
402
+ allowUnknownElements={false}
296
403
  bindings={bindings}
404
+ blacklistedTags={JSX_PREVIEW_BLACKLISTED_TAGS}
297
405
  components={components}
298
406
  jsx={displayJsx}
299
407
  onError={handleError}
@@ -1,5 +1,5 @@
1
1
  import { math } from "@streamdown/math";
2
- import { cleanup, render, screen } from "@testing-library/react";
2
+ import { cleanup, render, screen, waitFor } from "@testing-library/react";
3
3
  import type { ComponentProps } from "react";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
5
  import { MarkdownView } from "./markdown-view";
@@ -93,7 +93,7 @@ A paragraph with [a link](https://example.com) and \`inline\` code.
93
93
  expect(screen.getAllByRole("listitem")).toHaveLength(2);
94
94
  });
95
95
 
96
- it("merges a real `plugins.cjk` override in (append), keeps sanitisation on, and keeps the untouched `plugins.math` default alive (#10)", () => {
96
+ it("merges a real `plugins.cjk` override in (append), keeps sanitisation on, and keeps the untouched `plugins.math` default alive (#10)", async () => {
97
97
  // A real, discriminating lock — NOT `plugins={{}}` (that exercises zero
98
98
  // slots and passes identically under merge, replace, or a no-op; #10
99
99
  // review I3). This test supplies a genuine `cjk` plugin (one of the two
@@ -135,7 +135,11 @@ $$x^2$$
135
135
  expect(cjkRemarkSpy).toHaveBeenCalled();
136
136
  // (2b) …and the internal `math` default the consumer did not set is
137
137
  // still active — real KaTeX markup, not the literal `$$x^2$$` text.
138
- expect(document.querySelector(".katex")).toBeInTheDocument();
138
+ // `math` is now lazy-loaded off the source text (#perf-5) — it starts
139
+ // `undefined` and arrives after a dynamic import, hence `waitFor`.
140
+ await waitFor(() => {
141
+ expect(document.querySelector(".katex")).toBeInTheDocument();
142
+ });
139
143
  });
140
144
  });
141
145
 
@@ -48,6 +48,7 @@ import {
48
48
  import { cn } from "@elabs-ai/components-ui/lib/cn";
49
49
  import { stripSanitizerOverrides, warnOnTrustedPluginSlots } from "./_streamdown-safety";
50
50
  import {
51
+ getStreamdownPluginsKey,
51
52
  useStreamdownMermaidOptions,
52
53
  useStreamdownPlugins,
53
54
  useStreamdownTranslations,
@@ -227,7 +228,10 @@ export const MarkdownView = ({
227
228
  const translations = useStreamdownTranslations();
228
229
  // Brand-token-derived `code` plugin, not the package's static github-*
229
230
  // default (#315 follow-up) — re-derives when the active theme changes.
230
- const internalPlugins = useStreamdownPlugins();
231
+ // `math`/`cjk` are lazy-loaded off the raw markdown source (#perf-5, see
232
+ // `_streamdown-i18n.ts`) — pass `children` so they load only when this
233
+ // view actually needs them.
234
+ const internalPlugins = useStreamdownPlugins(typeof children === "string" ? children : "");
231
235
  // Same per-key merge as `components` — see the `plugins` prop doc above.
232
236
  // `math.rehypePlugin`/`mermaid` are the two slots that land in the DOM after
233
237
  // (or outside) the sanitiser chain, so replacing one is a TRUSTED-CODE
@@ -243,6 +247,9 @@ export const MarkdownView = ({
243
247
  const mermaidOptions = useStreamdownMermaidOptions();
244
248
  return (
245
249
  <Streamdown
250
+ // Forces a remount the first time the lazy math/cjk slot resolves —
251
+ // see `getStreamdownPluginsKey`'s doc in `_streamdown-i18n.ts` for why.
252
+ key={getStreamdownPluginsKey(internalPlugins)}
246
253
  data-slot="markdown-view"
247
254
  className={cn("space-y-3 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0", className)}
248
255
  components={components}
@@ -17,7 +17,14 @@
17
17
  */
18
18
  import type { Meta, StoryObj } from "@storybook/react-vite";
19
19
  import { useState } from "react";
20
- import { MessageForm } from "./message-form";
20
+ import {
21
+ MessageForm,
22
+ MessageFormFields,
23
+ MessageFormProvider,
24
+ MessageFormRoot,
25
+ MessageFormSubmit,
26
+ MessageFormTitle,
27
+ } from "./message-form";
21
28
  import type { FormSpec, FormValues } from "./message-form-spec";
22
29
 
23
30
  const meta = {
@@ -95,7 +102,7 @@ export const Default: Story = {
95
102
  */
96
103
  export const StreamingPartial: Story = {
97
104
  args: {
98
- streaming: true,
105
+ isStreaming: true,
99
106
  spec: {
100
107
  formName: "book_demo",
101
108
  title: "Book a demo",
@@ -111,11 +118,37 @@ export const StreamingPartial: Story = {
111
118
  /** Streaming with nothing parsed yet → a layout-shaped skeleton. */
112
119
  export const StreamingSkeleton: Story = {
113
120
  args: {
114
- streaming: true,
121
+ isStreaming: true,
115
122
  spec: { formName: "book_demo", title: "Book a demo", fields: [] },
116
123
  },
117
124
  };
118
125
 
126
+ /**
127
+ * The compound composition — `MessageFormProvider` + the parts directly,
128
+ * instead of the `MessageForm` convenience wrapper (see the file-level doc
129
+ * comment's "compound structure" section). `isStreaming` lives on the
130
+ * provider itself since it is the one that owns the lifted form state; a
131
+ * custom layout that skips `MessageForm` still needs to pass it through here.
132
+ */
133
+ export const ProviderStreaming: Story = {
134
+ render: () => (
135
+ <MessageFormProvider
136
+ isStreaming
137
+ spec={{
138
+ formName: "book_demo",
139
+ title: "Book a demo",
140
+ fields: [{ type: "string", name: "fullName", label: "Full name", required: true }],
141
+ }}
142
+ >
143
+ <MessageFormRoot>
144
+ <MessageFormTitle />
145
+ <MessageFormFields />
146
+ <MessageFormSubmit />
147
+ </MessageFormRoot>
148
+ </MessageFormProvider>
149
+ ),
150
+ };
151
+
119
152
  /** Empty (not streaming) → a calm fallback, never broken UI. */
120
153
  export const Empty: Story = {
121
154
  args: {
@@ -116,16 +116,22 @@ describe("MessageForm — never throws / fallback", () => {
116
116
  });
117
117
 
118
118
  it("renders a skeleton (not a fallback) for an empty spec while streaming", () => {
119
- const { container } = render(<MessageForm spec={{ formName: "x", fields: [] }} streaming />);
119
+ const { container } = render(<MessageForm spec={{ formName: "x", fields: [] }} isStreaming />);
120
120
  expect(screen.queryByText(/no fields/i)).not.toBeInTheDocument();
121
121
  // Skeleton placeholders are aria-hidden decoration.
122
122
  expect(container.querySelector('[aria-hidden="true"] .animate-pulse')).not.toBeNull();
123
123
  });
124
124
 
125
+ it("still honors the deprecated `streaming` alias", () => {
126
+ const { container } = render(<MessageForm spec={{ formName: "x", fields: [] }} streaming />);
127
+ expect(screen.queryByText(/no fields/i)).not.toBeInTheDocument();
128
+ expect(container.querySelector('[aria-hidden="true"] .animate-pulse')).not.toBeNull();
129
+ });
130
+
125
131
  it("drops half-arrived fields while streaming instead of crashing", () => {
126
132
  render(
127
133
  <MessageForm
128
- streaming
134
+ isStreaming
129
135
  spec={{
130
136
  formName: "x",
131
137
  fields: [
@@ -88,7 +88,7 @@ interface MessageFormContextValue {
88
88
  submitted: boolean;
89
89
  submitting: boolean;
90
90
  disabled: boolean;
91
- streaming: boolean;
91
+ isStreaming: boolean;
92
92
  formId: string;
93
93
  headingId: string;
94
94
  }
@@ -145,6 +145,8 @@ export interface MessageFormProviderProps {
145
145
  /** In-flight submit: controls disabled, the submit button shows a spinner. */
146
146
  submitting?: boolean;
147
147
  /** The spec is still streaming in (renders a skeleton when no fields yet). */
148
+ isStreaming?: boolean;
149
+ /** @deprecated Use `isStreaming`. */
148
150
  streaming?: boolean;
149
151
  children: ReactNode;
150
152
  }
@@ -161,9 +163,11 @@ export function MessageFormProvider({
161
163
  disabled = false,
162
164
  submitted = false,
163
165
  submitting = false,
166
+ isStreaming,
164
167
  streaming = false,
165
168
  children,
166
169
  }: MessageFormProviderProps) {
170
+ const resolvedStreaming = isStreaming ?? streaming;
167
171
  const formId = useId();
168
172
  const headingId = `${formId}-title`;
169
173
 
@@ -231,7 +235,7 @@ export function MessageFormProvider({
231
235
  submitted,
232
236
  submitting,
233
237
  disabled,
234
- streaming,
238
+ isStreaming: resolvedStreaming,
235
239
  formId,
236
240
  headingId,
237
241
  }),
@@ -246,7 +250,7 @@ export function MessageFormProvider({
246
250
  submitted,
247
251
  submitting,
248
252
  disabled,
249
- streaming,
253
+ resolvedStreaming,
250
254
  formId,
251
255
  headingId,
252
256
  ],
@@ -570,9 +574,9 @@ export type MessageFormFieldsProps = HTMLAttributes<HTMLDivElement>;
570
574
  /** Renders every field in the spec, in order. A skeleton while streaming empty. */
571
575
  export const MessageFormFields = forwardRef<HTMLDivElement, MessageFormFieldsProps>(
572
576
  function MessageFormFields({ className, ...props }, ref) {
573
- const { spec, streaming } = useMessageFormContext();
577
+ const { spec, isStreaming } = useMessageFormContext();
574
578
 
575
- if (spec.fields.length === 0 && streaming) {
579
+ if (spec.fields.length === 0 && isStreaming) {
576
580
  return (
577
581
  <div ref={ref} className={cn("flex flex-col gap-4", className)} {...props}>
578
582
  <span className="sr-only" role="status" aria-live="polite">
@@ -771,6 +775,8 @@ export interface MessageFormProps extends Omit<
771
775
  /** In-flight submit: controls disabled, spinner on submit. */
772
776
  submitting?: boolean;
773
777
  /** The spec is still streaming (renders a skeleton when no fields yet). */
778
+ isStreaming?: boolean;
779
+ /** @deprecated Use `isStreaming`. */
774
780
  streaming?: boolean;
775
781
  }
776
782
 
@@ -789,12 +795,14 @@ export const MessageForm = forwardRef<HTMLDivElement, MessageFormProps>(function
789
795
  disabled,
790
796
  submitted,
791
797
  submitting,
798
+ isStreaming,
792
799
  streaming,
793
800
  className,
794
801
  ...props
795
802
  },
796
803
  ref,
797
804
  ) {
805
+ const resolvedStreaming = isStreaming ?? streaming;
798
806
  const result = normalizeFormSpec(spec);
799
807
  if (!result.ok) {
800
808
  return (
@@ -806,7 +814,7 @@ export const MessageForm = forwardRef<HTMLDivElement, MessageFormProps>(function
806
814
  const empty = normalized.fields.length === 0;
807
815
 
808
816
  // Not streaming + no fields → the spec is structurally valid but useless.
809
- if (empty && !streaming) {
817
+ if (empty && !resolvedStreaming) {
810
818
  return (
811
819
  <MessageFormFallback
812
820
  ref={ref}
@@ -826,7 +834,7 @@ export const MessageForm = forwardRef<HTMLDivElement, MessageFormProps>(function
826
834
  disabled={disabled}
827
835
  submitted={submitted}
828
836
  submitting={submitting}
829
- streaming={streaming}
837
+ isStreaming={resolvedStreaming}
830
838
  >
831
839
  <div ref={ref} className={cn("w-full", className)} {...props}>
832
840
  <MessageFormRoot>
@@ -105,7 +105,7 @@ export const Truncated: Story = {
105
105
  /** Streaming / partial — columns known, rows still arriving → skeleton rows. */
106
106
  export const StreamingRows: Story = {
107
107
  args: {
108
- streaming: true,
108
+ isStreaming: true,
109
109
  spec: { title: "Loading results", columns: invoicesSpec.columns, rows: [] },
110
110
  },
111
111
  };
@@ -113,7 +113,7 @@ export const StreamingRows: Story = {
113
113
  /** Missing cells render an em-dash — a half-arrived row never crashes. */
114
114
  export const PartialRows: Story = {
115
115
  args: {
116
- streaming: true,
116
+ isStreaming: true,
117
117
  spec: {
118
118
  title: "Streaming rows",
119
119
  columns: invoicesSpec.columns,
@@ -65,6 +65,13 @@ describe("MessageTable — never throws / fallback", () => {
65
65
  });
66
66
 
67
67
  it("renders skeleton rows while streaming with no rows yet", () => {
68
+ const { container } = render(
69
+ <MessageTable spec={{ columns: spec.columns, rows: [] }} isStreaming />,
70
+ );
71
+ expect(container.querySelector('[aria-hidden="true"] .animate-pulse')).not.toBeNull();
72
+ });
73
+
74
+ it("still honors the deprecated `streaming` alias", () => {
68
75
  const { container } = render(
69
76
  <MessageTable spec={{ columns: spec.columns, rows: [] }} streaming />,
70
77
  );
@@ -116,6 +116,8 @@ export interface MessageTableProps extends Omit<HTMLAttributes<HTMLDivElement>,
116
116
  /** Per-cell render override for custom composition. */
117
117
  renderCell?: RenderCell;
118
118
  /** The spec is still streaming (columns-but-no-rows shows skeleton rows). */
119
+ isStreaming?: boolean;
120
+ /** @deprecated Use `isStreaming`. */
119
121
  streaming?: boolean;
120
122
  }
121
123
 
@@ -145,12 +147,14 @@ export const MessageTable = forwardRef<HTMLDivElement, MessageTableProps>(functi
145
147
  onSortChange,
146
148
  maxRows,
147
149
  renderCell,
150
+ isStreaming,
148
151
  streaming = false,
149
152
  className,
150
153
  ...props
151
154
  },
152
155
  ref,
153
156
  ) {
157
+ const resolvedStreaming = isStreaming ?? streaming;
154
158
  const { t } = useLocale();
155
159
  const reactId = useId();
156
160
  const isControlled = sortProp !== undefined;
@@ -181,7 +185,7 @@ export const MessageTable = forwardRef<HTMLDivElement, MessageTableProps>(functi
181
185
 
182
186
  // No columns: skeleton while streaming, otherwise a fallback.
183
187
  if (columns.length === 0) {
184
- if (streaming) {
188
+ if (resolvedStreaming) {
185
189
  return (
186
190
  <div ref={ref} className={cn("w-full", className)} {...props}>
187
191
  <span className="sr-only" role="status" aria-live="polite">
@@ -256,7 +260,7 @@ export const MessageTable = forwardRef<HTMLDivElement, MessageTableProps>(functi
256
260
  </p>
257
261
  )}
258
262
  <div className="overflow-hidden rounded-md border border-border">
259
- {streaming && cappedRows.length === 0 && (
263
+ {resolvedStreaming && cappedRows.length === 0 && (
260
264
  <span className="sr-only" role="status" aria-live="polite">
261
265
  Loading table…
262
266
  </span>
@@ -264,7 +268,7 @@ export const MessageTable = forwardRef<HTMLDivElement, MessageTableProps>(functi
264
268
  <Table
265
269
  aria-labelledby={titleId}
266
270
  aria-label={title ? undefined : t("ai.messageTable.label")}
267
- aria-busy={(streaming && cappedRows.length === 0) || undefined}
271
+ aria-busy={(resolvedStreaming && cappedRows.length === 0) || undefined}
268
272
  >
269
273
  {truncatedCount > 0 && (
270
274
  <TableCaption className="mb-2 mt-3 px-3 text-caption">
@@ -322,7 +326,7 @@ export const MessageTable = forwardRef<HTMLDivElement, MessageTableProps>(functi
322
326
  </TableRow>
323
327
  </TableHeader>
324
328
  <TableBody>
325
- {cappedRows.length === 0 && streaming ? (
329
+ {cappedRows.length === 0 && resolvedStreaming ? (
326
330
  [0, 1, 2].map((i) => (
327
331
  <TableRow key={`skeleton-${i}`} aria-hidden="true">
328
332
  {columns.map((column) => (