{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "prompt-bar",
  "type": "registry:component",
  "title": "Prompt Bar",
  "description": "A prompt box that grows as you type, with attachments, a model menu, an approval toggle and a mic that opens into a live level meter, plus a send button that only lights up when there's something to send.",
  "categories": [
    "component",
    "Conversation"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/menu.json"
  ],
  "files": [
    {
      "path": "registry/devl/prompt-bar.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport {\n  ArrowUpIcon,\n  CalendarIcon,\n  ChevronDownIcon,\n  FileTextIcon,\n  FolderIcon,\n  ImageIcon,\n  LinkIcon,\n  type LucideIcon,\n  MailIcon,\n  MicIcon,\n  PlusIcon,\n  ShieldCheckIcon,\n  ShieldIcon,\n  SquareKanbanIcon,\n  XIcon,\n} from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type KeyboardEvent, useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport {\n  Menu,\n  MenuGroup,\n  MenuGroupLabel,\n  MenuItem,\n  MenuPopup,\n  MenuRadioGroup,\n  MenuRadioItem,\n  MenuTrigger,\n} from \"@/components/ui/menu\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface PromptBarModel {\n  id: string;\n  name: string;\n  hint?: string;\n}\n\nexport interface PromptBarApp {\n  id: string;\n  name: string;\n  icon: LucideIcon;\n  tint?: string;\n}\n\nexport interface PromptBarAttachment {\n  id: string;\n  name: string;\n  kind: \"file\" | \"image\" | \"link\";\n}\n\nexport interface PromptBarSendPayload {\n  text: string;\n  model: string;\n  approval: boolean;\n  apps: string[];\n  attachments: PromptBarAttachment[];\n}\n\nexport interface PromptBarProps {\n  placeholder?: string;\n  models?: PromptBarModel[];\n  defaultModel?: string;\n  apps?: PromptBarApp[];\n  defaultApps?: string[];\n  defaultApproval?: boolean;\n  transcript?: string;\n  onSend?: (payload: PromptBarSendPayload) => void;\n  className?: string;\n}\n\nconst defaultModels: PromptBarModel[] = [\n  { id: \"lumen-3\", name: \"Lumen 3\", hint: \"Balanced\" },\n  { id: \"lumen-3-swift\", name: \"Lumen 3 Swift\", hint: \"Fastest\" },\n  { id: \"atlas-pro\", name: \"Atlas Pro\", hint: \"Deep reasoning\" },\n];\n\nconst defaultApps: PromptBarApp[] = [\n  { id: \"calendar\", name: \"Calendar\", icon: CalendarIcon, tint: \"text-sky-600 dark:text-sky-400\" },\n  { id: \"mail\", name: \"Mail\", icon: MailIcon, tint: \"text-rose-600 dark:text-rose-400\" },\n  { id: \"files\", name: \"Files\", icon: FolderIcon, tint: \"text-amber-600 dark:text-amber-400\" },\n  { id: \"tracker\", name: \"Tracker\", icon: SquareKanbanIcon, tint: \"text-violet-600 dark:text-violet-400\" },\n];\n\nconst fakeFiles: Record<PromptBarAttachment[\"kind\"], string[]> = {\n  file: [\"q3-brief.pdf\", \"offsite-notes.md\", \"budget-v2.xlsx\"],\n  image: [\"moodboard.png\", \"whiteboard.jpg\"],\n  link: [\"pinecrest.io/pricing\", \"docs.halyard.dev\"],\n};\n\nconst kindIcon = { file: FileTextIcon, image: ImageIcon, link: LinkIcon } as const;\nconst spring = { type: \"spring\", stiffness: 480, damping: 36 } as const;\nconst easeOut = [0.23, 1, 0.32, 1] as const;\nconst press = \"transition-[color,background-color,border-color,scale] duration-150 ease-out active:scale-[0.97]\";\nconst menuMotion =\n  \"transition-[scale,opacity] duration-150 ease-out data-ending-style:scale-95 data-starting-style:scale-95 data-ending-style:opacity-0 data-starting-style:opacity-0\";\n\nexport function PromptBar({\n  placeholder = \"Ask anything or attach a file…\",\n  models = defaultModels,\n  defaultModel,\n  apps = defaultApps,\n  defaultApps: initialApps = [\"calendar\", \"mail\"],\n  defaultApproval = true,\n  transcript = \"Find a 30 minute slot with Priya next week and draft the invite.\",\n  onSend,\n  className,\n}: PromptBarProps) {\n  const reduce = useReducedMotion();\n  const inputId = useId();\n  const textareaRef = useRef<HTMLTextAreaElement>(null);\n  const [text, setText] = useState(\"\");\n  const [model, setModel] = useState(defaultModel ?? models[0]?.id ?? \"\");\n  const [approval, setApproval] = useState(defaultApproval);\n  const [active, setActive] = useState<string[]>(initialApps);\n  const [attachments, setAttachments] = useState<PromptBarAttachment[]>([]);\n  const [recording, setRecording] = useState(false);\n  const [sent, setSent] = useState(0);\n  const counter = useRef(0);\n\n  const canSend = text.trim().length > 0 && !recording;\n  const current = models.find((m) => m.id === model);\n\n  useLayoutEffect(() => {\n    const el = textareaRef.current;\n    if (!el) return;\n    el.style.height = \"0px\";\n    el.style.height = `${Math.min(el.scrollHeight, 168)}px`;\n  }, [text]);\n\n  const attach = (kind: PromptBarAttachment[\"kind\"]) => {\n    const pool = fakeFiles[kind];\n    const name = pool[counter.current % pool.length] ?? kind;\n    counter.current += 1;\n    setAttachments((a) => [...a, { id: `${kind}-${counter.current}`, name, kind }]);\n  };\n\n  const send = () => {\n    if (!canSend) return;\n    onSend?.({ text: text.trim(), model, approval, apps: active, attachments });\n    setText(\"\");\n    setAttachments([]);\n    setSent((s) => s + 1);\n  };\n\n  const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {\n    if (e.key === \"Enter\" && !e.shiftKey && !e.nativeEvent.isComposing) {\n      e.preventDefault();\n      send();\n    }\n  };\n\n  const stopRecording = (keep: boolean) => {\n    setRecording(false);\n    if (keep) {\n      setText((t) => (t ? `${t.trimEnd()} ${transcript}` : transcript));\n      requestAnimationFrame(() => textareaRef.current?.focus());\n    }\n  };\n\n  const toolBtn = cn(\n    \"inline-flex h-8 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-muted-foreground text-xs outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring data-popup-open:bg-accent data-popup-open:text-foreground\",\n    press,\n  );\n\n  return (\n    <div className={cn(\"w-full max-w-xl\", className)}>\n      <div className=\"@container rounded-2xl border bg-card p-2 shadow-xs/5 transition-[box-shadow,border-color] duration-150 has-[textarea:focus-visible]:border-ring has-[textarea:focus-visible]:ring-[3px] has-[textarea:focus-visible]:ring-ring/20\">\n        <AnimatePresence initial={false}>\n          {attachments.length > 0 && (\n            <motion.ul\n              animate={{ height: \"auto\", opacity: 1 }}\n              className=\"flex flex-wrap gap-1.5 overflow-hidden px-1\"\n              exit={{ height: 0, opacity: 0 }}\n              initial={{ height: 0, opacity: 0 }}\n              transition={spring}\n            >\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                {attachments.map((a) => {\n                  const Icon = kindIcon[a.kind];\n                  return (\n                    <motion.li\n                      animate={{ opacity: 1, scale: 1 }}\n                      className=\"mt-1 mb-1.5 flex items-center gap-1.5 rounded-lg border bg-background py-1 ps-2 pe-1 text-xs\"\n                      exit={{ opacity: 0, scale: 0.95, transition: { duration: 0.12 } }}\n                      initial={reduce ? false : { opacity: 0, scale: 0.95 }}\n                      key={a.id}\n                      layout\n                      transition={spring}\n                    >\n                      <Icon className=\"size-3.5 text-muted-foreground\" />\n                      <span className=\"max-w-36 truncate\">{a.name}</span>\n                      <button\n                        aria-label={`Remove ${a.name}`}\n                        className={cn(\n                          \"relative grid size-5 place-items-center rounded-md text-muted-foreground outline-none after:absolute after:-inset-1.5 hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\",\n                          press,\n                        )}\n                        onClick={() => setAttachments((list) => list.filter((x) => x.id !== a.id))}\n                        type=\"button\"\n                      >\n                        <XIcon className=\"size-3\" />\n                      </button>\n                    </motion.li>\n                  );\n                })}\n              </AnimatePresence>\n            </motion.ul>\n          )}\n        </AnimatePresence>\n\n        <label className=\"sr-only\" htmlFor={inputId}>\n          Message\n        </label>\n        <textarea\n          className=\"block max-h-42 min-h-11 w-full resize-none bg-transparent px-2 py-1.5 text-sm leading-6 outline-none placeholder:text-muted-foreground/70\"\n          id={inputId}\n          onChange={(e) => setText(e.target.value)}\n          onKeyDown={onKeyDown}\n          placeholder={recording ? \"Listening…\" : placeholder}\n          ref={textareaRef}\n          rows={1}\n          value={text}\n        />\n\n        <div className=\"mt-1 flex items-center gap-1\">\n          <Menu>\n            <MenuTrigger\n              aria-label=\"Attach\"\n              className={cn(\n                \"group/plus grid size-8 shrink-0 place-items-center rounded-full border text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring data-popup-open:bg-accent data-popup-open:text-foreground\",\n                press,\n              )}\n            >\n              <PlusIcon className=\"size-4 transition-[rotate] duration-200 ease-out group-data-popup-open/plus:rotate-45\" />\n            </MenuTrigger>\n            <MenuPopup align=\"start\" className={cn(\"min-w-44\", menuMotion)} side=\"top\" sideOffset={8}>\n              <MenuItem onClick={() => attach(\"file\")}>\n                <FileTextIcon />\n                Upload a file\n              </MenuItem>\n              <MenuItem onClick={() => attach(\"image\")}>\n                <ImageIcon />\n                Add an image\n              </MenuItem>\n              <MenuItem onClick={() => attach(\"link\")}>\n                <LinkIcon />\n                Paste a link\n              </MenuItem>\n            </MenuPopup>\n          </Menu>\n\n          <Menu>\n            <MenuTrigger className={toolBtn}>\n              <span className=\"font-medium\">{current?.name ?? \"Model\"}</span>\n              <ChevronDownIcon className=\"size-3.5 transition-[rotate] duration-200 ease-out in-data-popup-open:rotate-180\" />\n            </MenuTrigger>\n            <MenuPopup align=\"start\" className={cn(\"min-w-52\", menuMotion)} side=\"top\" sideOffset={8}>\n              <MenuGroup>\n                <MenuGroupLabel>Model</MenuGroupLabel>\n                <MenuRadioGroup onValueChange={(v) => setModel(String(v))} value={model}>\n                  {models.map((m) => (\n                    <MenuRadioItem key={m.id} value={m.id}>\n                      <span className=\"flex w-full items-center justify-between gap-4\">\n                        {m.name}\n                        {m.hint && <span className=\"text-muted-foreground text-xs\">{m.hint}</span>}\n                      </span>\n                    </MenuRadioItem>\n                  ))}\n                </MenuRadioGroup>\n              </MenuGroup>\n            </MenuPopup>\n          </Menu>\n\n          <motion.button\n            aria-pressed={approval}\n            className={cn(\n              \"inline-flex h-8 shrink-0 items-center gap-1.5 overflow-hidden rounded-full border px-2.5 text-xs outline-none transition-[color,background-color,border-color] duration-150 focus-visible:ring-2 focus-visible:ring-ring\",\n              approval\n                ? \"border-warning/40 bg-warning/12 text-warning-foreground\"\n                : \"border-transparent text-muted-foreground hover:bg-accent hover:text-foreground\",\n            )}\n            layout\n            onClick={() => setApproval((a) => !a)}\n            title=\"Ask before the assistant takes actions in connected apps\"\n            transition={spring}\n            type=\"button\"\n            whileTap={{ scale: 0.97 }}\n          >\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              <motion.span\n                animate={{ scale: 1, opacity: 1, filter: \"blur(0px)\" }}\n                className=\"grid\"\n                exit={{ scale: 0.6, opacity: 0, filter: \"blur(2px)\" }}\n                initial={{ scale: 0.6, opacity: 0, filter: \"blur(2px)\" }}\n                key={approval ? \"on\" : \"off\"}\n                transition={{ duration: 0.18, ease: easeOut }}\n              >\n                {approval ? <ShieldCheckIcon className=\"size-3.5\" /> : <ShieldIcon className=\"size-3.5\" />}\n              </motion.span>\n            </AnimatePresence>\n            <motion.span className=\"sr-only whitespace-nowrap @[25rem]:not-sr-only\" layout=\"position\">\n              {approval ? \"Approval required\" : \"Auto-run\"}\n            </motion.span>\n          </motion.button>\n\n          <div className=\"ms-auto flex items-center gap-1.5\">\n            <VoiceButton onStop={stopRecording} recording={recording} setRecording={setRecording} />\n            <motion.button\n              animate={{ scale: canSend || reduce ? 1 : 0.9 }}\n              aria-label=\"Send message\"\n              className={cn(\n                \"relative grid size-8 shrink-0 place-items-center overflow-hidden rounded-full outline-none transition-[color,background-color] duration-200 ease-out focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card\",\n                canSend ? \"bg-primary text-primary-foreground\" : \"bg-muted text-muted-foreground/60\",\n              )}\n              disabled={!canSend}\n              onClick={send}\n              transition={{ duration: 0.2, ease: easeOut }}\n              type=\"button\"\n              whileTap={canSend ? { scale: 0.94, transition: { duration: 0.1 } } : undefined}\n            >\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                <motion.span\n                  animate={{ y: 0, opacity: 1 }}\n                  className=\"grid\"\n                  exit={reduce ? { opacity: 0 } : { y: -22, opacity: 0 }}\n                  initial={reduce ? { opacity: 0 } : { y: 22, opacity: 0 }}\n                  key={sent}\n                  transition={{ duration: 0.25, ease: easeOut }}\n                >\n                  <ArrowUpIcon className=\"size-4\" />\n                </motion.span>\n              </AnimatePresence>\n            </motion.button>\n          </div>\n        </div>\n      </div>\n\n      {apps.length > 0 && (\n        <div className=\"-mx-1 mt-2 flex items-center gap-1 overflow-x-auto px-2 py-0.5 [mask-image:linear-gradient(to_right,black_calc(100%-24px),transparent)] [scrollbar-width:none]\">\n          <span className=\"me-1 shrink-0 text-muted-foreground text-xs\">Connected</span>\n          {apps.map((app) => {\n            const on = active.includes(app.id);\n            const Icon = app.icon;\n            return (\n              <button\n                aria-pressed={on}\n                className={cn(\n                  \"inline-flex h-7 shrink-0 items-center gap-1.5 rounded-full border px-2 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n                  press,\n                  on\n                    ? \"bg-card text-foreground\"\n                    : \"border-dashed border-border text-muted-foreground hover:border-solid hover:text-foreground\",\n                )}\n                key={app.id}\n                onClick={() => setActive((list) => (on ? list.filter((x) => x !== app.id) : [...list, app.id]))}\n                type=\"button\"\n              >\n                <Icon className={cn(\"size-3.5 transition-colors duration-150\", on && app.tint)} />\n                {app.name}\n                <motion.span\n                  animate={{ opacity: on ? 1 : 0, scale: on ? 1 : 0.5, width: on ? 6 : 0, marginInlineStart: on ? 0 : -6 }}\n                  className=\"h-1.5 shrink-0 rounded-full bg-success\"\n                  initial={false}\n                  transition={{ duration: reduce ? 0 : 0.2, ease: easeOut }}\n                />\n              </button>\n            );\n          })}\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction VoiceButton({\n  recording,\n  setRecording,\n  onStop,\n}: {\n  recording: boolean;\n  setRecording: (v: boolean) => void;\n  onStop: (keep: boolean) => void;\n}) {\n  const reduce = useReducedMotion();\n  const [levels, setLevels] = useState([0.3, 0.5, 0.8, 0.5, 0.3]);\n  const [seconds, setSeconds] = useState(0);\n  const stopRef = useRef(onStop);\n  stopRef.current = onStop;\n\n  useEffect(() => {\n    if (!recording) return;\n    setSeconds(0);\n    const start = Date.now();\n    const level = setInterval(() => {\n      if (!reduce) {\n        const t = Date.now() / 160;\n        setLevels((prev) =>\n          prev.map((_, i) => {\n            const shape = 1 - Math.abs(i - 2) * 0.22;\n            const wave = 0.45 + 0.35 * Math.sin(t + i * 0.9) * Math.sin(t / 3.1);\n            return Math.max(0.3, Math.min(1, (wave + Math.random() * 0.35) * shape));\n          }),\n        );\n      }\n      setSeconds(Math.floor((Date.now() - start) / 1000));\n    }, 110);\n    const auto = setTimeout(() => stopRef.current(true), 4200);\n    return () => {\n      clearInterval(level);\n      clearTimeout(auto);\n    };\n  }, [recording, reduce]);\n\n  return (\n    <motion.button\n      animate={{ width: recording ? \"auto\" : 32 }}\n      aria-label={recording ? \"Stop recording\" : \"Dictate\"}\n      aria-pressed={recording}\n      className={cn(\n        \"inline-flex h-8 shrink-0 items-center overflow-hidden rounded-full outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-ring\",\n        recording\n          ? \"bg-destructive/10 text-destructive-foreground\"\n          : \"text-muted-foreground hover:bg-accent hover:text-foreground\",\n      )}\n      initial={false}\n      onClick={() => (recording ? onStop(true) : setRecording(true))}\n      transition={{ duration: reduce ? 0 : 0.25, ease: easeOut }}\n      type=\"button\"\n      whileTap={{ scale: 0.97 }}\n    >\n      {recording ? (\n        <span className=\"flex items-center gap-2 px-2.5\">\n          <motion.span\n            animate={{ opacity: [1, 0.45, 1] }}\n            className=\"size-2 shrink-0 rounded-full bg-destructive\"\n            transition={{ duration: 1.1, ease: \"easeInOut\", repeat: Number.POSITIVE_INFINITY }}\n          />\n          <span aria-hidden className=\"flex h-4 items-center gap-[3px]\">\n            {levels.map((l, i) => (\n              <motion.span\n                animate={{ scaleY: l }}\n                className=\"h-4 w-[3px] origin-center rounded-full bg-current\"\n                key={i}\n                transition={{ type: \"spring\", stiffness: 700, damping: 30 }}\n              />\n            ))}\n          </span>\n          <span className=\"font-mono text-xs tabular-nums\">0:{String(seconds).padStart(2, \"0\")}</span>\n        </span>\n      ) : (\n        <span className=\"grid size-8 shrink-0 place-items-center\">\n          <MicIcon className=\"size-4\" />\n        </span>\n      )}\n    </motion.button>\n  );\n}\n"
    }
  ]
}