@oppulence/design-system 1.0.4 → 1.0.6

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 (46) hide show
  1. package/hooks/use-resize-observer.ts +24 -0
  2. package/lib/ai.ts +31 -0
  3. package/package.json +19 -1
  4. package/src/components/atoms/animated-size-container.tsx +59 -0
  5. package/src/components/atoms/currency-input.tsx +16 -0
  6. package/src/components/atoms/icons.tsx +840 -0
  7. package/src/components/atoms/image.tsx +23 -0
  8. package/src/components/atoms/index.ts +10 -0
  9. package/src/components/atoms/loader.tsx +92 -0
  10. package/src/components/atoms/quantity-input.tsx +103 -0
  11. package/src/components/atoms/record-button.tsx +178 -0
  12. package/src/components/atoms/submit-button.tsx +26 -0
  13. package/src/components/atoms/text-effect.tsx +251 -0
  14. package/src/components/atoms/text-shimmer.tsx +74 -0
  15. package/src/components/molecules/actions.tsx +53 -0
  16. package/src/components/molecules/branch.tsx +192 -0
  17. package/src/components/molecules/code-block.tsx +151 -0
  18. package/src/components/molecules/form.tsx +177 -0
  19. package/src/components/molecules/index.ts +12 -0
  20. package/src/components/molecules/inline-citation.tsx +295 -0
  21. package/src/components/molecules/message.tsx +64 -0
  22. package/src/components/molecules/sources.tsx +116 -0
  23. package/src/components/molecules/suggestion.tsx +53 -0
  24. package/src/components/molecules/task.tsx +74 -0
  25. package/src/components/molecules/time-range-input.tsx +73 -0
  26. package/src/components/molecules/tool-call-indicator.tsx +42 -0
  27. package/src/components/molecules/tool.tsx +130 -0
  28. package/src/components/organisms/combobox-dropdown.tsx +171 -0
  29. package/src/components/organisms/conversation.tsx +98 -0
  30. package/src/components/organisms/date-range-picker.tsx +53 -0
  31. package/src/components/organisms/editor/extentions/bubble-menu/bubble-item.tsx +30 -0
  32. package/src/components/organisms/editor/extentions/bubble-menu/bubble-menu-button.tsx +27 -0
  33. package/src/components/organisms/editor/extentions/bubble-menu/index.tsx +63 -0
  34. package/src/components/organisms/editor/extentions/bubble-menu/link-item.tsx +104 -0
  35. package/src/components/organisms/editor/extentions/register.ts +22 -0
  36. package/src/components/organisms/editor/index.tsx +50 -0
  37. package/src/components/organisms/editor/styles.css +31 -0
  38. package/src/components/organisms/editor/utils.ts +19 -0
  39. package/src/components/organisms/index.ts +11 -0
  40. package/src/components/organisms/multiple-selector.tsx +632 -0
  41. package/src/components/organisms/prompt-input.tsx +747 -0
  42. package/src/components/organisms/reasoning.tsx +170 -0
  43. package/src/components/organisms/response.tsx +121 -0
  44. package/src/components/organisms/toast-toaster.tsx +84 -0
  45. package/src/components/organisms/toast.tsx +124 -0
  46. package/src/components/organisms/use-toast.tsx +206 -0
