{
  "slug": "ai-input",
  "name": "AI Input",
  "description": "Ultra-smooth expandable AI prompt composer with model selection, effort control, attachments tray, and voice visualizer.",
  "summary": "An ultra-smooth AI prompt input component engineered with cubic-bezier spring physics, dynamic morphing text width transition, interactive model selector with cursor-following highlight pill, effort level cycle switcher, sliding image attachment drawer with shared-element FLIP modal preview, and Web Audio API real-time voice visualizer with speech recognition.",
  "category": "ai-stuff",
  "tags": [
    "ai",
    "prompt",
    "input",
    "composer",
    "voice",
    "spring",
    "attachments",
    "gallery",
    "model-selector",
    "audio-visualizer"
  ],
  "dependencies": [
    "clsx",
    "tailwind-merge"
  ],
  "version": "1.0.0",
  "createdDate": "2026-09-24",
  "updatedDate": "2026-09-24",
  "interactive": true,
  "supportsColor": false,
  "highlights": [
    "Fluid spring physics expanding from compact 48px bar to multi-line textarea with zero layout flicker",
    "Morphing text with smooth subpixel width transitions on model and effort switches",
    "Interactive model selector dropdown with floating cursor-following active highlight pill",
    "Sliding attachment tray that glides up smoothly from behind the input with hover thumbnail removal",
    "Shared-element FLIP image gallery modal transitioning from thumbnail rect to centered modal",
    "Web Audio API real-time 5-band frequency visualizer with graceful animated simulation fallback",
    "Web Speech Recognition API integration with real-time text transcription and auto-scrolling",
    "Full keyboard navigation: Enter to submit, Escape to collapse, auto-focus support",
    "100% responsive and centered across mobile and desktop viewports"
  ],
  "anatomy": [
    "<PromptInput> (Outer relative flex container with spring max-width animation)",
    "<AttachmentDrawer> (Independent sliding drawer emerging from behind the input card)",
    "<AttachmentThumb> (Individual image thumbnail with remove button and click-to-preview)",
    "<AttachmentGalleryModal> (Shared-element FLIP modal rendered via Portal)",
    "<textarea> (Auto-growing multiline input with top and bottom scroll fades)",
    "<ModelSelector> (Dropdown with animated floating highlight pill and custom icon support)",
    "<EffortSelector> (Dynamic 3-bar indicator and effort label cycler)",
    "<AudioVisualizer> (Real-time frequency bars reactive to voice input)",
    "<ActionButton> (Morphing 3-state button toggling between send, mic, and stop)"
  ],
  "physics": {
    "engine": "CSS Spring Physics (cubic-bezier) & Web Audio API Analyser",
    "description": "Custom cubic-bezier(0.16, 1, 0.3, 1) curve for container dimensions, combined with an FFT analyser that drives the voice bars.",
    "parameters": [
      {
        "label": "Container Expand Curve",
        "value": "cubic-bezier(0.16, 1, 0.3, 1)"
      },
      {
        "label": "Container Expand Duration",
        "value": "420ms"
      },
      {
        "label": "Height Growth Curve",
        "value": "cubic-bezier(0.2, 0, 0, 1) (160ms)"
      },
      {
        "label": "Audio Analyser FFT",
        "value": "fftSize 64 (32 bins) averaged into 5 bands, normalized 0..1"
      },
      {
        "label": "Audio Visualizer Frequency",
        "value": "requestAnimationFrame (display refresh rate); 90ms interval in simulated mode"
      }
    ]
  },
  "accessibility": {
    "role": "form",
    "aria": "Textarea labeled with aria-label; model and effort buttons carry descriptive aria-label attributes; full keyboard escape and enter shortcuts supported.",
    "keyboard": [
      {
        "key": "Enter",
        "description": "Submits current prompt and attachments when Shift is not held"
      },
      {
        "key": "Shift + Enter",
        "description": "Inserts a new line into the prompt"
      },
      {
        "key": "Escape",
        "description": "Closes the model menu and returns focus to its trigger; otherwise collapses prompt input if empty, or closes gallery preview modal"
      }
    ],
    "reducedMotion": "Transitions respect reduced motion and use hardware-accelerated transforms and opacity."
  },
  "guidelines": {
    "recommended": [
      "Primary AI prompt entry field in chatbots, copilots, and AI assistant apps",
      "Modern conversational search interfaces and query composers",
      "Multimodal generative AI input surfaces with file attachments and voice capture"
    ],
    "bestPractices": [
      "Pass an onSubmit handler to capture query text along with model, effort, and attachment files",
      "Configure custom models and modelIcons to match your AI backend offerings",
      "Use controlled value and onChange props if managing state in parent application stores"
    ]
  },
  "props": [
    {
      "name": "placeholder",
      "type": "string",
      "defaultValue": "\"Ask anything\"",
      "description": "Placeholder text displayed when the input is empty or collapsed."
    },
    {
      "name": "className",
      "type": "string",
      "description": "Additional CSS classes for the outer wrapper."
    },
    {
      "name": "models",
      "type": "(string | ModelOption)[]",
      "defaultValue": "[\"GPT 5.5\", \"Opus 4.8\", \"Gemini 3.5 Flash\", \"Composer 2.5\", \"GLM 5.2\"]",
      "description": "Available AI models for user selection, supporting custom icons."
    },
    {
      "name": "selectedModel",
      "type": "string",
      "defaultValue": "undefined",
      "description": "Controlled selected model name."
    },
    {
      "name": "onModelChange",
      "type": "(model: string) => void",
      "defaultValue": "undefined",
      "description": "Callback invoked when the user selects a different model."
    },
    {
      "name": "efforts",
      "type": "string[]",
      "defaultValue": "[\"Low\", \"Medium\", \"Max Effort\"]",
      "description": "Available reasoning effort levels."
    },
    {
      "name": "selectedEffort",
      "type": "string",
      "defaultValue": "undefined",
      "description": "Controlled reasoning effort level."
    },
    {
      "name": "onEffortChange",
      "type": "(effort: string) => void",
      "defaultValue": "undefined",
      "description": "Callback invoked when the user toggles effort level."
    },
    {
      "name": "defaultValue",
      "type": "string",
      "defaultValue": "\"\"",
      "description": "Initial prompt text when the input is uncontrolled."
    },
    {
      "name": "value",
      "type": "string",
      "defaultValue": "undefined",
      "description": "Controlled prompt input value."
    },
    {
      "name": "onChange",
      "type": "(value: string) => void",
      "defaultValue": "undefined",
      "description": "Callback invoked on prompt text changes."
    },
    {
      "name": "expanded",
      "type": "boolean",
      "description": "Controlled expanded state of the composer."
    },
    {
      "name": "onExpandedChange",
      "type": "(expanded: boolean) => void",
      "description": "Called when the composer expands or collapses."
    },
    {
      "name": "onSubmit",
      "type": "(value: string, meta: PromptInputMeta) => void",
      "defaultValue": "undefined",
      "description": "Callback triggered when submitting prompt with model, effort, and attachment metadata."
    },
    {
      "name": "maxAttachments",
      "type": "number",
      "defaultValue": "6",
      "description": "Maximum number of image attachments allowed."
    },
    {
      "name": "allowAttachments",
      "type": "boolean",
      "defaultValue": "true",
      "description": "Whether the attachment upload button and tray are enabled."
    },
    {
      "name": "allowVoice",
      "type": "boolean",
      "defaultValue": "true",
      "description": "Whether voice input recording and audio visualizer are enabled."
    },
    {
      "name": "allowModelSelect",
      "type": "boolean",
      "defaultValue": "true",
      "description": "Whether the model dropdown selector is enabled."
    },
    {
      "name": "allowEffortSelect",
      "type": "boolean",
      "defaultValue": "true",
      "description": "Whether the effort level cycler is enabled."
    },
    {
      "name": "minWidth",
      "type": "number | string",
      "defaultValue": "320",
      "description": "Minimum width of the collapsed input container."
    },
    {
      "name": "maxWidth",
      "type": "number | string",
      "defaultValue": "480",
      "description": "Maximum width of the expanded input container."
    },
    {
      "name": "collapsedHeight",
      "type": "number",
      "defaultValue": "48",
      "description": "Height in pixels of the collapsed composer."
    },
    {
      "name": "autoFocus",
      "type": "boolean",
      "defaultValue": "false",
      "description": "Whether the input should auto-focus and expand on mount."
    },
    {
      "name": "disabled",
      "type": "boolean",
      "defaultValue": "false",
      "description": "Disables interaction with the input."
    },
    {
      "name": "accentColor",
      "type": "string",
      "description": "CSS color used for the send button and the glow variant shadow."
    },
    {
      "name": "variant",
      "type": "\"default\" | \"glow\" | \"minimal\"",
      "defaultValue": "\"default\"",
      "description": "Surface style of the composer."
    },
    {
      "name": "modelIcons",
      "type": "Record<string, string | ReactNode>",
      "description": "Icon (image URL or element) per model name, used in the model picker."
    }
  ],
  "usage": "\"use client\";\n\nimport { AiInput } from \"@/components/ui/ai-input\";\n\nexport function AiInputDemo() {\n  return (\n    <AiInput\n      placeholder=\"Ask anything\"\n      onSubmit={(value, meta) => console.log(value, meta.model, meta.attachments)}\n    />\n  );\n}",
  "files": [
    {
      "name": "ai-input.tsx",
      "path": "registry/ui/ai-input.tsx",
      "code": "\"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",
      "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"
    }
  ],
  "type": "registry:ui",
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "title": "AI Input"
}
