InputBar

Collect prompts and attachments in the composer. Supports controlled mode (value/onChange), drag/paste handling, info bar, typing animation, multi-question navigation, and free-form toolbar slots (leftActions/rightActions) for composing model/mode pickers or any custom controls. collapsible turns the composer into one line while it is empty and not focused, and unfolds it on a click or a focus — a place to start a conversation that costs a single row on a landing page or next to a page of its own; collapsedAction replaces the button at the end of that line, collapsedWidth narrows it, and onExpand tells the host it opened. glow lights colour under the composer while the agent works — three soft bands that drift beneath the card, which turns translucent so they bleed through it; glow="always" keeps it burning, the colours are the --ae-glow-1…--ae-glow-4 tokens, and prefers-reduced-motion stops the drift. The folded line is its own shape, not the usual composer: it takes --ae-input-radius-collapsed so it reads as a pill, keeps one filled button in the corner for both states, and springs open over --ae-duration-unfold with --ae-ease-spring. The folded line keeps the same field, so attachments, completions and history work the moment it unfolds.

Getting Started
$pnpm add @sinups/ai-kit @mantine/core @mantine/hooks @tabler/icons-react
Examples
Basic input
Folded to one line
Colour while the agent works
With attachments
Focus outline
Info bar
Info bar (bottom)
Question bar
Toolbar actions (model + mode)
Commands and mentions
Message queue
Full-width composer
Source

The component source as it ships in the package. Useful as a reference, or as a starting point if you want to fork a piece.

input/InputBar.tsx
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
  ActionIcon,
  Box,
  Combobox,
  Group,
  Stack,
  Text,
  rem,
  Textarea,
  UnstyledButton,
  useCombobox,
} from '@mantine/core';
import { getHotkeyHandler } from '@mantine/hooks';
import { IconX } from '@tabler/icons-react';
import { useInputTyping } from '../hooks/use-input-typing';
import { QuestionHeader, type QuestionHeaderLabels } from '../question/QuestionHeader';
import {
  QuestionAnswer,
  QuestionConfig,
  QuestionPrompt,
  type QuestionPromptLabels,
} from '../question/QuestionPrompt';
import type { AttachedFile, AttachedImage, ChatStatus, InputSuggestions } from '../types';
import { getContentWidthStyle, type ContentWidth } from '../utils/content-width';
import { cx } from '../utils/cx';
import { AttachmentButton, type AttachmentButtonLabels } from './AttachmentButton';
import { applyCompletion, CompletionToken, findCompletionToken } from './completion-token';
import { FileAttachment, type FileAttachmentLabels } from './FileAttachment';
import { InputContext, type InputContextItem } from './InputContext';
import { PastedTextAttachment } from './PastedTextAttachment';
import {
  DEFAULT_PASTE_LABEL,
  expandPastedText,
  insertPastePlaceholder,
  prunePastes,
  removePastePlaceholder,
  shouldCollapsePaste,
  countLines,
  type PasteCollapseThreshold,
  type PastedText,
} from './pasted-text';
import {
  canBrowseNewer,
  canBrowseOlder,
  INITIAL_PROMPT_HISTORY_STATE,
  navigatePromptHistory,
  type PromptHistoryState,
} from './prompt-history';
import { PromptHistorySearch, type PromptHistorySearchLabels } from './PromptHistorySearch';
import { SendButton } from './SendButton';
import { SuggestionItem, Suggestions } from './Suggestions';
import { CompletionItem, CompletionSource, useCompletionItems } from './use-completion-items';
import classes from './InputBar.module.css';

const DEFAULT_PLACEHOLDER = 'Send a message...';

export type QueuedMessage = { id: string; content: string };

export interface InputBarLabels {
  /** Name of a collapsed paste chip and its preview title, `{id}` is replaced */
  pastedText: string;
  /** Secondary line of a collapsed paste chip, `{lines}` is replaced */
  pastedTextLines: string;
  /** Accessible label of the remove button of a collapsed paste */
  removePastedText: string;
  /** Heading of the queued messages list */
  queued: string;
  /** Accessible label of the remove button of a queued message */
  removeQueuedMessage: string;
  /** Labels of the prompt history search dialog */
  historySearch: Partial<PromptHistorySearchLabels>;
  /** Accessible label of the send button, `Send` by default */
  send: string;
  /** Accessible label of the send button while the agent answers, `Stop` by default */
  stop: string;
  /** Accessible label of the close button of the info bar, `Close` by default */
  closeInfoBar: string;
  /** Accessible label of the send button while attachments upload, `Wait for uploads to finish` by default */
  waitForUploads: string;
  /** Accessible label of the attach button */
  attach: Partial<AttachmentButtonLabels>;
  /** Labels of the staged file and image chips */
  attachment: Partial<FileAttachmentLabels>;
  /** Labels of the question bar: submit, skip, placeholders */
  question: Partial<QuestionPromptLabels>;
  /** Labels of the question bar header: caption and navigation */
  questionHeader: Partial<QuestionHeaderLabels>;
  /** Accessible label of the remove button of a context chip, `{label}` is replaced */
  removeContext: string;
  /** Line that brings a removed context item back, `{label}` is replaced */
  restoreContext: string;
}

export const DEFAULT_INPUT_BAR_LABELS: InputBarLabels = {
  pastedText: DEFAULT_PASTE_LABEL,
  pastedTextLines: '{lines} lines',
  removePastedText: 'Remove pasted text',
  queued: 'Queued',
  removeQueuedMessage: 'Remove queued message',
  historySearch: {},
  send: 'Send',
  stop: 'Stop',
  closeInfoBar: 'Close',
  waitForUploads: 'Wait for uploads to finish',
  attach: {},
  attachment: {},
  question: {},
  questionHeader: {},
  removeContext: 'Remove {label}',
  restoreContext: 'Add back: {label}',
};

