1526 lines
43 KiB
TypeScript
1526 lines
43 KiB
TypeScript
"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 type { PromptInputFilePart } from "@/core/uploads";
|
||
import { splitUnsupportedUploadFiles } from "@/core/uploads";
|
||
import { isIMEComposing } from "@/lib/ime";
|
||
import { cn } from "@/lib/utils";
|
||
import type { ChatStatus } from "ai";
|
||
import {
|
||
ArrowUpIcon,
|
||
ImageIcon,
|
||
Loader2Icon,
|
||
MicIcon,
|
||
PaperclipIcon,
|
||
PlusIcon,
|
||
SquareIcon,
|
||
UploadIcon,
|
||
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,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from "react";
|
||
import { toast } from "sonner";
|
||
|
||
// ============================================================================
|
||
// Provider Context & Types
|
||
// ============================================================================
|
||
|
||
export type AttachmentsContext = {
|
||
files: (PromptInputFilePart & { id: string })[];
|
||
add: (files: File[] | FileList) => void;
|
||
remove: (id: string) => void;
|
||
clear: () => void;
|
||
openFileDialog: () => void;
|
||
fileInputRef: RefObject<HTMLInputElement | null>;
|
||
};
|
||
|
||
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<HTMLInputElement | null>,
|
||
open: () => void,
|
||
) => void;
|
||
};
|
||
|
||
const PromptInputController = createContext<PromptInputControllerProps | null>(
|
||
null,
|
||
);
|
||
const ProviderAttachmentsContext = createContext<AttachmentsContext | null>(
|
||
null,
|
||
);
|
||
const PromptInputValidationContext = createContext<
|
||
((files: File[] | FileList) => File[]) | null
|
||
>(null);
|
||
|
||
export const usePromptInputController = () => {
|
||
const ctx = useContext(PromptInputController);
|
||
if (!ctx) {
|
||
throw new Error(
|
||
"Wrap your component inside <PromptInputProvider> 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 <PromptInputProvider> to use useProviderAttachments().",
|
||
);
|
||
}
|
||
return ctx;
|
||
};
|
||
|
||
const useOptionalProviderAttachments = () =>
|
||
useContext(ProviderAttachmentsContext);
|
||
const usePromptInputValidation = () => useContext(PromptInputValidationContext);
|
||
|
||
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 [attachmentFiles, setAttachmentFiles] = useState<
|
||
(PromptInputFilePart & { id: string })[]
|
||
>([]);
|
||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||
const openRef = useRef<() => void>(() => {});
|
||
|
||
const add = useCallback((files: File[] | FileList) => {
|
||
const incoming = Array.from(files);
|
||
if (incoming.length === 0) {
|
||
return;
|
||
}
|
||
|
||
setAttachmentFiles((prev) =>
|
||
prev.concat(
|
||
incoming.map((file) => ({
|
||
id: nanoid(),
|
||
type: "file" as const,
|
||
url: URL.createObjectURL(file),
|
||
mediaType: file.type,
|
||
filename: file.name,
|
||
file,
|
||
})),
|
||
),
|
||
);
|
||
}, []);
|
||
|
||
const remove = useCallback((id: string) => {
|
||
setAttachmentFiles((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(() => {
|
||
setAttachmentFiles((prev) => {
|
||
for (const f of prev) {
|
||
if (f.url) {
|
||
URL.revokeObjectURL(f.url);
|
||
}
|
||
}
|
||
return [];
|
||
});
|
||
}, []);
|
||
|
||
// Keep a ref to attachments for cleanup on unmount (avoids stale closure)
|
||
const attachmentsRef = useRef(attachmentFiles);
|
||
attachmentsRef.current = attachmentFiles;
|
||
|
||
// Cleanup blob URLs on unmount to prevent memory leaks
|
||
useEffect(() => {
|
||
return () => {
|
||
for (const f of attachmentsRef.current) {
|
||
if (f.url) {
|
||
URL.revokeObjectURL(f.url);
|
||
}
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
const openFileDialog = useCallback(() => {
|
||
openRef.current?.();
|
||
}, []);
|
||
|
||
const attachments = useMemo<AttachmentsContext>(
|
||
() => ({
|
||
files: attachmentFiles,
|
||
add,
|
||
remove,
|
||
clear,
|
||
openFileDialog,
|
||
fileInputRef,
|
||
}),
|
||
[attachmentFiles, add, remove, clear, openFileDialog],
|
||
);
|
||
|
||
const __registerFileInput = useCallback(
|
||
(ref: RefObject<HTMLInputElement | null>, open: () => void) => {
|
||
fileInputRef.current = ref.current;
|
||
openRef.current = open;
|
||
},
|
||
[],
|
||
);
|
||
|
||
const controller = useMemo<PromptInputControllerProps>(
|
||
() => ({
|
||
textInput: {
|
||
value: textInput,
|
||
setInput: setTextInput,
|
||
clear: clearInput,
|
||
},
|
||
attachments,
|
||
__registerFileInput,
|
||
}),
|
||
[textInput, clearInput, attachments, __registerFileInput],
|
||
);
|
||
|
||
return (
|
||
<PromptInputController.Provider value={controller}>
|
||
<ProviderAttachmentsContext.Provider value={attachments}>
|
||
{children}
|
||
</ProviderAttachmentsContext.Provider>
|
||
</PromptInputController.Provider>
|
||
);
|
||
}
|
||
|
||
// ============================================================================
|
||
// Component Context & Hooks
|
||
// ============================================================================
|
||
|
||
const LocalAttachmentsContext = createContext<AttachmentsContext | null>(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<HTMLDivElement> & {
|
||
data: PromptInputFilePart & { id: string };
|
||
className?: string;
|
||
};
|
||
|
||
export function PromptInputAttachment({
|
||
data,
|
||
className,
|
||
...props
|
||
}: PromptInputAttachmentProps) {
|
||
const attachments = usePromptInputAttachments();
|
||
|
||
const filename = data.filename || "";
|
||
|
||
const mediaType =
|
||
data.mediaType?.startsWith("image/") && data.url ? "image" : "file";
|
||
const isImage = mediaType === "image";
|
||
|
||
const attachmentLabel = filename || (isImage ? "Image" : "Attachment");
|
||
|
||
return (
|
||
<PromptInputHoverCard>
|
||
<HoverCardTrigger asChild>
|
||
<div
|
||
className={cn(
|
||
"group border-border hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 relative flex h-8 cursor-pointer items-center gap-1.5 rounded-md border px-1.5 text-sm font-medium transition-all select-none",
|
||
className,
|
||
)}
|
||
key={data.id}
|
||
{...props}
|
||
>
|
||
<div className="relative size-5 shrink-0">
|
||
<div className="bg-background absolute inset-0 flex size-5 items-center justify-center overflow-hidden rounded transition-opacity group-hover:opacity-0">
|
||
{isImage ? (
|
||
<img
|
||
alt={filename || "attachment"}
|
||
className="size-5 object-cover"
|
||
height={20}
|
||
src={data.url}
|
||
width={20}
|
||
/>
|
||
) : (
|
||
<div className="text-muted-foreground flex size-5 items-center justify-center">
|
||
<PaperclipIcon className="size-3" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
<Button
|
||
aria-label="Remove attachment"
|
||
className="absolute inset-0 size-5 cursor-pointer rounded p-0 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 [&>svg]:size-2.5"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
attachments.remove(data.id);
|
||
}}
|
||
type="button"
|
||
variant="ghost"
|
||
>
|
||
<XIcon />
|
||
<span className="sr-only">Remove</span>
|
||
</Button>
|
||
</div>
|
||
|
||
<span className="flex-1 truncate">{attachmentLabel}</span>
|
||
</div>
|
||
</HoverCardTrigger>
|
||
<PromptInputHoverCardContent className="w-auto p-2">
|
||
<div className="w-auto space-y-3">
|
||
{isImage && (
|
||
<div className="flex max-h-96 w-96 items-center justify-center overflow-hidden rounded-md border">
|
||
<img
|
||
alt={filename || "attachment preview"}
|
||
className="max-h-full max-w-full object-contain"
|
||
height={384}
|
||
src={data.url}
|
||
width={448}
|
||
/>
|
||
</div>
|
||
)}
|
||
<div className="flex items-center gap-2.5">
|
||
<div className="min-w-0 flex-1 space-y-1 px-0.5">
|
||
<h4 className="truncate text-sm leading-none font-semibold">
|
||
{filename || (isImage ? "Image" : "Attachment")}
|
||
</h4>
|
||
{data.mediaType && (
|
||
<p className="text-muted-foreground truncate font-mono text-xs">
|
||
{data.mediaType}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</PromptInputHoverCardContent>
|
||
</PromptInputHoverCard>
|
||
);
|
||
}
|
||
|
||
export type PromptInputAttachmentsProps = Omit<
|
||
HTMLAttributes<HTMLDivElement>,
|
||
"children"
|
||
> & {
|
||
children: (attachment: PromptInputFilePart & { id: string }) => ReactNode;
|
||
};
|
||
|
||
export function PromptInputAttachments({
|
||
children,
|
||
className,
|
||
...props
|
||
}: PromptInputAttachmentsProps) {
|
||
const attachments = usePromptInputAttachments();
|
||
|
||
if (!attachments.files.length) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className={cn("flex w-full flex-wrap items-center gap-2 p-3", className)}
|
||
{...props}
|
||
>
|
||
{attachments.files.map((file) => (
|
||
<Fragment key={file.id}>
|
||
<div className="max-w-60">{children(file)}</div>
|
||
</Fragment>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export type PromptInputActionAddAttachmentsProps = ComponentProps<
|
||
typeof DropdownMenuItem
|
||
> & {
|
||
label?: string;
|
||
};
|
||
|
||
export const PromptInputActionAddAttachments = ({
|
||
label = "Add photos or files",
|
||
...props
|
||
}: PromptInputActionAddAttachmentsProps) => {
|
||
const attachments = usePromptInputAttachments();
|
||
|
||
return (
|
||
<DropdownMenuItem
|
||
{...props}
|
||
onSelect={(e) => {
|
||
e.preventDefault();
|
||
attachments.openFileDialog();
|
||
}}
|
||
>
|
||
<PaperclipIcon className="mr-2 size-4" /> {label}
|
||
</DropdownMenuItem>
|
||
);
|
||
};
|
||
|
||
export type PromptInputMessage = {
|
||
text: string;
|
||
files: PromptInputFilePart[];
|
||
};
|
||
|
||
export type PromptInputProps = Omit<
|
||
HTMLAttributes<HTMLFormElement>,
|
||
"onSubmit" | "onError"
|
||
> & {
|
||
accept?: string; // e.g., "image/*" or leave undefined for any
|
||
disabled?: boolean;
|
||
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" | "unsupported_package";
|
||
message: string;
|
||
}) => void;
|
||
onSubmit: (
|
||
message: PromptInputMessage,
|
||
event: FormEvent<HTMLFormElement>,
|
||
) => void | Promise<void>;
|
||
};
|
||
|
||
export const PromptInput = ({
|
||
className,
|
||
accept,
|
||
disabled,
|
||
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<HTMLInputElement | null>(null);
|
||
const formRef = useRef<HTMLFormElement | null>(null);
|
||
|
||
// ----- Local attachments (only used when no provider)
|
||
const [items, setItems] = useState<(PromptInputFilePart & { id: string })[]>(
|
||
[],
|
||
);
|
||
const files = usingProvider ? controller.attachments.files : items;
|
||
|
||
// Keep a ref to files for cleanup on unmount (avoids stale closure)
|
||
const filesRef = useRef(files);
|
||
filesRef.current = files;
|
||
|
||
const openFileDialogLocal = useCallback(() => {
|
||
inputRef.current?.click();
|
||
}, []);
|
||
|
||
const matchesAccept = useCallback(
|
||
(f: File) => {
|
||
if (!accept || accept.trim() === "") {
|
||
return true;
|
||
}
|
||
|
||
const patterns = accept
|
||
.split(",")
|
||
.map((s) => s.trim())
|
||
.filter(Boolean);
|
||
|
||
return patterns.some((pattern) => {
|
||
if (pattern.endsWith("/*")) {
|
||
const prefix = pattern.slice(0, -1); // e.g: image/* -> image/
|
||
return f.type.startsWith(prefix);
|
||
}
|
||
return f.type === pattern;
|
||
});
|
||
},
|
||
[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: (PromptInputFilePart & { id: string })[] = [];
|
||
for (const file of capped) {
|
||
next.push({
|
||
id: nanoid(),
|
||
type: "file",
|
||
url: URL.createObjectURL(file),
|
||
mediaType: file.type,
|
||
filename: file.name,
|
||
file,
|
||
});
|
||
}
|
||
return prev.concat(next);
|
||
});
|
||
},
|
||
[matchesAccept, maxFiles, maxFileSize, onError],
|
||
);
|
||
|
||
const removeLocal = 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 clearLocal = useCallback(
|
||
() =>
|
||
setItems((prev) => {
|
||
for (const file of prev) {
|
||
if (file.url) {
|
||
URL.revokeObjectURL(file.url);
|
||
}
|
||
}
|
||
return [];
|
||
}),
|
||
[],
|
||
);
|
||
|
||
const add = usingProvider ? controller.attachments.add : addLocal;
|
||
const remove = usingProvider ? controller.attachments.remove : removeLocal;
|
||
const clear = usingProvider ? controller.attachments.clear : clearLocal;
|
||
const openFileDialog = usingProvider
|
||
? controller.attachments.openFileDialog
|
||
: openFileDialogLocal;
|
||
|
||
const sanitizeIncomingFiles = useCallback(
|
||
(fileList: File[] | FileList) => {
|
||
const { accepted, message } = splitUnsupportedUploadFiles(fileList);
|
||
if (message) {
|
||
onError?.({
|
||
code: "unsupported_package",
|
||
message,
|
||
});
|
||
if (!onError) {
|
||
toast.error(message);
|
||
}
|
||
}
|
||
return accepted;
|
||
},
|
||
[onError],
|
||
);
|
||
|
||
// 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;
|
||
if (globalDrop) return; // when global drop is on, let the document-level handler own drops
|
||
|
||
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) {
|
||
const accepted = sanitizeIncomingFiles(e.dataTransfer.files);
|
||
if (accepted.length > 0) {
|
||
add(accepted);
|
||
}
|
||
}
|
||
};
|
||
form.addEventListener("dragover", onDragOver);
|
||
form.addEventListener("drop", onDrop);
|
||
return () => {
|
||
form.removeEventListener("dragover", onDragOver);
|
||
form.removeEventListener("drop", onDrop);
|
||
};
|
||
}, [add, globalDrop, sanitizeIncomingFiles]);
|
||
|
||
useEffect(() => {
|
||
if (!globalDrop) return;
|
||
|
||
// Suppress the global chat-upload handler when the drop happens inside an open
|
||
// modal dialog or popover — those owners (e.g. the skill-install dialog) handle
|
||
// their own file uploads and should not leak into the active chat thread.
|
||
const isInsideModal = (target: EventTarget | null): boolean => {
|
||
if (!(target instanceof Element)) return false;
|
||
return target.closest('[role="dialog"], [role="alertdialog"]') !== null;
|
||
};
|
||
|
||
const onDragOver = (e: DragEvent) => {
|
||
if (isInsideModal(e.target)) return;
|
||
if (e.dataTransfer?.types?.includes("Files")) {
|
||
e.preventDefault();
|
||
}
|
||
};
|
||
const onDrop = (e: DragEvent) => {
|
||
if (isInsideModal(e.target)) return;
|
||
if (e.dataTransfer?.types?.includes("Files")) {
|
||
e.preventDefault();
|
||
}
|
||
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
|
||
const accepted = sanitizeIncomingFiles(e.dataTransfer.files);
|
||
if (accepted.length > 0) {
|
||
add(accepted);
|
||
}
|
||
}
|
||
};
|
||
document.addEventListener("dragover", onDragOver);
|
||
document.addEventListener("drop", onDrop);
|
||
return () => {
|
||
document.removeEventListener("dragover", onDragOver);
|
||
document.removeEventListener("drop", onDrop);
|
||
};
|
||
}, [add, globalDrop, sanitizeIncomingFiles]);
|
||
|
||
useEffect(
|
||
() => () => {
|
||
if (!usingProvider) {
|
||
for (const f of filesRef.current) {
|
||
if (f.url) URL.revokeObjectURL(f.url);
|
||
}
|
||
}
|
||
},
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps -- cleanup only on unmount; filesRef always current
|
||
[usingProvider],
|
||
);
|
||
|
||
const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
|
||
if (event.currentTarget.files) {
|
||
const accepted = sanitizeIncomingFiles(event.currentTarget.files);
|
||
if (accepted.length > 0) {
|
||
add(accepted);
|
||
}
|
||
}
|
||
// Reset input value to allow selecting files that were previously removed
|
||
event.currentTarget.value = "";
|
||
};
|
||
|
||
const convertBlobUrlToDataUrl = async (
|
||
url: string,
|
||
): Promise<string | null> => {
|
||
try {
|
||
const response = await fetch(url);
|
||
const blob = await response.blob();
|
||
return new Promise((resolve) => {
|
||
const reader = new FileReader();
|
||
reader.onloadend = () => resolve(reader.result as string);
|
||
reader.onerror = () => resolve(null);
|
||
reader.readAsDataURL(blob);
|
||
});
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
const ctx = useMemo<AttachmentsContext>(
|
||
() => ({
|
||
files: files.map((item) => ({ ...item, id: item.id })),
|
||
add,
|
||
remove,
|
||
clear,
|
||
openFileDialog,
|
||
fileInputRef: inputRef,
|
||
}),
|
||
[files, add, remove, clear, openFileDialog],
|
||
);
|
||
|
||
const handleSubmit: FormEventHandler<HTMLFormElement> = (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.file instanceof File) {
|
||
// Downstream upload prep reads the preserved File directly.
|
||
return item;
|
||
}
|
||
if (item.url && item.url.startsWith("blob:")) {
|
||
const dataUrl = await convertBlobUrlToDataUrl(item.url);
|
||
// If conversion failed, keep the original blob URL
|
||
return {
|
||
...item,
|
||
url: dataUrl ?? item.url,
|
||
};
|
||
}
|
||
return item;
|
||
}),
|
||
)
|
||
.then((convertedFiles: PromptInputFilePart[]) => {
|
||
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 {
|
||
// Don't clear on error - user may want to retry
|
||
}
|
||
})
|
||
.catch(() => {
|
||
// Don't clear on error - user may want to retry
|
||
});
|
||
};
|
||
|
||
// Render with or without local provider
|
||
const inner = (
|
||
<PromptInputValidationContext.Provider value={sanitizeIncomingFiles}>
|
||
<input
|
||
accept={accept}
|
||
aria-label="Upload files"
|
||
className="hidden"
|
||
multiple={multiple}
|
||
onChange={handleChange}
|
||
ref={inputRef}
|
||
title="Upload files"
|
||
type="file"
|
||
/>
|
||
<form
|
||
className={cn("w-full", className)}
|
||
onSubmit={handleSubmit}
|
||
ref={formRef}
|
||
{...props}
|
||
>
|
||
{/* Do not rely on InputGroup's :has(textarea) selector here: Chrome
|
||
versions before 105 ignore :has(), retain h-9, and clip the whole
|
||
multi-line composer to a single row. */}
|
||
<InputGroup className="h-auto min-h-16">{children}</InputGroup>
|
||
</form>
|
||
</PromptInputValidationContext.Provider>
|
||
);
|
||
|
||
return usingProvider ? (
|
||
inner
|
||
) : (
|
||
<LocalAttachmentsContext.Provider value={ctx}>
|
||
{inner}
|
||
</LocalAttachmentsContext.Provider>
|
||
);
|
||
};
|
||
|
||
export type PromptInputBodyProps = HTMLAttributes<HTMLDivElement>;
|
||
|
||
export const PromptInputBody = ({
|
||
className,
|
||
...props
|
||
}: PromptInputBodyProps) => (
|
||
<div className={cn("contents", className)} {...props} />
|
||
);
|
||
|
||
export type PromptInputTextareaProps = ComponentProps<
|
||
typeof InputGroupTextarea
|
||
>;
|
||
|
||
export const PromptInputTextarea = ({
|
||
onChange,
|
||
onInput,
|
||
className,
|
||
placeholder = "What would you like to know?",
|
||
...props
|
||
}: PromptInputTextareaProps) => {
|
||
const controller = useOptionalPromptInputController();
|
||
const attachments = usePromptInputAttachments();
|
||
const sanitizeIncomingFiles = usePromptInputValidation();
|
||
const [isComposing, setIsComposing] = useState(false);
|
||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||
|
||
const resizeForLegacyChrome = useCallback((textarea: HTMLTextAreaElement) => {
|
||
if (
|
||
typeof CSS !== "undefined" &&
|
||
typeof CSS.supports === "function" &&
|
||
CSS.supports("field-sizing", "content")
|
||
) {
|
||
return;
|
||
}
|
||
|
||
// Chrome 109 ignores field-sizing: content. Preserve the same 4rem–12rem
|
||
// autosize range used by min-h-16/max-h-48 instead of leaving the prompt
|
||
// stuck at its minimum height.
|
||
textarea.style.height = "auto";
|
||
const height = Math.max(64, Math.min(textarea.scrollHeight, 192));
|
||
textarea.style.height = `${height}px`;
|
||
textarea.style.overflowY = textarea.scrollHeight > 192 ? "auto" : "hidden";
|
||
}, []);
|
||
|
||
// onInput does not run for a controlled value filled by a suggested question
|
||
// or restored draft. Resize once after every render in legacy Chrome too.
|
||
useEffect(() => {
|
||
const textarea = textareaRef.current;
|
||
if (!textarea) return;
|
||
|
||
const frame = window.requestAnimationFrame(() => {
|
||
resizeForLegacyChrome(textarea);
|
||
});
|
||
return () => window.cancelAnimationFrame(frame);
|
||
}, [controller?.textInput.value, resizeForLegacyChrome]);
|
||
|
||
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
|
||
if (e.key === "Enter") {
|
||
if (isIMEComposing(e, isComposing)) {
|
||
return;
|
||
}
|
||
if (e.shiftKey) {
|
||
return;
|
||
}
|
||
e.preventDefault();
|
||
|
||
// Check if the submit button is disabled before submitting
|
||
const form = e.currentTarget.form;
|
||
const submitButton = form?.querySelector(
|
||
'button[type="submit"]',
|
||
) as HTMLButtonElement | null;
|
||
if (submitButton?.disabled) {
|
||
return;
|
||
}
|
||
|
||
form?.requestSubmit();
|
||
}
|
||
|
||
// Remove last attachment when Backspace is pressed and textarea is empty
|
||
if (
|
||
e.key === "Backspace" &&
|
||
e.currentTarget.value === "" &&
|
||
attachments.files.length > 0
|
||
) {
|
||
e.preventDefault();
|
||
const lastAttachment = attachments.files.at(-1);
|
||
if (lastAttachment) {
|
||
attachments.remove(lastAttachment.id);
|
||
}
|
||
}
|
||
};
|
||
|
||
const handlePaste: ClipboardEventHandler<HTMLTextAreaElement> = (event) => {
|
||
const items = event.clipboardData?.items;
|
||
|
||
if (!items) {
|
||
return;
|
||
}
|
||
|
||
const files: File[] = [];
|
||
|
||
for (const item of items) {
|
||
if (item.kind === "file") {
|
||
const file = item.getAsFile();
|
||
if (file) {
|
||
files.push(file);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (files.length > 0) {
|
||
event.preventDefault();
|
||
const accepted = sanitizeIncomingFiles
|
||
? sanitizeIncomingFiles(files)
|
||
: files;
|
||
if (accepted.length > 0) {
|
||
attachments.add(accepted);
|
||
}
|
||
}
|
||
};
|
||
|
||
const controlledProps = controller
|
||
? {
|
||
value: controller.textInput.value,
|
||
onChange: (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||
controller.textInput.setInput(e.currentTarget.value);
|
||
onChange?.(e);
|
||
},
|
||
}
|
||
: {
|
||
onChange,
|
||
};
|
||
|
||
return (
|
||
<InputGroupTextarea
|
||
className={cn("field-sizing-content max-h-48 min-h-16", className)}
|
||
name="message"
|
||
ref={textareaRef}
|
||
rows={3}
|
||
onCompositionEnd={() => setIsComposing(false)}
|
||
onCompositionStart={() => setIsComposing(true)}
|
||
onKeyDown={handleKeyDown}
|
||
onInput={(event) => {
|
||
resizeForLegacyChrome(event.currentTarget);
|
||
onInput?.(event);
|
||
}}
|
||
onPaste={handlePaste}
|
||
placeholder={placeholder}
|
||
{...props}
|
||
{...controlledProps}
|
||
/>
|
||
);
|
||
};
|
||
|
||
export type PromptInputHeaderProps = Omit<
|
||
ComponentProps<typeof InputGroupAddon>,
|
||
"align"
|
||
>;
|
||
|
||
export const PromptInputHeader = ({
|
||
className,
|
||
...props
|
||
}: PromptInputHeaderProps) => (
|
||
<InputGroupAddon
|
||
align="block-end"
|
||
className={cn("order-first flex-wrap gap-1", className)}
|
||
{...props}
|
||
/>
|
||
);
|
||
|
||
export type PromptInputFooterProps = Omit<
|
||
ComponentProps<typeof InputGroupAddon>,
|
||
"align"
|
||
>;
|
||
|
||
export const PromptInputFooter = ({
|
||
className,
|
||
...props
|
||
}: PromptInputFooterProps) => (
|
||
<InputGroupAddon
|
||
align="block-end"
|
||
className={cn("justify-between gap-1", className)}
|
||
{...props}
|
||
/>
|
||
);
|
||
|
||
export type PromptInputToolsProps = HTMLAttributes<HTMLDivElement>;
|
||
|
||
export const PromptInputTools = ({
|
||
className,
|
||
...props
|
||
}: PromptInputToolsProps) => (
|
||
<div className={cn("flex items-center gap-1", className)} {...props} />
|
||
);
|
||
|
||
export type PromptInputButtonProps = ComponentProps<typeof InputGroupButton>;
|
||
|
||
export const PromptInputButton = ({
|
||
variant = "ghost",
|
||
className,
|
||
size,
|
||
...props
|
||
}: PromptInputButtonProps) => {
|
||
return (
|
||
<InputGroupButton
|
||
className={cn(className)}
|
||
size="sm"
|
||
type="button"
|
||
variant={variant}
|
||
{...props}
|
||
/>
|
||
);
|
||
};
|
||
|
||
export type PromptInputActionMenuProps = ComponentProps<typeof DropdownMenu>;
|
||
export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => (
|
||
<DropdownMenu {...props} />
|
||
);
|
||
|
||
export type PromptInputActionMenuTriggerProps = PromptInputButtonProps;
|
||
|
||
export const PromptInputActionMenuTrigger = ({
|
||
className,
|
||
children,
|
||
...props
|
||
}: PromptInputActionMenuTriggerProps) => (
|
||
<DropdownMenuTrigger asChild>
|
||
<PromptInputButton className={className} {...props}>
|
||
{children ?? <PlusIcon className="size-4" />}
|
||
</PromptInputButton>
|
||
</DropdownMenuTrigger>
|
||
);
|
||
|
||
export type PromptInputActionMenuContentProps = ComponentProps<
|
||
typeof DropdownMenuContent
|
||
>;
|
||
export const PromptInputActionMenuContent = ({
|
||
className,
|
||
...props
|
||
}: PromptInputActionMenuContentProps) => (
|
||
<DropdownMenuContent align="start" className={cn(className)} {...props} />
|
||
);
|
||
|
||
export type PromptInputActionMenuItemProps = ComponentProps<
|
||
typeof DropdownMenuItem
|
||
>;
|
||
export const PromptInputActionMenuItem = ({
|
||
className,
|
||
...props
|
||
}: PromptInputActionMenuItemProps) => (
|
||
<DropdownMenuItem 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 InputGroupButton> & {
|
||
status?: ChatStatus;
|
||
};
|
||
|
||
export const PromptInputSubmit = ({
|
||
className,
|
||
variant = "default",
|
||
size = "icon-sm",
|
||
status,
|
||
children,
|
||
...props
|
||
}: PromptInputSubmitProps) => {
|
||
let Icon = <ArrowUpIcon 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 (
|
||
<InputGroupButton
|
||
aria-label="Submit"
|
||
className={cn(
|
||
"transition-all duration-300",
|
||
status === "streaming" &&
|
||
"animate-breathe-stop bg-primary/10 text-primary border-primary/25 shadow-[0_0_0_0_rgba(217,33,33,0.25)] hover:bg-primary/14",
|
||
className,
|
||
)}
|
||
size={size}
|
||
type="submit"
|
||
variant={variant}
|
||
{...props}
|
||
>
|
||
{children ?? Icon}
|
||
</InputGroupButton>
|
||
);
|
||
};
|
||
|
||
interface SpeechRecognition extends EventTarget {
|
||
continuous: boolean;
|
||
interimResults: boolean;
|
||
lang: string;
|
||
start(): void;
|
||
stop(): void;
|
||
onstart: ((this: SpeechRecognition, ev: Event) => any) | null;
|
||
onend: ((this: SpeechRecognition, ev: Event) => any) | null;
|
||
onresult:
|
||
| ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => any)
|
||
| null;
|
||
onerror:
|
||
| ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => any)
|
||
| null;
|
||
}
|
||
|
||
interface SpeechRecognitionEvent extends Event {
|
||
results: SpeechRecognitionResultList;
|
||
resultIndex: number;
|
||
}
|
||
|
||
type SpeechRecognitionResultList = {
|
||
readonly length: number;
|
||
item(index: number): SpeechRecognitionResult;
|
||
[index: number]: SpeechRecognitionResult;
|
||
};
|
||
|
||
type SpeechRecognitionResult = {
|
||
readonly length: number;
|
||
item(index: number): SpeechRecognitionAlternative;
|
||
[index: number]: SpeechRecognitionAlternative;
|
||
isFinal: boolean;
|
||
};
|
||
|
||
type SpeechRecognitionAlternative = {
|
||
transcript: string;
|
||
confidence: number;
|
||
};
|
||
|
||
interface SpeechRecognitionErrorEvent extends Event {
|
||
error: string;
|
||
}
|
||
|
||
declare global {
|
||
interface Window {
|
||
SpeechRecognition: {
|
||
new (): SpeechRecognition;
|
||
};
|
||
webkitSpeechRecognition: {
|
||
new (): SpeechRecognition;
|
||
};
|
||
}
|
||
}
|
||
|
||
export type PromptInputSpeechButtonProps = ComponentProps<
|
||
typeof PromptInputButton
|
||
> & {
|
||
textareaRef?: RefObject<HTMLTextAreaElement | null>;
|
||
onTranscriptionChange?: (text: string) => void;
|
||
};
|
||
|
||
export const PromptInputSpeechButton = ({
|
||
className,
|
||
textareaRef,
|
||
onTranscriptionChange,
|
||
...props
|
||
}: PromptInputSpeechButtonProps) => {
|
||
const [isListening, setIsListening] = useState(false);
|
||
const [recognition, setRecognition] = useState<SpeechRecognition | null>(
|
||
null,
|
||
);
|
||
const recognitionRef = useRef<SpeechRecognition | null>(null);
|
||
const callbacksRef = useRef({ textareaRef, onTranscriptionChange });
|
||
callbacksRef.current = { textareaRef, onTranscriptionChange };
|
||
|
||
useEffect(() => {
|
||
if (
|
||
typeof window !== "undefined" &&
|
||
("SpeechRecognition" in window || "webkitSpeechRecognition" in window)
|
||
) {
|
||
const SpeechRecognition =
|
||
window.SpeechRecognition || window.webkitSpeechRecognition;
|
||
const speechRecognition = new SpeechRecognition();
|
||
|
||
speechRecognition.continuous = true;
|
||
speechRecognition.interimResults = true;
|
||
speechRecognition.lang = "en-US";
|
||
|
||
speechRecognition.onstart = () => {
|
||
setIsListening(true);
|
||
};
|
||
|
||
speechRecognition.onend = () => {
|
||
setIsListening(false);
|
||
};
|
||
|
||
speechRecognition.onresult = (event) => {
|
||
let finalTranscript = "";
|
||
|
||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||
const result = event.results[i];
|
||
if (result?.isFinal) {
|
||
finalTranscript += result[0]?.transcript ?? "";
|
||
}
|
||
}
|
||
|
||
const currentTextareaRef = callbacksRef.current.textareaRef;
|
||
const currentOnTranscriptionChange =
|
||
callbacksRef.current.onTranscriptionChange;
|
||
|
||
if (finalTranscript && currentTextareaRef?.current) {
|
||
const textarea = currentTextareaRef.current;
|
||
const currentValue = textarea.value;
|
||
const newValue =
|
||
currentValue + (currentValue ? " " : "") + finalTranscript;
|
||
|
||
textarea.value = newValue;
|
||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||
currentOnTranscriptionChange?.(newValue);
|
||
}
|
||
};
|
||
|
||
speechRecognition.onerror = (event) => {
|
||
console.error("Speech recognition error:", event.error);
|
||
setIsListening(false);
|
||
};
|
||
|
||
recognitionRef.current = speechRecognition;
|
||
setRecognition(speechRecognition);
|
||
}
|
||
|
||
return () => {
|
||
if (recognitionRef.current) {
|
||
recognitionRef.current.stop();
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
const toggleListening = useCallback(() => {
|
||
if (!recognition) {
|
||
return;
|
||
}
|
||
|
||
if (isListening) {
|
||
recognition.stop();
|
||
} else {
|
||
recognition.start();
|
||
}
|
||
}, [recognition, isListening]);
|
||
|
||
return (
|
||
<PromptInputButton
|
||
className={cn(
|
||
"relative transition-all duration-200",
|
||
isListening && "bg-accent text-accent-foreground animate-pulse",
|
||
className,
|
||
)}
|
||
disabled={!recognition}
|
||
onClick={toggleListening}
|
||
{...props}
|
||
>
|
||
<MicIcon className="size-4" />
|
||
</PromptInputButton>
|
||
);
|
||
};
|
||
|
||
export type PromptInputSelectProps = ComponentProps<typeof Select>;
|
||
|
||
export const PromptInputSelect = (props: PromptInputSelectProps) => (
|
||
<Select {...props} />
|
||
);
|
||
|
||
export type PromptInputSelectTriggerProps = ComponentProps<
|
||
typeof SelectTrigger
|
||
>;
|
||
|
||
export const PromptInputSelectTrigger = ({
|
||
className,
|
||
...props
|
||
}: PromptInputSelectTriggerProps) => (
|
||
<SelectTrigger
|
||
className={cn(
|
||
"text-muted-foreground border-none bg-transparent font-medium shadow-none transition-colors",
|
||
"hover:bg-accent hover:text-foreground aria-expanded:bg-accent aria-expanded:text-foreground",
|
||
className,
|
||
)}
|
||
{...props}
|
||
/>
|
||
);
|
||
|
||
export type PromptInputSelectContentProps = ComponentProps<
|
||
typeof SelectContent
|
||
>;
|
||
|
||
export const PromptInputSelectContent = ({
|
||
className,
|
||
...props
|
||
}: PromptInputSelectContentProps) => (
|
||
<SelectContent className={cn(className)} {...props} />
|
||
);
|
||
|
||
export type PromptInputSelectItemProps = ComponentProps<typeof SelectItem>;
|
||
|
||
export const PromptInputSelectItem = ({
|
||
className,
|
||
...props
|
||
}: PromptInputSelectItemProps) => (
|
||
<SelectItem className={cn(className)} {...props} />
|
||
);
|
||
|
||
export type PromptInputSelectValueProps = ComponentProps<typeof SelectValue>;
|
||
|
||
export const PromptInputSelectValue = ({
|
||
className,
|
||
...props
|
||
}: PromptInputSelectValueProps) => (
|
||
<SelectValue className={cn(className)} {...props} />
|
||
);
|
||
|
||
export type PromptInputHoverCardProps = ComponentProps<typeof HoverCard>;
|
||
|
||
export const PromptInputHoverCard = ({
|
||
openDelay = 0,
|
||
closeDelay = 0,
|
||
...props
|
||
}: PromptInputHoverCardProps) => (
|
||
<HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} />
|
||
);
|
||
|
||
export type PromptInputHoverCardTriggerProps = ComponentProps<
|
||
typeof HoverCardTrigger
|
||
>;
|
||
|
||
export const PromptInputHoverCardTrigger = (
|
||
props: PromptInputHoverCardTriggerProps,
|
||
) => <HoverCardTrigger {...props} />;
|
||
|
||
export type PromptInputHoverCardContentProps = ComponentProps<
|
||
typeof HoverCardContent
|
||
>;
|
||
|
||
export const PromptInputHoverCardContent = ({
|
||
align = "start",
|
||
...props
|
||
}: PromptInputHoverCardContentProps) => (
|
||
<HoverCardContent align={align} {...props} />
|
||
);
|
||
|
||
export type PromptInputTabsListProps = HTMLAttributes<HTMLDivElement>;
|
||
|
||
export const PromptInputTabsList = ({
|
||
className,
|
||
...props
|
||
}: PromptInputTabsListProps) => <div className={cn(className)} {...props} />;
|
||
|
||
export type PromptInputTabProps = HTMLAttributes<HTMLDivElement>;
|
||
|
||
export const PromptInputTab = ({
|
||
className,
|
||
...props
|
||
}: PromptInputTabProps) => <div className={cn(className)} {...props} />;
|
||
|
||
export type PromptInputTabLabelProps = HTMLAttributes<HTMLHeadingElement>;
|
||
|
||
export const PromptInputTabLabel = ({
|
||
className,
|
||
...props
|
||
}: PromptInputTabLabelProps) => (
|
||
<h3
|
||
className={cn(
|
||
"text-muted-foreground mb-2 px-3 text-xs font-medium",
|
||
className,
|
||
)}
|
||
{...props}
|
||
/>
|
||
);
|
||
|
||
export type PromptInputTabBodyProps = HTMLAttributes<HTMLDivElement>;
|
||
|
||
export const PromptInputTabBody = ({
|
||
className,
|
||
...props
|
||
}: PromptInputTabBodyProps) => (
|
||
<div className={cn("space-y-1", className)} {...props} />
|
||
);
|
||
|
||
export type PromptInputTabItemProps = HTMLAttributes<HTMLDivElement>;
|
||
|
||
export const PromptInputTabItem = ({
|
||
className,
|
||
...props
|
||
}: PromptInputTabItemProps) => (
|
||
<div
|
||
className={cn(
|
||
"hover:bg-accent flex items-center gap-2 px-3 py-2 text-xs",
|
||
className,
|
||
)}
|
||
{...props}
|
||
/>
|
||
);
|
||
|
||
export type PromptInputCommandProps = ComponentProps<typeof Command>;
|
||
|
||
export const PromptInputCommand = ({
|
||
className,
|
||
...props
|
||
}: PromptInputCommandProps) => <Command className={cn(className)} {...props} />;
|
||
|
||
export type PromptInputCommandInputProps = ComponentProps<typeof CommandInput>;
|
||
|
||
export const PromptInputCommandInput = ({
|
||
className,
|
||
...props
|
||
}: PromptInputCommandInputProps) => (
|
||
<CommandInput className={cn(className)} {...props} />
|
||
);
|
||
|
||
export type PromptInputCommandListProps = ComponentProps<typeof CommandList>;
|
||
|
||
export const PromptInputCommandList = ({
|
||
className,
|
||
...props
|
||
}: PromptInputCommandListProps) => (
|
||
<CommandList className={cn(className)} {...props} />
|
||
);
|
||
|
||
export type PromptInputCommandEmptyProps = ComponentProps<typeof CommandEmpty>;
|
||
|
||
export const PromptInputCommandEmpty = ({
|
||
className,
|
||
...props
|
||
}: PromptInputCommandEmptyProps) => (
|
||
<CommandEmpty className={cn(className)} {...props} />
|
||
);
|
||
|
||
export type PromptInputCommandGroupProps = ComponentProps<typeof CommandGroup>;
|
||
|
||
export const PromptInputCommandGroup = ({
|
||
className,
|
||
...props
|
||
}: PromptInputCommandGroupProps) => (
|
||
<CommandGroup className={cn(className)} {...props} />
|
||
);
|
||
|
||
export type PromptInputCommandItemProps = ComponentProps<typeof CommandItem>;
|
||
|
||
export const PromptInputCommandItem = ({
|
||
className,
|
||
...props
|
||
}: PromptInputCommandItemProps) => (
|
||
<CommandItem className={cn(className)} {...props} />
|
||
);
|
||
|
||
export type PromptInputCommandSeparatorProps = ComponentProps<
|
||
typeof CommandSeparator
|
||
>;
|
||
|
||
export const PromptInputCommandSeparator = ({
|
||
className,
|
||
...props
|
||
}: PromptInputCommandSeparatorProps) => (
|
||
<CommandSeparator className={cn(className)} {...props} />
|
||
);
|