shadimage
Simple AI
uichat-inputsource: ok · hit

Chat input

Chat input with mentions and streaming status.

npx shadcn add "https://www.simple-ai.dev/r/chat-input.json"
free

Where it goes

statschat

Interaction

animated-counter

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
rich-text@tiptap/core@tiptap/react@tiptap/starter-kit@tiptap/extension-mention@tiptap/extension-placeholder@tiptap/suggestion

Registry dependencies

input-group

Files1 · source cached 10/14/2026, 2:03:10 PM

packages/registry/registry/ui/chat-input.tsx
"use client";

import { Extension, mergeAttributes } from "@tiptap/core";
import { Mention as MentionExtension } from "@tiptap/extension-mention";
import Placeholder from "@tiptap/extension-placeholder";
import type { Editor, JSONContent } from "@tiptap/react";
import { EditorContent, ReactRenderer, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import type {
  SuggestionKeyDownProps,
  SuggestionProps,
} from "@tiptap/suggestion";
import type { ChatStatus } from "ai";
import { ArrowUpIcon, AtSignIcon, Loader2Icon, SquareIcon } from "lucide-react";
import {
  type ComponentProps,
  createContext,
  type ReactNode,
  type Ref,
  type RefObject,
  useCallback,
  useContext,
  useEffect,
  useImperativeHandle,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
} from "react";
import { InputGroup, InputGroupButton } from "@/components/ui/input-group";
import { cn } from "@/lib/utils";

export interface BaseMentionItem {
  id: string;
  name: string;
}

export interface MentionConfig<T extends BaseMentionItem> {
  /** Fixed at mount — changing it later has no effect. */
  trigger: string;
  items: T[] | ((query: string) => T[] | Promise<T[]>);
  render?: (item: T, selected: boolean) => ReactNode;
  chipClassName?: string;
}

export type MentionConfigs = Record<string, MentionConfig<BaseMentionItem>>;

type SelectedMentionItems = Record<string, Map<string, BaseMentionItem>>;

// Mapped + intersection shape — cannot be an interface.
export type ChatInputParsed<Items extends Record<string, BaseMentionItem>> = {
  text: string;
} & { [K in keyof Items]?: Items[K][] };

export interface ChatInputHandle {
  clear: () => void;
  focus: () => void;
  getText: () => string;
  setText: (text: string) => void;
  insertText: (text: string) => void;
}

interface ChatInputHelpers {
  clear: () => void;
  focus: () => void;
}

interface ChatInputContextValue {
  editor: Editor | null;
  setEditor: (editor: Editor | null) => void;
  submit: () => void;
  status?: ChatStatus;
  onStop?: () => void;
  disabled: boolean;
  defaultValue?: string;
  mentions: MentionConfigs | undefined;
  mentionsRef: RefObject<MentionConfigs | undefined>;
  selectedItemsRef: RefObject<SelectedMentionItems>;
}

function filterStaticItems<T extends BaseMentionItem>(
  items: T[],
  query: string
): T[] {
  const q = query.toLowerCase();
  return items.filter((item) => item.name.toLowerCase().startsWith(q));
}

function resolveMentionItems<T extends BaseMentionItem>(
  config: MentionConfig<T>,
  query: string
): T[] | Promise<T[]> {
  if (typeof config.items === "function") {
    return config.items(query);
  }
  return filterStaticItems(config.items, query);
}

function textToDoc(text: string): JSONContent {
  return {
    type: "doc",
    content: text.split("\n").map((line) => ({
      type: "paragraph",
      ...(line ? { content: [{ type: "text", text: line }] } : {}),
    })),
  };
}

interface MentionListHandle {
  onKeyDown: (props: SuggestionKeyDownProps) => boolean;
}

interface MentionListProps<T extends BaseMentionItem> {
  items: T[];
  loading?: boolean;
  command: (item: { id: string; label: string }) => void;
  renderItem?: (item: T, selected: boolean) => ReactNode;
  onSelectItem?: (item: T) => void;
  ref?: React.Ref<MentionListHandle>;
}

function MentionList<T extends BaseMentionItem>({
  items,
  loading,
  command,
  renderItem,
  onSelectItem,
  ref,
}: MentionListProps<T>) {
  const [selectedIndex, setSelectedIndex] = useState(0);
  const [prevItems, setPrevItems] = useState(items);
  const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);

  if (prevItems !== items) {
    setPrevItems(items);
    setSelectedIndex(0);
  }

  const selectItem = (index: number) => {
    const item = items[index];
    if (!item) {
      return;
    }
    onSelectItem?.(item);
    command({ id: item.id, label: item.name });
  };

  const moveSelection = (delta: number) => {
    setSelectedIndex((prev) => {
      const length = Math.max(items.length, 1);
      const next = (prev + delta + length) % length;
      itemRefs.current[next]?.scrollIntoView({
        behavior: "smooth",
        block: "nearest",
      });
      return next;
    });
  };

  useImperativeHandle(ref, () => ({
    onKeyDown: ({ event }) => {
      if (event.key === "ArrowUp") {
        moveSelection(-1);
        return true;
      }
      if (event.key === "ArrowDown") {
        moveSelection(1);
        return true;
      }
      if (event.key === "Enter") {
        selectItem(selectedIndex);
        return true;
      }
      return false;
    },
  }));

  if (loading && items.length === 0) {
    return (
      <div className="min-w-48 rounded-md bg-popover px-2 py-1.5 text-muted-foreground text-sm shadow-md ring-1 ring-foreground/10">
        Loading…
      </div>
    );
  }

  return (
    <div className="flex max-h-48 min-w-48 max-w-64 flex-col overflow-y-auto overflow-x-hidden rounded-md bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10">
      {items.length ? (
        items.map((item, index) => (
          <button
            className={cn(
              "relative flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm outline-hidden [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
              selectedIndex === index && "bg-accent text-accent-foreground"
            )}
            key={item.id}
            onClick={() => selectItem(index)}
            ref={(el) => {
              itemRefs.current[index] = el;
            }}
            type="button"
          >
            {renderItem ? (
              renderItem(item, selectedIndex === index)
            ) : (
              <span className="truncate">{item.name}</span>
            )}
          </button>
        ))
      ) : (
        <div className="px-2 py-1.5 text-muted-foreground text-sm">
          No results found
        </div>
      )}
    </div>
  );
}

