{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "input-message", "title": "Input Message", "description": "Chat-style message composer with auto-resizing textarea, drag-and-drop file attachments (PNG / JPEG / PDF), flexible left/right action slots, and a built-in send button on a Surface-2 substrate.", "dependencies": [ "framer-motion" ], "registryDependencies": [ "https://zeron-ui.vercel.app/r/surfaces.json", "utils", "https://zeron-ui.vercel.app/r/springs.json", "https://zeron-ui.vercel.app/r/shape-context.json", "https://zeron-ui.vercel.app/r/icon-context.json", "https://zeron-ui.vercel.app/r/surface-context.json", "https://zeron-ui.vercel.app/r/surface-classes.json", "https://zeron-ui.vercel.app/r/file-thumbnail.json", "https://zeron-ui.vercel.app/r/button.json", "https://zeron-ui.vercel.app/r/tooltip.json" ], "files": [ { "path": "src/components/ui/input-message.tsx", "content": "\"use client\";\n\nimport {\n forwardRef,\n useCallback,\n useEffect,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n type ChangeEvent,\n type DragEvent as ReactDragEvent,\n type HTMLAttributes,\n type KeyboardEvent as ReactKeyboardEvent,\n type ReactNode,\n type TextareaHTMLAttributes,\n} from \"react\";\nimport { AnimatePresence, motion, Reorder, useReducedMotion } from \"framer-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { spring } from \"@/lib/springs\";\nimport { useShape } from \"@/lib/shape-context\";\nimport { useIcon } from \"@/lib/icon-context\";\nimport { surfaceClasses } from \"@/lib/surface-classes\";\nimport { SurfaceProvider } from \"@/lib/surface-context\";\nimport { FileThumbnail } from \"@/components/ui/file-thumbnail\";\nimport { Button } from \"@/components/ui/button\";\nimport { Tooltip } from \"@/components/ui/tooltip\";\n\nconst useIsoLayoutEffect =\n typeof window !== \"undefined\" ? useLayoutEffect : useEffect;\n\n// Touch devices have no hover, so hover-revealed affordances (like a queued\n// row's × button) would never appear. `(hover: none)` flags those so they can\n// be shown persistently instead. SSR-safe: starts false, resolves on mount.\nfunction useIsTouch() {\n const [isTouch, setIsTouch] = useState(false);\n useEffect(() => {\n const mq = window.matchMedia(\"(hover: none)\");\n const update = () => setIsTouch(mq.matches);\n update();\n mq.addEventListener(\"change\", update);\n return () => mq.removeEventListener(\"change\", update);\n }, []);\n return isTouch;\n}\n\nconst DEFAULT_ACCEPT = \"image/png,image/jpeg,application/pdf\";\n\ninterface InputMessageSlotContext {\n /** Opens the native file picker via the hidden ``.\n * Pass `acceptOverride` (e.g. `\"image/*\"`) to scope the picker to a\n * subset of the component's accept types just for this invocation. */\n openFilePicker: (acceptOverride?: string) => void;\n /** Currently-attached files (controlled). */\n files: File[];\n}\n\ntype InputMessageSlot =\n | ReactNode\n | ((ctx: InputMessageSlotContext) => ReactNode);\n\n/** A message held in the queue while the assistant is responding. Carries the\n * trimmed text plus a snapshot of the files attached when it was queued, so\n * double-click-to-edit can restore both. `id` is a stable key minted on enqueue. */\ninterface QueuedMessage {\n id: string;\n text: string;\n files: File[];\n}\n\ninterface InputMessageProps\n extends Omit, \"onChange\"> {\n /** Controlled textarea value. */\n value: string;\n /** Called with the new value on every textarea change. */\n onValueChange: (value: string) => void;\n /** Fired when the user submits (Enter or the send button) and when a queued\n * message auto-dispatches. Receives the trimmed value, the attached files,\n * and — for auto-dispatched queue items — `meta.queuedId` (the originating\n * QueuedMessage id), so a consumer can e.g. morph the queued item into the\n * sent message via a shared-layout (`layoutId`) transition. */\n onSend?: (\n value: string,\n files: File[],\n meta?: { queuedId?: string }\n ) => void;\n /** Placeholder text shown when the value is empty. */\n placeholder?: string;\n /** Content rendered in the bottom-left action area. Can be a function that\n * receives `{ openFilePicker, files }` to wire an attach button. */\n leftSlot?: InputMessageSlot;\n /** Content rendered in the bottom-right action area, before the built-in\n * send button. Same render-fn shape as leftSlot. */\n rightSlot?: InputMessageSlot;\n /** Disables the textarea, send button, and drag-and-drop. */\n disabled?: boolean;\n /** Minimum visible rows before the textarea grows. */\n minRows?: number;\n /** Maximum visible rows before the textarea starts to scroll. */\n maxRows?: number;\n /** When false, clicking the surrounding container won't refocus the textarea. */\n clickToFocus?: boolean;\n /** Accessible label for the send button. */\n sendLabel?: string;\n /** Controlled list of attached files. When undefined, attachment behavior\n * is disabled (no drag-drop, no file input). */\n files?: File[];\n /** Called when files are added (drag-drop or picker) or removed. */\n onFilesChange?: (files: File[]) => void;\n /** Accepted MIME types as a comma-separated string. Defaults to PNG / JPEG / PDF. */\n accept?: string;\n /** Maximum number of files. Extra files are dropped when the limit is exceeded. */\n maxFiles?: number;\n /** Side of each preview tile in pixels. Defaults to 80. */\n filePreviewSize?: number;\n /** Extra props forwarded to the underlying textarea. */\n textareaProps?: Omit<\n TextareaHTMLAttributes,\n \"value\" | \"onChange\" | \"onKeyDown\" | \"disabled\" | \"placeholder\"\n >;\n /** Assistant response state. When `\"streaming\"`, the send button becomes a\n * Stop control (empty draft) or a Queue action (non-empty draft); on the\n * `streaming → idle` edge the next queued message auto-dispatches via `onSend`.\n * Leave undefined to keep the legacy send-immediately behavior. */\n status?: \"idle\" | \"streaming\";\n /** Fired when the Stop control is pressed (streaming, empty draft). The\n * consumer should halt the current response and flip `status` to `\"idle\"`,\n * which immediately dispatches the next queued message. */\n onStop?: () => void;\n /** Controlled queue of pending messages. Requires `status` to be controlled. */\n queue?: QueuedMessage[];\n /** Called when the queue changes (enqueue, edit, delete, reorder, dispatch). */\n onQueueChange?: (queue: QueuedMessage[]) => void;\n /** Render the built-in reorderable queue rows above the textarea. Set to\n * `false` to suppress them and render the queue yourself (e.g. as full-width\n * rows above the composer) — enqueue + auto-dispatch still run. */\n showQueue?: boolean;\n /** Previously-sent messages, oldest first. When the textarea is focused,\n * ArrowUp (caret on the first line) recalls the previous one and walks\n * backward through history; ArrowDown (caret on the last line) walks forward\n * toward the in-progress draft. Editing or sending exits history mode. */\n history?: string[];\n}\n\n// ─── File preview tile ────────────────────────────────────────────────────\n// Composer-row tile: a FileThumbnail wrapped with enter/exit motion and a\n// hover-revealed remove (×) button.\ninterface FilePreviewTileProps {\n file: File;\n onRemove: () => void;\n size: number;\n}\n\nfunction FilePreviewTile({ file, onRemove, size }: FilePreviewTileProps) {\n const XIcon = useIcon(\"x\");\n\n return (\n \n \n \n {\n e.stopPropagation();\n onRemove();\n }}\n aria-label={`Remove ${file.name}`}\n // Force the light-mode palette (dark circle + white X) regardless\n // of theme — the close badge needs to read as a \"delete affordance\"\n // over arbitrary image/PDF content, so it sits at a fixed contrast\n // instead of flipping with the surrounding surface.\n className=\"absolute top-1 right-1 w-5 h-5 rounded-full bg-inverse-background text-fg-on-inverse opacity-0 group-hover/tile:opacity-100 transition-opacity duration-fast flex items-center justify-center cursor-pointer outline-none focus-visible:opacity-100 focus-visible:ring-1 focus-visible:ring-focus-ring\"\n >\n \n \n \n \n );\n}\n\n// ─── Queued message row ───────────────────────────────────────────────────\n// A pending message in the queue: a recessed, draggable row that reads as\n// \"staged, not live\". Double-click (or Enter/F2) edits it back into the\n// composer; the hover-revealed × (or Delete) removes it; drag — or Alt+↑/↓ —\n// reorders. Top of the list is next to dispatch.\ninterface QueuedRowProps {\n item: QueuedMessage;\n index: number;\n total: number;\n reduceMotion: boolean;\n isTouch: boolean;\n onEdit: (item: QueuedMessage) => void;\n onRemove: (item: QueuedMessage) => void;\n onMove: (item: QueuedMessage, dir: -1 | 1) => void;\n}\n\nfunction QueuedRow({\n item,\n index,\n total,\n reduceMotion,\n isTouch,\n onEdit,\n onRemove,\n onMove,\n}: QueuedRowProps) {\n const XIcon = useIcon(\"x\");\n const ImageIcon = useIcon(\"image\");\n const fileCount = item.files.length;\n const label =\n item.text || `${fileCount} attachment${fileCount === 1 ? \"\" : \"s\"}`;\n\n return (\n onEdit(item)}\n onKeyDown={(e) => {\n if (e.key === \"Enter\" || e.key === \"F2\") {\n e.preventDefault();\n onEdit(item);\n } else if (e.key === \"Delete\" || e.key === \"Backspace\") {\n e.preventDefault();\n onRemove(item);\n } else if (e.altKey && (e.key === \"ArrowUp\" || e.key === \"ArrowDown\")) {\n e.preventDefault();\n onMove(item, e.key === \"ArrowUp\" ? -1 : 1);\n }\n }}\n className={cn(\n // Fixed height keeps queued rows aligned during reorder animations.\n \"group/qrow flex h-control-sm items-center gap-2 rounded-control bg-muted px-2.5\",\n \"text-body text-fg-default/85 select-none outline-none\",\n \"cursor-grab active:cursor-grabbing\",\n \"focus-visible:ring-1 focus-visible:ring-focus-ring\", \"font-normal\"\n )}\n >\n {fileCount > 0 && (\n \n \n {item.text && {fileCount}}\n \n )}\n {label}\n \n e.stopPropagation()}\n onClick={(e) => {\n e.stopPropagation();\n onRemove(item);\n }}\n aria-label={`Remove queued message: ${label}`}\n className={cn(\n \"shrink-0 flex h-5 w-5 items-center justify-center rounded-full\",\n \"text-fg-muted hover:text-fg-default hover:bg-hover\",\n // Hover devices reveal × on row-hover; touch has no hover, so keep\n // it persistently visible there.\n isTouch\n ? \"opacity-100\"\n : \"opacity-0 group-hover/qrow:opacity-100 focus-visible:opacity-100\",\n \"transition-opacity duration-fast cursor-pointer outline-none\",\n \"focus-visible:ring-1 focus-visible:ring-focus-ring\"\n )}\n >\n \n \n \n \n );\n}\n\n// ─── InputMessage ─────────────────────────────────────────────────────────\n\nconst InputMessage = forwardRef(\n (\n {\n value,\n onValueChange,\n onSend,\n placeholder = \"Ask me anything…\",\n leftSlot,\n rightSlot,\n disabled,\n minRows = 1,\n maxRows = 8,\n clickToFocus = true,\n sendLabel = \"Send\",\n files,\n onFilesChange,\n accept = DEFAULT_ACCEPT,\n maxFiles,\n filePreviewSize = 80,\n textareaProps,\n status,\n onStop,\n queue,\n onQueueChange,\n showQueue = true,\n history = [],\n className,\n style,\n ...props\n },\n ref\n ) => {\n const shape = useShape();\n const ArrowUpIcon = useIcon(\"arrow-up\");\n const reduceMotion = useReducedMotion() ?? false;\n const isTouch = useIsTouch();\n\n const textareaRef = useRef(null);\n const fileInputRef = useRef(null);\n const [focusVisible, setFocusVisible] = useState(false);\n const [dragOver, setDragOver] = useState(false);\n const [hovered, setHovered] = useState(false);\n\n // Split out onFocus/onBlur so the rest-spread onto the textarea can't\n // clobber the composed handlers below.\n const {\n onFocus: _textareaOnFocus,\n onBlur: _textareaOnBlur,\n ...restTextareaProps\n } = textareaProps ?? {};\n\n const filesArr = useMemo(() => files ?? [], [files]);\n const supportsFiles = onFilesChange !== undefined;\n\n // Queue is active only when both the status is controlled and a change\n // handler is wired — same opt-in shape as `supportsFiles`.\n const queueArr = useMemo(() => queue ?? [], [queue]);\n // Always-current view of the queue, so enqueue/edit/remove/move read the\n // latest value even if a handler closure is stale (e.g. two submits land\n // before the controlled `queue` prop round-trips back).\n const queueRef = useRef(queueArr);\n queueRef.current = queueArr;\n const supportsQueue = status !== undefined && onQueueChange !== undefined;\n const streaming = status === \"streaming\";\n const [liveMsg, setLiveMsg] = useState(\"\");\n\n // Sent-message history navigation (readline-style). `historyIndex` is null\n // when not browsing; `draftBeforeHistory` stashes the in-progress text so\n // ArrowDown past the newest entry restores it.\n const [historyIndex, setHistoryIndex] = useState(null);\n const draftBeforeHistory = useRef(\"\");\n\n // Parsed line-height, cached per textarea element — getComputedStyle on\n // every keystroke is needless work when the value only changes with font\n // or zoom changes.\n const lineHeightCache = useRef<{ el: HTMLTextAreaElement; value: number } | null>(null);\n\n useIsoLayoutEffect(() => {\n const el = textareaRef.current;\n if (!el) return;\n el.style.height = \"auto\";\n let cache = lineHeightCache.current;\n if (!cache || cache.el !== el) {\n const lineHeight = parseFloat(getComputedStyle(el).lineHeight);\n cache = { el, value: Number.isNaN(lineHeight) ? 20 : lineHeight };\n lineHeightCache.current = cache;\n }\n const min = cache.value * minRows;\n const max = cache.value * maxRows;\n const next = Math.min(Math.max(el.scrollHeight, min), max);\n el.style.height = `${next}px`;\n el.style.overflowY = el.scrollHeight > max ? \"auto\" : \"hidden\";\n }, [value, minRows, maxRows]);\n\n const trimmed = value.trim();\n const canSend = !disabled && (trimmed.length > 0 || filesArr.length > 0);\n\n // Edge = the box-shadow's 1px ring, recoloured in place per state so the\n // stroke gains contrast without ever appearing to thicken (no second\n // border band layered beside it). The drop (`0 1px 1px`) is kept so the\n // composer holds its lift across states. Applied inline (not via a Tailwind\n // `shadow-*` utility, which mangles multi-layer arbitrary values) with the\n // precedence drag > focus > hover; when none are active, the className's\n // `shadow-raised` supplies the resting edge.\n const EDGE_DROP = \"0 1px 1px -0.5px var(--shadow-color)\";\n const edgeShadow = dragOver\n ? `0 0 0 1px var(--focus-ring), ${EDGE_DROP}`\n : focusVisible\n ? `0 0 0 1px var(--input-hover), ${EDGE_DROP}`\n : hovered && clickToFocus && !disabled\n ? `0 0 0 1px var(--border), ${EDGE_DROP}`\n : undefined;\n\n const handleSend = useCallback(() => {\n if (!canSend) return;\n setHistoryIndex(null);\n // While the assistant is streaming, a submit enqueues instead of sending:\n // snapshot the draft (text + currently-attached files) into a queue item,\n // then clear the composer and keep focus.\n if (streaming && supportsQueue) {\n const item: QueuedMessage = {\n id: crypto.randomUUID(),\n text: trimmed,\n files: filesArr,\n };\n onQueueChange?.([...queueRef.current, item]);\n onValueChange(\"\");\n if (supportsFiles) onFilesChange?.([]);\n requestAnimationFrame(() => textareaRef.current?.focus());\n return;\n }\n onSend?.(trimmed, filesArr);\n }, [\n canSend,\n streaming,\n supportsQueue,\n onSend,\n trimmed,\n filesArr,\n onQueueChange,\n onValueChange,\n supportsFiles,\n onFilesChange,\n ]);\n\n const handleStop = useCallback(() => onStop?.(), [onStop]);\n\n // Auto-dispatch: on the streaming → idle edge (whether the response\n // finished on its own or the user pressed Stop), fire the head of the\n // queue and drop it. The consumer is expected to set status back to\n // \"streaming\" inside onSend, which re-arms this for the next item.\n const prevStatusRef = useRef(status);\n useEffect(() => {\n const prev = prevStatusRef.current;\n prevStatusRef.current = status;\n if (!supportsQueue) return;\n if (prev === \"streaming\" && status === \"idle\" && queueArr.length > 0) {\n const [next, ...rest] = queueArr;\n onQueueChange?.(rest);\n onSend?.(next.text, next.files, { queuedId: next.id });\n setLiveMsg(\n `Message sent.${rest.length ? ` ${rest.length} still queued.` : \"\"}`\n );\n }\n }, [status, supportsQueue, queueArr, onQueueChange, onSend]);\n\n // ── Queue item actions ────────────────────────────────────────────\n const editQueued = useCallback(\n (item: QueuedMessage) => {\n if (!supportsQueue) return;\n // Silent replace: pull the item out of the queue into the composer,\n // overwriting any current draft. Re-sending re-queues it to the end.\n setHistoryIndex(null);\n onValueChange(item.text);\n if (supportsFiles) {\n onFilesChange?.(\n maxFiles != null ? item.files.slice(0, maxFiles) : item.files\n );\n }\n onQueueChange?.(queueRef.current.filter((q) => q.id !== item.id));\n requestAnimationFrame(() => {\n const el = textareaRef.current;\n if (!el) return;\n el.focus();\n el.setSelectionRange(el.value.length, el.value.length);\n });\n },\n [\n supportsQueue,\n supportsFiles,\n onValueChange,\n onFilesChange,\n maxFiles,\n onQueueChange,\n ]\n );\n\n const removeQueued = useCallback(\n (item: QueuedMessage) =>\n onQueueChange?.(queueRef.current.filter((q) => q.id !== item.id)),\n [onQueueChange]\n );\n\n const moveQueued = useCallback(\n (item: QueuedMessage, dir: -1 | 1) => {\n const cur = queueRef.current;\n const i = cur.findIndex((q) => q.id === item.id);\n const j = i + dir;\n if (i < 0 || j < 0 || j >= cur.length) return;\n const next = [...cur];\n [next[i], next[j]] = [next[j], next[i]];\n onQueueChange?.(next);\n },\n [onQueueChange]\n );\n\n // Send button morph: Stop (streaming + empty draft) → Queue (streaming +\n // draft) → Send (idle). Send and Queue share the arrow-up glyph; only the\n // Stop⇄arrow swap animates.\n const buttonMode: \"send\" | \"queue\" | \"stop\" = !streaming\n ? \"send\"\n : canSend && supportsQueue\n ? \"queue\"\n : onStop\n ? \"stop\"\n : \"send\";\n const buttonLabel =\n buttonMode === \"stop\"\n ? \"Stop\"\n : buttonMode === \"queue\"\n ? \"Queue message\"\n : sendLabel;\n\n const setCaretEnd = useCallback(() => {\n requestAnimationFrame(() => {\n const el = textareaRef.current;\n if (el) el.setSelectionRange(el.value.length, el.value.length);\n });\n }, []);\n\n const handleKeyDown = useCallback(\n (e: ReactKeyboardEvent) => {\n if (e.nativeEvent.isComposing) return;\n\n // Readline-style history. Only plain ArrowUp/ArrowDown navigate (no\n // modifiers), and only when the caret is on the first/last line so\n // multi-line editing still works normally.\n if (\n history.length > 0 &&\n (e.key === \"ArrowUp\" || e.key === \"ArrowDown\") &&\n !e.shiftKey &&\n !e.altKey &&\n !e.metaKey &&\n !e.ctrlKey\n ) {\n const el = e.currentTarget;\n const caret = el.selectionStart ?? 0;\n const end = el.selectionEnd ?? caret;\n if (e.key === \"ArrowUp\" && !value.slice(0, caret).includes(\"\\n\")) {\n const start = historyIndex == null ? history.length : historyIndex;\n if (start > 0) {\n e.preventDefault();\n if (historyIndex == null) draftBeforeHistory.current = value;\n const ni = start - 1;\n setHistoryIndex(ni);\n onValueChange(history[ni]);\n setCaretEnd();\n }\n return;\n }\n if (\n e.key === \"ArrowDown\" &&\n historyIndex != null &&\n !value.slice(end).includes(\"\\n\")\n ) {\n e.preventDefault();\n const ni = historyIndex + 1;\n if (ni >= history.length) {\n setHistoryIndex(null);\n onValueChange(draftBeforeHistory.current);\n } else {\n setHistoryIndex(ni);\n onValueChange(history[ni]);\n }\n setCaretEnd();\n return;\n }\n }\n\n if (e.key === \"Enter\" && !e.shiftKey) {\n e.preventDefault();\n handleSend();\n }\n },\n [history, value, historyIndex, onValueChange, setCaretEnd, handleSend]\n );\n\n const handleContainerMouseDown = useCallback(\n (e: React.MouseEvent) => {\n if (!clickToFocus || disabled) return;\n const target = e.target as HTMLElement;\n if (target === textareaRef.current) return;\n if (\n target.closest(\n 'button, a, input, select, textarea, [contenteditable], [role=\"button\"], [data-im-queue]'\n )\n ) {\n return;\n }\n e.preventDefault();\n textareaRef.current?.focus();\n },\n [clickToFocus, disabled]\n );\n\n // ── File helpers ──────────────────────────────────────────────────\n const acceptTokens = useMemo(\n () => accept.split(\",\").map((s) => s.trim()).filter(Boolean),\n [accept]\n );\n\n const matchesAccept = useCallback(\n (file: File) =>\n acceptTokens.some((token) => {\n if (token.endsWith(\"/*\")) return file.type.startsWith(token.slice(0, -1));\n if (token.startsWith(\".\")) return file.name.toLowerCase().endsWith(token.toLowerCase());\n return file.type === token;\n }),\n [acceptTokens]\n );\n\n const addFiles = useCallback(\n (incoming: File[]) => {\n if (!onFilesChange) return;\n // Identity key for dedup: name + size + lastModified is unique enough\n // to catch \"user dropped the same file twice\" without false positives\n // on legitimately distinct files (different bytes ⇒ different size).\n const fingerprint = (f: File) => `${f.name}-${f.size}-${f.lastModified}`;\n const existing = new Set(filesArr.map(fingerprint));\n const accepted: File[] = [];\n for (const f of incoming) {\n if (!matchesAccept(f)) continue;\n const fp = fingerprint(f);\n if (existing.has(fp)) continue;\n existing.add(fp);\n accepted.push(f);\n }\n if (!accepted.length) return;\n const next = [...filesArr, ...accepted];\n onFilesChange(maxFiles != null ? next.slice(0, maxFiles) : next);\n },\n [onFilesChange, filesArr, matchesAccept, maxFiles]\n );\n\n const removeFile = useCallback(\n (idx: number) => {\n if (!onFilesChange) return;\n onFilesChange(filesArr.filter((_, i) => i !== idx));\n },\n [onFilesChange, filesArr]\n );\n\n const openFilePicker = useCallback(\n (overrideAccept?: string) => {\n const el = fileInputRef.current;\n if (!el) return;\n // Temporarily narrow `accept` for this invocation (e.g. \"image/*\").\n // Reset after the click so subsequent native invocations still honor\n // the component-level accept.\n if (overrideAccept) {\n el.accept = overrideAccept;\n el.click();\n // Restore on next tick — the picker dialog reads `accept` synchronously.\n queueMicrotask(() => {\n if (fileInputRef.current) fileInputRef.current.accept = accept;\n });\n return;\n }\n el.click();\n },\n [accept]\n );\n\n // ── Slot rendering ────────────────────────────────────────────────\n const slotCtx = useMemo(\n () => ({ openFilePicker, files: filesArr }),\n [openFilePicker, filesArr]\n );\n const leftContent =\n typeof leftSlot === \"function\" ? leftSlot(slotCtx) : leftSlot;\n const rightContent =\n typeof rightSlot === \"function\" ? rightSlot(slotCtx) : rightSlot;\n\n // ── Drag-and-drop ────────────────────────────────────────────────\n const handleDragOver = useCallback(\n (e: ReactDragEvent) => {\n if (!supportsFiles || disabled) return;\n // Only treat as a file drag — text/HTML drags shouldn't trigger.\n if (!Array.from(e.dataTransfer.types).includes(\"Files\")) return;\n e.preventDefault();\n e.dataTransfer.dropEffect = \"copy\";\n setDragOver(true);\n },\n [supportsFiles, disabled]\n );\n\n const handleDragLeave = useCallback(\n (e: ReactDragEvent) => {\n const wrapper = e.currentTarget;\n const next = e.relatedTarget as Node | null;\n if (next && wrapper.contains(next)) return;\n setDragOver(false);\n },\n []\n );\n\n const handleDrop = useCallback(\n (e: ReactDragEvent) => {\n e.preventDefault();\n setDragOver(false);\n if (!supportsFiles || disabled) return;\n addFiles(Array.from(e.dataTransfer.files));\n },\n [supportsFiles, disabled, addFiles]\n );\n\n const handleFileInputChange = useCallback(\n (e: ChangeEvent) => {\n if (!e.target.files) return;\n addFiles(Array.from(e.target.files));\n e.target.value = \"\"; // Allow re-selecting the same file.\n },\n [addFiles]\n );\n\n return (\n setHovered(true)}\n onMouseLeave={() => setHovered(false)}\n {...props}\n >\n \n {supportsFiles && (\n 1}\n className=\"hidden\"\n onChange={handleFileInputChange}\n aria-hidden=\"true\"\n tabIndex={-1}\n />\n )}\n\n {/* Attached files preview row — sits above the textarea.\n The outer motion.div animates the row's height (collapsing the\n whole component height) when files appear / disappear.\n The inner `mode=\"popLayout\"` AnimatePresence pulls a removing\n tile out of layout flow so siblings can slide into the gap\n without fighting its exit anim. Keys are purely file-identity\n (no index) so removing the first file doesn't re-key — and\n remount — every surviving sibling. */}\n \n {filesArr.length > 0 && (\n \n
\n \n {filesArr.map((file, i) => (\n removeFile(i)}\n size={filePreviewSize}\n />\n ))}\n \n
\n \n )}\n
\n\n {/* Queued messages — reorderable rows above the textarea. The outer\n motion.div collapses the region height when the queue empties;\n the Reorder.Group handles drag-reorder (top = next to dispatch)\n and AnimatePresence handles per-row enter/exit. */}\n {supportsQueue && showQueue && (\n \n {queueArr.length > 0 && (\n \n onQueueChange?.(next)}\n data-im-queue\n className=\"flex flex-col gap-1 pb-1\"\n >\n \n {queueArr.map((item, i) => (\n \n ))}\n \n \n \n )}\n \n )}\n\n {\n // Real typing exits history mode (recall sets the value\n // programmatically, which doesn't fire onChange).\n setHistoryIndex(null);\n onValueChange(e.target.value);\n }}\n onKeyDown={handleKeyDown}\n // Compose the consumer's textareaProps handlers with the internal\n // focus-visible tracking (the spread below would otherwise\n // overwrite these).\n onFocus={(e) => {\n if (e.target.matches(\":focus-visible\")) setFocusVisible(true);\n textareaProps?.onFocus?.(e);\n }}\n onBlur={(e) => {\n setFocusVisible(false);\n textareaProps?.onBlur?.(e);\n }}\n placeholder={\n dragOver && supportsFiles\n ? \"Drop files here to add to chat\"\n : placeholder\n }\n disabled={disabled}\n rows={minRows}\n aria-label={textareaProps?.[\"aria-label\"] ?? \"Message\"}\n className={cn(\n \"w-full resize-none bg-transparent outline-none\",\n \"text-body leading-5 text-fg-default placeholder:text-fg-muted\",\n \"px-2 py-2\", \"font-normal\"\n )}\n {...restTextareaProps}\n />\n
\n
{leftContent}
\n
\n {rightContent}\n \n \n \n {buttonMode === \"stop\" ? (\n \n ) : (\n // Override icon-sm's small 14px svg — the send glyph reads\n // better a touch larger. `size` matches the attribute to\n // the CSS so the svg box stays centered.\n \n )}\n \n \n \n
\n
\n {/* Politely announces auto-dispatch of queued messages. */}\n \n {liveMsg}\n \n
\n \n );\n }\n);\n\nInputMessage.displayName = \"InputMessage\";\n\nexport { InputMessage };\nexport type { InputMessageProps, InputMessageSlotContext, QueuedMessage };\nexport default InputMessage;\n", "type": "registry:ui", "target": "components/ui/input-message.tsx" } ], "type": "registry:ui" }