AI Patterns

composer

Prompt Bar

A composer with @ sources, / commands, a model picker, and dictation.

Write a message...

Try typing @ to reference a source or / for commands.

"use client";

import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import { ChevronDown, ChevronRight, Folder, Link2, Mic, Paperclip, Plus } from "lucide-react";

import { cn } from "@/lib/utils";
import { StopGenerationButton, type GenerationState } from "../../buttons/stop-generation-button/component";
import { Attachment, AttachmentChip } from "../../uploads/attachment-chip/component";

export type PromptBarVariant = "rounded" | "pill";

export interface PromptBarItem {
  id: string;
  label: string;
  description?: string;
}

export interface PromptBarProps {
  variant?: PromptBarVariant;
  placeholder?: string;
  sources?: PromptBarItem[];
  commands?: PromptBarItem[];
  models?: PromptBarItem[];
  onSubmit?: (value: string) => void;
  className?: string;
}

const defaultSources: PromptBarItem[] = [
  { id: "docs", label: "Docs" },
  { id: "codebase", label: "Codebase" },
  { id: "web", label: "Web" },
  { id: "figma", label: "Figma" },
];

const defaultCommands: PromptBarItem[] = [
  { id: "summarize", label: "/summarize", description: "Summarize the thread" },
  { id: "draft", label: "/draft", description: "Draft a reply" },
  { id: "translate", label: "/translate", description: "Translate this message" },
];

const defaultModels: PromptBarItem[] = [
  { id: "model-5", label: "Model 5" },
  { id: "model-5-mini", label: "Model 5 mini" },
  { id: "model-4", label: "Model 4" },
];

interface Trigger {
  type: "source" | "command";
  query: string;
  start: number;
}

function getActiveTrigger(text: string): Trigger | null {
  const match = text.match(/(?:^|\s)([@/])(\S*)$/);
  if (!match) return null;
  const [full, symbol, query] = match;
  return {
    type: symbol === "@" ? "source" : "command",
    query: query.toLowerCase(),
    start: text.length - full.length + full.indexOf(symbol),
  };
}

const CHIP_CLASS =
  "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";