function createMentionSuggestion(
  key: string,
  mentionsRef: RefObject<MentionConfigs | undefined>,
  selectedItemsRef: RefObject<SelectedMentionItems>
) {
  return {
    items: ({ query }: { query: string }) => {
      const config = mentionsRef.current?.[key];
      if (!config) {
        return [];
      }
      return resolveMentionItems(config, query);
    },
    render: () => {
      let component: ReactRenderer<MentionListHandle> | null = null;
      let unmount: (() => void) | undefined;

      const rememberItem = (item: BaseMentionItem) => {
        if (!selectedItemsRef.current[key]) {
          selectedItemsRef.current[key] = new Map();
        }
        selectedItemsRef.current[key].set(item.id, item);
      };

      return {
        onStart: (props: SuggestionProps<BaseMentionItem>) => {
          const config = mentionsRef.current?.[key];
          component = new ReactRenderer(MentionList, {
            props: {
              items: props.items,
              loading: props.loading,
              command: props.command,
              renderItem: config?.render,
              onSelectItem: rememberItem,
            },
            editor: props.editor,
          });
          // The mount wrapper is the positioned element (appended to body,
          // position:absolute, no z-index) — without this it stacks below
          // elevated surfaces like the z-50 chat dock.
          component.element.style.zIndex = "50";
          unmount = props.mount(component.element);
        },
        onUpdate: (props: SuggestionProps<BaseMentionItem>) => {
          const config = mentionsRef.current?.[key];
          component?.updateProps({
            items: props.items,
            loading: props.loading,
            command: props.command,
            renderItem: config?.render,
            onSelectItem: rememberItem,
          });
        },
        onKeyDown: (props: SuggestionKeyDownProps) => {
          if (props.event.key === "Escape") {
            unmount?.();
            unmount = undefined;
            return true;
          }
          return component?.ref?.onKeyDown(props) ?? false;
        },
        onExit: () => {
          unmount?.();
          unmount = undefined;
          component?.destroy();
          component = null;
        },
      };
    },
  };
}

