@elabs-ai/components-ai 4.0.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 (180) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -0
  3. package/dist/_audio-player-media-chrome-KA5DY54G.js +81 -0
  4. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +1 -0
  5. package/dist/_flow-boundary-D63PJ65S.js +186 -0
  6. package/dist/_flow-boundary-D63PJ65S.js.map +1 -0
  7. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +11 -0
  8. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +1 -0
  9. package/dist/_persona-rive-RFR2EUWP.js +164 -0
  10. package/dist/_persona-rive-RFR2EUWP.js.map +1 -0
  11. package/dist/index.d.ts +4079 -0
  12. package/dist/index.js +11770 -0
  13. package/dist/index.js.map +1 -0
  14. package/package.json +87 -0
  15. package/src/_audio-player-media-chrome.tsx +146 -0
  16. package/src/_chat-shell-rail.tsx +18 -0
  17. package/src/_code-block-theme.test.ts +134 -0
  18. package/src/_code-block-theme.ts +184 -0
  19. package/src/_flow-boundary.tsx +237 -0
  20. package/src/_flow-lazy.ts +34 -0
  21. package/src/_interactive-terminal-xterm.ts +32 -0
  22. package/src/_lazy-mermaid.test.ts +101 -0
  23. package/src/_lazy-mermaid.ts +117 -0
  24. package/src/_persona-rive.tsx +252 -0
  25. package/src/_streamdown-i18n.ts +119 -0
  26. package/src/agent-timeline.stories.tsx +66 -0
  27. package/src/agent-timeline.test.tsx +74 -0
  28. package/src/agent-timeline.tsx +106 -0
  29. package/src/agent.tsx +113 -0
  30. package/src/agentic-workspace.stories.tsx +1099 -0
  31. package/src/artifact.stories.tsx +62 -0
  32. package/src/artifact.test.tsx +112 -0
  33. package/src/artifact.tsx +167 -0
  34. package/src/asset-preview.stories.tsx +100 -0
  35. package/src/asset-preview.test.tsx +126 -0
  36. package/src/asset-preview.tsx +218 -0
  37. package/src/attachments.tsx +393 -0
  38. package/src/audio-player.stories.tsx +99 -0
  39. package/src/audio-player.tsx +170 -0
  40. package/src/blocks-ai-composer.stories.tsx +83 -0
  41. package/src/canvas.stories.tsx +142 -0
  42. package/src/canvas.tsx +29 -0
  43. package/src/chain-of-thought.tsx +193 -0
  44. package/src/chat-greeting.stories.tsx +96 -0
  45. package/src/chat-greeting.test.tsx +91 -0
  46. package/src/chat-greeting.tsx +91 -0
  47. package/src/chat-shell.stories.tsx +123 -0
  48. package/src/chat-shell.test.tsx +113 -0
  49. package/src/chat-shell.tsx +97 -0
  50. package/src/chat.stories.tsx +99 -0
  51. package/src/checkpoint.tsx +54 -0
  52. package/src/code-block.stories.tsx +81 -0
  53. package/src/code-block.test.tsx +191 -0
  54. package/src/code-block.tsx +673 -0
  55. package/src/commit.tsx +343 -0
  56. package/src/composer.stories.tsx +188 -0
  57. package/src/composer.test.tsx +182 -0
  58. package/src/composer.tsx +202 -0
  59. package/src/confirmation.stories.tsx +61 -0
  60. package/src/confirmation.test.tsx +136 -0
  61. package/src/confirmation.tsx +273 -0
  62. package/src/connection.tsx +23 -0
  63. package/src/context-panel.stories.tsx +137 -0
  64. package/src/context-panel.test.tsx +198 -0
  65. package/src/context-panel.tsx +609 -0
  66. package/src/context.stories.tsx +16 -0
  67. package/src/context.tsx +363 -0
  68. package/src/controls.tsx +20 -0
  69. package/src/conversation.stories.tsx +26 -0
  70. package/src/conversation.test.tsx +123 -0
  71. package/src/conversation.tsx +143 -0
  72. package/src/dark-theme-variant.test.ts +47 -0
  73. package/src/edge.tsx +33 -0
  74. package/src/environment-variables.tsx +304 -0
  75. package/src/file-tree.stories.tsx +55 -0
  76. package/src/file-tree.test.tsx +101 -0
  77. package/src/file-tree.tsx +390 -0
  78. package/src/gallery.stories.tsx +182 -0
  79. package/src/gallery.test.tsx +150 -0
  80. package/src/gallery.tsx +737 -0
  81. package/src/grouped-parts.stories.tsx +104 -0
  82. package/src/grouped-parts.test.tsx +167 -0
  83. package/src/grouped-parts.tsx +247 -0
  84. package/src/image.stories.tsx +67 -0
  85. package/src/image.test.tsx +105 -0
  86. package/src/image.tsx +115 -0
  87. package/src/index.ts +93 -0
  88. package/src/inline-citation.stories.tsx +91 -0
  89. package/src/inline-citation.test.tsx +96 -0
  90. package/src/inline-citation.tsx +314 -0
  91. package/src/interactive-terminal.stories.tsx +165 -0
  92. package/src/interactive-terminal.test.tsx +448 -0
  93. package/src/interactive-terminal.tsx +444 -0
  94. package/src/jsx-preview.stories.tsx +265 -0
  95. package/src/jsx-preview.test.tsx +75 -0
  96. package/src/jsx-preview.tsx +409 -0
  97. package/src/markdown-view.stories.tsx +57 -0
  98. package/src/markdown-view.test.tsx +36 -0
  99. package/src/markdown-view.tsx +113 -0
  100. package/src/message-edit.stories.tsx +150 -0
  101. package/src/message-edit.test.tsx +115 -0
  102. package/src/message-edit.tsx +336 -0
  103. package/src/message-feedback.stories.tsx +53 -0
  104. package/src/message-feedback.test.tsx +62 -0
  105. package/src/message-feedback.tsx +117 -0
  106. package/src/message-form-spec.ts +349 -0
  107. package/src/message-form.stories.tsx +182 -0
  108. package/src/message-form.test.tsx +227 -0
  109. package/src/message-form.tsx +845 -0
  110. package/src/message-table-spec.ts +281 -0
  111. package/src/message-table.stories.tsx +176 -0
  112. package/src/message-table.test.tsx +144 -0
  113. package/src/message-table.tsx +366 -0
  114. package/src/message.stories.tsx +262 -0
  115. package/src/message.test.tsx +508 -0
  116. package/src/message.tsx +619 -0
  117. package/src/mic-selector.tsx +339 -0
  118. package/src/microcopy.test.tsx +123 -0
  119. package/src/model-selector.stories.tsx +73 -0
  120. package/src/model-selector.test.tsx +55 -0
  121. package/src/model-selector.tsx +225 -0
  122. package/src/motion-config.tsx +49 -0
  123. package/src/node.tsx +71 -0
  124. package/src/open-in-chat.tsx +340 -0
  125. package/src/package-info.tsx +205 -0
  126. package/src/panel.tsx +20 -0
  127. package/src/part-groups.ts +258 -0
  128. package/src/persona-sources.ts +59 -0
  129. package/src/persona.stories.tsx +111 -0
  130. package/src/persona.test.tsx +67 -0
  131. package/src/persona.tsx +148 -0
  132. package/src/plan.tsx +135 -0
  133. package/src/prompt-input.stories.tsx +261 -0
  134. package/src/prompt-input.test.tsx +392 -0
  135. package/src/prompt-input.tsx +1668 -0
  136. package/src/queue.tsx +237 -0
  137. package/src/reasoning.stories.tsx +81 -0
  138. package/src/reasoning.test.tsx +50 -0
  139. package/src/reasoning.tsx +229 -0
  140. package/src/sandbox.stories.tsx +62 -0
  141. package/src/sandbox.test.tsx +42 -0
  142. package/src/sandbox.tsx +149 -0
  143. package/src/schema-display.tsx +412 -0
  144. package/src/selection-toolbar.stories.tsx +86 -0
  145. package/src/selection-toolbar.test.tsx +89 -0
  146. package/src/selection-toolbar.tsx +192 -0
  147. package/src/shimmer.stories.tsx +10 -0
  148. package/src/shimmer.tsx +81 -0
  149. package/src/snippet.stories.tsx +19 -0
  150. package/src/snippet.test.tsx +116 -0
  151. package/src/snippet.tsx +138 -0
  152. package/src/sources.stories.tsx +34 -0
  153. package/src/sources.test.tsx +129 -0
  154. package/src/sources.tsx +93 -0
  155. package/src/speech-input.tsx +312 -0
  156. package/src/stack-trace.tsx +479 -0
  157. package/src/streamdown-i18n.test.tsx +163 -0
  158. package/src/suggestion.stories.tsx +75 -0
  159. package/src/suggestion.test.tsx +72 -0
  160. package/src/suggestion.tsx +120 -0
  161. package/src/task.stories.tsx +22 -0
  162. package/src/task.test.tsx +36 -0
  163. package/src/task.tsx +89 -0
  164. package/src/templates-ai-assistant.stories.tsx +142 -0
  165. package/src/terminal.tsx +244 -0
  166. package/src/test-results.stories.tsx +177 -0
  167. package/src/test-results.test.tsx +241 -0
  168. package/src/test-results.tsx +408 -0
  169. package/src/tool-result-card.stories.tsx +143 -0
  170. package/src/tool-result-card.test.tsx +72 -0
  171. package/src/tool-result-card.tsx +86 -0
  172. package/src/tool.stories.tsx +60 -0
  173. package/src/tool.test.tsx +63 -0
  174. package/src/tool.tsx +235 -0
  175. package/src/toolbar.tsx +44 -0
  176. package/src/transcription.tsx +118 -0
  177. package/src/voice-selector.tsx +469 -0
  178. package/src/web-preview.stories.tsx +67 -0
  179. package/src/web-preview.test.tsx +117 -0
  180. package/src/web-preview.tsx +283 -0
