@sero-ai/ui 0.1.0 → 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.
@@ -1,78 +1,33 @@
1
1
  "use client";
2
2
 
3
- import type { ChatStatus, FileUIPart, SourceDocumentUIPart } from "ai";
4
3
  import type {
5
- ChangeEvent,
6
- ChangeEventHandler,
7
- ClipboardEventHandler,
8
- ComponentProps,
9
4
  FormEvent,
10
5
  FormEventHandler,
11
6
  HTMLAttributes,
12
- KeyboardEventHandler,
13
- PropsWithChildren,
14
- ReactNode,
15
- RefObject,
16
7
  } from "react";
17
-
18
- import {
19
- Command,
20
- CommandEmpty,
21
- CommandGroup,
22
- CommandInput,
23
- CommandItem,
24
- CommandList,
25
- CommandSeparator,
26
- } from "../ui/command";
27
- import {
28
- DropdownMenu,
29
- DropdownMenuContent,
30
- DropdownMenuItem,
31
- DropdownMenuTrigger,
32
- } from "../ui/dropdown-menu";
33
- import {
34
- HoverCard,
35
- HoverCardContent,
36
- HoverCardTrigger,
37
- } from "../ui/hover-card";
38
- import {
39
- InputGroup,
40
- InputGroupAddon,
41
- InputGroupButton,
42
- InputGroupTextarea,
43
- } from "../ui/input-group";
44
- import {
45
- Select,
46
- SelectContent,
47
- SelectItem,
48
- SelectTrigger,
49
- SelectValue,
50
- } from "../ui/select";
51
- import { Spinner } from "../ui/spinner";
52
- import {
53
- Tooltip,
54
- TooltipContent,
55
- TooltipTrigger,
56
- } from "../ui/tooltip";
8
+ import type { FileUIPart, SourceDocumentUIPart } from "ai";
9
+ import { InputGroup } from "../ui/input-group";
57
10
  import { cn } from "../../lib/utils";
58
- import {
59
- CornerDownLeftIcon,
60
- ImageIcon,
61
- PlusIcon,
62
- SquareIcon,
63
- XIcon,
64
- } from "lucide-react";
65
11
  import { nanoid } from "nanoid";
66
12
  import {
67
- Children,
68
- createContext,
69
13
  useCallback,
70
- useContext,
71
14
  useEffect,
72
15
  useMemo,
73
16
  useRef,
74
17
  useState,
75
18
  } from "react";
19
+ import {
20
+ useOptionalPromptInputController,
21
+ LocalAttachmentsContext,
22
+ LocalReferencedSourcesContext,
23
+ type AttachmentsContext,
24
+ type ReferencedSourcesContext,
25
+ } from "./prompt-input-context";
26
+
27
+ // Re-export everything so consumers can keep importing from this single path
28
+ export * from "./prompt-input-context";
29
+ export * from "./prompt-input-textarea";
30
+ export * from "./prompt-input-elements";
76
31
 
77
32
  // ============================================================================
78
33
  // Helpers
@@ -98,266 +53,9 @@ const convertBlobUrlToDataUrl = async (url: string): Promise<string | null> => {
98
53
  };
99
54
 
100
55
  // ============================================================================
