{"slug":"prompt-bar","category":"composer","job":"ask","added":"2026-09-11T20:37Z","title":"Prompt Bar","description":"A composer with @ sources, / commands, a model picker, and dictation.","url":"https://ai-patterns.app/patterns/composer/prompt-bar","markdown":"https://ai-patterns.app/patterns/composer/prompt-bar.md","json":"https://ai-patterns.app/api/patterns/composer/prompt-bar","seenIn":[{"team":"Cursor","source":"https://cursor.com/docs/context/symbols"},{"team":"Claude","source":"https://claude.com/download"}],"spec":"# Prompt Bar\n\n## Summary\nThe primary text composer for talking to an agent: a single input that also supports inline `@`-mentions of context/sources, `/`-slash commands, a model picker, and voice dictation, all without leaving the text field.\n\n## When to use\n- The main input for a chat/agent interface where users need to both type free text and quickly reference structured things (files, people, data sources) or trigger canned actions inline.\n\n## When not to use\n- A simple, single-purpose text field with no mentions, commands, or model choice — use a plain input. Don't add this pattern's complexity where none of it is needed.\n- A model picker when the product only ever has one model — remove unused affordances rather than showing a picker with a single option.\n\n## Anatomy\n- Leading add/attach action.\n- Auto-growing text field.\n- Inline `@`/`/` autocomplete popover.\n- Trailing model picker.\n- Dictation toggle.\n- Send button.\n\n## Behavior\n- `@` opens a source/context picker filtered as the user keeps typing; `/` opens a command picker the same way.\n- Arrow keys move the highlighted suggestion; Enter or Tab accepts it; Escape dismisses the popover without clearing what was typed.\n- Plain Enter (no popover open) submits the message; Shift+Enter inserts a newline.\n- The send button is visually inactive until there is non-whitespace content.\n- The model picker and dictation toggle are independent of the text content and can be changed at any time, including mid-draft.\n- Dictation shows an unambiguous \"listening\" state (e.g. a pulsing indicator) so it's never unclear whether the mic is live.\n\n## Content guidelines\n- Mention and command labels are short, recognizable nouns/verbs.\n- Command descriptions (shown secondary to the label) state what the command does in a few words, not a full sentence.\n\n## Accessibility\n- The autocomplete popover must be fully operable from the keyboard — it's the primary interaction path, not a mouse-only nicety — and should communicate the current highlighted item to assistive tech.\n- The model picker and dictation toggle need explicit `aria-label`s since they're icon-only or short-label controls.\n- Respect `prefers-reduced-motion` for the dictation pulse; fall back to a solid color change instead of an animated ring.\n\n## Related patterns\n- **Prompt Bar Pro** — Use it instead on a first-run screen that needs dictation and a richer composer.\n- **Thinking Loader** — Use it as well for the agent's working state after the message is sent.\n- **Streaming Text** — Use it as well for the reply, so the composer, loader and answer form a full turn.\n","code":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { ChevronDown, ChevronRight, Folder, Link2, Mic, Paperclip, Plus } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { StopGenerationButton, type GenerationState } from \"../../buttons/stop-generation-button/component\";\nimport { Attachment, AttachmentChip } from \"../../uploads/attachment-chip/component\";\n\nexport type PromptBarVariant = \"rounded\" | \"pill\";\n\nexport interface PromptBarItem {\n  id: string;\n  label: string;\n  description?: string;\n}\n\nexport interface PromptBarProps {\n  variant?: PromptBarVariant;\n  placeholder?: string;\n  sources?: PromptBarItem[];\n  commands?: PromptBarItem[];\n  models?: PromptBarItem[];\n  onSubmit?: (value: string) => void;\n  className?: string;\n}\n\nconst defaultSources: PromptBarItem[] = [\n  { id: \"docs\", label: \"Docs\" },\n  { id: \"codebase\", label: \"Codebase\" },\n  { id: \"web\", label: \"Web\" },\n  { id: \"figma\", label: \"Figma\" },\n];\n\nconst defaultCommands: PromptBarItem[] = [\n  { id: \"summarize\", label: \"/summarize\", description: \"Summarize the thread\" },\n  { id: \"draft\", label: \"/draft\", description: \"Draft a reply\" },\n  { id: \"translate\", label: \"/translate\", description: \"Translate this message\" },\n];\n\nconst defaultModels: PromptBarItem[] = [\n  { id: \"model-5\", label: \"Model 5\" },\n  { id: \"model-5-mini\", label: \"Model 5 mini\" },\n  { id: \"model-4\", label: \"Model 4\" },\n];\n\ninterface Trigger {\n  type: \"source\" | \"command\";\n  query: string;\n  start: number;\n}\n\nfunction getActiveTrigger(text: string): Trigger | null {\n  const match = text.match(/(?:^|\\s)([@/])(\\S*)$/);\n  if (!match) return null;\n  const [full, symbol, query] = match;\n  return {\n    type: symbol === \"@\" ? \"source\" : \"command\",\n    query: query.toLowerCase(),\n    start: text.length - full.length + full.indexOf(symbol),\n  };\n}\n\nconst CHIP_CLASS =\n  \"inline-block rounded-sm bg-primary/10 text-primary px-1.5 py-1 text-xs font-medium leading-none select-none align-middle mx-px mb-1 mr-1\";\n\nexport function PromptBar({\n  variant = \"rounded\",\n  placeholder = \"Write a message...\",\n  sources = defaultSources,\n  commands = defaultCommands,\n  models = defaultModels,\n  onSubmit,\n  className,\n}: PromptBarProps) {\n  const [model, setModel] = React.useState(models[0]?.label ?? \"\");\n  const [modelOpen, setModelOpen] = React.useState(false);\n  const [dictating, setDictating] = React.useState(false);\n  const [activeIndex, setActiveIndex] = React.useState(0);\n  const [suppressed, setSuppressed] = React.useState(false);\n  const [generationState, setGenerationState] = React.useState<GenerationState>(\"idle\");\n  const [textBeforeCursor, setTextBeforeCursor] = React.useState(\"\");\n  const [hasContent, setHasContent] = React.useState(false);\n  const [attachments, setAttachments] = React.useState<Attachment[]>([]);\n  const [attachMenuOpen, setAttachMenuOpen] = React.useState(false);\n  const editorRef = React.useRef<HTMLDivElement>(null);\n  const fileInputRef = React.useRef<HTMLInputElement>(null);\n  const modelMenuRef = useClickOutside<HTMLDivElement>(() => setModelOpen(false));\n  const attachMenuRef = useClickOutside<HTMLDivElement>(() => setAttachMenuOpen(false));\n\n  const isDisabled = generationState === \"generating\";\n\n  function addAttachments(files: FileList | null) {\n    if (!files) return;\n    const next: Attachment[] = Array.from(files).map((f) => ({\n      id: `${f.name}-${Date.now()}-${Math.random()}`,\n      name: f.name,\n      size: f.size,\n      progress: 100,\n      status: \"done\" as const,\n      previewUrl: f.type.startsWith(\"image/\") ? URL.createObjectURL(f) : undefined,\n    }));\n    setAttachments((prev) => [...prev, ...next]);\n  }\n\n  function removeAttachment(id: string) {\n    setAttachments((prev) => {\n      const att = prev.find((a) => a.id === id);\n      if (att?.previewUrl) URL.revokeObjectURL(att.previewUrl);\n      return prev.filter((a) => a.id !== id);\n    });\n  }\n\n  const trigger = getActiveTrigger(textBeforeCursor);\n  const suggestions = trigger\n    ? (trigger.type === \"source\" ? sources : commands).filter((item) =>\n        item.label\n          .toLowerCase()\n          .replace(/^\\//, \"\")\n          .includes(trigger.query.replace(/^\\//, \"\"))\n      )\n    : [];\n  const showSuggestions = Boolean(trigger) && !suppressed && suggestions.length > 0;\n  const canSend = hasContent || attachments.length > 0;\n\n  const triggerKey = trigger ? `${trigger.type}:${trigger.start}` : null;\n  const prevTriggerKeyRef = React.useRef(triggerKey);\n  if (triggerKey !== prevTriggerKeyRef.current) {\n    prevTriggerKeyRef.current = triggerKey;\n    if (activeIndex !== 0) setActiveIndex(0);\n    if (suppressed) setSuppressed(false);\n  }\n\n  React.useEffect(() => {\n    if (generationState !== \"generating\") return;\n    const id = window.setTimeout(() => setGenerationState(\"idle\"), 2600);\n    return () => window.clearTimeout(id);\n  }, [generationState]);\n\n  function getTextBeforeCursor(): string {\n    const sel = window.getSelection();\n    const el = editorRef.current;\n    if (!sel?.rangeCount || !el) return \"\";\n    const cursorRange = sel.getRangeAt(0);\n    const range = document.createRange();\n    range.setStart(el, 0);\n    range.setEnd(cursorRange.startContainer, cursorRange.startOffset);\n    const frag = range.cloneContents();\n    let text = \"\";\n    frag.childNodes.forEach((node) => {\n      if (node.nodeType === Node.TEXT_NODE) {\n        text += node.textContent ?? \"\";\n      } else if (node instanceof HTMLElement && node.dataset.chip === \"true\") {\n        text += `@${node.dataset.label}`;\n      }\n    });\n    return text;\n  }\n\n  function serializeEditor(): string {\n    const el = editorRef.current;\n    if (!el) return \"\";\n    let text = \"\";\n    el.childNodes.forEach((node) => {\n      if (node.nodeType === Node.TEXT_NODE) {\n        text += node.textContent ?? \"\";\n      } else if (node instanceof HTMLElement && node.dataset.chip === \"true\") {\n        text += `@${node.dataset.label}`;\n      }\n    });\n    return text.trim();\n  }\n\n  function updateEditorState() {\n    const el = editorRef.current;\n    if (!el) return;\n    const hasText = (el.textContent ?? \"\").trim().length > 0;\n    const hasChips = el.querySelector(\"[data-chip]\") !== null;\n    setHasContent(hasText || hasChips);\n    setTextBeforeCursor(getTextBeforeCursor());\n  }\n\n  function handlePaste(e: React.ClipboardEvent<HTMLDivElement>) {\n    e.preventDefault();\n    const text = e.clipboardData.getData(\"text/plain\");\n    document.execCommand(\"insertText\", false, text);\n  }\n\n  function insertChip(item: PromptBarItem) {\n    const sel = window.getSelection();\n    const el = editorRef.current;\n    if (!sel?.rangeCount || !el) return;\n\n    const range = sel.getRangeAt(0);\n    const container = range.startContainer;\n    const offset = range.startOffset;\n\n    // Delete the @query text from the current text node\n    if (container.nodeType === Node.TEXT_NODE) {\n      const text = container.textContent ?? \"\";\n      const textBefore = text.slice(0, offset);\n      const atIndex = textBefore.lastIndexOf(\"@\");\n      if (atIndex !== -1) {\n        container.textContent = text.slice(0, atIndex) + text.slice(offset);\n        const newRange = document.createRange();\n        newRange.setStart(container, atIndex);\n        newRange.collapse(true);\n        sel.removeAllRanges();\n        sel.addRange(newRange);\n      }\n    }\n\n    const chip = document.createElement(\"span\");\n    chip.contentEditable = \"false\";\n    chip.dataset.chip = \"true\";\n    chip.dataset.label = item.label;\n    chip.textContent = `@${item.label}`;\n    chip.className = CHIP_CLASS;\n\n    const insertRange = sel.getRangeAt(0);\n    insertRange.insertNode(chip);\n\n    // Place cursor in a text node right after the chip\n    const space = document.createTextNode(\" \");\n    const afterRange = document.createRange();\n    afterRange.setStartAfter(chip);\n    afterRange.insertNode(space);\n    afterRange.setStartAfter(space);\n    afterRange.collapse(true);\n    sel.removeAllRanges();\n    sel.addRange(afterRange);\n\n    updateEditorState();\n    el.focus();\n  }\n\n  function handleSubmit() {\n    const content = serializeEditor();\n    if ((!content && attachments.length === 0) || isDisabled) return;\n    onSubmit?.(content);\n    if (editorRef.current) editorRef.current.innerHTML = \"\";\n    setHasContent(false);\n    setTextBeforeCursor(\"\");\n    attachments.forEach((a) => { if (a.previewUrl) URL.revokeObjectURL(a.previewUrl); });\n    setAttachments([]);\n    setGenerationState(\"generating\");\n  }\n\n  function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {\n    if (showSuggestions) {\n      if (e.key === \"ArrowDown\") {\n        e.preventDefault();\n        setActiveIndex((i) => (i + 1) % suggestions.length);\n        return;\n      }\n      if (e.key === \"ArrowUp\") {\n        e.preventDefault();\n        setActiveIndex((i) => (i - 1 + suggestions.length) % suggestions.length);\n        return;\n      }\n      if (e.key === \"Enter\" || e.key === \"Tab\") {\n        e.preventDefault();\n        insertChip(suggestions[activeIndex]);\n        return;\n      }\n      if (e.key === \"Escape\") {\n        e.preventDefault();\n        setSuppressed(true);\n        return;\n      }\n    }\n    if (e.key === \"Enter\" && !e.shiftKey) {\n      e.preventDefault();\n      handleSubmit();\n    }\n  }\n\n  return (\n    <div className={cn(\"relative w-full\", className)}>\n      <AnimatePresence>\n        {showSuggestions && (\n          <motion.div\n            initial={{ opacity: 0, y: 4 }}\n            animate={{ opacity: 1, y: 0 }}\n            exit={{ opacity: 0, y: 4 }}\n            transition={{ duration: 0.12 }}\n            className=\"absolute bottom-full left-0 z-10 mb-2 w-full max-w-xs overflow-hidden rounded-xl border bg-popover shadow-md\"\n          >\n            {suggestions.map((item, i) => (\n              <button\n                key={item.id}\n                type=\"button\"\n                onMouseDown={(e) => {\n                  e.preventDefault();\n                  insertChip(item);\n                }}\n                className={cn(\n                  \"flex w-full flex-col items-start gap-0.5 px-3 py-2 text-left text-sm\",\n                  i === activeIndex ? \"bg-accent text-accent-foreground\" : \"hover:bg-accent/60\"\n                )}\n              >\n                <span className=\"font-medium\">\n                  {trigger?.type === \"source\" ? `@${item.label}` : item.label}\n                </span>\n                {item.description && (\n                  <span className=\"text-xs text-muted-foreground\">{item.description}</span>\n                )}\n              </button>\n            ))}\n          </motion.div>\n        )}\n      </AnimatePresence>\n\n      <div\n        className={cn(\n          \"flex flex-wrap items-end gap-1.5 border bg-card p-2.5 shadow-sm sm:flex-nowrap sm:gap-1 sm:p-2\",\n          variant === \"pill\" ? \"rounded-3xl sm:rounded-full\" : \"rounded-2xl\"\n        )}\n      >\n        <input\n          ref={fileInputRef}\n          type=\"file\"\n          multiple\n          className=\"hidden\"\n          onChange={(e) => addAttachments(e.target.files)}\n          onClick={(e) => { (e.target as HTMLInputElement).value = \"\"; }}\n        />\n\n        <div ref={attachMenuRef} className=\"relative order-2 shrink-0 sm:order-none\">\n          <button\n            type=\"button\"\n            onClick={() => setAttachMenuOpen((v) => !v)}\n            className=\"flex size-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground\"\n            aria-label=\"Add attachment\"\n          >\n            <Plus className=\"size-4\" />\n          </button>\n          <AnimatePresence>\n            {attachMenuOpen && (\n              <motion.div\n                initial={{ opacity: 0, y: 4 }}\n                animate={{ opacity: 1, y: 0 }}\n                exit={{ opacity: 0, y: 4 }}\n                transition={{ duration: 0.12 }}\n                className=\"absolute bottom-full left-0 z-20 mb-1.5 w-52 overflow-hidden rounded-xl border bg-popover shadow-md\"\n              >\n                <button\n                  type=\"button\"\n                  onMouseDown={(e) => { e.preventDefault(); fileInputRef.current?.click(); setAttachMenuOpen(false); }}\n                  className=\"flex w-full items-center gap-2.5 px-3 py-2.5 text-sm hover:bg-accent\"\n                >\n                  <Paperclip className=\"size-4 text-muted-foreground\" />\n                  Upload files or images\n                </button>\n                <button type=\"button\" className=\"flex w-full items-center gap-2.5 px-3 py-2.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground\">\n                  <Link2 className=\"size-4\" />\n                  Connectors\n                  <ChevronRight className=\"ml-auto size-3.5\" />\n                </button>\n                <button type=\"button\" className=\"flex w-full items-center gap-2.5 px-3 py-2.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground\">\n                  <Folder className=\"size-4\" />\n                  Projects\n                  <ChevronRight className=\"ml-auto size-3.5\" />\n                </button>\n              </motion.div>\n            )}\n          </AnimatePresence>\n        </div>\n\n        <div className=\"relative order-1 w-full min-w-0 basis-full sm:order-none sm:w-auto sm:flex-1\">\n          <AnimatePresence initial={false}>\n            {attachments.length > 0 && (\n              <motion.div\n                initial={{ opacity: 0, height: 0 }}\n                animate={{ opacity: 1, height: \"auto\" }}\n                exit={{ opacity: 0, height: 0 }}\n                transition={{ duration: 0.15 }}\n                className=\"overflow-hidden\"\n              >\n                <ul className=\"flex gap-2 overflow-x-auto pt-2 pr-2 pb-2\">\n                  {attachments.map((att) => (\n                    <AttachmentChip key={att.id} attachment={att} onRemove={removeAttachment} />\n                  ))}\n                </ul>\n              </motion.div>\n            )}\n          </AnimatePresence>\n          <div className=\"relative\">\n            <div\n              ref={editorRef}\n              contentEditable={!isDisabled}\n              suppressContentEditableWarning\n              role=\"textbox\"\n              aria-multiline=\"true\"\n              aria-label={placeholder}\n              onInput={updateEditorState}\n              onKeyDown={handleKeyDown}\n              onPaste={handlePaste}\n              className={cn(\n                \"max-h-40 min-h-[1.5rem] overflow-x-hidden overflow-y-auto break-words bg-transparent py-1.5 text-sm outline-none leading-normal\",\n                isDisabled && \"pointer-events-none text-muted-foreground\"\n              )}\n            />\n            {!hasContent && (\n              <span className=\"pointer-events-none absolute left-0 top-1.5 text-sm text-muted-foreground\">\n                {placeholder}\n              </span>\n            )}\n          </div>\n        </div>\n\n        <div ref={modelMenuRef} className=\"relative order-2 ml-auto shrink-0 sm:order-none sm:ml-0\">\n          <button\n            type=\"button\"\n            onClick={() => setModelOpen((v) => !v)}\n            className=\"flex items-center gap-1 rounded-full px-2 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground\"\n          >\n            {model}\n            <ChevronDown className={cn(\"size-3.5 transition-transform\", modelOpen && \"rotate-180\")} />\n          </button>\n          <AnimatePresence>\n            {modelOpen && (\n              <motion.div\n                initial={{ opacity: 0, y: 4 }}\n                animate={{ opacity: 1, y: 0 }}\n                exit={{ opacity: 0, y: 4 }}\n                transition={{ duration: 0.12 }}\n                className=\"absolute bottom-full right-0 z-10 mb-2 w-40 overflow-hidden rounded-xl border bg-popover shadow-md\"\n              >\n                {models.map((m) => (\n                  <button\n                    key={m.id}\n                    type=\"button\"\n                    onClick={() => {\n                      setModel(m.label);\n                      setModelOpen(false);\n                    }}\n                    className={cn(\n                      \"block w-full px-3 py-2 text-left text-sm hover:bg-accent\",\n                      m.label === model && \"font-medium\"\n                    )}\n                  >\n                    {m.label}\n                  </button>\n                ))}\n              </motion.div>\n            )}\n          </AnimatePresence>\n        </div>\n\n        <button\n          type=\"button\"\n          onClick={() => setDictating((v) => !v)}\n          aria-pressed={dictating}\n          aria-label=\"Toggle dictation\"\n          className={cn(\n            \"relative order-2 flex size-8 shrink-0 items-center justify-center rounded-full transition-colors sm:order-none\",\n            dictating ? \"text-destructive\" : \"text-muted-foreground hover:bg-accent hover:text-foreground\"\n          )}\n        >\n          {dictating && (\n            <motion.span\n              className=\"absolute inset-0 rounded-full bg-destructive/20\"\n              animate={{ scale: [1, 1.4], opacity: [0, 0.6, 0] }}\n              transition={{ duration: 1.2, repeat: Infinity, ease: \"easeOut\" }}\n            />\n          )}\n          <Mic className=\"size-4\" />\n        </button>\n\n        <StopGenerationButton\n          state={generationState}\n          disabled={!canSend}\n          onSubmit={handleSubmit}\n          onStop={() => setGenerationState(\"idle\")}\n          className=\"order-2 sm:order-none\"\n        />\n      </div>\n    </div>\n  );\n}\n\nfunction useClickOutside<T extends HTMLElement>(onOutside: () => void) {\n  const ref = React.useRef<T>(null);\n  React.useEffect(() => {\n    function handle(e: MouseEvent) {\n      if (ref.current && !ref.current.contains(e.target as Node)) onOutside();\n    }\n    document.addEventListener(\"mousedown\", handle);\n    return () => document.removeEventListener(\"mousedown\", handle);\n  }, [onOutside]);\n  return ref;\n}\n","dependencies":["lucide-react","motion"],"localImports":["@/lib/utils"]}