{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ai-input",
  "type": "registry:ui",
  "title": "AI Input",
  "description": "Ultra-smooth expandable AI prompt composer with model selection, effort control, attachments tray, and voice visualizer.",
  "dependencies": [
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/ui/ai-input.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  useRef,\n  useState,\n  useEffect,\n  useLayoutEffect,\n  useCallback,\n  useId,\n  useSyncExternalStore,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { cn } from \"@/lib/utils\";\n\nconst SPRING_TRANSITION =\n  \"max-width 0.42s cubic-bezier(0.16, 1, 0.3, 1), height 0.42s cubic-bezier(0.16, 1, 0.3, 1), border-radius 0.28s ease, box-shadow 0.3s ease\";\nconst SMOOTH_HEIGHT_TRANSITION =\n  \"max-width 0.42s cubic-bezier(0.16, 1, 0.3, 1), height 0.16s cubic-bezier(0.2, 0, 0, 1), box-shadow 0.3s ease\";\n\nconst emptySubscribe = () => () => {};\n\nexport interface Attachment {\n  id: string;\n  file: File;\n  url: string;\n  name: string;\n  width?: number;\n  height?: number;\n}\n\nexport interface ModelOption {\n  name: string;\n  icon?: string | React.ReactNode;\n}\n\nexport interface PromptInputMeta {\n  model: string;\n  effort: string;\n  attachments: File[];\n}\n\nexport interface PromptInputRef {\n  focus: () => void;\n  expand: () => void;\n  collapse: () => void;\n  startVoice: () => void;\n  stopVoice: () => void;\n  addAttachment: (\n    file: File,\n    url: string,\n    width?: number,\n    height?: number,\n  ) => void;\n  clear: () => void;\n  element: HTMLDivElement | null;\n}\n\nexport interface PromptInputProps {\n  onSubmit?: (value: string, meta: PromptInputMeta) => void;\n  placeholder?: string;\n  className?: string;\n  models?: (string | ModelOption)[];\n  selectedModel?: string;\n  onModelChange?: (model: string) => void;\n  efforts?: string[];\n  selectedEffort?: string;\n  onEffortChange?: (effort: string) => void;\n  defaultValue?: string;\n  value?: string;\n  onChange?: (value: string) => void;\n  expanded?: boolean;\n  onExpandedChange?: (expanded: boolean) => void;\n  maxAttachments?: number;\n  allowAttachments?: boolean;\n  allowVoice?: boolean;\n  allowModelSelect?: boolean;\n  allowEffortSelect?: boolean;\n  minWidth?: number | string;\n  maxWidth?: number | string;\n  collapsedHeight?: number;\n  autoFocus?: boolean;\n  disabled?: boolean;\n  accentColor?: string;\n  variant?: \"default\" | \"glow\" | \"minimal\";\n  modelIcons?: Record<string, string | React.ReactNode>;\n}\n\ninterface BrowserSpeechRecognitionEvent {\n  resultIndex: number;\n  results: {\n    length: number;\n    [index: number]: {\n      isFinal: boolean;\n      [index: number]: { transcript: string };\n    };\n  };\n}\n\ninterface BrowserSpeechRecognition {\n  continuous: boolean;\n  interimResults: boolean;\n  start: () => void;\n  stop: () => void;\n  onresult: ((event: BrowserSpeechRecognitionEvent) => void) | null;\n  onerror: (() => void) | null;\n  onend: (() => void) | null;\n}\n\ntype BrowserSpeechRecognitionConstructor = new () => BrowserSpeechRecognition;\n\nconst DEFAULT_MODELS: string[] = [\n  \"GPT 5.5\",\n  \"Opus 4.8\",\n  \"Gemini 3.5 Flash\",\n  \"Composer 2.5\",\n  \"GLM 5.2\",\n];\n\nconst DEFAULT_EFFORTS: string[] = [\"Low\", \"Medium\", \"Max Effort\"];\n\nconst DEFAULT_ICONS: Record<string, string> = {\n  \"Composer 2.5\":\n    \"https://cdn.21st.dev/assets/mirror/7d/7dc00bc09f225fcda46cbc9c6b669c69c025a231877d6c17baa6a003f04f02b2.svg\",\n  \"Gemini 3.5 Flash\":\n    \"https://cdn.21st.dev/assets/mirror/cd/cda2df6631d5fa227de3fa04ed78cf354f910ba92a9f086e7455655c10ad9d09.svg\",\n  \"GPT 5.5\":\n    \"https://cdn.21st.dev/assets/mirror/b9/b93fa7942be639a1dae60194ff12141145d7d9fd59581582d6ff23335755f19c.svg\",\n  \"Opus 4.8\":\n    \"https://cdn.21st.dev/assets/mirror/5d/5de1221c77cc91e748066fd642ad0eee1c1fa65328814f5178166f901e599709.svg\",\n  \"GLM 5.2\":\n    \"https://cdn.21st.dev/assets/mirror/b2/b2a6c0ff63efd8a555edf8a174ea6fcfeca120ac1595a2d461ca11d3ae89276c.svg\",\n};\n\nfunction ArrowUpIcon() {\n  return (\n    <svg\n      width=\"13\"\n      height=\"13\"\n      viewBox=\"0 0 14 14\"\n      fill=\"none\"\n      aria-hidden=\"true\"\n    >\n      <path\n        d=\"M7 12V2M7 2L2.5 6.5M7 2L11.5 6.5\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.8\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n\nfunction MicIcon() {\n  return (\n    <svg\n      width=\"13\"\n      height=\"13\"\n      viewBox=\"0 0 14 14\"\n      fill=\"none\"\n      aria-hidden=\"true\"\n    >\n      <rect\n        x=\"5\"\n        y=\"1\"\n        width=\"4\"\n        height=\"7\"\n        rx=\"2\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.5\"\n      />\n      <path\n        d=\"M2.75 6.5V7a4.25 4.25 0 0 0 8.5 0v-.5M7 11.25V13\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\"\n      />\n    </svg>\n  );\n}\n\nfunction StopIcon() {\n  return (\n    <svg\n      width=\"13\"\n      height=\"13\"\n      viewBox=\"0 0 14 14\"\n      fill=\"none\"\n      aria-hidden=\"true\"\n    >\n      <rect x=\"3.5\" y=\"3.5\" width=\"7\" height=\"7\" rx=\"1.5\" fill=\"currentColor\" />\n    </svg>\n  );\n}\n\nfunction PlusIcon() {\n  return (\n    <svg\n      width=\"13\"\n      height=\"13\"\n      viewBox=\"0 0 14 14\"\n      fill=\"none\"\n      aria-hidden=\"true\"\n    >\n      <path\n        d=\"M7 2.5V11.5M2.5 7H11.5\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\"\n      />\n    </svg>\n  );\n}\n\nfunction CloseIcon() {\n  return (\n    <svg\n      width=\"10\"\n      height=\"10\"\n      viewBox=\"0 0 14 14\"\n      fill=\"none\"\n      aria-hidden=\"true\"\n    >\n      <path\n        d=\"M2.5 2.5L11.5 11.5M11.5 2.5L2.5 11.5\"\n        stroke=\"currentColor\"\n        strokeWidth=\"2\"\n        strokeLinecap=\"round\"\n      />\n    </svg>\n  );\n}\n\nfunction CheckIcon() {\n  return (\n    <svg\n      width=\"12\"\n      height=\"12\"\n      viewBox=\"0 0 14 14\"\n      fill=\"none\"\n      aria-hidden=\"true\"\n    >\n      <path\n        d=\"M2.5 7.5L5.5 10.5L11.5 3.5\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.8\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n\nfunction DynamicBarsIcon({ level }: { level: string }) {\n  const isMediumOrHigh = level === \"Medium\" || level === \"Max Effort\";\n  const isHigh = level === \"Max Effort\";\n\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 14 14\"\n      fill=\"none\"\n      aria-hidden=\"true\"\n    >\n      <rect\n        x=\"1.5\"\n        y=\"8\"\n        width=\"2.5\"\n        height=\"4.5\"\n        rx=\"1\"\n        fill=\"currentColor\"\n        className=\"transition-opacity duration-300\"\n        opacity={1}\n      />\n      <rect\n        x=\"5.75\"\n        y=\"5\"\n        width=\"2.5\"\n        height=\"7.5\"\n        rx=\"1\"\n        fill=\"currentColor\"\n        className=\"transition-opacity duration-300\"\n        opacity={isMediumOrHigh ? 1 : 0.3}\n      />\n      <rect\n        x=\"10\"\n        y=\"2\"\n        width=\"2.5\"\n        height=\"10.5\"\n        rx=\"1\"\n        fill=\"currentColor\"\n        className=\"transition-opacity duration-300\"\n        opacity={isHigh ? 1 : 0.3}\n      />\n    </svg>\n  );\n}\n\nfunction ModelFallbackIcon({ className }: { className?: string }) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 14 14\"\n      fill=\"none\"\n      className={className}\n      aria-hidden=\"true\"\n    >\n      <circle\n        cx=\"7\"\n        cy=\"7\"\n        r=\"5\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.2\"\n        strokeDasharray=\"3 2\"\n      />\n      <circle cx=\"7\" cy=\"7\" r=\"2\" fill=\"currentColor\" />\n    </svg>\n  );\n}\n\nfunction ModelIcon({\n  model,\n  customIcon,\n  className,\n}: {\n  model: string;\n  customIcon?: string | React.ReactNode;\n  className?: string;\n}) {\n  const [loadFailed, setLoadFailed] = useState(false);\n\n  if (customIcon && typeof customIcon !== \"string\") {\n    return (\n      <span\n        className={cn(\n          \"inline-flex shrink-0 items-center justify-center\",\n          className,\n        )}\n      >\n        {customIcon}\n      </span>\n    );\n  }\n\n  const iconSrc =\n    typeof customIcon === \"string\" ? customIcon : DEFAULT_ICONS[model];\n\n  if (!iconSrc || loadFailed) {\n    return (\n      <ModelFallbackIcon\n        className={cn(\"size-3.5 shrink-0 opacity-70\", className)}\n      />\n    );\n  }\n\n  return (\n    <img\n      src={iconSrc}\n      alt={model}\n      onError={() => setLoadFailed(true)}\n      className={cn(\n        \"size-3.5 shrink-0 object-contain\",\n        model === \"GPT 5.5\" && \"dark:invert\",\n        className,\n      )}\n    />\n  );\n}\n\nfunction MorphingText({ text }: { text: string }) {\n  const spanRef = useRef<HTMLSpanElement>(null);\n  const [width, setWidth] = useState<number | \"auto\">(\"auto\");\n\n  useLayoutEffect(() => {\n    if (spanRef.current) {\n      setWidth(spanRef.current.getBoundingClientRect().width);\n    }\n  }, [text]);\n\n  return (\n    <span\n      className=\"relative inline-flex items-center justify-center overflow-hidden transition-[width] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]\"\n      style={{ width: width === \"auto\" ? \"auto\" : `${width}px` }}\n    >\n      <span ref={spanRef} className=\"invisible whitespace-nowrap px-1\">\n        {text}\n      </span>\n      <span\n        key={text}\n        className=\"absolute inset-0 flex items-center justify-center whitespace-nowrap animate-in fade-in zoom-in-95 duration-200\"\n      >\n        {text}\n      </span>\n    </span>\n  );\n}\n\nfunction AttachmentThumb({\n  attachment,\n  index,\n  onRemove,\n  onOpen,\n  registerRef,\n}: {\n  attachment: Attachment;\n  index: number;\n  onRemove: (id: string) => void;\n  onOpen: (attachment: Attachment, rect: DOMRect) => void;\n  registerRef: (id: string, el: HTMLButtonElement | null) => void;\n}) {\n  const [isHovered, setIsHovered] = useState(false);\n  const btnRef = useRef<HTMLButtonElement>(null);\n\n  return (\n    <button\n      ref={(el) => {\n        btnRef.current = el;\n        registerRef(attachment.id, el);\n      }}\n      type=\"button\"\n      onMouseDown={(e) => e.preventDefault()}\n      onMouseEnter={() => setIsHovered(true)}\n      onMouseLeave={() => setIsHovered(false)}\n      onClick={(e) => {\n        e.stopPropagation();\n        if (btnRef.current) {\n          onOpen(attachment, btnRef.current.getBoundingClientRect());\n        }\n      }}\n      style={{\n        animationDelay: `${index * 35}ms`,\n        animationFillMode: \"backwards\",\n      }}\n      className={cn(\n        \"group relative size-12 shrink-0 overflow-hidden rounded-xl border border-zinc-200 bg-zinc-100 dark:border-white/10 dark:bg-zinc-900/90 outline-none\",\n        \"transition-transform duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] hover:scale-[1.04] active:scale-[0.96]\",\n        \"animate-in fade-in slide-in-from-top-3 zoom-in-90 duration-300\",\n      )}\n      aria-label={`Open preview of ${attachment.name}`}\n    >\n      <img\n        src={attachment.url}\n        alt={attachment.name}\n        className=\"size-full object-cover\"\n        draggable={false}\n      />\n      <span\n        className={cn(\n          \"absolute inset-0 flex items-start justify-end bg-black/0 transition-colors duration-200\",\n          isHovered && \"bg-black/30\",\n        )}\n      >\n        <span\n          role=\"button\"\n          tabIndex={-1}\n          onMouseDown={(e) => {\n            e.preventDefault();\n            e.stopPropagation();\n          }}\n          onClick={(e) => {\n            e.stopPropagation();\n            onRemove(attachment.id);\n          }}\n          className={cn(\n            \"m-1 flex size-4 items-center justify-center rounded-full bg-zinc-200/90 text-zinc-600 dark:bg-zinc-950/90 dark:text-zinc-300 shadow-sm transition-all duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] hover:bg-zinc-900 hover:text-white dark:hover:bg-white dark:hover:text-black hover:scale-110\",\n            isHovered\n              ? \"opacity-100 scale-100\"\n              : \"opacity-0 scale-50 pointer-events-none\",\n          )}\n          aria-label={`Remove ${attachment.name}`}\n        >\n          <CloseIcon />\n        </span>\n      </span>\n    </button>\n  );\n}\n\nfunction AttachmentGalleryModal({\n  attachment,\n  originRect,\n  onClose,\n}: {\n  attachment: Attachment;\n  originRect: DOMRect;\n  onClose: () => void;\n}) {\n  const [phase, setPhase] = useState<\"opening\" | \"open\" | \"closing\">(\"opening\");\n\n  const targetGeometry = React.useMemo(() => {\n    if (typeof window === \"undefined\") {\n      return { top: 0, left: 0, width: 300, height: 200, radius: 20 };\n    }\n    const maxW = Math.min(window.innerWidth * 0.88, 620);\n    const maxH = Math.min(window.innerHeight * 0.82, 760);\n\n    const naturalW = attachment.width || 800;\n    const naturalH = attachment.height || 600;\n    const scale = Math.min(maxW / naturalW, maxH / naturalH, 1.5);\n\n    const width = naturalW * scale;\n    const height = naturalH * scale;\n\n    return {\n      top: (window.innerHeight - height) / 2,\n      left: (window.innerWidth - width) / 2,\n      width,\n      height,\n      radius: 20,\n    };\n  }, [attachment.width, attachment.height]);\n\n  useEffect(() => {\n    const raf = requestAnimationFrame(() => setPhase(\"open\"));\n    return () => cancelAnimationFrame(raf);\n  }, []);\n\n  const handleClose = useCallback(() => setPhase(\"closing\"), []);\n\n  useEffect(() => {\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") handleClose();\n    };\n    document.addEventListener(\"keydown\", onKey);\n    return () => document.removeEventListener(\"keydown\", onKey);\n  }, [handleClose]);\n\n  const isOpen = phase === \"open\";\n  const isClosing = phase === \"closing\";\n\n  const geometry = isOpen\n    ? targetGeometry\n    : {\n        top: originRect.top,\n        left: originRect.left,\n        width: originRect.width,\n        height: originRect.height,\n        radius: 12,\n      };\n\n  const animEasing = isClosing ? \"ease-out\" : \"cubic-bezier(0.16, 1, 0.3, 1)\";\n  const animDur = isClosing ? \"0.26s\" : \"0.38s\";\n  const flipTransition = `top ${animDur} ${animEasing}, left ${animDur} ${animEasing}, width ${animDur} ${animEasing}, height ${animDur} ${animEasing}, border-radius ${animDur} ${animEasing}`;\n\n  return createPortal(\n    <div\n      className=\"fixed inset-0 z-100 flex items-center justify-center\"\n      onClick={handleClose}\n      role=\"dialog\"\n      aria-modal=\"true\"\n    >\n      <div\n        className=\"absolute inset-0 bg-black/75 backdrop-blur-md transition-opacity duration-300\"\n        style={{ opacity: isOpen ? 1 : 0 }}\n      />\n      <div\n        style={{\n          position: \"fixed\",\n          top: geometry.top,\n          left: geometry.left,\n          width: geometry.width,\n          height: geometry.height,\n          borderRadius: geometry.radius,\n          transition: flipTransition,\n          overflow: \"hidden\",\n          boxShadow: isOpen\n            ? \"0 28px 70px -15px rgba(0, 0, 0, 0.7)\"\n            : \"0 0 0 0 transparent\",\n        }}\n        className=\"bg-zinc-100 border border-zinc-200 dark:bg-zinc-950 dark:border-white/10\"\n        onTransitionEnd={() => {\n          if (phase === \"closing\") onClose();\n        }}\n        onClick={(e) => e.stopPropagation()}\n      >\n        <img\n          src={attachment.url}\n          alt={attachment.name}\n          className=\"size-full object-cover select-none\"\n          draggable={false}\n        />\n      </div>\n\n      <button\n        type=\"button\"\n        onClick={handleClose}\n        style={{\n          opacity: isOpen ? 1 : 0,\n          transform: isOpen ? \"scale(1)\" : \"scale(0.8)\",\n        }}\n        className={cn(\n          \"fixed right-5 top-5 flex size-9 items-center justify-center rounded-full bg-white/90 text-zinc-700 dark:bg-zinc-900/90 dark:text-zinc-300 shadow-lg border border-zinc-200 dark:border-white/10 backdrop-blur-sm\",\n          \"transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-zinc-800 dark:hover:text-white\",\n          !isOpen && \"pointer-events-none\",\n        )}\n        aria-label=\"Close image preview\"\n      >\n        <span className=\"scale-125\">\n          <CloseIcon />\n        </span>\n      </button>\n    </div>,\n    document.body,\n  );\n}\n\nexport const PromptInput = React.forwardRef<PromptInputRef, PromptInputProps>(\n  (\n    {\n      onSubmit,\n      placeholder = \"Ask anything\",\n      className,\n      models = DEFAULT_MODELS,\n      selectedModel: controlledModel,\n      onModelChange,\n      efforts = DEFAULT_EFFORTS,\n      selectedEffort: controlledEffort,\n      onEffortChange,\n      defaultValue = \"\",\n      value: controlledValue,\n      onChange,\n      expanded: controlledExpanded,\n      onExpandedChange,\n      maxAttachments = 6,\n      allowAttachments = true,\n      allowVoice = true,\n      allowModelSelect = true,\n      allowEffortSelect = true,\n      minWidth = 320,\n      maxWidth = 480,\n      collapsedHeight = 48,\n      autoFocus = false,\n      disabled = false,\n      accentColor,\n      variant = \"default\",\n      modelIcons,\n    },\n    ref,\n  ) => {\n    const inputId = useId();\n    const mounted = useSyncExternalStore(\n      emptySubscribe,\n      () => true,\n      () => false,\n    );\n\n    const normalizedModels: ModelOption[] = React.useMemo(() => {\n      return models.map((m) => {\n        if (typeof m === \"string\") {\n          return { name: m, icon: modelIcons?.[m] };\n        }\n        return { name: m.name, icon: m.icon ?? modelIcons?.[m.name] };\n      });\n    }, [models, modelIcons]);\n\n    const isControlledModel = controlledModel !== undefined;\n    const [internalModel, setInternalModel] = useState<string>(\n      controlledModel || (normalizedModels[0]?.name ?? \"GPT 5.5\"),\n    );\n    const activeModel = isControlledModel ? controlledModel : internalModel;\n\n    const isControlledEffort = controlledEffort !== undefined;\n    const [internalEffortIndex, setInternalEffortIndex] = useState(1);\n    const currentEffort = isControlledEffort\n      ? controlledEffort\n      : efforts[internalEffortIndex] || efforts[0] || \"Medium\";\n\n    const [isSmoothResize, setIsSmoothResize] = useState(false);\n    const [localValue, setLocalValue] = useState(defaultValue);\n    const [isModelSelectOpen, setIsModelSelectOpen] = useState(false);\n\n    const [attachments, setAttachments] = useState<Attachment[]>([]);\n    const [activeAttachment, setActiveAttachment] = useState<{\n      attachment: Attachment;\n      rect: DOMRect;\n    } | null>(null);\n\n    const [isRecording, setIsRecording] = useState(false);\n    const [audioData, setAudioData] = useState<number[]>(new Array(5).fill(0));\n    const valueRef = useRef(\n      controlledValue !== undefined ? controlledValue : localValue,\n    );\n\n    const streamRef = useRef<MediaStream | null>(null);\n    const audioContextRef = useRef<AudioContext | null>(null);\n    const rafRef = useRef<number | null>(null);\n    const recognitionRef = useRef<BrowserSpeechRecognition | null>(null);\n    const demoIntervalRef = useRef<number | null>(null);\n    const demoTextIntervalRef = useRef<number | null>(null);\n\n    const initialModelIdx = Math.max(\n      0,\n      normalizedModels.findIndex((m) => m.name === activeModel),\n    );\n    const [hoverStyle, setHoverStyle] = useState({\n      opacity: 0,\n      transform: `translateY(${initialModelIdx * 34}px) scale(0.96)`,\n      transition: \"none\",\n    });\n\n    const [textareaHeight, setTextareaHeight] = useState(68);\n    const containerHeight = Math.max(116, textareaHeight + 48);\n    const [isScrolling, setIsScrolling] = useState(false);\n\n    const isControlled = controlledValue !== undefined;\n    const value = isControlled ? controlledValue : localValue;\n    const hasAttachments = attachments.length > 0;\n    const hasValue = value.trim() !== \"\" || hasAttachments;\n\n    const isControlledExpanded = controlledExpanded !== undefined;\n    const [internalExpanded, setInternalExpanded] = useState(\n      Boolean(\n        autoFocus ||\n        (defaultValue && defaultValue.trim() !== \"\") ||\n        (controlledValue && controlledValue.trim() !== \"\"),\n      ),\n    );\n    const expanded = isControlledExpanded\n      ? controlledExpanded\n      : internalExpanded || hasValue;\n\n    const textareaRef = useRef<HTMLTextAreaElement>(null);\n    const internalContainerRef = useRef<HTMLDivElement>(null);\n    const topFadeRef = useRef<HTMLDivElement>(null);\n    const bottomFadeRef = useRef<HTMLDivElement>(null);\n    const fileInputRef = useRef<HTMLInputElement>(null);\n    const modelTriggerRef = useRef<HTMLButtonElement>(null);\n    const modelMenuRef = useRef<HTMLDivElement>(null);\n    const modelMenuId = `${inputId}-models`;\n    const thumbRefs = useRef<Map<string, HTMLButtonElement | null>>(new Map());\n\n    useEffect(() => {\n      valueRef.current = value;\n    }, [value]);\n\n    const updateFades = useCallback(() => {\n      const el = textareaRef.current;\n      if (!el) return;\n      const { scrollTop, scrollHeight, clientHeight } = el;\n      if (topFadeRef.current) {\n        topFadeRef.current.style.opacity = Math.min(\n          scrollTop / 20,\n          1,\n        ).toString();\n      }\n      if (bottomFadeRef.current) {\n        const bottomScroll = scrollHeight - clientHeight - scrollTop;\n        bottomFadeRef.current.style.opacity = Math.min(\n          Math.max(bottomScroll - 16, 0) / 10,\n          1,\n        ).toString();\n      }\n    }, []);\n\n    const handleValueChange = useCallback(\n      (val: string) => {\n        setIsSmoothResize(true);\n        if (!isControlled) setLocalValue(val);\n        onChange?.(val);\n        if (val.trim() !== \"\" && !internalExpanded) {\n          setInternalExpanded(true);\n          onExpandedChange?.(true);\n        }\n      },\n      [isControlled, onChange, internalExpanded, onExpandedChange],\n    );\n\n    const expand = useCallback(() => {\n      setIsSmoothResize(false);\n      setInternalExpanded(true);\n      onExpandedChange?.(true);\n    }, [onExpandedChange]);\n\n    const stopRecording = useCallback(() => {\n      if (recognitionRef.current) {\n        try {\n          recognitionRef.current.stop();\n        } catch {}\n        recognitionRef.current = null;\n      }\n      if (rafRef.current) {\n        cancelAnimationFrame(rafRef.current);\n        rafRef.current = null;\n      }\n      if (streamRef.current) {\n        streamRef.current.getTracks().forEach((track) => track.stop());\n        streamRef.current = null;\n      }\n      if (audioContextRef.current) {\n        try {\n          audioContextRef.current.close();\n        } catch {}\n        audioContextRef.current = null;\n      }\n      if (demoIntervalRef.current) {\n        window.clearInterval(demoIntervalRef.current);\n        demoIntervalRef.current = null;\n      }\n      if (demoTextIntervalRef.current) {\n        window.clearInterval(demoTextIntervalRef.current);\n        demoTextIntervalRef.current = null;\n      }\n      setIsRecording(false);\n      setAudioData(new Array(5).fill(0));\n    }, []);\n\n    const startRecording = useCallback(async () => {\n      setIsSmoothResize(false);\n      setInternalExpanded(true);\n      onExpandedChange?.(true);\n\n      let stream: MediaStream | null = null;\n      try {\n        if (\n          typeof navigator !== \"undefined\" &&\n          navigator.mediaDevices?.getUserMedia\n        ) {\n          stream = await navigator.mediaDevices.getUserMedia({ audio: true });\n        }\n      } catch {\n        stream = null;\n      }\n\n      setIsRecording(true);\n\n      const simulateText = () => {\n        const fakeText =\n          \"Build a high performance, accessible AI prompt input with smooth spring transitions.\";\n        const words = fakeText.split(\" \");\n        let i = 0;\n        let currentBase = valueRef.current;\n        demoTextIntervalRef.current = window.setInterval(() => {\n          if (i < words.length) {\n            currentBase = (currentBase ? currentBase + \" \" : \"\") + words[i];\n            handleValueChange(currentBase);\n            i++;\n          } else {\n            stopRecording();\n          }\n        }, 260);\n      };\n\n      if (stream) {\n        streamRef.current = stream;\n        const AudioCtx =\n          window.AudioContext ||\n          (window as unknown as { webkitAudioContext: typeof AudioContext })\n            .webkitAudioContext;\n        const audioCtx = new AudioCtx();\n        audioContextRef.current = audioCtx;\n\n        const analyser = audioCtx.createAnalyser();\n        analyser.fftSize = 64;\n        const source = audioCtx.createMediaStreamSource(stream);\n        source.connect(analyser);\n\n        const dataArray = new Uint8Array(analyser.frequencyBinCount);\n\n        const updateVisualizer = () => {\n          analyser.getByteFrequencyData(dataArray);\n          const bands = new Array(5).fill(0);\n          const step = Math.floor(dataArray.length / 5);\n          for (let i = 0; i < 5; i++) {\n            let sum = 0;\n            for (let j = 0; j < step; j++) {\n              sum += dataArray[i * step + j];\n            }\n            bands[i] = sum / step / 255;\n          }\n          setAudioData(bands);\n          rafRef.current = requestAnimationFrame(updateVisualizer);\n        };\n        updateVisualizer();\n\n        const SpeechRec =\n          (\n            window as unknown as {\n              SpeechRecognition?: BrowserSpeechRecognitionConstructor;\n              webkitSpeechRecognition?: BrowserSpeechRecognitionConstructor;\n            }\n          ).SpeechRecognition ||\n          (\n            window as unknown as {\n              SpeechRecognition?: BrowserSpeechRecognitionConstructor;\n              webkitSpeechRecognition?: BrowserSpeechRecognitionConstructor;\n            }\n          ).webkitSpeechRecognition;\n\n        if (SpeechRec) {\n          const recognition = new SpeechRec();\n          recognition.continuous = true;\n          recognition.interimResults = true;\n\n          let baseline = valueRef.current;\n\n          recognition.onresult = (event: BrowserSpeechRecognitionEvent) => {\n            let interimTranscript = \"\";\n            let finalTranscript = \"\";\n\n            for (let i = event.resultIndex; i < event.results.length; ++i) {\n              if (event.results[i].isFinal) {\n                finalTranscript += event.results[i][0].transcript;\n              } else {\n                interimTranscript += event.results[i][0].transcript;\n              }\n            }\n\n            if (finalTranscript) {\n              baseline += (baseline ? \" \" : \"\") + finalTranscript;\n            }\n\n            handleValueChange(\n              (\n                baseline + (interimTranscript ? \" \" + interimTranscript : \"\")\n              ).trim(),\n            );\n          };\n\n          recognition.onerror = () => {\n            stopRecording();\n          };\n\n          recognition.onend = () => {\n            stopRecording();\n          };\n\n          recognitionRef.current = recognition;\n          try {\n            recognition.start();\n          } catch {\n            simulateText();\n          }\n        } else {\n          simulateText();\n        }\n      } else {\n        demoIntervalRef.current = window.setInterval(() => {\n          setAudioData(\n            Array.from({ length: 5 }, () => Math.random() * 0.75 + 0.15),\n          );\n        }, 90);\n        simulateText();\n      }\n    }, [handleValueChange, stopRecording, onExpandedChange]);\n\n    useEffect(() => {\n      if (isRecording && textareaRef.current) {\n        textareaRef.current.scrollTop = textareaRef.current.scrollHeight;\n      }\n    }, [value, isRecording]);\n\n    useEffect(() => {\n      return () => {\n        stopRecording();\n        attachments.forEach((a) => URL.revokeObjectURL(a.url));\n      };\n    }, [stopRecording, attachments]);\n\n    useEffect(() => {\n      if (expanded && !isRecording) {\n        const timer = setTimeout(() => {\n          if (textareaRef.current) {\n            textareaRef.current.focus();\n            const length = textareaRef.current.value.length;\n            textareaRef.current.setSelectionRange(length, length);\n          }\n        }, 40);\n        return () => clearTimeout(timer);\n      }\n    }, [expanded, isRecording]);\n\n    useEffect(() => {\n      const el = textareaRef.current;\n      if (!el) return;\n\n      el.style.height = \"auto\";\n      const scrollHeight = el.scrollHeight;\n      const newHeight = Math.max(68, Math.min(scrollHeight, 180));\n      el.style.height = `${newHeight}px`;\n\n      setTextareaHeight(newHeight);\n      setIsScrolling(scrollHeight > 180);\n      updateFades();\n    }, [value, expanded, updateFades]);\n\n    const closeModelSelect = useCallback(() => {\n      const restoreFocus = modelMenuRef.current?.contains(\n        document.activeElement,\n      );\n      setIsModelSelectOpen(false);\n      if (restoreFocus) modelTriggerRef.current?.focus();\n    }, []);\n\n    useEffect(() => {\n      if (!isModelSelectOpen) return;\n\n      const handleOutsideClick = (e: MouseEvent) => {\n        if (\n          internalContainerRef.current &&\n          !internalContainerRef.current.contains(e.target as Node)\n        ) {\n          setIsModelSelectOpen(false);\n        }\n      };\n      const handleEscape = (e: KeyboardEvent) => {\n        if (e.key !== \"Escape\") return;\n        e.preventDefault();\n        e.stopPropagation();\n        closeModelSelect();\n      };\n      document.addEventListener(\"mousedown\", handleOutsideClick);\n      window.addEventListener(\"keydown\", handleEscape, true);\n      return () => {\n        document.removeEventListener(\"mousedown\", handleOutsideClick);\n        window.removeEventListener(\"keydown\", handleEscape, true);\n      };\n    }, [isModelSelectOpen, closeModelSelect]);\n\n    const handleBlur = (e: React.FocusEvent<HTMLDivElement>) => {\n      if (\n        internalContainerRef.current &&\n        internalContainerRef.current.contains(e.relatedTarget as Node)\n      ) {\n        return;\n      }\n      if (value.trim() === \"\" && !hasAttachments && !isRecording) {\n        setIsSmoothResize(false);\n        setInternalExpanded(false);\n        onExpandedChange?.(false);\n        setIsModelSelectOpen(false);\n      }\n    };\n\n    const handleSubmit = () => {\n      if (value.trim() === \"\" && !hasAttachments) return;\n      setIsSmoothResize(false);\n      onSubmit?.(value, {\n        model: activeModel,\n        effort: currentEffort,\n        attachments: attachments.map((a) => a.file),\n      });\n      handleValueChange(\"\");\n      attachments.forEach((a) => URL.revokeObjectURL(a.url));\n      setAttachments([]);\n      setInternalExpanded(false);\n      onExpandedChange?.(false);\n      setIsModelSelectOpen(false);\n    };\n\n    const cycleEffort = (e: React.MouseEvent) => {\n      e.stopPropagation();\n      const nextIdx = (internalEffortIndex + 1) % efforts.length;\n      if (!isControlledEffort) {\n        setInternalEffortIndex(nextIdx);\n      }\n      onEffortChange?.(efforts[nextIdx]);\n    };\n\n    const selectModel = (modelName: string) => {\n      if (!isControlledModel) {\n        setInternalModel(modelName);\n      }\n      onModelChange?.(modelName);\n      closeModelSelect();\n    };\n\n    const activeModelIndex = Math.max(\n      0,\n      normalizedModels.findIndex((m) => m.name === activeModel),\n    );\n\n    const highlightModel = (idx: number) => {\n      setHoverStyle({\n        opacity: 1,\n        transform: `translateY(${idx * 34}px) scale(1)`,\n        transition:\n          \"transform 0.25s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.15s ease\",\n      });\n    };\n\n    const focusModelOption = (idx: number) => {\n      const options = modelMenuRef.current?.querySelectorAll<HTMLElement>(\n        '[role=\"menuitemradio\"]',\n      );\n      if (!options?.length) return;\n      options[(idx + options.length) % options.length].focus();\n    };\n\n    const openModelSelectWithFocus = () => {\n      setIsModelSelectOpen(true);\n      requestAnimationFrame(() => focusModelOption(activeModelIndex));\n    };\n\n    const handleModelMenuKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n      const options = Array.from(\n        e.currentTarget.querySelectorAll<HTMLElement>('[role=\"menuitemradio\"]'),\n      );\n      const current = options.indexOf(document.activeElement as HTMLElement);\n      if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n        e.preventDefault();\n        focusModelOption(current + (e.key === \"ArrowDown\" ? 1 : -1));\n      } else if (e.key === \"Home\" || e.key === \"End\") {\n        e.preventDefault();\n        focusModelOption(e.key === \"Home\" ? 0 : options.length - 1);\n      } else if (e.key === \"Tab\") {\n        setIsModelSelectOpen(false);\n      }\n    };\n\n    const openFileChooser = (e: React.MouseEvent) => {\n      e.stopPropagation();\n      fileInputRef.current?.click();\n    };\n\n    const addAttachment = useCallback(\n      (file: File, url: string, width = 800, height = 600) => {\n        const id = `${file.name}-${file.lastModified || Date.now()}-${Math.random().toString(36).slice(2, 8)}`;\n        setAttachments((prev) => [\n          ...prev,\n          { id, file, url, name: file.name, width, height },\n        ]);\n        setInternalExpanded(true);\n        onExpandedChange?.(true);\n      },\n      [onExpandedChange],\n    );\n\n    const handleFilesChosen = async (\n      e: React.ChangeEvent<HTMLInputElement>,\n    ) => {\n      const files = Array.from(e.target.files ?? []).filter((f) =>\n        f.type.startsWith(\"image/\"),\n      );\n      e.target.value = \"\";\n\n      if (files.length === 0) return;\n      const room = Math.max(0, maxAttachments - attachments.length);\n      const accepted = files.slice(0, room);\n\n      if (!expanded) {\n        setIsSmoothResize(false);\n        setInternalExpanded(true);\n        onExpandedChange?.(true);\n      } else {\n        setIsSmoothResize(true);\n      }\n\n      for (const file of accepted) {\n        const url = URL.createObjectURL(file);\n        const img = new Image();\n        img.onload = () =>\n          addAttachment(file, url, img.naturalWidth, img.naturalHeight);\n        img.onerror = () => addAttachment(file, url, 800, 600);\n        img.src = url;\n      }\n    };\n\n    React.useImperativeHandle(\n      ref,\n      () => ({\n        focus: () => textareaRef.current?.focus(),\n        expand: () => {\n          setIsSmoothResize(false);\n          setInternalExpanded(true);\n          onExpandedChange?.(true);\n        },\n        collapse: () => {\n          setIsSmoothResize(false);\n          setInternalExpanded(false);\n          onExpandedChange?.(false);\n        },\n        startVoice: () => {\n          startRecording();\n        },\n        stopVoice: () => {\n          stopRecording();\n        },\n        addAttachment: (file: File, url: string, width = 800, height = 600) => {\n          addAttachment(file, url, width, height);\n        },\n        clear: () => {\n          handleValueChange(\"\");\n          attachments.forEach((a) => URL.revokeObjectURL(a.url));\n          setAttachments([]);\n          setInternalExpanded(false);\n          onExpandedChange?.(false);\n        },\n        element: internalContainerRef.current,\n      }),\n      [\n        startRecording,\n        stopRecording,\n        addAttachment,\n        handleValueChange,\n        attachments,\n        onExpandedChange,\n      ],\n    );\n\n    const removeAttachment = (id: string) => {\n      setIsSmoothResize(true);\n      setAttachments((prev) => {\n        const target = prev.find((a) => a.id === id);\n        if (target) URL.revokeObjectURL(target.url);\n        return prev.filter((a) => a.id !== id);\n      });\n      thumbRefs.current.delete(id);\n    };\n\n    const showArrow = hasValue && !isRecording;\n    const showStop = isRecording;\n    const showMic = !hasValue && !isRecording;\n\n    const onActionButtonClick = (e: React.MouseEvent) => {\n      e.preventDefault();\n      if (disabled) return;\n      if (isRecording) {\n        stopRecording();\n      } else if (hasValue) {\n        handleSubmit();\n      } else if (allowVoice) {\n        startRecording();\n      }\n    };\n\n    const resolvedMinW =\n      typeof minWidth === \"number\" ? `${minWidth}px` : minWidth;\n    const resolvedMaxW =\n      typeof maxWidth === \"number\" ? `${maxWidth}px` : maxWidth;\n\n    const customGlowShadow =\n      variant === \"glow\"\n        ? accentColor\n          ? `0 0 35px -5px ${accentColor}33, 0 16px 40px rgba(0, 0, 0, 0.65)`\n          : \"0 0 35px -5px rgba(255, 255, 255, 0.12), 0 16px 40px rgba(0, 0, 0, 0.65)\"\n        : undefined;\n\n    return (\n      <>\n        <div\n          ref={(node) => {\n            (\n              internalContainerRef as React.MutableRefObject<HTMLDivElement | null>\n            ).current = node;\n          }}\n          onBlur={handleBlur}\n          className={cn(\n            \"relative flex flex-col w-full items-center\",\n            className,\n          )}\n          style={{\n            maxWidth: expanded\n              ? `min(100%, ${resolvedMaxW})`\n              : `min(100%, ${resolvedMinW})`,\n            transition: isSmoothResize\n              ? \"max-width 0.16s ease-out\"\n              : \"max-width 0.42s cubic-bezier(0.16, 1, 0.3, 1)\",\n          }}\n        >\n          {allowAttachments && (\n            <input\n              ref={fileInputRef}\n              type=\"file\"\n              accept=\"image/*\"\n              multiple\n              disabled={disabled}\n              onChange={handleFilesChosen}\n              className=\"hidden\"\n              tabIndex={-1}\n              aria-hidden=\"true\"\n            />\n          )}\n\n          <div\n            aria-hidden={!hasAttachments}\n            style={{\n              height: hasAttachments && expanded ? 68 : 0,\n              transition: isSmoothResize\n                ? \"height 0.16s ease-out\"\n                : \"height 0.42s cubic-bezier(0.16, 1, 0.3, 1)\",\n            }}\n            className=\"w-full relative z-0 overflow-hidden\"\n          >\n            <div\n              style={{\n                position: \"absolute\",\n                bottom: -8,\n                left: 16,\n                right: 16,\n                height: 68,\n                transform:\n                  hasAttachments && expanded\n                    ? \"translateY(0)\"\n                    : \"translateY(100%)\",\n                opacity: hasAttachments && expanded ? 1 : 0,\n                transition: isSmoothResize\n                  ? \"transform 0.16s ease-out, opacity 0.16s ease-out\"\n                  : \"transform 0.42s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s ease-out\",\n              }}\n              className=\"border border-zinc-200 dark:border-white/10 border-b-0 bg-zinc-50/95 dark:bg-[#161619]/95 backdrop-blur-xl rounded-t-2xl px-2.5 pt-2 pb-1 flex items-start gap-2 overflow-x-auto prompt-scrollbar\"\n            >\n              {attachments.map((attachment, index) => (\n                <AttachmentThumb\n                  key={attachment.id}\n                  attachment={attachment}\n                  index={index}\n                  onRemove={removeAttachment}\n                  onOpen={(a, rect) =>\n                    setActiveAttachment({ attachment: a, rect })\n                  }\n                  registerRef={(id, el) => thumbRefs.current.set(id, el)}\n                />\n              ))}\n            </div>\n          </div>\n\n          <div\n            onMouseDown={(e) => {\n              const isTextarea = e.target === textareaRef.current;\n              if (expanded && !isTextarea && !isRecording && !disabled) {\n                e.preventDefault();\n                textareaRef.current?.focus();\n              }\n            }}\n            style={{\n              borderRadius: 24,\n              height: expanded ? containerHeight : collapsedHeight,\n              transition: isSmoothResize\n                ? SMOOTH_HEIGHT_TRANSITION\n                : SPRING_TRANSITION,\n              overflow: expanded ? \"visible\" : \"hidden\",\n              boxShadow: customGlowShadow,\n            }}\n            className={cn(\n              \"relative w-full border bg-white/95 dark:bg-[#0c0c0e]/95 backdrop-blur-xl shadow-[0_4px_20px_rgba(0,0,0,0.08)] dark:shadow-[0_16px_40px_rgba(0,0,0,0.6)] z-10 transition-colors duration-200\",\n              variant === \"glow\"\n                ? \"border-zinc-300 dark:border-white/20\"\n                : variant === \"minimal\"\n                  ? \"border-zinc-200 dark:border-white/8 dark:bg-[#09090b]/90\"\n                  : \"border-zinc-200 dark:border-white/12\",\n              \"focus-within:border-zinc-400 focus-within:ring-1 focus-within:ring-zinc-300 dark:focus-within:border-white/30 dark:focus-within:ring-white/20 hover:border-zinc-300 dark:hover:border-white/20\",\n              expanded ? \"cursor-text\" : \"cursor-default\",\n              disabled && \"opacity-60 pointer-events-none\",\n            )}\n          >\n\n            <textarea\n              id={inputId}\n              ref={textareaRef}\n              value={value}\n              onChange={(e) => handleValueChange(e.target.value)}\n              onScroll={updateFades}\n              onKeyDown={(e) => {\n                if (e.key === \"Enter\" && !e.shiftKey) {\n                  e.preventDefault();\n                  handleSubmit();\n                }\n                if (\n                  e.key === \"Escape\" &&\n                  value.trim() === \"\" &&\n                  !hasAttachments &&\n                  !isModelSelectOpen\n                ) {\n                  setIsSmoothResize(false);\n                  setInternalExpanded(false);\n                  onExpandedChange?.(false);\n                }\n              }}\n              placeholder={placeholder}\n              aria-label=\"Prompt message\"\n              disabled={isRecording || disabled}\n              style={{\n                transition: isSmoothResize\n                  ? \"height 0.16s ease-out\"\n                  : \"opacity 0.25s ease-out, transform 0.25s ease-out, height 0.42s cubic-bezier(0.16, 1, 0.3, 1)\",\n              }}\n              className={cn(\n                \"prompt-scrollbar absolute top-0 inset-x-0 z-1 w-full resize-none bg-transparent pl-4 pr-12 py-3.5 text-sm leading-5.5 text-zinc-900 dark:text-zinc-100 outline-none placeholder:font-medium placeholder:text-zinc-400 dark:placeholder:text-zinc-500 cursor-text\",\n                expanded\n                  ? \"opacity-100 scale-100 translate-y-0\"\n                  : \"opacity-0 scale-95 -translate-y-1 pointer-events-none\",\n                isScrolling ? \"overflow-y-auto\" : \"overflow-y-hidden\",\n                isRecording && \"pointer-events-none\",\n              )}\n            />\n\n            <div\n              ref={topFadeRef}\n              className=\"absolute left-4 right-12 top-0 z-2 h-7 bg-linear-to-b from-white dark:from-[#0c0c0e] via-white/85 dark:via-[#0c0c0e]/85 to-transparent pointer-events-none opacity-0 transition-opacity duration-150\"\n            />\n            <div\n              ref={bottomFadeRef}\n              className=\"absolute left-4 right-12 z-2 h-7 bg-linear-to-t from-white dark:from-[#0c0c0e] via-white/85 dark:via-[#0c0c0e]/85 to-transparent pointer-events-none opacity-0\"\n              style={{\n                top: `${textareaHeight - 28}px`,\n                transition: isSmoothResize\n                  ? \"top 0.16s ease-out, opacity 0.15s ease-out\"\n                  : \"top 0.42s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.15s ease-out\",\n              }}\n            />\n\n            <button\n              type=\"button\"\n              onClick={expand}\n              style={{\n                transition: isSmoothResize\n                  ? \"none\"\n                  : \"all 0.38s cubic-bezier(0.16, 1, 0.3, 1)\",\n              }}\n              className={cn(\n                \"absolute inset-x-0 top-0 z-1 cursor-text pl-4 pr-12 py-3.75 text-left text-sm font-medium leading-4.25 text-zinc-400 dark:text-zinc-500 outline-none select-none\",\n                !expanded\n                  ? \"opacity-100 scale-100 translate-y-0\"\n                  : \"opacity-0 scale-105 translate-y-1 pointer-events-none\",\n              )}\n              aria-label=\"Open prompt input\"\n            >\n              {placeholder}\n            </button>\n\n            <div\n              className={cn(\n                \"absolute bottom-2 left-3 right-12 z-10 flex items-center gap-1 transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]\",\n                expanded && !isRecording\n                  ? \"opacity-100 blur-0 translate-y-0 pointer-events-auto\"\n                  : \"opacity-0 blur-sm translate-y-2 pointer-events-none\",\n              )}\n            >\n              {allowModelSelect && (\n                <div className=\"relative\">\n                  <button\n                    ref={modelTriggerRef}\n                    type=\"button\"\n                    onMouseDown={(e) => e.preventDefault()}\n                    onClick={(e) => {\n                      e.stopPropagation();\n                      const activeIdx = normalizedModels.findIndex(\n                        (m) => m.name === activeModel,\n                      );\n                      if (activeIdx !== -1) {\n                        setHoverStyle({\n                          opacity: 1,\n                          transform: `translateY(${activeIdx * 34}px) scale(1)`,\n                          transition: \"none\",\n                        });\n                      }\n                      if (isModelSelectOpen) {\n                        setIsModelSelectOpen(false);\n                      } else if (e.detail === 0) {\n                        openModelSelectWithFocus();\n                      } else {\n                        setIsModelSelectOpen(true);\n                      }\n                    }}\n                    onKeyDown={(e) => {\n                      if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n                        e.preventDefault();\n                        openModelSelectWithFocus();\n                      }\n                    }}\n                    aria-haspopup=\"menu\"\n                    aria-expanded={isModelSelectOpen}\n                    aria-controls={modelMenuId}\n                    className={cn(\n                      \"group relative flex items-center gap-1.5 rounded-full px-2.5 py-1 text-zinc-500 dark:text-zinc-400 transition-all duration-200 outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 dark:focus-visible:ring-white/40 after:absolute after:-inset-y-2.5 after:inset-x-0 hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-white/10 dark:hover:text-zinc-100 cursor-pointer select-none\",\n                      isModelSelectOpen ? \"bg-zinc-100 text-zinc-900 dark:bg-white/10 dark:text-white\" : \"\",\n                    )}\n                    aria-label={`Select model. Current: ${activeModel}`}\n                  >\n                    <ModelIcon\n                      model={activeModel}\n                      customIcon={\n                        normalizedModels.find((m) => m.name === activeModel)\n                          ?.icon\n                      }\n                      className=\"size-3.5 opacity-80 group-hover:opacity-100 transition-opacity\"\n                    />\n                    <span className=\"text-xs font-medium select-none transition-colors\">\n                      <MorphingText text={activeModel} />\n                    </span>\n                  </button>\n\n                  <div\n                    ref={modelMenuRef}\n                    id={modelMenuId}\n                    role=\"menu\"\n                    aria-label=\"Models\"\n                    inert={!isModelSelectOpen}\n                    onKeyDown={handleModelMenuKeyDown}\n                    style={{ transformOrigin: \"bottom left\" }}\n                    onMouseLeave={() => {\n                      const activeIdx = normalizedModels.findIndex(\n                        (m) => m.name === activeModel,\n                      );\n                      if (activeIdx !== -1) {\n                        setHoverStyle({\n                          opacity: 1,\n                          transform: `translateY(${activeIdx * 34}px) scale(1)`,\n                          transition:\n                            \"transform 0.25s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease\",\n                        });\n                      } else {\n                        setHoverStyle((prev) => ({\n                          ...prev,\n                          opacity: 0,\n                          transform: prev.transform.replace(\n                            \"scale(1)\",\n                            \"scale(0.96)\",\n                          ),\n                          transition:\n                            \"opacity 0.2s ease-in, transform 0.2s ease-out\",\n                        }));\n                      }\n                    }}\n                    className={cn(\n                      \"absolute bottom-full left-0 mb-2.5 z-50 w-48 rounded-2xl border border-zinc-200 dark:border-white/10 bg-white/95 dark:bg-[#121215]/95 p-1 shadow-xl dark:shadow-2xl backdrop-blur-2xl flex flex-col gap-0.5 transition-all duration-300 select-none\",\n                      isModelSelectOpen\n                        ? \"opacity-100 scale-100 translate-y-0 pointer-events-auto ease-[cubic-bezier(0.16,1,0.3,1)]\"\n                        : \"opacity-0 scale-95 translate-y-2 pointer-events-none ease-[cubic-bezier(0.16,1,0.3,1)]\",\n                    )}\n                  >\n                    <div className=\"relative flex flex-col gap-0.5\">\n                      <div\n                        style={hoverStyle}\n                        className=\"absolute left-0 right-0 top-0 h-8 -z-10 rounded-xl bg-zinc-100 dark:bg-white/10 pointer-events-none\"\n                      />\n                      {normalizedModels.map((m, idx) => {\n                        const isSelected = m.name === activeModel;\n                        return (\n                          <button\n                            key={m.name}\n                            type=\"button\"\n                            role=\"menuitemradio\"\n                            aria-checked={isSelected}\n                            tabIndex={-1}\n                            onMouseDown={(e) => e.preventDefault()}\n                            onMouseEnter={() => highlightModel(idx)}\n                            onFocus={() => highlightModel(idx)}\n                            onClick={(e) => {\n                              e.stopPropagation();\n                              selectModel(m.name);\n                            }}\n                            className={cn(\n                              \"group relative flex h-8 w-full items-center justify-between rounded-xl px-2.5 py-1.5 text-left text-xs font-medium transition-colors outline-none active:scale-[0.98] cursor-pointer\",\n                              isSelected\n                                ? \"text-zinc-900 font-semibold dark:text-white\"\n                                : \"text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-200\",\n                            )}\n                          >\n                            <span className=\"flex items-center gap-2\">\n                              <ModelIcon\n                                model={m.name}\n                                customIcon={m.icon}\n                                className={cn(\n                                  \"size-3.5 transition-opacity\",\n                                  isSelected\n                                    ? \"opacity-100\"\n                                    : \"opacity-75 group-hover:opacity-100\",\n                                )}\n                              />\n                              <span>{m.name}</span>\n                            </span>\n                            {isSelected && (\n                              <span className=\"text-zinc-900 dark:text-white\">\n                                <CheckIcon />\n                              </span>\n                            )}\n                          </button>\n                        );\n                      })}\n                    </div>\n                  </div>\n                </div>\n              )}\n\n              {allowEffortSelect && (\n                <button\n                  type=\"button\"\n                  onMouseDown={(e) => e.preventDefault()}\n                  onClick={cycleEffort}\n                  className=\"group relative flex items-center gap-1.5 rounded-full px-2.5 py-1 text-zinc-500 dark:text-zinc-400 transition-all duration-200 hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-white/10 dark:hover:text-zinc-100 outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 dark:focus-visible:ring-white/40 after:absolute after:-inset-y-2.5 after:inset-x-0 cursor-pointer select-none\"\n                  aria-label={`Cycle effort level. Current: ${currentEffort}`}\n                >\n                  <DynamicBarsIcon level={currentEffort} />\n                  <span className=\"text-xs font-medium select-none transition-colors\">\n                    <MorphingText text={currentEffort} />\n                  </span>\n                </button>\n              )}\n\n              {allowAttachments && (\n                <button\n                  type=\"button\"\n                  onMouseDown={(e) => e.preventDefault()}\n                  onClick={openFileChooser}\n                  disabled={attachments.length >= maxAttachments || disabled}\n                  className=\"relative ml-auto flex size-7 items-center justify-center rounded-full text-zinc-500 dark:text-zinc-400 transition-all duration-200 hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-white/10 dark:hover:text-zinc-100 outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 dark:focus-visible:ring-white/40 after:absolute after:-inset-y-2 after:-left-2 after:right-0 cursor-pointer disabled:opacity-40 disabled:pointer-events-none select-none\"\n                  aria-label=\"Attach images\"\n                >\n                  <PlusIcon />\n                </button>\n              )}\n            </div>\n\n            <div\n              className={cn(\n                \"absolute right-12 bottom-2 z-10 flex h-8 items-center justify-end gap-0.75 transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]\",\n                isRecording\n                  ? \"w-16 opacity-100 translate-x-0\"\n                  : \"w-0 opacity-0 translate-x-4 pointer-events-none\",\n              )}\n            >\n              {audioData.map((val, i) => (\n                <div\n                  key={i}\n                  className={cn(\n                    \"w-1 rounded-full transition-[height] duration-75 ease-out\",\n                    !accentColor && \"bg-zinc-900 dark:bg-white\",\n                  )}\n                  style={{\n                    height: `${Math.max(4, val * 24)}px`,\n                    ...(accentColor ? { backgroundColor: accentColor } : {}),\n                  }}\n                />\n              ))}\n            </div>\n\n            <button\n              type=\"button\"\n              onMouseDown={(e) => {\n                e.preventDefault();\n                e.stopPropagation();\n              }}\n              onClick={onActionButtonClick}\n              disabled={disabled}\n              aria-label={\n                showArrow\n                  ? \"Send prompt\"\n                  : showStop\n                    ? \"Stop recording\"\n                    : \"Use voice input\"\n              }\n              style={{\n                backgroundColor: accentColor || undefined,\n                color: accentColor ? \"#000000\" : undefined,\n              }}\n              className=\"absolute right-2 bottom-2 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-zinc-900 text-white dark:bg-white dark:text-black transition-all duration-300 hover:opacity-90 active:scale-95 outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 dark:focus-visible:ring-white/40 after:absolute after:-inset-1.5 cursor-pointer shadow-sm\"\n            >\n              <span className=\"relative flex h-full w-full items-center justify-center\">\n                <span\n                  className={cn(\n                    \"absolute inset-0 flex items-center justify-center transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]\",\n                    showArrow\n                      ? \"opacity-100 scale-100 rotate-0 blur-none\"\n                      : \"opacity-0 scale-50 rotate-45 blur-[1px] pointer-events-none\",\n                  )}\n                >\n                  <ArrowUpIcon />\n                </span>\n                <span\n                  className={cn(\n                    \"absolute inset-0 flex items-center justify-center transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]\",\n                    showMic\n                      ? \"opacity-100 scale-100 rotate-0 blur-none\"\n                      : \"opacity-0 scale-50 -rotate-45 blur-[1px] pointer-events-none\",\n                  )}\n                >\n                  <MicIcon />\n                </span>\n                <span\n                  className={cn(\n                    \"absolute inset-0 flex items-center justify-center transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]\",\n                    showStop\n                      ? \"opacity-100 scale-100 rotate-0 blur-none\"\n                      : \"opacity-0 scale-50 rotate-45 blur-[1px] pointer-events-none\",\n                  )}\n                >\n                  <StopIcon />\n                </span>\n              </span>\n            </button>\n          </div>\n        </div>\n\n        {mounted && activeAttachment && (\n          <AttachmentGalleryModal\n            attachment={activeAttachment.attachment}\n            originRect={activeAttachment.rect}\n            onClose={() => setActiveAttachment(null)}\n          />\n        )}\n      </>\n    );\n  },\n);\n\nPromptInput.displayName = \"PromptInput\";\n\nexport const AiInput = PromptInput;\nexport default PromptInput;\n",
      "type": "registry:ui",
      "target": "@ui/ai-input.tsx"
    }
  ]
}
