@houston-ai/chat 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1507 @@
1
+ "use client";
2
+
3
+ import {
4
+ Command,
5
+ CommandEmpty,
6
+ CommandGroup,
7
+ CommandInput,
8
+ CommandItem,
9
+ CommandList,
10
+ CommandSeparator,
11
+ } from "@houston-ai/core";
12
+ import {
13
+ DropdownMenu,
14
+ DropdownMenuContent,
15
+ DropdownMenuItem,
16
+ DropdownMenuTrigger,
17
+ } from "@houston-ai/core";
18
+ import {
19
+ HoverCard,
20
+ HoverCardContent,
21
+ HoverCardTrigger,
22
+ } from "@houston-ai/core";
23
+ import {
24
+ // @ts-expect-error -- kept for type references in sub-components
25
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
26
+ InputGroup,
27
+ InputGroupAddon,
28
+ InputGroupButton,
29
+ InputGroupTextarea,
30
+ } from "@houston-ai/core";
31
+ import {
32
+ Select,
33
+ SelectContent,
34
+ SelectItem,
35
+ SelectTrigger,
36
+ SelectValue,
37
+ } from "@houston-ai/core";
38
+ import { Spinner } from "@houston-ai/core";
39
+ import {
40
+ Tooltip,
41
+ TooltipContent,
42
+ TooltipTrigger,
43
+ } from "@houston-ai/core";
44
+ import { cn } from "@houston-ai/core";
45
+ import type { ChatStatus, FileUIPart, SourceDocumentUIPart } from "ai";
46
+ import {
47
+
48
+ ArrowUpIcon,
49
+ ImageIcon,
50
+ Monitor,
51
+ PlusIcon,
52
+ SquareIcon,
53
+ XIcon,
54
+ // CornerDownLeftIcon removed — using ArrowUpIcon (ChatGPT style)
55
+ } from "lucide-react";
56
+ import { nanoid } from "nanoid";
57
+ import type {
58
+ ChangeEvent,
59
+ ChangeEventHandler,
60
+ ClipboardEventHandler,
61
+ ComponentProps,
62
+ FormEvent,
63
+ FormEventHandler,
64
+ HTMLAttributes,
65
+ KeyboardEventHandler,
66
+ PropsWithChildren,
67
+ ReactNode,
68
+ RefObject,
69
+ } from "react";
70
+ import {
71
+ Children,
72
+ createContext,
73
+ useCallback,
74
+ useContext,
75
+ useEffect,
76
+ useMemo,
77
+ useRef,
78
+ useState,
79
+ } from "react";
80
+
81
+ // ============================================================================
82
+ // Helpers
83
+ // ============================================================================
84
+
85
+ const convertBlobUrlToDataUrl = async (url: string): Promise<string | null> => {
86
+ try {
87
+ const response = await fetch(url);
88
+ const blob = await response.blob();
89
+ return new Promise((resolve) => {
90
+ const reader = new FileReader();
91
+ reader.onloadend = () => resolve(reader.result as string);
92
+ reader.onerror = () => resolve(null);
93
+ reader.readAsDataURL(blob);
94
+ });
95
+ } catch {
96
+ return null;
97
+ }
98
+ };
99
+
100
+ const captureScreenshot = async (): Promise<File | null> => {
101
+ if (
102
+ typeof navigator === "undefined" ||
103
+ !navigator.mediaDevices?.getDisplayMedia
104
+ ) {
105
+ return null;
106
+ }
107
+
108
+ let stream: MediaStream | null = null;
109
+ const video = document.createElement("video");
110
+ video.muted = true;
111
+ video.playsInline = true;
112
+
113
+ try {
114
+ stream = await navigator.mediaDevices.getDisplayMedia({
115
+ audio: false,
116
+ video: true,
117
+ });
118
+
119
+ video.srcObject = stream;
120
+
121
+ await new Promise<void>((resolve, reject) => {
122
+ video.onloadedmetadata = () => resolve();
123
+ video.onerror = () => reject(new Error("Failed to load screen stream"));
124
+ });
125
+
126
+ await video.play();
127
+
128
+ const width = video.videoWidth;
129
+ const height = video.videoHeight;
130
+ if (!width || !height) {
131
+ return null;
132
+ }
133
+
134
+ const canvas = document.createElement("canvas");
135
+ canvas.width = width;
136
+ canvas.height = height;
137
+ const context = canvas.getContext("2d");
138
+ if (!context) {
139
+ return null;
140
+ }
141
+
142
+ context.drawImage(video, 0, 0, width, height);
143
+ const blob = await new Promise<Blob | null>((resolve) => {
144
+ canvas.toBlob(resolve, "image/png");
145
+ });
146
+ if (!blob) {
147
+ return null;
148
+ }
149
+
150
+ const timestamp = new Date()
151
+ .toISOString()
152
+ .replaceAll(/[:.]/g, "-")
153
+ .replace("T", "_")
154
+ .replace("Z", "");
155
+
156
+ return new File([blob], `screenshot-${timestamp}.png`, {
157
+ lastModified: Date.now(),
158
+ type: "image/png",
159
+ });
160
+ } finally {
161
+ if (stream) {
162
+ for (const track of stream.getTracks()) {
163
+ track.stop();
164
+ }
165
+ }
166
+ video.pause();
167
+ video.srcObject = null;
168
+ }
169
+ };
170
+
171
+ // ============================================================================
172
+ // Provider Context & Types
173
+ // ============================================================================
174
+
175
+ export interface AttachmentsContext {
176
+ files: (FileUIPart & { id: string })[];
177
+ add: (files: File[] | FileList) => void;
178
+ remove: (id: string) => void;
179
+ clear: () => void;
180
+ openFileDialog: () => void;
181
+ fileInputRef: RefObject<HTMLInputElement | null>;
182
+ }
183
+
184
+ export interface TextInputContext {
185
+ value: string;
186
+ setInput: (v: string) => void;
187
+ clear: () => void;
188
+ }
189
+
190
+ export interface PromptInputControllerProps {
191
+ textInput: TextInputContext;
192
+ attachments: AttachmentsContext;
193
+ /** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */
194
+ __registerFileInput: (
195
+ ref: RefObject<HTMLInputElement | null>,
196
+ open: () => void
197
+ ) => void;
198
+ }
199
+
200
+ const PromptInputController = createContext<PromptInputControllerProps | null>(
201
+ null
202
+ );
203
+ const ProviderAttachmentsContext = createContext<AttachmentsContext | null>(
204
+ null
205
+ );
206
+
207
+ export const usePromptInputController = () => {
208
+ const ctx = useContext(PromptInputController);
209
+ if (!ctx) {
210
+ throw new Error(
211
+ "Wrap your component inside <PromptInputProvider> to use usePromptInputController()."
212
+ );
213
+ }
214
+ return ctx;
215
+ };
216
+
217
+ // Optional variants (do NOT throw). Useful for dual-mode components.
218
+ const useOptionalPromptInputController = () =>
219
+ useContext(PromptInputController);
220
+
221
+ export const useProviderAttachments = () => {
222
+ const ctx = useContext(ProviderAttachmentsContext);
223
+ if (!ctx) {
224
+ throw new Error(
225
+ "Wrap your component inside <PromptInputProvider> to use useProviderAttachments()."
226
+ );
227
+ }
228
+ return ctx;
229
+ };
230
+
231
+ const useOptionalProviderAttachments = () =>
232
+ useContext(ProviderAttachmentsContext);
233
+
234
+ export type PromptInputProviderProps = PropsWithChildren<{
235
+ initialInput?: string;
236
+ }>;
237
+
238
+ /**
239
+ * Optional global provider that lifts PromptInput state outside of PromptInput.
240
+ * If you don't use it, PromptInput stays fully self-managed.
241
+ */
242
+ export const PromptInputProvider = ({
243
+ initialInput: initialTextInput = "",
244
+ children,
245
+ }: PromptInputProviderProps) => {
246
+ // ----- textInput state
247
+ const [textInput, setTextInput] = useState(initialTextInput);
248
+ const clearInput = useCallback(() => setTextInput(""), []);
249
+
250
+ // ----- attachments state (global when wrapped)
251
+ const [attachmentFiles, setAttachmentFiles] = useState<
252
+ (FileUIPart & { id: string })[]
253
+ >([]);
254
+ const fileInputRef = useRef<HTMLInputElement | null>(null);
255
+ const openRef = useRef<() => void>(() => {});
256
+
257
+ const add = useCallback((files: File[] | FileList) => {
258
+ const incoming = [...files];
259
+ if (incoming.length === 0) {
260
+ return;
261
+ }
262
+
263
+ setAttachmentFiles((prev) => [
264
+ ...prev,
265
+ ...incoming.map((file) => ({
266
+ filename: file.name,
267
+ id: nanoid(),
268
+ mediaType: file.type,
269
+ type: "file" as const,
270
+ url: URL.createObjectURL(file),
271
+ })),
272
+ ]);
273
+ }, []);
274
+
275
+ const remove = useCallback((id: string) => {
276
+ setAttachmentFiles((prev) => {
277
+ const found = prev.find((f) => f.id === id);
278
+ if (found?.url) {
279
+ URL.revokeObjectURL(found.url);
280
+ }
281
+ return prev.filter((f) => f.id !== id);
282
+ });
283
+ }, []);
284
+
285
+ const clear = useCallback(() => {
286
+ setAttachmentFiles((prev) => {
287
+ for (const f of prev) {
288
+ if (f.url) {
289
+ URL.revokeObjectURL(f.url);
290
+ }
291
+ }
292
+ return [];
293
+ });
294
+ }, []);
295
+
296
+ // Keep a ref to attachments for cleanup on unmount (avoids stale closure)
297
+ const attachmentsRef = useRef(attachmentFiles);
298
+
299
+ useEffect(() => {
300
+ attachmentsRef.current = attachmentFiles;
301
+ }, [attachmentFiles]);
302
+
303
+ // Cleanup blob URLs on unmount to prevent memory leaks
304
+ useEffect(
305
+ () => () => {
306
+ for (const f of attachmentsRef.current) {
307
+ if (f.url) {
308
+ URL.revokeObjectURL(f.url);
309
+ }
310
+ }
311
+ },
312
+ []
313
+ );
314
+
315
+ const openFileDialog = useCallback(() => {
316
+ openRef.current?.();
317
+ }, []);
318
+
319
+ const attachments = useMemo<AttachmentsContext>(
320
+ () => ({
321
+ add,
322
+ clear,
323
+ fileInputRef,
324
+ files: attachmentFiles,
325
+ openFileDialog,
326
+ remove,
327
+ }),
328
+ [attachmentFiles, add, remove, clear, openFileDialog]
329
+ );
330
+
331
+ const __registerFileInput = useCallback(
332
+ (ref: RefObject<HTMLInputElement | null>, open: () => void) => {
333
+ fileInputRef.current = ref.current;
334
+ openRef.current = open;
335
+ },
336
+ []
337
+ );
338
+
339
+ const controller = useMemo<PromptInputControllerProps>(
340
+ () => ({
341
+ __registerFileInput,
342
+ attachments,
343
+ textInput: {
344
+ clear: clearInput,
345
+ setInput: setTextInput,
346
+ value: textInput,
347
+ },
348
+ }),
349
+ [textInput, clearInput, attachments, __registerFileInput]
350
+ );
351
+
352
+ return (
353
+ <PromptInputController.Provider value={controller}>
354
+ <ProviderAttachmentsContext.Provider value={attachments}>
355
+ {children}
356
+ </ProviderAttachmentsContext.Provider>
357
+ </PromptInputController.Provider>
358
+ );
359
+ };
360
+
361
+ // ============================================================================
362
+ // Component Context & Hooks
363
+ // ============================================================================
364
+
365
+ const LocalAttachmentsContext = createContext<AttachmentsContext | null>(null);
366
+
367
+ export const usePromptInputAttachments = () => {
368
+ // Prefer local context (inside PromptInput) as it has validation, fall back to provider
369
+ const provider = useOptionalProviderAttachments();
370
+ const local = useContext(LocalAttachmentsContext);
371
+ const context = local ?? provider;
372
+ if (!context) {
373
+ throw new Error(
374
+ "usePromptInputAttachments must be used within a PromptInput or PromptInputProvider"
375
+ );
376
+ }
377
+ return context;
378
+ };
379
+
380
+ // ============================================================================
381
+ // Referenced Sources (Local to PromptInput)
382
+ // ============================================================================
383
+
384
+ export interface ReferencedSourcesContext {
385
+ sources: (SourceDocumentUIPart & { id: string })[];
386
+ add: (sources: SourceDocumentUIPart[] | SourceDocumentUIPart) => void;
387
+ remove: (id: string) => void;
388
+ clear: () => void;
389
+ }
390
+
391
+ export const LocalReferencedSourcesContext =
392
+ createContext<ReferencedSourcesContext | null>(null);
393
+
394
+ export const usePromptInputReferencedSources = () => {
395
+ const ctx = useContext(LocalReferencedSourcesContext);
396
+ if (!ctx) {
397
+ throw new Error(
398
+ "usePromptInputReferencedSources must be used within a LocalReferencedSourcesContext.Provider"
399
+ );
400
+ }
401
+ return ctx;
402
+ };
403
+
404
+ export type PromptInputActionAddAttachmentsProps = ComponentProps<
405
+ typeof DropdownMenuItem
406
+ > & {
407
+ label?: string;
408
+ };
409
+
410
+ export const PromptInputActionAddAttachments = ({
411
+ label = "Add photos or files",
412
+ ...props
413
+ }: PromptInputActionAddAttachmentsProps) => {
414
+ const attachments = usePromptInputAttachments();
415
+
416
+ const handleSelect = useCallback(
417
+ (e: Event) => {
418
+ e.preventDefault();
419
+ attachments.openFileDialog();
420
+ },
421
+ [attachments]
422
+ );
423
+
424
+ return (
425
+ <DropdownMenuItem {...props} onSelect={handleSelect}>
426
+ <ImageIcon className="mr-2 size-4" /> {label}
427
+ </DropdownMenuItem>
428
+ );
429
+ };
430
+
431
+ export type PromptInputActionAddScreenshotProps = ComponentProps<
432
+ typeof DropdownMenuItem
433
+ > & {
434
+ label?: string;
435
+ };
436
+
437
+ export const PromptInputActionAddScreenshot = ({
438
+ label = "Take screenshot",
439
+ onSelect,
440
+ ...props
441
+ }: PromptInputActionAddScreenshotProps) => {
442
+ const attachments = usePromptInputAttachments();
443
+
444
+ const handleSelect = useCallback(
445
+ async (event: Event) => {
446
+ onSelect?.(event);
447
+ if (event.defaultPrevented) {
448
+ return;
449
+ }
450
+
451
+ try {
452
+ const screenshot = await captureScreenshot();
453
+ if (screenshot) {
454
+ attachments.add([screenshot]);
455
+ }
456
+ } catch (error) {
457
+ if (
458
+ error instanceof DOMException &&
459
+ (error.name === "NotAllowedError" || error.name === "AbortError")
460
+ ) {
461
+ return;
462
+ }
463
+ throw error;
464
+ }
465
+ },
466
+ [onSelect, attachments]
467
+ );
468
+
469
+ return (
470
+ <DropdownMenuItem {...props} onSelect={handleSelect}>
471
+ <Monitor className="mr-2 size-4" />
472
+ {label}
473
+ </DropdownMenuItem>
474
+ );
475
+ };
476
+
477
+ export interface PromptInputMessage {
478
+ text: string;
479
+ files: FileUIPart[];
480
+ }
481
+
482
+ export type PromptInputProps = Omit<
483
+ HTMLAttributes<HTMLFormElement>,
484
+ "onSubmit" | "onError"
485
+ > & {
486
+ // e.g., "image/*" or leave undefined for any
487
+ accept?: string;
488
+ multiple?: boolean;
489
+ // When true, accepts drops anywhere on document. Default false (opt-in).
490
+ globalDrop?: boolean;
491
+ // Render a hidden input with given name and keep it in sync for native form posts. Default false.
492
+ syncHiddenInput?: boolean;
493
+ // Minimal constraints
494
+ maxFiles?: number;
495
+ // bytes
496
+ maxFileSize?: number;
497
+ onError?: (err: {
498
+ code: "max_files" | "max_file_size" | "accept";
499
+ message: string;
500
+ }) => void;
501
+ onSubmit: (
502
+ message: PromptInputMessage,
503
+ event: FormEvent<HTMLFormElement>
504
+ ) => void | Promise<void>;
505
+ };
506
+
507
+ export const PromptInput = ({
508
+ className,
509
+ accept,
510
+ multiple,
511
+ globalDrop,
512
+ syncHiddenInput,
513
+ maxFiles,
514
+ maxFileSize,
515
+ onError,
516
+ onSubmit,
517
+ children,
518
+ ...props
519
+ }: PromptInputProps) => {
520
+ // Try to use a provider controller if present
521
+ const controller = useOptionalPromptInputController();
522
+ const usingProvider = !!controller;
523
+
524
+ // Refs
525
+ const inputRef = useRef<HTMLInputElement | null>(null);
526
+ const formRef = useRef<HTMLFormElement | null>(null);
527
+
528
+ // ----- Local attachments (only used when no provider)
529
+ const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]);
530
+ const files = usingProvider ? controller.attachments.files : items;
531
+
532
+ // ----- Local referenced sources (always local to PromptInput)
533
+ const [referencedSources, setReferencedSources] = useState<
534
+ (SourceDocumentUIPart & { id: string })[]
535
+ >([]);
536
+
537
+ // Keep a ref to files for cleanup on unmount (avoids stale closure)
538
+ const filesRef = useRef(files);
539
+
540
+ useEffect(() => {
541
+ filesRef.current = files;
542
+ }, [files]);
543
+
544
+ const openFileDialogLocal = useCallback(() => {
545
+ inputRef.current?.click();
546
+ }, []);
547
+
548
+ const matchesAccept = useCallback(
549
+ (f: File) => {
550
+ if (!accept || accept.trim() === "") {
551
+ return true;
552
+ }
553
+
554
+ const patterns = accept
555
+ .split(",")
556
+ .map((s) => s.trim())
557
+ .filter(Boolean);
558
+
559
+ return patterns.some((pattern) => {
560
+ if (pattern.endsWith("/*")) {
561
+ // e.g: image/* -> image/
562
+ const prefix = pattern.slice(0, -1);
563
+ return f.type.startsWith(prefix);
564
+ }
565
+ return f.type === pattern;
566
+ });
567
+ },
568
+ [accept]
569
+ );
570
+
571
+ const addLocal = useCallback(
572
+ (fileList: File[] | FileList) => {
573
+ const incoming = [...fileList];
574
+ const accepted = incoming.filter((f) => matchesAccept(f));
575
+ if (incoming.length && accepted.length === 0) {
576
+ onError?.({
577
+ code: "accept",
578
+ message: "No files match the accepted types.",
579
+ });
580
+ return;
581
+ }
582
+ const withinSize = (f: File) =>
583
+ maxFileSize ? f.size <= maxFileSize : true;
584
+ const sized = accepted.filter(withinSize);
585
+ if (accepted.length > 0 && sized.length === 0) {
586
+ onError?.({
587
+ code: "max_file_size",
588
+ message: "All files exceed the maximum size.",
589
+ });
590
+ return;
591
+ }
592
+
593
+ setItems((prev) => {
594
+ const capacity =
595
+ typeof maxFiles === "number"
596
+ ? Math.max(0, maxFiles - prev.length)
597
+ : undefined;
598
+ const capped =
599
+ typeof capacity === "number" ? sized.slice(0, capacity) : sized;
600
+ if (typeof capacity === "number" && sized.length > capacity) {
601
+ onError?.({
602
+ code: "max_files",
603
+ message: "Too many files. Some were not added.",
604
+ });
605
+ }
606
+ const next: (FileUIPart & { id: string })[] = [];
607
+ for (const file of capped) {
608
+ next.push({
609
+ filename: file.name,
610
+ id: nanoid(),
611
+ mediaType: file.type,
612
+ type: "file",
613
+ url: URL.createObjectURL(file),
614
+ });
615
+ }
616
+ return [...prev, ...next];
617
+ });
618
+ },
619
+ [matchesAccept, maxFiles, maxFileSize, onError]
620
+ );
621
+
622
+ const removeLocal = useCallback(
623
+ (id: string) =>
624
+ setItems((prev) => {
625
+ const found = prev.find((file) => file.id === id);
626
+ if (found?.url) {
627
+ URL.revokeObjectURL(found.url);
628
+ }
629
+ return prev.filter((file) => file.id !== id);
630
+ }),
631
+ []
632
+ );
633
+
634
+ // Wrapper that validates files before calling provider's add
635
+ const addWithProviderValidation = useCallback(
636
+ (fileList: File[] | FileList) => {
637
+ const incoming = [...fileList];
638
+ const accepted = incoming.filter((f) => matchesAccept(f));
639
+ if (incoming.length && accepted.length === 0) {
640
+ onError?.({
641
+ code: "accept",
642
+ message: "No files match the accepted types.",
643
+ });
644
+ return;
645
+ }
646
+ const withinSize = (f: File) =>
647
+ maxFileSize ? f.size <= maxFileSize : true;
648
+ const sized = accepted.filter(withinSize);
649
+ if (accepted.length > 0 && sized.length === 0) {
650
+ onError?.({
651
+ code: "max_file_size",
652
+ message: "All files exceed the maximum size.",
653
+ });
654
+ return;
655
+ }
656
+
657
+ const currentCount = files.length;
658
+ const capacity =
659
+ typeof maxFiles === "number"
660
+ ? Math.max(0, maxFiles - currentCount)
661
+ : undefined;
662
+ const capped =
663
+ typeof capacity === "number" ? sized.slice(0, capacity) : sized;
664
+ if (typeof capacity === "number" && sized.length > capacity) {
665
+ onError?.({
666
+ code: "max_files",
667
+ message: "Too many files. Some were not added.",
668
+ });
669
+ }
670
+
671
+ if (capped.length > 0) {
672
+ controller?.attachments.add(capped);
673
+ }
674
+ },
675
+ [matchesAccept, maxFileSize, maxFiles, onError, files.length, controller]
676
+ );
677
+
678
+ const clearAttachments = useCallback(
679
+ () =>
680
+ usingProvider
681
+ ? controller?.attachments.clear()
682
+ : setItems((prev) => {
683
+ for (const file of prev) {
684
+ if (file.url) {
685
+ URL.revokeObjectURL(file.url);
686
+ }
687
+ }
688
+ return [];
689
+ }),
690
+ [usingProvider, controller]
691
+ );
692
+
693
+ const clearReferencedSources = useCallback(
694
+ () => setReferencedSources([]),
695
+ []
696
+ );
697
+
698
+ const add = usingProvider ? addWithProviderValidation : addLocal;
699
+ const remove = usingProvider ? controller.attachments.remove : removeLocal;
700
+ const openFileDialog = usingProvider
701
+ ? controller.attachments.openFileDialog
702
+ : openFileDialogLocal;
703
+
704
+ const clear = useCallback(() => {
705
+ clearAttachments();
706
+ clearReferencedSources();
707
+ }, [clearAttachments, clearReferencedSources]);
708
+
709
+ // Let provider know about our hidden file input so external menus can call openFileDialog()
710
+ useEffect(() => {
711
+ if (!usingProvider) {
712
+ return;
713
+ }
714
+ controller.__registerFileInput(inputRef, () => inputRef.current?.click());
715
+ }, [usingProvider, controller]);
716
+
717
+ // Note: File input cannot be programmatically set for security reasons
718
+ // The syncHiddenInput prop is no longer functional
719
+ useEffect(() => {
720
+ if (syncHiddenInput && inputRef.current && files.length === 0) {
721
+ inputRef.current.value = "";
722
+ }
723
+ }, [files, syncHiddenInput]);
724
+
725
+ // Attach drop handlers on nearest form and document (opt-in)
726
+ useEffect(() => {
727
+ const form = formRef.current;
728
+ if (!form) {
729
+ return;
730
+ }
731
+ if (globalDrop) {
732
+ // when global drop is on, let the document-level handler own drops
733
+ return;
734
+ }
735
+
736
+ const onDragOver = (e: DragEvent) => {
737
+ if (e.dataTransfer?.types?.includes("Files")) {
738
+ e.preventDefault();
739
+ }
740
+ };
741
+ const onDrop = (e: DragEvent) => {
742
+ if (e.dataTransfer?.types?.includes("Files")) {
743
+ e.preventDefault();
744
+ }
745
+ if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
746
+ add(e.dataTransfer.files);
747
+ }
748
+ };
749
+ form.addEventListener("dragover", onDragOver);
750
+ form.addEventListener("drop", onDrop);
751
+ return () => {
752
+ form.removeEventListener("dragover", onDragOver);
753
+ form.removeEventListener("drop", onDrop);
754
+ };
755
+ }, [add, globalDrop]);
756
+
757
+ useEffect(() => {
758
+ if (!globalDrop) {
759
+ return;
760
+ }
761
+
762
+ const onDragOver = (e: DragEvent) => {
763
+ if (e.dataTransfer?.types?.includes("Files")) {
764
+ e.preventDefault();
765
+ }
766
+ };
767
+ const onDrop = (e: DragEvent) => {
768
+ if (e.dataTransfer?.types?.includes("Files")) {
769
+ e.preventDefault();
770
+ }
771
+ if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
772
+ add(e.dataTransfer.files);
773
+ }
774
+ };
775
+ document.addEventListener("dragover", onDragOver);
776
+ document.addEventListener("drop", onDrop);
777
+ return () => {
778
+ document.removeEventListener("dragover", onDragOver);
779
+ document.removeEventListener("drop", onDrop);
780
+ };
781
+ }, [add, globalDrop]);
782
+
783
+ useEffect(
784
+ () => () => {
785
+ if (!usingProvider) {
786
+ for (const f of filesRef.current) {
787
+ if (f.url) {
788
+ URL.revokeObjectURL(f.url);
789
+ }
790
+ }
791
+ }
792
+ },
793
+ [usingProvider]
794
+ );
795
+
796
+ const handleChange: ChangeEventHandler<HTMLInputElement> = useCallback(
797
+ (event) => {
798
+ if (event.currentTarget.files) {
799
+ add(event.currentTarget.files);
800
+ }
801
+ // Reset input value to allow selecting files that were previously removed
802
+ event.currentTarget.value = "";
803
+ },
804
+ [add]
805
+ );
806
+
807
+ const attachmentsCtx = useMemo<AttachmentsContext>(
808
+ () => ({
809
+ add,
810
+ clear: clearAttachments,
811
+ fileInputRef: inputRef,
812
+ files: files.map((item) => ({ ...item, id: item.id })),
813
+ openFileDialog,
814
+ remove,
815
+ }),
816
+ [files, add, remove, clearAttachments, openFileDialog]
817
+ );
818
+
819
+ const refsCtx = useMemo<ReferencedSourcesContext>(
820
+ () => ({
821
+ add: (incoming: SourceDocumentUIPart[] | SourceDocumentUIPart) => {
822
+ const array = Array.isArray(incoming) ? incoming : [incoming];
823
+ setReferencedSources((prev) => [
824
+ ...prev,
825
+ ...array.map((s) => ({ ...s, id: nanoid() })),
826
+ ]);
827
+ },
828
+ clear: clearReferencedSources,
829
+ remove: (id: string) => {
830
+ setReferencedSources((prev) => prev.filter((s) => s.id !== id));
831
+ },
832
+ sources: referencedSources,
833
+ }),
834
+ [referencedSources, clearReferencedSources]
835
+ );
836
+
837
+ const handleSubmit: FormEventHandler<HTMLFormElement> = useCallback(
838
+ async (event) => {
839
+ event.preventDefault();
840
+
841
+ const form = event.currentTarget;
842
+ const text = usingProvider
843
+ ? controller.textInput.value
844
+ : (() => {
845
+ const formData = new FormData(form);
846
+ return (formData.get("message") as string) || "";
847
+ })();
848
+
849
+ // Reset form immediately after capturing text to avoid race condition
850
+ // where user input during async blob conversion would be lost
851
+ if (!usingProvider) {
852
+ form.reset();
853
+ }
854
+
855
+ try {
856
+ // Convert blob URLs to data URLs asynchronously
857
+ const convertedFiles: FileUIPart[] = await Promise.all(
858
+ files.map(async ({ id: _id, ...item }) => {
859
+ if (item.url?.startsWith("blob:")) {
860
+ const dataUrl = await convertBlobUrlToDataUrl(item.url);
861
+ // If conversion failed, keep the original blob URL
862
+ return {
863
+ ...item,
864
+ url: dataUrl ?? item.url,
865
+ };
866
+ }
867
+ return item;
868
+ })
869
+ );
870
+
871
+ const result = onSubmit({ files: convertedFiles, text }, event);
872
+
873
+ // Handle both sync and async onSubmit
874
+ if (result instanceof Promise) {
875
+ try {
876
+ await result;
877
+ clear();
878
+ if (usingProvider) {
879
+ controller.textInput.clear();
880
+ }
881
+ } catch {
882
+ // Don't clear on error - user may want to retry
883
+ }
884
+ } else {
885
+ // Sync function completed without throwing, clear inputs
886
+ clear();
887
+ if (usingProvider) {
888
+ controller.textInput.clear();
889
+ }
890
+ }
891
+ } catch {
892
+ // Don't clear on error - user may want to retry
893
+ }
894
+ },
895
+ [usingProvider, controller, files, onSubmit, clear]
896
+ );
897
+
898
+ // Render with or without local provider
899
+ const inner = (
900
+ <>
901
+ <input
902
+ accept={accept}
903
+ aria-label="Upload files"
904
+ className="hidden"
905
+ multiple={multiple}
906
+ onChange={handleChange}
907
+ ref={inputRef}
908
+ title="Upload files"
909
+ type="file"
910
+ />
911
+ <form
912
+ className={cn("w-full", className)}
913
+ onSubmit={handleSubmit}
914
+ ref={formRef}
915
+ {...props}
916
+ >
917
+ <div
918
+ data-composer-surface="true"
919
+ className="group/composer cursor-text overflow-clip rounded-[28px] border border-border/50 bg-background p-2.5 shadow-[0_1px_6px_rgba(0,0,0,0.06)] focus-within:shadow-[0_1px_10px_rgba(0,0,0,0.1)] dark:bg-muted grid grid-cols-[auto_1fr_auto] [grid-template-areas:'header_header_header'_'leading_primary_trailing'_'._footer_.'] data-[expanded]:[grid-template-areas:'header_header_header'_'primary_primary_primary'_'leading_footer_trailing']"
920
+ onClick={(e) => {
921
+ if (!(e.target as HTMLElement).closest("button")) {
922
+ formRef.current?.querySelector("textarea")?.focus();
923
+ }
924
+ }}
925
+ >
926
+ {children}
927
+ </div>
928
+ </form>
929
+ </>
930
+ );
931
+
932
+ const withReferencedSources = (
933
+ <LocalReferencedSourcesContext.Provider value={refsCtx}>
934
+ {inner}
935
+ </LocalReferencedSourcesContext.Provider>
936
+ );
937
+
938
+ // Always provide LocalAttachmentsContext so children get validated add function
939
+ return (
940
+ <LocalAttachmentsContext.Provider value={attachmentsCtx}>
941
+ {withReferencedSources}
942
+ </LocalAttachmentsContext.Provider>
943
+ );
944
+ };
945
+
946
+ export type PromptInputBodyProps = HTMLAttributes<HTMLDivElement>;
947
+
948
+ export const PromptInputBody = ({
949
+ className,
950
+ ...props
951
+ }: PromptInputBodyProps) => (
952
+ <div className={cn("flex items-center overflow-x-hidden px-1 [[data-expanded]_&]:px-3 [grid-area:primary]", className)} {...props} />
953
+ );
954
+
955
+ export type PromptInputTextareaProps = ComponentProps<
956
+ typeof InputGroupTextarea
957
+ >;
958
+
959
+ export const PromptInputTextarea = ({
960
+ onChange,
961
+ onKeyDown,
962
+ className,
963
+ placeholder = "What would you like to know?",
964
+ ...props
965
+ }: PromptInputTextareaProps) => {
966
+ const controller = useOptionalPromptInputController();
967
+ const attachments = usePromptInputAttachments();
968
+ const [isComposing, setIsComposing] = useState(false);
969
+
970
+ const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = useCallback(
971
+ (e) => {
972
+ // Call the external onKeyDown handler first
973
+ onKeyDown?.(e);
974
+
975
+ // If the external handler prevented default, don't run internal logic
976
+ if (e.defaultPrevented) {
977
+ return;
978
+ }
979
+
980
+ if (e.key === "Enter") {
981
+ if (isComposing || e.nativeEvent.isComposing) {
982
+ return;
983
+ }
984
+ if (e.shiftKey) {
985
+ return;
986
+ }
987
+ e.preventDefault();
988
+
989
+ // Check if the submit button is disabled before submitting
990
+ const { form } = e.currentTarget;
991
+ const submitButton = form?.querySelector(
992
+ 'button[type="submit"]'
993
+ ) as HTMLButtonElement | null;
994
+ if (submitButton?.disabled) {
995
+ return;
996
+ }
997
+
998
+ form?.requestSubmit();
999
+ }
1000
+
1001
+ // Remove last attachment when Backspace is pressed and textarea is empty
1002
+ if (
1003
+ e.key === "Backspace" &&
1004
+ e.currentTarget.value === "" &&
1005
+ attachments.files.length > 0
1006
+ ) {
1007
+ e.preventDefault();
1008
+ const lastAttachment = attachments.files.at(-1);
1009
+ if (lastAttachment) {
1010
+ attachments.remove(lastAttachment.id);
1011
+ }
1012
+ }
1013
+ },
1014
+ [onKeyDown, isComposing, attachments]
1015
+ );
1016
+
1017
+ const handlePaste: ClipboardEventHandler<HTMLTextAreaElement> = useCallback(
1018
+ (event) => {
1019
+ const items = event.clipboardData?.items;
1020
+
1021
+ if (!items) {
1022
+ return;
1023
+ }
1024
+
1025
+ const files: File[] = [];
1026
+
1027
+ for (const item of items) {
1028
+ if (item.kind === "file") {
1029
+ const file = item.getAsFile();
1030
+ if (file) {
1031
+ files.push(file);
1032
+ }
1033
+ }
1034
+ }
1035
+
1036
+ if (files.length > 0) {
1037
+ event.preventDefault();
1038
+ attachments.add(files);
1039
+ }
1040
+ },
1041
+ [attachments]
1042
+ );
1043
+
1044
+ const handleCompositionEnd = useCallback(() => setIsComposing(false), []);
1045
+ const handleCompositionStart = useCallback(() => setIsComposing(true), []);
1046
+
1047
+ const controlledProps = controller
1048
+ ? {
1049
+ onChange: (e: ChangeEvent<HTMLTextAreaElement>) => {
1050
+ controller.textInput.setInput(e.currentTarget.value);
1051
+ onChange?.(e);
1052
+ },
1053
+ value: controller.textInput.value,
1054
+ }
1055
+ : {
1056
+ onChange,
1057
+ };
1058
+
1059
+ const localRef = useRef<HTMLTextAreaElement>(null);
1060
+ const expandedRef = useRef(false);
1061
+
1062
+ // Resize textarea + toggle expanded layout.
1063
+ // No visual flash: measure with a hidden clone instead of resetting height.
1064
+ useEffect(() => {
1065
+ const el = localRef.current;
1066
+ if (!el) return;
1067
+
1068
+ // Measure needed height without touching the visible element:
1069
+ // temporarily set height to 0 to get true scrollHeight, but use
1070
+ // overflow:hidden to prevent visual jump
1071
+ const surface = el.closest("[data-composer-surface]");
1072
+ const value = el.value;
1073
+
1074
+ if (!value) {
1075
+ // Empty → collapse to single line, reset height completely
1076
+ expandedRef.current = false;
1077
+ el.style.height = "";
1078
+ el.style.overflow = "hidden";
1079
+ surface?.removeAttribute("data-expanded");
1080
+ } else {
1081
+ // Has content → measure height
1082
+ el.style.height = "0px";
1083
+ el.style.overflow = "hidden";
1084
+ const scrollH = el.scrollHeight;
1085
+ const newHeight = Math.min(scrollH, 208);
1086
+ el.style.height = newHeight + "px";
1087
+ el.style.overflow = newHeight >= 208 ? "auto" : "hidden";
1088
+
1089
+ if (scrollH > 36) {
1090
+ expandedRef.current = true;
1091
+ surface?.setAttribute("data-expanded", "");
1092
+ } else if (expandedRef.current) {
1093
+ surface?.setAttribute("data-expanded", "");
1094
+ }
1095
+ }
1096
+ });
1097
+
1098
+ return (
1099
+ <textarea
1100
+ ref={localRef}
1101
+ className={cn(
1102
+ "max-h-52 flex-1 resize-none border-none bg-transparent text-base leading-[1.2] py-0.5 text-foreground outline-none transition-[height] duration-100 ease-out placeholder:text-muted-foreground/50",
1103
+ className,
1104
+ )}
1105
+ name="message"
1106
+ rows={1}
1107
+ onCompositionEnd={handleCompositionEnd}
1108
+ onCompositionStart={handleCompositionStart}
1109
+ onKeyDown={handleKeyDown}
1110
+ onPaste={handlePaste}
1111
+ placeholder={placeholder}
1112
+ {...props}
1113
+ {...controlledProps}
1114
+ />
1115
+ );
1116
+ };
1117
+
1118
+ export type PromptInputHeaderProps = Omit<
1119
+ ComponentProps<typeof InputGroupAddon>,
1120
+ "align"
1121
+ >;
1122
+
1123
+ export const PromptInputHeader = ({
1124
+ className,
1125
+ ...props
1126
+ }: PromptInputHeaderProps) => (
1127
+ <div
1128
+ className={cn("flex flex-wrap gap-1 [grid-area:header]", className)}
1129
+ {...props}
1130
+ />
1131
+ );
1132
+
1133
+ export type PromptInputFooterProps = Omit<
1134
+ ComponentProps<typeof InputGroupAddon>,
1135
+ "align"
1136
+ >;
1137
+
1138
+ export const PromptInputFooter = ({
1139
+ className,
1140
+ ...props
1141
+ }: PromptInputFooterProps) => (
1142
+ <div
1143
+ className={cn("flex items-center justify-between gap-1.5 [grid-area:footer] col-span-3", className)}
1144
+ {...props}
1145
+ />
1146
+ );
1147
+
1148
+ export type PromptInputToolsProps = HTMLAttributes<HTMLDivElement>;
1149
+
1150
+ export const PromptInputTools = ({
1151
+ className,
1152
+ ...props
1153
+ }: PromptInputToolsProps) => (
1154
+ <div
1155
+ className={cn("flex min-w-0 items-center gap-1", className)}
1156
+ {...props}
1157
+ />
1158
+ );
1159
+
1160
+ export type PromptInputButtonTooltip =
1161
+ | string
1162
+ | {
1163
+ content: ReactNode;
1164
+ shortcut?: string;
1165
+ side?: ComponentProps<typeof TooltipContent>["side"];
1166
+ };
1167
+
1168
+ export type PromptInputButtonProps = ComponentProps<typeof InputGroupButton> & {
1169
+ tooltip?: PromptInputButtonTooltip;
1170
+ };
1171
+
1172
+ export const PromptInputButton = ({
1173
+ variant = "ghost",
1174
+ className,
1175
+ size,
1176
+ tooltip,
1177
+ ...props
1178
+ }: PromptInputButtonProps) => {
1179
+ const newSize =
1180
+ size ?? (Children.count(props.children) > 1 ? "sm" : "icon-sm");
1181
+
1182
+ const button = (
1183
+ <InputGroupButton
1184
+ className={cn(className)}
1185
+ size={newSize}
1186
+ type="button"
1187
+ variant={variant}
1188
+ {...props}
1189
+ />
1190
+ );
1191
+
1192
+ if (!tooltip) {
1193
+ return button;
1194
+ }
1195
+
1196
+ const tooltipContent =
1197
+ typeof tooltip === "string" ? tooltip : tooltip.content;
1198
+ const shortcut = typeof tooltip === "string" ? undefined : tooltip.shortcut;
1199
+ const side = typeof tooltip === "string" ? "top" : (tooltip.side ?? "top");
1200
+
1201
+ return (
1202
+ <Tooltip>
1203
+ <TooltipTrigger asChild>{button}</TooltipTrigger>
1204
+ <TooltipContent side={side}>
1205
+ {tooltipContent}
1206
+ {shortcut && (
1207
+ <span className="ml-2 text-muted-foreground">{shortcut}</span>
1208
+ )}
1209
+ </TooltipContent>
1210
+ </Tooltip>
1211
+ );
1212
+ };
1213
+
1214
+ export type PromptInputActionMenuProps = ComponentProps<typeof DropdownMenu>;
1215
+ export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => (
1216
+ <DropdownMenu {...props} />
1217
+ );
1218
+
1219
+ export type PromptInputActionMenuTriggerProps = PromptInputButtonProps;
1220
+
1221
+ export const PromptInputActionMenuTrigger = ({
1222
+ className,
1223
+ children,
1224
+ ...props
1225
+ }: PromptInputActionMenuTriggerProps) => (
1226
+ <DropdownMenuTrigger asChild>
1227
+ <PromptInputButton className={className} {...props}>
1228
+ {children ?? <PlusIcon className="size-4" />}
1229
+ </PromptInputButton>
1230
+ </DropdownMenuTrigger>
1231
+ );
1232
+
1233
+ export type PromptInputActionMenuContentProps = ComponentProps<
1234
+ typeof DropdownMenuContent
1235
+ >;
1236
+ export const PromptInputActionMenuContent = ({
1237
+ className,
1238
+ ...props
1239
+ }: PromptInputActionMenuContentProps) => (
1240
+ <DropdownMenuContent align="start" className={cn(className)} {...props} />
1241
+ );
1242
+
1243
+ export type PromptInputActionMenuItemProps = ComponentProps<
1244
+ typeof DropdownMenuItem
1245
+ >;
1246
+ export const PromptInputActionMenuItem = ({
1247
+ className,
1248
+ ...props
1249
+ }: PromptInputActionMenuItemProps) => (
1250
+ <DropdownMenuItem className={cn(className)} {...props} />
1251
+ );
1252
+
1253
+ // Note: Actions that perform side-effects (like opening a file dialog)
1254
+ // are provided in opt-in modules (e.g., prompt-input-attachments).
1255
+
1256
+ export type PromptInputSubmitProps = ComponentProps<typeof InputGroupButton> & {
1257
+ status?: ChatStatus;
1258
+ onStop?: () => void;
1259
+ };
1260
+
1261
+ export const PromptInputSubmit = ({
1262
+ className,
1263
+ variant = "default",
1264
+ size = "icon-sm",
1265
+ status,
1266
+ onStop,
1267
+ onClick,
1268
+ children,
1269
+ ...props
1270
+ }: PromptInputSubmitProps) => {
1271
+ const isGenerating = status === "submitted" || status === "streaming";
1272
+
1273
+ let Icon = <ArrowUpIcon className="size-4" />;
1274
+
1275
+ if (status === "submitted") {
1276
+ Icon = <Spinner />;
1277
+ } else if (status === "streaming") {
1278
+ Icon = <SquareIcon className="size-3.5 fill-current" />;
1279
+ } else if (status === "error") {
1280
+ Icon = <XIcon className="size-4" />;
1281
+ }
1282
+
1283
+ const handleClick = useCallback(
1284
+ (e: React.MouseEvent<HTMLButtonElement>) => {
1285
+ if (isGenerating && onStop) {
1286
+ e.preventDefault();
1287
+ onStop();
1288
+ return;
1289
+ }
1290
+ onClick?.(e);
1291
+ },
1292
+ [isGenerating, onStop, onClick]
1293
+ );
1294
+
1295
+ return (
1296
+ <button
1297
+ aria-label={isGenerating ? "Stop" : "Submit"}
1298
+ className={cn(
1299
+ "flex size-9 items-center justify-center rounded-full bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-30 transition-colors",
1300
+ className,
1301
+ )}
1302
+ onClick={handleClick}
1303
+ type={isGenerating && onStop ? "button" : "submit"}
1304
+ {...props}
1305
+ >
1306
+ {children ?? Icon}
1307
+ </button>
1308
+ );
1309
+ };
1310
+
1311
+ export type PromptInputSelectProps = ComponentProps<typeof Select>;
1312
+
1313
+ export const PromptInputSelect = (props: PromptInputSelectProps) => (
1314
+ <Select {...props} />
1315
+ );
1316
+
1317
+ export type PromptInputSelectTriggerProps = ComponentProps<
1318
+ typeof SelectTrigger
1319
+ >;
1320
+
1321
+ export const PromptInputSelectTrigger = ({
1322
+ className,
1323
+ ...props
1324
+ }: PromptInputSelectTriggerProps) => (
1325
+ <SelectTrigger
1326
+ className={cn(
1327
+ "border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors",
1328
+ "hover:bg-accent hover:text-foreground aria-expanded:bg-accent aria-expanded:text-foreground",
1329
+ className
1330
+ )}
1331
+ {...props}
1332
+ />
1333
+ );
1334
+
1335
+ export type PromptInputSelectContentProps = ComponentProps<
1336
+ typeof SelectContent
1337
+ >;
1338
+
1339
+ export const PromptInputSelectContent = ({
1340
+ className,
1341
+ ...props
1342
+ }: PromptInputSelectContentProps) => (
1343
+ <SelectContent className={cn(className)} {...props} />
1344
+ );
1345
+
1346
+ export type PromptInputSelectItemProps = ComponentProps<typeof SelectItem>;
1347
+
1348
+ export const PromptInputSelectItem = ({
1349
+ className,
1350
+ ...props
1351
+ }: PromptInputSelectItemProps) => (
1352
+ <SelectItem className={cn(className)} {...props} />
1353
+ );
1354
+
1355
+ export type PromptInputSelectValueProps = ComponentProps<typeof SelectValue>;
1356
+
1357
+ export const PromptInputSelectValue = ({
1358
+ className,
1359
+ ...props
1360
+ }: PromptInputSelectValueProps) => (
1361
+ <SelectValue className={cn(className)} {...props} />
1362
+ );
1363
+
1364
+ export type PromptInputHoverCardProps = ComponentProps<typeof HoverCard>;
1365
+
1366
+ export const PromptInputHoverCard = ({
1367
+ openDelay = 0,
1368
+ closeDelay = 0,
1369
+ ...props
1370
+ }: PromptInputHoverCardProps) => (
1371
+ <HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} />
1372
+ );
1373
+
1374
+ export type PromptInputHoverCardTriggerProps = ComponentProps<
1375
+ typeof HoverCardTrigger
1376
+ >;
1377
+
1378
+ export const PromptInputHoverCardTrigger = (
1379
+ props: PromptInputHoverCardTriggerProps
1380
+ ) => <HoverCardTrigger {...props} />;
1381
+
1382
+ export type PromptInputHoverCardContentProps = ComponentProps<
1383
+ typeof HoverCardContent
1384
+ >;
1385
+
1386
+ export const PromptInputHoverCardContent = ({
1387
+ align = "start",
1388
+ ...props
1389
+ }: PromptInputHoverCardContentProps) => (
1390
+ <HoverCardContent align={align} {...props} />
1391
+ );
1392
+
1393
+ export type PromptInputTabsListProps = HTMLAttributes<HTMLDivElement>;
1394
+
1395
+ export const PromptInputTabsList = ({
1396
+ className,
1397
+ ...props
1398
+ }: PromptInputTabsListProps) => <div className={cn(className)} {...props} />;
1399
+
1400
+ export type PromptInputTabProps = HTMLAttributes<HTMLDivElement>;
1401
+
1402
+ export const PromptInputTab = ({
1403
+ className,
1404
+ ...props
1405
+ }: PromptInputTabProps) => <div className={cn(className)} {...props} />;
1406
+
1407
+ export type PromptInputTabLabelProps = HTMLAttributes<HTMLHeadingElement>;
1408
+
1409
+ export const PromptInputTabLabel = ({
1410
+ className,
1411
+ ...props
1412
+ }: PromptInputTabLabelProps) => (
1413
+ <h3
1414
+ className={cn(
1415
+ "mb-2 px-3 font-medium text-muted-foreground text-xs",
1416
+ className
1417
+ )}
1418
+ {...props}
1419
+ />
1420
+ );
1421
+
1422
+ export type PromptInputTabBodyProps = HTMLAttributes<HTMLDivElement>;
1423
+
1424
+ export const PromptInputTabBody = ({
1425
+ className,
1426
+ ...props
1427
+ }: PromptInputTabBodyProps) => (
1428
+ <div className={cn("space-y-1", className)} {...props} />
1429
+ );
1430
+
1431
+ export type PromptInputTabItemProps = HTMLAttributes<HTMLDivElement>;
1432
+
1433
+ export const PromptInputTabItem = ({
1434
+ className,
1435
+ ...props
1436
+ }: PromptInputTabItemProps) => (
1437
+ <div
1438
+ className={cn(
1439
+ "flex items-center gap-2 px-3 py-2 text-xs hover:bg-accent",
1440
+ className
1441
+ )}
1442
+ {...props}
1443
+ />
1444
+ );
1445
+
1446
+ export type PromptInputCommandProps = ComponentProps<typeof Command>;
1447
+
1448
+ export const PromptInputCommand = ({
1449
+ className,
1450
+ ...props
1451
+ }: PromptInputCommandProps) => <Command className={cn(className)} {...props} />;
1452
+
1453
+ export type PromptInputCommandInputProps = ComponentProps<typeof CommandInput>;
1454
+
1455
+ export const PromptInputCommandInput = ({
1456
+ className,
1457
+ ...props
1458
+ }: PromptInputCommandInputProps) => (
1459
+ <CommandInput className={cn(className)} {...props} />
1460
+ );
1461
+
1462
+ export type PromptInputCommandListProps = ComponentProps<typeof CommandList>;
1463
+
1464
+ export const PromptInputCommandList = ({
1465
+ className,
1466
+ ...props
1467
+ }: PromptInputCommandListProps) => (
1468
+ <CommandList className={cn(className)} {...props} />
1469
+ );
1470
+
1471
+ export type PromptInputCommandEmptyProps = ComponentProps<typeof CommandEmpty>;
1472
+
1473
+ export const PromptInputCommandEmpty = ({
1474
+ className,
1475
+ ...props
1476
+ }: PromptInputCommandEmptyProps) => (
1477
+ <CommandEmpty className={cn(className)} {...props} />
1478
+ );
1479
+
1480
+ export type PromptInputCommandGroupProps = ComponentProps<typeof CommandGroup>;
1481
+
1482
+ export const PromptInputCommandGroup = ({
1483
+ className,
1484
+ ...props
1485
+ }: PromptInputCommandGroupProps) => (
1486
+ <CommandGroup className={cn(className)} {...props} />
1487
+ );
1488
+
1489
+ export type PromptInputCommandItemProps = ComponentProps<typeof CommandItem>;
1490
+
1491
+ export const PromptInputCommandItem = ({
1492
+ className,
1493
+ ...props
1494
+ }: PromptInputCommandItemProps) => (
1495
+ <CommandItem className={cn(className)} {...props} />
1496
+ );
1497
+
1498
+ export type PromptInputCommandSeparatorProps = ComponentProps<
1499
+ typeof CommandSeparator
1500
+ >;
1501
+
1502
+ export const PromptInputCommandSeparator = ({
1503
+ className,
1504
+ ...props
1505
+ }: PromptInputCommandSeparatorProps) => (
1506
+ <CommandSeparator className={cn(className)} {...props} />
1507
+ );