function buildMentionExtensions(
  mentionsRef: RefObject<MentionConfigs | undefined>,
  selectedItemsRef: RefObject<SelectedMentionItems>,
  initialMentions: MentionConfigs | undefined
) {
  return Object.entries(initialMentions ?? {}).map(([key, config]) => {
    const trigger = config.trigger || "@";
    const MentionPlugin = MentionExtension.extend({
      name: `${key}-mention`,
      renderHTML({ node, HTMLAttributes }) {
        const chipClassName = mentionsRef.current?.[key]?.chipClassName;
        return [
          "span",
          mergeAttributes(HTMLAttributes, {
            class: cn(
              "rounded-sm bg-primary px-1 py-0.5 text-primary-foreground no-underline",
              chipClassName
            ),
          }),
          `${trigger}${node.attrs.label ?? node.attrs.id}`,
        ];
      },
    });

    return MentionPlugin.configure({
      suggestion: {
        char: trigger,
        ...createMentionSuggestion(key, mentionsRef, selectedItemsRef),
      },
    });
  });
}

function appendMentionFromNode(
  node: JSONContent,
  mentions: MentionConfigs | undefined,
  selectedItems: SelectedMentionItems,
  buckets: Record<string, BaseMentionItem[]>
): string {
  const key = (node.type ?? "").slice(0, -"-mention".length);
  const config = mentions?.[key];
  const attrs = node.attrs ?? {};
  const id = String(attrs.id ?? "");
  const label = String(attrs.label ?? "");
  const trigger = config?.trigger ?? "";

  const cached = selectedItems[key]?.get(id);
  const item: BaseMentionItem = cached ?? { id, name: label };
  if (!buckets[key]) {
    buckets[key] = [];
  }
  if (!buckets[key].some((existing) => existing.id === id)) {
    buckets[key].push(item);
  }
  return `${trigger}${label}`;
}

export function parseEditorContent(
  json: JSONContent,
  mentions: MentionConfigs | undefined,
  selectedItems: SelectedMentionItems
) {
  let text = "";
  const buckets: Record<string, BaseMentionItem[]> = {};

  function recurse(node: JSONContent) {
    if (node.type === "text" && node.text) {
      text += node.text;
      return;
    }
    if (node.type === "hardBreak") {
      text += "\n";
      return;
    }
    if (node.type?.endsWith("-mention")) {
      text += appendMentionFromNode(node, mentions, selectedItems, buckets);
      return;
    }
    if (node.content) {
      for (const child of node.content) {
        recurse(child);
      }
      if (node.type === "paragraph") {
        text += "\n\n";
      }
    }
  }

  if (json.content) {
    for (const node of json.content) {
      recurse(node);
    }
  }

  return { text: text.trim(), ...buckets };
}

const ChatInputContext = createContext<ChatInputContextValue | null>(null);

function useChatInputContext() {
  const ctx = useContext(ChatInputContext);
  if (!ctx) {
    throw new Error("ChatInput components must be used within <ChatInput>");
  }
  return ctx;
}

type SharedChatInputProps = {
  status?: ChatStatus;
  onStop?: () => void;
  disabled?: boolean;
  defaultValue?: string;
  className?: string;
  children: ReactNode;
  /** Imperative handle (clear/focus/getText/setText/insertText), not the DOM node. */
  ref?: Ref<ChatInputHandle>;
} & Omit<
  ComponentProps<"div">,
  "children" | "onSubmit" | "defaultValue" | "ref"
>;

type ChatInputPropsWithMentions<Items extends Record<string, BaseMentionItem>> =
  SharedChatInputProps & {
    mentions: { [K in keyof Items]: MentionConfig<Items[K]> };
    onSubmit: (
      parsed: ChatInputParsed<Items>,
      helpers: ChatInputHelpers
    ) => void;
  };