export interface InputBarProps {
  onSend: (message: { role: 'user'; content: string }) => void;
  status: ChatStatus;
  onStop: () => void;
  placeholder?: string;
  className?: string;
  style?: React.CSSProperties;
  /** Max width of the composer: a number in px or any CSS width, `420px` by default. Pass `'100%'` for a full-width chat */
  contentWidth?: ContentWidth;

  /** Renders the attach button when provided */
  onAttach?: () => void;
  attachedImages?: AttachedImage[];
  attachedFiles?: AttachedFile[];
  onRemoveImage?: (id: string) => void;
  onRemoveFile?: (id: string) => void;
  /** Stops the upload of a staged image or file */
  onCancelFile?: (id: string) => void;
  /** Starts a failed upload of a staged image or file again */
  onRetryFile?: (id: string) => void;
  /** Keeps Send off while an attachment has `status: 'uploading'`, `true` by default */
  blockSendWhileUploading?: boolean;
  onPaste?: (e: React.ClipboardEvent) => void;
  /** Context chips above the text, for example the open document; the host keeps `removed` */
  contextItems?: InputContextItem[];
  /** Renders a remove button on every context chip */
  onRemoveContext?: (id: string) => void;
  /** Shows removed context items as a line that brings them back */
  onRestoreContext?: (id: string) => void;
  /** Highlights the field border while files are dragged over it */
  isDragOver?: boolean;
  /** Opens a staged image attachment in a fullscreen lightbox on click, `true` by default */
  enableImagePreview?: boolean;

  /** Controlled input value */
  value?: string;
  onChange?: (value: string) => void;
  disabled?: boolean;
  autoFocus?: boolean;
  /** Suggestion pills rendered above the field, never below it */
  suggestions?: InputSuggestions;

  /** Simulated typing shown in place of the textarea, for demos */
  typingAnimation?: {
    text: string;
    duration: number;
    image?: string;
    isActive: boolean;
    onComplete: () => void;
  };

  /** Dismissible message strip above or below the field */
  infoBar?: {
    title?: string;
    description?: string;
    onClose?: () => void;
    position?: 'top' | 'bottom';
    /** Optional primary action rendered on the right (for example "Upgrade") */
    action?: {
      label: string;
      onClick: () => void;
    };
  };

  /**
   * Inline question panel docked above the field.
   * Changing `id` starts a new question set: the active index and stored answers are reset.
   */
  questionBar?: {
    id: string;
    questions: QuestionConfig[];
    /** Controlled 1-based active question, used together with `onPreviousQuestion`/`onNextQuestion` */
    questionIndex?: number;
    totalQuestions?: number;
    /** When this or `onNextQuestion` is set, navigation is external and the host drives `questionIndex` */
    onPreviousQuestion?: () => void;
    onNextQuestion?: () => void;
    submitLabel?: string;
    skipLabel?: string;
    allowSkip?: boolean;
    /**
     * Called for EVERY answered question with its 1-based `questionIndex`.
     * With internal navigation the bar then advances to the next question; the panel closes
     * only after the last question is answered.
     */
    onSubmit: (answer: QuestionAnswer, meta: { questionIndex: number }) => void;
    /** Called once when Skip is pressed (`onSubmit` is not called for it); Skip closes the panel */
    onSkip?: (meta: { questionIndex: number }) => void;
  };

  /** Trigger-based autocomplete lists, for example slash commands on `/` and mentions on `@` */
  completions?: CompletionSource[];

  /** Messages waiting to be sent after the current response, shown above the field */
  queuedMessages?: QueuedMessage[];
  /** Renders a remove button on every queued message */
  onRemoveQueued?: (id: string) => void;
  /** When set, submitting while the response is streaming queues the message instead of ignoring it */
  onQueue?: (message: { role: 'user'; content: string }) => void;

  /**
   * Shrinks the composer to one line while it is empty and not focused, and unfolds it on a click
   * or a focus: a place to start a conversation that costs one row until it is needed.
   */
  collapsible?: boolean;
  /** Button shown at the end of the collapsed line, the send button by default */
  collapsedAction?: React.ReactNode;
  /** Width of the collapsed line, for example `420` or `'60%'`; the full width by default */
  collapsedWidth?: number | string;
  /**
   * Colour drifting under the composer while the agent works, `false` by default; `'always'` keeps
   * it burning. The colours are the `--ae-glow-*` tokens, so they can be the ones of the host.
   */
  glow?: boolean | 'always';
  /** Called when the collapsed composer unfolds */
  onExpand?: () => void;
  /** Content rendered on the left of the toolbar, next to the attachment button */
  leftActions?: React.ReactNode;
  /** Content rendered on the right of the toolbar, before the send button */
  rightActions?: React.ReactNode;

  /** Pastes at or above the threshold collapse into an attachment chip and are expanded again in `onSend`; `false` turns collapsing off. 10000 characters or 50 lines by default */
  pasteCollapseThreshold?: PasteCollapseThreshold | false;
  /** Previously sent prompts, oldest first: ArrowUp in an empty field or at the start of the text shows older ones, ArrowDown returns to the draft */
  history?: string[];
  /** Hotkey that opens the prompt history search over `history`, `mod+R` by default; `null` turns it off */
  historySearchHotkey?: string | null;
  /** Called by the history search hotkey instead of opening the built-in dialog */
  onHistorySearch?: () => void;
  /** Overrides of the default English labels */
  labels?: Partial<InputBarLabels>;
}

