{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chat-composer",
  "title": "Chat composer",
  "description": "Enter sends, Shift+Enter breaks · `busy` turns Send into Stop · `accept` turns on attachments (paperclip, drop, chips)",
  "dependencies": [
    "cn@^0.4.0",
    "lucide-react@^1.52.0"
  ],
  "registryDependencies": [
    "@kit/attachment-chip",
    "@kit/button",
    "textarea"
  ],
  "files": [
    {
      "path": "components/kit/chat-composer.tsx",
      "content": "'use client';\nimport { ArrowUpIcon, PaperclipIcon, SquareIcon } from 'lucide-react';\nimport { cn } from 'cn';\nimport * as React from 'react';\nimport { AttachmentChip } from '@/components/kit/attachment-chip';\nimport { Button } from '@/components/ui/button';\nimport { Textarea } from '@/components/ui/textarea';\n\ntype Props = {\n  /** The message, and any files attached to it (an empty array when attachments are off or none). */\n  onSend: (text: string, files: File[]) => void;\n  /** While the assistant answers: the send button becomes Stop. */\n  busy?: boolean;\n  onStop?: () => void;\n  placeholder?: string;\n  /**\n   * Turns on attachments: a paperclip button, dropping files on the composer, and chips to review and\n   * remove them before sending. The value is the file input's `accept` (\"image/*,.pdf\"; \"\" for any).\n   */\n  accept?: string;\n  /** At most this many files per message (default 5). */\n  maxFiles?: number;\n  className?: string;\n};\n\n/** Where the user writes. Enter sends, Shift+Enter adds a line. Can carry files when `accept` is set. */\nexport function ChatComposer({ onSend, busy, onStop, placeholder = 'Ask anything', accept, maxFiles = 5, className }: Props) {\n  const [value, setValue] = React.useState('');\n  const [files, setFiles] = React.useState<File[]>([]);\n  const [dragging, setDragging] = React.useState(false);\n  const id = React.useId();\n  const picker = React.useRef<HTMLInputElement>(null);\n  const attachments = accept !== undefined;\n\n  const add = (list: FileList | null) => {\n    if (!list?.length) return;\n    setFiles((fs) => [...fs, ...Array.from(list)].slice(0, maxFiles));\n  };\n\n  const send = () => {\n    const text = value.trim();\n    if ((!text && !files.length) || busy) return;\n    onSend(text, files);\n    setValue('');\n    setFiles([]);\n  };\n\n  const canSend = !!value.trim() || files.length > 0;\n\n  return (\n    <form\n      onSubmit={(e) => {\n        e.preventDefault();\n        send();\n      }}\n      onDragOver={\n        attachments\n          ? (e) => {\n              if (!e.dataTransfer.types.includes('Files')) return;\n              e.preventDefault();\n              setDragging(true);\n            }\n          : undefined\n      }\n      onDragLeave={attachments ? () => setDragging(false) : undefined}\n      onDrop={\n        attachments\n          ? (e) => {\n              e.preventDefault();\n              setDragging(false);\n              add(e.dataTransfer.files);\n            }\n          : undefined\n      }\n      className={cn(\n        'border-input bg-card focus-within:ring-ring/50 flex flex-col gap-2 rounded-2xl border p-2 focus-within:ring-[3px]',\n        dragging && 'border-primary ring-ring/50 ring-[3px]',\n        className\n      )}>\n      {files.length ? (\n        <ul aria-label=\"Attachments\" className=\"flex flex-wrap gap-2 px-1 pt-1\">\n          {files.map((f, i) => (\n            <li key={`${f.name}-${i}`}>\n              <AttachmentChip file={f} onRemove={() => setFiles((fs) => fs.filter((_, j) => j !== i))} />\n            </li>\n          ))}\n        </ul>\n      ) : null}\n      <div className=\"flex items-end gap-2\">\n        {attachments ? (\n          <>\n            <input\n              ref={picker}\n              type=\"file\"\n              multiple={maxFiles > 1}\n              accept={accept || undefined}\n              className=\"sr-only\"\n              tabIndex={-1}\n              aria-hidden\n              onChange={(e) => {\n                add(e.target.files);\n                e.target.value = '';\n              }}\n            />\n            <Button\n              type=\"button\"\n              variant=\"ghost\"\n              size=\"icon\"\n              className=\"rounded-full\"\n              aria-label=\"Attach files\"\n              disabled={files.length >= maxFiles}\n              onClick={() => picker.current?.click()}>\n              <PaperclipIcon />\n            </Button>\n          </>\n        ) : null}\n        <label htmlFor={id} className=\"sr-only\">\n          Message\n        </label>\n        <Textarea\n          id={id}\n          value={value}\n          onChange={(e) => setValue(e.target.value)}\n          onKeyDown={(e) => {\n            if (e.key === 'Enter' && !e.shiftKey) {\n              e.preventDefault();\n              send();\n            }\n          }}\n          placeholder={placeholder}\n          rows={1}\n          className=\"max-h-40 min-h-10 resize-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent\"\n        />\n        {busy ? (\n          <Button type=\"button\" size=\"icon\" variant=\"secondary\" className=\"rounded-full\" onClick={onStop} aria-label=\"Stop answering\">\n            <SquareIcon />\n          </Button>\n        ) : (\n          <Button type=\"submit\" size=\"icon\" className=\"rounded-full\" disabled={!canSend} aria-label=\"Send\">\n            <ArrowUpIcon />\n          </Button>\n        )}\n      </div>\n    </form>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/kit/chat-composer.tsx"
    }
  ],
  "meta": {
    "exports": [
      "ChatComposer"
    ],
    "api": "<ChatComposer onSend={(text, files) => …} busy? onStop? placeholder? accept? maxFiles? />"
  },
  "docs": "Needs the kit theme (`@kit/theme`) and a next-themes ThemeProvider (attribute=\"class\") at the root.",
  "categories": [
    "ai"
  ],
  "type": "registry:component"
}