{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "composer",
  "type": "registry:ui",
  "title": "Composer",
  "description": "A chat message input component with file attachments, auto-growing textarea, and tool integration support.",
  "registryDependencies": [
    "icons",
    "file-preview",
    "slash-command-dropdown"
  ],
  "files": [
    {
      "path": "registry/new-york/ui/composer.tsx",
      "content": "\"use client\";\n\nimport type { FC, ReactNode } from \"react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport {\n\tArrowUp02Icon,\n\tHugeiconsIcon,\n\tPlusSignIcon,\n\tToolsIcon,\n} from \"@/components/icons\";\nimport { cn } from \"@/lib/utils\";\nimport {\n\tFilePreview,\n\ttype UploadedFile,\n} from \"@/registry/new-york/ui/file-preview\";\nimport {\n\tSlashCommandDropdown,\n\ttype SlashCommandMatch,\n\ttype Tool,\n} from \"@/registry/new-york/ui/slash-command-dropdown\";\n\n// Re-export types for convenience\nexport type { Tool, SlashCommandMatch, UploadedFile };\n\nexport interface ComposerContextOption {\n\tid: string;\n\tlabel: string;\n\ticon?: ReactNode;\n\tdescription?: string;\n\tonClick?: () => void;\n}\n\nexport interface ComposerProps {\n\t/** Placeholder text for the input */\n\tplaceholder?: string;\n\t/** Callback when message is submitted */\n\tonSubmit?: (message: string, files?: UploadedFile[]) => void;\n\t/** Callback when input value changes */\n\tonChange?: (value: string) => void;\n\t/** Whether the composer is disabled */\n\tdisabled?: boolean;\n\t/** Whether to show the tools button */\n\tshowToolsButton?: boolean;\n\t/** Callback when a tool is selected from slash command dropdown */\n\tonToolSelect?: (tool: Tool) => void;\n\t/** Available tools for the slash command dropdown */\n\ttools?: Tool[];\n\t/** Callback when attach button is clicked (if no context options) */\n\tonAttachClick?: () => void;\n\t/** Context options for the plus button dropdown */\n\tcontextOptions?: ComposerContextOption[];\n\t/** Whether to auto-focus the input */\n\tautoFocus?: boolean;\n\t/** Maximum rows for the textarea */\n\tmaxRows?: number;\n\t/** Initial value */\n\tdefaultValue?: string;\n\t/** Controlled value */\n\tvalue?: string;\n\t/** Additional className for the container */\n\tclassName?: string;\n\t/** Attached files to display */\n\tattachedFiles?: UploadedFile[];\n\t/** Callback to remove an attached file */\n\tonRemoveFile?: (id: string) => void;\n\t/** Whether the composer is in loading state */\n\tisLoading?: boolean;\n}\n\n// Primary color matching GAIA: #00bbff\nconst PRIMARY_COLOR = \"#00bbff\";\n\nexport const Composer: FC<ComposerProps> = ({\n\tplaceholder = \"What can I do for you today?\",\n\tonSubmit,\n\tonChange,\n\tdisabled = false,\n\tshowToolsButton = true,\n\tonToolSelect,\n\ttools = [],\n\tonAttachClick,\n\tcontextOptions,\n\tautoFocus = false,\n\tmaxRows = 8,\n\tdefaultValue = \"\",\n\tvalue,\n\tclassName,\n\tattachedFiles = [],\n\tonRemoveFile,\n\tisLoading = false,\n}) => {\n\tconst [inputValue, setInputValue] = useState(defaultValue);\n\tconst [isToolsDropdownOpen, setIsToolsDropdownOpen] = useState(false);\n\tconst [isContextMenuOpen, setIsContextMenuOpen] = useState(false);\n\tconst [selectedCategory, setSelectedCategory] = useState(\"all\");\n\tconst selectedToolIndex = 0; // Currently no keyboard navigation, always start at 0\n\tconst textareaRef = useRef<HTMLTextAreaElement>(null);\n\tconst composerRef = useRef<HTMLDivElement>(null);\n\n\t// Use controlled or uncontrolled value\n\tconst currentValue = value !== undefined ? value : inputValue;\n\n\t// Handle input change\n\tconst handleInputChange = useCallback(\n\t\t(e: React.ChangeEvent<HTMLTextAreaElement>) => {\n\t\t\tconst newValue = e.target.value;\n\t\t\tif (value === undefined) {\n\t\t\t\tsetInputValue(newValue);\n\t\t\t}\n\t\t\tonChange?.(newValue);\n\t\t},\n\t\t[onChange, value],\n\t);\n\n\t// Auto-resize textarea\n\t// biome-ignore lint/correctness/useExhaustiveDependencies: currentValue triggers resize when content changes\n\tuseEffect(() => {\n\t\tconst textarea = textareaRef.current;\n\t\tif (!textarea) return;\n\n\t\ttextarea.style.height = \"auto\";\n\t\tconst lineHeight = 24;\n\t\tconst maxHeight = lineHeight * maxRows;\n\t\tconst newHeight = Math.min(textarea.scrollHeight, maxHeight);\n\t\ttextarea.style.height = `${newHeight}px`;\n\t}, [currentValue, maxRows]);\n\n\t// Handle form submit\n\tconst handleSubmit = useCallback(\n\t\t(e?: React.FormEvent) => {\n\t\t\te?.preventDefault();\n\t\t\tif (isLoading) return;\n\t\t\tif (currentValue.trim() || attachedFiles.length > 0) {\n\t\t\t\tonSubmit?.(currentValue, attachedFiles);\n\t\t\t\tif (value === undefined) {\n\t\t\t\t\tsetInputValue(\"\");\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t[currentValue, attachedFiles, onSubmit, value, isLoading],\n\t);\n\n\t// Handle key down\n\tconst handleKeyDown = useCallback(\n\t\t(e: React.KeyboardEvent<HTMLTextAreaElement>) => {\n\t\t\tif (e.key === \"Enter\" && !e.shiftKey && !disabled && !isLoading) {\n\t\t\t\te.preventDefault();\n\t\t\t\thandleSubmit();\n\t\t\t}\n\t\t\tif (e.key === \"Escape\") {\n\t\t\t\tsetIsToolsDropdownOpen(false);\n\t\t\t\tsetIsContextMenuOpen(false);\n\t\t\t}\n\t\t},\n\t\t[handleSubmit, disabled, isLoading],\n\t);\n\n\t// Handle tools button click\n\tconst handleToolsClick = useCallback(() => {\n\t\tif (isLoading) return;\n\t\tsetIsToolsDropdownOpen(!isToolsDropdownOpen);\n\t\tsetIsContextMenuOpen(false);\n\t}, [isToolsDropdownOpen, isLoading]);\n\n\t// Handle context menu click\n\tconst handleContextClick = useCallback(() => {\n\t\tif (isLoading) return;\n\t\tif (contextOptions && contextOptions.length > 0) {\n\t\t\tsetIsContextMenuOpen(!isContextMenuOpen);\n\t\t\tsetIsToolsDropdownOpen(false);\n\t\t} else {\n\t\t\tonAttachClick?.();\n\t\t}\n\t}, [contextOptions, isContextMenuOpen, onAttachClick, isLoading]);\n\n\t// Handle tool selection\n\tconst handleToolSelect = useCallback(\n\t\t(match: SlashCommandMatch) => {\n\t\t\tonToolSelect?.(match.tool);\n\t\t\tsetIsToolsDropdownOpen(false);\n\t\t},\n\t\t[onToolSelect],\n\t);\n\n\t// Close dropdowns when clicking outside\n\tuseEffect(() => {\n\t\tconst handleClickOutside = (event: MouseEvent) => {\n\t\t\tconst target = event.target as Element;\n\t\t\tif (\n\t\t\t\t!composerRef.current?.contains(target) &&\n\t\t\t\t!target.closest(\".slash-command-dropdown\")\n\t\t\t) {\n\t\t\t\tsetIsToolsDropdownOpen(false);\n\t\t\t\tsetIsContextMenuOpen(false);\n\t\t\t}\n\t\t};\n\n\t\tdocument.addEventListener(\"click\", handleClickOutside);\n\t\treturn () => document.removeEventListener(\"click\", handleClickOutside);\n\t}, []);\n\n\t// Focus input on mount if autoFocus is true\n\tuseEffect(() => {\n\t\tif (autoFocus && textareaRef.current) {\n\t\t\ttextareaRef.current.focus();\n\t\t}\n\t}, [autoFocus]);\n\n\t// Convert tools to matches for dropdown\n\tconst toolMatches: SlashCommandMatch[] = tools.map((tool) => ({\n\t\ttool,\n\t\tscore: 1,\n\t}));\n\n\t// Get unique categories\n\tconst categories = [\"all\", ...new Set(tools.map((t) => t.category))];\n\n\tconst canSubmit = currentValue.trim() || attachedFiles.length > 0;\n\n\treturn (\n\t\t<div className={cn(\"relative w-full\", className)}>\n\t\t\t{/* Main Composer Container - light mode friendly */}\n\t\t\t<div\n\t\t\t\tref={composerRef}\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"relative rounded-3xl px-1 pt-1 pb-2\",\n\t\t\t\t\t// Light mode: light gray background, Dark mode: zinc-800\n\t\t\t\t\t\"bg-zinc-100 dark:bg-zinc-800\",\n\t\t\t\t)}\n\t\t\t>\n\t\t\t\t{/* Slash Command Dropdown - positioned above composer */}\n\t\t\t\t{showToolsButton && tools.length > 0 && isToolsDropdownOpen && (\n\t\t\t\t\t<div className=\"absolute bottom-full left-0 right-0 mb-2 z-50\">\n\t\t\t\t\t\t<SlashCommandDropdown\n\t\t\t\t\t\t\tmatches={toolMatches}\n\t\t\t\t\t\t\tselectedIndex={selectedToolIndex}\n\t\t\t\t\t\t\tonSelect={handleToolSelect}\n\t\t\t\t\t\t\tonClose={() => setIsToolsDropdownOpen(false)}\n\t\t\t\t\t\t\tposition={{ left: 0, width: undefined }}\n\t\t\t\t\t\t\tisVisible={true}\n\t\t\t\t\t\t\topenedViaButton={true}\n\t\t\t\t\t\t\tselectedCategory={selectedCategory}\n\t\t\t\t\t\t\tcategories={categories}\n\t\t\t\t\t\t\tonCategoryChange={setSelectedCategory}\n\t\t\t\t\t\t\tclassName=\"relative w-full\"\n\t\t\t\t\t\t\tstyle={{ position: \"relative\" }}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t\t{/* Attached Files Preview - using FilePreview component */}\n\t\t\t\t<FilePreview\n\t\t\t\t\tfiles={attachedFiles}\n\t\t\t\t\tonRemove={onRemoveFile}\n\t\t\t\t\tclassName=\"rounded-xl\"\n\t\t\t\t/>\n\n\t\t\t\t{/* Textarea Input */}\n\t\t\t\t<form onSubmit={handleSubmit}>\n\t\t\t\t\t<div className=\"relative px-3\">\n\t\t\t\t\t\t<textarea\n\t\t\t\t\t\t\tref={textareaRef}\n\t\t\t\t\t\t\tvalue={currentValue}\n\t\t\t\t\t\t\tonChange={handleInputChange}\n\t\t\t\t\t\t\tonKeyDown={handleKeyDown}\n\t\t\t\t\t\t\tplaceholder={placeholder}\n\t\t\t\t\t\t\tdisabled={disabled || isLoading}\n\t\t\t\t\t\t\trows={1}\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\"w-full resize-none bg-transparent py-3 pr-24 transition-all text-base font-light\",\n\t\t\t\t\t\t\t\t// Light mode text colors\n\t\t\t\t\t\t\t\t\"text-zinc-900 dark:text-white\",\n\t\t\t\t\t\t\t\t\"placeholder:text-zinc-400 dark:placeholder:text-zinc-500\",\n\t\t\t\t\t\t\t\t\"focus:outline-none\",\n\t\t\t\t\t\t\t\t\"disabled:cursor-not-allowed disabled:opacity-50\",\n\t\t\t\t\t\t\t\t\"scrollbar-thin scrollbar-thumb-zinc-300 dark:scrollbar-thumb-zinc-600 scrollbar-track-transparent\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tminHeight: \"24px\",\n\t\t\t\t\t\t\t\tmaxHeight: `${24 * maxRows}px`,\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t{/* Hint for slash commands */}\n\t\t\t\t\t\t<div className=\"absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1 text-xs text-zinc-400 dark:text-zinc-500 pointer-events-none\">\n\t\t\t\t\t\t\t<kbd className=\"rounded bg-zinc-200 dark:bg-zinc-700 px-1.5 py-0.5 text-[10px] font-medium text-zinc-500 dark:text-zinc-400\">\n\t\t\t\t\t\t\t\t/\n\t\t\t\t\t\t\t</kbd>\n\t\t\t\t\t\t\t<span>for tools</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</form>\n\n\t\t\t\t{/* Toolbar */}\n\t\t\t\t<div className=\"flex items-center justify-between px-2 pt-1\">\n\t\t\t\t\t<div className=\"flex items-center gap-1\">\n\t\t\t\t\t\t{/* Add Context / Attach Button */}\n\t\t\t\t\t\t<div className=\"relative\">\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\tonClick={handleContextClick}\n\t\t\t\t\t\t\t\tdisabled={disabled || isLoading}\n\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\"group relative flex h-9 w-9 items-center justify-center rounded-full cursor-pointer\",\n\t\t\t\t\t\t\t\t\t// Light mode colors\n\t\t\t\t\t\t\t\t\t\"bg-zinc-200 dark:bg-zinc-700 transition-colors\",\n\t\t\t\t\t\t\t\t\t\"hover:bg-zinc-300 dark:hover:bg-zinc-600/90\",\n\t\t\t\t\t\t\t\t\t\"disabled:cursor-wait disabled:opacity-70\",\n\t\t\t\t\t\t\t\t\tisContextMenuOpen && \"bg-zinc-300 dark:bg-zinc-600\",\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\taria-label=\"Add context or attach files\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<HugeiconsIcon\n\t\t\t\t\t\t\t\t\ticon={PlusSignIcon}\n\t\t\t\t\t\t\t\t\tsize={23}\n\t\t\t\t\t\t\t\t\tclassName=\"text-zinc-500 dark:text-zinc-400\"\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</button>\n\n\t\t\t\t\t\t\t{/* Context Menu Dropdown */}\n\t\t\t\t\t\t\t{isContextMenuOpen && contextOptions && (\n\t\t\t\t\t\t\t\t<div className=\"absolute bottom-full left-0 mb-2 min-w-[200px] rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-1 shadow-xl overflow-hidden animate-in fade-in-0 slide-in-from-bottom-2 duration-150\">\n\t\t\t\t\t\t\t\t\t{contextOptions.map((option) => (\n\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\tkey={option.id}\n\t\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\t\toption.onClick?.();\n\t\t\t\t\t\t\t\t\t\t\t\tsetIsContextMenuOpen(false);\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-left text-sm text-zinc-900 dark:text-white hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors\"\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{option.icon && (\n\t\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex-shrink-0\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: PRIMARY_COLOR }}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{option.icon}\n\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex flex-col\">\n\t\t\t\t\t\t\t\t\t\t\t\t<span>{option.label}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t{option.description && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-xs text-zinc-500 dark:text-zinc-400\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{option.description}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t{/* Tools Button */}\n\t\t\t\t\t\t{showToolsButton && (\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\tonClick={handleToolsClick}\n\t\t\t\t\t\t\t\tdisabled={disabled || isLoading}\n\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\"group relative flex h-9 w-9 items-center justify-center rounded-full cursor-pointer\",\n\t\t\t\t\t\t\t\t\t// Light mode colors\n\t\t\t\t\t\t\t\t\t\"bg-zinc-200 dark:bg-zinc-700 text-zinc-500 dark:text-zinc-400 transition-colors\",\n\t\t\t\t\t\t\t\t\t\"hover:bg-zinc-300 dark:hover:bg-zinc-600/90\",\n\t\t\t\t\t\t\t\t\t\"disabled:cursor-wait disabled:opacity-70\",\n\t\t\t\t\t\t\t\t\t// Active state\n\t\t\t\t\t\t\t\t\tisToolsDropdownOpen &&\n\t\t\t\t\t\t\t\t\t\t\"bg-[#00bbff]/20 text-[#00bbff] hover:bg-[#00bbff]/30 dark:hover:bg-[#00bbff]/40\",\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\taria-label=\"Browse all tools\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<HugeiconsIcon icon={ToolsIcon} size={23} />\n\t\t\t\t\t\t\t\t{isToolsDropdownOpen && (\n\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\tclassName=\"absolute top-0 right-0 h-2 w-2 rounded-full transition\"\n\t\t\t\t\t\t\t\t\t\tstyle={{ backgroundColor: PRIMARY_COLOR }}\n\t\t\t\t\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\n\t\t\t\t\t{/* Send Button */}\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={() => handleSubmit()}\n\t\t\t\t\t\tdisabled={disabled || isLoading || !canSubmit}\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\"flex h-9 w-9 min-w-9 max-w-9 items-center justify-center rounded-full transition-colors cursor-pointer\",\n\t\t\t\t\t\t\t\"disabled:cursor-not-allowed\",\n\t\t\t\t\t\t\t// Enabled state - primary color background\n\t\t\t\t\t\t\tcanSubmit && \"bg-[#00bbff] text-white hover:bg-[#00a3e0]\",\n\t\t\t\t\t\t\t// Disabled state - visible gray background that contrasts with composer\n\t\t\t\t\t\t\t!canSubmit &&\n\t\t\t\t\t\t\t\t\"bg-zinc-200 dark:bg-zinc-700 text-zinc-400 dark:text-zinc-500\",\n\t\t\t\t\t\t)}\n\t\t\t\t\t\taria-label=\"Send message\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<HugeiconsIcon icon={ArrowUp02Icon} size={20} />\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t);\n};\n\nexport default Composer;\n",
      "type": "registry:ui"
    }
  ]
}