{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "claude-chat-input", "type": "registry:ui", "title": "Claude Chat Input", "description": "Multi-modal chat input with file uploads, model selector, extended thinking toggle, and spring-timed interactions.", "dependencies": [ "lucide-react" ], "registryDependencies": [ "button", "textarea", "popover", "separator", "switch", "badge" ], "files": [ { "path": "registry/ruixenui/claude-chat-input.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n Plus,\n ChevronDown,\n ChevronRight,\n ArrowUp,\n X,\n FileText,\n Loader2,\n Check,\n Archive,\n} from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { Button } from \"@/components/ui/button\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { Badge } from \"@/components/ui/badge\";\n\n/* ------------------------------------------------------------------ */\n/* utility */\n/* ------------------------------------------------------------------ */\nconst formatFileSize = (bytes: number) => {\n if (bytes === 0) return \"0 B\";\n const k = 1024;\n const sizes = [\"B\", \"KB\", \"MB\", \"GB\"];\n const i = Math.floor(Math.log(bytes) / Math.log(k));\n return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + \" \" + sizes[i];\n};\n\n/* ------------------------------------------------------------------ */\n/* types */\n/* ------------------------------------------------------------------ */\nexport interface AttachedFile {\n id: string;\n file: File;\n type: string;\n preview: string | null;\n uploadStatus: \"pending\" | \"uploading\" | \"complete\";\n}\n\nexport interface PastedContent {\n id: string;\n content: string;\n timestamp: Date;\n}\n\nexport interface ModelOption {\n id: string;\n name: string;\n description?: string;\n}\n\nexport interface SuggestionChip {\n icon: React.ReactNode;\n label?: string;\n onClick?: () => void;\n}\n\nexport interface ClaudeChatInputProps {\n onSubmit?: (data: {\n message: string;\n files: AttachedFile[];\n pastedContent: PastedContent[];\n model: string;\n thinking: boolean;\n }) => void;\n placeholder?: string;\n models?: ModelOption[];\n defaultModel?: string;\n suggestions?: SuggestionChip[];\n greeting?: string;\n className?: string;\n}\n\n/* ------------------------------------------------------------------ */\n/* file preview card */\n/* ------------------------------------------------------------------ */\nconst FilePreviewCard: React.FC<{\n file: AttachedFile;\n onRemove: (id: string) => void;\n}> = ({ file, onRemove }) => {\n const isImage = file.type.startsWith(\"image/\") && file.preview;\n\n return (\n
\n {isImage ? (\n
\n \n
\n
\n ) : (\n
\n
\n
\n \n
\n \n {file.file.name.split(\".\").pop()}\n \n
\n
\n \n {file.file.name}\n

\n

\n {formatFileSize(file.file.size)}\n

\n
\n
\n )}\n\n onRemove(file.id)}\n className=\"absolute right-1 top-1 h-auto w-auto rounded-full bg-black/40 p-1 text-white opacity-0 hover:bg-black/60 hover:text-white group-hover/file:opacity-100\"\n >\n \n \n\n {file.uploadStatus === \"uploading\" && (\n
\n \n
\n )}\n
\n );\n};\n\n/* ------------------------------------------------------------------ */\n/* pasted content card */\n/* ------------------------------------------------------------------ */\nconst PastedContentCard: React.FC<{\n content: PastedContent;\n onRemove: (id: string) => void;\n}> = ({ content, onRemove }) => (\n
\n

\n {content.content}\n

\n \n PASTED\n \n onRemove(content.id)}\n className=\"absolute right-2 top-2 h-auto w-auto rounded-full border border-foreground/[0.08] bg-background p-[3px] text-foreground/25 opacity-0 hover:bg-background hover:text-foreground/50 group-hover/paste:opacity-100\"\n >\n \n \n
\n);\n\n/* ------------------------------------------------------------------ */\n/* Claude starburst — the real icon, every curve authentic */\n/* ------------------------------------------------------------------ */\nconst ClaudeStarburst: React.FC<{ size?: number; className?: string }> = ({\n size = 32,\n className,\n}) => (\n \n \n \n);\n\n/* ------------------------------------------------------------------ */\n/* model selector — Popover with thinking toggle & more models */\n/* ------------------------------------------------------------------ */\nconst ModelSelector: React.FC<{\n models: ModelOption[];\n selectedModel: string;\n onSelect: (id: string) => void;\n thinking: boolean;\n onThinkingChange: (v: boolean) => void;\n}> = ({ models, selectedModel, onSelect, thinking, onThinkingChange }) => {\n const [isOpen, setIsOpen] = React.useState(false);\n const current = models.find((m) => m.id === selectedModel) || models[0];\n\n return (\n \n \n \n {current.name}\n \n \n \n\n \n {/* model list */}\n
\n {models.map((model) => (\n {\n onSelect(model.id);\n setIsOpen(false);\n }}\n className=\"flex w-full items-center justify-between rounded-xl px-3 py-2.5 text-left transition-all duration-150 ease-[cubic-bezier(0.16,1,0.3,1)] hover:bg-foreground/[0.035]\"\n >\n
\n \n {model.name}\n \n {model.description && (\n \n {model.description}\n \n )}\n
\n {selectedModel === model.id && (\n \n )}\n \n ))}\n
\n\n \n\n {/* extended thinking toggle */}\n
\n onThinkingChange(!thinking)}\n className=\"flex w-full items-center justify-between gap-3 rounded-xl px-3 py-2.5 text-left transition-all duration-150 ease-[cubic-bezier(0.16,1,0.3,1)] hover:bg-foreground/[0.035]\"\n >\n
\n