/** Chat composer: auto-growing textarea, attachments, toolbar, info and question bars, suggestions */
export const InputBar = memo(function InputBar({
  onSend,
  status,
  onStop,
  placeholder,
  className,
  style,
  contentWidth,
  onAttach,
  attachedImages = [],
  attachedFiles = [],
  onRemoveImage,
  onRemoveFile,
  onCancelFile,
  onRetryFile,
  blockSendWhileUploading = true,
  onPaste,
  contextItems = [],
  onRemoveContext,
  onRestoreContext,
  isDragOver,
  enableImagePreview = true,
  value: controlledValue,
  onChange: controlledOnChange,
  disabled,
  autoFocus,
  suggestions = [],
  typingAnimation,
  infoBar,
  questionBar,
  completions,
  queuedMessages = [],
  onRemoveQueued,
  onQueue,
  collapsible = false,
  collapsedAction,
  collapsedWidth,
  glow = false,
  onExpand,
  leftActions,
  rightActions,
  pasteCollapseThreshold,
  history,
  historySearchHotkey = 'mod+R',
  onHistorySearch,
  labels: labelsProp,
}: InputBarProps) {
  const labels = { ...DEFAULT_INPUT_BAR_LABELS, ...labelsProp };
  const [internalInput, setInternalInput] = useState('');
  const [isInfoBarOpen, setIsInfoBarOpen] = useState(true);
  const [isInfoBarCollapsed, setIsInfoBarCollapsed] = useState(false);
  const infoBarKey = `${infoBar?.title ?? ''}\u0000${infoBar?.description ?? ''}`;
  const [prevInfoBarKey, setPrevInfoBarKey] = useState(infoBarKey);
  if (prevInfoBarKey !== infoBarKey) {
    setPrevInfoBarKey(infoBarKey);
    setIsInfoBarOpen(true);
    setIsInfoBarCollapsed(false);
  }

  const [dismissedQuestionId, setDismissedQuestionId] = useState<string | null>(null);
  const [questionBarIndex, setQuestionBarIndex] = useState(1);
  const [questionBarAnswers, setQuestionBarAnswers] = useState<Record<number, QuestionAnswer>>({});
  const questionBarId = questionBar?.id;
  const [prevQuestionBarId, setPrevQuestionBarId] = useState(questionBarId);
  if (prevQuestionBarId !== questionBarId) {
    setPrevQuestionBarId(questionBarId);
    setQuestionBarIndex(1);
    setQuestionBarAnswers({});
  }
  const isControlled = controlledValue !== undefined;
  const input = isControlled ? controlledValue : internalInput;
  const setInput = useCallback(
    (v: string) => {
      if (isControlled) {
        controlledOnChange?.(v);
      } else {
        setInternalInput(v);
      }
    },
    [isControlled, controlledOnChange]
  );
  const textareaRef = useRef<HTMLTextAreaElement>(null);

  const isStreaming = status === 'streaming' || status === 'submitted';
  const waitingForUploads =
    blockSendWhileUploading &&
    [...attachedImages, ...attachedFiles].some((item) => item.status === 'uploading');
  const isTyping = typingAnimation?.isActive ?? false;

  const { displayedText, showImage } = useInputTyping(
    typingAnimation?.text ?? '',
    typingAnimation?.duration ?? 2000,
    isTyping,
    typingAnimation?.onComplete ?? (() => {})
  );

  const effectivePlaceholder = placeholder ?? DEFAULT_PLACEHOLDER;

  useEffect(() => {
    if (!autoFocus) {
      return;
    }
    textareaRef.current?.focus();
  }, [autoFocus]);

  const [pastes, setPastes] = useState<PastedText[]>([]);
  const pasteCounterRef = useRef(0);
  const activePastes = useMemo(
    () => prunePastes(input, pastes, labels.pastedText),
    [input, pastes, labels.pastedText]
  );
  const [historyState, setHistoryState] = useState<PromptHistoryState>(
    INITIAL_PROMPT_HISTORY_STATE
  );
  const [isHistorySearchOpen, setIsHistorySearchOpen] = useState(false);
  const [focusWithin, setFocusWithin] = useState(false);
  const unfoldRef = useRef<HTMLDivElement>(null);
  const [unfoldHeight, setUnfoldHeight] = useState<number | null>(null);

  const resetComposerExtras = useCallback(() => {
    setPastes([]);
    pasteCounterRef.current = 0;
    setHistoryState(INITIAL_PROMPT_HISTORY_STATE);
  }, []);

  const handleSubmit = useCallback(() => {
    if (!input.trim() || disabled || waitingForUploads) {
      return;
    }
    const content = expandPastedText(input, activePastes, labels.pastedText).trim();
    if (isStreaming) {
      if (onQueue) {
        onQueue({ role: 'user', content });
        setInput('');
        resetComposerExtras();
      }
      return;
    }
    onSend({ role: 'user', content });
    setInput('');
    resetComposerExtras();
  }, [
    input,
    activePastes,
    labels.pastedText,
    isStreaming,
    disabled,
    waitingForUploads,
    onSend,
    onQueue,
    setInput,
    resetComposerExtras,
  ]);

  const moveCaret = useCallback((position: 'start' | 'end' | number) => {
    requestAnimationFrame(() => {
      const el = textareaRef.current;
      if (!el) {
        return;
      }
      el.focus();
      const offset = position === 'start' ? 0 : position === 'end' ? el.value.length : position;
      el.setSelectionRange(offset, offset);
    });
  }, []);

  const handlePaste = useCallback(
    (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
      onPaste?.(e);
      if (e.defaultPrevented) {
        return;
      }
      const text = e.clipboardData.getData('text/plain');
      if (!text || !shouldCollapsePaste(text, pasteCollapseThreshold)) {
        return;
      }
      e.preventDefault();
      const el = e.currentTarget;
      pasteCounterRef.current += 1;
      const id = pasteCounterRef.current;
      const next = insertPastePlaceholder(
        input,
        el.selectionStart,
        el.selectionEnd,
        id,
        labels.pastedText
      );
      setPastes((prev) => [
        ...prunePastes(input, prev, labels.pastedText),
        { id, text, lines: countLines(text) },
      ]);
      setInput(next.text);
      setCaret(next.caret);
      moveCaret(next.caret);
    },
    [onPaste, pasteCollapseThreshold, input, setInput, moveCaret, labels.pastedText]
  );

  const removePaste = useCallback(
    (id: number) => {
      setInput(removePastePlaceholder(input, id, labels.pastedText));
      setPastes((prev) => prev.filter((paste) => paste.id !== id));
    },
    [input, setInput, labels.pastedText]
  );

  const hasHistory = (history?.length ?? 0) > 0;
  const openHistorySearch = useCallback(() => {
    if (onHistorySearch) {
      onHistorySearch();
    } else if (hasHistory) {
      setIsHistorySearchOpen(true);
    }
  }, [onHistorySearch, hasHistory]);

  const handleHistorySelect = useCallback(
    (prompt: string) => {
      setInput(prompt);
      setHistoryState(INITIAL_PROMPT_HISTORY_STATE);
      moveCaret('end');
    },
    [setInput, moveCaret]
  );

  const [caret, setCaret] = useState<number | null>(null);
  const [dismissedTokenKey, setDismissedTokenKey] = useState<string | null>(null);
  const completionTriggers = useMemo(
    () => (completions ?? []).map((source) => source.trigger),
    [completions]
  );
  const completionToken: CompletionToken | null =
    completionTriggers.length > 0 && caret !== null && !disabled && !isTyping
      ? findCompletionToken(input, Math.min(caret, input.length), completionTriggers)
      : null;
  const completionTokenKey = completionToken
    ? `${completionToken.start}:${completionToken.trigger}`
    : null;
  if (!completionTokenKey && dismissedTokenKey) {
    setDismissedTokenKey(null);
  }
  const completionSource = completionToken
    ? completions?.find((source) => source.trigger === completionToken.trigger)
    : undefined;
  const completionItems = useCompletionItems(completionSource, completionToken?.query ?? null);
  const isCompletionOpen =
    completionItems.length > 0 &&
    completionTokenKey !== null &&
    completionTokenKey !== dismissedTokenKey;

  const combobox = useCombobox({
    opened: isCompletionOpen,
    onOpenedChange: (opened) => {
      if (!opened) {
        setDismissedTokenKey(completionTokenKey);
      }
    },
  });
  const comboboxRef = useRef(combobox);
  comboboxRef.current = combobox;

  useEffect(() => {
    if (isCompletionOpen) {
      comboboxRef.current.selectFirstOption();
    }
  }, [isCompletionOpen, completionItems]);

  const insertCompletion = useCallback(
    (item: CompletionItem) => {
      if (!completionToken) {
        return;
      }
      const next = applyCompletion(input, completionToken, item.value);
      setInput(next.text);
      setCaret(next.caret);
      completionSource?.onSelect?.(item);
      requestAnimationFrame(() => {
        const el = textareaRef.current;
        if (!el) {
          return;
        }
        el.focus();
        el.setSelectionRange(next.caret, next.caret);
      });
    },
    [completionToken, completionSource, input, setInput]
  );

  const completionGroups = useMemo(() => {
    const groups: Array<{
      label: string;
      entries: Array<{ item: CompletionItem; index: number }>;
    }> = [];
    completionItems.forEach((item, index) => {
      const label = item.group ?? '';
      const last = groups[groups.length - 1];
      if (last && last.label === label) {
        last.entries.push({ item, index });
      } else {
        groups.push({ label, entries: [{ item, index }] });
      }
    });
    return groups;
  }, [completionItems]);

  const handleInfoBarClose = useCallback(() => {
    setIsInfoBarOpen(false);
    infoBar?.onClose?.();
  }, [infoBar]);

  const infoBarPosition = infoBar?.position ?? 'top';
  const shouldShowInfoBar = Boolean(infoBar && (infoBar.title || infoBar.description));
  const infoBarData = infoBar ?? {};

  const hasInfoBarBackground = shouldShowInfoBar && (isInfoBarOpen || !isInfoBarCollapsed);

  const handleInfoBarTransitionEnd = (e: React.TransitionEvent<HTMLDivElement>) => {
    if (e.target === e.currentTarget && !isInfoBarOpen) {
      setIsInfoBarCollapsed(true);
    }
  };

  const infoBarNode = shouldShowInfoBar ? (
    <div
      className={classes.infoBar}
      data-open={isInfoBarOpen || undefined}
      data-position={infoBarPosition}
      onTransitionEnd={handleInfoBarTransitionEnd}
    >
      <div className={classes.infoBarText}>
        {infoBarData.title && <span className={classes.infoBarTitle}>{infoBarData.title}</span>}
        {infoBarData.description && (
          <span className={classes.infoBarDescription}>
            {infoBarData.title ? ` ${infoBarData.description}` : infoBarData.description}
          </span>
        )}
      </div>
      <div className={classes.infoBarActions}>
        {infoBarData.action && (
          <UnstyledButton onClick={infoBarData.action.onClick} className={classes.infoBarAction}>
            {infoBarData.action.label}
          </UnstyledButton>
        )}
        {infoBarData.onClose && (
          <UnstyledButton
            onClick={handleInfoBarClose}
            className={classes.infoBarClose}
            aria-label={labels.closeInfoBar}
          >
            <IconX size={14} stroke={2} />
          </UnstyledButton>
        )}
      </div>
    </div>
  ) : null;

  const shouldShowQuestionBar = Boolean(questionBar && questionBar.id !== dismissedQuestionId);
  const questionBarData = questionBar;
  const questionSet = questionBarData?.questions ?? [];
  const hasQuestions = questionSet.length > 0;
  const derivedTotal = hasQuestions ? questionSet.length : 1;
  const totalQuestions = questionBarData?.totalQuestions ?? derivedTotal;
  const hasExternalQuestionNavigation = Boolean(
    questionBarData?.onPreviousQuestion || questionBarData?.onNextQuestion
  );
  const questionIndex = hasExternalQuestionNavigation
    ? (questionBarData?.questionIndex ?? 1)
    : questionBarIndex;
  const clampedQuestionIndex = Math.max(1, Math.min(questionIndex, totalQuestions));
  const activeQuestion = hasQuestions ? questionSet[clampedQuestionIndex - 1] : undefined;
  const showQuestionNavigation = totalQuestions > 1;
  const canGoPrev = clampedQuestionIndex > 1;
  const canGoNext = clampedQuestionIndex < totalQuestions;

  const handleQuestionPrevious = useCallback(() => {
    if (!canGoPrev) {
      return;
    }
    if (questionBarData?.onPreviousQuestion) {
      questionBarData.onPreviousQuestion();
      return;
    }
    setQuestionBarIndex((prev) => Math.max(1, prev - 1));
  }, [canGoPrev, questionBarData]);

  const handleQuestionNext = useCallback(() => {
    if (!canGoNext) {
      return;
    }
    if (questionBarData?.onNextQuestion) {
      questionBarData.onNextQuestion();
      return;
    }
    setQuestionBarIndex((prev) => Math.min(totalQuestions, prev + 1));
  }, [canGoNext, questionBarData, totalQuestions]);

  const questionBarNode =
    shouldShowQuestionBar && questionBarData && activeQuestion ? (
      <div
        className={classes.questionBar}
        data-rounded-top={!shouldShowInfoBar || infoBarPosition === 'bottom' || undefined}
      >
        <QuestionHeader
          index={clampedQuestionIndex}
          total={totalQuestions}
          showNavigation={showQuestionNavigation}
          onPrevious={handleQuestionPrevious}
          onNext={handleQuestionNext}
          labels={labels.questionHeader}
        />
        <QuestionPrompt
          key={`${clampedQuestionIndex}-${activeQuestion.title}`}
          questions={questionSet}
          questionIndex={clampedQuestionIndex}
          totalQuestions={totalQuestions}
          initialAnswer={questionBarAnswers[clampedQuestionIndex]}
          submitLabel={questionBarData.submitLabel}
          skipLabel={questionBarData.skipLabel}
          allowSkip={questionBarData.allowSkip}
          labels={labels.question}
          onSubmit={(answer) => {
            const answeredIndex = clampedQuestionIndex;
            setQuestionBarAnswers((prev) => ({ ...prev, [answeredIndex]: answer }));
            questionBarData.onSubmit(answer, { questionIndex: answeredIndex });
            if (answeredIndex >= totalQuestions) {
              setDismissedQuestionId(questionBarData.id);
            } else if (!hasExternalQuestionNavigation) {
              setQuestionBarIndex(answeredIndex + 1);
            }
          }}
          onSkip={() => {
            questionBarData.onSkip?.({ questionIndex: clampedQuestionIndex });
            setDismissedQuestionId(questionBarData.id);
          }}
        />
      </div>
    ) : null;

  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
      if (e.nativeEvent.isComposing || e.keyCode === 229) {
        return;
      }
      if (isCompletionOpen) {
        if (e.key === 'ArrowDown') {
          e.preventDefault();
          combobox.selectNextOption();
          return;
        }
        if (e.key === 'ArrowUp') {
          e.preventDefault();
          combobox.selectPreviousOption();
          return;
        }
        if (e.key === 'Escape') {
          e.preventDefault();
          setDismissedTokenKey(completionTokenKey);
          return;
        }
        if ((e.key === 'Enter' && !e.shiftKey) || e.key === 'Tab') {
          e.preventDefault();
          const selectedIndex = combobox.getSelectedOptionIndex();
          insertCompletion(completionItems[selectedIndex >= 0 ? selectedIndex : 0]);
          return;
        }
      }
      if (historySearchHotkey && (hasHistory || onHistorySearch)) {
        let matched = false;
        getHotkeyHandler([
          [
            historySearchHotkey,
            () => {
              matched = true;
              openHistorySearch();
            },
          ],
        ])(e);
        if (matched) {
          return;
        }
      }
      const isPlainArrow = !e.shiftKey && !e.altKey && !e.metaKey && !e.ctrlKey;
      if (history && isPlainArrow && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
        const el = e.currentTarget;
        const older = e.key === 'ArrowUp';
        const allowed = older
          ? canBrowseOlder(input, el.selectionStart, el.selectionEnd)
          : historyState.index !== null &&
            canBrowseNewer(input, el.selectionStart, el.selectionEnd);
        const result = allowed
          ? navigatePromptHistory(history, historyState, older ? 'older' : 'newer', input)
          : null;
        if (result) {
          e.preventDefault();
          setHistoryState(result.state);
          setInput(result.value);
          moveCaret(older ? 'start' : 'end');
          return;
        }
      }
      if (e.key === 'Enter' && !e.shiftKey) {
        e.preventDefault();
        handleSubmit();
      }
    },
    [
      combobox,
      completionItems,
      completionTokenKey,
      handleSubmit,
      insertCompletion,
      isCompletionOpen,
      historySearchHotkey,
      hasHistory,
      onHistorySearch,
      openHistorySearch,
      history,
      input,
      historyState,
      setInput,
      moveCaret,
    ]
  );

  const queueNode =
    queuedMessages.length > 0 ? (
      <Stack gap={2} px={12} pt={6} pb={8} aria-label={labels.queued}>
        <Text size="xs" fw={500} className={classes.queueLabel}>
          {labels.queued}
        </Text>
        {queuedMessages.map((message) => (
          <Group key={message.id} gap={6} wrap="nowrap" miw={0}>
            <Text size="xs" truncate="end" flex={1} miw={0} className={classes.queueText}>
              {message.content}
            </Text>
            {onRemoveQueued && (
              <ActionIcon
                variant="subtle"
                color="gray"
                size="xs"
                aria-label={labels.removeQueuedMessage}
                onClick={() => onRemoveQueued(message.id)}
              >
                <IconX size={12} stroke={2} />
              </ActionIcon>
            )}
          </Group>
        ))}
      </Stack>
    ) : null;

  const hasInput = input.trim().length > 0;
  const visibleContext = contextItems.filter((item) => !item.removed || onRestoreContext);
  const hasContextItems =
    visibleContext.length > 0 ||
    attachedImages.length > 0 ||
    attachedFiles.length > 0 ||
    activePastes.length > 0;

  const collapsed =
    collapsible && !focusWithin && !hasInput && !hasContextItems && !isStreaming && !isTyping;

  // The card animates to a real height, the way a spring needs: `fr` units swallow the overshoot.
  useEffect(() => {
    const content = unfoldRef.current;
    if (!collapsible || !content || typeof ResizeObserver === 'undefined') {
      return;
    }
    const measure = () => setUnfoldHeight(content.offsetHeight);
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(content);
    return () => observer.disconnect();
  }, [collapsible]);

  const handleFocusWithin = () => {
    if (!focusWithin) {
      setFocusWithin(true);
      if (collapsed) {
        onExpand?.();
      }
    }
  };

  const handleBlurWithin = (event: React.FocusEvent<HTMLElement>) => {
    const next = event.relatedTarget as Node | null;
    if (!next || !event.currentTarget.contains(next)) {
      setFocusWithin(false);
    }
  };

  const handleContainerClick = useCallback((e: React.MouseEvent) => {
    if (!e.currentTarget.contains(e.target as Node)) {
      return;
    }
    if (e.target === e.currentTarget || !(e.target as HTMLElement).closest('button, textarea')) {
      textareaRef.current?.focus();
    }
  }, []);

  const handleSuggestionSelect = useCallback(
    (item: SuggestionItem) => {
      if (disabled || isStreaming) {
        return;
      }
      setInput(item.value ?? item.label);
      requestAnimationFrame(() => {
        const el = textareaRef.current;
        if (!el) {
          return;
        }
        el.focus();
        const end = el.value.length;
        el.setSelectionRange(end, end);
      });
    },
    [disabled, isStreaming, setInput]
  );

  const suggestionItems = Array.isArray(suggestions) ? suggestions : (suggestions?.items ?? []);
  const suggestionsClassName = Array.isArray(suggestions) ? undefined : suggestions?.className;
  const suggestionItemClassName = Array.isArray(suggestions)
    ? undefined
    : suggestions?.itemClassName;

  let sendState: 'idle' | 'typing' | 'streaming' = 'idle';
  if (isStreaming) {
    sendState = 'streaming';
  } else if (hasInput && !disabled && !waitingForUploads) {
    sendState = 'typing';
  }

  return (
    <Box
      className={cx(classes.root, className)}
      data-collapsed={collapsed || undefined}
      style={{
        ...getContentWidthStyle(contentWidth, style),
        ...(collapsedWidth === undefined
          ? undefined
          : { '--ae-input-collapsed-width': rem(collapsedWidth) }),
      }}
    >
      <div className={classes.inner}>
        {glow && (
          <div
            className={classes.glow}
            data-lit={glow === 'always' || isStreaming || undefined}
            aria-hidden="true"
          >
            <span className={classes.glowBand} />
            <span className={classes.glowBand} />
            <span className={classes.glowBand} />
          </div>
        )}
        <div className={classes.stack} data-info-bar={hasInfoBarBackground || undefined}>
          {infoBarPosition === 'top' && infoBarNode}
          {questionBarNode}
          {queueNode}
          {suggestionItems.length > 0 && (
            <Suggestions
              items={suggestionItems}
              onSelect={handleSuggestionSelect}
              disabled={disabled || isStreaming}
              className={cx(classes.suggestions, suggestionsClassName)}
              itemClassName={suggestionItemClassName}
            />
          )}
          <Combobox
            store={combobox}
            onOptionSubmit={(value) => insertCompletion(completionItems[Number(value)])}
            position="top-start"
            width="target"
            offset={6}
            transitionProps={{ duration: 0 }}
            classNames={{ dropdown: classes.completionDropdown }}
          >
            <Combobox.DropdownTarget>
              <div
                role="presentation"
                className={classes.field}
                data-drag-over={isDragOver || undefined}
                data-collapsible={collapsible || undefined}
                data-collapsed={collapsed || undefined}
                data-glow={(glow && (glow === 'always' || isStreaming)) || undefined}
                style={unfoldHeight === null ? undefined : { height: rem(unfoldHeight) }}
                onClick={handleContainerClick}
                onFocusCapture={collapsible ? handleFocusWithin : undefined}
                onBlurCapture={collapsible ? handleBlurWithin : undefined}
              >
                <div ref={collapsible ? unfoldRef : undefined} className={classes.unfold}>
                  <div className={classes.contextGrid} data-open={hasContextItems || undefined}>
                    <div className={classes.contextClip}>
                      {hasContextItems && (
                        <div className={classes.contextItems}>
                          {visibleContext.length > 0 && (
                            <InputContext
                              items={visibleContext}
                              onRemove={onRemoveContext}
                              onRestore={onRestoreContext}
                              field={textareaRef}
                              labels={labels}
                            />
                          )}
                          {attachedImages.map((img) => (
                            <FileAttachment
                              key={img.id}
                              id={img.id}
                              filename={img.filename}
                              size={img.size}
                              isImage
                              url={img.url}
                              display="image-only"
                              enableImagePreview={enableImagePreview}
                              labels={labels.attachment}
                              status={img.status}
                              progress={img.progress}
                              error={img.error}
                              onCancel={onCancelFile && (() => onCancelFile(img.id))}
                              onRetry={onRetryFile && (() => onRetryFile(img.id))}
                              onRemove={onRemoveImage ? () => onRemoveImage(img.id) : undefined}
                            />
                          ))}
                          {attachedFiles.map((file) => (
                            <FileAttachment
                              key={file.id}
                              id={file.id}
                              filename={file.filename}
                              size={file.size}
                              labels={labels.attachment}
                              status={file.status}
                              progress={file.progress}
                              error={file.error}
                              onCancel={onCancelFile && (() => onCancelFile(file.id))}
                              onRetry={onRetryFile && (() => onRetryFile(file.id))}
                              onRemove={onRemoveFile ? () => onRemoveFile(file.id) : undefined}
                            />
                          ))}
                          {activePastes.map((paste) => (
                            <PastedTextAttachment
                              key={paste.id}
                              paste={paste}
                              labels={{
                                name: labels.pastedText,
                                lines: labels.pastedTextLines,
                                remove: labels.removePastedText,
                              }}
                              onRemove={disabled ? undefined : () => removePaste(paste.id)}
                            />
                          ))}
                        </div>
                      )}
                    </div>
                  </div>

                  {isTyping && typingAnimation?.image && showImage && (
                    <div className={classes.typingImages}>
                      <div className={classes.typingImage}>
                        <img src={typingAnimation.image} alt="" className={classes.typingImg} />
                      </div>
                    </div>
                  )}

                  <div className={classes.inputWrap}>
                    {isTyping ? (
                      <div className={classes.typingText}>
                        <span>{displayedText}</span>
                        <span className={classes.caret} />
                      </div>
                    ) : (
                      <>
                        <Textarea
                          ref={textareaRef}
                          variant="unstyled"
                          autosize
                          minRows={1}
                          maxRows={5}
                          value={input}
                          onChange={(e) => {
                            setInput(e.currentTarget.value);
                            setCaret(e.currentTarget.selectionStart);
                            if (historyState.index !== null) {
                              setHistoryState(INITIAL_PROMPT_HISTORY_STATE);
                            }
                          }}
                          onSelect={(e) => setCaret(e.currentTarget.selectionStart)}
                          onKeyDown={handleKeyDown}
                          aria-expanded={
                            completionTriggers.length > 0 ? isCompletionOpen : undefined
                          }
                          aria-haspopup={completionTriggers.length > 0 ? 'listbox' : undefined}
                          onPaste={handlePaste}
                          placeholder={effectivePlaceholder}
                          disabled={disabled}
                          classNames={{
                            root: classes.textareaRoot,
                            wrapper: classes.textareaWrapper,
                            input: classes.textarea,
                          }}
                        />
                        <div className={classes.focusRing} />
                      </>
                    )}
                  </div>

                  {collapsible && (
                    <div className={classes.collapsedAction}>
                      {collapsedAction ?? (
                        <UnstyledButton
                          className={classes.sendWrap}
                          aria-label={isStreaming ? labels.stop : labels.send}
                          onClick={() => {
                            if (isStreaming) {
                              onStop();
                            } else if (hasInput) {
                              handleSubmit();
                            } else {
                              textareaRef.current?.focus();
                            }
                          }}
                        >
                          <SendButton state={sendState} solid />
                        </UnstyledButton>
                      )}
                    </div>
                  )}
                  <div className={classes.toolbarGrid}>
                    <div className={classes.toolbarClip} inert={collapsed || undefined}>
                      <div className={classes.toolbar}>
                        <div className={cx(classes.toolbarGroup, classes.toolbarLeft)}>
                          {onAttach && (
                            <AttachmentButton onClick={onAttach} labels={labels.attach} />
                          )}
                          {leftActions}
                        </div>
                        <div className={classes.toolbarGroup}>
                          {rightActions}
                          {!collapsible && (
                            <UnstyledButton
                              className={classes.sendWrap}
                              aria-label={
                                isStreaming
                                  ? labels.stop
                                  : waitingForUploads
                                    ? labels.waitForUploads
                                    : labels.send
                              }
                              onClick={() => {
                                if (isStreaming) {
                                  onStop();
                                } else if (hasInput) {
                                  handleSubmit();
                                }
                              }}
                            >
                              <SendButton state={sendState} />
                            </UnstyledButton>
                          )}
                        </div>
                      </div>
                    </div>
                  </div>
                </div>
              </div>
            </Combobox.DropdownTarget>
            <Combobox.Dropdown>
              <Combobox.Options mah={240} className={classes.completionOptions}>
                {completionGroups.map((group) => {
                  const options = group.entries.map(({ item, index }) => (
                    <Combobox.Option
                      key={`${index}-${item.value}`}
                      value={String(index)}
                      className={classes.completionOption}
                    >
                      <Group gap={8} wrap="nowrap" miw={0}>
                        {item.icon && <span className={classes.completionIcon}>{item.icon}</span>}
                        <Text size="xs" fw={500} className={classes.completionLabel}>
                          {item.label}
                        </Text>
                        {item.description && (
                          <Text
                            size="xs"
                            truncate="end"
                            miw={0}
                            className={classes.completionDescription}
                          >
                            {item.description}
                          </Text>
                        )}
                      </Group>
                    </Combobox.Option>
                  ));
                  return group.label ? (
                    <Combobox.Group key={group.label} label={group.label}>
                      {options}
                    </Combobox.Group>
                  ) : (
                    <React.Fragment key="__ungrouped">{options}</React.Fragment>
                  );
                })}
              </Combobox.Options>
            </Combobox.Dropdown>
          </Combobox>
          {infoBarPosition === 'bottom' && infoBarNode}
        </div>
      </div>
      {history && !onHistorySearch && (
        <PromptHistorySearch
          opened={isHistorySearchOpen}
          onClose={() => setIsHistorySearchOpen(false)}
          history={history}
          onSelect={handleHistorySelect}
          labels={labels.historySearch}
        />
      )}
    </Box>
  );
});