export function PromptBar({
  variant = "rounded",
  placeholder = "Write a message...",
  sources = defaultSources,
  commands = defaultCommands,
  models = defaultModels,
  onSubmit,
  className,
}: PromptBarProps) {
  const [model, setModel] = React.useState(models[0]?.label ?? "");
  const [modelOpen, setModelOpen] = React.useState(false);
  const [dictating, setDictating] = React.useState(false);
  const [activeIndex, setActiveIndex] = React.useState(0);
  const [suppressed, setSuppressed] = React.useState(false);
  const [generationState, setGenerationState] = React.useState<GenerationState>("idle");
  const [textBeforeCursor, setTextBeforeCursor] = React.useState("");
  const [hasContent, setHasContent] = React.useState(false);
  const [attachments, setAttachments] = React.useState<Attachment[]>([]);
  const [attachMenuOpen, setAttachMenuOpen] = React.useState(false);
  const editorRef = React.useRef<HTMLDivElement>(null);
  const fileInputRef = React.useRef<HTMLInputElement>(null);
  const modelMenuRef = useClickOutside<HTMLDivElement>(() => setModelOpen(false));
  const attachMenuRef = useClickOutside<HTMLDivElement>(() => setAttachMenuOpen(false));

  const isDisabled = generationState === "generating";

  function addAttachments(files: FileList | null) {
    if (!files) return;
    const next: Attachment[] = Array.from(files).map((f) => ({
      id: `${f.name}-${Date.now()}-${Math.random()}`,
      name: f.name,
      size: f.size,
      progress: 100,
      status: "done" as const,
      previewUrl: f.type.startsWith("image/") ? URL.createObjectURL(f) : undefined,
    }));
    setAttachments((prev) => [...prev, ...next]);
  }

  function removeAttachment(id: string) {
    setAttachments((prev) => {
      const att = prev.find((a) => a.id === id);
      if (att?.previewUrl) URL.revokeObjectURL(att.previewUrl);
      return prev.filter((a) => a.id !== id);
    });
  }

  const trigger = getActiveTrigger(textBeforeCursor);
  const suggestions = trigger
    ? (trigger.type === "source" ? sources : commands).filter((item) =>
        item.label
          .toLowerCase()
          .replace(/^\//, "")
          .includes(trigger.query.replace(/^\//, ""))
      )
    : [];
  const showSuggestions = Boolean(trigger) && !suppressed && suggestions.length > 0;
  const canSend = hasContent || attachments.length > 0;

  const triggerKey = trigger ? `${trigger.type}:${trigger.start}` : null;
  const prevTriggerKeyRef = React.useRef(triggerKey);
  if (triggerKey !== prevTriggerKeyRef.current) {
    prevTriggerKeyRef.current = triggerKey;
    if (activeIndex !== 0) setActiveIndex(0);
    if (suppressed) setSuppressed(false);
  }

  React.useEffect(() => {
    if (generationState !== "generating") return;
    const id = window.setTimeout(() => setGenerationState("idle"), 2600);
    return () => window.clearTimeout(id);
  }, [generationState]);

  function getTextBeforeCursor(): string {
    const sel = window.getSelection();
    const el = editorRef.current;
    if (!sel?.rangeCount || !el) return "";
    const cursorRange = sel.getRangeAt(0);
    const range = document.createRange();
    range.setStart(el, 0);
    range.setEnd(cursorRange.startContainer, cursorRange.startOffset);
    const frag = range.cloneContents();
    let text = "";
    frag.childNodes.forEach((node) => {
      if (node.nodeType === Node.TEXT_NODE) {
        text += node.textContent ?? "";
      } else if (node instanceof HTMLElement && node.dataset.chip === "true") {
        text += `@${node.dataset.label}`;
      }
    });
    return text;
  }

  function serializeEditor(): string {
    const el = editorRef.current;
    if (!el) return "";
    let text = "";
    el.childNodes.forEach((node) => {
      if (node.nodeType === Node.TEXT_NODE) {
        text += node.textContent ?? "";
      } else if (node instanceof HTMLElement && node.dataset.chip === "true") {
        text += `@${node.dataset.label}`;
      }
    });
    return text.trim();
  }

  function updateEditorState() {
    const el = editorRef.current;
    if (!el) return;
    const hasText = (el.textContent ?? "").trim().length > 0;
    const hasChips = el.querySelector("[data-chip]") !== null;
    setHasContent(hasText || hasChips);
    setTextBeforeCursor(getTextBeforeCursor());
  }

  function handlePaste(e: React.ClipboardEvent<HTMLDivElement>) {
    e.preventDefault();
    const text = e.clipboardData.getData("text/plain");
    document.execCommand("insertText", false, text);
  }

  function insertChip(item: PromptBarItem) {
    const sel = window.getSelection();
    const el = editorRef.current;
    if (!sel?.rangeCount || !el) return;

    const range = sel.getRangeAt(0);
    const container = range.startContainer;
    const offset = range.startOffset;

    // Delete the @query text from the current text node
    if (container.nodeType === Node.TEXT_NODE) {
      const text = container.textContent ?? "";
      const textBefore = text.slice(0, offset);
      const atIndex = textBefore.lastIndexOf("@");
      if (atIndex !== -1) {
        container.textContent = text.slice(0, atIndex) + text.slice(offset);
        const newRange = document.createRange();
        newRange.setStart(container, atIndex);
        newRange.collapse(true);
        sel.removeAllRanges();
        sel.addRange(newRange);
      }
    }

    const chip = document.createElement("span");
    chip.contentEditable = "false";
    chip.dataset.chip = "true";
    chip.dataset.label = item.label;
    chip.textContent = `@${item.label}`;
    chip.className = CHIP_CLASS;

    const insertRange = sel.getRangeAt(0);
    insertRange.insertNode(chip);

    // Place cursor in a text node right after the chip
    const space = document.createTextNode(" ");
    const afterRange = document.createRange();
    afterRange.setStartAfter(chip);
    afterRange.insertNode(space);
    afterRange.setStartAfter(space);
    afterRange.collapse(true);
    sel.removeAllRanges();
    sel.addRange(afterRange);

    updateEditorState();
    el.focus();
  }

  function handleSubmit() {
    const content = serializeEditor();
    if ((!content && attachments.length === 0) || isDisabled) return;
    onSubmit?.(content);
    if (editorRef.current) editorRef.current.innerHTML = "";
    setHasContent(false);
    setTextBeforeCursor("");
    attachments.forEach((a) => { if (a.previewUrl) URL.revokeObjectURL(a.previewUrl); });
    setAttachments([]);
    setGenerationState("generating");
  }

  function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
    if (showSuggestions) {
      if (e.key === "ArrowDown") {
        e.preventDefault();
        setActiveIndex((i) => (i + 1) % suggestions.length);
        return;
      }
      if (e.key === "ArrowUp") {
        e.preventDefault();
        setActiveIndex((i) => (i - 1 + suggestions.length) % suggestions.length);
        return;
      }
      if (e.key === "Enter" || e.key === "Tab") {
        e.preventDefault();
        insertChip(suggestions[activeIndex]);
        return;
      }
      if (e.key === "Escape") {
        e.preventDefault();
        setSuppressed(true);
        return;
      }
    }
    if (e.key === "Enter" && !e.shiftKey) {
      e.preventDefault();
      handleSubmit();
    }
  }

  return (
    <div className={cn("relative w-full", className)}>
      <AnimatePresence>
        {showSuggestions && (
          <motion.div
            initial={{ opacity: 0, y: 4 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: 4 }}
            transition={{ duration: 0.12 }}
            className="absolute bottom-full left-0 z-10 mb-2 w-full max-w-xs overflow-hidden rounded-xl border bg-popover shadow-md"
          >
            {suggestions.map((item, i) => (
              <button
                key={item.id}
                type="button"
                onMouseDown={(e) => {
                  e.preventDefault();
                  insertChip(item);
                }}
                className={cn(
                  "flex w-full flex-col items-start gap-0.5 px-3 py-2 text-left text-sm",
                  i === activeIndex ? "bg-accent text-accent-foreground" : "hover:bg-accent/60"
                )}
              >
                <span className="font-medium">
                  {trigger?.type === "source" ? `@${item.label}` : item.label}
                </span>
                {item.description && (
                  <span className="text-xs text-muted-foreground">{item.description}</span>
                )}
              </button>
            ))}
          </motion.div>
        )}
      </AnimatePresence>

      <div
        className={cn(
          "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",
          variant === "pill" ? "rounded-3xl sm:rounded-full" : "rounded-2xl"
        )}
      >
        <input
          ref={fileInputRef}
          type="file"
          multiple
          className="hidden"
          onChange={(e) => addAttachments(e.target.files)}
          onClick={(e) => { (e.target as HTMLInputElement).value = ""; }}
        />

        <div ref={attachMenuRef} className="relative order-2 shrink-0 sm:order-none">
          <button
            type="button"
            onClick={() => setAttachMenuOpen((v) => !v)}
            className="flex size-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
            aria-label="Add attachment"
          >
            <Plus className="size-4" />
          </button>
          <AnimatePresence>
            {attachMenuOpen && (
              <motion.div
                initial={{ opacity: 0, y: 4 }}
                animate={{ opacity: 1, y: 0 }}
                exit={{ opacity: 0, y: 4 }}
                transition={{ duration: 0.12 }}
                className="absolute bottom-full left-0 z-20 mb-1.5 w-52 overflow-hidden rounded-xl border bg-popover shadow-md"
              >
                <button
                  type="button"
                  onMouseDown={(e) => { e.preventDefault(); fileInputRef.current?.click(); setAttachMenuOpen(false); }}
                  className="flex w-full items-center gap-2.5 px-3 py-2.5 text-sm hover:bg-accent"
                >
                  <Paperclip className="size-4 text-muted-foreground" />
                  Upload files or images
                </button>
                <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">
                  <Link2 className="size-4" />
                  Connectors
                  <ChevronRight className="ml-auto size-3.5" />
                </button>
                <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">
                  <Folder className="size-4" />
                  Projects
                  <ChevronRight className="ml-auto size-3.5" />
                </button>
              </motion.div>
            )}
          </AnimatePresence>
        </div>

        <div className="relative order-1 w-full min-w-0 basis-full sm:order-none sm:w-auto sm:flex-1">
          <AnimatePresence initial={false}>
            {attachments.length > 0 && (
              <motion.div
                initial={{ opacity: 0, height: 0 }}
                animate={{ opacity: 1, height: "auto" }}
                exit={{ opacity: 0, height: 0 }}
                transition={{ duration: 0.15 }}
                className="overflow-hidden"
              >
                <ul className="flex gap-2 overflow-x-auto pt-2 pr-2 pb-2">
                  {attachments.map((att) => (
                    <AttachmentChip key={att.id} attachment={att} onRemove={removeAttachment} />
                  ))}
                </ul>
              </motion.div>
            )}
          </AnimatePresence>
          <div className="relative">
            <div
              ref={editorRef}
              contentEditable={!isDisabled}
              suppressContentEditableWarning
              role="textbox"
              aria-multiline="true"
              aria-label={placeholder}
              onInput={updateEditorState}
              onKeyDown={handleKeyDown}
              onPaste={handlePaste}
              className={cn(
                "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",
                isDisabled && "pointer-events-none text-muted-foreground"
              )}
            />
            {!hasContent && (
              <span className="pointer-events-none absolute left-0 top-1.5 text-sm text-muted-foreground">
                {placeholder}
              </span>
            )}
          </div>
        </div>

        <div ref={modelMenuRef} className="relative order-2 ml-auto shrink-0 sm:order-none sm:ml-0">
          <button
            type="button"
            onClick={() => setModelOpen((v) => !v)}
            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"
          >
            {model}
            <ChevronDown className={cn("size-3.5 transition-transform", modelOpen && "rotate-180")} />
          </button>
          <AnimatePresence>
            {modelOpen && (
              <motion.div
                initial={{ opacity: 0, y: 4 }}
                animate={{ opacity: 1, y: 0 }}
                exit={{ opacity: 0, y: 4 }}
                transition={{ duration: 0.12 }}
                className="absolute bottom-full right-0 z-10 mb-2 w-40 overflow-hidden rounded-xl border bg-popover shadow-md"
              >
                {models.map((m) => (
                  <button
                    key={m.id}
                    type="button"
                    onClick={() => {
                      setModel(m.label);
                      setModelOpen(false);
                    }}
                    className={cn(
                      "block w-full px-3 py-2 text-left text-sm hover:bg-accent",
                      m.label === model && "font-medium"
                    )}
                  >
                    {m.label}
                  </button>
                ))}
              </motion.div>
            )}
          </AnimatePresence>
        </div>

        <button
          type="button"
          onClick={() => setDictating((v) => !v)}
          aria-pressed={dictating}
          aria-label="Toggle dictation"
          className={cn(
            "relative order-2 flex size-8 shrink-0 items-center justify-center rounded-full transition-colors sm:order-none",
            dictating ? "text-destructive" : "text-muted-foreground hover:bg-accent hover:text-foreground"
          )}
        >
          {dictating && (
            <motion.span
              className="absolute inset-0 rounded-full bg-destructive/20"
              animate={{ scale: [1, 1.4], opacity: [0, 0.6, 0] }}
              transition={{ duration: 1.2, repeat: Infinity, ease: "easeOut" }}
            />
          )}
          <Mic className="size-4" />
        </button>

        <StopGenerationButton
          state={generationState}
          disabled={!canSend}
          onSubmit={handleSubmit}
          onStop={() => setGenerationState("idle")}
          className="order-2 sm:order-none"
        />
      </div>
    </div>
  );
}

function useClickOutside<T extends HTMLElement>(onOutside: () => void) {
  const ref = React.useRef<T>(null);
  React.useEffect(() => {
    function handle(e: MouseEvent) {
      if (ref.current && !ref.current.contains(e.target as Node)) onOutside();
    }
    document.addEventListener("mousedown", handle);
    return () => document.removeEventListener("mousedown", handle);
  }, [onOutside]);
  return ref;
}

A UX spec for this pattern — written for agents implementing or reusing it, not the code.

# Prompt Bar

## Summary
The 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.

## When to use
- 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.

## When not to use
- 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.
- A model picker when the product only ever has one model — remove unused affordances rather than showing a picker with a single option.

## Anatomy
- Leading add/attach action.
- Auto-growing text field.
- Inline `@`/`/` autocomplete popover.
- Trailing model picker.
- Dictation toggle.
- Send button.

## Behavior
- `@` opens a source/context picker filtered as the user keeps typing; `/` opens a command picker the same way.
- Arrow keys move the highlighted suggestion; Enter or Tab accepts it; Escape dismisses the popover without clearing what was typed.
- Plain Enter (no popover open) submits the message; Shift+Enter inserts a newline.
- The send button is visually inactive until there is non-whitespace content.
- The model picker and dictation toggle are independent of the text content and can be changed at any time, including mid-draft.
- Dictation shows an unambiguous "listening" state (e.g. a pulsing indicator) so it's never unclear whether the mic is live.

## Content guidelines
- Mention and command labels are short, recognizable nouns/verbs.
- Command descriptions (shown secondary to the label) state what the command does in a few words, not a full sentence.

## Accessibility
- 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.
- The model picker and dictation toggle need explicit `aria-label`s since they're icon-only or short-label controls.
- Respect `prefers-reduced-motion` for the dictation pulse; fall back to a solid color change instead of an animated ring.

## Related patterns
- Typically pairs with Thinking Loader / Expandable Trace (the agent's working/done states) and Streaming Text (the reply) to form a full turn of conversation.