@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,1668 @@
1
+ "use client";
2
+
3
+ import {
4
+ Command,
5
+ CommandEmpty,
6
+ CommandGroup,
7
+ CommandInput,
8
+ CommandItem,
9
+ CommandList,
10
+ CommandSeparator,
11
+ } from "@elabs-ai/components-ui";
12
+ import {
13
+ DropdownMenu,
14
+ DropdownMenuContent,
15
+ DropdownMenuItem,
16
+ DropdownMenuTrigger,
17
+ } from "@elabs-ai/components-ui";
18
+ import { HoverCard, HoverCardContent, HoverCardTrigger } from "@elabs-ai/components-ui";
19
+ import {
20
+ InputGroup,
21
+ InputGroupAddon,
22
+ InputGroupButton,
23
+ InputGroupTextarea,
24
+ } from "@elabs-ai/components-ui";
25
+ import {
26
+ Select,
27
+ SelectContent,
28
+ SelectItem,
29
+ SelectTrigger,
30
+ SelectValue,
31
+ } from "@elabs-ai/components-ui";
32
+ import { Spinner } from "@elabs-ai/components-ui";
33
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@elabs-ai/components-ui";
34
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
35
+ import { useLocale } from "@elabs-ai/components-ui";
36
+ import type { ChatStatus, FileUIPart, SourceDocumentUIPart } from "ai";
37
+ import { CornerDownLeftIcon, ImageIcon, Monitor, PlusIcon, SquareIcon, XIcon } from "lucide-react";
38
+ import { nanoid } from "nanoid";
39
+ import type {
40
+ ChangeEvent,
41
+ ChangeEventHandler,
42
+ ClipboardEventHandler,
43
+ ComponentProps,
44
+ FormEvent,
45
+ FormEventHandler,
46
+ HTMLAttributes,
47
+ KeyboardEventHandler,
48
+ PropsWithChildren,
49
+ ReactNode,
50
+ RefObject,
51
+ } from "react";
52
+ import {
53
+ Children,
54
+ createContext,
55
+ isValidElement,
56
+ useCallback,
57
+ useContext,
58
+ useEffect,
59
+ useMemo,
60
+ useRef,
61
+ useState,
62
+ } from "react";
63
+
64
+ // ============================================================================
65
+ // Helpers
66
+ // ============================================================================
67
+
68
+ const convertBlobUrlToDataUrl = async (url: string): Promise<string | null> => {
69
+ try {
70
+ const response = await fetch(url);
71
+ const blob = await response.blob();
72
+ // FileReader uses callback-based API, wrapping in Promise is necessary
73
+ // oxlint-disable-next-line eslint-plugin-promise(avoid-new)
74
+ return new Promise((resolve) => {
75
+ const reader = new FileReader();
76
+ // oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
77
+ reader.onloadend = () => resolve(reader.result as string);
78
+ // oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
79
+ reader.onerror = () => resolve(null);
80
+ reader.readAsDataURL(blob);
81
+ });
82
+ } catch {
83
+ return null;
84
+ }
85
+ };
86
+
87
+ const captureScreenshot = async (): Promise<File | null> => {
88
+ if (typeof navigator === "undefined" || !navigator.mediaDevices?.getDisplayMedia) {
89
+ return null;
90
+ }
91
+
92
+ let stream: MediaStream | null = null;
93
+ const video = document.createElement("video");
94
+ video.muted = true;
95
+ video.playsInline = true;
96
+
97
+ try {
98
+ stream = await navigator.mediaDevices.getDisplayMedia({
99
+ audio: false,
100
+ video: true,
101
+ });
102
+
103
+ video.srcObject = stream;
104
+
105
+ // Video element uses callback-based API, wrapping in Promise is necessary
106
+ // oxlint-disable-next-line eslint-plugin-promise(avoid-new)
107
+ await new Promise<void>((resolve, reject) => {
108
+ // oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
109
+ video.onloadedmetadata = () => resolve();
110
+ // oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
111
+ video.onerror = () => reject(new Error("Failed to load screen stream"));
112
+ });
113
+
114
+ await video.play();
115
+
116
+ const width = video.videoWidth;
117
+ const height = video.videoHeight;
118
+ if (!width || !height) {
119
+ return null;
120
+ }
121
+
122
+ const canvas = document.createElement("canvas");
123
+ canvas.width = width;
124
+ canvas.height = height;
125
+ const context = canvas.getContext("2d");
126
+ if (!context) {
127
+ return null;
128
+ }
129
+
130
+ context.drawImage(video, 0, 0, width, height);
131
+ // canvas.toBlob uses callback-based API, wrapping in Promise is necessary
132
+ // oxlint-disable-next-line eslint-plugin-promise(avoid-new)
133
+ const blob = await new Promise<Blob | null>((resolve) => {
134
+ canvas.toBlob(resolve, "image/png");
135
+ });
136
+ if (!blob) {
137
+ return null;
138
+ }
139
+
140
+ const timestamp = new Date()
141
+ .toISOString()
142
+ .replaceAll(/[:.]/g, "-")
143
+ .replace("T", "_")
144
+ .replace("Z", "");
145
+
146
+ return new File([blob], `screenshot-${timestamp}.png`, {
147
+ lastModified: Date.now(),
148
+ type: "image/png",
149
+ });
150
+ } finally {
151
+ if (stream) {
152
+ for (const track of stream.getTracks()) {
153
+ track.stop();
154
+ }
155
+ }
156
+ video.pause();
157
+ video.srcObject = null;
158
+ }
159
+ };
160
+
161
+ // ============================================================================
162
+ // Provider Context & Types
163
+ // ============================================================================
164
+
165
+ export interface AttachmentsContext {
166
+ files: (FileUIPart & { id: string })[];
167
+ add: (files: File[] | FileList) => void;
168
+ remove: (id: string) => void;
169
+ clear: () => void;
170
+ openFileDialog: () => void;
171
+ fileInputRef: RefObject<HTMLInputElement | null>;
172
+ }
173
+
174
+ export interface TextInputContext {
175
+ value: string;
176
+ setInput: (v: string) => void;
177
+ clear: () => void;
178
+ }
179
+
180
+ export interface PromptInputControllerProps {
181
+ textInput: TextInputContext;
182
+ attachments: AttachmentsContext;
183
+ /** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */
184
+ __registerFileInput: (ref: RefObject<HTMLInputElement | null>, open: () => void) => void;
185
+ }
186
+
187
+ const PromptInputController = createContext<PromptInputControllerProps | null>(null);
188
+ const ProviderAttachmentsContext = createContext<AttachmentsContext | null>(null);
189
+
190
+ export const usePromptInputController = () => {
191
+ const ctx = useContext(PromptInputController);
192
+ if (!ctx) {
193
+ throw new Error(
194
+ "Wrap your component inside <PromptInputProvider> to use usePromptInputController().",
195
+ );
196
+ }
197
+ return ctx;
198
+ };
199
+
200
+ // Optional variants (do NOT throw). Useful for dual-mode components.
201
+ const useOptionalPromptInputController = () => useContext(PromptInputController);
202
+
203
+ export const useProviderAttachments = () => {
204
+ const ctx = useContext(ProviderAttachmentsContext);
205
+ if (!ctx) {
206
+ throw new Error(
207
+ "Wrap your component inside <PromptInputProvider> to use useProviderAttachments().",
208
+ );
209
+ }
210
+ return ctx;
211
+ };
212
+
213
+ const useOptionalProviderAttachments = () => useContext(ProviderAttachmentsContext);
214
+
215
+ export type PromptInputProviderProps = PropsWithChildren<{
216
+ initialInput?: string;
217
+ }>;
218
+
219
+ /**
220
+ * Optional global provider that lifts PromptInput state outside of PromptInput.
221
+ * If you don't use it, PromptInput stays fully self-managed.
222
+ */
223
+ export const PromptInputProvider = ({
224
+ initialInput: initialTextInput = "",
225
+ children,
226
+ }: PromptInputProviderProps) => {
227
+ // ----- textInput state
228
+ const [textInput, setTextInput] = useState(initialTextInput);
229
+ const clearInput = useCallback(() => setTextInput(""), []);
230
+
231
+ // ----- attachments state (global when wrapped)
232
+ const [attachmentFiles, setAttachmentFiles] = useState<(FileUIPart & { id: string })[]>([]);
233
+ const fileInputRef = useRef<HTMLInputElement | null>(null);
234
+ // oxlint-disable-next-line eslint(no-empty-function)
235
+ const openRef = useRef<() => void>(() => {});
236
+
237
+ const add = useCallback((files: File[] | FileList) => {
238
+ const incoming = [...files];
239
+ if (incoming.length === 0) {
240
+ return;
241
+ }
242
+
243
+ setAttachmentFiles((prev) => [
244
+ ...prev,
245
+ ...incoming.map((file) => ({
246
+ filename: file.name,
247
+ id: nanoid(),
248
+ mediaType: file.type,
249
+ type: "file" as const,
250
+ url: URL.createObjectURL(file),
251
+ })),
252
+ ]);
253
+ }, []);
254
+
255
+ const remove = useCallback((id: string) => {
256
+ setAttachmentFiles((prev) => {
257
+ const found = prev.find((f) => f.id === id);
258
+ if (found?.url) {
259
+ URL.revokeObjectURL(found.url);
260
+ }
261
+ return prev.filter((f) => f.id !== id);
262
+ });
263
+ }, []);
264
+
265
+ const clear = useCallback(() => {
266
+ setAttachmentFiles((prev) => {
267
+ for (const f of prev) {
268
+ if (f.url) {
269
+ URL.revokeObjectURL(f.url);
270
+ }
271
+ }
272
+ return [];
273
+ });
274
+ }, []);
275
+
276
+ // Keep a ref to attachments for cleanup on unmount (avoids stale closure)
277
+ const attachmentsRef = useRef(attachmentFiles);
278
+
279
+ useEffect(() => {
280
+ attachmentsRef.current = attachmentFiles;
281
+ }, [attachmentFiles]);
282
+
283
+ // Cleanup blob URLs on unmount to prevent memory leaks
284
+ useEffect(
285
+ () => () => {
286
+ for (const f of attachmentsRef.current) {
287
+ if (f.url) {
288
+ URL.revokeObjectURL(f.url);
289
+ }
290
+ }
291
+ },
292
+ [],
293
+ );
294
+
295
+ const openFileDialog = useCallback(() => {
296
+ openRef.current?.();
297
+ }, []);
298
+
299
+ const attachments = useMemo<AttachmentsContext>(
300
+ () => ({
301
+ add,
302
+ clear,
303
+ fileInputRef,
304
+ files: attachmentFiles,
305
+ openFileDialog,
306
+ remove,
307
+ }),
308
+ [attachmentFiles, add, remove, clear, openFileDialog],
309
+ );
310
+
311
+ const __registerFileInput = useCallback(
312
+ (ref: RefObject<HTMLInputElement | null>, open: () => void) => {
313
+ fileInputRef.current = ref.current;
314
+ openRef.current = open;
315
+ },
316
+ [],
317
+ );
318
+
319
+ const controller = useMemo<PromptInputControllerProps>(
320
+ () => ({
321
+ __registerFileInput,
322
+ attachments,
323
+ textInput: {
324
+ clear: clearInput,
325
+ setInput: setTextInput,
326
+ value: textInput,
327
+ },
328
+ }),
329
+ [textInput, clearInput, attachments, __registerFileInput],
330
+ );
331
+
332
+ return (
333
+ <PromptInputController.Provider value={controller}>
334
+ <ProviderAttachmentsContext.Provider value={attachments}>
335
+ {children}
336
+ </ProviderAttachmentsContext.Provider>
337
+ </PromptInputController.Provider>
338
+ );
339
+ };
340
+
341
+ // ============================================================================
342
+ // Component Context & Hooks
343
+ // ============================================================================
344
+
345
+ const LocalAttachmentsContext = createContext<AttachmentsContext | null>(null);
346
+
347
+ /**
348
+ * Submit-readiness, lifted into `PromptInput` so the guard and the affordance
349
+ * agree. `handleSubmit` is the invariant (it is reachable via `requestSubmit()`
350
+ * or a consumer's own button); `PromptInputSubmit`'s disabled state is the
351
+ * affordance. Both read `canSubmit` — a live-looking button that does nothing
352
+ * is an accessibility lie, and a guard nobody can see is a dead end.
353
+ */
354
+ interface PromptInputSubmitStateContextValue {
355
+ /** Non-whitespace text, or at least one attachment. */
356
+ canSubmit: boolean;
357
+ /**
358
+ * INTERNAL — the uncontrolled textarea reports empty↔non-empty transitions.
359
+ * A boolean, so React bails out on identical state and typing does not make
360
+ * an uncontrolled input re-render per keystroke.
361
+ */
362
+ setHasText: (hasText: boolean) => void;
363
+ /**
364
+ * True while at least one `PromptInputStop` is mounted (#351). When a
365
+ * consumer composes a dedicated Stop control, `PromptInputSubmit` never
366
+ * needs to double as Stop — it stays the Send action in every state.
367
+ */
368
+ hasDedicatedStop: boolean;
369
+ /** Registers a mounted `PromptInputStop`; returns its unregister fn. */
370
+ registerStop: () => () => void;
371
+ }
372
+
373
+ const PromptInputSubmitStateContext = createContext<PromptInputSubmitStateContextValue | null>(
374
+ null,
375
+ );
376
+
377
+ /** Optional — `PromptInputSubmit` may legitimately render outside a `PromptInput`. */
378
+ const useOptionalSubmitState = () => useContext(PromptInputSubmitStateContext);
379
+
380
+ /** The `data-slot` the Enter handler uses to find the send control. */
381
+ const SUBMIT_SLOT = "prompt-input-submit";
382
+
383
+ export const usePromptInputAttachments = () => {
384
+ // Prefer local context (inside PromptInput) as it has validation, fall back to provider
385
+ const provider = useOptionalProviderAttachments();
386
+ const local = useContext(LocalAttachmentsContext);
387
+ const context = local ?? provider;
388
+ if (!context) {
389
+ throw new Error(
390
+ "usePromptInputAttachments must be used within a PromptInput or PromptInputProvider",
391
+ );
392
+ }
393
+ return context;
394
+ };
395
+
396
+ // ============================================================================
397
+ // Referenced Sources (Local to PromptInput)
398
+ // ============================================================================
399
+
400
+ export interface ReferencedSourcesContext {
401
+ sources: (SourceDocumentUIPart & { id: string })[];
402
+ add: (sources: SourceDocumentUIPart[] | SourceDocumentUIPart) => void;
403
+ remove: (id: string) => void;
404
+ clear: () => void;
405
+ }
406
+
407
+ export const LocalReferencedSourcesContext = createContext<ReferencedSourcesContext | null>(null);
408
+
409
+ export const usePromptInputReferencedSources = () => {
410
+ const ctx = useContext(LocalReferencedSourcesContext);
411
+ if (!ctx) {
412
+ throw new Error(
413
+ "usePromptInputReferencedSources must be used within a LocalReferencedSourcesContext.Provider",
414
+ );
415
+ }
416
+ return ctx;
417
+ };
418
+
419
+ export type PromptInputActionAddAttachmentsProps = ComponentProps<typeof DropdownMenuItem> & {
420
+ label?: string;
421
+ };
422
+
423
+ export const PromptInputActionAddAttachments = ({
424
+ label = "Add photos or files",
425
+ ...props
426
+ }: PromptInputActionAddAttachmentsProps) => {
427
+ const attachments = usePromptInputAttachments();
428
+
429
+ const handleSelect = useCallback(
430
+ (e: Event) => {
431
+ e.preventDefault();
432
+ attachments.openFileDialog();
433
+ },
434
+ [attachments],
435
+ );
436
+
437
+ return (
438
+ <DropdownMenuItem {...props} onSelect={handleSelect}>
439
+ <ImageIcon className="me-2 size-4" /> {label}
440
+ </DropdownMenuItem>
441
+ );
442
+ };
443
+
444
+ export type PromptInputActionAddScreenshotProps = ComponentProps<typeof DropdownMenuItem> & {
445
+ label?: string;
446
+ };
447
+
448
+ export const PromptInputActionAddScreenshot = ({
449
+ label = "Take screenshot",
450
+ onSelect,
451
+ ...props
452
+ }: PromptInputActionAddScreenshotProps) => {
453
+ const attachments = usePromptInputAttachments();
454
+
455
+ const handleSelect = useCallback(
456
+ async (event: Event) => {
457
+ onSelect?.(event);
458
+ if (event.defaultPrevented) {
459
+ return;
460
+ }
461
+
462
+ try {
463
+ const screenshot = await captureScreenshot();
464
+ if (screenshot) {
465
+ attachments.add([screenshot]);
466
+ }
467
+ } catch (error) {
468
+ if (
469
+ error instanceof DOMException &&
470
+ (error.name === "NotAllowedError" || error.name === "AbortError")
471
+ ) {
472
+ return;
473
+ }
474
+ throw error;
475
+ }
476
+ },
477
+ [onSelect, attachments],
478
+ );
479
+
480
+ return (
481
+ <DropdownMenuItem {...props} onSelect={handleSelect}>
482
+ <Monitor className="me-2 size-4" />
483
+ {label}
484
+ </DropdownMenuItem>
485
+ );
486
+ };
487
+
488
+ export interface PromptInputMessage {
489
+ text: string;
490
+ files: FileUIPart[];
491
+ }
492
+
493
+ export type PromptInputProps = Omit<HTMLAttributes<HTMLFormElement>, "onSubmit" | "onError"> & {
494
+ // e.g., "image/*" or leave undefined for any
495
+ accept?: string;
496
+ multiple?: boolean;
497
+ // When true, accepts drops anywhere on document. Default false (opt-in).
498
+ globalDrop?: boolean;
499
+ // Render a hidden input with given name and keep it in sync for native form posts. Default false.
500
+ syncHiddenInput?: boolean;
501
+ // Minimal constraints
502
+ maxFiles?: number;
503
+ // bytes
504
+ maxFileSize?: number;
505
+ /**
506
+ * The fill of the inner well (the `InputGroup` `variant`). `"surface"`
507
+ * (default) is the standard muted composer look; `"card"` renders a
508
+ * `bg-card` well for nesting inside an already-tinted outer frame — the
509
+ * "double card" look (#254). e.g.
510
+ * `<div className="rounded-xl bg-surface-muted p-1.5"><PromptInput tone="card">…</PromptInput></div>`.
511
+ * The well fill is NOT universally "white": `--card` reads lighter
512
+ * (raised) than `--surface-muted` on light themes, but darker (recessed) on
513
+ * dark — the same theme-dependent trade-off documented
514
+ * for `bg-surface-muted`-as-a-well in `styling-and-tokens.md`. The well
515
+ * stays a legible, distinct tone against the outer frame in every theme;
516
+ * only the "raised" framing is light-themes-specific.
517
+ */
518
+ tone?: "surface" | "card";
519
+ onError?: (err: { code: "max_files" | "max_file_size" | "accept"; message: string }) => void;
520
+ onSubmit: (
521
+ message: PromptInputMessage,
522
+ event: FormEvent<HTMLFormElement>,
523
+ ) => void | Promise<void>;
524
+ /**
525
+ * Classes for the inner surface (the visible `InputGroup` well), e.g. to shape
526
+ * its corners. `className` styles the outer `<form>`; this styles the well —
527
+ * the only way to reach it from outside (used by `Composer`).
528
+ */
529
+ surfaceClassName?: string;
530
+ };
531
+
532
+ export const PromptInput = ({
533
+ className,
534
+ surfaceClassName,
535
+ tone = "surface",
536
+ accept,
537
+ multiple,
538
+ globalDrop,
539
+ syncHiddenInput,
540
+ maxFiles,
541
+ maxFileSize,
542
+ onError,
543
+ onSubmit,
544
+ children,
545
+ ...props
546
+ }: PromptInputProps) => {
547
+ // Try to use a provider controller if present
548
+ const controller = useOptionalPromptInputController();
549
+ const usingProvider = !!controller;
550
+
551
+ // Microcopy goes through the locale seam. `useLocale` is provider-optional —
552
+ // with no <LocaleProvider> it returns the shipped English defaults, so this is
553
+ // not a breaking change and needs no ancestor. See ADR 0017.
554
+ const { t } = useLocale();
555
+
556
+ // Refs
557
+ const inputRef = useRef<HTMLInputElement | null>(null);
558
+ const formRef = useRef<HTMLFormElement | null>(null);
559
+
560
+ // ----- Local attachments (only used when no provider)
561
+ const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]);
562
+ const files = usingProvider ? controller.attachments.files : items;
563
+
564
+ // ----- Submit readiness (drives both the guard and the disabled affordance)
565
+ const [hasLocalText, setHasLocalText] = useState(false);
566
+ const hasText = usingProvider ? controller.textInput.value.trim().length > 0 : hasLocalText;
567
+
568
+ // ----- Dedicated Stop control registration (#351 — the composed "separate"
569
+ // arrangement). A ref counter tolerates >1 mounted PromptInputStop; the
570
+ // boolean state is what's actually read, so React bails out on identical
571
+ // values the same way `setHasText` does.
572
+ const [hasDedicatedStop, setHasDedicatedStop] = useState(false);
573
+ const stopCountRef = useRef(0);
574
+ const registerStop = useCallback(() => {
575
+ stopCountRef.current += 1;
576
+ setHasDedicatedStop(true);
577
+ return () => {
578
+ stopCountRef.current = Math.max(0, stopCountRef.current - 1);
579
+ if (stopCountRef.current === 0) {
580
+ setHasDedicatedStop(false);
581
+ }
582
+ };
583
+ }, []);
584
+
585
+ // ----- Local referenced sources (always local to PromptInput)
586
+ const [referencedSources, setReferencedSources] = useState<
587
+ (SourceDocumentUIPart & { id: string })[]
588
+ >([]);
589
+
590
+ // Keep a ref to files for cleanup on unmount (avoids stale closure)
591
+ const filesRef = useRef(files);
592
+
593
+ useEffect(() => {
594
+ filesRef.current = files;
595
+ }, [files]);
596
+
597
+ const openFileDialogLocal = useCallback(() => {
598
+ inputRef.current?.click();
599
+ }, []);
600
+
601
+ const matchesAccept = useCallback(
602
+ (f: File) => {
603
+ if (!accept || accept.trim() === "") {
604
+ return true;
605
+ }
606
+
607
+ const patterns = accept
608
+ .split(",")
609
+ .map((s) => s.trim())
610
+ .filter(Boolean);
611
+
612
+ return patterns.some((pattern) => {
613
+ if (pattern.endsWith("/*")) {
614
+ // e.g: image/* -> image/
615
+ const prefix = pattern.slice(0, -1);
616
+ return f.type.startsWith(prefix);
617
+ }
618
+ return f.type === pattern;
619
+ });
620
+ },
621
+ [accept],
622
+ );
623
+
624
+ const addLocal = useCallback(
625
+ (fileList: File[] | FileList) => {
626
+ const incoming = [...fileList];
627
+ const accepted = incoming.filter((f) => matchesAccept(f));
628
+ if (incoming.length && accepted.length === 0) {
629
+ onError?.({
630
+ code: "accept",
631
+ message: t("ai.promptInput.errorAccept"),
632
+ });
633
+ return;
634
+ }
635
+ const withinSize = (f: File) => (maxFileSize ? f.size <= maxFileSize : true);
636
+ const sized = accepted.filter(withinSize);
637
+ if (accepted.length > 0 && sized.length === 0) {
638
+ onError?.({
639
+ code: "max_file_size",
640
+ message: t("ai.promptInput.errorMaxFileSize"),
641
+ });
642
+ return;
643
+ }
644
+
645
+ setItems((prev) => {
646
+ const capacity =
647
+ typeof maxFiles === "number" ? Math.max(0, maxFiles - prev.length) : undefined;
648
+ const capped = typeof capacity === "number" ? sized.slice(0, capacity) : sized;
649
+ if (typeof capacity === "number" && sized.length > capacity) {
650
+ onError?.({
651
+ code: "max_files",
652
+ message: t("ai.promptInput.errorMaxFiles"),
653
+ });
654
+ }
655
+ const next: (FileUIPart & { id: string })[] = [];
656
+ for (const file of capped) {
657
+ next.push({
658
+ filename: file.name,
659
+ id: nanoid(),
660
+ mediaType: file.type,
661
+ type: "file",
662
+ url: URL.createObjectURL(file),
663
+ });
664
+ }
665
+ return [...prev, ...next];
666
+ });
667
+ },
668
+ [matchesAccept, maxFiles, maxFileSize, onError],
669
+ );
670
+
671
+ const removeLocal = useCallback(
672
+ (id: string) =>
673
+ setItems((prev) => {
674
+ const found = prev.find((file) => file.id === id);
675
+ if (found?.url) {
676
+ URL.revokeObjectURL(found.url);
677
+ }
678
+ return prev.filter((file) => file.id !== id);
679
+ }),
680
+ [],
681
+ );
682
+
683
+ // Wrapper that validates files before calling provider's add
684
+ const addWithProviderValidation = useCallback(
685
+ (fileList: File[] | FileList) => {
686
+ const incoming = [...fileList];
687
+ const accepted = incoming.filter((f) => matchesAccept(f));
688
+ if (incoming.length && accepted.length === 0) {
689
+ onError?.({
690
+ code: "accept",
691
+ message: t("ai.promptInput.errorAccept"),
692
+ });
693
+ return;
694
+ }
695
+ const withinSize = (f: File) => (maxFileSize ? f.size <= maxFileSize : true);
696
+ const sized = accepted.filter(withinSize);
697
+ if (accepted.length > 0 && sized.length === 0) {
698
+ onError?.({
699
+ code: "max_file_size",
700
+ message: t("ai.promptInput.errorMaxFileSize"),
701
+ });
702
+ return;
703
+ }
704
+
705
+ const currentCount = files.length;
706
+ const capacity =
707
+ typeof maxFiles === "number" ? Math.max(0, maxFiles - currentCount) : undefined;
708
+ const capped = typeof capacity === "number" ? sized.slice(0, capacity) : sized;
709
+ if (typeof capacity === "number" && sized.length > capacity) {
710
+ onError?.({
711
+ code: "max_files",
712
+ message: t("ai.promptInput.errorMaxFiles"),
713
+ });
714
+ }
715
+
716
+ if (capped.length > 0) {
717
+ controller?.attachments.add(capped);
718
+ }
719
+ },
720
+ [matchesAccept, maxFileSize, maxFiles, onError, files.length, controller],
721
+ );
722
+
723
+ const clearAttachments = useCallback(
724
+ () =>
725
+ usingProvider
726
+ ? controller?.attachments.clear()
727
+ : setItems((prev) => {
728
+ for (const file of prev) {
729
+ if (file.url) {
730
+ URL.revokeObjectURL(file.url);
731
+ }
732
+ }
733
+ return [];
734
+ }),
735
+ [usingProvider, controller],
736
+ );
737
+
738
+ const clearReferencedSources = useCallback(() => setReferencedSources([]), []);
739
+
740
+ const add = usingProvider ? addWithProviderValidation : addLocal;
741
+ const remove = usingProvider ? controller.attachments.remove : removeLocal;
742
+ const openFileDialog = usingProvider
743
+ ? controller.attachments.openFileDialog
744
+ : openFileDialogLocal;
745
+
746
+ const clear = useCallback(() => {
747
+ clearAttachments();
748
+ clearReferencedSources();
749
+ }, [clearAttachments, clearReferencedSources]);
750
+
751
+ // Let provider know about our hidden file input so external menus can call openFileDialog()
752
+ useEffect(() => {
753
+ if (!usingProvider) {
754
+ return;
755
+ }
756
+ controller.__registerFileInput(inputRef, () => inputRef.current?.click());
757
+ }, [usingProvider, controller]);
758
+
759
+ // Note: File input cannot be programmatically set for security reasons
760
+ // The syncHiddenInput prop is no longer functional
761
+ useEffect(() => {
762
+ if (syncHiddenInput && inputRef.current && files.length === 0) {
763
+ inputRef.current.value = "";
764
+ }
765
+ }, [files, syncHiddenInput]);
766
+
767
+ // Attach drop handlers on nearest form and document (opt-in)
768
+ useEffect(() => {
769
+ const form = formRef.current;
770
+ if (!form) {
771
+ return;
772
+ }
773
+ if (globalDrop) {
774
+ // when global drop is on, let the document-level handler own drops
775
+ return;
776
+ }
777
+
778
+ const onDragOver = (e: DragEvent) => {
779
+ if (e.dataTransfer?.types?.includes("Files")) {
780
+ e.preventDefault();
781
+ }
782
+ };
783
+ const onDrop = (e: DragEvent) => {
784
+ if (e.dataTransfer?.types?.includes("Files")) {
785
+ e.preventDefault();
786
+ }
787
+ if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
788
+ add(e.dataTransfer.files);
789
+ }
790
+ };
791
+ form.addEventListener("dragover", onDragOver);
792
+ form.addEventListener("drop", onDrop);
793
+ return () => {
794
+ form.removeEventListener("dragover", onDragOver);
795
+ form.removeEventListener("drop", onDrop);
796
+ };
797
+ }, [add, globalDrop]);
798
+
799
+ useEffect(() => {
800
+ if (!globalDrop) {
801
+ return;
802
+ }
803
+
804
+ const onDragOver = (e: DragEvent) => {
805
+ if (e.dataTransfer?.types?.includes("Files")) {
806
+ e.preventDefault();
807
+ }
808
+ };
809
+ const onDrop = (e: DragEvent) => {
810
+ if (e.dataTransfer?.types?.includes("Files")) {
811
+ e.preventDefault();
812
+ }
813
+ if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
814
+ add(e.dataTransfer.files);
815
+ }
816
+ };
817
+ document.addEventListener("dragover", onDragOver);
818
+ document.addEventListener("drop", onDrop);
819
+ return () => {
820
+ document.removeEventListener("dragover", onDragOver);
821
+ document.removeEventListener("drop", onDrop);
822
+ };
823
+ }, [add, globalDrop]);
824
+
825
+ useEffect(
826
+ () => () => {
827
+ if (!usingProvider) {
828
+ for (const f of filesRef.current) {
829
+ if (f.url) {
830
+ URL.revokeObjectURL(f.url);
831
+ }
832
+ }
833
+ }
834
+ },
835
+ [usingProvider],
836
+ );
837
+
838
+ const handleChange: ChangeEventHandler<HTMLInputElement> = useCallback(
839
+ (event) => {
840
+ if (event.currentTarget.files) {
841
+ add(event.currentTarget.files);
842
+ }
843
+ // Reset input value to allow selecting files that were previously removed
844
+ event.currentTarget.value = "";
845
+ },
846
+ [add],
847
+ );
848
+
849
+ const attachmentsCtx = useMemo<AttachmentsContext>(
850
+ () => ({
851
+ add,
852
+ clear: clearAttachments,
853
+ fileInputRef: inputRef,
854
+ files: files.map((item) => ({ ...item, id: item.id })),
855
+ openFileDialog,
856
+ remove,
857
+ }),
858
+ [files, add, remove, clearAttachments, openFileDialog],
859
+ );
860
+
861
+ const submitState = useMemo<PromptInputSubmitStateContextValue>(
862
+ () => ({
863
+ canSubmit: hasText || files.length > 0,
864
+ hasDedicatedStop,
865
+ registerStop,
866
+ setHasText: setHasLocalText,
867
+ }),
868
+ [hasText, files.length, hasDedicatedStop, registerStop],
869
+ );
870
+
871
+ const refsCtx = useMemo<ReferencedSourcesContext>(
872
+ () => ({
873
+ add: (incoming: SourceDocumentUIPart[] | SourceDocumentUIPart) => {
874
+ const array = Array.isArray(incoming) ? incoming : [incoming];
875
+ setReferencedSources((prev) => [...prev, ...array.map((s) => ({ ...s, id: nanoid() }))]);
876
+ },
877
+ clear: clearReferencedSources,
878
+ remove: (id: string) => {
879
+ setReferencedSources((prev) => prev.filter((s) => s.id !== id));
880
+ },
881
+ sources: referencedSources,
882
+ }),
883
+ [referencedSources, clearReferencedSources],
884
+ );
885
+
886
+ const handleSubmit: FormEventHandler<HTMLFormElement> = useCallback(
887
+ async (event) => {
888
+ event.preventDefault();
889
+
890
+ const form = event.currentTarget;
891
+ const text = usingProvider
892
+ ? controller.textInput.value
893
+ : (() => {
894
+ const formData = new FormData(form);
895
+ return (formData.get("message") as string) || "";
896
+ })();
897
+
898
+ // Nothing to send: no text AND no attachments. This runs BEFORE the reset
899
+ // below — a later guard would still have wiped the composer. Only the
900
+ // guard trims; the untrimmed `text` still reaches `onSubmit`, preserving
901
+ // the existing payload semantics. An attachments-only submit (no text) is
902
+ // legitimate and must still go through.
903
+ if (text.trim().length === 0 && files.length === 0) {
904
+ return;
905
+ }
906
+
907
+ // Reset form immediately after capturing text to avoid race condition
908
+ // where user input during async blob conversion would be lost
909
+ if (!usingProvider) {
910
+ form.reset();
911
+ // `form.reset()` fires no change event, so tell the submit affordance.
912
+ setHasLocalText(false);
913
+ }
914
+
915
+ try {
916
+ // Convert blob URLs to data URLs asynchronously
917
+ const convertedFiles: FileUIPart[] = await Promise.all(
918
+ files.map(async ({ id: _id, ...item }) => {
919
+ if (item.url?.startsWith("blob:")) {
920
+ const dataUrl = await convertBlobUrlToDataUrl(item.url);
921
+ // If conversion failed, keep the original blob URL
922
+ return {
923
+ ...item,
924
+ url: dataUrl ?? item.url,
925
+ };
926
+ }
927
+ return item;
928
+ }),
929
+ );
930
+
931
+ const result = onSubmit({ files: convertedFiles, text }, event);
932
+
933
+ // Handle both sync and async onSubmit
934
+ if (result instanceof Promise) {
935
+ try {
936
+ await result;
937
+ clear();
938
+ if (usingProvider) {
939
+ controller.textInput.clear();
940
+ }
941
+ } catch {
942
+ // Don't clear on error - user may want to retry
943
+ }
944
+ } else {
945
+ // Sync function completed without throwing, clear inputs
946
+ clear();
947
+ if (usingProvider) {
948
+ controller.textInput.clear();
949
+ }
950
+ }
951
+ } catch {
952
+ // Don't clear on error - user may want to retry
953
+ }
954
+ },
955
+ [usingProvider, controller, files, onSubmit, clear],
956
+ );
957
+
958
+ // Render with or without local provider
959
+ const inner = (
960
+ <>
961
+ <input
962
+ accept={accept}
963
+ aria-label={t("ai.promptInput.uploadFiles")}
964
+ className="hidden"
965
+ multiple={multiple}
966
+ onChange={handleChange}
967
+ ref={inputRef}
968
+ title={t("ai.promptInput.uploadFiles")}
969
+ type="file"
970
+ />
971
+ <form className={cn("w-full", className)} onSubmit={handleSubmit} ref={formRef} {...props}>
972
+ {/* The composer is a soft fill + focus ring, not a hard box (#194,
973
+ research 02 §3a BTN-3 / 08 §C.1): the chat footer already draws a
974
+ `border-t` (chat-shell.tsx), so the box border was redundant. */}
975
+ <InputGroup variant={tone} className={cn("overflow-hidden", surfaceClassName)}>
976
+ {children}
977
+ </InputGroup>
978
+ </form>
979
+ </>
980
+ );
981
+
982
+ const withReferencedSources = (
983
+ <LocalReferencedSourcesContext.Provider value={refsCtx}>
984
+ <PromptInputSubmitStateContext.Provider value={submitState}>
985
+ {inner}
986
+ </PromptInputSubmitStateContext.Provider>
987
+ </LocalReferencedSourcesContext.Provider>
988
+ );
989
+
990
+ // Always provide LocalAttachmentsContext so children get validated add function
991
+ return (
992
+ <LocalAttachmentsContext.Provider value={attachmentsCtx}>
993
+ {withReferencedSources}
994
+ </LocalAttachmentsContext.Provider>
995
+ );
996
+ };
997
+
998
+ export type PromptInputBodyProps = HTMLAttributes<HTMLDivElement>;
999
+
1000
+ export const PromptInputBody = ({ className, ...props }: PromptInputBodyProps) => (
1001
+ <div className={cn("contents", className)} {...props} />
1002
+ );
1003
+
1004
+ export type PromptInputTextareaProps = ComponentProps<typeof InputGroupTextarea>;
1005
+
1006
+ export const PromptInputTextarea = ({
1007
+ onChange,
1008
+ onKeyDown,
1009
+ className,
1010
+ placeholder,
1011
+ ...props
1012
+ }: PromptInputTextareaProps) => {
1013
+ const { t } = useLocale();
1014
+ const controller = useOptionalPromptInputController();
1015
+ const attachments = usePromptInputAttachments();
1016
+ const submitState = useOptionalSubmitState();
1017
+ const [isComposing, setIsComposing] = useState(false);
1018
+
1019
+ const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = useCallback(
1020
+ (e) => {
1021
+ // Call the external onKeyDown handler first
1022
+ onKeyDown?.(e);
1023
+
1024
+ // If the external handler prevented default, don't run internal logic
1025
+ if (e.defaultPrevented) {
1026
+ return;
1027
+ }
1028
+
1029
+ if (e.key === "Enter") {
1030
+ if (isComposing || e.nativeEvent.isComposing) {
1031
+ return;
1032
+ }
1033
+ if (e.shiftKey) {
1034
+ return;
1035
+ }
1036
+ e.preventDefault();
1037
+
1038
+ // Don't submit when the send control is unavailable. Query by data-slot,
1039
+ // NOT `button[type="submit"]`: PromptInputSubmit flips to type="button"
1040
+ // while generating, so the old selector matched nothing mid-stream and
1041
+ // `null?.disabled` (undefined, falsy) let Enter send a second message.
1042
+ const { form } = e.currentTarget;
1043
+ const submitControl = form?.querySelector<HTMLButtonElement>(
1044
+ `[data-slot="${SUBMIT_SLOT}"]`,
1045
+ );
1046
+ // `data-action` is the current contract (#351): "stop" blocks Enter,
1047
+ // "send" (including the running+typed flip) lets it through. Fall back
1048
+ // to the older `data-generating` flag for a consumer-supplied control
1049
+ // that still only sets that attribute.
1050
+ const action =
1051
+ submitControl?.dataset.action ??
1052
+ (submitControl?.dataset.generating === "true" ? "stop" : undefined);
1053
+ // `aria-disabled` is checked alongside the real `disabled` property: the
1054
+ // resting empty-composer state marks the control aria-disabled so it stays
1055
+ // FOCUSABLE (a real `disabled` button is skipped by the tab order and
1056
+ // announces nothing), while a consumer-supplied `disabled` is a genuine
1057
+ // DOM disable. Enter must be refused for both.
1058
+ if (
1059
+ submitControl?.disabled ||
1060
+ submitControl?.getAttribute("aria-disabled") === "true" ||
1061
+ action === "stop"
1062
+ ) {
1063
+ return;
1064
+ }
1065
+
1066
+ form?.requestSubmit();
1067
+ }
1068
+
1069
+ // Remove last attachment when Backspace is pressed and textarea is empty
1070
+ if (e.key === "Backspace" && e.currentTarget.value === "" && attachments.files.length > 0) {
1071
+ e.preventDefault();
1072
+ const lastAttachment = attachments.files.at(-1);
1073
+ if (lastAttachment) {
1074
+ attachments.remove(lastAttachment.id);
1075
+ }
1076
+ }
1077
+ },
1078
+ [onKeyDown, isComposing, attachments],
1079
+ );
1080
+
1081
+ const handlePaste: ClipboardEventHandler<HTMLTextAreaElement> = useCallback(
1082
+ (event) => {
1083
+ const items = event.clipboardData?.items;
1084
+
1085
+ if (!items) {
1086
+ return;
1087
+ }
1088
+
1089
+ const files: File[] = [];
1090
+
1091
+ for (const item of items) {
1092
+ if (item.kind === "file") {
1093
+ const file = item.getAsFile();
1094
+ if (file) {
1095
+ files.push(file);
1096
+ }
1097
+ }
1098
+ }
1099
+
1100
+ if (files.length > 0) {
1101
+ event.preventDefault();
1102
+ attachments.add(files);
1103
+ }
1104
+ },
1105
+ [attachments],
1106
+ );
1107
+
1108
+ const handleCompositionEnd = useCallback(() => setIsComposing(false), []);
1109
+ const handleCompositionStart = useCallback(() => setIsComposing(true), []);
1110
+
1111
+ const controlledProps = controller
1112
+ ? {
1113
+ onChange: (e: ChangeEvent<HTMLTextAreaElement>) => {
1114
+ controller.textInput.setInput(e.currentTarget.value);
1115
+ onChange?.(e);
1116
+ },
1117
+ value: controller.textInput.value,
1118
+ }
1119
+ : {
1120
+ // Uncontrolled: report only the empty↔non-empty transition so the submit
1121
+ // affordance can enable/disable. Boolean state ⇒ React bails out on
1122
+ // identical values, so this stays cheap per keystroke.
1123
+ onChange: (e: ChangeEvent<HTMLTextAreaElement>) => {
1124
+ submitState?.setHasText(e.currentTarget.value.trim().length > 0);
1125
+ onChange?.(e);
1126
+ },
1127
+ };
1128
+
1129
+ return (
1130
+ <InputGroupTextarea
1131
+ className={cn("field-sizing-content max-h-48 min-h-10", className)}
1132
+ name="message"
1133
+ onCompositionEnd={handleCompositionEnd}
1134
+ onCompositionStart={handleCompositionStart}
1135
+ onKeyDown={handleKeyDown}
1136
+ onPaste={handlePaste}
1137
+ placeholder={placeholder ?? t("ai.promptInput.placeholder")}
1138
+ {...props}
1139
+ {...controlledProps}
1140
+ />
1141
+ );
1142
+ };
1143
+
1144
+ export type PromptInputHeaderProps = Omit<ComponentProps<typeof InputGroupAddon>, "align">;
1145
+
1146
+ export const PromptInputHeader = ({ className, ...props }: PromptInputHeaderProps) => (
1147
+ <InputGroupAddon
1148
+ align="block-end"
1149
+ className={cn("order-first flex-wrap gap-1", className)}
1150
+ {...props}
1151
+ />
1152
+ );
1153
+
1154
+ export type PromptInputFooterProps = Omit<ComponentProps<typeof InputGroupAddon>, "align">;
1155
+
1156
+ export const PromptInputFooter = ({ className, ...props }: PromptInputFooterProps) => (
1157
+ <InputGroupAddon
1158
+ align="block-end"
1159
+ className={cn("justify-between gap-1", className)}
1160
+ {...props}
1161
+ />
1162
+ );
1163
+
1164
+ export type PromptInputToolsProps = HTMLAttributes<HTMLDivElement>;
1165
+
1166
+ export const PromptInputTools = ({ className, ...props }: PromptInputToolsProps) => (
1167
+ <div className={cn("flex min-w-0 items-center gap-1", className)} {...props} />
1168
+ );
1169
+
1170
+ export type PromptInputButtonTooltip =
1171
+ | string
1172
+ | {
1173
+ content: ReactNode;
1174
+ shortcut?: string;
1175
+ side?: ComponentProps<typeof TooltipContent>["side"];
1176
+ };
1177
+
1178
+ export type PromptInputButtonProps = ComponentProps<typeof InputGroupButton> & {
1179
+ tooltip?: PromptInputButtonTooltip;
1180
+ };
1181
+
1182
+ /**
1183
+ * True when `node` renders any visible text (a non-blank string/number node,
1184
+ * at any nesting depth) rather than icon-only content (bare SVGs/icon
1185
+ * components). Adapts the same "inspect the children's shape" notion the
1186
+ * size heuristic below uses (`Children.count(...) > 1`) to the question that
1187
+ * actually matters for the accessible name: is there a visible label already,
1188
+ * not merely how many top-level children there are.
1189
+ */
1190
+ const hasVisibleText = (node: ReactNode): boolean => {
1191
+ if (typeof node === "string" || typeof node === "number") {
1192
+ return String(node).trim().length > 0;
1193
+ }
1194
+ if (Array.isArray(node)) {
1195
+ return node.some(hasVisibleText);
1196
+ }
1197
+ if (isValidElement<{ children?: ReactNode }>(node)) {
1198
+ return hasVisibleText(node.props.children);
1199
+ }
1200
+ return false;
1201
+ };
1202
+
1203
+ export const PromptInputButton = ({
1204
+ variant = "ghost",
1205
+ className,
1206
+ size,
1207
+ tooltip,
1208
+ "aria-label": ariaLabelProp,
1209
+ ...props
1210
+ }: PromptInputButtonProps) => {
1211
+ const newSize = size ?? (Children.count(props.children) > 1 ? "sm" : "icon-sm");
1212
+
1213
+ const tooltipContent = tooltip
1214
+ ? typeof tooltip === "string"
1215
+ ? tooltip
1216
+ : tooltip.content
1217
+ : undefined;
1218
+
1219
+ // Icon-only buttons (Attach, Voice, …) commonly carry ONLY a tooltip and no
1220
+ // visible text, so the tooltip is the only description of what the button
1221
+ // does — default the accessible name to it. A button that already has a
1222
+ // VISIBLE TEXT label must NOT have that label replaced by the tooltip (WCAG
1223
+ // 2.5.3 Label in Name) — the derived name is opt-in to icon-only buttons
1224
+ // only. An explicit, DEFINED `aria-label` still wins over the derived
1225
+ // default; an explicitly-passed `aria-label={undefined}` must NOT clobber
1226
+ // it, so we pull `aria-label` out of `props` above (it is applied, as the
1227
+ // final computed value, AFTER the `{...props}` spread below — the spread
1228
+ // can no longer reintroduce the `undefined` key over it).
1229
+ const isIconOnly = !hasVisibleText(props.children);
1230
+ const ariaLabel =
1231
+ ariaLabelProp ??
1232
+ (isIconOnly && typeof tooltipContent === "string" ? tooltipContent : undefined);
1233
+
1234
+ const button = (
1235
+ <InputGroupButton
1236
+ className={cn(className)}
1237
+ size={newSize}
1238
+ type="button"
1239
+ variant={variant}
1240
+ {...props}
1241
+ aria-label={ariaLabel}
1242
+ />
1243
+ );
1244
+
1245
+ if (!tooltip) {
1246
+ return button;
1247
+ }
1248
+
1249
+ const shortcut = typeof tooltip === "string" ? undefined : tooltip.shortcut;
1250
+ const side = typeof tooltip === "string" ? "top" : (tooltip.side ?? "top");
1251
+
1252
+ // Self-provide so the button works anywhere — a consumer rendering a bare
1253
+ // composer (a block, a story) shouldn't have to remember to mount a global
1254
+ // TooltipProvider. Mirrors the ThemeSwitcher self-provision convention.
1255
+ return (
1256
+ <TooltipProvider>
1257
+ <Tooltip>
1258
+ <TooltipTrigger asChild>{button}</TooltipTrigger>
1259
+ <TooltipContent side={side}>
1260
+ {tooltipContent}
1261
+ {shortcut && <span className="ms-2 text-muted-foreground">{shortcut}</span>}
1262
+ </TooltipContent>
1263
+ </Tooltip>
1264
+ </TooltipProvider>
1265
+ );
1266
+ };
1267
+
1268
+ export type PromptInputActionMenuProps = ComponentProps<typeof DropdownMenu>;
1269
+ export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => (
1270
+ <DropdownMenu {...props} />
1271
+ );
1272
+
1273
+ export type PromptInputActionMenuTriggerProps = PromptInputButtonProps;
1274
+
1275
+ export const PromptInputActionMenuTrigger = ({
1276
+ className,
1277
+ children,
1278
+ ...props
1279
+ }: PromptInputActionMenuTriggerProps) => (
1280
+ <DropdownMenuTrigger asChild>
1281
+ <PromptInputButton className={className} {...props}>
1282
+ {children ?? <PlusIcon className="size-4" />}
1283
+ </PromptInputButton>
1284
+ </DropdownMenuTrigger>
1285
+ );
1286
+
1287
+ export type PromptInputActionMenuContentProps = ComponentProps<typeof DropdownMenuContent>;
1288
+ export const PromptInputActionMenuContent = ({
1289
+ className,
1290
+ ...props
1291
+ }: PromptInputActionMenuContentProps) => (
1292
+ <DropdownMenuContent align="start" className={cn(className)} {...props} />
1293
+ );
1294
+
1295
+ export type PromptInputActionMenuItemProps = ComponentProps<typeof DropdownMenuItem>;
1296
+ export const PromptInputActionMenuItem = ({
1297
+ className,
1298
+ ...props
1299
+ }: PromptInputActionMenuItemProps) => <DropdownMenuItem className={cn(className)} {...props} />;
1300
+
1301
+ // Note: Actions that perform side-effects (like opening a file dialog)
1302
+ // are provided in opt-in modules (e.g., prompt-input-attachments).
1303
+
1304
+ /**
1305
+ * Which action the merged primary control currently performs (#351). The
1306
+ * component owns the AFFORDANCE only — it always invokes `onSubmit` (via the
1307
+ * normal form submit) once the action is "send", never `onStop`. What a
1308
+ * mid-turn submit MEANS (queued, interleaved, dropped) is entirely up to the
1309
+ * app's own `onSubmit`/runtime — brand-ui asserts nothing about that (D5).
1310
+ */
1311
+ export type PromptInputSubmitAction = "send" | "stop";
1312
+
1313
+ export type PromptInputSubmitProps = ComponentProps<typeof InputGroupButton> & {
1314
+ status?: ChatStatus;
1315
+ onStop?: () => void;
1316
+ /**
1317
+ * Glyph for the SEND action only — survives the send↔stop flip, so a
1318
+ * consumer's resting glyph (e.g. `Composer`'s circular arrow) is no longer
1319
+ * lost while generating with a non-empty composer. Prefer this over
1320
+ * `children`.
1321
+ */
1322
+ sendIcon?: ReactNode;
1323
+ /**
1324
+ * @deprecated Replaces the glyph for **every** status — ready, submitted,
1325
+ * streaming AND error — which is why generating/error affordances can
1326
+ * disappear even though the control is still live. Prefer `sendIcon`,
1327
+ * which only ever replaces the resting Send glyph.
1328
+ */
1329
+ children?: ReactNode;
1330
+ };
1331
+
1332
+ /**
1333
+ * Merged primary-action contract (#351):
1334
+ * 1. `ready`/`undefined`/`error` → **Send** (unchanged).
1335
+ * 2. `submitted`/`streaming` + composer EMPTY → **Stop** (unchanged).
1336
+ * 3. `submitted`/`streaming` + composer NON-EMPTY → **Send** (the fix — a
1337
+ * follow-up can always be composed and submitted mid-turn).
1338
+ * 4. A `PromptInputStop` mounted alongside → this control is ALWAYS Send;
1339
+ * the dedicated control owns stopping (the composed "separate" shape).
1340
+ */
1341
+ export const PromptInputSubmit = ({
1342
+ className,
1343
+ variant = "default",
1344
+ size = "icon-sm",
1345
+ status,
1346
+ onStop,
1347
+ onClick,
1348
+ children,
1349
+ sendIcon,
1350
+ disabled,
1351
+ ...props
1352
+ }: PromptInputSubmitProps) => {
1353
+ const { t } = useLocale();
1354
+ const isGenerating = status === "submitted" || status === "streaming";
1355
+ const submitState = useOptionalSubmitState();
1356
+
1357
+ const action: PromptInputSubmitAction =
1358
+ isGenerating && !submitState?.canSubmit && !submitState?.hasDedicatedStop ? "stop" : "send";
1359
+
1360
+ // Auto-disable only the Send action at rest, and only inside a PromptInput
1361
+ // (a standalone submit has no context and keeps working). `error` must stay
1362
+ // clickable to retry, and the Stop action is never auto-disabled. An
1363
+ // explicit `disabled` prop always wins.
1364
+ //
1365
+ // NOTE this drives `aria-disabled`, not the native attribute — see the render.
1366
+ const autoDisabled =
1367
+ action === "send" && status !== "error" && submitState ? !submitState.canSubmit : false;
1368
+
1369
+ let Icon = sendIcon ?? <CornerDownLeftIcon className="size-4" data-rtl-flip />;
1370
+
1371
+ if (action === "stop") {
1372
+ Icon = status === "submitted" ? <Spinner /> : <SquareIcon className="size-4" />;
1373
+ } else if (status === "error") {
1374
+ Icon = <XIcon className="size-4" />;
1375
+ }
1376
+
1377
+ const handleClick = useCallback(
1378
+ (e: React.MouseEvent<HTMLButtonElement>) => {
1379
+ // `aria-disabled` (see the render) does not block activation the way the
1380
+ // native attribute does, so the handler has to.
1381
+ if (autoDisabled) {
1382
+ e.preventDefault();
1383
+ return;
1384
+ }
1385
+ if (action === "stop" && onStop) {
1386
+ e.preventDefault();
1387
+ onStop();
1388
+ return;
1389
+ }
1390
+ onClick?.(e);
1391
+ },
1392
+ [autoDisabled, action, onStop, onClick],
1393
+ );
1394
+
1395
+ return (
1396
+ <InputGroupButton
1397
+ aria-label={action === "stop" ? t("ai.promptInput.stop") : t("ai.promptInput.submit")}
1398
+ /*
1399
+ * `aria-disabled`, NOT the native `disabled` attribute, for the RESTING
1400
+ * empty-composer state.
1401
+ *
1402
+ * Two reasons. (1) A focused control that becomes natively disabled is
1403
+ * removed from the focus order by the HTML focus-fixup rule, so focus
1404
+ * drops to <body> — after every keyboard-initiated send, since the
1405
+ * composer clears and the button goes not-ready in the same commit. That
1406
+ * silently strands keyboard and screen-reader users mid-conversation.
1407
+ * (2) `interaction-guidelines.md` says a submit stays enabled until the
1408
+ * request starts; aria-disabled keeps it a real, focusable tab stop that
1409
+ * still announces its state, while `handleClick` + `handleSubmit` do the
1410
+ * actual blocking.
1411
+ *
1412
+ * An explicit `disabled` prop is still honoured natively — that is the
1413
+ * consumer deliberately taking the control out of the tab order.
1414
+ */
1415
+ aria-disabled={autoDisabled || undefined}
1416
+ className={cn(autoDisabled && "cursor-not-allowed opacity-50 hover:bg-primary", className)}
1417
+ data-action={action}
1418
+ data-generating={action === "stop" ? "true" : undefined}
1419
+ data-slot={SUBMIT_SLOT}
1420
+ disabled={disabled}
1421
+ onClick={handleClick}
1422
+ size={size}
1423
+ type={autoDisabled || (action === "stop" && onStop) ? "button" : "submit"}
1424
+ variant={variant}
1425
+ {...props}
1426
+ >
1427
+ {children ?? (
1428
+ <span
1429
+ key={`${status ?? "ready"}-${action}`}
1430
+ className="flex animate-in fade-in-0 zoom-in-95 duration-fast ease-entrance"
1431
+ >
1432
+ {Icon}
1433
+ </span>
1434
+ )}
1435
+ </InputGroupButton>
1436
+ );
1437
+ };
1438
+
1439
+ /** The `data-slot` `PromptInputStop` renders with. */
1440
+ const STOP_SLOT = "prompt-input-stop";
1441
+
1442
+ export type PromptInputStopProps = ComponentProps<typeof InputGroupButton> & {
1443
+ status?: ChatStatus;
1444
+ onStop: () => void;
1445
+ /** Glyph override; defaults to a square Stop icon. */
1446
+ children?: ReactNode;
1447
+ };
1448
+
1449
+ /**
1450
+ * A dedicated Stop control — the composed answer to "two buttons" (#351;
1451
+ * never a `mode` prop, per `component-api.md`'s ban on behavioural-mode
1452
+ * props). Renders `null` unless the turn is running (`status` is `submitted`
1453
+ * or `streaming`). While at least one `PromptInputStop` is mounted inside the
1454
+ * same `PromptInput`, `PromptInputSubmit` stays the Send action in every
1455
+ * state — the dedicated control owns stopping instead.
1456
+ */
1457
+ export const PromptInputStop = ({
1458
+ className,
1459
+ variant = "outline",
1460
+ size = "icon-sm",
1461
+ status,
1462
+ onStop,
1463
+ onClick,
1464
+ children,
1465
+ ...props
1466
+ }: PromptInputStopProps) => {
1467
+ const { t } = useLocale();
1468
+ const submitState = useOptionalSubmitState();
1469
+ const isGenerating = status === "submitted" || status === "streaming";
1470
+
1471
+ useEffect(() => {
1472
+ const unregister = submitState?.registerStop();
1473
+ return unregister;
1474
+ }, [submitState]);
1475
+
1476
+ const handleClick = useCallback(
1477
+ (e: React.MouseEvent<HTMLButtonElement>) => {
1478
+ onClick?.(e);
1479
+ if (e.defaultPrevented) {
1480
+ return;
1481
+ }
1482
+ onStop();
1483
+ },
1484
+ [onClick, onStop],
1485
+ );
1486
+
1487
+ if (!isGenerating) {
1488
+ return null;
1489
+ }
1490
+
1491
+ return (
1492
+ <InputGroupButton
1493
+ aria-label={t("ai.promptInput.stop")}
1494
+ className={cn(className)}
1495
+ data-slot={STOP_SLOT}
1496
+ onClick={handleClick}
1497
+ size={size}
1498
+ type="button"
1499
+ variant={variant}
1500
+ {...props}
1501
+ >
1502
+ {children ?? <SquareIcon className="size-4" />}
1503
+ </InputGroupButton>
1504
+ );
1505
+ };
1506
+
1507
+ export type PromptInputSelectProps = ComponentProps<typeof Select>;
1508
+
1509
+ export const PromptInputSelect = (props: PromptInputSelectProps) => <Select {...props} />;
1510
+
1511
+ export type PromptInputSelectTriggerProps = ComponentProps<typeof SelectTrigger>;
1512
+
1513
+ export const PromptInputSelectTrigger = ({
1514
+ className,
1515
+ ...props
1516
+ }: PromptInputSelectTriggerProps) => (
1517
+ <SelectTrigger
1518
+ className={cn(
1519
+ "border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors",
1520
+ "hover:bg-accent hover:text-foreground aria-expanded:bg-accent aria-expanded:text-foreground",
1521
+ className,
1522
+ )}
1523
+ {...props}
1524
+ />
1525
+ );
1526
+
1527
+ export type PromptInputSelectContentProps = ComponentProps<typeof SelectContent>;
1528
+
1529
+ export const PromptInputSelectContent = ({
1530
+ className,
1531
+ ...props
1532
+ }: PromptInputSelectContentProps) => <SelectContent className={cn(className)} {...props} />;
1533
+
1534
+ export type PromptInputSelectItemProps = ComponentProps<typeof SelectItem>;
1535
+
1536
+ export const PromptInputSelectItem = ({ className, ...props }: PromptInputSelectItemProps) => (
1537
+ <SelectItem className={cn(className)} {...props} />
1538
+ );
1539
+
1540
+ export type PromptInputSelectValueProps = ComponentProps<typeof SelectValue>;
1541
+
1542
+ export const PromptInputSelectValue = ({ className, ...props }: PromptInputSelectValueProps) => (
1543
+ <SelectValue className={cn(className)} {...props} />
1544
+ );
1545
+
1546
+ export type PromptInputHoverCardProps = ComponentProps<typeof HoverCard>;
1547
+
1548
+ export const PromptInputHoverCard = ({
1549
+ openDelay = 0,
1550
+ closeDelay = 0,
1551
+ ...props
1552
+ }: PromptInputHoverCardProps) => (
1553
+ <HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} />
1554
+ );
1555
+
1556
+ export type PromptInputHoverCardTriggerProps = ComponentProps<typeof HoverCardTrigger>;
1557
+
1558
+ export const PromptInputHoverCardTrigger = (props: PromptInputHoverCardTriggerProps) => (
1559
+ <HoverCardTrigger {...props} />
1560
+ );
1561
+
1562
+ export type PromptInputHoverCardContentProps = ComponentProps<typeof HoverCardContent>;
1563
+
1564
+ export const PromptInputHoverCardContent = ({
1565
+ align = "start",
1566
+ ...props
1567
+ }: PromptInputHoverCardContentProps) => <HoverCardContent align={align} {...props} />;
1568
+
1569
+ export type PromptInputTabsListProps = HTMLAttributes<HTMLDivElement>;
1570
+
1571
+ export const PromptInputTabsList = ({ className, ...props }: PromptInputTabsListProps) => (
1572
+ <div className={cn(className)} {...props} />
1573
+ );
1574
+
1575
+ export type PromptInputTabProps = HTMLAttributes<HTMLDivElement>;
1576
+
1577
+ export const PromptInputTab = ({ className, ...props }: PromptInputTabProps) => (
1578
+ <div className={cn(className)} {...props} />
1579
+ );
1580
+
1581
+ export type PromptInputTabLabelProps = HTMLAttributes<HTMLHeadingElement>;
1582
+
1583
+ export const PromptInputTabLabel = ({ className, ...props }: PromptInputTabLabelProps) => (
1584
+ // Content provided via children in props
1585
+ // oxlint-disable-next-line eslint-plugin-jsx-a11y(heading-has-content)
1586
+ <h3 className={cn("mb-2 px-3 font-medium text-muted-foreground text-xs", className)} {...props} />
1587
+ );
1588
+
1589
+ export type PromptInputTabBodyProps = HTMLAttributes<HTMLDivElement>;
1590
+
1591
+ export const PromptInputTabBody = ({ className, ...props }: PromptInputTabBodyProps) => (
1592
+ <div className={cn("space-y-1", className)} {...props} />
1593
+ );
1594
+
1595
+ export type PromptInputTabItemProps = HTMLAttributes<HTMLDivElement>;
1596
+
1597
+ export const PromptInputTabItem = ({ className, ...props }: PromptInputTabItemProps) => (
1598
+ <div
1599
+ className={cn("flex items-center gap-2 px-3 py-2 text-xs hover:bg-accent", className)}
1600
+ {...props}
1601
+ />
1602
+ );
1603
+
1604
+ export type PromptInputCommandProps = ComponentProps<typeof Command>;
1605
+
1606
+ export const PromptInputCommand = ({ className, ...props }: PromptInputCommandProps) => (
1607
+ <Command className={cn(className)} {...props} />
1608
+ );
1609
+
1610
+ export type PromptInputCommandInputProps = ComponentProps<typeof CommandInput>;
1611
+
1612
+ export const PromptInputCommandInput = ({ className, ...props }: PromptInputCommandInputProps) => (
1613
+ <CommandInput className={cn(className)} {...props} />
1614
+ );
1615
+
1616
+ export type PromptInputCommandListProps = ComponentProps<typeof CommandList>;
1617
+
1618
+ export const PromptInputCommandList = ({ className, ...props }: PromptInputCommandListProps) => (
1619
+ <CommandList className={cn(className)} {...props} />
1620
+ );
1621
+
1622
+ export type PromptInputCommandEmptyProps = ComponentProps<typeof CommandEmpty>;
1623
+
1624
+ export const PromptInputCommandEmpty = ({ className, ...props }: PromptInputCommandEmptyProps) => (
1625
+ <CommandEmpty className={cn(className)} {...props} />
1626
+ );
1627
+
1628
+ export type PromptInputCommandGroupProps = ComponentProps<typeof CommandGroup>;
1629
+
1630
+ export const PromptInputCommandGroup = ({ className, ...props }: PromptInputCommandGroupProps) => (
1631
+ <CommandGroup className={cn(className)} {...props} />
1632
+ );
1633
+
1634
+ export type PromptInputCommandItemProps = ComponentProps<typeof CommandItem>;
1635
+
1636
+ /**
1637
+ * A selectable row inside `PromptInputCommand` (e.g. an `@`-mention popup).
1638
+ *
1639
+ * `id`/`role`/`aria-selected` are assigned internally by the `cmdk` dependency
1640
+ * itself — one layer below this wrapper and below `@elabs-ai/components-ui`'s
1641
+ * own `CommandItem` — and applied AFTER any props you pass, so a
1642
+ * consumer-supplied `id`/`role`/`aria-selected` is silently overridden
1643
+ * (`CommandItem` warns about this in development; #365).
1644
+ *
1645
+ * To wire `aria-activedescendant` from an input rendered OUTSIDE this
1646
+ * `PromptInputCommand` tree (the composer textarea driving the popup), use
1647
+ * `PromptInputCommand`'s `onActiveItemIdChange` callback (inherited from
1648
+ * `@elabs-ai/components-ui`'s `Command`, since `PromptInputCommandProps =
1649
+ * ComponentProps<typeof Command>`) instead of reading the id back
1650
+ * positionally from the DOM:
1651
+ *
1652
+ * ```tsx
1653
+ * const [activeId, setActiveId] = useState<string>();
1654
+ * <textarea role="combobox" aria-expanded={open} aria-controls={listId}
1655
+ * aria-activedescendant={activeId} />
1656
+ * <PromptInputCommand onActiveItemIdChange={setActiveId}>…</PromptInputCommand>
1657
+ * ```
1658
+ */
1659
+ export const PromptInputCommandItem = ({ className, ...props }: PromptInputCommandItemProps) => (
1660
+ <CommandItem className={cn(className)} {...props} />
1661
+ );
1662
+
1663
+ export type PromptInputCommandSeparatorProps = ComponentProps<typeof CommandSeparator>;
1664
+
1665
+ export const PromptInputCommandSeparator = ({
1666
+ className,
1667
+ ...props
1668
+ }: PromptInputCommandSeparatorProps) => <CommandSeparator className={cn(className)} {...props} />;