InputBar.displayName = 'InputBar';
API reference
Prop
Type
Required
onSend
(message: { role: 'user'; content: string }) => void
Yes
status
ChatStatus
Yes
onStop
() => void
Yes
placeholder
string
No
className
string
No
style
React.CSSProperties
No
contentWidth
ContentWidth
No
Max width of the composer: a number in px or any CSS width, `420px` by default. Pass `'100%'` for a full-width chat
onAttach
() => void
No
Renders the attach button when provided
attachedImages
AttachedImage[]
No
attachedFiles
AttachedFile[]
No
onRemoveImage
(id: string) => void
No
onRemoveFile
(id: string) => void
No
onCancelFile
(id: string) => void
No
Stops the upload of a staged image or file
onRetryFile
(id: string) => void
No
Starts a failed upload of a staged image or file again
blockSendWhileUploading
boolean
No
Keeps Send off while an attachment has `status: 'uploading'`, `true` by default
onPaste
(e: React.ClipboardEvent) => void
No
contextItems
InputContextItem[]
No
Context chips above the text, for example the open document; the host keeps `removed`
onRemoveContext
(id: string) => void
No
Renders a remove button on every context chip
onRestoreContext
(id: string) => void
No
Shows removed context items as a line that brings them back
isDragOver
boolean
No
Highlights the field border while files are dragged over it
enableImagePreview
boolean
No
Opens a staged image attachment in a fullscreen lightbox on click, `true` by default
value
string
No
Controlled input value
onChange
(value: string) => void
No
disabled
boolean
No
autoFocus
boolean
No
suggestions
InputSuggestions
No
Suggestion pills rendered above the field, never below it
typingAnimation
{ text: string; duration: number; image?: string; isActive: boolean; onComplete: () => void; }
No
Simulated typing shown in place of the textarea, for demos
infoBar
{ title?: string; description?: string; onClose?: () => void; position?: 'top' | 'bottom'; action?: { label: string; onClick: () => void; }; }
No
Dismissible message strip above or below the field
questionBar
{ id: string; questions: QuestionConfig[]; questionIndex?: number; totalQuestions?: number; onPreviousQuestion?: () => void; onNextQuestion?: () => void; submitLabel?: string; skipLabel?: string; allowSkip?: boolean; onSubmit: (answer: QuestionAnswer, meta: { questionIndex: number }) => void; onSkip?: (meta: { questionIndex: number }) => void; }
No
Inline question panel docked above the field. Changing `id` starts a new question set: the active index and stored answers are reset.
completions
CompletionSource[]
No
Trigger-based autocomplete lists, for example slash commands on `/` and mentions on `@`
queuedMessages
QueuedMessage[]
No
Messages waiting to be sent after the current response, shown above the field
onRemoveQueued
(id: string) => void
No
Renders a remove button on every queued message
onQueue
(message: { role: 'user'; content: string }) => void
No
When set, submitting while the response is streaming queues the message instead of ignoring it
collapsible
boolean
No
Shrinks the composer to one line while it is empty and not focused, and unfolds it on a click or a focus: a place to start a conversation that costs one row until it is needed.
collapsedAction
React.ReactNode
No
Button shown at the end of the collapsed line, the send button by default
collapsedWidth
number | string
No
Width of the collapsed line, for example `420` or `'60%'`; the full width by default
glow
boolean | 'always'
No
Colour drifting under the composer while the agent works, `false` by default; `'always'` keeps it burning. The colours are the `--ae-glow-*` tokens, so they can be the ones of the host.
onExpand
() => void
No
Called when the collapsed composer unfolds
leftActions
React.ReactNode
No
Content rendered on the left of the toolbar, next to the attachment button
rightActions
React.ReactNode
No
Content rendered on the right of the toolbar, before the send button
pasteCollapseThreshold
PasteCollapseThreshold | false
No
Pastes at or above the threshold collapse into an attachment chip and are expanded again in `onSend`; `false` turns collapsing off. 10000 characters or 50 lines by default
history
string[]
No
Previously sent prompts, oldest first: ArrowUp in an empty field or at the start of the text shows older ones, ArrowDown returns to the draft
historySearchHotkey
string | null
No
Hotkey that opens the prompt history search over `history`, `mod+R` by default; `null` turns it off
onHistorySearch
() => void
No
Called by the history search hotkey instead of opening the built-in dialog
labels
Partial<InputBarLabels>
No
Overrides of the default English labels