{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "prompt-composer",
  "title": "Prompt Composer",
  "description": "A focused multiline prompt surface with attachment and model menus plus an explicit send/stop lifecycle.",
  "dependencies": [
    "lucide-react",
    "@base-ui/react",
    "class-variance-authority",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "button",
    "menu",
    "springs"
  ],
  "files": [
    {
      "path": "src/components/trovecn/ai-workbench/prompt-composer.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useRef, useState, type FormEvent, type KeyboardEvent } from \"react\";\nimport {\n  ArrowUpIcon,\n  ChevronDownIcon,\n  ClockIcon,\n  FileIcon,\n  FileTextIcon,\n  ImageIcon,\n  ListPlusIcon,\n  PlusIcon,\n  SquareIcon,\n  XIcon,\n} from \"lucide-react\";\nimport { AnimatePresence, motion, Reorder, useReducedMotion } from \"motion/react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Menu,\n  MenuContent,\n  MenuItem,\n  MenuRadioGroup,\n  MenuRadioItem,\n  MenuTrigger,\n} from \"@/components/ui/menu\";\nimport { cn } from \"@/lib/utils\";\nimport { spring } from \"@/lib/springs\";\n\nexport interface PromptComposerSubmitEvent {\n  prompt: string;\n}\n\nexport interface PromptComposerAttachmentOption {\n  id: string;\n  label: string;\n  description?: string;\n  kind?: \"file\" | \"image\" | \"folder\";\n}\n\nexport type PromptComposerFileKind = \"image\" | \"file\";\n\nexport interface PromptComposerFile {\n  id: string;\n  name: string;\n  kind?: PromptComposerFileKind;\n  /** Object URL or remote URL rendered inside the preview tile for image files. */\n  previewUrl?: string;\n}\n\nexport interface PromptComposerQueuedMessage {\n  id: string;\n  prompt: string;\n}\n\nexport interface PromptComposerProps {\n  /** Controlled draft text. */\n  value?: string;\n  /** Initial draft text when the component is uncontrolled. */\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  onSubmit?: (event: PromptComposerSubmitEvent) => void;\n  /** Called while a response is being generated. */\n  onStop?: () => void;\n  /** Replaces Send with Stop and prevents edits until the response ends. */\n  isRunning?: boolean;\n  disabled?: boolean;\n  placeholder?: string;\n  /** Limits the number of characters in the draft. */\n  maxLength?: number;\n  /** Optional model choices, rendered as a compact footer menu. */\n  models?: readonly string[];\n  model?: string;\n  onModelChange?: (model: string) => void;\n  /** Choices opened by the attachment menu above the composer. */\n  attachmentOptions?: readonly PromptComposerAttachmentOption[];\n  onAttachmentOptionSelect?: (option: PromptComposerAttachmentOption) => void;\n  /** Attachments for the current draft, shown as preview tiles above the textarea. */\n  files?: readonly PromptComposerFile[];\n  onFilesChange?: (files: readonly PromptComposerFile[]) => void;\n  /** Side length, in pixels, of each file preview tile. */\n  filePreviewSize?: number;\n  /**\n   * Messages waiting to send while a response is running. Providing this\n   * (with `onQueueChange`) lets the draft stay open and Send become Queue\n   * mid-response, instead of locking the composer until it finishes.\n   */\n  queue?: readonly PromptComposerQueuedMessage[];\n  onQueueChange?: (queue: readonly PromptComposerQueuedMessage[]) => void;\n  className?: string;\n}\n\n/**\n * The minimal prompt surface shared by chat, generation, and agent products.\n * Attachments, model choice, and queueing are optional, controlled enhancements.\n */\nfunction PromptComposer({\n  value,\n  defaultValue = \"\",\n  onValueChange,\n  onSubmit,\n  onStop,\n  isRunning = false,\n  disabled = false,\n  placeholder = \"Ask anything...\",\n  maxLength,\n  models = [],\n  model,\n  onModelChange,\n  attachmentOptions = [],\n  onAttachmentOptionSelect,\n  files = [],\n  onFilesChange,\n  filePreviewSize = 64,\n  queue = [],\n  onQueueChange,\n  className,\n}: PromptComposerProps) {\n  const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);\n  const [isAttachmentMenuOpen, setIsAttachmentMenuOpen] = useState(false);\n  const [isSending, setIsSending] = useState(false);\n  const textareaId = useId();\n  const statusId = useId();\n  const textareaRef = useRef<HTMLTextAreaElement>(null);\n  const reduceMotion = useReducedMotion();\n  const prompt = value ?? uncontrolledValue;\n  const canQueue = isRunning && Boolean(onQueueChange);\n  const canSubmit = prompt.trim().length > 0 && !disabled && !isSending && (!isRunning || canQueue);\n  const showQueueAction = canQueue && prompt.trim().length > 0;\n  const showStop = isRunning && !showQueueAction;\n\n  useEffect(() => {\n    const textarea = textareaRef.current;\n    if (!textarea) return;\n    textarea.style.height = \"0px\";\n    textarea.style.height = `${Math.min(textarea.scrollHeight, 160)}px`;\n  }, [prompt]);\n\n  function setPrompt(nextValue: string) {\n    if (value === undefined) setUncontrolledValue(nextValue);\n    onValueChange?.(nextValue);\n  }\n\n  function submit() {\n    if (!canSubmit) return;\n    setIsSending(true);\n    if (canQueue) {\n      onQueueChange?.([...queue, { id: crypto.randomUUID(), prompt: prompt.trim() }]);\n      return;\n    }\n    onSubmit?.({ prompt: prompt.trim() });\n  }\n\n  function completeSendAnimation() {\n    if (!isSending) return;\n    setPrompt(\"\");\n    setIsSending(false);\n  }\n\n  function handleSubmit(event: FormEvent<HTMLFormElement>) {\n    event.preventDefault();\n    submit();\n  }\n\n  function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {\n    if (event.nativeEvent.isComposing || event.key !== \"Enter\" || event.shiftKey) return;\n    event.preventDefault();\n    submit();\n  }\n\n  function removeFile(id: string) {\n    onFilesChange?.(files.filter((file) => file.id !== id));\n  }\n\n  function removeQueuedMessage(id: string) {\n    onQueueChange?.(queue.filter((item) => item.id !== id));\n  }\n\n  function editQueuedMessage(id: string) {\n    const target = queue.find((item) => item.id === id);\n    if (!target) return;\n    setPrompt(target.prompt);\n    onQueueChange?.(queue.filter((item) => item.id !== id));\n    textareaRef.current?.focus();\n  }\n\n  function moveQueuedMessage(id: string, direction: -1 | 1) {\n    const index = queue.findIndex((item) => item.id === id);\n    if (index === -1) return;\n    const nextIndex = index + direction;\n    if (nextIndex < 0 || nextIndex >= queue.length) return;\n    const next = [...queue];\n    const [item] = next.splice(index, 1);\n    next.splice(nextIndex, 0, item);\n    onQueueChange?.(next);\n  }\n\n  return (\n    <form\n      data-slot=\"prompt-composer\"\n      className={cn(\n        \"w-full rounded-[20px] border border-border bg-card p-2 shadow-[0_18px_40px_-32px_color-mix(in_oklab,var(--foreground)_70%,transparent)] transition-[border-color,box-shadow] duration-quick focus-within:border-ring/70 focus-within:ring-1 focus-within:ring-ring/25 dark:bg-card/80\",\n        className,\n      )}\n      onSubmit={handleSubmit}\n    >\n      <span id={statusId} role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {isRunning ? \"Generating response. Stop is available.\" : \"Ready to send prompt.\"}\n      </span>\n\n      {queue.length > 0 ? (\n        <Reorder.Group\n          as=\"ul\"\n          axis=\"y\"\n          values={[...queue]}\n          onReorder={(next) => onQueueChange?.(next)}\n          className=\"flex flex-col gap-1 px-1 pt-1\"\n        >\n          {queue.map((item) => (\n            <Reorder.Item\n              key={item.id}\n              value={item}\n              tabIndex={0}\n              onKeyDown={(event) => {\n                if (!event.altKey || (event.key !== \"ArrowUp\" && event.key !== \"ArrowDown\")) {\n                  return;\n                }\n                event.preventDefault();\n                moveQueuedMessage(item.id, event.key === \"ArrowUp\" ? -1 : 1);\n              }}\n              className=\"flex items-center gap-2 rounded-lg border border-border bg-muted/60 px-2.5 py-1.5 text-body text-muted-foreground\"\n            >\n              <ClockIcon className=\"size-3.5 shrink-0 opacity-60\" aria-hidden=\"true\" />\n              <button\n                type=\"button\"\n                onDoubleClick={() => editQueuedMessage(item.id)}\n                title=\"Double-click to edit\"\n                className=\"min-w-0 flex-1 truncate text-left\"\n              >\n                {item.prompt}\n              </button>\n              <button\n                type=\"button\"\n                onClick={() => removeQueuedMessage(item.id)}\n                aria-label=\"Remove queued message\"\n                className=\"flex size-5 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-background hover:text-foreground\"\n              >\n                <XIcon className=\"size-3\" />\n              </button>\n            </Reorder.Item>\n          ))}\n        </Reorder.Group>\n      ) : null}\n\n      {files.length > 0 ? (\n        <ul className=\"flex flex-wrap gap-2 px-1 pt-2\">\n          <AnimatePresence initial={false}>\n            {files.map((file) => (\n              <motion.li\n                key={file.id}\n                layout\n                initial={reduceMotion ? false : { opacity: 0, scale: 0.85 }}\n                animate={{ opacity: 1, scale: 1 }}\n                exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.85 }}\n                transition={reduceMotion ? { duration: 0 } : spring.quick.enter}\n                className=\"group/file relative shrink-0 overflow-hidden rounded-xl border border-border bg-muted\"\n                style={{ width: filePreviewSize, height: filePreviewSize }}\n              >\n                {file.kind === \"image\" && file.previewUrl ? (\n                  // eslint-disable-next-line @next/next/no-img-element\n                  <img src={file.previewUrl} alt={file.name} className=\"size-full object-cover\" />\n                ) : (\n                  <div className=\"flex size-full flex-col items-center justify-center gap-1 px-1.5 text-center\">\n                    {file.kind === \"image\" ? (\n                      <ImageIcon className=\"size-4 text-muted-foreground\" aria-hidden=\"true\" />\n                    ) : (\n                      <FileIcon className=\"size-4 text-muted-foreground\" aria-hidden=\"true\" />\n                    )}\n                    <span className=\"line-clamp-2 text-[0.6rem] leading-tight break-all text-muted-foreground\">\n                      {file.name}\n                    </span>\n                  </div>\n                )}\n                {!disabled ? (\n                  <button\n                    type=\"button\"\n                    onClick={() => removeFile(file.id)}\n                    aria-label={`Remove ${file.name}`}\n                    className=\"absolute top-1 right-1 flex size-5 items-center justify-center rounded-full bg-background/90 text-foreground opacity-0 shadow-sm transition-opacity duration-quick group-hover/file:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50\"\n                  >\n                    <XIcon className=\"size-3\" />\n                  </button>\n                ) : null}\n              </motion.li>\n            ))}\n          </AnimatePresence>\n        </ul>\n      ) : null}\n\n      <label htmlFor={textareaId} className=\"sr-only\">\n        Prompt\n      </label>\n      <motion.div\n        initial={false}\n        animate={isSending ? { opacity: 0, y: reduceMotion ? 0 : -8 } : { opacity: 1, y: 0 }}\n        transition={\n          reduceMotion ? { duration: 0 } : isSending ? spring.quick.exit : spring.quick.enter\n        }\n        onAnimationComplete={completeSendAnimation}\n      >\n        <textarea\n          ref={textareaRef}\n          id={textareaId}\n          value={prompt}\n          disabled={disabled || isSending || (isRunning && !onQueueChange)}\n          aria-describedby={statusId}\n          placeholder={placeholder}\n          maxLength={maxLength}\n          rows={1}\n          className=\"block max-h-40 w-full resize-none overflow-y-auto bg-transparent p-2 text-lede leading-6 text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-60\"\n          onChange={(event) => setPrompt(event.target.value)}\n          onKeyDown={handleKeyDown}\n        />\n      </motion.div>\n\n      <div className=\"flex min-h-9 items-center gap-1 px-0 pt-1.5\">\n        {attachmentOptions.length > 0 ? (\n          <Menu onOpenChange={setIsAttachmentMenuOpen}>\n            <MenuTrigger\n              render={\n                <Button\n                  type=\"button\"\n                  variant=\"ghost\"\n                  size=\"icon-sm\"\n                  disabled={disabled || showStop}\n                  aria-label=\"Add an attachment\"\n                  title=\"Add an attachment\"\n                  className=\"rounded-full text-muted-foreground hover:text-foreground\"\n                />\n              }\n            >\n              <motion.span\n                className=\"flex\"\n                animate={{ rotate: isAttachmentMenuOpen ? 45 : 0 }}\n                transition={reduceMotion ? { duration: 0 } : spring.quick.enter}\n              >\n                <PlusIcon className=\"size-4\" />\n              </motion.span>\n            </MenuTrigger>\n            <MenuContent align=\"start\" side=\"top\" sideOffset={8} className=\"w-60\">\n              {attachmentOptions.map((option) => (\n                <MenuItem key={option.id} onClick={() => onAttachmentOptionSelect?.(option)}>\n                  {option.kind === \"image\" ? <ImageIcon /> : <FileTextIcon />}\n                  <span className=\"flex min-w-0 flex-col\">\n                    <span>{option.label}</span>\n                    {option.description ? (\n                      <span className=\"text-minor leading-4 text-muted-foreground\">\n                        {option.description}\n                      </span>\n                    ) : null}\n                  </span>\n                </MenuItem>\n              ))}\n            </MenuContent>\n          </Menu>\n        ) : null}\n        <span className=\"flex-1\" />\n        {models.length > 0 ? (\n          <Menu>\n            <MenuTrigger\n              render={\n                <Button\n                  type=\"button\"\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  disabled={disabled || showStop}\n                  className=\"text-body text-muted-foreground hover:text-foreground\"\n                />\n              }\n            >\n              <span className=\"max-w-28 truncate\">{model ?? models[0]}</span>\n              <ChevronDownIcon className=\"size-4 opacity-50\" />\n            </MenuTrigger>\n            <MenuContent align=\"end\" side=\"top\" sideOffset={8} className=\"w-52\">\n              <MenuRadioGroup value={model ?? models[0]} onValueChange={onModelChange}>\n                {models.map((option) => (\n                  <MenuRadioItem\n                    key={option}\n                    value={option}\n                    indicator=\"check\"\n                    className=\"data-checked:bg-active data-checked:text-foreground\"\n                  >\n                    {option}\n                  </MenuRadioItem>\n                ))}\n              </MenuRadioGroup>\n            </MenuContent>\n          </Menu>\n        ) : null}\n        {showStop ? (\n          <Button\n            type=\"button\"\n            size=\"icon-sm\"\n            onClick={onStop}\n            disabled={disabled}\n            aria-label=\"Stop generating\"\n            title=\"Stop generating\"\n            className=\"rounded-full\"\n          >\n            <SquareIcon className=\"size-3 fill-current\" />\n          </Button>\n        ) : (\n          <Button\n            type=\"submit\"\n            size=\"icon-sm\"\n            disabled={!canSubmit}\n            aria-label={showQueueAction ? \"Queue prompt\" : \"Send prompt\"}\n            title={\n              showQueueAction ? \"Send once the current response finishes\" : \"Send prompt (Enter)\"\n            }\n            className=\"rounded-full\"\n          >\n            <AnimatePresence initial={false} mode=\"wait\">\n              <motion.span\n                key={showQueueAction ? \"queue\" : \"send\"}\n                className=\"flex\"\n                initial={reduceMotion ? false : { opacity: 0, scale: 0.7 }}\n                animate={{ opacity: 1, scale: 1 }}\n                exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.7 }}\n                transition={reduceMotion ? { duration: 0 } : spring.quick.enter}\n              >\n                {showQueueAction ? (\n                  <ListPlusIcon className=\"size-4\" />\n                ) : (\n                  <ArrowUpIcon className=\"size-4\" />\n                )}\n              </motion.span>\n            </AnimatePresence>\n          </Button>\n        )}\n      </div>\n    </form>\n  );\n}\n\nexport { PromptComposer };\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}