@@ -0,0 +1,339 @@
1
+ "use client";
2
+
3
+ import { useControllableState } from "@radix-ui/react-use-controllable-state";
4
+ import { Button } from "@elabs-ai/components-ui";
5
+ import {
6
+ Command,
7
+ CommandEmpty,
8
+ CommandInput,
9
+ CommandItem,
10
+ CommandList,
11
+ } from "@elabs-ai/components-ui";
12
+ import { Popover, PopoverContent, PopoverTrigger } from "@elabs-ai/components-ui";
13
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
14
+ import { useLocale } from "@elabs-ai/components-ui";
15
+ import { ChevronsUpDownIcon } from "lucide-react";
16
+ import type { ComponentProps, ReactNode } from "react";
17
+ import {
18
+ createContext,
19
+ useCallback,
20
+ useContext,
21
+ useEffect,
22
+ useMemo,
23
+ useRef,
24
+ useState,
25
+ } from "react";
26
+
27
+ const deviceIdRegex = /\(([\da-fA-F]{4}:[\da-fA-F]{4})\)$/;
28
+
29
+ interface MicSelectorContextType {
30
+ data: MediaDeviceInfo[];
31
+ value: string | undefined;
32
+ onValueChange?: (value: string) => void;
33
+ open: boolean;
34
+ onOpenChange?: (open: boolean) => void;
35
+ width: number;
36
+ setWidth?: (width: number) => void;
37
+ }
38
+
39
+ const MicSelectorContext = createContext<MicSelectorContextType>({
40
+ data: [],
41
+ onOpenChange: undefined,
42
+ onValueChange: undefined,
43
+ open: false,
44
+ setWidth: undefined,
45
+ value: undefined,
46
+ width: 200,
47
+ });
48
+
49
+ export const useAudioDevices = () => {
50
+ const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
51
+ const [loading, setLoading] = useState(true);
52
+ const [error, setError] = useState<string | null>(null);
53
+ const [hasPermission, setHasPermission] = useState(false);
54
+
55
+ const loadDevicesWithoutPermission = useCallback(async () => {
56
+ try {
57
+ setLoading(true);
58
+ setError(null);
59
+
60
+ const deviceList = await navigator.mediaDevices.enumerateDevices();
61
+ const audioInputs = deviceList.filter((device) => device.kind === "audioinput");
62
+
63
+ setDevices(audioInputs);
64
+ } catch (caughtError) {
65
+ const message =
66
+ caughtError instanceof Error ? caughtError.message : "Failed to get audio devices";
67
+
68
+ setError(message);
69
+ console.error("Error getting audio devices:", message);
70
+ } finally {
71
+ setLoading(false);
72
+ }
73
+ }, []);
74
+
75
+ const loadDevicesWithPermission = useCallback(async () => {
76
+ if (loading) {
77
+ return;
78
+ }
79
+
80
+ try {
81
+ setLoading(true);
82
+ setError(null);
83
+
84
+ const tempStream = await navigator.mediaDevices.getUserMedia({
85
+ audio: true,
86
+ });
87
+
88
+ for (const track of tempStream.getTracks()) {
89
+ track.stop();
90
+ }
91
+
92
+ const deviceList = await navigator.mediaDevices.enumerateDevices();
93
+ const audioInputs = deviceList.filter((device) => device.kind === "audioinput");
94
+
95
+ setDevices(audioInputs);
96
+ setHasPermission(true);
97
+ } catch (caughtError) {
98
+ const message =
99
+ caughtError instanceof Error ? caughtError.message : "Failed to get audio devices";
100
+
101
+ setError(message);
102
+ console.error("Error getting audio devices:", message);
103
+ } finally {
104
+ setLoading(false);
105
+ }
106
+ }, [loading]);
107
+
108
+ useEffect(() => {
109
+ loadDevicesWithoutPermission();
110
+ }, [loadDevicesWithoutPermission]);
111
+
112
+ useEffect(() => {
113
+ const handleDeviceChange = () => {
114
+ if (hasPermission) {
115
+ loadDevicesWithPermission();
116
+ } else {
117
+ loadDevicesWithoutPermission();
118
+ }
119
+ };
120
+
121
+ navigator.mediaDevices.addEventListener("devicechange", handleDeviceChange);
122
+
123
+ return () => {
124
+ navigator.mediaDevices.removeEventListener("devicechange", handleDeviceChange);
125
+ };
126
+ }, [hasPermission, loadDevicesWithPermission, loadDevicesWithoutPermission]);
127
+
128
+ return {
129
+ devices,
130
+ error,
131
+ hasPermission,
132
+ loadDevices: loadDevicesWithPermission,
133
+ loading,
134
+ };
135
+ };
136
+
137
+ export type MicSelectorProps = ComponentProps<typeof Popover> & {
138
+ defaultValue?: string;
139
+ value?: string | undefined;
140
+ onValueChange?: (value: string | undefined) => void;
141
+ open?: boolean;
142
+ onOpenChange?: (open: boolean) => void;
143
+ };
144
+
145
+ export const MicSelector = ({
146
+ defaultValue,
147
+ value: controlledValue,
148
+ onValueChange: controlledOnValueChange,
149
+ defaultOpen = false,
150
+ open: controlledOpen,
151
+ onOpenChange: controlledOnOpenChange,
152
+ ...props
153
+ }: MicSelectorProps) => {
154
+ const [value, onValueChange] = useControllableState<string | undefined>({
155
+ defaultProp: defaultValue,
156
+ onChange: controlledOnValueChange,
157
+ prop: controlledValue,
158
+ });
159
+ const [open, onOpenChange] = useControllableState({
160
+ defaultProp: defaultOpen,
161
+ onChange: controlledOnOpenChange,
162
+ prop: controlledOpen,
163
+ });
164
+ const [width, setWidth] = useState(200);
165
+ const { devices, loading, hasPermission, loadDevices } = useAudioDevices();
166
+
167
+ useEffect(() => {
168
+ if (open && !hasPermission && !loading) {
169
+ loadDevices();
170
+ }
171
+ }, [open, hasPermission, loading, loadDevices]);
172
+
173
+ const contextValue = useMemo(
174
+ () => ({
175
+ data: devices,
176
+ onOpenChange,
177
+ onValueChange,
178
+ open,
179
+ setWidth,
180
+ value,
181
+ width,
182
+ }),
183
+ [devices, onOpenChange, onValueChange, open, setWidth, value, width],
184
+ );
185
+
186
+ return (
187
+ <MicSelectorContext.Provider value={contextValue}>
188
+ <Popover {...props} onOpenChange={onOpenChange} open={open} />
189
+ </MicSelectorContext.Provider>
190
+ );
191
+ };
192
+
193
+ export type MicSelectorTriggerProps = ComponentProps<typeof Button>;
194
+
195
+ export const MicSelectorTrigger = ({ children, ...props }: MicSelectorTriggerProps) => {
196
+ const { setWidth } = useContext(MicSelectorContext);
197
+ const ref = useRef<HTMLButtonElement>(null);
198
+
199
+ useEffect(() => {
200
+ // Create a ResizeObserver to detect width changes
201
+ const resizeObserver = new ResizeObserver((entries) => {
202
+ for (const entry of entries) {
203
+ const newWidth = (entry.target as HTMLElement).offsetWidth;
204
+ if (newWidth) {
205
+ setWidth?.(newWidth);
206
+ }
207
+ }
208
+ });
209
+
210
+ if (ref.current) {
211
+ resizeObserver.observe(ref.current);
212
+ }
213
+
214
+ // Clean up the observer when component unmounts
215
+ return () => {
216
+ resizeObserver.disconnect();
217
+ };
218
+ }, [setWidth]);
219
+
220
+ return (
221
+ <PopoverTrigger asChild>
222
+ <Button variant="outline" {...props} ref={ref}>
223
+ {children}
224
+ <ChevronsUpDownIcon className="shrink-0 text-muted-foreground" size={16} />
225
+ </Button>
226
+ </PopoverTrigger>
227
+ );
228
+ };
229
+
230
+ export type MicSelectorContentProps = ComponentProps<typeof Command> & {
231
+ popoverOptions?: ComponentProps<typeof PopoverContent>;
232
+ };
233
+
234
+ export const MicSelectorContent = ({
235
+ className,
236
+ popoverOptions,
237
+ ...props
238
+ }: MicSelectorContentProps) => {
239
+ const { width, onValueChange, value } = useContext(MicSelectorContext);
240
+
241
+ return (
242
+ <PopoverContent className={cn("p-0", className)} style={{ width }} {...popoverOptions}>
243
+ <Command onValueChange={onValueChange} value={value} {...props} />
244
+ </PopoverContent>
245
+ );
246
+ };
247
+
248
+ export type MicSelectorInputProps = ComponentProps<typeof CommandInput> & {
249
+ value?: string;
250
+ defaultValue?: string;
251
+ onValueChange?: (value: string) => void;
252
+ };
253
+
254
+ export const MicSelectorInput = ({ ...props }: MicSelectorInputProps) => {
255
+ const { t } = useLocale();
256
+ return <CommandInput placeholder={t("ai.micSelector.searchPlaceholder")} {...props} />;
257
+ };
258
+
259
+ export type MicSelectorListProps = Omit<ComponentProps<typeof CommandList>, "children"> & {
260
+ children: (devices: MediaDeviceInfo[]) => ReactNode;
261
+ };
262
+
263
+ export const MicSelectorList = ({ children, ...props }: MicSelectorListProps) => {
264
+ const { data } = useContext(MicSelectorContext);
265
+
266
+ return <CommandList {...props}>{children(data)}</CommandList>;
267
+ };
268
+
269
+ export type MicSelectorEmptyProps = ComponentProps<typeof CommandEmpty>;
270
+
271
+ export const MicSelectorEmpty = ({
272
+ children = "No microphone found.",
273
+ ...props
274
+ }: MicSelectorEmptyProps) => <CommandEmpty {...props}>{children}</CommandEmpty>;
275
+
276
+ export type MicSelectorItemProps = ComponentProps<typeof CommandItem>;
277
+
278
+ export const MicSelectorItem = (props: MicSelectorItemProps) => {
279
+ const { onValueChange, onOpenChange } = useContext(MicSelectorContext);
280
+
281
+ const handleSelect = useCallback(
282
+ (currentValue: string) => {
283
+ onValueChange?.(currentValue);
284
+ onOpenChange?.(false);
285
+ },
286
+ [onValueChange, onOpenChange],
287
+ );
288
+
289
+ return <CommandItem onSelect={handleSelect} {...props} />;
290
+ };
291
+
292
+ export type MicSelectorLabelProps = ComponentProps<"span"> & {
293
+ device: MediaDeviceInfo;
294
+ };
295
+
296
+ export const MicSelectorLabel = ({ device, className, ...props }: MicSelectorLabelProps) => {
297
+ const matches = device.label.match(deviceIdRegex);
298
+
299
+ if (!matches) {
300
+ return (
301
+ <span className={className} {...props}>
302
+ {device.label}
303
+ </span>
304
+ );
305
+ }
306
+
307
+ const [, deviceId] = matches;
308
+ const name = device.label.replace(deviceIdRegex, "");
309
+
310
+ return (
311
+ <span className={className} {...props}>
312
+ <span>{name}</span>
313
+ <span className="text-muted-foreground"> ({deviceId})</span>
314
+ </span>
315
+ );
316
+ };
317
+
318
+ export type MicSelectorValueProps = ComponentProps<"span">;
319
+
320
+ export const MicSelectorValue = ({ className, ...props }: MicSelectorValueProps) => {
321
+ const { data, value } = useContext(MicSelectorContext);
322
+ const currentDevice = data.find((d) => d.deviceId === value);
323
+
324
+ if (!currentDevice) {
325
+ return (
326
+ <span className={cn("flex-1 text-start", className)} {...props}>
327
+ Select microphone...
328
+ </span>
329
+ );
330
+ }
331
+
332
+ return (
333
+ <MicSelectorLabel
334
+ className={cn("flex-1 text-start", className)}
335
+ device={currentDevice}
336
+ {...props}
337
+ />
338
+ );
339
+ };
@@ -0,0 +1,123 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { LocaleProvider } from "@elabs-ai/components-ui";
4
+
5
+ import { Composer } from "./composer";
6
+ import { InlineCitationCard, InlineCitationCarouselNext } from "./inline-citation";
7
+ import { Message, MessageBranch, MessageBranchNext } from "./message";
8
+ import { PromptInput, PromptInputBody, PromptInputTextarea } from "./prompt-input";
9
+
10
+ /**
11
+ * The locale seam is only real if microcopy can actually be overridden. These
12
+ * tests assert both halves of the contract: the shipped English defaults are
13
+ * byte-identical to what was hardcoded before (so adoption is non-breaking), and
14
+ * a `LocaleProvider` can replace them.
15
+ */
16
+
17
+ const de = {
18
+ "ai.composer.placeholder": "Frag mich etwas…",
19
+ "ai.message.nextBranch": "Nächster Zweig",
20
+ "ai.promptInput.placeholder": "Was möchtest du wissen?",
21
+ "ai.promptInput.uploadFiles": "Dateien hochladen",
22
+ next: "Weiter",
23
+ };
24
+
25
+ describe("microcopy — English defaults are unchanged", () => {
26
+ it("PromptInput keeps its shipped placeholder and upload label", () => {
27
+ render(
28
+ <PromptInput onSubmit={() => undefined}>
29
+ <PromptInputBody>
30
+ <PromptInputTextarea />
31
+ </PromptInputBody>
32
+ </PromptInput>,
33
+ );
34
+ expect(screen.getByPlaceholderText("What would you like to know?")).toBeInTheDocument();
35
+ expect(screen.getByLabelText("Upload files")).toBeInTheDocument();
36
+ });
37
+
38
+ it("Composer keeps its shipped placeholder", () => {
39
+ render(<Composer />);
40
+ expect(screen.getByPlaceholderText("Ask me anything…")).toBeInTheDocument();
41
+ });
42
+
43
+ it("message branch controls keep their shipped labels", () => {
44
+ render(
45
+ <MessageBranch>
46
+ <MessageBranchNext />
47
+ </MessageBranch>,
48
+ );
49
+ expect(screen.getByRole("button", { name: "Next branch" })).toBeInTheDocument();
50
+ });
51
+ });
52
+
53
+ describe("microcopy — a LocaleProvider overrides it", () => {
54
+ it("translates the PromptInput placeholder and upload label", () => {
55
+ render(
56
+ <LocaleProvider locale="de-DE" messages={de}>
57
+ <PromptInput onSubmit={() => undefined}>
58
+ <PromptInputBody>
59
+ <PromptInputTextarea />
60
+ </PromptInputBody>
61
+ </PromptInput>
62
+ </LocaleProvider>,
63
+ );
64
+ expect(screen.getByPlaceholderText("Was möchtest du wissen?")).toBeInTheDocument();
65
+ expect(screen.getByLabelText("Dateien hochladen")).toBeInTheDocument();
66
+ });
67
+
68
+ it("translates the Composer placeholder", () => {
69
+ render(
70
+ <LocaleProvider locale="de-DE" messages={de}>
71
+ <Composer />
72
+ </LocaleProvider>,
73
+ );
74
+ expect(screen.getByPlaceholderText("Frag mich etwas…")).toBeInTheDocument();
75
+ });
76
+
77
+ it("translates a namespaced @elabs-ai/components-ai aria-label", () => {
78
+ render(
79
+ <LocaleProvider locale="de-DE" messages={de}>
80
+ <MessageBranch>
81
+ <MessageBranchNext />
82
+ </MessageBranch>
83
+ </LocaleProvider>,
84
+ );
85
+ expect(screen.getByRole("button", { name: "Nächster Zweig" })).toBeInTheDocument();
86
+ });
87
+
88
+ it("reuses the GENERIC keys where they already existed", () => {
89
+ // inline-citation hardcoded "Next" while DEFAULT_MESSAGES already had a
90
+ // `next` key — the double gap the audit found. One override now covers both.
91
+ render(
92
+ <LocaleProvider locale="de-DE" messages={de}>
93
+ <InlineCitationCard>
94
+ <InlineCitationCarouselNext />
95
+ </InlineCitationCard>
96
+ </LocaleProvider>,
97
+ );
98
+ expect(screen.getByRole("button", { name: "Weiter" })).toBeInTheDocument();
99
+ });
100
+
101
+ it("an explicit prop still beats the locale bundle", () => {
102
+ render(
103
+ <LocaleProvider locale="de-DE" messages={de}>
104
+ <Composer placeholder="Explicit wins" />
105
+ </LocaleProvider>,
106
+ );
107
+ expect(screen.getByPlaceholderText("Explicit wins")).toBeInTheDocument();
108
+ });
109
+ });
110
+
111
+ describe("microcopy — no provider is required", () => {
112
+ it("renders English with no LocaleProvider ancestor at all", () => {
113
+ // useLocale is provider-optional, which is what makes adoption non-breaking.
114
+ render(
115
+ <Message from="assistant">
116
+ <MessageBranch>
117
+ <MessageBranchNext />
118
+ </MessageBranch>
119
+ </Message>,
120
+ );
121
+ expect(screen.getByRole("button", { name: "Next branch" })).toBeInTheDocument();
122
+ });
123
+ });
@@ -0,0 +1,73 @@
1
+ /**
2
+ * ModelSelector — a command-palette model picker.
3
+ *
4
+ * `ModelSelectorLogo` fetches provider marks from a **remote origin**
5
+ * (`models.dev`) by default, so a restrictive `img-src` blocks them. Pass `src`
6
+ * to self-host, or `fallback` to control what a blocked load renders — it is
7
+ * never a broken-image glyph. See `docs/CSP-AND-NETWORK.md`.
8
+ */
9
+ import type { Meta, StoryObj } from "@storybook/react-vite";
10
+
11
+ import { ModelSelectorLogo } from "./model-selector";
12
+
13
+ const meta = {
14
+ title: "AI/ModelSelectorLogo",
15
+ component: ModelSelectorLogo,
16
+ parameters: {
17
+ layout: "centered",
18
+ docs: {
19
+ description: {
20
+ component:
21
+ "Provider logo for the model selector. Defaults to fetching from models.dev; pass `src` for a self-hosted asset and `fallback` for the blocked/offline case. A failed load renders a neutral glyph, never a broken image.",
22
+ },
23
+ },
24
+ },
25
+ args: { provider: "anthropic" },
26
+ tags: ["autodocs"],
27
+ } satisfies Meta<typeof ModelSelectorLogo>;
28
+
29
+ export default meta;
30
+ type Story = StoryObj<typeof meta>;
31
+
32
+ /**
33
+ * The blocked / offline state — the DEFAULT story on purpose, so neither CI nor
34
+ * the visual sweep depends on a third-party origin. `src` cannot resolve, so the
35
+ * neutral fallback glyph renders.
36
+ */
37
+ export const Default: Story = {
38
+ args: { src: "about:blank#logo-offline" },
39
+ };
40
+
41
+ /** A caller-supplied fallback instead of the built-in glyph. */
42
+ export const CustomFallback: Story = {
43
+ args: {
44
+ src: "about:blank#logo-offline",
45
+ fallback: <span className="text-meta text-muted-foreground">AI</span>,
46
+ },
47
+ };
48
+
49
+ /** A self-hosted (inline data-URI) logo — the CSP-safe path. */
50
+ export const SelfHosted: Story = {
51
+ args: {
52
+ src: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Ccircle cx='6' cy='6' r='5' fill='%23888'/%3E%3C/svg%3E",
53
+ },
54
+ };
55
+
56
+ /**
57
+ * Live logos from the default remote origin.
58
+ *
59
+ * ⚠️ The only story here that reaches the network (`models.dev`). Each falls
60
+ * back to the neutral glyph if blocked.
61
+ */
62
+ export const LiveRemoteLogos: Story = {
63
+ render: () => (
64
+ <div className="flex items-center gap-4">
65
+ {(["anthropic", "openai", "google", "mistral"] as const).map((provider) => (
66
+ <figure className="flex flex-col items-center gap-2" key={provider}>
67
+ <ModelSelectorLogo provider={provider} />
68
+ <figcaption className="text-meta text-muted-foreground">{provider}</figcaption>
69
+ </figure>
70
+ ))}
71
+ </div>
72
+ ),
73
+ };
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { fireEvent, render, screen } from "@testing-library/react";
3
+
4
+ import { MODEL_SELECTOR_LOGO_BASE_URL, ModelSelectorLogo } from "./model-selector";
5
+
6
+ describe("ModelSelectorLogo — remote origin escape hatches", () => {
7
+ it("defaults to the models.dev logo for the provider", () => {
8
+ render(<ModelSelectorLogo provider="anthropic" />);
9
+ expect(screen.getByRole("img", { name: "anthropic logo" })).toHaveAttribute(
10
+ "src",
11
+ `${MODEL_SELECTOR_LOGO_BASE_URL}/anthropic.svg`,
12
+ );
13
+ });
14
+
15
+ it("lets a caller override src for a self-hosted asset", () => {
16
+ // The old props type did `Omit<…, "src">` AND spread props before `src=`, so
17
+ // a self-hosting consumer had no way to win. Both are fixed.
18
+ render(<ModelSelectorLogo provider="anthropic" src="/local/anthropic.svg" />);
19
+ expect(screen.getByRole("img", { name: "anthropic logo" })).toHaveAttribute(
20
+ "src",
21
+ "/local/anthropic.svg",
22
+ );
23
+ });
24
+
25
+ it("renders a neutral glyph instead of a broken image when the load fails", () => {
26
+ render(<ModelSelectorLogo provider="anthropic" />);
27
+ fireEvent.error(screen.getByRole("img", { name: "anthropic logo" }));
28
+
29
+ const fallback = screen.getByRole("img", { name: "anthropic logo" });
30
+ expect(fallback.tagName.toLowerCase()).toBe("svg");
31
+ expect(fallback).not.toHaveAttribute("src");
32
+ });
33
+
34
+ it("renders a caller-supplied fallback when provided", () => {
35
+ render(<ModelSelectorLogo provider="anthropic" fallback={<span data-testid="fb">AI</span>} />);
36
+ fireEvent.error(screen.getByRole("img", { name: "anthropic logo" }));
37
+ expect(screen.getByTestId("fb")).toBeInTheDocument();
38
+ });
39
+
40
+ it("still calls a caller's onError", () => {
41
+ const onError = vi.fn();
42
+ render(<ModelSelectorLogo provider="anthropic" onError={onError} />);
43
+ fireEvent.error(screen.getByRole("img", { name: "anthropic logo" }));
44
+ expect(onError).toHaveBeenCalledTimes(1);
45
+ });
46
+
47
+ it("keeps an accessible name in both states", () => {
48
+ const { rerender } = render(<ModelSelectorLogo provider="openai" />);
49
+ expect(screen.getByRole("img", { name: "openai logo" })).toBeInTheDocument();
50
+
51
+ fireEvent.error(screen.getByRole("img", { name: "openai logo" }));
52
+ rerender(<ModelSelectorLogo provider="openai" />);
53
+ expect(screen.getByRole("img", { name: "openai logo" })).toBeInTheDocument();
54
+ });
55
+ });