type ChatInputPropsWithoutMentions = SharedChatInputProps & {
  mentions?: undefined;
  onSubmit: (parsed: { text: string }, helpers: ChatInputHelpers) => void;
};

export function ChatInput<Items extends Record<string, BaseMentionItem>>(
  props: ChatInputPropsWithMentions<Items>
): React.JSX.Element;
export function ChatInput(
  props: ChatInputPropsWithoutMentions
): React.JSX.Element;
export function ChatInput({
  mentions,
  onSubmit,
  status,
  onStop,
  disabled = false,
  defaultValue,
  className,
  children,
  ref,
  ...props
}: SharedChatInputProps & {
  mentions?: MentionConfigs;
  // Runtime parse is untyped; overloads restore Items at the call site.
  // biome-ignore lint/suspicious/noExplicitAny: overload boundary
  onSubmit: (parsed: any, helpers: ChatInputHelpers) => void;
}) {
  const [editor, setEditor] = useState<Editor | null>(null);
  const mentionsRef = useRef(mentions);
  const onSubmitRef = useRef(onSubmit);
  const selectedItemsRef = useRef<SelectedMentionItems>({});

  mentionsRef.current = mentions;
  onSubmitRef.current = onSubmit;

  const parse = useCallback(() => {
    if (!editor) {
      return { text: "" };
    }
    return parseEditorContent(
      editor.getJSON(),
      mentionsRef.current,
      selectedItemsRef.current
    );
  }, [editor]);

  const clear = useCallback(() => {
    editor?.commands.clearContent(true);
    selectedItemsRef.current = {};
  }, [editor]);

  const focus = useCallback(() => {
    editor?.commands.focus("end");
  }, [editor]);

  const submit = useCallback(() => {
    if (disabled) {
      return;
    }
    const parsed = parse();
    editor?.commands.blur();
    onSubmitRef.current(parsed, { clear, focus });
  }, [clear, disabled, editor, focus, parse]);

  useImperativeHandle(
    ref,
    () => ({
      clear,
      focus,
      getText: () => parse().text,
      setText: (text) => {
        editor?.commands.setContent(textToDoc(text));
      },
      insertText: (text) => {
        editor?.chain().focus().insertContent(text).run();
      },
    }),
    [clear, editor, focus, parse]
  );

  const contextValue = useMemo<ChatInputContextValue>(
    () => ({
      editor,
      setEditor,
      submit,
      status,
      onStop,
      disabled,
      defaultValue,
      mentions,
      mentionsRef,
      selectedItemsRef,
    }),
    [defaultValue, disabled, editor, mentions, onStop, status, submit]
  );

  return (
    <ChatInputContext.Provider value={contextValue}>
      <InputGroup
        className={cn("h-auto", className)}
        data-slot="chat-input"
        {...props}
      >
        {children}
      </InputGroup>
    </ChatInputContext.Provider>
  );
}

const SubmitEnter = Extension.create({
  name: "chatInputSubmitEnter",
  addOptions() {
    return {
      getOnEnter: (): (() => void) => () => undefined,
    };
  },
  addKeyboardShortcuts() {
    return {
      Enter: () => {
        this.options.getOnEnter()?.();
        return true;
      },
    };
  },
});