@@ -0,0 +1,747 @@
1
+ "use client";
2
+
3
+ import type { ChatStatus, FileUIPart } from "../../../lib/ai";
4
+ import { cn } from "../../../lib/utils";
5
+ import {
6
+ DropdownMenu,
7
+ DropdownMenuContent,
8
+ DropdownMenuItem,
9
+ DropdownMenuTrigger,
10
+ } from "./dropdown-menu";
11
+ import {
12
+ Select,
13
+ SelectContent,
14
+ SelectItem,
15
+ SelectTrigger,
16
+ SelectValue,
17
+ } from "../molecules/select";
18
+ import { PaperclipIcon, PlusIcon, XIcon } from "lucide-react";
19
+ import {
20
+ type ButtonHTMLAttributes,
21
+ type ChangeEventHandler,
22
+ Children,
23
+ type ComponentProps,
24
+ type FormEvent,
25
+ type FormEventHandler,
26
+ Fragment,
27
+ type HTMLAttributes,
28
+ type KeyboardEventHandler,
29
+ type ReactNode,
30
+ type RefObject,
31
+ type TextareaHTMLAttributes,
32
+ createContext,
33
+ useCallback,
34
+ useContext,
35
+ useEffect,
36
+ useLayoutEffect,
37
+ useMemo,
38
+ useRef,
39
+ useState,
40
+ } from "react";
41
+ import { Icons } from "../atoms/icons";
42
+ import { buttonVariants, type ButtonProps } from "../atoms/button";
43
+
44
+ type AttachmentsContext = {
45
+ files: (FileUIPart & { id: string })[];
46
+ add: (files: File[] | FileList) => void;
47
+ remove: (id: string) => void;
48
+ clear: () => void;
49
+ openFileDialog: () => void;
50
+ fileInputRef: RefObject<HTMLInputElement | null>;
51
+ };
52
+
53
+ const AttachmentsContext = createContext<AttachmentsContext | null>(null);
54
+
55
+ export const usePromptInputAttachments = () => {
56
+ const context = useContext(AttachmentsContext);
57
+
58
+ if (!context) {
59
+ throw new Error(
60
+ "usePromptInputAttachments must be used within a PromptInput",
61
+ );
62
+ }
63
+
64
+ return context;
65
+ };
66
+
67
+ export type PromptInputAttachmentProps = Omit<
68
+ HTMLAttributes<HTMLDivElement>,
69
+ "className"
70
+ > & {
71
+ data: FileUIPart & { id: string };
72
+ };
73
+
74
+ export function PromptInputAttachment({
75
+ data,
76
+ ...props
77
+ }: PromptInputAttachmentProps) {
78
+ const attachments = usePromptInputAttachments();
79
+
80
+ return (
81
+ <div
82
+ className="group relative h-14 w-14 border"
83
+ key={data.id}
84
+ {...props}
85
+ >
86
+ {data.mediaType?.startsWith("image/") && data.url ? (
87
+ <img
88
+ alt={data.filename || "attachment"}
89
+ className="size-full object-cover"
90
+ height={56}
91
+ src={data.url}
92
+ width={56}
93
+ />
94
+ ) : (
95
+ <div className="flex size-full items-center justify-center text-muted-foreground">
96
+ <PaperclipIcon className="size-4" />
97
+ </div>
98
+ )}
99
+ <button
100
+ aria-label="Remove attachment"
101
+ className={cn(
102
+ buttonVariants({ variant: "outline", size: "icon-xs" }),
103
+ "-right-1.5 -top-1.5 absolute h-6 w-6 rounded-full opacity-0 group-hover:opacity-100",
104
+ )}
105
+ onClick={() => attachments.remove(data.id)}
106
+ type="button"
107
+ >
108
+ <XIcon className="h-3 w-3" />
109
+ </button>
110
+ </div>
111
+ );
112
+ }
113
+
114
+ export type PromptInputAttachmentsProps = Omit<
115
+ HTMLAttributes<HTMLDivElement>,
116
+ "children" | "className"
117
+ > & {
118
+ children: (attachment: FileUIPart & { id: string }) => ReactNode;
119
+ };
120
+
121
+ export function PromptInputAttachments({
122
+ children,
123
+ ...props
124
+ }: PromptInputAttachmentsProps) {
125
+ const attachments = usePromptInputAttachments();
126
+ const [height, setHeight] = useState(0);
127
+ const contentRef = useRef<HTMLDivElement>(null);
128
+
129
+ useLayoutEffect(() => {
130
+ const el = contentRef.current;
131
+ if (!el) {
132
+ return;
133
+ }
134
+ const ro = new ResizeObserver(() => {
135
+ setHeight(el.getBoundingClientRect().height);
136
+ });
137
+ ro.observe(el);
138
+ setHeight(el.getBoundingClientRect().height);
139
+ return () => ro.disconnect();
140
+ }, []);
141
+
142
+ return (
143
+ <div
144
+ aria-live="polite"
145
+ className="overflow-hidden transition-[height] duration-200 ease-out"
146
+ style={{ height: attachments.files.length ? height : 0 }}
147
+ {...props}
148
+ >
149
+ <div className="flex flex-wrap gap-2 p-3 pt-3" ref={contentRef}>
150
+ {attachments.files.map((file) => (
151
+ <Fragment key={file.id}>{children(file)}</Fragment>
152
+ ))}
153
+ </div>
154
+ </div>
155
+ );
156
+ }
157
+
158
+ export type PromptInputActionAddAttachmentsProps = Omit<
159
+ ButtonHTMLAttributes<HTMLButtonElement>,
160
+ "className"
161
+ > & {
162
+ label?: string;
163
+ };
164
+
165
+ export const PromptInputActionAddAttachments = (
166
+ props: PromptInputActionAddAttachmentsProps,
167
+ ) => {
168
+ const attachments = usePromptInputAttachments();
169
+
170
+ return (
171
+ <button
172
+ className={cn(
173
+ buttonVariants({ variant: "ghost", size: "icon-xs" }),
174
+ "size-6 text-muted-foreground",
175
+ )}
176
+ type="button"
177
+ onClick={() => attachments.openFileDialog()}
178
+ {...props}
179
+ >
180
+ <Icons.Add size={16} />
181
+ </button>
182
+ );
183
+ };
184
+
185
+ export type PromptInputMessage = {
186
+ text?: string;
187
+ files?: FileUIPart[];
188
+ };
189
+
190
+ export type PromptInputProps = Omit<
191
+ HTMLAttributes<HTMLFormElement>,
192
+ "onSubmit" | "className"
193
+ > & {
194
+ accept?: string; // e.g., "image/*" or leave undefined for any
195
+ multiple?: boolean;
196
+ // When true, accepts drops anywhere on document. Default false (opt-in).
197
+ globalDrop?: boolean;
198
+ // Render a hidden input with given name and keep it in sync for native form posts. Default false.
199
+ syncHiddenInput?: boolean;
200
+ // Minimal constraints
201
+ maxFiles?: number;
202
+ maxFileSize?: number; // bytes
203
+ onError?: (err: {
204
+ code: "max_files" | "max_file_size" | "accept";
205
+ message: string;
206
+ }) => void;
207
+ onSubmit: (
208
+ message: PromptInputMessage,
209
+ event: FormEvent<HTMLFormElement>,
210
+ ) => void;
211
+ };
212
+
213
+ export const PromptInput = ({
214
+ accept,
215
+ multiple,
216
+ globalDrop,
217
+ syncHiddenInput,
218
+ maxFiles,
219
+ maxFileSize,
220
+ onError,
221
+ onSubmit,
222
+ ...props
223
+ }: PromptInputProps) => {
224
+ const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]);
225
+ const inputRef = useRef<HTMLInputElement | null>(null);
226
+ const anchorRef = useRef<HTMLSpanElement>(null);
227
+ const formRef = useRef<HTMLFormElement | null>(null);
228
+
229
+ // Find nearest form to scope drag & drop
230
+ useEffect(() => {
231
+ const root = anchorRef.current?.closest("form");
232
+ if (root instanceof HTMLFormElement) {
233
+ formRef.current = root;
234
+ }
235
+ }, []);
236
+
237
+ const openFileDialog = useCallback(() => {
238
+ inputRef.current?.click();
239
+ }, []);
240
+
241
+ const matchesAccept = useCallback(
242
+ (f: File) => {
243
+ if (!accept || accept.trim() === "") {
244
+ return true;
245
+ }
246
+ // Split accept string into individual types
247
+ const acceptTypes = accept.split(",").map((t) => t.trim());
248
+ return acceptTypes.some((type) => {
249
+ if (type.endsWith("/*")) {
250
+ // Handle wildcard types like "image/*" or "application/*"
251
+ const baseType = type.slice(0, -2);
252
+ return f.type.startsWith(`${baseType}/`);
253
+ }
254
+ // Handle specific MIME types like "application/pdf"
255
+ return f.type === type;
256
+ });
257
+ },
258
+ [accept],
259
+ );
260
+
261
+ const convertFilesToDataURLs = useCallback(
262
+ (
263
+ files: FileList | File[],
264
+ ): Promise<
265
+ { type: "file"; filename: string; mediaType: string; url: string }[]
266
+ > => {
267
+ return Promise.all(
268
+ Array.from(files).map(
269
+ (file) =>
270
+ new Promise<{
271
+ type: "file";
272
+ filename: string;
273
+ mediaType: string;
274
+ url: string;
275
+ }>((resolve, reject) => {
276
+ const reader = new FileReader();
277
+ reader.onload = () => {
278
+ resolve({
279
+ type: "file",
280
+ filename: file.name,
281
+ mediaType: file.type,
282
+ url: reader.result as string, // Data URL
283
+ });
284
+ };
285
+ reader.onerror = reject;
286
+ reader.readAsDataURL(file);
287
+ }),
288
+ ),
289
+ );
290
+ },
291
+ [],
292
+ );
293
+
294
+ const add = useCallback(
295
+ (files: File[] | FileList) => {
296
+ const incoming = Array.from(files);
297
+ const accepted = incoming.filter((f) => matchesAccept(f));
298
+ if (accepted.length === 0) {
299
+ onError?.({
300
+ code: "accept",
301
+ message: "No files match the accepted types.",
302
+ });
303
+ return;
304
+ }
305
+ const withinSize = (f: File) =>
306
+ maxFileSize ? f.size <= maxFileSize : true;
307
+ const sized = accepted.filter(withinSize);
308
+ if (sized.length === 0 && accepted.length > 0) {
309
+ onError?.({
310
+ code: "max_file_size",
311
+ message: "All files exceed the maximum size.",
312
+ });
313
+ return;
314
+ }
315
+ setItems((prev) => {
316
+ const capacity =
317
+ typeof maxFiles === "number"
318
+ ? Math.max(0, maxFiles - prev.length)
319
+ : undefined;
320
+ const capped =
321
+ typeof capacity === "number" ? sized.slice(0, capacity) : sized;
322
+ if (typeof capacity === "number" && sized.length > capacity) {
323
+ onError?.({
324
+ code: "max_files",
325
+ message: "Too many files. Some were not added.",
326
+ });
327
+ }
328
+
329
+ // Create temporary items with blob URLs for immediate UI display
330
+ // Use filename + index as ID
331
+ const tempItems = capped.map((file, index) => {
332
+ const blobUrl = URL.createObjectURL(file);
333
+ return {
334
+ id: `${file.name}-${index}-${Date.now()}`,
335
+ type: "file" as const,
336
+ url: blobUrl,
337
+ mediaType: file.type,
338
+ filename: file.name,
339
+ };
340
+ });
341
+
342
+ // Convert files to data URLs using FileReader and replace temp items
343
+ convertFilesToDataURLs(capped).then((convertedFiles) => {
344
+ setItems((current) => {
345
+ // Match temp items to converted items by index (since arrays are parallel)
346
+ return current.map((item) => {
347
+ // If this is a temp item (blob URL), find its corresponding converted file
348
+ const itemUrl = item.url;
349
+ if (
350
+ itemUrl &&
351
+ typeof itemUrl === "string" &&
352
+ itemUrl.startsWith("blob:")
353
+ ) {
354
+ // Find the temp item by matching ID
355
+ const tempItem = tempItems.find((temp) => temp.id === item.id);
356
+ if (tempItem) {
357
+ const tempIndex = tempItems.indexOf(tempItem);
358
+ if (
359
+ tempIndex >= 0 &&
360
+ tempIndex < convertedFiles.length &&
361
+ itemUrl
362
+ ) {
363
+ const converted = convertedFiles[tempIndex];
364
+ if (converted) {
365
+ // Revoke the blob URL
366
+ URL.revokeObjectURL(itemUrl);
367
+ // Return converted item with same id and filename
368
+ return {
369
+ ...item,
370
+ url: converted.url, // Data URL
371
+ data: converted.url, // Also store in data field
372
+ filename: converted.filename, // Keep original filename
373
+ };
374
+ }
375
+ }
376
+ }
377
+ }
378
+ return item;
379
+ });
380
+ });
381
+ });
382
+
383
+ // Return immediately with temporary blob URLs for UI responsiveness
384
+ return prev.concat(tempItems);
385
+ });
386
+ },
387
+ [matchesAccept, maxFiles, maxFileSize, onError, convertFilesToDataURLs],
388
+ );
389
+
390
+ const remove = useCallback((id: string) => {
391
+ setItems((prev) => {
392
+ const found = prev.find((file) => file.id === id);
393
+ // Only revoke blob URLs, not data URLs
394
+ if (found?.url?.startsWith("blob:")) {
395
+ URL.revokeObjectURL(found.url);
396
+ }
397
+ return prev.filter((file) => file.id !== id);
398
+ });
399
+ }, []);
400
+
401
+ const clear = useCallback(() => {
402
+ setItems((prev) => {
403
+ for (const file of prev) {
404
+ // Only revoke blob URLs, not data URLs
405
+ if (file.url?.startsWith("blob:")) {
406
+ URL.revokeObjectURL(file.url);
407
+ }
408
+ }
409
+ return [];
410
+ });
411
+ }, []);
412
+
413
+ // Note: File input cannot be programmatically set for security reasons
414
+ // The syncHiddenInput prop is no longer functional
415
+ useEffect(() => {
416
+ if (syncHiddenInput && inputRef.current) {
417
+ // Clear the input when items are cleared
418
+ if (items.length === 0) {
419
+ inputRef.current.value = "";
420
+ }
421
+ }
422
+ }, [items, syncHiddenInput]);
423
+
424
+ // Attach drop handlers on nearest form and document (opt-in)
425
+ useEffect(() => {
426
+ const form = formRef.current;
427
+ if (!form) {
428
+ return;
429
+ }
430
+ const onDragOver = (e: DragEvent) => {
431
+ if (e.dataTransfer?.types?.includes("Files")) {
432
+ e.preventDefault();
433
+ }
434
+ };
435
+ const onDrop = (e: DragEvent) => {
436
+ if (e.dataTransfer?.types?.includes("Files")) {
437
+ e.preventDefault();
438
+ }
439
+ if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
440
+ add(e.dataTransfer.files);
441
+ }
442
+ };
443
+ form.addEventListener("dragover", onDragOver);
444
+ form.addEventListener("drop", onDrop);
445
+ return () => {
446
+ form.removeEventListener("dragover", onDragOver);
447
+ form.removeEventListener("drop", onDrop);
448
+ };
449
+ }, [add]);
450
+
451
+ useEffect(() => {
452
+ if (!globalDrop) {
453
+ return;
454
+ }
455
+ const onDragOver = (e: DragEvent) => {
456
+ if (e.dataTransfer?.types?.includes("Files")) {
457
+ e.preventDefault();
458
+ }
459
+ };
460
+ const onDrop = (e: DragEvent) => {
461
+ if (e.dataTransfer?.types?.includes("Files")) {
462
+ e.preventDefault();
463
+ }
464
+ if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
465
+ add(e.dataTransfer.files);
466
+ }
467
+ };
468
+ document.addEventListener("dragover", onDragOver);
469
+ document.addEventListener("drop", onDrop);
470
+ return () => {
471
+ document.removeEventListener("dragover", onDragOver);
472
+ document.removeEventListener("drop", onDrop);
473
+ };
474
+ }, [add, globalDrop]);
475
+
476
+ const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
477
+ if (event.currentTarget.files) {
478
+ add(event.currentTarget.files);
479
+ }
480
+ };
481
+
482
+ const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
483
+ event.preventDefault();
484
+
485
+ const files: FileUIPart[] = items.map(({ ...item }) => ({
486
+ ...item,
487
+ }));
488
+
489
+ const formData = new FormData(event.currentTarget);
490
+ const textValue = formData.get("message");
491
+ const text = typeof textValue === "string" ? textValue : undefined;
492
+
493
+ onSubmit({ text, files }, event);
494
+ };
495
+
496
+ const ctx = useMemo<AttachmentsContext>(
497
+ () => ({
498
+ files: items.map((item) => ({ ...item, id: item.id })),
499
+ add,
500
+ remove,
501
+ clear,
502
+ openFileDialog,
503
+ fileInputRef: inputRef,
504
+ }),
505
+ [items, add, remove, clear, openFileDialog],
506
+ );
507
+
508
+ return (
509
+ <AttachmentsContext.Provider value={ctx}>
510
+ <span aria-hidden="true" className="hidden" ref={anchorRef} />
511
+ <input
512
+ accept={accept}
513
+ className="hidden"
514
+ multiple={multiple}
515
+ onChange={handleChange}
516
+ ref={inputRef}
517
+ type="file"
518
+ />
519
+ <form
520
+ className="w-full overflow-hidden bg-[#F7F7F7] dark:bg-[#131313]"
521
+ onSubmit={handleSubmit}
522
+ {...props}
523
+ />
524
+ </AttachmentsContext.Provider>
525
+ );
526
+ };
527
+
528
+ export type PromptInputBodyProps = Omit<
529
+ HTMLAttributes<HTMLDivElement>,
530
+ "className"
531
+ >;
532
+
533
+ export const PromptInputBody = ({ ...props }: PromptInputBodyProps) => (
534
+ <div className="flex flex-col" {...props} />
535
+ );
536
+
537
+ export type PromptInputTextareaProps = Omit<
538
+ TextareaHTMLAttributes<HTMLTextAreaElement>,
539
+ "className"
540
+ >;
541
+
542
+ export const PromptInputTextarea = ({
543
+ onChange,
544
+ placeholder = "Ask anything",
545
+ ...props
546
+ }: PromptInputTextareaProps) => {
547
+ const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
548
+ if (e.key === "Enter") {
549
+ // Don't submit if IME composition is in progress
550
+ if (e.nativeEvent.isComposing) {
551
+ return;
552
+ }
553
+
554
+ if (e.shiftKey) {
555
+ // Allow newline
556
+ return;
557
+ }
558
+
559
+ // Submit on Enter (without Shift)
560
+ e.preventDefault();
561
+ const form = e.currentTarget.form;
562
+ if (form) {
563
+ form.requestSubmit();
564
+ }
565
+ }
566
+ };
567
+
568
+ return (
569
+ <textarea
570
+ className={cn(
571
+ "w-full resize-none rounded-none border-none p-3 pt-4 shadow-none outline-none ring-0 text-sm",
572
+ "field-sizing-content bg-transparent dark:bg-transparent placeholder:text-[rgba(102,102,102,0.5)]",
573
+ "max-h-[55px] min-h-[55px]",
574
+ "focus-visible:ring-0",
575
+ )}
576
+ name="message"
577
+ onChange={(e) => {
578
+ onChange?.(e);
579
+ }}
580
+ onKeyDown={handleKeyDown}
581
+ placeholder={placeholder}
582
+ {...props}
583
+ />
584
+ );
585
+ };
586
+
587
+ export type PromptInputToolbarProps = Omit<
588
+ HTMLAttributes<HTMLDivElement>,
589
+ "className"
590
+ >;
591
+
592
+ export const PromptInputToolbar = ({ ...props }: PromptInputToolbarProps) => (
593
+ <div className="flex items-center justify-between px-3 pb-2" {...props} />
594
+ );
595
+
596
+ export type PromptInputToolsProps = Omit<
597
+ HTMLAttributes<HTMLDivElement>,
598
+ "className"
599
+ >;
600
+
601
+ export const PromptInputTools = ({ ...props }: PromptInputToolsProps) => (
602
+ <div className="flex items-center gap-3.5" {...props} />
603
+ );
604
+
605
+ export type PromptInputButtonProps = Omit<
606
+ ButtonHTMLAttributes<HTMLButtonElement>,
607
+ "className"
608
+ > & {
609
+ variant?: ButtonProps["variant"];
610
+ size?: ButtonProps["size"];
611
+ };
612
+
613
+ export const PromptInputButton = ({
614
+ variant = "ghost",
615
+ size,
616
+ ...props
617
+ }: PromptInputButtonProps) => {
618
+ const newSize =
619
+ (size ?? Children.count(props.children) > 1) ? "default" : "icon";
620
+
621
+ return (
622
+ <button
623
+ className={cn(
624
+ buttonVariants({ variant, size: newSize }),
625
+ "shrink-0 gap-1.5",
626
+ variant === "ghost" && "text-muted-foreground",
627
+ newSize === "default" && "px-3",
628
+ )}
629
+ type={props.type ?? "button"}
630
+ {...props}
631
+ />
632
+ );
633
+ };
634
+
635
+ export type PromptInputActionMenuProps = ComponentProps<typeof DropdownMenu>;
636
+ export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => (
637
+ <DropdownMenu {...props} />
638
+ );
639
+
640
+ export type PromptInputActionMenuTriggerProps = ComponentProps<
641
+ typeof PromptInputButton
642
+ > & {};
643
+ export const PromptInputActionMenuTrigger = ({
644
+ children,
645
+ ...props
646
+ }: PromptInputActionMenuTriggerProps) => (
647
+ <DropdownMenuTrigger render={<PromptInputButton {...props} />}>
648
+ {children ?? <PlusIcon className="size-4" />}
649
+ </DropdownMenuTrigger>
650
+ );
651
+
652
+ export type PromptInputActionMenuContentProps = ComponentProps<
653
+ typeof DropdownMenuContent
654
+ >;
655
+ export const PromptInputActionMenuContent = (
656
+ props: PromptInputActionMenuContentProps,
657
+ ) => <DropdownMenuContent align="start" {...props} />;
658
+
659
+ export type PromptInputActionMenuItemProps = ComponentProps<
660
+ typeof DropdownMenuItem
661
+ >;
662
+ export const PromptInputActionMenuItem = (
663
+ props: PromptInputActionMenuItemProps,
664
+ ) => <DropdownMenuItem {...props} />;
665
+
666
+ // Note: Actions that perform side-effects (like opening a file dialog)
667
+ // are provided in opt-in modules (e.g., prompt-input-attachments).
668
+
669
+ export type PromptInputSubmitProps = Omit<
670
+ ButtonHTMLAttributes<HTMLButtonElement>,
671
+ "className"
672
+ > & {
673
+ variant?: ButtonProps["variant"];
674
+ size?: ButtonProps["size"];
675
+ status?: ChatStatus;
676
+ };
677
+
678
+ export const PromptInputSubmit = ({
679
+ variant = "default",
680
+ size = "icon",
681
+ status,
682
+ children,
683
+ ...props
684
+ }: PromptInputSubmitProps) => {
685
+ let Icon = <Icons.ArrowUpward size={16} />;
686
+
687
+ if (status === "streaming") {
688
+ Icon = <Icons.Stop size={16} />;
689
+ } else if (status === "error") {
690
+ Icon = <XIcon className="size-4" />;
691
+ }
692
+
693
+ // Change button type to "button" when streaming to prevent form submission
694
+ // The onClick handler will handle stopping the stream
695
+ const buttonType =
696
+ status === "streaming" || status === "submitted" ? "button" : "submit";
697
+
698
+ return (
699
+ <button
700
+ className={cn(
701
+ buttonVariants({ variant, size }),
702
+ "gap-1.5",
703
+ size === "icon" && "size-8",
704
+ )}
705
+ type={buttonType}
706
+ {...props}
707
+ >
708
+ {children ?? Icon}
709
+ </button>
710
+ );
711
+ };
712
+
713
+ export type PromptInputModelSelectProps = ComponentProps<typeof Select>;
714
+
715
+ export const PromptInputModelSelect = (props: PromptInputModelSelectProps) => (
716
+ <Select {...props} />
717
+ );
718
+
719
+ export type PromptInputModelSelectTriggerProps = ComponentProps<
720
+ typeof SelectTrigger
721
+ >;
722
+
723
+ export const PromptInputModelSelectTrigger = (
724
+ props: PromptInputModelSelectTriggerProps,
725
+ ) => <SelectTrigger {...props} />;
726
+
727
+ export type PromptInputModelSelectContentProps = ComponentProps<
728
+ typeof SelectContent
729
+ >;
730
+
731
+ export const PromptInputModelSelectContent = (
732
+ props: PromptInputModelSelectContentProps,
733
+ ) => <SelectContent {...props} />;
734
+
735
+ export type PromptInputModelSelectItemProps = ComponentProps<typeof SelectItem>;
736
+
737
+ export const PromptInputModelSelectItem = (
738
+ props: PromptInputModelSelectItemProps,
739
+ ) => <SelectItem {...props} />;
740
+
741
+ export type PromptInputModelSelectValueProps = ComponentProps<
742
+ typeof SelectValue
743
+ >;
744
+
745
+ export const PromptInputModelSelectValue = (
746
+ props: PromptInputModelSelectValueProps,
747
+ ) => <SelectValue {...props} />;