{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"prompt-bar","type":"registry:block","title":"Prompt Bar","description":"Compact credits-aware AI prompt bar: a dismissible credits banner, queued messages you can steer, edit or drop while streaming, slash-commands that morph into a removable skill chip, demo attachments, a web-search toggle pill, a model menu, and a morphing send-to-stop button.","author":"UI Lab","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"cssVars":{"light":{"wb-hover":"rgb(0 0 0 / 0.05)","wb-hover-subtle":"rgb(0 0 0 / 0.04)","wb-hover-strong":"rgb(0 0 0 / 0.1)","wb-hover-stronger":"rgb(0 0 0 / 0.1)","wb-inset":"rgb(0 0 0 / 0.05)","wb-inset-strong":"rgb(0 0 0 / 0.06)","wb-inset-subtle":"rgb(0 0 0 / 0.03)","wb-inset-faint":"rgb(0 0 0 / 0.02)","wb-code-inline":"rgb(0 0 0 / 0.07)","wb-code-block":"rgb(0 0 0 / 0.04)","wb-border":"rgb(0 0 0 / 0.12)","wb-border-subtle":"rgb(0 0 0 / 0.1)","wb-border-strong":"rgb(0 0 0 / 0.17)","wb-border-emphasis":"rgb(0 0 0 / 0.22)","wb-divider":"rgb(0 0 0 / 0.07)","wb-hairline":"rgb(0 0 0 / 0.12)","wb-hairline-soft":"rgb(0 0 0 / 0.1)","wb-hairline-subtle":"rgb(0 0 0 / 0.08)","wb-control-hairline":"rgb(0 0 0 / 0.08)","wb-control-tick":"rgb(0 0 0 / 0.2)","wb-resize-handle":"rgb(0 0 0 / 0.2)","wb-surface":"rgb(255 255 255)","wb-surface-translucent":"oklch(94.5% 0.002 250 / 0.75)","wb-overlay-scrim":"rgb(0 0 0 / 0.06)","wb-surface-raised":"rgb(255 255 255 / 0.95)","wb-surface-composer":"rgb(255 255 255 / 0.9)","wb-card":"rgb(0 0 0 / 0.04)","wb-inverse":"oklch(20.5% 0 0)","wb-inverse-fg":"rgb(255 255 255)","wb-accent-fg":"rgb(255 255 255)","wb-solid-control-fg":"oklch(20.5% 0 0)","wb-shimmer-dim":"rgb(0 0 0 / 0.4)","wb-shimmer-bright":"rgb(0 0 0 / 0.85)","wb-accent":"#339cff","wb-success":"oklch(59.6% 0.145 163.225)","wb-success-border":"oklch(69.6% 0.17 162.48)","wb-danger":"oklch(63.7% 0.237 25.331)","wb-danger-strong":"#fa423e","wb-danger-surface":"oklch(63.7% 0.237 25.331)","wb-warning":"#e25507","wb-warning-text":"oklch(64.6% 0.222 41.116)","wb-warning-hover":"oklch(70.5% 0.213 47.604)","wb-warning-surface":"#e25507"},"dark":{"wb-hover":"rgb(255 255 255 / 0.1)","wb-hover-subtle":"rgb(255 255 255 / 0.05)","wb-hover-strong":"rgb(255 255 255 / 0.1)","wb-hover-stronger":"rgb(255 255 255 / 0.15)","wb-inset":"rgb(255 255 255 / 0.05)","wb-inset-strong":"rgb(255 255 255 / 0.1)","wb-inset-subtle":"rgb(255 255 255 / 0.05)","wb-inset-faint":"rgb(255 255 255 / 0.03)","wb-code-inline":"rgb(255 255 255 / 0.1)","wb-code-block":"rgb(255 255 255 / 0.05)","wb-border":"rgb(255 255 255 / 0.1)","wb-border-subtle":"rgb(255 255 255 / 0.1)","wb-border-strong":"rgb(255 255 255 / 0.15)","wb-border-emphasis":"rgb(255 255 255 / 0.2)","wb-divider":"rgb(255 255 255 / 0.06)","wb-hairline":"rgb(255 255 255 / 0.157)","wb-hairline-soft":"rgb(255 255 255 / 0.15)","wb-hairline-subtle":"rgb(255 255 255 / 0.1)","wb-control-hairline":"rgb(255 255 255 / 0.08)","wb-control-tick":"rgb(255 255 255 / 0.3)","wb-resize-handle":"rgb(255 255 255 / 0.25)","wb-surface":"oklch(20.5% 0 0)","wb-surface-translucent":"oklch(20.5% 0 0 / 0.6)","wb-overlay-scrim":"rgb(0 0 0 / 0.24)","wb-surface-raised":"oklch(26.9% 0 0 / 0.95)","wb-surface-composer":"oklch(26.9% 0 0 / 0.9)","wb-card":"oklch(26.9% 0 0 / 0.5)","wb-inverse":"rgb(255 255 255)","wb-inverse-fg":"oklch(20.5% 0 0)","wb-accent-fg":"rgb(255 255 255)","wb-solid-control-fg":"oklch(20.5% 0 0)","wb-shimmer-dim":"rgb(255 255 255 / 0.4)","wb-shimmer-bright":"rgb(255 255 255 / 0.95)","wb-accent":"#339cff","wb-success":"#40c977","wb-success-border":"oklch(69.6% 0.17 162.48)","wb-danger":"#fa423e","wb-danger-strong":"#fa423e","wb-danger-surface":"oklch(63.7% 0.237 25.331)","wb-warning":"#ff8549","wb-warning-text":"#ff8549","wb-warning-hover":"#e25507","wb-warning-surface":"#e25507"}},"files":[{"path":"components/motion/prompt-bar.tsx","type":"registry:component","target":"@components/motion/prompt-bar.tsx","content":"\"use client\";\n// ui-lab-ten.vercel.app/components/blocks/prompt-bar\n\nimport {\n  ArrowUp,\n  Check,\n  ChevronDown,\n  CornerDownRight,\n  Globe,\n  Mic,\n  Paperclip,\n  Pencil,\n  Slash,\n  Square,\n  Trash2,\n  X,\n} from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  type ChangeEvent,\n  type KeyboardEvent,\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_PANEL, SPRING_PRESS, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface PromptBarSkill {\n  id: string;\n  label: string;\n  hint?: string;\n}\n\nexport interface PromptBarModel {\n  id: string;\n  label: string;\n}\n\nexport interface PromptBarQueuedMessage {\n  id: string;\n  text: string;\n}\n\nexport interface PromptBarProps {\n  /** Slash-triggered skills — typing \"/\" opens the picker. */\n  skills?: PromptBarSkill[];\n  models?: PromptBarModel[];\n  defaultModelId?: string;\n  /** Shows the credits banner above the bar when provided (including 0). */\n  credits?: number;\n  onUpgrade?: () => void;\n  placeholder?: string;\n  onSubmit?: (payload: {\n    text: string;\n    skillId?: string;\n    attachments: string[];\n    webSearch: boolean;\n    modelId: string;\n  }) => void;\n  className?: string;\n}\n\n/** Demo file chip — no real file picker, just a label to show/remove. */\ninterface Attachment {\n  id: string;\n  name: string;\n}\n\n/** Demo streaming duration — a real integration would resolve this from the actual response. */\nconst STREAM_DEMO_MS = 3000;\n\n/** Flat demo cost per send — a real integration would price this server-side. */\nconst CREDIT_COST_PER_SEND = 10;\n\nconst DEMO_FILE_NAMES = [\"brief.pdf\", \"screenshot.png\", \"meeting-notes.md\", \"diagram.svg\", \"transcript.txt\"];\n\n/** Glass-surface hairline + soft shadow trio — same technique as agent-composer's Composer shell. */\nconst HAIRLINE_SHADOW = \"0 0 0 0.5px var(--wb-hairline-soft), 0 3px 7.5px rgba(0,0,0,0.04), 0 0 20px rgba(0,0,0,0.05)\";\n\n/** Matches a skill against the text typed after \"/\", by id or label prefix. */\nfunction matchesSkillQuery(skill: PromptBarSkill, query: string): boolean {\n  if (query.length === 0) return true;\n  const q = query.toLowerCase();\n  const label = skill.label.replace(/^\\//, \"\").toLowerCase();\n  return label.startsWith(q) || skill.id.toLowerCase().startsWith(q);\n}\n\nfunction CreditsNumber({ value, reduce }: { value: number; reduce: boolean }) {\n  return (\n    <span className=\"relative inline-block overflow-hidden font-medium text-foreground tabular-nums\">\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        <motion.span\n          key={value}\n          initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8 }}\n          animate={{ opacity: 1, y: 0 }}\n          exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}\n          transition={reduce ? { duration: 0.15, ease: EASE_OUT } : SPRING_SWAP}\n          className=\"block\"\n        >\n          {value}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\n/** Dismissible banner shown above the bar when `credits` is provided. */\nfunction CreditsBanner({\n  credits,\n  onUpgrade,\n  onDismiss,\n}: {\n  credits: number;\n  onUpgrade?: () => void;\n  onDismiss: () => void;\n}) {\n  const reduce = useReducedMotion() ?? false;\n  return (\n    <motion.div\n      layout={!reduce}\n      initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}\n      animate={{ opacity: 1, height: \"auto\" }}\n      exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}\n      transition={reduce ? { duration: 0.15, ease: EASE_OUT } : { height: SPRING_LAYOUT, opacity: { duration: 0.18 } }}\n      style={{ overflow: \"hidden\" }}\n      className=\"flex items-center justify-between gap-3 rounded-2xl bg-[var(--wb-inset-subtle)] px-3.5 py-2\"\n    >\n      <span className=\"flex items-center gap-1 text-[13px] text-muted-foreground\">\n        <CreditsNumber value={credits} reduce={reduce} />\n        Credits Remaining\n      </span>\n      <div className=\"flex shrink-0 items-center gap-0.5\">\n        <button\n          type=\"button\"\n          onClick={onUpgrade}\n          className=\"rounded-full px-2 py-1 text-[13px] font-medium text-[var(--wb-accent)] transition-colors hover:bg-[var(--wb-hover)]\"\n        >\n          Upgrade\n        </button>\n        <button\n          type=\"button\"\n          aria-label=\"Dismiss credits banner\"\n          onClick={onDismiss}\n          className=\"flex h-6 w-6 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-[var(--wb-hover)] hover:text-foreground\"\n        >\n          <X className=\"h-3.5 w-3.5\" aria-hidden />\n        </button>\n      </div>\n    </motion.div>\n  );\n}\n\nfunction RowActionButton({\n  \"aria-label\": ariaLabel,\n  onClick,\n  children,\n}: {\n  \"aria-label\": string;\n  onClick: () => void;\n  children: ReactNode;\n}) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={ariaLabel}\n      onClick={onClick}\n      className=\"flex h-6 w-6 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-[var(--wb-hover-strong)] hover:text-foreground\"\n    >\n      {children}\n    </button>\n  );\n}\n\n/** One queued message above the bar — steer it in now, edit it back into the draft, or drop it. */\nfunction QueuedRow({\n  item,\n  onSteer,\n  onEdit,\n  onDelete,\n}: {\n  item: PromptBarQueuedMessage;\n  onSteer: () => void;\n  onEdit: () => void;\n  onDelete: () => void;\n}) {\n  const reduce = useReducedMotion() ?? false;\n  return (\n    <motion.div\n      layout={!reduce}\n      initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0, y: -4 }}\n      animate={{ opacity: 1, height: \"auto\", y: 0 }}\n      exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0, y: -4 }}\n      transition={\n        reduce ? { duration: 0.12 } : { height: SPRING_LAYOUT, y: SPRING_LAYOUT, opacity: { duration: 0.15 } }\n      }\n      style={{ overflow: \"hidden\" }}\n      className=\"flex items-center gap-2 rounded-xl bg-[var(--wb-inset-subtle)] px-3 py-1.5\"\n    >\n      <CornerDownRight className=\"h-3.5 w-3.5 shrink-0 text-muted-foreground\" aria-hidden />\n      <span className=\"min-w-0 flex-1 truncate text-[13px] text-foreground\">{item.text}</span>\n      <div className=\"flex shrink-0 items-center gap-0.5\">\n        <RowActionButton aria-label={`Steer now: ${item.text}`} onClick={onSteer}>\n          <ArrowUp className=\"h-3.5 w-3.5\" aria-hidden />\n        </RowActionButton>\n        <RowActionButton aria-label={`Edit: ${item.text}`} onClick={onEdit}>\n          <Pencil className=\"h-3.5 w-3.5\" aria-hidden />\n        </RowActionButton>\n        <RowActionButton aria-label={`Delete: ${item.text}`} onClick={onDelete}>\n          <Trash2 className=\"h-3.5 w-3.5\" aria-hidden />\n        </RowActionButton>\n      </div>\n    </motion.div>\n  );\n}\n\nfunction AttachmentChip({ name, onRemove }: { name: string; onRemove: () => void }) {\n  const reduce = useReducedMotion() ?? false;\n  return (\n    <motion.span\n      layout={!reduce}\n      initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9 }}\n      animate={{ opacity: 1, scale: 1 }}\n      exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9 }}\n      transition={reduce ? { duration: 0.12 } : SPRING_SWAP}\n      className=\"flex items-center gap-1.5 rounded-lg bg-[var(--wb-inset-subtle)] py-1 pr-1 pl-2 text-[12px] text-foreground\"\n    >\n      <Paperclip className=\"h-3 w-3 shrink-0 text-muted-foreground\" aria-hidden />\n      <span className=\"max-w-40 truncate\">{name}</span>\n      <button\n        type=\"button\"\n        aria-label={`Remove ${name}`}\n        onClick={onRemove}\n        className=\"flex h-4 w-4 shrink-0 items-center justify-center rounded hover:bg-[var(--wb-hover-strong)]\"\n      >\n        <X className=\"h-3 w-3\" aria-hidden />\n      </button>\n    </motion.span>\n  );\n}\n\n/** The \"/xxx\" text solidified into a removable pill once a skill is picked. */\nfunction SkillChip({ skill, onRemove }: { skill: PromptBarSkill; onRemove: () => void }) {\n  const reduce = useReducedMotion() ?? false;\n  return (\n    <motion.span\n      layout={!reduce}\n      initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.85 }}\n      animate={{ opacity: 1, scale: 1 }}\n      exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.85 }}\n      transition={reduce ? { duration: 0.12 } : SPRING_SWAP}\n      className=\"mt-0.5 flex h-6 shrink-0 items-center gap-1 rounded-full bg-[var(--wb-accent)]/15 py-0 pr-1 pl-2 text-[12px] font-medium text-[var(--wb-accent)]\"\n    >\n      <Slash className=\"h-3 w-3\" aria-hidden />\n      {skill.label.replace(/^\\//, \"\")}\n      <button\n        type=\"button\"\n        aria-label={`Remove ${skill.label} skill`}\n        onClick={onRemove}\n        className=\"flex h-4 w-4 shrink-0 items-center justify-center rounded-full hover:bg-[var(--wb-accent)]/25\"\n      >\n        <X className=\"h-2.5 w-2.5\" aria-hidden />\n      </button>\n    </motion.span>\n  );\n}\n\n/** Floating slash-command list — arrow keys and Enter are driven from the textarea's onKeyDown. */\nfunction SkillPanel({\n  listId,\n  items,\n  highlightIndex,\n  onSelect,\n}: {\n  listId: string;\n  items: PromptBarSkill[];\n  highlightIndex: number;\n  onSelect: (skill: PromptBarSkill) => void;\n}) {\n  const reduce = useReducedMotion() ?? false;\n  return (\n    <motion.ul\n      id={listId}\n      role=\"listbox\"\n      aria-label=\"Skills\"\n      initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }}\n      animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}\n      exit={reduce ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }}\n      transition={reduce ? { duration: 0.12 } : SPRING_PANEL}\n      style={{ transformOrigin: \"bottom left\", boxShadow: HAIRLINE_SHADOW }}\n      className=\"absolute bottom-[calc(100%+8px)] left-3 z-20 max-h-56 w-64 overflow-auto rounded-2xl bg-[var(--wb-surface-raised)] p-1 backdrop-blur-xl\"\n    >\n      {items.map((skill, index) => (\n        <li key={skill.id}>\n          <button\n            type=\"button\"\n            id={`${listId}-${skill.id}`}\n            role=\"option\"\n            aria-selected={index === highlightIndex}\n            onMouseDown={(event) => event.preventDefault()}\n            onClick={() => onSelect(skill)}\n            className={cn(\n              \"flex w-full flex-col items-start gap-0.5 rounded-lg px-2.5 py-1.5 text-left transition-colors\",\n              index === highlightIndex ? \"bg-[var(--wb-hover-strong)]\" : \"hover:bg-[var(--wb-hover)]\",\n            )}\n          >\n            <span className=\"flex items-center gap-1 text-[13px] font-medium text-foreground\">\n              <Slash className=\"h-3 w-3 text-muted-foreground\" aria-hidden />\n              {skill.label.replace(/^\\//, \"\")}\n            </span>\n            {skill.hint ? <span className=\"truncate text-[12px] text-muted-foreground\">{skill.hint}</span> : null}\n          </button>\n        </li>\n      ))}\n    </motion.ul>\n  );\n}\n\n/** Small text-button model picker — a simplified, self-contained cousin of components/motion/select.tsx. */\nfunction ModelMenu({\n  models,\n  modelId,\n  onSelect,\n}: {\n  models: PromptBarModel[];\n  modelId: string;\n  onSelect: (id: string) => void;\n}) {\n  const reduce = useReducedMotion() ?? false;\n  const [open, setOpen] = useState(false);\n  const rootRef = useRef<HTMLDivElement>(null);\n  const current = models.find((m) => m.id === modelId) ?? models[0];\n\n  useEffect(() => {\n    if (!open) return;\n    const onKeyDown = (event: globalThis.KeyboardEvent) => {\n      if (event.key === \"Escape\") setOpen(false);\n    };\n    const onPointerDown = (event: PointerEvent) => {\n      if (rootRef.current && !rootRef.current.contains(event.target as Node)) setOpen(false);\n    };\n    window.addEventListener(\"keydown\", onKeyDown);\n    window.addEventListener(\"pointerdown\", onPointerDown);\n    return () => {\n      window.removeEventListener(\"keydown\", onKeyDown);\n      window.removeEventListener(\"pointerdown\", onPointerDown);\n    };\n  }, [open]);\n\n  return (\n    <div ref={rootRef} className=\"relative\">\n      <button\n        type=\"button\"\n        aria-haspopup=\"listbox\"\n        aria-expanded={open}\n        onClick={() => setOpen((v) => !v)}\n        className=\"flex h-7 items-center gap-1 rounded-full px-2 text-[13px] text-muted-foreground transition-colors hover:bg-[var(--wb-hover)]\"\n      >\n        {current?.label ?? \"Model\"}\n        <ChevronDown className=\"h-3.5 w-3.5\" aria-hidden />\n      </button>\n      <AnimatePresence>\n        {open ? (\n          <motion.ul\n            role=\"listbox\"\n            aria-label=\"Model\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: 4 }}\n            animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: 4 }}\n            transition={reduce ? { duration: 0.12 } : SPRING_PANEL}\n            style={{ transformOrigin: \"bottom right\", boxShadow: HAIRLINE_SHADOW }}\n            className=\"absolute right-0 bottom-[calc(100%+8px)] z-20 min-w-[160px] rounded-2xl bg-[var(--wb-surface-raised)] p-1 backdrop-blur-xl\"\n          >\n            {models.map((model) => (\n              <li key={model.id}>\n                <button\n                  type=\"button\"\n                  role=\"option\"\n                  aria-selected={model.id === current?.id}\n                  onClick={() => {\n                    onSelect(model.id);\n                    setOpen(false);\n                  }}\n                  className={cn(\n                    \"flex w-full items-center justify-between gap-4 rounded-lg px-2.5 py-1.5 text-left text-[13px] transition-colors\",\n                    model.id === current?.id\n                      ? \"bg-[var(--wb-hover-strong)] text-foreground\"\n                      : \"text-muted-foreground hover:bg-[var(--wb-hover)] hover:text-foreground\",\n                  )}\n                >\n                  {model.label}\n                  {model.id === current?.id ? <Check className=\"h-3.5 w-3.5 shrink-0\" aria-hidden /> : null}\n                </button>\n              </li>\n            ))}\n          </motion.ul>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  );\n}\n\nfunction ToolbarIconButton({\n  \"aria-label\": ariaLabel,\n  onClick,\n  children,\n}: {\n  \"aria-label\": string;\n  onClick?: () => void;\n  children: ReactNode;\n}) {\n  const reduce = useReducedMotion() ?? false;\n  return (\n    <motion.button\n      type=\"button\"\n      aria-label={ariaLabel}\n      onClick={onClick}\n      whileTap={reduce ? undefined : { scale: 0.92, transition: SPRING_PRESS }}\n      className=\"flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-[var(--wb-hover)] hover:text-foreground\"\n    >\n      {children}\n    </motion.button>\n  );\n}\n\n/** Icon-only when off; springs open into a filled \"Search\" pill when on. */\nfunction WebSearchToggle({ on, onToggle }: { on: boolean; onToggle: () => void }) {\n  const reduce = useReducedMotion() ?? false;\n  return (\n    <motion.button\n      type=\"button\"\n      layout={!reduce}\n      aria-pressed={on}\n      aria-label={on ? \"Turn off web search\" : \"Turn on web search\"}\n      onClick={onToggle}\n      transition={reduce ? { duration: 0.15, ease: EASE_OUT } : { layout: SPRING_LAYOUT }}\n      whileTap={reduce ? undefined : { scale: 0.96, transition: SPRING_PRESS }}\n      className={cn(\n        \"flex h-7 shrink-0 items-center justify-center gap-1.5 rounded-full px-2 font-medium text-[13px] transition-colors\",\n        on\n          ? \"bg-[var(--wb-accent)] text-[var(--wb-accent-fg)]\"\n          : \"w-7 px-0 text-muted-foreground hover:bg-[var(--wb-hover)]\",\n      )}\n    >\n      <Globe className=\"h-4 w-4 shrink-0\" aria-hidden />\n      {on ? \"Search\" : null}\n    </motion.button>\n  );\n}\n\n/** Morphs between a circular send arrow and a stop square — icon swap via SPRING_SWAP, per spec. */\nfunction SendStopButton({\n  streaming,\n  canSubmit,\n  onSend,\n  onStop,\n}: {\n  streaming: boolean;\n  canSubmit: boolean;\n  onSend: () => void;\n  onStop: () => void;\n}) {\n  const reduce = useReducedMotion() ?? false;\n  return (\n    <button\n      type=\"button\"\n      onClick={streaming ? onStop : onSend}\n      disabled={!streaming && !canSubmit}\n      aria-label={streaming ? \"Stop generating\" : \"Send message\"}\n      className=\"flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-foreground text-background disabled:pointer-events-none disabled:opacity-40\"\n    >\n      <AnimatePresence mode=\"wait\" initial={false}>\n        {streaming ? (\n          <motion.span\n            key=\"stop\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}\n            transition={reduce ? { duration: 0.12, ease: EASE_OUT } : SPRING_SWAP}\n            className=\"flex items-center justify-center\"\n          >\n            <Square className=\"h-3 w-3\" fill=\"currentColor\" aria-hidden />\n          </motion.span>\n        ) : (\n          <motion.span\n            key=\"send\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}\n            transition={reduce ? { duration: 0.12, ease: EASE_OUT } : SPRING_SWAP}\n            className=\"flex items-center justify-center\"\n          >\n            <ArrowUp className=\"h-4 w-4\" aria-hidden />\n          </motion.span>\n        )}\n      </AnimatePresence>\n    </button>\n  );\n}\n\n/**\n * Compact single-line AI prompt bar — the lightweight sibling to\n * `agent-composer`'s full workbench input. Shares its glass-surface toolbar\n * language (hairline shadow trio, pill controls, send/stop morph) but adds\n * two moves of its own: typing \"/\" opens a skill picker whose pick\n * \"solidifies\" into a removable chip, and submitting again while a reply is\n * still streaming queues the message instead of sending it — queued rows can\n * be steered in immediately, edited back into the draft, or dropped.\n */\nexport function PromptBar({\n  skills = [],\n  models = [],\n  defaultModelId,\n  credits,\n  onUpgrade,\n  placeholder = \"What do you want to do today?\",\n  onSubmit,\n  className,\n}: PromptBarProps) {\n  const reduce = useReducedMotion() ?? false;\n  const skillListId = useId();\n\n  const textareaRef = useRef<HTMLTextAreaElement>(null);\n  const composerShellRef = useRef<HTMLDivElement>(null);\n  const streamTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const idSeqRef = useRef(0);\n  const attachSeqRef = useRef(0);\n\n  const [text, setText] = useState(\"\");\n  const [skillId, setSkillId] = useState<string | undefined>(undefined);\n  const [attachments, setAttachments] = useState<Attachment[]>([]);\n  const [webSearch, setWebSearch] = useState(false);\n  const [modelId, setModelId] = useState(defaultModelId ?? models[0]?.id ?? \"\");\n  const [queue, setQueue] = useState<PromptBarQueuedMessage[]>([]);\n  const [streaming, setStreaming] = useState(false);\n  const [creditsLeft, setCreditsLeft] = useState(credits ?? 0);\n  const [creditsDismissed, setCreditsDismissed] = useState(false);\n  const [dismissed, setDismissed] = useState(false);\n  const [skillHighlight, setSkillHighlight] = useState(0);\n\n  const makeId = useCallback((prefix: string) => {\n    idSeqRef.current += 1;\n    return `${prefix}-${idSeqRef.current}`;\n  }, []);\n\n  // Auto-resize up to ~5 lines; the wrapper (max-h + overflow-y-auto) clips/scrolls past that.\n  // biome-ignore lint/correctness/useExhaustiveDependencies: `text` is the trigger — height depends on rendered content read from the DOM, not from the value itself.\n  useLayoutEffect(() => {\n    const el = textareaRef.current;\n    if (!el) return;\n    el.style.height = \"auto\";\n    el.style.height = `${el.scrollHeight}px`;\n  }, [text]);\n\n  const beginStream = useCallback(\n    (messageText: string, opts?: { skillId?: string; attachments?: string[] }) => {\n      onSubmit?.({\n        text: messageText,\n        skillId: opts?.skillId,\n        attachments: opts?.attachments ?? [],\n        webSearch,\n        modelId,\n      });\n      setCreditsLeft((c) => Math.max(0, c - CREDIT_COST_PER_SEND));\n      setStreaming(true);\n      if (streamTimerRef.current) clearTimeout(streamTimerRef.current);\n      streamTimerRef.current = setTimeout(() => {\n        streamTimerRef.current = null;\n        setStreaming(false);\n      }, STREAM_DEMO_MS);\n    },\n    [onSubmit, webSearch, modelId],\n  );\n\n  // Whenever a stream ends (timeout or manual stop), pop the next queued\n  // message and fire it — closes the demo loop described in the spec.\n  useEffect(() => {\n    if (streaming || queue.length === 0) return;\n    const head = queue[0];\n    setQueue((prev) => prev.slice(1));\n    beginStream(head.text);\n  }, [streaming, queue, beginStream]);\n\n  // Clear any pending demo timer on unmount.\n  useEffect(\n    () => () => {\n      if (streamTimerRef.current) clearTimeout(streamTimerRef.current);\n    },\n    [],\n  );\n\n  const handleStop = useCallback(() => {\n    if (streamTimerRef.current) {\n      clearTimeout(streamTimerRef.current);\n      streamTimerRef.current = null;\n    }\n    setStreaming(false);\n  }, []);\n\n  const canSubmit = text.trim().length > 0 || attachments.length > 0;\n\n  const handleComposerSubmit = () => {\n    const trimmed = text.trim();\n    if (trimmed.length === 0 && attachments.length === 0) return;\n    if (streaming) {\n      const queuedText = trimmed.length > 0 ? trimmed : attachments.map((a) => a.name).join(\", \");\n      setQueue((prev) => [...prev, { id: makeId(\"queued\"), text: queuedText }]);\n    } else {\n      beginStream(trimmed, { skillId, attachments: attachments.map((a) => a.name) });\n    }\n    setText(\"\");\n    setSkillId(undefined);\n    setAttachments([]);\n  };\n\n  const handleSteer = (item: PromptBarQueuedMessage) => {\n    setQueue((prev) => prev.filter((q) => q.id !== item.id));\n    if (streamTimerRef.current) {\n      clearTimeout(streamTimerRef.current);\n      streamTimerRef.current = null;\n    }\n    beginStream(item.text);\n  };\n\n  const handleEditQueued = (item: PromptBarQueuedMessage) => {\n    setQueue((prev) => prev.filter((q) => q.id !== item.id));\n    setText(item.text);\n    textareaRef.current?.focus();\n  };\n\n  const handleDeleteQueued = (item: PromptBarQueuedMessage) => {\n    setQueue((prev) => prev.filter((q) => q.id !== item.id));\n  };\n\n  const activeSkill = skills.find((s) => s.id === skillId);\n\n  const slashMatch = !skillId && skills.length > 0 ? text.match(/^\\/(\\S*)/) : null;\n  const slashQuery = slashMatch ? slashMatch[1] : null;\n  const filteredSkills = slashQuery !== null ? skills.filter((s) => matchesSkillQuery(s, slashQuery)) : [];\n  const skillPanelOpen = slashQuery !== null && !dismissed && filteredSkills.length > 0;\n  const clampedHighlight = Math.min(skillHighlight, Math.max(filteredSkills.length - 1, 0));\n  const highlightedSkill = skillPanelOpen ? filteredSkills[clampedHighlight] : undefined;\n  const activeOptionId = highlightedSkill ? `${skillListId}-${highlightedSkill.id}` : undefined;\n\n  const selectSkill = (skill: PromptBarSkill) => {\n    const match = text.match(/^\\/(\\S*)/);\n    const rest = match ? text.slice(match[0].length).replace(/^\\s+/, \"\") : text;\n    setText(rest);\n    setSkillId(skill.id);\n    setDismissed(false);\n    textareaRef.current?.focus();\n  };\n\n  const removeSkillChip = () => {\n    setSkillId(undefined);\n    textareaRef.current?.focus();\n  };\n\n  const addDemoAttachment = () => {\n    const name = DEMO_FILE_NAMES[attachSeqRef.current % DEMO_FILE_NAMES.length];\n    attachSeqRef.current += 1;\n    setAttachments((prev) => [...prev, { id: makeId(\"att\"), name }]);\n  };\n\n  const removeAttachment = (id: string) => {\n    setAttachments((prev) => prev.filter((a) => a.id !== id));\n  };\n\n  // Close the skill panel on outside pointerdown, matching the popover\n  // dismissal convention used across the composer family.\n  useEffect(() => {\n    if (!skillPanelOpen) return;\n    const onPointerDown = (event: PointerEvent) => {\n      if (composerShellRef.current && !composerShellRef.current.contains(event.target as Node)) {\n        setDismissed(true);\n      }\n    };\n    window.addEventListener(\"pointerdown\", onPointerDown);\n    return () => window.removeEventListener(\"pointerdown\", onPointerDown);\n  }, [skillPanelOpen]);\n\n  const handleTextChange = (event: ChangeEvent<HTMLTextAreaElement>) => {\n    setText(event.target.value);\n    setDismissed(false);\n    setSkillHighlight(0);\n  };\n\n  const handleTextKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {\n    if (skillPanelOpen) {\n      if (event.key === \"ArrowDown\") {\n        event.preventDefault();\n        setSkillHighlight((i) => (Math.min(i, filteredSkills.length - 1) + 1) % filteredSkills.length);\n        return;\n      }\n      if (event.key === \"ArrowUp\") {\n        event.preventDefault();\n        setSkillHighlight((i) => {\n          const clamped = Math.min(i, filteredSkills.length - 1);\n          return (clamped - 1 + filteredSkills.length) % filteredSkills.length;\n        });\n        return;\n      }\n      if (event.key === \"Escape\") {\n        event.preventDefault();\n        setDismissed(true);\n        return;\n      }\n      if (event.key === \"Enter\" && !event.shiftKey) {\n        event.preventDefault();\n        if (highlightedSkill) selectSkill(highlightedSkill);\n        return;\n      }\n    }\n    if (event.key === \"Enter\" && !event.shiftKey) {\n      event.preventDefault();\n      handleComposerSubmit();\n    }\n  };\n\n  const effectivePlaceholder = activeSkill ? (activeSkill.hint ?? activeSkill.label) : placeholder;\n  const showCreditsBanner = credits !== undefined && !creditsDismissed;\n\n  return (\n    <div className={cn(\"flex w-full flex-col gap-2\", className)}>\n      <AnimatePresence initial={false}>\n        {showCreditsBanner ? (\n          <CreditsBanner credits={creditsLeft} onUpgrade={onUpgrade} onDismiss={() => setCreditsDismissed(true)} />\n        ) : null}\n      </AnimatePresence>\n\n      <AnimatePresence initial={false}>\n        {queue.map((item) => (\n          <QueuedRow\n            key={item.id}\n            item={item}\n            onSteer={() => handleSteer(item)}\n            onEdit={() => handleEditQueued(item)}\n            onDelete={() => handleDeleteQueued(item)}\n          />\n        ))}\n      </AnimatePresence>\n\n      <motion.div layout={!reduce} ref={composerShellRef} className=\"relative\">\n        <AnimatePresence>\n          {skillPanelOpen ? (\n            <SkillPanel\n              listId={skillListId}\n              items={filteredSkills}\n              highlightIndex={clampedHighlight}\n              onSelect={selectSkill}\n            />\n          ) : null}\n        </AnimatePresence>\n\n        <div\n          style={{ boxShadow: HAIRLINE_SHADOW }}\n          className=\"relative flex flex-col rounded-[20px] bg-[var(--wb-surface-composer)] backdrop-blur-lg\"\n        >\n          {attachments.length > 0 ? (\n            <div className=\"flex flex-wrap gap-1.5 px-3.5 pt-3\">\n              <AnimatePresence initial={false}>\n                {attachments.map((a) => (\n                  <AttachmentChip key={a.id} name={a.name} onRemove={() => removeAttachment(a.id)} />\n                ))}\n              </AnimatePresence>\n            </div>\n          ) : null}\n\n          <div className=\"flex items-start gap-1.5 px-3.5 pt-2.5 pb-1\">\n            <AnimatePresence initial={false}>\n              {activeSkill ? (\n                <SkillChip key={activeSkill.id} skill={activeSkill} onRemove={removeSkillChip} />\n              ) : null}\n            </AnimatePresence>\n            <div className=\"max-h-[124px] min-h-[24px] flex-1 overflow-y-auto\">\n              <textarea\n                ref={textareaRef}\n                rows={1}\n                value={text}\n                onChange={handleTextChange}\n                onKeyDown={handleTextKeyDown}\n                placeholder={effectivePlaceholder}\n                aria-label=\"Prompt\"\n                role=\"combobox\"\n                aria-expanded={skillPanelOpen}\n                aria-controls={skillListId}\n                aria-activedescendant={activeOptionId}\n                aria-autocomplete=\"list\"\n                autoComplete=\"off\"\n                className=\"min-h-[24px] w-full resize-none border-none bg-transparent text-sm leading-6 text-foreground outline-none placeholder:text-muted-foreground\"\n              />\n            </div>\n          </div>\n\n          <div className=\"flex items-center gap-1 px-2 pt-1 pb-1.5\">\n            <ToolbarIconButton aria-label=\"Add attachment\" onClick={addDemoAttachment}>\n              <Paperclip className=\"h-4 w-4\" aria-hidden />\n            </ToolbarIconButton>\n            <ToolbarIconButton aria-label=\"Dictate\">\n              <Mic className=\"h-4 w-4\" aria-hidden />\n            </ToolbarIconButton>\n            <WebSearchToggle on={webSearch} onToggle={() => setWebSearch((v) => !v)} />\n            <div className=\"ml-auto flex items-center gap-1\">\n              {models.length > 0 ? <ModelMenu models={models} modelId={modelId} onSelect={setModelId} /> : null}\n              <SendStopButton\n                streaming={streaming}\n                canSubmit={canSubmit}\n                onSend={handleComposerSubmit}\n                onStop={handleStop}\n              />\n            </div>\n          </div>\n        </div>\n      </motion.div>\n    </div>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}