@elabs-ai/components-ai 4.0.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 +62 -12
- package/dist/{_audio-player-media-chrome-KA5DY54G.js → _audio-player-media-chrome-T3XVXWRZ.js} +8 -4
- package/dist/_audio-player-media-chrome-T3XVXWRZ.js.map +1 -0
- package/dist/{_flow-boundary-D63PJ65S.js → _flow-boundary-SHNWLQG5.js} +32 -43
- package/dist/_flow-boundary-SHNWLQG5.js.map +1 -0
- package/dist/{_persona-rive-RFR2EUWP.js → _persona-rive-JEG44YHX.js} +9 -5
- package/dist/_persona-rive-JEG44YHX.js.map +1 -0
- package/dist/index.d.ts +1152 -218
- package/dist/index.js +5601 -3241
- package/dist/index.js.map +1 -1
- package/package.json +30 -15
- 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/_audio-player-media-chrome.tsx +102 -15
- package/src/_chat-shell-rail.tsx +2 -2
- package/src/_flow-boundary.tsx +68 -49
- package/src/_lazy-boundary-conformance.ts +38 -0
- package/src/_lazy-cjk.test.ts +43 -0
- package/src/_lazy-cjk.ts +73 -0
- package/src/_lazy-engine-boundary.tsx +61 -0
- package/src/_lazy-math.test.ts +63 -0
- package/src/_lazy-math.ts +90 -0
- package/src/_lazy-mermaid-absent.test.ts +53 -0
- package/src/_lazy-mermaid.test.ts +15 -0
- package/src/_lazy-mermaid.ts +24 -1
- package/src/_mermaid-error-panel.test.tsx +50 -0
- package/src/_mermaid-error-panel.tsx +66 -0
- package/src/_persona-rive.tsx +62 -10
- package/src/_streamdown-i18n.ts +94 -22
- package/src/_streamdown-safety.ts +170 -0
- package/src/_theme-scope-store.test.ts +83 -0
- package/src/_theme-scope-store.ts +103 -0
- package/src/agent-event.stories.tsx +97 -0
- package/src/agent-event.test.tsx +145 -0
- package/src/agent-event.tsx +187 -0
- package/src/agent-timeline.stories.tsx +9 -1
- package/src/agent.test.tsx +47 -0
- package/src/agent.tsx +43 -24
- package/src/agentic-workspace.stories.tsx +27 -20
- package/src/artifact.tsx +17 -14
- package/src/asset-preview.test.tsx +40 -0
- package/src/asset-preview.tsx +83 -12
- package/src/attachments.tsx +7 -4
- package/src/audio-player.test.tsx +78 -0
- package/src/audio-player.tsx +243 -56
- package/src/audio-visualizer.stories.tsx +126 -0
- package/src/audio-visualizer.test.tsx +438 -0
- package/src/audio-visualizer.tsx +367 -0
- package/src/canvas.stories.tsx +150 -1
- package/src/chat-shell.stories.tsx +18 -3
- package/src/chat.stories.tsx +16 -2
- package/src/code-block.stories.tsx +9 -1
- package/src/code-block.test.tsx +100 -1
- package/src/code-block.tsx +166 -103
- package/src/commit.tsx +30 -41
- package/src/composer.stories.tsx +372 -9
- package/src/composer.test.tsx +357 -6
- package/src/composer.tsx +217 -35
- package/src/confirmation.stories.tsx +72 -1
- package/src/confirmation.test.tsx +216 -2
- package/src/confirmation.tsx +264 -4
- package/src/context-panel.stories.tsx +9 -1
- package/src/context-panel.tsx +3 -2
- package/src/conversation.stories.tsx +86 -2
- package/src/conversation.test.tsx +66 -0
- package/src/conversation.tsx +52 -9
- package/src/diff-view.stories.tsx +196 -0
- package/src/diff-view.test.tsx +238 -0
- package/src/diff-view.tsx +697 -0
- package/src/environment-variables.tsx +20 -37
- package/src/file-tree.test.tsx +21 -0
- package/src/file-tree.tsx +12 -2
- package/src/gallery.tsx +4 -2
- package/src/index.ts +14 -4
- package/src/inline-citation.tsx +5 -5
- package/src/jsx-preview.stories.tsx +2 -2
- package/src/jsx-preview.tsx +151 -43
- package/src/markdown-view.stories.tsx +92 -1
- package/src/markdown-view.test.tsx +237 -2
- package/src/markdown-view.tsx +157 -6
- package/src/message-compare.stories.tsx +175 -0
- package/src/message-compare.test.tsx +207 -0
- package/src/message-compare.tsx +453 -0
- package/src/message-form.stories.tsx +65 -4
- 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.stories.tsx +9 -1
- package/src/message.test.tsx +247 -1
- package/src/message.tsx +125 -15
- package/src/microcopy.test.tsx +40 -0
- package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
- package/src/model-provider-logo.test.tsx +109 -0
- package/src/model-provider-logo.tsx +183 -0
- package/src/open-in-chat.tsx +50 -29
- package/src/package-info.tsx +12 -12
- package/src/permission-mode-select.stories.tsx +82 -0
- package/src/permission-mode-select.test.tsx +100 -0
- package/src/permission-mode-select.tsx +137 -0
- package/src/persona-missing-peer.test.tsx +54 -0
- package/src/persona.tsx +68 -22
- package/src/plan.stories.tsx +166 -0
- package/src/plan.test.tsx +267 -0
- package/src/plan.tsx +182 -20
- package/src/prompt-input-effort.stories.tsx +123 -0
- package/src/prompt-input-effort.test.tsx +83 -0
- package/src/prompt-input-effort.tsx +136 -0
- package/src/prompt-input-mode.stories.tsx +108 -0
- package/src/prompt-input-mode.test.tsx +99 -0
- package/src/prompt-input-mode.tsx +169 -0
- package/src/prompt-input-slash.stories.tsx +211 -0
- package/src/prompt-input-slash.test.tsx +262 -0
- package/src/prompt-input-slash.tsx +541 -0
- package/src/prompt-input.stories.tsx +2 -2
- 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 +42 -17
- package/src/sandbox.stories.tsx +9 -1
- package/src/sandbox.tsx +3 -3
- package/src/schema-display.test.tsx +56 -0
- package/src/schema-display.tsx +71 -37
- package/src/selection-toolbar.stories.tsx +9 -1
- package/src/session-header.stories.tsx +128 -0
- package/src/session-header.test.tsx +138 -0
- package/src/session-header.tsx +243 -0
- package/src/session-status-bar.stories.tsx +73 -0
- package/src/session-status-bar.test.tsx +94 -0
- package/src/session-status-bar.tsx +165 -0
- package/src/snippet.stories.tsx +9 -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 +24 -36
- package/src/streamdown-i18n.test.tsx +24 -1
- package/src/task.stories.tsx +13 -3
- package/src/templates-ai-assistant.stories.tsx +21 -1
- package/src/test-results.tsx +47 -27
- package/src/token-usage.stories.tsx +24 -0
- package/src/token-usage.test.tsx +92 -0
- package/src/{context.tsx → token-usage.tsx} +73 -59
- package/src/tool-result-card.stories.tsx +9 -1
- package/src/tool.stories.tsx +13 -3
- package/src/tool.test.tsx +65 -0
- package/src/tool.tsx +73 -30
- package/src/transcription.tsx +1 -1
- package/src/turn-status.stories.tsx +124 -0
- package/src/turn-status.test.tsx +74 -0
- package/src/turn-status.tsx +174 -0
- package/src/use-audio-level.ts +104 -0
- package/src/voice-selector.tsx +5 -5
- package/src/web-preview.test.tsx +51 -1
- package/src/web-preview.tsx +46 -10
- package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
- package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
- package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
- package/src/_interactive-terminal-xterm.ts +0 -32
- package/src/blocks-ai-composer.stories.tsx +0 -83
- package/src/context.stories.tsx +0 -16
- package/src/interactive-terminal.stories.tsx +0 -165
- package/src/interactive-terminal.test.tsx +0 -448
- package/src/interactive-terminal.tsx +0 -444
- package/src/model-selector.test.tsx +0 -55
- package/src/model-selector.tsx +0 -225
- package/src/terminal.tsx +0 -244
|
@@ -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) => (
|
package/src/message.stories.tsx
CHANGED
|
@@ -28,7 +28,15 @@ import { SourceList } from "./sources";
|
|
|
28
28
|
const meta = {
|
|
29
29
|
title: "AI/Message",
|
|
30
30
|
component: Message,
|
|
31
|
-
parameters: {
|
|
31
|
+
parameters: {
|
|
32
|
+
layout: "padded",
|
|
33
|
+
docs: {
|
|
34
|
+
description: {
|
|
35
|
+
component:
|
|
36
|
+
"One turn of a CHAT transcript; one line of a CONSOLE transcript is `Terminal/TerminalTranscriptRow` — see [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). `UserMessage` and `AgentMessage` are named presets over this same component and keep its `message` slot, so one consumer selector matches every entry point.",
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
32
40
|
} satisfies Meta<typeof Message>;
|
|
33
41
|
export default meta;
|
|
34
42
|
type Story = StoryObj<typeof meta>;
|
package/src/message.test.tsx
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { math } from "@streamdown/math";
|
|
1
2
|
import { useState } from "react";
|
|
3
|
+
import type { ComponentProps } from "react";
|
|
2
4
|
import { describe, expect, it, vi } from "vitest";
|
|
3
|
-
import { render, screen } from "@testing-library/react";
|
|
5
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
4
6
|
import userEvent from "@testing-library/user-event";
|
|
5
7
|
import {
|
|
6
8
|
AgentMessage,
|
|
@@ -183,6 +185,153 @@ describe("MessageResponse loading (#269, loading-states.md)", () => {
|
|
|
183
185
|
});
|
|
184
186
|
});
|
|
185
187
|
|
|
188
|
+
describe("MessageResponse memo comparator covers every prop, not a hand-picked field list (perf review §2)", () => {
|
|
189
|
+
it("re-renders when only `className` changes (children/isAnimating/loading held constant)", () => {
|
|
190
|
+
const { container, rerender } = render(
|
|
191
|
+
<MessageResponse className="response-a">{"same content"}</MessageResponse>,
|
|
192
|
+
);
|
|
193
|
+
// Streamdown's own root `<div>` does not forward `data-slot` (it spreads
|
|
194
|
+
// rest props onto the inner markdown renderer, not its wrapper div), so
|
|
195
|
+
// key off the fixed `size-full` class `MessageResponse` always applies.
|
|
196
|
+
const responseEl = () => container.querySelector(".size-full");
|
|
197
|
+
expect(responseEl()).toHaveClass("response-a");
|
|
198
|
+
|
|
199
|
+
rerender(<MessageResponse className="response-b">{"same content"}</MessageResponse>);
|
|
200
|
+
|
|
201
|
+
// Under the old comparator (children/isAnimating/loading only), this
|
|
202
|
+
// rerender was skipped entirely — the DOM kept `response-a` even though
|
|
203
|
+
// the caller supplied a new `className`.
|
|
204
|
+
expect(responseEl()).toHaveClass("response-b");
|
|
205
|
+
expect(responseEl()).not.toHaveClass("response-a");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("re-renders when only `mode` changes (children/isAnimating/loading held constant)", () => {
|
|
209
|
+
// `mode="streaming"` (the default) leniently auto-closes incomplete
|
|
210
|
+
// markdown syntax; `mode="static"` renders it literally. A real,
|
|
211
|
+
// discriminating, upstream-independent signal for this prop.
|
|
212
|
+
const { container, rerender } = render(
|
|
213
|
+
<MessageResponse mode="streaming">{"**bold"}</MessageResponse>,
|
|
214
|
+
);
|
|
215
|
+
expect(container.querySelector('[data-streamdown="strong"]')).toBeInTheDocument();
|
|
216
|
+
|
|
217
|
+
rerender(<MessageResponse mode="static">{"**bold"}</MessageResponse>);
|
|
218
|
+
|
|
219
|
+
// Under the old comparator (children/isAnimating/loading only), this
|
|
220
|
+
// rerender was skipped, so the switch to `mode="static"` never took
|
|
221
|
+
// effect and the incomplete markdown stayed auto-completed.
|
|
222
|
+
expect(container.querySelector('[data-streamdown="strong"]')).not.toBeInTheDocument();
|
|
223
|
+
expect(screen.getByText("**bold")).toBeInTheDocument();
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("MessageResponse plugins/components overrides (#10 — merge-not-replace semantics)", () => {
|
|
228
|
+
it("merges a real `plugins.cjk` override in (append), keeps sanitisation on, and keeps the untouched `plugins.math` default alive (#10)", async () => {
|
|
229
|
+
// A real, discriminating lock — NOT `plugins={{}}` (that exercises zero
|
|
230
|
+
// slots and passes identically under merge, replace, or a no-op; #10
|
|
231
|
+
// review I3). This test supplies a genuine `cjk` plugin (one of the two
|
|
232
|
+
// slots MessageResponse actually reaches — `code`/`mermaid`/`renderers` are
|
|
233
|
+
// consulted only inside Streamdown's OWN default `code` renderer, which
|
|
234
|
+
// `MessageResponse` always shadows) and proves BOTH halves of the
|
|
235
|
+
// merge property:
|
|
236
|
+
// 1. Streamdown's default rehypePlugins (raw → sanitize → harden)
|
|
237
|
+
// still hold — a <script> is stripped.
|
|
238
|
+
// 2. The supplied `cjk` plugin APPENDS (its remark transformer runs)
|
|
239
|
+
// *and* the internal `math` default the consumer did NOT set
|
|
240
|
+
// SURVIVES alongside it (`$$x^2$$` renders as real KaTeX, not
|
|
241
|
+
// literal text). A REPLACE implementation
|
|
242
|
+
// (`plugins = pluginOverrides`) would drop `math` — along with
|
|
243
|
+
// `code`/`mermaid` — the moment a consumer sets `cjk`, and this
|
|
244
|
+
// assertion would fail.
|
|
245
|
+
const cjkRemarkSpy = vi.fn(() => (tree: unknown) => tree);
|
|
246
|
+
const customCjkPlugin: NonNullable<
|
|
247
|
+
NonNullable<ComponentProps<typeof MessageResponse>["plugins"]>["cjk"]
|
|
248
|
+
> = {
|
|
249
|
+
name: "cjk",
|
|
250
|
+
remarkPlugins: [],
|
|
251
|
+
remarkPluginsAfter: [cjkRemarkSpy],
|
|
252
|
+
remarkPluginsBefore: [],
|
|
253
|
+
type: "cjk",
|
|
254
|
+
};
|
|
255
|
+
const UNSAFE_DOC = `# Note
|
|
256
|
+
|
|
257
|
+
<script>window.__pwned = true;</script>
|
|
258
|
+
|
|
259
|
+
$$x^2$$
|
|
260
|
+
`;
|
|
261
|
+
render(<MessageResponse plugins={{ cjk: customCjkPlugin }}>{UNSAFE_DOC}</MessageResponse>);
|
|
262
|
+
|
|
263
|
+
// (1) sanitisation still holds.
|
|
264
|
+
expect(document.querySelector("script")).not.toBeInTheDocument();
|
|
265
|
+
expect(document.body.textContent).not.toMatch(/pwned/);
|
|
266
|
+
// (2a) the supplied `cjk` plugin appended (its transformer ran)…
|
|
267
|
+
expect(cjkRemarkSpy).toHaveBeenCalled();
|
|
268
|
+
// (2b) …and the internal `math` default the consumer did not set is
|
|
269
|
+
// still active — real KaTeX markup, not the literal `$$x^2$$` text.
|
|
270
|
+
// `math` is now lazy-loaded off the source text (#perf-5) — it starts
|
|
271
|
+
// `undefined` and arrives after a dynamic import, hence `waitFor`.
|
|
272
|
+
await waitFor(
|
|
273
|
+
() => {
|
|
274
|
+
expect(document.querySelector(".katex")).toBeInTheDocument();
|
|
275
|
+
},
|
|
276
|
+
{ timeout: 5000 },
|
|
277
|
+
);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe("MessageResponse sanitiser is not overridable (#36)", () => {
|
|
282
|
+
it("ignores a caller-supplied rehypePlugins array (the sanitiser is not overridable)", () => {
|
|
283
|
+
// A rehype plugin that appends a <script> AFTER the pipeline. Under the bug the
|
|
284
|
+
// consumer array REPLACES [rehypeRaw, rehypeSanitize, harden], so nothing strips it.
|
|
285
|
+
const injectScript = () => (tree: { children: unknown[] }) => {
|
|
286
|
+
tree.children.push({
|
|
287
|
+
type: "element",
|
|
288
|
+
tagName: "script",
|
|
289
|
+
properties: {},
|
|
290
|
+
children: [{ type: "text", value: "globalThis.__pwned = true" }],
|
|
291
|
+
});
|
|
292
|
+
};
|
|
293
|
+
const { container } = render(
|
|
294
|
+
// `as any`: a JS consumer, an `any`, or a wider spread object still reaches
|
|
295
|
+
// this path even after the type-level `Omit`, so the assertion must exercise
|
|
296
|
+
// the RUNTIME strip, not the type. (`as never` doesn't typecheck as a JSX
|
|
297
|
+
// spread — TS2698, "Spread types may only be created from object types" —
|
|
298
|
+
// `any` is the cast the issue itself names as the bypass vector.)
|
|
299
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- deliberate: proves the runtime strip, not the type
|
|
300
|
+
<MessageResponse {...({ rehypePlugins: [injectScript] } as any)}>{"# hi"}</MessageResponse>,
|
|
301
|
+
);
|
|
302
|
+
expect(container.querySelector("script")).toBeNull();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("SUPPORTS a caller-supplied remarkPlugins array, and still sanitises what it injects", () => {
|
|
306
|
+
// PR #74 review, round 1: `remarkPlugins` is NOT a sanitiser override. It runs
|
|
307
|
+
// upstream of [rehypeRaw, rehypeSanitize, harden], which Streamdown derives
|
|
308
|
+
// without reading it — so the prop stays supported (no `as any` below: the TYPE
|
|
309
|
+
// must accept it) while its output is still sanitised. Both halves are asserted,
|
|
310
|
+
// so re-adding it to the runtime strip fails, and dropping the rehype chain fails.
|
|
311
|
+
const ran = vi.fn();
|
|
312
|
+
const injectHostile = () => (tree: { children: unknown[] }) => {
|
|
313
|
+
ran();
|
|
314
|
+
tree.children.push({
|
|
315
|
+
type: "html",
|
|
316
|
+
value: '<script>globalThis.__pwned = true</script><img src="x" onerror="void 0">',
|
|
317
|
+
});
|
|
318
|
+
tree.children.push({
|
|
319
|
+
type: "paragraph",
|
|
320
|
+
data: { hName: "script", hChildren: [{ type: "text", value: "globalThis.__x = 1" }] },
|
|
321
|
+
children: [],
|
|
322
|
+
});
|
|
323
|
+
};
|
|
324
|
+
const { container } = render(
|
|
325
|
+
<MessageResponse remarkPlugins={[injectHostile]}>{"# hi"}</MessageResponse>,
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
expect(ran).toHaveBeenCalled(); // the prop really reached Streamdown
|
|
329
|
+
expect(container.querySelector("script")).toBeNull();
|
|
330
|
+
expect(container.querySelector("[onerror]")).toBeNull();
|
|
331
|
+
expect(document.body.textContent).not.toMatch(/pwned/);
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
186
335
|
describe("Message data-slot / data-role contract", () => {
|
|
187
336
|
it("marks the root and its parts with kebab-case data-slots", () => {
|
|
188
337
|
const { container } = render(
|
|
@@ -278,6 +427,30 @@ function BranchHarness(props: { branch?: number; onBranchChange?: (n: number) =>
|
|
|
278
427
|
);
|
|
279
428
|
}
|
|
280
429
|
|
|
430
|
+
describe("MessageBranchContent — id uniqueness and content sync (perf review §2)", () => {
|
|
431
|
+
it("does not spread a caller-supplied id onto every branch (each branch keeps a unique id)", () => {
|
|
432
|
+
const { container } = render(
|
|
433
|
+
<MessageBranch>
|
|
434
|
+
<MessageBranchContent id="branch-content">
|
|
435
|
+
<div key="a">Branch A</div>
|
|
436
|
+
<div key="b">Branch B</div>
|
|
437
|
+
<div key="c">Branch C</div>
|
|
438
|
+
</MessageBranchContent>
|
|
439
|
+
</MessageBranch>,
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
const contentDivs = container.querySelectorAll('[data-slot="message-branch-content"]');
|
|
443
|
+
expect(contentDivs).toHaveLength(3);
|
|
444
|
+
const ids = Array.from(contentDivs).map((el) => el.getAttribute("id"));
|
|
445
|
+
// Under the bug, `{...props}` spread the literal `id="branch-content"`
|
|
446
|
+
// onto every mapped div — three duplicate DOM ids for one supplied id.
|
|
447
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
448
|
+
for (const id of ids) {
|
|
449
|
+
expect(id).toMatch(/^branch-content-\d+$/);
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
|
|
281
454
|
describe("MessageBranch — controlled mode (#361)", () => {
|
|
282
455
|
it("is uncontrolled by default: defaultBranch + internal Next/Previous navigation are unaffected", async () => {
|
|
283
456
|
const onBranchChange = vi.fn();
|
|
@@ -506,3 +679,76 @@ describe("MessageActions hover-reveal pill", () => {
|
|
|
506
679
|
expect(screen.getByRole("button", { name: "Unpin" })).toHaveAttribute("aria-pressed", "true");
|
|
507
680
|
});
|
|
508
681
|
});
|
|
682
|
+
|
|
683
|
+
describe("MessageResponse trusted plugin slots run after the sanitiser (#76)", () => {
|
|
684
|
+
// `plugins.math.rehypePlugin` is appended to the END of Streamdown's rehype
|
|
685
|
+
// pipeline, i.e. AFTER `rehype-raw` → `rehype-sanitize` → `rehype-harden`, so
|
|
686
|
+
// whatever it emits is never re-sanitised. That is a deliberate, documented
|
|
687
|
+
// trusted-code seam (a consumer who can supply an executable `Pluggable` can
|
|
688
|
+
// already run code in their own bundle) — but until #76 it had no runtime
|
|
689
|
+
// half at all, unlike `rehypePlugins`. These three tests are the runtime half:
|
|
690
|
+
// it WARNS (1), it does not become noise (2), and it stays OPEN (3).
|
|
691
|
+
const injectScript = () => (tree: { children: unknown[] }) => {
|
|
692
|
+
tree.children.push({
|
|
693
|
+
type: "element",
|
|
694
|
+
tagName: "script",
|
|
695
|
+
properties: {},
|
|
696
|
+
children: [{ type: "text", value: "globalThis.__pwned76 = true" }],
|
|
697
|
+
});
|
|
698
|
+
};
|
|
699
|
+
const evilMath = { ...math, rehypePlugin: injectScript };
|
|
700
|
+
|
|
701
|
+
it("warns when a consumer replaces plugins.math.rehypePlugin", () => {
|
|
702
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
703
|
+
try {
|
|
704
|
+
render(<MessageResponse plugins={{ math: evilMath }}>{"# hi"}</MessageResponse>);
|
|
705
|
+
const messages = warn.mock.calls.flat().join("\n");
|
|
706
|
+
expect(messages).toMatch(/math\.rehypePlugin/);
|
|
707
|
+
expect(messages).toMatch(/after/i);
|
|
708
|
+
} finally {
|
|
709
|
+
warn.mockRestore();
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
it("does not warn for the default plugin set or a safe slot override", () => {
|
|
714
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
715
|
+
try {
|
|
716
|
+
const { unmount } = render(<MessageResponse>{"# hi"}</MessageResponse>);
|
|
717
|
+
unmount();
|
|
718
|
+
// A `cjk` override is remark-stage only — its output is re-sanitised
|
|
719
|
+
// downstream, so it is NOT a trust boundary and must stay silent. This is
|
|
720
|
+
// the arm that stops the warning degrading into noise consumers learn to
|
|
721
|
+
// ignore.
|
|
722
|
+
const customCjkPlugin: NonNullable<
|
|
723
|
+
NonNullable<ComponentProps<typeof MessageResponse>["plugins"]>["cjk"]
|
|
724
|
+
> = {
|
|
725
|
+
name: "cjk",
|
|
726
|
+
remarkPlugins: [],
|
|
727
|
+
remarkPluginsAfter: [],
|
|
728
|
+
remarkPluginsBefore: [],
|
|
729
|
+
type: "cjk",
|
|
730
|
+
};
|
|
731
|
+
render(<MessageResponse plugins={{ cjk: customCjkPlugin }}>{"# hi"}</MessageResponse>);
|
|
732
|
+
expect(warn).not.toHaveBeenCalled();
|
|
733
|
+
} finally {
|
|
734
|
+
warn.mockRestore();
|
|
735
|
+
}
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it("documents the boundary rather than closing it (the slot stays open)", () => {
|
|
739
|
+
// Counter-intuitive but load-bearing: this pins the deliberate trusted-code
|
|
740
|
+
// escape hatch OPEN. A future "hardening" that strips the slot would break a
|
|
741
|
+
// legitimate consumer (a real KaTeX/math plugin) silently — this fails loudly
|
|
742
|
+
// instead. The defence is the warning above plus the documented boundary,
|
|
743
|
+
// NOT removing the capability.
|
|
744
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
745
|
+
try {
|
|
746
|
+
const { container } = render(
|
|
747
|
+
<MessageResponse plugins={{ math: evilMath }}>{"# hi"}</MessageResponse>,
|
|
748
|
+
);
|
|
749
|
+
expect(container.querySelector("script")).not.toBeNull();
|
|
750
|
+
} finally {
|
|
751
|
+
warn.mockRestore();
|
|
752
|
+
}
|
|
753
|
+
});
|
|
754
|
+
});
|
package/src/message.tsx
CHANGED
|
@@ -6,7 +6,13 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@elabs
|
|
|
6
6
|
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
7
7
|
import { useLocale } from "@elabs-ai/components-ui";
|
|
8
8
|
import { cva, type VariantProps } from "class-variance-authority";
|
|
9
|
-
import {
|
|
9
|
+
import { stripSanitizerOverrides, warnOnTrustedPluginSlots } from "./_streamdown-safety";
|
|
10
|
+
import {
|
|
11
|
+
getStreamdownPluginsKey,
|
|
12
|
+
useStreamdownMermaidOptions,
|
|
13
|
+
useStreamdownPlugins,
|
|
14
|
+
useStreamdownTranslations,
|
|
15
|
+
} from "./_streamdown-i18n";
|
|
10
16
|
import type { UIMessage } from "ai";
|
|
11
17
|
import { BotIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
|
12
18
|
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
|
@@ -428,19 +434,20 @@ export const MessageBranch = ({
|
|
|
428
434
|
|
|
429
435
|
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
|
|
430
436
|
|
|
431
|
-
export const MessageBranchContent = ({ children, ...props }: MessageBranchContentProps) => {
|
|
432
|
-
const { currentBranch, setBranches
|
|
437
|
+
export const MessageBranchContent = ({ children, id, ...props }: MessageBranchContentProps) => {
|
|
438
|
+
const { currentBranch, setBranches } = useMessageBranch();
|
|
433
439
|
const childrenArray = useMemo(
|
|
434
440
|
() => (Array.isArray(children) ? children : [children]),
|
|
435
441
|
[children],
|
|
436
442
|
);
|
|
437
443
|
|
|
438
|
-
//
|
|
444
|
+
// Always resync — an earlier length-gate (`branches.length !==
|
|
445
|
+
// childrenArray.length`) skipped this when a branch's CONTENT changed
|
|
446
|
+
// (e.g. an edited message) without the branch COUNT changing, leaving
|
|
447
|
+
// `context.branches` stale relative to what is actually rendered.
|
|
439
448
|
useEffect(() => {
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
}
|
|
443
|
-
}, [childrenArray, branches, setBranches]);
|
|
449
|
+
setBranches(childrenArray);
|
|
450
|
+
}, [childrenArray, setBranches]);
|
|
444
451
|
|
|
445
452
|
return childrenArray.map((branch, index) => (
|
|
446
453
|
<div
|
|
@@ -449,6 +456,10 @@ export const MessageBranchContent = ({ children, ...props }: MessageBranchConten
|
|
|
449
456
|
index === currentBranch ? "block" : "hidden",
|
|
450
457
|
)}
|
|
451
458
|
data-slot="message-branch-content"
|
|
459
|
+
// Spreading a caller-supplied `id` onto every mapped branch produced
|
|
460
|
+
// duplicate DOM ids (one per branch); suffix it per index instead so
|
|
461
|
+
// each branch keeps a unique, stable id.
|
|
462
|
+
id={id ? `${id}-${index}` : undefined}
|
|
452
463
|
key={branch.key}
|
|
453
464
|
{...props}
|
|
454
465
|
>
|
|
@@ -540,7 +551,19 @@ export const MessageBranchPage = ({ className, ...props }: MessageBranchPageProp
|
|
|
540
551
|
);
|
|
541
552
|
};
|
|
542
553
|
|
|
543
|
-
|
|
554
|
+
/**
|
|
555
|
+
* `rehypePlugins` is NOT exposed on `MessageResponse` (#36, fixed). This
|
|
556
|
+
* renders untrusted, streamed model output — the type-level `Omit` below plus
|
|
557
|
+
* a runtime `stripSanitizerOverrides()` call before the `{...props}` spread
|
|
558
|
+
* onto `<Streamdown>` keep Streamdown's default sanitiser chain (rehype-raw →
|
|
559
|
+
* rehype-sanitize → rehype-harden) non-overridable, even through a JS consumer
|
|
560
|
+
* or a force-cast.
|
|
561
|
+
*
|
|
562
|
+
* `remarkPlugins` IS supported: the remark stage runs upstream of that chain
|
|
563
|
+
* and cannot bypass it (PR #74 review, round 1). See `MarkdownView`'s TSDoc and
|
|
564
|
+
* `packages/ai/src/_streamdown-safety.ts` for the full security model.
|
|
565
|
+
*/
|
|
566
|
+
export type MessageResponseProps = Omit<ComponentProps<typeof Streamdown>, "rehypePlugins"> & {
|
|
544
567
|
/**
|
|
545
568
|
* No content has arrived yet (loading-states.md `loading`) — renders
|
|
546
569
|
* skeleton lines at the body line-height instead of `<Streamdown>`, so the
|
|
@@ -548,10 +571,58 @@ export type MessageResponseProps = ComponentProps<typeof Streamdown> & {
|
|
|
548
571
|
* @default false
|
|
549
572
|
*/
|
|
550
573
|
loading?: boolean;
|
|
574
|
+
/**
|
|
575
|
+
* Custom renderers for markdown elements. **Semantics differ from
|
|
576
|
+
* `MarkdownView`:** `components` here REPLACES (not merges) per key against
|
|
577
|
+
* Streamdown's own defaults, since `MessageResponse` has no internal
|
|
578
|
+
* component map to merge with. See `MarkdownView` for merge-over-defaults
|
|
579
|
+
* semantics and `#10` for details.
|
|
580
|
+
*/
|
|
581
|
+
components?: ComponentProps<typeof Streamdown>["components"];
|
|
582
|
+
/**
|
|
583
|
+
* Streamdown plugin-slot overrides (`cjk`/`code`/`math`/`mermaid`/
|
|
584
|
+
* `renderers`), **merged** per key over the internal defaults, so a
|
|
585
|
+
* consumer's entry wins for its key; every key the consumer does not set
|
|
586
|
+
* keeps the reactive, i18n-aware internal default. Same semantics as
|
|
587
|
+
* `MarkdownView` (#10 fix round).
|
|
588
|
+
*
|
|
589
|
+
* **This narrow `plugins` prop can only APPEND — it can never displace
|
|
590
|
+
* Streamdown's default `rehypePlugins` chain** (`rehype-raw` →
|
|
591
|
+
* `rehype-sanitize` → `rehype-harden`). `MessageResponse` never sets
|
|
592
|
+
* `rehypePlugins` itself, and nothing in `PluginConfig` removes a member of
|
|
593
|
+
* that pipeline. But two of the five slots are **not** upstream of it and
|
|
594
|
+
* must be treated as TRUSTED CODE (#76):
|
|
595
|
+
* - **`math.rehypePlugin` runs AFTER `rehype-sanitize`/`rehype-harden`** —
|
|
596
|
+
* it is appended to the end of the rehype pipeline (verified against
|
|
597
|
+
* `streamdown@2.5.0`'s `dist/chunk-BO2N2NFS.js`), so its output is never
|
|
598
|
+
* re-sanitised. Replacing it emits a dev-only `console.warn`; the plugin
|
|
599
|
+
* still runs (the seam is deliberate, not a hole to close). The sharp
|
|
600
|
+
* edge in practice is KaTeX's `trust` option — it disables KaTeX's own
|
|
601
|
+
* sanitisation, so leave it off for model-authored content.
|
|
602
|
+
* - **`mermaid` never enters the rehype/remark pipeline at all.** Its
|
|
603
|
+
* render output is written via `dangerouslySetInnerHTML` (streamdown's
|
|
604
|
+
* only such sink); brand-ui's default pins mermaid's strict security
|
|
605
|
+
* level, and a replacement must sanitise its own SVG. Also dev-warned.
|
|
606
|
+
* - `cjk` (remark-stage, re-sanitised downstream), `code` (feeds
|
|
607
|
+
* `shikiTheme` only) and `renderers` (ordinary React components) do not
|
|
608
|
+
* bypass sanitisation and are never warned about.
|
|
609
|
+
*
|
|
610
|
+
* See `docs/CSP-AND-NETWORK.md` §1 and
|
|
611
|
+
* `packages/ai/src/_streamdown-safety.ts`; `MarkdownView`'s TSDoc carries
|
|
612
|
+
* the same model for the sibling surface.
|
|
613
|
+
*/
|
|
614
|
+
plugins?: ComponentProps<typeof Streamdown>["plugins"];
|
|
551
615
|
};
|
|
552
616
|
|
|
553
617
|
export const MessageResponse = memo(
|
|
554
|
-
({ className, loading = false, ...props }: MessageResponseProps) => {
|
|
618
|
+
({ className, loading = false, plugins: pluginOverrides, ...props }: MessageResponseProps) => {
|
|
619
|
+
// Streamdown installs [rehypeRaw, rehypeSanitize, harden] as the DEFAULT VALUE of
|
|
620
|
+
// `rehypePlugins`; a supplied array REPLACES it. This component renders untrusted,
|
|
621
|
+
// streamed model output, so the chain is not overridable. Widen with `allowedTags` /
|
|
622
|
+
// `literalTagContent`, which merge into the sanitize schema. See issue #36.
|
|
623
|
+
// `remarkPlugins` is deliberately left alone — it runs upstream of the rehype
|
|
624
|
+
// chain and cannot bypass it (PR #74 review; see `_streamdown-safety.ts`).
|
|
625
|
+
stripSanitizerOverrides(props);
|
|
555
626
|
// Streamdown renders its own chrome (code copy, table menus, Mermaid
|
|
556
627
|
// toolbar); route its labels through the locale seam (#310). Spread AFTER
|
|
557
628
|
// so an explicit `translations` prop still wins (ADR 0017 override chain).
|
|
@@ -559,7 +630,32 @@ export const MessageResponse = memo(
|
|
|
559
630
|
const { t } = useLocale();
|
|
560
631
|
// Brand-token-derived `code` plugin, not the package's static github-*
|
|
561
632
|
// default (#315 follow-up) — re-derives when the active theme changes.
|
|
562
|
-
|
|
633
|
+
// `math`/`cjk` are lazy-loaded off the raw markdown source (#perf-5, see
|
|
634
|
+
// `_streamdown-i18n.ts`) — pass `props.children` so they load only when
|
|
635
|
+
// this message actually needs them.
|
|
636
|
+
const internalPlugins = useStreamdownPlugins(
|
|
637
|
+
typeof props.children === "string" ? props.children : "",
|
|
638
|
+
);
|
|
639
|
+
// MERGED per key over the internal defaults — the same semantics as
|
|
640
|
+
// `MarkdownView`'s `plugins` prop (#10 fix round, M4): a consumer entry
|
|
641
|
+
// wins for its key; every key the consumer does not set keeps the
|
|
642
|
+
// reactive, i18n-aware internal default. Previously this component
|
|
643
|
+
// REPLACED `plugins` wholesale (`{...props}` spread after `plugins=`),
|
|
644
|
+
// the opposite of `MarkdownView` for the same prop on a sibling
|
|
645
|
+
// Streamdown surface — aligned so both components mean the same thing by
|
|
646
|
+
// "override a plugin slot".
|
|
647
|
+
// `math.rehypePlugin`/`mermaid` are the two slots that land in the DOM
|
|
648
|
+
// after (or outside) the sanitiser chain, so replacing one is a
|
|
649
|
+
// TRUSTED-CODE decision. The runtime half of that documented boundary is a
|
|
650
|
+
// dev warning — the plugin still runs; stripping it would break the
|
|
651
|
+
// legitimate use (#76).
|
|
652
|
+
const plugins = useMemo(() => {
|
|
653
|
+
warnOnTrustedPluginSlots(pluginOverrides, internalPlugins);
|
|
654
|
+
return { ...internalPlugins, ...pluginOverrides };
|
|
655
|
+
}, [internalPlugins, pluginOverrides]);
|
|
656
|
+
// The TOP-LEVEL Streamdown `mermaid` prop — catches a failed diagram
|
|
657
|
+
// render, including a missing `mermaid` optional peer (issue #33).
|
|
658
|
+
const mermaidOptions = useStreamdownMermaidOptions();
|
|
563
659
|
|
|
564
660
|
if (loading) {
|
|
565
661
|
return (
|
|
@@ -591,17 +687,31 @@ export const MessageResponse = memo(
|
|
|
591
687
|
"[&_p]:text-body [&_li]:text-body [&_code]:text-code",
|
|
592
688
|
className,
|
|
593
689
|
)}
|
|
690
|
+
// Forces a remount the first time the lazy math/cjk slot resolves —
|
|
691
|
+
// see `getStreamdownPluginsKey`'s doc in `_streamdown-i18n.ts` for why.
|
|
692
|
+
key={getStreamdownPluginsKey(internalPlugins)}
|
|
594
693
|
data-slot="message-response"
|
|
595
694
|
plugins={plugins}
|
|
596
695
|
translations={translations}
|
|
696
|
+
mermaid={mermaidOptions}
|
|
597
697
|
{...props}
|
|
598
698
|
/>
|
|
599
699
|
);
|
|
600
700
|
},
|
|
601
|
-
(
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
701
|
+
// A hand-picked field list (`children`/`isAnimating`/`loading`) silently
|
|
702
|
+
// went stale as `MessageResponseProps` grew: `className`, `components`,
|
|
703
|
+
// `plugins`, `mode` and every other `Streamdown` prop passed through
|
|
704
|
+
// `...props` were never compared, so a caller-driven change to any of them
|
|
705
|
+
// (e.g. toggling `mode="static"`/`"streaming"`, swapping `components`, or
|
|
706
|
+
// overriding a `plugins` slot) rendered stale content until an unrelated
|
|
707
|
+
// prop also changed. Compare every own key on BOTH sides with `Object.is`
|
|
708
|
+
// instead, so a newly added prop is covered for free.
|
|
709
|
+
(prevProps, nextProps) => {
|
|
710
|
+
const prevKeys = Object.keys(prevProps) as (keyof MessageResponseProps)[];
|
|
711
|
+
const nextKeys = Object.keys(nextProps) as (keyof MessageResponseProps)[];
|
|
712
|
+
if (prevKeys.length !== nextKeys.length) return false;
|
|
713
|
+
return prevKeys.every((key) => Object.is(prevProps[key], nextProps[key]));
|
|
714
|
+
},
|
|
605
715
|
);
|
|
606
716
|
|
|
607
717
|
MessageResponse.displayName = "MessageResponse";
|
package/src/microcopy.test.tsx
CHANGED
|
@@ -2,9 +2,11 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
import { render, screen } from "@testing-library/react";
|
|
3
3
|
import { LocaleProvider } from "@elabs-ai/components-ui";
|
|
4
4
|
|
|
5
|
+
import { Artifact, ArtifactClose } from "./artifact";
|
|
5
6
|
import { Composer } from "./composer";
|
|
6
7
|
import { InlineCitationCard, InlineCitationCarouselNext } from "./inline-citation";
|
|
7
8
|
import { Message, MessageBranch, MessageBranchNext } from "./message";
|
|
9
|
+
import { Plan, PlanTrigger } from "./plan";
|
|
8
10
|
import { PromptInput, PromptInputBody, PromptInputTextarea } from "./prompt-input";
|
|
9
11
|
|
|
10
12
|
/**
|
|
@@ -19,7 +21,9 @@ const de = {
|
|
|
19
21
|
"ai.message.nextBranch": "Nächster Zweig",
|
|
20
22
|
"ai.promptInput.placeholder": "Was möchtest du wissen?",
|
|
21
23
|
"ai.promptInput.uploadFiles": "Dateien hochladen",
|
|
24
|
+
"ai.plan.togglePlan": "Plan umschalten",
|
|
22
25
|
next: "Weiter",
|
|
26
|
+
close: "Schließen",
|
|
23
27
|
};
|
|
24
28
|
|
|
25
29
|
describe("microcopy — English defaults are unchanged", () => {
|
|
@@ -48,6 +52,20 @@ describe("microcopy — English defaults are unchanged", () => {
|
|
|
48
52
|
);
|
|
49
53
|
expect(screen.getByRole("button", { name: "Next branch" })).toBeInTheDocument();
|
|
50
54
|
});
|
|
55
|
+
|
|
56
|
+
it("ArtifactClose keeps its sr-only 'Close' name (#18 — reuses the generic `close` key)", () => {
|
|
57
|
+
render(<ArtifactClose />);
|
|
58
|
+
expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("PlanTrigger keeps its sr-only 'Toggle plan' name (#18)", () => {
|
|
62
|
+
render(
|
|
63
|
+
<Plan>
|
|
64
|
+
<PlanTrigger />
|
|
65
|
+
</Plan>,
|
|
66
|
+
);
|
|
67
|
+
expect(screen.getByRole("button", { name: "Toggle plan" })).toBeInTheDocument();
|
|
68
|
+
});
|
|
51
69
|
});
|
|
52
70
|
|
|
53
71
|
describe("microcopy — a LocaleProvider overrides it", () => {
|
|
@@ -106,6 +124,28 @@ describe("microcopy — a LocaleProvider overrides it", () => {
|
|
|
106
124
|
);
|
|
107
125
|
expect(screen.getByPlaceholderText("Explicit wins")).toBeInTheDocument();
|
|
108
126
|
});
|
|
127
|
+
|
|
128
|
+
it("translates ArtifactClose's sr-only name via the reused generic `close` key (#18)", () => {
|
|
129
|
+
render(
|
|
130
|
+
<LocaleProvider locale="de-DE" messages={de}>
|
|
131
|
+
<Artifact>
|
|
132
|
+
<ArtifactClose />
|
|
133
|
+
</Artifact>
|
|
134
|
+
</LocaleProvider>,
|
|
135
|
+
);
|
|
136
|
+
expect(screen.getByRole("button", { name: "Schließen" })).toBeInTheDocument();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("translates PlanTrigger's sr-only name via the new `ai.plan.togglePlan` key (#18)", () => {
|
|
140
|
+
render(
|
|
141
|
+
<LocaleProvider locale="de-DE" messages={de}>
|
|
142
|
+
<Plan>
|
|
143
|
+
<PlanTrigger />
|
|
144
|
+
</Plan>
|
|
145
|
+
</LocaleProvider>,
|
|
146
|
+
);
|
|
147
|
+
expect(screen.getByRole("button", { name: "Plan umschalten" })).toBeInTheDocument();
|
|
148
|
+
});
|
|
109
149
|
});
|
|
110
150
|
|
|
111
151
|
describe("microcopy — no provider is required", () => {
|