"use client"
import {
Children,
createContext,
Fragment,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type ChangeEvent,
type ChangeEventHandler,
type ClipboardEventHandler,
type ComponentProps,
type FormEvent,
type FormEventHandler,
type HTMLAttributes,
type KeyboardEventHandler,
type PropsWithChildren,
type ReactNode,
type RefObject,
} from "react"
import type { ChatStatus, FileUIPart } from "ai"
import {
ImageIcon,
Loader2Icon,
MicIcon,
PaperclipIcon,
PlusIcon,
SendIcon,
SquareIcon,
XIcon,
} from "lucide-react"
import { nanoid } from "nanoid"
import { cn } from "@/lib/utils"
import { Button } from "@/registry/new-york/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/registry/new-york/ui/command"
import { HoverCard, HoverCardContent } from "@/registry/new-york/ui/hover-card"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupTextarea,
} from "@/registry/new-york/ui/input-group"
import { Menu, MenuItem, MenuTrigger } from "@/registry/new-york/ui/menu"
import { Select, SelectItem } from "@/registry/new-york/ui/select"
import { Tooltip, TooltipTrigger } from "@/registry/new-york/ui/tooltip"
// ============================================================================
// 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<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
)
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)
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<HTMLInputElement | null>(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<AttachmentsContext>(
() => ({
files: attachements,
add,
remove,
clear,
openFileDialog,
fileInputRef,
}),
[attachements, 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: 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 (
<div
className={cn(
"group relative h-14 w-14 rounded-md border",
className,
mediaType === "image" ? "h-14 w-14" : "h-8 w-auto max-w-full"
)}
key={data.id}
{...props}
>
{mediaType === "image" ? (
<img
alt={data.filename || "attachment"}
className="size-full rounded-md object-cover"
height={56}
src={data.url}
width={56}
/>
) : (
<div className="text-muted-foreground flex size-full max-w-full cursor-pointer items-center justify-start gap-2 overflow-hidden px-2">
<PaperclipIcon className="size-4 shrink-0" />
<TooltipTrigger className="min-w-0 flex-1" delay={400}>
<h4 className="w-full truncate text-left text-sm font-medium">
{data.filename || "Unknown file"}
</h4>
<Tooltip>
<div className="text-muted-foreground text-xs">
<h4 className="max-w-[240px] overflow-hidden text-left text-sm font-semibold break-words whitespace-normal">
{data.filename || "Unknown file"}
</h4>
{data.mediaType && <div>{data.mediaType}</div>}
</div>
</Tooltip>
</TooltipTrigger>
</div>
)}
<Button
aria-label="Remove attachment"
className="absolute -top-1.5 -right-1.5 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 }) => 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()
}, [])
// 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 (
<InputGroupAddon
align="block-start"
aria-live="polite"
className={cn(
"overflow-hidden transition-[height] duration-200 ease-out",
className
)}
style={{ height: attachments.files.length ? height : 0 }}
{...props}
>
<div className="space-y-2 py-1" ref={contentRef}>
<div className="flex flex-wrap gap-2">
{attachments.files
.filter((f) => !(f.mediaType?.startsWith("image/") && f.url))
.map((file) => (
<Fragment key={file.id}>{children(file)}</Fragment>
))}
</div>
<div className="flex flex-wrap gap-2">
{attachments.files
.filter((f) => f.mediaType?.startsWith("image/") && f.url)
.map((file) => (
<Fragment key={file.id}>{children(file)}</Fragment>
))}
</div>
</div>
</InputGroupAddon>
)
}
export type PromptInputActionAddAttachmentsProps = ComponentProps<
typeof MenuItem
> & {
label?: string
}
export const PromptInputActionAddAttachments = ({
label = "Add photos or files",
...props
}: PromptInputActionAddAttachmentsProps) => {
const attachments = usePromptInputAttachments()
return (
<MenuItem
{...props}
onAction={() => {
attachments.openFileDialog()
}}
>
<ImageIcon className="mr-2 size-4" /> {label}
</MenuItem>
)
}
export type PromptInputMessage = {
text?: string
files?: FileUIPart[]
}
export type PromptInputProps = Omit<
HTMLAttributes<HTMLFormElement>,
"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<HTMLFormElement>
) => void | Promise<void>
}
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<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
}
}, [])
// ----- 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<HTMLInputElement> = (event) => {
if (event.currentTarget.files) {
add(event.currentTarget.files)
}
}
const convertBlobUrlToDataUrl = async (url: string): Promise<string> => {
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<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.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 = (
<>
<span aria-hidden="true" className="hidden" ref={anchorRef} />
<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}
{...props}
>
<InputGroup>{children}</InputGroup>
</form>
</>
)
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,
className,
placeholder = "What would you like to know?",
...props
}: PromptInputTextareaProps) => {
const controller = useOptionalPromptInputController()
const attachments = usePromptInputAttachments()
const [isComposing, setIsComposing] = useState(false)
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
if (e.key === "Enter") {
if (isComposing || e.nativeEvent.isComposing) {
return
}
if (e.shiftKey) {
return
}
e.preventDefault()
e.currentTarget.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()
attachments.add(files)
}
}
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"
onCompositionEnd={() => setIsComposing(false)}
onCompositionStart={() => setIsComposing(true)}
onKeyDown={handleKeyDown}
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 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) => {
const newSize =
size ?? (Children.count(props.children) > 1 ? "sm" : "icon-sm")
return (
<InputGroupButton
className={cn(className)}
size={newSize}
type="button"
variant={variant}
{...props}
/>
)
}
export type PromptInputActionMenuProps = ComponentProps<typeof MenuTrigger>
export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => (
<MenuTrigger {...props} />
)
export type PromptInputActionMenuTriggerProps = PromptInputButtonProps
export const PromptInputActionMenuTrigger = ({
className,
children,
...props
}: PromptInputActionMenuTriggerProps) => (
<PromptInputButton className={className} {...props}>
{children ?? <PlusIcon className="size-4" />}
</PromptInputButton>
)
export type PromptInputActionMenuContentProps = ComponentProps<typeof Menu>
export const PromptInputActionMenuContent = ({
className,
...props
}: PromptInputActionMenuContentProps) => (
<Menu placement="bottom start" className={cn(className)} {...props} />
)
export type PromptInputActionMenuItemProps = ComponentProps<typeof MenuItem>
export const PromptInputActionMenuItem = ({
className,
...props
}: PromptInputActionMenuItemProps) => (
<MenuItem 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 = <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 (
<InputGroupButton
aria-label="Submit"
className={cn(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
}
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)
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 = ""
const results = Array.from(event.results)
for (const result of results) {
if (result.isFinal) {
finalTranscript += result[0].transcript
}
}
if (finalTranscript && textareaRef?.current) {
const textarea = textareaRef.current
const currentValue = textarea.value
const newValue =
currentValue + (currentValue ? " " : "") + finalTranscript
textarea.value = newValue
textarea.dispatchEvent(new Event("input", { bubbles: true }))
onTranscriptionChange?.(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()
}
}
}, [textareaRef, onTranscriptionChange])
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 PromptInputModelSelectProps<T extends object = { id: string }> =
ComponentProps<typeof Select<T>> & {
className?: string
}
export const PromptInputModelSelect = <T extends object = { id: string }>({
className,
...props
}: PromptInputModelSelectProps<T>) => (
<Select<T>
className={cn(
"[&_button]:text-muted-foreground [&_button]:h-auto [&_button]:border-none [&_button]:bg-transparent [&_button]:px-2 [&_button]:py-1 [&_button]:font-medium [&_button]:shadow-none [&_button]:transition-colors",
"[&_button:hover]:bg-accent [&_button:hover]:text-foreground [&_button[data-pressed]]:bg-accent [&_button[data-pressed]]:text-foreground",
className
)}
{...props}
/>
)
export type PromptInputModelSelectItemProps = ComponentProps<typeof SelectItem>
export const PromptInputModelSelectItem = ({
className,
...props
}: PromptInputModelSelectItemProps) => (
<SelectItem className={cn(className)} {...props} />
)
export type PromptInputHoverCardProps = ComponentProps<typeof HoverCard>
export const PromptInputHoverCard = ({
delay = 0,
closeDelay = 0,
...props
}: PromptInputHoverCardProps) => (
<HoverCard closeDelay={closeDelay} delay={delay} {...props} />
)
export type PromptInputHoverCardContentProps = ComponentProps<
typeof HoverCardContent
>
export const PromptInputHoverCardContent = ({
placement = "start",
...props
}: PromptInputHoverCardContentProps) => (
<HoverCardContent placement={placement} {...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} />
)