\n Extended thinking\n

\n

\n Think longer for complex tasks\n

\n
\n e.stopPropagation()}\n className=\"h-[22px] w-10 data-[state=checked]:bg-[#c96442] data-[state=unchecked]:bg-foreground/[0.12]\"\n />\n \n
\n\n \n\n {/* more models */}\n
\n \n \n More models\n \n \n \n
\n \n
\n );\n};\n\n/* ------------------------------------------------------------------ */\n/* defaults */\n/* ------------------------------------------------------------------ */\nconst defaultModels: ModelOption[] = [\n {\n id: \"opus-4.6\",\n name: \"Opus 4.6\",\n description: \"Most capable for ambitious work\",\n },\n {\n id: \"sonnet-4.5\",\n name: \"Sonnet 4.5\",\n description: \"Best for everyday tasks\",\n },\n {\n id: \"haiku-4.5\",\n name: \"Haiku 4.5\",\n description: \"Fastest for quick answers\",\n },\n];\n\nconst defaultSuggestions: SuggestionChip[] = [\n {\n icon: (\n \n \n \n ),\n label: \"Code\",\n },\n {\n icon: (\n \n \n \n ),\n label: \"Write\",\n },\n {\n icon: (\n \n \n \n ),\n label: \"Learn\",\n },\n {\n icon: (\n \n \n \n ),\n label: \"Life stuff\",\n },\n {\n icon: (\n \n \n \n ),\n label: \"Claude's choice\",\n },\n];\n\n/* ------------------------------------------------------------------ */\n/* main component */\n/* ------------------------------------------------------------------ */\nexport default function ClaudeChatInput({\n onSubmit,\n placeholder = \"How can I help you today?\",\n models = defaultModels,\n defaultModel = \"opus-4.6\",\n suggestions = defaultSuggestions,\n greeting,\n className,\n}: ClaudeChatInputProps) {\n const [message, setMessage] = React.useState(\"\");\n const [files, setFiles] = React.useState([]);\n const [pastedContent, setPastedContent] = React.useState([]);\n const [isDragging, setIsDragging] = React.useState(false);\n const [selectedModel, setSelectedModel] = React.useState(defaultModel);\n const [thinking, setThinking] = React.useState(false);\n\n const textareaRef = React.useRef(null);\n const fileInputRef = React.useRef(null);\n\n /* auto-resize textarea */\n React.useEffect(() => {\n const el = textareaRef.current;\n if (!el) return;\n el.style.height = \"auto\";\n el.style.height = Math.min(el.scrollHeight, 384) + \"px\";\n }, [message]);\n\n /* file handling */\n const handleFiles = React.useCallback((list: FileList | File[]) => {\n const incoming = Array.from(list).map((file) => {\n const isImage =\n file.type.startsWith(\"image/\") ||\n /\\.(jpg|jpeg|png|gif|webp|svg)$/i.test(file.name);\n return {\n id: Math.random().toString(36).slice(2, 11),\n file,\n type: isImage\n ? \"image/unknown\"\n : file.type || \"application/octet-stream\",\n preview: isImage ? URL.createObjectURL(file) : null,\n uploadStatus: \"pending\" as const,\n };\n });\n\n setFiles((prev) => [...prev, ...incoming]);\n\n incoming.forEach((f) => {\n setTimeout(\n () =>\n setFiles((prev) =>\n prev.map((p) =>\n p.id === f.id ? { ...p, uploadStatus: \"complete\" as const } : p,\n ),\n ),\n 600 + Math.random() * 800,\n );\n });\n }, []);\n\n /* paste handling */\n const handlePaste = (e: React.ClipboardEvent) => {\n const items = e.clipboardData.items;\n const pastedFiles: File[] = [];\n for (let i = 0; i < items.length; i++) {\n if (items[i].kind === \"file\") {\n const file = items[i].getAsFile();\n if (file) pastedFiles.push(file);\n }\n }\n if (pastedFiles.length > 0) {\n e.preventDefault();\n handleFiles(pastedFiles);\n return;\n }\n const text = e.clipboardData.getData(\"text\");\n if (text.length > 300) {\n e.preventDefault();\n setPastedContent((prev) => [\n ...prev,\n {\n id: Math.random().toString(36).slice(2, 11),\n content: text,\n timestamp: new Date(),\n },\n ]);\n }\n };\n\n /* drag & drop */\n const onDragOver = (e: React.DragEvent) => {\n e.preventDefault();\n setIsDragging(true);\n };\n const onDragLeave = (e: React.DragEvent) => {\n e.preventDefault();\n setIsDragging(false);\n };\n const onDrop = (e: React.DragEvent) => {\n e.preventDefault();\n setIsDragging(false);\n if (e.dataTransfer.files) handleFiles(e.dataTransfer.files);\n };\n\n /* submit */\n const handleSend = () => {\n if (!message.trim() && files.length === 0 && pastedContent.length === 0)\n return;\n onSubmit?.({\n message,\n files,\n pastedContent,\n model: selectedModel,\n thinking,\n });\n setMessage(\"\");\n setFiles([]);\n setPastedContent([]);\n if (textareaRef.current) textareaRef.current.style.height = \"auto\";\n };\n\n const handleKeyDown = (e: React.KeyboardEvent) => {\n if (e.key === \"Enter\" && !e.shiftKey) {\n e.preventDefault();\n handleSend();\n }\n };\n\n const hasContent =\n message.trim() || files.length > 0 || pastedContent.length > 0;\n\n return (\n \n {/* greeting — starburst + serif text, matching Claude's Tiempos feel */}\n {greeting && (\n
\n \n

\n {greeting}\n

\n
\n )}\n\n {/* input container — barely-there border, gentle shadow lift */}\n \n
\n {/* attachments row */}\n {(files.length > 0 || pastedContent.length > 0) && (\n
\n {pastedContent.map((c) => (\n \n setPastedContent((prev) => prev.filter((p) => p.id !== id))\n }\n />\n ))}\n {files.map((f) => (\n \n setFiles((prev) => prev.filter((p) => p.id !== id))\n }\n />\n ))}\n
\n )}\n\n {/* textarea */}\n
\n setMessage(e.target.value)}\n onPaste={handlePaste}\n onKeyDown={handleKeyDown}\n placeholder={placeholder}\n aria-label=\"Message input\"\n className=\"min-h-0 w-full resize-none overflow-hidden rounded-none border-0 bg-transparent px-0 py-0 text-[15px] leading-[1.65] tracking-[-0.01em] text-foreground/90 shadow-none outline-none ring-0 ring-offset-0 placeholder:text-foreground/[0.28] focus-visible:ring-0 focus-visible:ring-offset-0\"\n rows={1}\n style={{ minHeight: \"1.5em\" }}\n />\n
\n\n {/* action bar */}\n
\n {/* left: attach */}\n
\n fileInputRef.current?.click()}\n type=\"button\"\n className=\"h-8 w-8 rounded-xl text-foreground/25 transition-all duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] hover:bg-foreground/[0.04] hover:text-foreground/45 active:scale-95\"\n >\n \n \n
\n\n {/* right: model selector + send */}\n
\n \n\n {/* send — raw button to avoid shadcn base-class conflicts */}\n \n \n \n
\n
\n
\n
\n\n {/* suggestion chips — barely-there pills, warm on hover */}\n {suggestions.length > 0 && (\n
\n {suggestions.map((chip, i) => (\n \n {chip.icon}\n {chip.label && {chip.label}}\n \n ))}\n
\n )}\n\n {/* drag overlay */}\n {isDragging && (\n
\n \n

\n Drop files to upload\n

\n
\n )}\n\n {/* hidden file input */}\n {\n if (e.target.files) handleFiles(e.target.files);\n e.target.value = \"\";\n }}\n />\n \n );\n}\n", "type": "registry:ui", "target": "components/ruixen/claude-chat-input.tsx" } ] }