'use client';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import type { ChatStatus, FileUIPart } from 'ai';
import {
ImageIcon,
Loader2Icon,
PaperclipIcon,
PlusIcon,
SendIcon,
SquareIcon,
XIcon,
} from 'lucide-react';
import { nanoid } from 'nanoid';
import {
type ChangeEventHandler,
Children,
type ComponentProps,
createContext,
type FormEvent,
type FormEventHandler,
Fragment,
type HTMLAttributes,
type KeyboardEventHandler,
type RefObject,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
type AttachmentsContext = {
files: (FileUIPart & { id: string })[];
add: (files: File[] | FileList) => void;
remove: (id: string) => void;
clear: () => void;
openFileDialog: () => void;
fileInputRef: RefObject<HTMLInputElement | null>;
};
const AttachmentsContext = createContext<AttachmentsContext | null>(null);
export const usePromptInputAttachments = () => {
const context = useContext(AttachmentsContext);
if (!context) {
throw new Error(
'usePromptInputAttachments must be used within a PromptInput',
);
}
return context;
};
export type PromptInputAttachmentProps = HTMLAttributes<HTMLDivElement> & {
data: FileUIPart & { id: string };
className?: string;
};
export function PromptInputAttachment({
data,
className,
...props
}: PromptInputAttachmentProps) {
const attachments = usePromptInputAttachments();
return (
<div
data-slot="prompt-input-attachment"
className={cn('group relative h-14 w-14 rounded-md border', className)}
key={data.id}
{...props}
>
{data.mediaType?.startsWith('image/') && data.url ? (
<img
alt={data.filename || 'attachment'}
className="size-full rounded-md object-cover"
height={56}
src={data.url}
width={56}
/>
) : (
<div className="flex size-full items-center justify-center text-muted-foreground">
<PaperclipIcon className="size-4" />
</div>
)}
<Button
aria-label="Remove attachment"
className="-right-1.5 -top-1.5 absolute h-6 w-6 rounded-full opacity-0 group-hover:opacity-100"
onClick={() => attachments.remove(data.id)}
size="icon"
type="button"
variant="outline"
>
<XIcon className="h-3 w-3" />
</Button>
</div>
);
}
export type PromptInputAttachmentsProps = Omit<
HTMLAttributes<HTMLDivElement>,
'children'
> & {
children: (attachment: FileUIPart & { id: string }) => React.ReactNode;
};
export function PromptInputAttachments({
className,
children,
...props
}: PromptInputAttachmentsProps) {
const attachments = usePromptInputAttachments();
const [height, setHeight] = useState(0);
const contentRef = useRef<HTMLDivElement>(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();
}, []);
return (
<div
data-slot="prompt-input-attachments"
aria-live="polite"
className={cn(
'overflow-hidden transition-[height] duration-200 ease-out',
className,
)}
style={{ height: attachments.files.length ? height : 0 }}
{...props}
>
<div className="flex flex-wrap gap-2 p-3 pt-3" ref={contentRef}>
{attachments.files.map((file) => (
<Fragment key={file.id}>{children(file)}</Fragment>
))}
</div>
</div>
);
}
export type PromptInputActionAddAttachmentsProps = ComponentProps<
typeof DropdownMenuItem
> & {
label?: string;
};
export const PromptInputActionAddAttachments = ({
label = 'Add photos or files',
...props
}: PromptInputActionAddAttachmentsProps) => {
const attachments = usePromptInputAttachments();
return (
<DropdownMenuItem
data-slot="prompt-input-action-add-attachments"
{...props}
onSelect={(e) => {
e.preventDefault();
attachments.openFileDialog();
}}
>
<ImageIcon className="mr-2 size-4" /> {label}
</DropdownMenuItem>
);
};
export type PromptInputMessage = {
text?: string;
files?: FileUIPart[];
};
export type PromptInputProps = Omit<
HTMLAttributes<HTMLFormElement>,
'onSubmit'
> & {
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<HTMLFormElement>,
) => void;
};
export const PromptInput = ({
className,
accept,
multiple,
globalDrop,
syncHiddenInput,
maxFiles,
maxFileSize,
onError,
onSubmit,
...props
}: PromptInputProps) => {
const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]);
const inputRef = useRef<HTMLInputElement | null>(null);
const anchorRef = useRef<HTMLSpanElement>(null);
const formRef = useRef<HTMLFormElement | null>(null);
// Find nearest form to scope drag & drop
useEffect(() => {
const root = anchorRef.current?.closest('form');
if (root instanceof HTMLFormElement) {
formRef.current = root;
}
}, []);
const openFileDialog = useCallback(() => {
inputRef.current?.click();
}, []);
const matchesAccept = useCallback(
(f: File) => {
if (!accept || accept.trim() === '') {
return true;
}
// Simple check: if accept includes "image/*", filter to images; otherwise allow.
if (accept.includes('image/*')) {
return f.type.startsWith('image/');
}
return true;
},
[accept],
);
const add = useCallback(
(files: File[] | FileList) => {
const incoming = Array.from(files);
const accepted = incoming.filter((f) => matchesAccept(f));
if (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 (sized.length === 0 && accepted.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 remove = useCallback((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 = useCallback(() => {
setItems((prev) => {
for (const file of prev) {
if (file.url) {
URL.revokeObjectURL(file.url);
}
}
return [];
});
}, []);
// Note: File input cannot be programmatically set for security reasons
// The syncHiddenInput prop is no longer functional
useEffect(() => {
if (syncHiddenInput && inputRef.current) {
// Clear the input when items are cleared
if (items.length === 0) {
inputRef.current.value = '';
}
}
}, [items, 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]);
const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
if (event.currentTarget.files) {
add(event.currentTarget.files);
}
};
const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
const files: FileUIPart[] = items.map(({ ...item }) => ({
...item,
}));
onSubmit({ text: event.currentTarget.message.value, files }, event);
};
const ctx = useMemo<AttachmentsContext>(
() => ({
files: items.map((item) => ({ ...item, id: item.id })),
add,
remove,
clear,
openFileDialog,
fileInputRef: inputRef,
}),
[items, add, remove, clear, openFileDialog],
);
return (
<AttachmentsContext.Provider value={ctx}>
<span aria-hidden="true" className="hidden" ref={anchorRef} />
<input
accept={accept}
className="hidden"
multiple={multiple}
onChange={handleChange}
ref={inputRef}
type="file"
/>
<form
data-slot="prompt-input"
className={cn(
'w-full overflow-hidden rounded-xl border bg-background shadow-sm',
className,
)}
onSubmit={handleSubmit}
{...props}
/>
</AttachmentsContext.Provider>
);
};
export type PromptInputBodyProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputBody = ({
className,
...props
}: PromptInputBodyProps) => (
<div data-slot="prompt-input-body" className={cn(className, 'flex flex-col')} {...props} />
);
export type PromptInputTextareaProps = ComponentProps<typeof Textarea>;
export const PromptInputTextarea = ({
onChange,
className,
placeholder = 'What would you like to know?',
...props
}: PromptInputTextareaProps) => {
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
if (e.key === 'Enter') {
// Don't submit if IME composition is in progress
if (e.nativeEvent.isComposing) {
return;
}
if (e.shiftKey) {
// Allow newline
return;
}
// Submit on Enter (without Shift)
e.preventDefault();
const form = e.currentTarget.form;
if (form) {
form.requestSubmit();
}
}
};
return (
<Textarea
data-slot="prompt-input-textarea"
className={cn(
'w-full resize-none rounded-none border-none shadow-none outline-none ring-0',
'field-sizing-content bg-transparent dark:bg-transparent',
'max-h-48 min-h-16',
'focus-visible:ring-0',
className,
)}
name="message"
onChange={(e) => {
onChange?.(e);
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
{...props}
/>
);
};
export type PromptInputToolbarProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputToolbar = ({
className,
...props
}: PromptInputToolbarProps) => (
<div
data-slot="prompt-input-toolbar"
className={cn('flex items-center justify-between p-1', className)}
{...props}
/>
);
export type PromptInputToolsProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputTools = ({
className,
...props
}: PromptInputToolsProps) => (
<div
data-slot="prompt-input-tools"
className={cn(
'flex items-center gap-1',
'[&_button:first-child]:rounded-bl-xl',
className,
)}
{...props}
/>
);
export type PromptInputButtonProps = ComponentProps<typeof Button>;
export const PromptInputButton = ({
variant = 'ghost',
className,
size,
...props
}: PromptInputButtonProps) => {
const newSize =
(size ?? Children.count(props.children) > 1) ? 'default' : 'icon';
return (
<Button
data-slot="prompt-input-button"
className={cn(
'shrink-0 gap-1.5 rounded-lg',
variant === 'ghost' && 'text-muted-foreground',
newSize === 'default' && 'px-3',
className,
)}
size={newSize}
type="button"
variant={variant}
{...props}
/>
);
};
export type PromptInputActionMenuProps = ComponentProps<typeof DropdownMenu>;
export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => (
<DropdownMenu data-slot="prompt-input-action-menu" {...props} />
);
export type PromptInputActionMenuTriggerProps = ComponentProps<
typeof Button
> & {};
export const PromptInputActionMenuTrigger = ({
className,
children,
...props
}: PromptInputActionMenuTriggerProps) => (
<DropdownMenuTrigger asChild data-slot="prompt-input-action-menu-trigger">
<PromptInputButton className={className} {...props}>
{children ?? <PlusIcon className="size-4" />}
</PromptInputButton>
</DropdownMenuTrigger>
);
export type PromptInputActionMenuContentProps = ComponentProps<
typeof DropdownMenuContent
>;
export const PromptInputActionMenuContent = ({
className,
...props
}: PromptInputActionMenuContentProps) => (
<DropdownMenuContent data-slot="prompt-input-action-menu-content" align="start" className={cn(className)} {...props} />
);
export type PromptInputActionMenuItemProps = ComponentProps<
typeof DropdownMenuItem
>;
export const PromptInputActionMenuItem = ({
className,
...props
}: PromptInputActionMenuItemProps) => (
<DropdownMenuItem data-slot="prompt-input-action-menu-item" className={cn(className)} {...props} />
);
// Note: Actions that perform side-effects (like opening a file dialog)
// are provided in opt-in modules (e.g., prompt-input-attachments).
export type PromptInputSubmitProps = ComponentProps<typeof Button> & {
status?: ChatStatus;
};
export const PromptInputSubmit = ({
className,
variant = 'default',
size = 'icon',
status,
children,
...props
}: PromptInputSubmitProps) => {
let Icon = <SendIcon className="size-4" />;
if (status === 'submitted') {
Icon = <Loader2Icon className="size-4 animate-spin" />;
} else if (status === 'streaming') {
Icon = <SquareIcon className="size-4" />;
} else if (status === 'error') {
Icon = <XIcon className="size-4" />;
}
return (
<Button
data-slot="prompt-input-submit"
className={cn('gap-1.5 rounded-lg', className)}
size={size}
type="submit"
variant={variant}
{...props}
>
{children ?? Icon}
</Button>
);
};
export type PromptInputModelSelectProps = ComponentProps<typeof Select>;
export const PromptInputModelSelect = (props: PromptInputModelSelectProps) => (
<Select data-slot="prompt-input-model-select" {...props} />
);
export type PromptInputModelSelectTriggerProps = ComponentProps<
typeof SelectTrigger
>;
export const PromptInputModelSelectTrigger = ({
className,
...props
}: PromptInputModelSelectTriggerProps) => (
<SelectTrigger
data-slot="prompt-input-model-select-trigger"
className={cn(
'border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors',
'hover:bg-accent hover:text-foreground [&[aria-expanded="true"]]:bg-accent [&[aria-expanded="true"]]:text-foreground',
className,
)}
{...props}
/>
);
export type PromptInputModelSelectContentProps = ComponentProps<
typeof SelectContent
>;
export const PromptInputModelSelectContent = ({
className,
...props
}: PromptInputModelSelectContentProps) => (
<SelectContent data-slot="prompt-input-model-select-content" className={cn(className)} {...props} />
);
export type PromptInputModelSelectItemProps = ComponentProps<typeof SelectItem>;
export const PromptInputModelSelectItem = ({
className,
...props
}: PromptInputModelSelectItemProps) => (
<SelectItem data-slot="prompt-input-model-select-item" className={cn(className)} {...props} />
);
export type PromptInputModelSelectValueProps = ComponentProps<
typeof SelectValue
>;
export const PromptInputModelSelectValue = ({
className,
...props
}: PromptInputModelSelectValueProps) => (
<SelectValue data-slot="prompt-input-model-select-value" className={cn(className)} {...props} />
);
pnpm dlx codebase add prompt-input
import { PromptInput } from "@/components/atom/prompt-input"<PromptInput />