"use client"; import { Button } from "@/components/ui/button"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, } from "@/components/ui/command"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { HoverCard, HoverCardContent, HoverCardTrigger, } from "@/components/ui/hover-card"; import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea, } from "@/components/ui/input-group"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import type { ChatStatus, FileUIPart } from "ai"; import { ImageIcon, Loader2Icon, MicIcon, PaperclipIcon, PlusIcon, SendIcon, SquareIcon, XIcon, } from "lucide-react"; import { nanoid } from "nanoid"; import { type ChangeEvent, type ChangeEventHandler, Children, type ClipboardEventHandler, type ComponentProps, createContext, type FormEvent, type FormEventHandler, Fragment, type HTMLAttributes, type KeyboardEventHandler, type PropsWithChildren, type ReactNode, type RefObject, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; // ============================================================================ // Provider Context & Types // ============================================================================ export type AttachmentsContext = { files: (FileUIPart & { id: string })[]; add: (files: File[] | FileList) => void; remove: (id: string) => void; clear: () => void; openFileDialog: () => void; fileInputRef: RefObject; }; export type TextInputContext = { value: string; setInput: (v: string) => void; clear: () => void; }; export type PromptInputControllerProps = { textInput: TextInputContext; attachments: AttachmentsContext; /** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */ __registerFileInput: ( ref: RefObject, open: () => void ) => void; }; const PromptInputController = createContext( null ); const ProviderAttachmentsContext = createContext( null ); export const usePromptInputController = () => { const ctx = useContext(PromptInputController); if (!ctx) { throw new Error( "Wrap your component inside to use usePromptInputController()." ); } return ctx; }; // Optional variants (do NOT throw). Useful for dual-mode components. const useOptionalPromptInputController = () => useContext(PromptInputController); export const useProviderAttachments = () => { const ctx = useContext(ProviderAttachmentsContext); if (!ctx) { throw new Error( "Wrap your component inside to use useProviderAttachments()." ); } return ctx; }; const useOptionalProviderAttachments = () => useContext(ProviderAttachmentsContext); export type PromptInputProviderProps = PropsWithChildren<{ initialInput?: string; }>; /** * Optional global provider that lifts PromptInput state outside of PromptInput. * If you don't use it, PromptInput stays fully self-managed. */ export function PromptInputProvider({ initialInput: initialTextInput = "", children, }: PromptInputProviderProps) { // ----- textInput state const [textInput, setTextInput] = useState(initialTextInput); const clearInput = useCallback(() => setTextInput(""), []); // ----- attachments state (global when wrapped) const [attachements, setAttachements] = useState< (FileUIPart & { id: string })[] >([]); const fileInputRef = useRef(null); const openRef = useRef<() => void>(() => { }); const add = useCallback((files: File[] | FileList) => { const incoming = Array.from(files); if (incoming.length === 0) return; setAttachements((prev) => prev.concat( incoming.map((file) => ({ id: nanoid(), type: "file" as const, url: URL.createObjectURL(file), mediaType: file.type, filename: file.name, })) ) ); }, []); const remove = useCallback((id: string) => { setAttachements((prev) => { const found = prev.find((f) => f.id === id); if (found?.url) URL.revokeObjectURL(found.url); return prev.filter((f) => f.id !== id); }); }, []); const clear = useCallback(() => { setAttachements((prev) => { for (const f of prev) if (f.url) URL.revokeObjectURL(f.url); return []; }); }, []); const openFileDialog = useCallback(() => { openRef.current?.(); }, []); const attachments = useMemo( () => ({ files: attachements, add, remove, clear, openFileDialog, fileInputRef, }), [attachements, add, remove, clear, openFileDialog] ); const __registerFileInput = useCallback( (ref: RefObject, open: () => void) => { fileInputRef.current = ref.current; openRef.current = open; }, [] ); const controller = useMemo( () => ({ textInput: { value: textInput, setInput: setTextInput, clear: clearInput, }, attachments, __registerFileInput, }), [textInput, clearInput, attachments, __registerFileInput] ); return ( {children} ); } // ============================================================================ // Component Context & Hooks // ============================================================================ const LocalAttachmentsContext = createContext(null); export const usePromptInputAttachments = () => { // Dual-mode: prefer provider if present, otherwise use local const provider = useOptionalProviderAttachments(); const local = useContext(LocalAttachmentsContext); const context = provider ?? local; if (!context) { throw new Error( "usePromptInputAttachments must be used within a PromptInput or PromptInputProvider" ); } return context; }; export type PromptInputAttachmentProps = HTMLAttributes & { data: FileUIPart & { id: string }; className?: string; }; export function PromptInputAttachment({ data, className, ...props }: PromptInputAttachmentProps) { const attachments = usePromptInputAttachments(); const mediaType = data.mediaType?.startsWith("image/") && data.url ? "image" : "file"; return (
{mediaType === "image" ? ( {data.filename ) : (

{data.filename || "Unknown file"}

{data.filename || "Unknown file"}

{data.mediaType &&
{data.mediaType}
}
)}
); } export type PromptInputAttachmentsProps = Omit< HTMLAttributes, "children" > & { children: (attachment: FileUIPart & { id: string }) => ReactNode; }; export function PromptInputAttachments({ className, children, ...props }: PromptInputAttachmentsProps) { const attachments = usePromptInputAttachments(); const [height, setHeight] = useState(0); const contentRef = useRef(null); useLayoutEffect(() => { const el = contentRef.current; if (!el) { return; } const ro = new ResizeObserver(() => { setHeight(el.getBoundingClientRect().height); }); ro.observe(el); setHeight(el.getBoundingClientRect().height); return () => ro.disconnect(); }, []); // biome-ignore lint/correctness/useExhaustiveDependencies: Force height measurement when attachments change useLayoutEffect(() => { const el = contentRef.current; if (!el) { return; } setHeight(el.getBoundingClientRect().height); }, [attachments.files.length]); if (attachments.files.length === 0) { return null; } return (
{attachments.files .filter((f) => !(f.mediaType?.startsWith("image/") && f.url)) .map((file) => ( {children(file)} ))}
{attachments.files .filter((f) => f.mediaType?.startsWith("image/") && f.url) .map((file) => ( {children(file)} ))}
); } export type PromptInputActionAddAttachmentsProps = ComponentProps< typeof DropdownMenuItem > & { label?: string; }; export const PromptInputActionAddAttachments = ({ label = "Add photos or files", ...props }: PromptInputActionAddAttachmentsProps) => { const attachments = usePromptInputAttachments(); return ( { e.preventDefault(); attachments.openFileDialog(); }} > {label} ); }; export type PromptInputMessage = { text?: string; files?: FileUIPart[]; }; export type PromptInputProps = Omit< HTMLAttributes, "onSubmit" | "onError" > & { accept?: string; // e.g., "image/*" or leave undefined for any multiple?: boolean; // When true, accepts drops anywhere on document. Default false (opt-in). globalDrop?: boolean; // Render a hidden input with given name and keep it in sync for native form posts. Default false. syncHiddenInput?: boolean; // Minimal constraints maxFiles?: number; maxFileSize?: number; // bytes onError?: (err: { code: "max_files" | "max_file_size" | "accept"; message: string; }) => void; onSubmit: ( message: PromptInputMessage, event: FormEvent ) => void | Promise; }; export const PromptInput = ({ className, accept, multiple, globalDrop, syncHiddenInput, maxFiles, maxFileSize, onError, onSubmit, children, ...props }: PromptInputProps) => { // Try to use a provider controller if present const controller = useOptionalPromptInputController(); const usingProvider = !!controller; // Refs const inputRef = useRef(null); const anchorRef = useRef(null); const formRef = useRef(null); // Find nearest form to scope drag & drop useEffect(() => { const root = anchorRef.current?.closest("form"); if (root instanceof HTMLFormElement) { formRef.current = root; } }, []); // ----- Local attachments (only used when no provider) const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]); const files = usingProvider ? controller.attachments.files : items; const openFileDialogLocal = useCallback(() => { inputRef.current?.click(); }, []); const matchesAccept = useCallback( (f: File) => { if (!accept || accept.trim() === "") { return true; } if (accept.includes("image/*")) { return f.type.startsWith("image/"); } // NOTE: keep simple; expand as needed return true; }, [accept] ); const addLocal = useCallback( (fileList: File[] | FileList) => { const incoming = Array.from(fileList); const accepted = incoming.filter((f) => matchesAccept(f)); if (incoming.length && accepted.length === 0) { onError?.({ code: "accept", message: "No files match the accepted types.", }); return; } const withinSize = (f: File) => maxFileSize ? f.size <= maxFileSize : true; const sized = accepted.filter(withinSize); if (accepted.length > 0 && sized.length === 0) { onError?.({ code: "max_file_size", message: "All files exceed the maximum size.", }); return; } setItems((prev) => { const capacity = typeof maxFiles === "number" ? Math.max(0, maxFiles - prev.length) : undefined; const capped = typeof capacity === "number" ? sized.slice(0, capacity) : sized; if (typeof capacity === "number" && sized.length > capacity) { onError?.({ code: "max_files", message: "Too many files. Some were not added.", }); } const next: (FileUIPart & { id: string })[] = []; for (const file of capped) { next.push({ id: nanoid(), type: "file", url: URL.createObjectURL(file), mediaType: file.type, filename: file.name, }); } return prev.concat(next); }); }, [matchesAccept, maxFiles, maxFileSize, onError] ); const add = usingProvider ? (files: File[] | FileList) => controller.attachments.add(files) : addLocal; const remove = usingProvider ? (id: string) => controller.attachments.remove(id) : (id: string) => setItems((prev) => { const found = prev.find((file) => file.id === id); if (found?.url) { URL.revokeObjectURL(found.url); } return prev.filter((file) => file.id !== id); }); const clear = usingProvider ? () => controller.attachments.clear() : () => setItems((prev) => { for (const file of prev) { if (file.url) { URL.revokeObjectURL(file.url); } } return []; }); const openFileDialog = usingProvider ? () => controller.attachments.openFileDialog() : openFileDialogLocal; // Let provider know about our hidden file input so external menus can call openFileDialog() useEffect(() => { if (!usingProvider) return; controller.__registerFileInput(inputRef, () => inputRef.current?.click()); }, [usingProvider, controller]); // Note: File input cannot be programmatically set for security reasons // The syncHiddenInput prop is no longer functional useEffect(() => { if (syncHiddenInput && inputRef.current && files.length === 0) { inputRef.current.value = ""; } }, [files, syncHiddenInput]); // Attach drop handlers on nearest form and document (opt-in) useEffect(() => { const form = formRef.current; if (!form) return; const onDragOver = (e: DragEvent) => { if (e.dataTransfer?.types?.includes("Files")) { e.preventDefault(); } }; const onDrop = (e: DragEvent) => { if (e.dataTransfer?.types?.includes("Files")) { e.preventDefault(); } if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { add(e.dataTransfer.files); } }; form.addEventListener("dragover", onDragOver); form.addEventListener("drop", onDrop); return () => { form.removeEventListener("dragover", onDragOver); form.removeEventListener("drop", onDrop); }; }, [add]); useEffect(() => { if (!globalDrop) return; const onDragOver = (e: DragEvent) => { if (e.dataTransfer?.types?.includes("Files")) { e.preventDefault(); } }; const onDrop = (e: DragEvent) => { if (e.dataTransfer?.types?.includes("Files")) { e.preventDefault(); } if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { add(e.dataTransfer.files); } }; document.addEventListener("dragover", onDragOver); document.addEventListener("drop", onDrop); return () => { document.removeEventListener("dragover", onDragOver); document.removeEventListener("drop", onDrop); }; }, [add, globalDrop]); useEffect( () => () => { if (!usingProvider) { for (const f of files) { if (f.url) URL.revokeObjectURL(f.url); } } }, [usingProvider, files] ); const handleChange: ChangeEventHandler = (event) => { if (event.currentTarget.files) { add(event.currentTarget.files); } }; const convertBlobUrlToDataUrl = async (url: string): Promise => { const response = await fetch(url); const blob = await response.blob(); return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result as string); reader.onerror = reject; reader.readAsDataURL(blob); }); }; const ctx = useMemo( () => ({ files: files.map((item) => ({ ...item, id: item.id })), add, remove, clear, openFileDialog, fileInputRef: inputRef, }), [files, add, remove, clear, openFileDialog] ); const handleSubmit: FormEventHandler = (event) => { event.preventDefault(); const form = event.currentTarget; const text = usingProvider ? controller.textInput.value : (() => { const formData = new FormData(form); return (formData.get("message") as string) || ""; })(); // Reset form immediately after capturing text to avoid race condition // where user input during async blob conversion would be lost if (!usingProvider) { form.reset(); } // Convert blob URLs to data URLs asynchronously Promise.all( files.map(async ({ id, ...item }) => { if (item.url && item.url.startsWith("blob:")) { return { ...item, url: await convertBlobUrlToDataUrl(item.url), }; } return item; }) ).then((convertedFiles: FileUIPart[]) => { try { const result = onSubmit({ text, files: convertedFiles }, event); // Handle both sync and async onSubmit if (result instanceof Promise) { result .then(() => { clear(); if (usingProvider) { controller.textInput.clear(); } }) .catch(() => { // Don't clear on error - user may want to retry }); } else { // Sync function completed without throwing, clear attachments clear(); if (usingProvider) { controller.textInput.clear(); } } } catch (error) { // Don't clear on error - user may want to retry } }); }; // Render with or without local provider const inner = ( <>