export function ChatInputEditor({
  placeholder = "Type a message...",
  className,
  autoFocus,
}: {
  placeholder?: string;
  className?: string;
  autoFocus?: boolean;
}) {
  const {
    setEditor,
    submit,
    disabled,
    defaultValue,
    mentions,
    mentionsRef,
    selectedItemsRef,
  } = useChatInputContext();

  const initialMentionsRef = useRef(mentions);
  const placeholderRef = useRef(placeholder);
  placeholderRef.current = placeholder;

  const onEnterRef = useRef(submit);
  onEnterRef.current = submit;

  // biome-ignore lint/correctness/useExhaustiveDependencies: editor built once; live config via refs
  const extensions = useMemo(
    () => [
      StarterKit.configure({
        heading: false,
        blockquote: false,
        codeBlock: false,
        horizontalRule: false,
        listItem: false,
        bulletList: false,
        orderedList: false,
      }),
      Placeholder.configure({
        placeholder: () => placeholderRef.current,
      }),
      SubmitEnter.configure({
        getOnEnter: () => onEnterRef.current,
      }),
      ...buildMentionExtensions(
        mentionsRef,
        selectedItemsRef,
        initialMentionsRef.current
      ),
    ],
    []
  );

  const editor = useEditor({
    extensions,
    content: defaultValue ?? "",
    editable: !disabled,
    autofocus: autoFocus ? "end" : false,
    immediatelyRender: false,
    editorProps: {
      attributes: {
        "data-slot": "input-group-control",
        class: cn(
          "tiptap max-w-none flex-1 rounded-none border-0 bg-transparent py-2 shadow-none outline-none ring-0 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent"
        ),
      },
    },
  });

  useLayoutEffect(() => {
    setEditor(editor);
    return () => setEditor(null);
  }, [editor, setEditor]);

  useEffect(() => {
    if (editor) {
      editor.setEditable(!disabled);
    }
  }, [disabled, editor]);

  return (
    <EditorContent
      className={cn(
        "max-h-48 min-h-16 w-full flex-1 overflow-y-auto px-3 py-0",
        "[&_.tiptap]:outline-none",
        "[&_.tiptap_p.is-editor-empty:first-child]:before:pointer-events-none",
        "[&_.tiptap_p.is-editor-empty:first-child]:before:float-left",
        "[&_.tiptap_p.is-editor-empty:first-child]:before:h-0",
        "[&_.tiptap_p.is-editor-empty:first-child]:before:text-muted-foreground",
        "[&_.tiptap_p.is-editor-empty:first-child]:before:content-[attr(data-placeholder)]",
        className
      )}
      editor={editor}
    />
  );
}

export function ChatInputSubmitButton({
  className,
  disabled,
  children,
  ...props
}: ComponentProps<typeof InputGroupButton>) {
  const {
    submit,
    status,
    onStop,
    disabled: contextDisabled,
  } = useChatInputContext();

  const isInFlight = status === "submitted" || status === "streaming";
  const actAsStop = isInFlight && onStop !== undefined;

  let icon = <ArrowUpIcon className="size-4" />;
  if (actAsStop) {
    icon = <SquareIcon className="size-4" />;
  } else if (isInFlight) {
    icon = <Loader2Icon className="size-4 animate-spin" />;
  }

  return (
    <InputGroupButton
      aria-label={actAsStop ? "Stop" : "Send"}
      className={className}
      disabled={(disabled ?? contextDisabled) || (isInFlight && !actAsStop)}
      onClick={(event) => {
        event.preventDefault();
        if (actAsStop) {
          onStop();
        } else {
          submit();
        }
      }}
      size="icon-sm"
      type="button"
      variant="default"
      {...props}
    >
      {children ?? icon}
      <span className="sr-only">{actAsStop ? "Stop" : "Send"}</span>
    </InputGroupButton>
  );
}

export function ChatInputMentionButton({
  trigger,
  className,
  children,
  ...props
}: ComponentProps<typeof InputGroupButton> & { trigger?: string }) {
  const { editor, mentions } = useChatInputContext();

  const configs = mentions ? Object.values(mentions) : [];
  const resolvedTrigger = trigger ?? configs[0]?.trigger;
  if (!resolvedTrigger) {
    return null;
  }

  return (
    <InputGroupButton
      aria-label={`Insert ${resolvedTrigger}`}
      className={cn("shrink-0", className)}
      onClick={() => {
        editor?.chain().focus().insertContent(resolvedTrigger).run();
      }}
      size="icon-sm"
      type="button"
      variant="outline"
      {...props}
    >
      {children ?? <AtSignIcon className="size-4" />}
      <span className="sr-only">Insert {resolvedTrigger}</span>
    </InputGroupButton>
  );
}

More from Simple AI

Browse all components in this library →