101
- // Provider Context & Types
102
- // ============================================================================
103
-
104
- export interface AttachmentsContext {
105
- files: (FileUIPart & { id: string })[];
106
- add: (files: File[] | FileList) => void;
107
- remove: (id: string) => void;
108
- clear: () => void;
109
- openFileDialog: () => void;
110
- fileInputRef: RefObject<HTMLInputElement | null>;
111
- }
112
-
113
- export interface TextInputContext {
114
- value: string;
115
- setInput: (v: string) => void;
116
- clear: () => void;
117
- }
118
-
119
- export interface PromptInputControllerProps {
120
- textInput: TextInputContext;
121
- attachments: AttachmentsContext;
122
- /** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */
123
- __registerFileInput: (
124
- ref: RefObject<HTMLInputElement | null>,
125
- open: () => void
126
- ) => void;
127
- }
128
-
129
- const PromptInputController = createContext<PromptInputControllerProps | null>(
130
- null
131
- );
132
- const ProviderAttachmentsContext = createContext<AttachmentsContext | null>(
133
- null
134
- );
135
-
136
- export const usePromptInputController = () => {
137
- const ctx = useContext(PromptInputController);
138
- if (!ctx) {
139
- throw new Error(
140
- "Wrap your component inside <PromptInputProvider> to use usePromptInputController()."
141
- );
142
- }
143
- return ctx;
144
- };
145
-
146
- // Optional variants (do NOT throw). Useful for dual-mode components.
147
- const useOptionalPromptInputController = () =>
148
- useContext(PromptInputController);
149
-
150
- export const useProviderAttachments = () => {
151
- const ctx = useContext(ProviderAttachmentsContext);
152
- if (!ctx) {
153
- throw new Error(
154
- "Wrap your component inside <PromptInputProvider> to use useProviderAttachments()."
155
- );
156
- }
157
- return ctx;
158
- };
159
-
160
- const useOptionalProviderAttachments = () =>
161
- useContext(ProviderAttachmentsContext);
162
-
163
- export type PromptInputProviderProps = PropsWithChildren<{
164
- initialInput?: string;
165
- }>;
166
-
167
- /**
168
- * Optional global provider that lifts PromptInput state outside of PromptInput.
169
- * If you don't use it, PromptInput stays fully self-managed.
170
- */
171
- export const PromptInputProvider = ({
172
- initialInput: initialTextInput = "",
173
- children,
174
- }: PromptInputProviderProps) => {
175
- // ----- textInput state
176
- const [textInput, setTextInput] = useState(initialTextInput);
177
- const clearInput = useCallback(() => setTextInput(""), []);
178
-
179
- // ----- attachments state (global when wrapped)
180
- const [attachmentFiles, setAttachmentFiles] = useState<
181
- (FileUIPart & { id: string })[]
182
- >([]);
183
- const fileInputRef = useRef<HTMLInputElement | null>(null);
184
- // oxlint-disable-next-line eslint(no-empty-function)
185
- const openRef = useRef<() => void>(() => {});
186
-
187
- const add = useCallback((files: File[] | FileList) => {
188
- const incoming = [...files];
189
- if (incoming.length === 0) {
190
- return;
191
- }
192
-
193
- setAttachmentFiles((prev) => [
194
- ...prev,
195
- ...incoming.map((file) => ({
196
- filename: file.name,
197
- id: nanoid(),
198
- mediaType: file.type,
199
- type: "file" as const,
200
- url: URL.createObjectURL(file),
201
- })),
202
- ]);
203
- }, []);
204
-
205
- const remove = useCallback((id: string) => {
206
- setAttachmentFiles((prev) => {
207
- const found = prev.find((f) => f.id === id);
208
- if (found?.url) {
209
- URL.revokeObjectURL(found.url);
210
- }
211
- return prev.filter((f) => f.id !== id);
212
- });
213
- }, []);
214
-
215
- const clear = useCallback(() => {
216
- setAttachmentFiles((prev) => {
217
- for (const f of prev) {
218
- if (f.url) {
219
- URL.revokeObjectURL(f.url);
220
- }
221
- }
222
- return [];
223
- });
224
- }, []);
225
-
226
- // Keep a ref to attachments for cleanup on unmount (avoids stale closure)
227
- const attachmentsRef = useRef(attachmentFiles);
228
-
229
- useEffect(() => {
230
- attachmentsRef.current = attachmentFiles;
231
- }, [attachmentFiles]);
232
-
233
- // Cleanup blob URLs on unmount to prevent memory leaks
234
- useEffect(
235
- () => () => {
236
- for (const f of attachmentsRef.current) {
237
- if (f.url) {
238
- URL.revokeObjectURL(f.url);
239
- }
240
- }
241
- },
242
- []
243
- );
244
-
245
- const openFileDialog = useCallback(() => {
246
- openRef.current?.();
247
- }, []);
248
-
249
- const attachments = useMemo<AttachmentsContext>(
250
- () => ({
251
- add,
252
- clear,
253
- fileInputRef,
254
- files: attachmentFiles,
255
- openFileDialog,
256
- remove,
257
- }),
258
- [attachmentFiles, add, remove, clear, openFileDialog]
259
- );
260
-
261
- const __registerFileInput = useCallback(
262
- (ref: RefObject<HTMLInputElement | null>, open: () => void) => {
263
- fileInputRef.current = ref.current;
264
- openRef.current = open;
265
- },
266
- []
267
- );
268
-
269
- const controller = useMemo<PromptInputControllerProps>(
270
- () => ({
271
- __registerFileInput,
272
- attachments,
273
- textInput: {
274
- clear: clearInput,
275
- setInput: setTextInput,
276
- value: textInput,
277
- },
278
- }),
279
- [textInput, clearInput, attachments, __registerFileInput]
280
- );
281
-
282
- return (
283
- <PromptInputController.Provider value={controller}>
284
- <ProviderAttachmentsContext.Provider value={attachments}>
285
- {children}
286
- </ProviderAttachmentsContext.Provider>
287
- </PromptInputController.Provider>
288
- );
289
- };
290
-
291
- // ============================================================================
292
- // Component Context & Hooks
293
- // ============================================================================
294
-
295
- const LocalAttachmentsContext = createContext<AttachmentsContext | null>(null);
296
-
297
- export const usePromptInputAttachments = () => {
298
- // Prefer local context (inside PromptInput) as it has validation, fall back to provider
299
- const provider = useOptionalProviderAttachments();
300
- const local = useContext(LocalAttachmentsContext);
301
- const context = local ?? provider;
302
- if (!context) {
303
- throw new Error(
304
- "usePromptInputAttachments must be used within a PromptInput or PromptInputProvider"
305
- );
306
- }
307
- return context;
308
- };
309
-
310
- // ============================================================================
311
- // Referenced Sources (Local to PromptInput)
56
+ // PromptInput
312
57
  // ============================================================================
313
58
 
