@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.
- package/README.md +2 -2
- package/dist/index.d.ts +43 -16
- package/dist/index.js +1297 -926
- package/dist/index.js.map +1 -1
- package/package.json +9 -9
- package/src/__contract__/audio-visualizer.contract.test.tsx +49 -0
- package/src/__contract__/chat-shell.contract.test.tsx +49 -0
- package/src/__contract__/grouped-parts.contract.test.tsx +49 -0
- package/src/__contract__/image.contract.test.tsx +49 -0
- package/src/__contract__/markdown-view.contract.test.tsx +49 -0
- package/src/__contract__/message-feedback.contract.test.tsx +49 -0
- package/src/__contract__/message-form.contract.test.tsx +49 -0
- package/src/__contract__/message-table.contract.test.tsx +49 -0
- package/src/__contract__/model-provider-logo.contract.test.tsx +49 -0
- package/src/__contract__/persona.contract.test.tsx +49 -0
- package/src/__contract__/prompt-input-effort.contract.test.tsx +49 -0
- package/src/__contract__/prompt-input-mode.contract.test.tsx +49 -0
- package/src/_chat-shell-rail.tsx +2 -2
- package/src/_lazy-cjk.test.ts +43 -0
- package/src/_lazy-cjk.ts +73 -0
- package/src/_lazy-math.test.ts +63 -0
- package/src/_lazy-math.ts +90 -0
- package/src/_streamdown-i18n.ts +73 -21
- package/src/_streamdown-safety.ts +2 -2
- package/src/_theme-scope-store.test.ts +83 -0
- package/src/_theme-scope-store.ts +103 -0
- package/src/agent-event.tsx +1 -1
- package/src/agent.tsx +18 -13
- package/src/artifact.tsx +2 -2
- package/src/asset-preview.test.tsx +40 -0
- package/src/asset-preview.tsx +83 -12
- package/src/attachments.tsx +7 -4
- package/src/code-block.test.tsx +100 -1
- package/src/code-block.tsx +166 -103
- package/src/commit.tsx +30 -41
- package/src/confirmation.tsx +1 -1
- package/src/context-panel.tsx +1 -1
- package/src/conversation.stories.tsx +23 -0
- package/src/conversation.test.tsx +53 -0
- package/src/conversation.tsx +32 -7
- package/src/diff-view.test.tsx +52 -2
- package/src/diff-view.tsx +89 -34
- package/src/environment-variables.tsx +20 -37
- package/src/file-tree.test.tsx +21 -0
- package/src/file-tree.tsx +12 -2
- package/src/inline-citation.tsx +5 -5
- package/src/jsx-preview.tsx +151 -43
- package/src/markdown-view.test.tsx +7 -3
- package/src/markdown-view.tsx +8 -1
- package/src/message-form.stories.tsx +36 -3
- package/src/message-form.test.tsx +8 -2
- package/src/message-form.tsx +15 -7
- package/src/message-table.stories.tsx +2 -2
- package/src/message-table.test.tsx +7 -0
- package/src/message-table.tsx +8 -4
- package/src/message.test.tsx +73 -3
- package/src/message.tsx +36 -12
- package/src/model-provider-logo.test.tsx +57 -3
- package/src/model-provider-logo.tsx +45 -11
- package/src/open-in-chat.tsx +50 -29
- package/src/package-info.tsx +12 -12
- package/src/prompt-input-slash.stories.tsx +1 -1
- package/src/prompt-input.test.tsx +67 -1
- package/src/prompt-input.tsx +42 -4
- package/src/queue.tsx +4 -4
- package/src/reasoning.tsx +17 -6
- package/src/sandbox.tsx +3 -3
- package/src/schema-display.test.tsx +56 -0
- package/src/schema-display.tsx +68 -37
- package/src/session-header.tsx +1 -1
- package/src/snippet.test.tsx +6 -2
- package/src/snippet.tsx +14 -34
- package/src/speech-input.test.tsx +109 -0
- package/src/speech-input.tsx +31 -3
- package/src/stack-trace.tsx +20 -36
- package/src/streamdown-i18n.test.tsx +23 -0
- package/src/test-results.tsx +47 -27
- package/src/token-usage.tsx +6 -6
- package/src/tool.test.tsx +65 -0
- package/src/tool.tsx +59 -23
- package/src/transcription.tsx +1 -1
- package/src/voice-selector.tsx +5 -5
- package/src/web-preview.test.tsx +51 -1
- package/src/web-preview.tsx +39 -8
package/src/jsx-preview.tsx
CHANGED
|
@@ -46,7 +46,23 @@ interface JSXPreviewContextValue {
|
|
|
46
46
|
|
|
47
47
|
const JSXPreviewContext = createContext<JSXPreviewContextValue | null>(null);
|
|
48
48
|
|
|
49
|
-
|
|
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
|
|
60
|
-
|
|
61
|
-
return null;
|
|
62
|
-
}
|
|
75
|
+
const TAG_NAME_START = /[a-zA-Z]/;
|
|
76
|
+
const TAG_NAME_CHAR = /[a-zA-Z0-9]/;
|
|
63
77
|
|
|
64
|
-
|
|
78
|
+
interface JsxTagMatch {
|
|
79
|
+
tagName: string;
|
|
80
|
+
type: "opening" | "closing" | "self-closing";
|
|
81
|
+
endIndex: number;
|
|
82
|
+
}
|
|
65
83
|
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
102
|
+
let j = i + 1;
|
|
103
|
+
const closing = code[j] === "/";
|
|
104
|
+
if (closing) j++;
|
|
71
105
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
|
110
|
-
let currentPosition = 0;
|
|
189
|
+
let position = 0;
|
|
111
190
|
|
|
112
|
-
while (
|
|
113
|
-
const match =
|
|
191
|
+
while (position < code.length) {
|
|
192
|
+
const match = findNextJsxTag(code, position);
|
|
114
193
|
if (!match) {
|
|
115
|
-
// No more tags
|
|
116
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
205
|
+
position = match.endIndex;
|
|
131
206
|
}
|
|
132
207
|
|
|
133
208
|
return (
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/markdown-view.tsx
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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: [] }}
|
|
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
|
-
|
|
134
|
+
isStreaming
|
|
129
135
|
spec={{
|
|
130
136
|
formName: "x",
|
|
131
137
|
fields: [
|
package/src/message-form.tsx
CHANGED
|
@@ -88,7 +88,7 @@ interface MessageFormContextValue {
|
|
|
88
88
|
submitted: boolean;
|
|
89
89
|
submitting: boolean;
|
|
90
90
|
disabled: boolean;
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
577
|
+
const { spec, isStreaming } = useMessageFormContext();
|
|
574
578
|
|
|
575
|
-
if (spec.fields.length === 0 &&
|
|
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 && !
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
);
|
package/src/message-table.tsx
CHANGED
|
@@ -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 (
|
|
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
|
-
{
|
|
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={(
|
|
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 &&
|
|
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) => (
|