314
- export interface ReferencedSourcesContext {
315
- sources: (SourceDocumentUIPart & { id: string })[];
316
- add: (sources: SourceDocumentUIPart[] | SourceDocumentUIPart) => void;
317
- remove: (id: string) => void;
318
- clear: () => void;
319
- }
320
-
321
- export const LocalReferencedSourcesContext =
322
- createContext<ReferencedSourcesContext | null>(null);
323
-
324
- export const usePromptInputReferencedSources = () => {
325
- const ctx = useContext(LocalReferencedSourcesContext);
326
- if (!ctx) {
327
- throw new Error(
328
- "usePromptInputReferencedSources must be used within a LocalReferencedSourcesContext.Provider"
329
- );
330
- }
331
- return ctx;
332
- };
333
-
334
- export type PromptInputActionAddAttachmentsProps = ComponentProps<
335
- typeof DropdownMenuItem
336
- > & {
337
- label?: string;
338
- };
339
-
340
- export const PromptInputActionAddAttachments = ({
341
- label = "Add photos or files",
342
- ...props
343
- }: PromptInputActionAddAttachmentsProps) => {
344
- const attachments = usePromptInputAttachments();
345
-
346
- const handleSelect = useCallback(
347
- (e: Event) => {
348
- e.preventDefault();
349
- attachments.openFileDialog();
350
- },
351
- [attachments]
352
- );
353
-
354
- return (
355
- <DropdownMenuItem {...props} onSelect={handleSelect}>
356
- <ImageIcon className="mr-2 size-4" /> {label}
357
- </DropdownMenuItem>
358
- );
359
- };
360
-
361
59
  export interface PromptInputMessage {
362
60
  text: string;
363
61
  files: FileUIPart[];
@@ -401,11 +99,9 @@ export const PromptInput = ({
401
99
  children,
402
100
  ...props
403
101
  }: PromptInputProps) => {
404
- // Try to use a provider controller if present
405
102
  const controller = useOptionalPromptInputController();
406
103
  const usingProvider = !!controller;
407
104
 
408
- // Refs
409
105
  const inputRef = useRef<HTMLInputElement | null>(null);
410
106
  const formRef = useRef<HTMLFormElement | null>(null);
411
107
 
@@ -420,29 +116,16 @@ export const PromptInput = ({
420
116
 
421
117
  // Keep a ref to files for cleanup on unmount (avoids stale closure)
422
118
  const filesRef = useRef(files);
119
+ useEffect(() => { filesRef.current = files; }, [files]);
423
120
 
424
- useEffect(() => {
425
- filesRef.current = files;
426
- }, [files]);
427
-
428
- const openFileDialogLocal = useCallback(() => {
429
- inputRef.current?.click();
430
- }, []);
121
+ const openFileDialogLocal = useCallback(() => { inputRef.current?.click(); }, []);
431
122
 
432
123
  const matchesAccept = useCallback(
433
124
  (f: File) => {
434
- if (!accept || accept.trim() === "") {
435
- return true;
436
- }
437
-
438
- const patterns = accept
439
- .split(",")
440
- .map((s) => s.trim())
441
- .filter(Boolean);
442
-
125
+ if (!accept || accept.trim() === "") return true;
126
+ const patterns = accept.split(",").map((s) => s.trim()).filter(Boolean);
443
127
  return patterns.some((pattern) => {
444
128
  if (pattern.endsWith("/*")) {
445
- // e.g: image/* -> image/
446
129
  const prefix = pattern.slice(0, -1);
447
130
  return f.type.startsWith(prefix);
448
131
  }
@@ -457,45 +140,25 @@ export const PromptInput = ({
457
140
  const incoming = [...fileList];
458
141
  const accepted = incoming.filter((f) => matchesAccept(f));
459
142
  if (incoming.length && accepted.length === 0) {
460
- onError?.({
461
- code: "accept",
462
- message: "No files match the accepted types.",
463
- });
143
+ onError?.({ code: "accept", message: "No files match the accepted types." });
464
144
  return;
465
145
  }
466
- const withinSize = (f: File) =>
467
- maxFileSize ? f.size <= maxFileSize : true;
146
+ const withinSize = (f: File) => maxFileSize ? f.size <= maxFileSize : true;
468
147
  const sized = accepted.filter(withinSize);
469
148
  if (accepted.length > 0 && sized.length === 0) {
470
- onError?.({
471
- code: "max_file_size",
472
- message: "All files exceed the maximum size.",
473
- });
149
+ onError?.({ code: "max_file_size", message: "All files exceed the maximum size." });
474
150
  return;
475
151
  }
476
152
 
477
153
  setItems((prev) => {
478
- const capacity =
479
- typeof maxFiles === "number"
480
- ? Math.max(0, maxFiles - prev.length)
481
- : undefined;
482
- const capped =
483
- typeof capacity === "number" ? sized.slice(0, capacity) : sized;
154
+ const capacity = typeof maxFiles === "number" ? Math.max(0, maxFiles - prev.length) : undefined;
155
+ const capped = typeof capacity === "number" ? sized.slice(0, capacity) : sized;
484
156
  if (typeof capacity === "number" && sized.length > capacity) {
485
- onError?.({
486
- code: "max_files",
487
- message: "Too many files. Some were not added.",
488
- });
157
+ onError?.({ code: "max_files", message: "Too many files. Some were not added." });
489
158
  }
490
159
  const next: (FileUIPart & { id: string })[] = [];
491
160
  for (const file of capped) {
492
- next.push({
493
- filename: file.name,
494
- id: nanoid(),
495
- mediaType: file.type,
496
- type: "file",
497
- url: URL.createObjectURL(file),
498
- });
161
+ next.push({ filename: file.name, id: nanoid(), mediaType: file.type, type: "file", url: URL.createObjectURL(file) });
499
162
  }
500
163
  return [...prev, ...next];
501
164
  });
@@ -507,9 +170,7 @@ export const PromptInput = ({
507
170
  (id: string) =>
508
171
  setItems((prev) => {
509
172
  const found = prev.find((file) => file.id === id);
510
- if (found?.url) {
511
- URL.revokeObjectURL(found.url);
512
- }
173
+ if (found?.url) URL.revokeObjectURL(found.url);
513
174
  return prev.filter((file) => file.id !== id);
514
175
  }),
515
176
  []
@@ -521,40 +182,21 @@ export const PromptInput = ({
521
182
  const incoming = [...fileList];
522
183
  const accepted = incoming.filter((f) => matchesAccept(f));
523
184
  if (incoming.length && accepted.length === 0) {
524
- onError?.({
525
- code: "accept",
526
- message: "No files match the accepted types.",
527
- });
185
+ onError?.({ code: "accept", message: "No files match the accepted types." });
528
186
  return;
529
187
  }
530
- const withinSize = (f: File) =>
531
- maxFileSize ? f.size <= maxFileSize : true;
188
+ const withinSize = (f: File) => maxFileSize ? f.size <= maxFileSize : true;
532
189
  const sized = accepted.filter(withinSize);
533
190
  if (accepted.length > 0 && sized.length === 0) {
534
- onError?.({
535
- code: "max_file_size",
536
- message: "All files exceed the maximum size.",
537
- });
191
+ onError?.({ code: "max_file_size", message: "All files exceed the maximum size." });
538
192
  return;
539
193
  }
540
-
541
- const currentCount = files.length;
542
- const capacity =
543
- typeof maxFiles === "number"
544
- ? Math.max(0, maxFiles - currentCount)
545
- : undefined;
546
- const capped =
547
- typeof capacity === "number" ? sized.slice(0, capacity) : sized;
194
+ const capacity = typeof maxFiles === "number" ? Math.max(0, maxFiles - files.length) : undefined;
195
+ const capped = typeof capacity === "number" ? sized.slice(0, capacity) : sized;
548
196
  if (typeof capacity === "number" && sized.length > capacity) {
549
- onError?.({
550
- code: "max_files",
551
- message: "Too many files. Some were not added.",
552
- });
553
- }
554
-
555
- if (capped.length > 0) {
556
- controller?.attachments.add(capped);
197
+ onError?.({ code: "max_files", message: "Too many files. Some were not added." });
557
198
  }
199
+ if (capped.length > 0) controller?.attachments.add(capped);
558
200
  },
559
201
  [matchesAccept, maxFileSize, maxFiles, onError, files.length, controller]
560
202
  );
@@ -565,25 +207,18 @@ export const PromptInput = ({
565
207
  ? controller?.attachments.clear()
566
208
  : setItems((prev) => {
567
209
  for (const file of prev) {
568
- if (file.url) {
569
- URL.revokeObjectURL(file.url);
570
- }
210
+ if (file.url) URL.revokeObjectURL(file.url);
571
211
  }
572
212
  return [];
573
213
  }),
574
214
  [usingProvider, controller]
575
215
  );
576
216
 
577
- const clearReferencedSources = useCallback(
578
- () => setReferencedSources([]),
579
- []
580
- );
217
+ const clearReferencedSources = useCallback(() => setReferencedSources([]), []);
581
218
 
582
219
  const add = usingProvider ? addWithProviderValidation : addLocal;
583
220
  const remove = usingProvider ? controller.attachments.remove : removeLocal;
584
- const openFileDialog = usingProvider
585
- ? controller.attachments.openFileDialog
586
- : openFileDialogLocal;
221
+ const openFileDialog = usingProvider ? controller.attachments.openFileDialog : openFileDialogLocal;
587
222
 
588
223
  const clear = useCallback(() => {
589
224
  clearAttachments();
@@ -592,9 +227,7 @@ export const PromptInput = ({
592
227
 
593
228
  // Let provider know about our hidden file input so external menus can call openFileDialog()
594
229
  useEffect(() => {
595
- if (!usingProvider) {
596
- return;
597
- }
230
+ if (!usingProvider) return;
598
231
  controller.__registerFileInput(inputRef, () => inputRef.current?.click());
599
232
  }, [usingProvider, controller]);
600
233
 
@@ -606,29 +239,17 @@ export const PromptInput = ({
606
239
  }
607
240
  }, [files, syncHiddenInput]);
608
241
 
609
- // Attach drop handlers on nearest form and document (opt-in)
242
+ // Attach drop handlers on form (local) or document (opt-in global)
610
243
  useEffect(() => {
611
244
  const form = formRef.current;
612
- if (!form) {
613
- return;
614
- }
615
- if (globalDrop) {
616
- // when global drop is on, let the document-level handler own drops
617
- return;
618
- }
245
+ if (!form || globalDrop) return;
619
246
 
620
247
  const onDragOver = (e: DragEvent) => {
621
- if (e.dataTransfer?.types?.includes("Files")) {
622
- e.preventDefault();
623
- }
248
+ if (e.dataTransfer?.types?.includes("Files")) e.preventDefault();
624
249
  };
625
250
  const onDrop = (e: DragEvent) => {
626
- if (e.dataTransfer?.types?.includes("Files")) {
627
- e.preventDefault();
628
- }
629
- if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
630
- add(e.dataTransfer.files);
631
- }
251
+ if (e.dataTransfer?.types?.includes("Files")) e.preventDefault();
252
+ if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) add(e.dataTransfer.files);
632
253
  };
633
254
  form.addEventListener("dragover", onDragOver);
634
255
  form.addEventListener("drop", onDrop);
@@ -639,22 +260,13 @@ export const PromptInput = ({
639
260
  }, [add, globalDrop]);
640
261
 
641
262
  useEffect(() => {
642
- if (!globalDrop) {
643
- return;
644
- }
645
-
263
+ if (!globalDrop) return;
646
264
  const onDragOver = (e: DragEvent) => {
647
- if (e.dataTransfer?.types?.includes("Files")) {
648
- e.preventDefault();
649
- }
265
+ if (e.dataTransfer?.types?.includes("Files")) e.preventDefault();
650
266
  };
651
267
  const onDrop = (e: DragEvent) => {
652
- if (e.dataTransfer?.types?.includes("Files")) {
653
- e.preventDefault();
654
- }
655
- if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
656
- add(e.dataTransfer.files);
657
- }
268
+ if (e.dataTransfer?.types?.includes("Files")) e.preventDefault();
269
+ if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) add(e.dataTransfer.files);
658
270
  };
659
271
  document.addEventListener("dragover", onDragOver);
660
272
  document.addEventListener("drop", onDrop);
@@ -664,13 +276,12 @@ export const PromptInput = ({
664
276
  };
665
277
  }, [add, globalDrop]);
666
278
 
279
+ // Cleanup blob URLs on unmount
667
280
  useEffect(
668
281
  () => () => {
669
282
  if (!usingProvider) {
670
283
  for (const f of filesRef.current) {
671
- if (f.url) {
672
- URL.revokeObjectURL(f.url);
673
- }
284
+ if (f.url) URL.revokeObjectURL(f.url);
674
285
  }
675
286
  }
676
287
  },
@@ -678,11 +289,9 @@ export const PromptInput = ({
678
289
  [usingProvider]
679
290
  );
680
291
 
681
- const handleChange: ChangeEventHandler<HTMLInputElement> = useCallback(
682
- (event) => {
683
- if (event.currentTarget.files) {
684
- add(event.currentTarget.files);
685
- }
292
+ const handleChange = useCallback(
293
+ (event: React.ChangeEvent<HTMLInputElement>) => {
294
+ if (event.currentTarget.files) add(event.currentTarget.files);
686
295
  // Reset input value to allow selecting files that were previously removed
687
296
  event.currentTarget.value = "";
688
297
  },
@@ -733,9 +342,7 @@ export const PromptInput = ({
733
342
 
734
343
  // Reset form immediately after capturing text to avoid race condition
735
344
  // where user input during async blob conversion would be lost
736
- if (!usingProvider) {
737
- form.reset();
738
- }
345
+ if (!usingProvider) form.reset();
739
346
 
740
347
  try {
741
348
  // Convert blob URLs to data URLs asynchronously
@@ -743,11 +350,7 @@ export const PromptInput = ({
743
350
  files.map(async ({ id: _id, ...item }) => {
744
351
  if (item.url?.startsWith("blob:")) {
745
352
  const dataUrl = await convertBlobUrlToDataUrl(item.url);
746
- // If conversion failed, keep the original blob URL
747
- return {
748
- ...item,
749
- url: dataUrl ?? item.url,
750
- };
353
+ return { ...item, url: dataUrl ?? item.url };
751
354
  }
752
355
  return item;
753
356
  })
@@ -755,23 +358,17 @@ export const PromptInput = ({
755
358
 
756
359
  const result = onSubmit({ files: convertedFiles, text }, event);
757
360
 
758
- // Handle both sync and async onSubmit
759
361
  if (result instanceof Promise) {
760
362
  try {
761
363
  await result;
762
364
  clear();
763
- if (usingProvider) {
764
- controller.textInput.clear();
765
- }
365
+ if (usingProvider) controller.textInput.clear();
766
366
  } catch {
767
367
  // Don't clear on error - user may want to retry
768
368
  }
769
369
  } else {
770
- // Sync function completed without throwing, clear inputs
771
370
  clear();
772
- if (usingProvider) {
773
- controller.textInput.clear();
774
- }
371
+ if (usingProvider) controller.textInput.clear();
775
372
  }
776
373
  } catch {
777
374
  // Don't clear on error - user may want to retry
@@ -780,7 +377,6 @@ export const PromptInput = ({
780
377
  [usingProvider, controller, files, onSubmit, clear]
781
378
  );
782
379
 
783
- // Render with or without local provider
784
380
  const inner = (
785
381
  <>
786
382
  <input
@@ -804,538 +400,11 @@ export const PromptInput = ({
804
400
  </>
805
401
  );
806
402
 
807
- const withReferencedSources = (
808
- <LocalReferencedSourcesContext.Provider value={refsCtx}>
809
- {inner}
810
- </LocalReferencedSourcesContext.Provider>
811
- );
812
-
813
- // Always provide LocalAttachmentsContext so children get validated add function
814
403
  return (
815
404
  <LocalAttachmentsContext.Provider value={attachmentsCtx}>
816
- {withReferencedSources}
405
+ <LocalReferencedSourcesContext.Provider value={refsCtx}>
406
+ {inner}
407
+ </LocalReferencedSourcesContext.Provider>
817
408
  </LocalAttachmentsContext.Provider>
818
409
  );
819
410
  };
820
-
821
- export type PromptInputBodyProps = HTMLAttributes<HTMLDivElement>;
822
-
823
- export const PromptInputBody = ({
824
- className,
825
- ...props
826
- }: PromptInputBodyProps) => (
827
- <div className={cn("contents", className)} {...props} />
828
- );
829
-
830
- export type PromptInputTextareaProps = ComponentProps<
831
- typeof InputGroupTextarea
832
- >;
833
-
834
- export const PromptInputTextarea = ({
835
- onChange,
836
- onKeyDown,
837
- className,
838
- placeholder = "What would you like to know?",
839
- ...props
840
- }: PromptInputTextareaProps) => {
841
- const controller = useOptionalPromptInputController();
842
- const attachments = usePromptInputAttachments();
843
- const [isComposing, setIsComposing] = useState(false);
844
-
845
- const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = useCallback(
846
- (e) => {
847
- // Call the external onKeyDown handler first
848
- onKeyDown?.(e);
849
-
850
- // If the external handler prevented default, don't run internal logic
851
- if (e.defaultPrevented) {
852
- return;
853
- }
854
-
855
- if (e.key === "Enter") {
856
- if (isComposing || e.nativeEvent.isComposing) {
857
- return;
858
- }
859
- if (e.shiftKey) {
860
- return;
861
- }
862
- e.preventDefault();
863
-
864
- // Check if the submit button is disabled before submitting
865
- const { form } = e.currentTarget;
866
- const submitButton = form?.querySelector(
867
- 'button[type="submit"]'
868
- ) as HTMLButtonElement | null;
869
- if (submitButton?.disabled) {
870
- return;
871
- }
872
-
873
- form?.requestSubmit();
874
- }
875
-
876
- // Remove last attachment when Backspace is pressed and textarea is empty
877
- if (
878
- e.key === "Backspace" &&
879
- e.currentTarget.value === "" &&
880
- attachments.files.length > 0
881
- ) {
882
- e.preventDefault();
883
- const lastAttachment = attachments.files.at(-1);
884
- if (lastAttachment) {
885
- attachments.remove(lastAttachment.id);
886
- }
887
- }
888
- },
889
- [onKeyDown, isComposing, attachments]
890
- );
891
-
892
- const handlePaste: ClipboardEventHandler<HTMLTextAreaElement> = useCallback(
893
- (event) => {
894
- const items = event.clipboardData?.items;
895
-
896
- if (!items) {
897
- return;
898
- }
899
-
900
- const files: File[] = [];
901
-
902
- for (const item of items) {
903
- if (item.kind === "file") {
904
- const file = item.getAsFile();
905
- if (file) {
906
- files.push(file);
907
- }
908
- }
909
- }
910
-
911
- if (files.length > 0) {
912
- event.preventDefault();
913
- attachments.add(files);
914
- }
915
- },
916
- [attachments]
917
- );
918
-
919
- const handleCompositionEnd = useCallback(() => setIsComposing(false), []);
920
- const handleCompositionStart = useCallback(() => setIsComposing(true), []);
921
-
922
- const controlledProps = controller
923
- ? {
924
- onChange: (e: ChangeEvent<HTMLTextAreaElement>) => {
925
- controller.textInput.setInput(e.currentTarget.value);
926
- onChange?.(e);
927
- },
928
- value: controller.textInput.value,
929
- }
930
- : {
931
- onChange,
932
- };
933
-
934
- return (
935
- <InputGroupTextarea
936
- className={cn("field-sizing-content max-h-48 min-h-16", className)}
937
- name="message"
938
- onCompositionEnd={handleCompositionEnd}
939
- onCompositionStart={handleCompositionStart}
940
- onKeyDown={handleKeyDown}
941
- onPaste={handlePaste}
942
- placeholder={placeholder}
943
- {...props}
944
- {...controlledProps}
945
- />
946
- );
947
- };
948
-
949
- export type PromptInputHeaderProps = Omit<
950
- ComponentProps<typeof InputGroupAddon>,
951
- "align"
952
- >;
953
-
954
- export const PromptInputHeader = ({
955
- className,
956
- ...props
957
- }: PromptInputHeaderProps) => (
958
- <InputGroupAddon
959
- align="block-end"
960
- className={cn("order-first flex-wrap gap-1", className)}
961
- {...props}
962
- />
963
- );
964
-
965
- export type PromptInputFooterProps = Omit<
966
- ComponentProps<typeof InputGroupAddon>,
967
- "align"
968
- >;
969
-
970
- export const PromptInputFooter = ({
971
- className,
972
- ...props
973
- }: PromptInputFooterProps) => (
974
- <InputGroupAddon
975
- align="block-end"
976
- className={cn("justify-between gap-1", className)}
977
- {...props}
978
- />
979
- );
980
-
981
- export type PromptInputToolsProps = HTMLAttributes<HTMLDivElement>;
982
-
983
- export const PromptInputTools = ({
984
- className,
985
- ...props
986
- }: PromptInputToolsProps) => (
987
- <div
988
- className={cn("flex min-w-0 items-center gap-1", className)}
989
- {...props}
990
- />
991
- );
992
-
993
- export type PromptInputButtonTooltip =
994
- | string
995
- | {
996
- content: ReactNode;
997
- shortcut?: string;
998
- side?: ComponentProps<typeof TooltipContent>["side"];
999
- };
1000
-
1001
- export type PromptInputButtonProps = ComponentProps<typeof InputGroupButton> & {
1002
- tooltip?: PromptInputButtonTooltip;
1003
- };
1004
-
1005
- export const PromptInputButton = ({
1006
- variant = "ghost",
1007
- className,
1008
- size,
1009
- tooltip,
1010
- ...props
1011
- }: PromptInputButtonProps) => {
1012
- const newSize =
1013
- size ?? (Children.count(props.children) > 1 ? "sm" : "icon-sm");
1014
-
1015
- const button = (
1016
- <InputGroupButton
1017
- className={cn(className)}
1018
- size={newSize}
1019
- type="button"
1020
- variant={variant}
1021
- {...props}
1022
- />
1023
- );
1024
-
1025
- if (!tooltip) {
1026
- return button;
1027
- }
1028
-
1029
- const tooltipContent =
1030
- typeof tooltip === "string" ? tooltip : tooltip.content;
1031
- const shortcut = typeof tooltip === "string" ? undefined : tooltip.shortcut;
1032
- const side = typeof tooltip === "string" ? "top" : (tooltip.side ?? "top");
1033
-
1034
- return (
1035
- <Tooltip>
1036
- <TooltipTrigger asChild>{button}</TooltipTrigger>
1037
- <TooltipContent side={side}>
1038
- {tooltipContent}
1039
- {shortcut && (
1040
- <span className="ml-2 text-muted-foreground">{shortcut}</span>
1041
- )}
1042
- </TooltipContent>
1043
- </Tooltip>
1044
- );
1045
- };
1046
-
1047
- export type PromptInputActionMenuProps = ComponentProps<typeof DropdownMenu>;
1048
- export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => (
1049
- <DropdownMenu {...props} />
1050
- );
1051
-
1052
- export type PromptInputActionMenuTriggerProps = PromptInputButtonProps;
1053
-
1054
- export const PromptInputActionMenuTrigger = ({
1055
- className,
1056
- children,
1057
- ...props
1058
- }: PromptInputActionMenuTriggerProps) => (
1059
- <DropdownMenuTrigger asChild>
1060
- <PromptInputButton className={className} {...props}>
1061
- {children ?? <PlusIcon className="size-4" />}
1062
- </PromptInputButton>
1063
- </DropdownMenuTrigger>
1064
- );
1065
-
1066
- export type PromptInputActionMenuContentProps = ComponentProps<
1067
- typeof DropdownMenuContent
1068
- >;
1069
- export const PromptInputActionMenuContent = ({
1070
- className,
1071
- ...props
1072
- }: PromptInputActionMenuContentProps) => (
1073
- <DropdownMenuContent align="start" className={cn(className)} {...props} />
1074
- );
1075
-
1076
- export type PromptInputActionMenuItemProps = ComponentProps<
1077
- typeof DropdownMenuItem
1078
- >;
1079
- export const PromptInputActionMenuItem = ({
1080
- className,
1081
- ...props
1082
- }: PromptInputActionMenuItemProps) => (
1083
- <DropdownMenuItem className={cn(className)} {...props} />
1084
- );
1085
-
1086
- // Note: Actions that perform side-effects (like opening a file dialog)
1087
- // are provided in opt-in modules (e.g., prompt-input-attachments).
1088
-
1089
- export type PromptInputSubmitProps = ComponentProps<typeof InputGroupButton> & {
1090
- status?: ChatStatus;
1091
- onStop?: () => void;
1092
- };
1093
-
1094
- export const PromptInputSubmit = ({
1095
- className,
1096
- variant = "default",
1097
- size = "icon-sm",
1098
- status,
1099
- onStop,
1100
- onClick,
1101
- children,
1102
- ...props
1103
- }: PromptInputSubmitProps) => {
1104
- const isGenerating = status === "submitted" || status === "streaming";
1105
-
1106
- let Icon = <CornerDownLeftIcon className="size-4" />;
1107
-
1108
- if (status === "submitted") {
1109
- Icon = <Spinner />;
1110
- } else if (status === "streaming") {
1111
- Icon = <SquareIcon className="size-4" />;
1112
- } else if (status === "error") {
1113
- Icon = <XIcon className="size-4" />;
1114
- }
1115
-
1116
- const handleClick = useCallback(
1117
- (e: React.MouseEvent<HTMLButtonElement>) => {
1118
- if (isGenerating && onStop) {
1119
- e.preventDefault();
1120
- onStop();
1121
- return;
1122
- }
1123
- onClick?.(e);
1124
- },
1125
- [isGenerating, onStop, onClick]
1126
- );
1127
-
1128
- return (
1129
- <InputGroupButton
1130
- aria-label={isGenerating ? "Stop" : "Submit"}
1131
- className={cn(className)}
1132
- onClick={handleClick}
1133
- size={size}
1134
- type={isGenerating && onStop ? "button" : "submit"}
1135
- variant={variant}
1136
- {...props}
1137
- >
1138
- {children ?? Icon}
1139
- </InputGroupButton>
1140
- );
1141
- };
1142
-
1143
- export type PromptInputSelectProps = ComponentProps<typeof Select>;
1144
-
1145
- export const PromptInputSelect = (props: PromptInputSelectProps) => (
1146
- <Select {...props} />
1147
- );
1148
-
1149
- export type PromptInputSelectTriggerProps = ComponentProps<
1150
- typeof SelectTrigger
1151
- >;
1152
-
1153
- export const PromptInputSelectTrigger = ({
1154
- className,
1155
- ...props
1156
- }: PromptInputSelectTriggerProps) => (
1157
- <SelectTrigger
1158
- className={cn(
1159
- "border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors",
1160
- "hover:bg-accent hover:text-foreground aria-expanded:bg-accent aria-expanded:text-foreground",
1161
- className
1162
- )}
1163
- {...props}
1164
- />
1165
- );
1166
-
1167
- export type PromptInputSelectContentProps = ComponentProps<
1168
- typeof SelectContent
1169
- >;
1170
-
1171
- export const PromptInputSelectContent = ({
1172
- className,
1173
- ...props
1174
- }: PromptInputSelectContentProps) => (
1175
- <SelectContent className={cn(className)} {...props} />
1176
- );
1177
-
1178
- export type PromptInputSelectItemProps = ComponentProps<typeof SelectItem>;
1179
-
1180
- export const PromptInputSelectItem = ({
1181
- className,
1182
- ...props
1183
- }: PromptInputSelectItemProps) => (
1184
- <SelectItem className={cn(className)} {...props} />
1185
- );
1186
-
1187
- export type PromptInputSelectValueProps = ComponentProps<typeof SelectValue>;
1188
-
1189
- export const PromptInputSelectValue = ({
1190
- className,
1191
- ...props
1192
- }: PromptInputSelectValueProps) => (
1193
- <SelectValue className={cn(className)} {...props} />
1194
- );
1195
-
1196
- export type PromptInputHoverCardProps = ComponentProps<typeof HoverCard>;
1197
-
1198
- export const PromptInputHoverCard = ({
1199
- openDelay = 0,
1200
- closeDelay = 0,
1201
- ...props
1202
- }: PromptInputHoverCardProps) => (
1203
- <HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} />
1204
- );
1205
-
1206
- export type PromptInputHoverCardTriggerProps = ComponentProps<
1207
- typeof HoverCardTrigger
1208
- >;
1209
-
1210
- export const PromptInputHoverCardTrigger = (
1211
- props: PromptInputHoverCardTriggerProps
1212
- ) => <HoverCardTrigger {...props} />;
1213
-
1214
- export type PromptInputHoverCardContentProps = ComponentProps<
1215
- typeof HoverCardContent
1216
- >;
1217
-
1218
- export const PromptInputHoverCardContent = ({
1219
- align = "start",
1220
- ...props
1221
- }: PromptInputHoverCardContentProps) => (
1222
- <HoverCardContent align={align} {...props} />
1223
- );
1224
-
1225
- export type PromptInputTabsListProps = HTMLAttributes<HTMLDivElement>;
1226
-
1227
- export const PromptInputTabsList = ({
1228
- className,
1229
- ...props
1230
- }: PromptInputTabsListProps) => <div className={cn(className)} {...props} />;
1231
-
1232
- export type PromptInputTabProps = HTMLAttributes<HTMLDivElement>;
1233
-
1234
- export const PromptInputTab = ({
1235
- className,
1236
- ...props
1237
- }: PromptInputTabProps) => <div className={cn(className)} {...props} />;
1238
-
1239
- export type PromptInputTabLabelProps = HTMLAttributes<HTMLHeadingElement>;
1240
-
1241
- export const PromptInputTabLabel = ({
1242
- className,
1243
- ...props
1244
- }: PromptInputTabLabelProps) => (
1245
- // Content provided via children in props
1246
- // oxlint-disable-next-line eslint-plugin-jsx-a11y(heading-has-content)
1247
- <h3
1248
- className={cn(
1249
- "mb-2 px-3 font-medium text-muted-foreground text-xs",
1250
- className
1251
- )}
1252
- {...props}
1253
- />
1254
- );
1255
-
1256
- export type PromptInputTabBodyProps = HTMLAttributes<HTMLDivElement>;
1257
-
1258
- export const PromptInputTabBody = ({
1259
- className,
1260
- ...props
1261
- }: PromptInputTabBodyProps) => (
1262
- <div className={cn("space-y-1", className)} {...props} />
1263
- );
1264
-
1265
- export type PromptInputTabItemProps = HTMLAttributes<HTMLDivElement>;
1266
-
1267
- export const PromptInputTabItem = ({
1268
- className,
1269
- ...props
1270
- }: PromptInputTabItemProps) => (
1271
- <div
1272
- className={cn(
1273
- "flex items-center gap-2 px-3 py-2 text-xs hover:bg-accent",
1274
- className
1275
- )}
1276
- {...props}
1277
- />
1278
- );
1279
-
1280
- export type PromptInputCommandProps = ComponentProps<typeof Command>;
1281
-
1282
- export const PromptInputCommand = ({
1283
- className,
1284
- ...props
1285
- }: PromptInputCommandProps) => <Command className={cn(className)} {...props} />;
1286
-
1287
- export type PromptInputCommandInputProps = ComponentProps<typeof CommandInput>;
1288
-
1289
- export const PromptInputCommandInput = ({
1290
- className,
1291
- ...props
1292
- }: PromptInputCommandInputProps) => (
1293
- <CommandInput className={cn(className)} {...props} />
1294
- );
1295
-
1296
- export type PromptInputCommandListProps = ComponentProps<typeof CommandList>;
1297
-
1298
- export const PromptInputCommandList = ({
1299
- className,
1300
- ...props
1301
- }: PromptInputCommandListProps) => (
1302
- <CommandList className={cn(className)} {...props} />
1303
- );
1304
-
1305
- export type PromptInputCommandEmptyProps = ComponentProps<typeof CommandEmpty>;
1306
-
1307
- export const PromptInputCommandEmpty = ({
1308
- className,
1309
- ...props
1310
- }: PromptInputCommandEmptyProps) => (
1311
- <CommandEmpty className={cn(className)} {...props} />
1312
- );
1313
-
1314
- export type PromptInputCommandGroupProps = ComponentProps<typeof CommandGroup>;
1315
-
1316
- export const PromptInputCommandGroup = ({
1317
- className,
1318
- ...props
1319
- }: PromptInputCommandGroupProps) => (
1320
- <CommandGroup className={cn(className)} {...props} />
1321
- );
1322
-
1323
- export type PromptInputCommandItemProps = ComponentProps<typeof CommandItem>;
1324
-
1325
- export const PromptInputCommandItem = ({
1326
- className,
1327
- ...props
1328
- }: PromptInputCommandItemProps) => (
1329
- <CommandItem className={cn(className)} {...props} />
1330
- );
1331
-
1332
- export type PromptInputCommandSeparatorProps = ComponentProps<
1333
- typeof CommandSeparator
1334
- >;
1335
-
1336
- export const PromptInputCommandSeparator = ({
1337
- className,
1338
- ...props
1339
- }: PromptInputCommandSeparatorProps) => (
1340
- <CommandSeparator className={cn(className)} {...props} />
1341
- );