# AI composer

Add document references to a shadcn/ui composer with AI SDK 7 and Lexical.

Canonical: https://reactmention.com/docs/ai-composer



Write “Compare @pricing.md with @roadmap.md” and send the selected document IDs with your message. Mention handles suggestions, Lexical stores the draft and references, shadcn/ui supplies the controls, and AI SDK sends and receives messages.

## Try the interaction [#try-the-interaction]

Type `@`, choose a document, then press Enter again to send. Delete a reference or undo its deletion before sending to see how the request changes. **View submitted context** shows the exact outgoing message parts.

Responses are scripted in your browser. Enable **Fail the next response** to try recovery. Stopping or failing a response keeps your draft.

<div className="docs-example not-prose">
  <AIComposerDemo />
</div>

## Install the composer [#install-the-composer]

In a React 19 project configured for shadcn/ui:

```sh title="Install from the Mention GitHub registry"
npx shadcn@latest add danielivanovz/mention/ai-composer
```

The item installs Mention 0.2.1, AI SDK 7, the matching React hooks, Lexical 0.50, and the required shadcn components. It copies four files into your components directory: the composer, editor host, message types, and server resolver.

Preview the files with `shadcn view danielivanovz/mention/ai-composer` or add `--dry-run` to the install command. Append `#<commit-or-tag>` to the item address to pin a GitHub revision.

Render the copied composer with the documents the current user may search:

```tsx title="DocumentChat.tsx"
"use client";

import { AiComposer } from "@/components/ai-composer/ai-composer";

export function DocumentChat() {
  return <AiComposer documents={[
    { id: "doc-pricing", name: "pricing.md", description: "Plans and pricing" },
    { id: "doc-roadmap", name: "roadmap.md", description: "Upcoming work" },
  ]} />;
}
```

Configure your application's authenticated `/api/chat` route before sending. The composer posts there by default. Pass a `ChatTransport<ContextMessage>` as `transport` to use another endpoint or transport.

## Turn references into model context [#turn-references-into-model-context]

The user message contains a text part and, when needed, a typed `data-mentions` part with `{ id, name }` references. Both come from one read of the current Lexical document. Repeated references to the same ID are sent once; typing a matching label as plain text does not create a reference.

`onSelect` is an insertion notification, not the current reference inventory. Never accumulate a second list there: it would include mentions deleted later and miss mentions restored through undo or clipboard paste.

AI SDK's `convertToModelMessages` drops custom data parts unless you convert them explicitly. `resolveContextMessages` validates the message, looks up each ID on your server, and includes the stored document name and contents as text for the model. It rejects requests with missing or inaccessible documents.

Call it from your route before invoking the model:

```ts title="Inside your authenticated chat route"
const { messages } = await request.json();
const modelMessages = await resolveContextMessages(messages, async (id) => {
  // Implement this lookup with your application's verified user/session.
  return readDocumentForUser(user.id, id);
});
```

Your application supplies `user` and `readDocumentForUser`. The lookup returns `{ name, content }` or `null`. Filtering suggestions in the browser is not an access check. Resolve authorization again on every request, use server-owned document contents, and treat those contents as untrusted reference material in your model instructions.

This starter handles text conversations and user document references. It rejects client-supplied system messages and unsupported message parts. Add your application's tool and metadata schemas when expanding it into a tool-enabled chat. References from previous turns are re-resolved against current permissions and contents; use versioned IDs and storage if your product requires historical snapshots.

## Suggestion positioning [#suggestion-positioning]

The document panel anchors to the complete input group, prefers the space above it, and matches its width. This keeps document names and descriptions in a stable panel while the caret moves. If there is insufficient space above, Mention can flip the panel below and constrain it to available space.

The composer uses these `Mention.Popover` props:

```tsx title="Anchor to the composer"
const composer = useRef<HTMLDivElement>(null);

// Inside Mention.Root:
<InputGroup ref={composer}>{/* Editor and controls */}</InputGroup>
<Mention.Popover
  anchorRef={composer}
  placement="top-start"
  matchAnchorWidth
  aria-label="Documents"
>
  {/* Mention.List and Mention.Empty */}
</Mention.Popover>
```

Caret anchoring remains the default when `anchorRef` is omitted or empty. `container` controls the portal destination independently. These positioning options do not change the editor's selection, insertion, or keyboard handling.

## Editing and recovery [#editing-and-recovery]

* Enter commits a highlighted suggestion before it can submit. Shift+Enter inserts a line. Composition and modified keys remain with the editor.
* Copy, paste, formatting, deletion, and undo use the same Lexical host as the [rich-editor integration](/docs/lexical).
* A send captures the draft and references together. Editing pauses during the response; success clears only that submitted draft.
* Failure and stop preserve the draft. **Retry response** retries the earlier user message without appending a duplicate. If you edited the draft after a failure, retry leaves that newer draft untouched.
* `MessageScroller` follows the conversation within its own viewport and lets the reader scroll back. Mention scrolls its suggestion list independently.

Automated browser checks do not establish real OS IME or screen-reader compatibility. See the [accessibility guide](/docs/accessibility) for those remaining checks.

## Source [#source]

These are the files installed by the registry and run by this website. The editor host is also shared with the existing Lexical example.

```tsx title="ai-composer.tsx"
// biome-ignore-all lint/suspicious/noArrayIndexKey: AI SDK appends parts at stable positions; streamed text has no part ID.
"use client";

import { useChat } from "@ai-sdk/react";
import { Mention } from "@danielivanov/mention";
import { type ChatTransport, DefaultChatTransport } from "ai";
import { ArrowUpIcon, AtSignIcon, SquareIcon } from "lucide-react";
import { useId, useRef, useState } from "react";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import { Button } from "@/components/ui/button";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
} from "@/components/ui/input-group";
import {
  Message,
  MessageContent,
  MessageHeader,
} from "@/components/ui/message";
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from "@/components/ui/message-scroller";
import type {
  ContextDocument,
  ContextMessage,
} from "@/registry/default/ai-composer/context-message";
import {
  LexicalMentionEditor,
  type LexicalMentionEditorHandle,
} from "@/registry/default/ai-composer/mention-editor";
import "@danielivanov/mention/styles.css";

const defaultTransport = new DefaultChatTransport<ContextMessage>({
  api: "/api/chat",
});

export function AiComposer({
  documents,
  transport = defaultTransport,
  active = true,
}: {
  documents: ContextDocument[];
  transport?: ChatTransport<ContextMessage>;
  active?: boolean;
}) {
  const id = useId();
  const editor = useRef<LexicalMentionEditorHandle>(null);
  const composer = useRef<HTMLDivElement>(null);
  const sending = useRef(false);
  const completed = useRef(false);
  const submittedDraft = useRef("");
  const [empty, setEmpty] = useState(true);
  const [sendingDraft, setSendingDraft] = useState(false);
  const { messages, status, error, sendMessage, regenerate, stop } =
    useChat<ContextMessage>({
      transport,
      onFinish: ({ isAbort, isError }) => {
        completed.current = !isAbort && !isError;
      },
    });
  const busy = sendingDraft || status === "submitted" || status === "streaming";

  async function submit(retry = false) {
    if (!active || sending.current || busy || !editor.current) return;
    const draft = editor.current.getSnapshot();
    if (!retry && !draft.text.trim()) return;
    sending.current = true;
    setSendingDraft(true);
    completed.current = false;
    try {
      if (retry) {
        await regenerate();
      } else {
        submittedDraft.current = editor.current.getJSON();
        await sendMessage({
          parts: [
            { type: "text", text: draft.text },
            ...(draft.references.length
              ? [{ type: "data-mentions" as const, data: draft.references }]
              : []),
          ],
        });
      }
      // AI SDK reports transport failures through onError, not a rejected send promise.
      // Keep a failed/stopped draft. Retrying an old message must not clear a newer draft.
      if (
        completed.current &&
        editor.current?.getJSON() === submittedDraft.current
      ) {
        editor.current.clear();
      }
    } finally {
      sending.current = false;
      setSendingDraft(false);
    }
  }

  return (
    <div className="ai-composer flex min-w-0 flex-col gap-4">
      {messages.length > 0 && (
        <div className="h-64 min-w-0">
          <MessageScrollerProvider autoScroll>
            <MessageScroller>
              <MessageScrollerViewport aria-label="Conversation" tabIndex={0}>
                <MessageScrollerContent className="gap-5 p-1">
                  {messages.map((message) => (
                    <MessageScrollerItem
                      key={message.id}
                      messageId={message.id}
                      scrollAnchor={message.role === "user"}
                    >
                      <Message
                        align={message.role === "user" ? "end" : "start"}
                      >
                        <MessageContent>
                          <MessageHeader>
                            {message.role === "user" ? "You" : "Assistant"}
                          </MessageHeader>
                          <Bubble
                            variant={
                              message.role === "user" ? "secondary" : "ghost"
                            }
                          >
                            <BubbleContent>
                              {message.parts.map((part, index) =>
                                part.type === "text" ? (
                                  <p
                                    key={index}
                                    className="whitespace-pre-wrap"
                                  >
                                    {part.text}
                                  </p>
                                ) : part.type === "data-mentions" ? (
                                  <ul
                                    key={index}
                                    aria-label="Referenced documents"
                                    className="mt-2 flex flex-wrap gap-2"
                                  >
                                    {part.data.map((reference) => (
                                      <li
                                        key={reference.id}
                                        data-reference-id={reference.id}
                                        className="rounded-sm border border-border px-2 py-1 text-xs"
                                      >
                                        {reference.name}
                                      </li>
                                    ))}
                                  </ul>
                                ) : null,
                              )}
                            </BubbleContent>
                          </Bubble>
                        </MessageContent>
                      </Message>
                    </MessageScrollerItem>
                  ))}
                </MessageScrollerContent>
              </MessageScrollerViewport>
              <MessageScrollerButton />
            </MessageScroller>
          </MessageScrollerProvider>
        </div>
      )}

      <Mention.Root<ContextDocument>
        items={documents}
        allowSpaces
        getKey={(item) => item.id}
        getLabel={(item) => item.name}
      >
        <form
          onSubmit={(event) => {
            event.preventDefault();
            void submit();
          }}
          className="flex flex-col gap-2"
        >
          <label htmlFor={id} className="text-sm font-medium">
            Message with context
          </label>
          <InputGroup ref={composer}>
            <LexicalMentionEditor<ContextDocument>
              ref={editor}
              id={id}
              label="Message with context"
              active={active && !busy}
              data-slot="input-group-control"
              aria-describedby={`${id}-hint`}
              className="min-h-28 w-full min-w-0 whitespace-pre-wrap break-words p-3 text-base outline-none [&_.mention-token]:rounded-sm [&_.mention-token]:bg-muted [&_.mention-token]:text-foreground"
              onDocument={() =>
                setEmpty(!editor.current?.getSnapshot().text.trim())
              }
              onSubmit={() => void submit()}
            />
            <InputGroupAddon align="block-end">
              <InputGroupButton
                disabled={!active || busy}
                size="sm"
                onClick={() => editor.current?.insertTrigger("@")}
              >
                <AtSignIcon data-icon="inline-start" /> Reference
              </InputGroupButton>
              {busy ? (
                <InputGroupButton
                  type="button"
                  size="icon-sm"
                  variant="outline"
                  className="ml-auto"
                  aria-label="Stop response"
                  onClick={() => void stop()}
                >
                  <SquareIcon />
                </InputGroupButton>
              ) : (
                <InputGroupButton
                  type="submit"
                  size="icon-sm"
                  variant="default"
                  className="ml-auto"
                  aria-label="Send message"
                  disabled={!active || empty}
                >
                  <ArrowUpIcon />
                </InputGroupButton>
              )}
            </InputGroupAddon>
          </InputGroup>
          <p id={`${id}-hint`} className="text-sm text-muted-foreground">
            Type @ to reference a document. Enter selects, then sends.
            Shift+Enter adds a line.
          </p>
          <p role="status" className="sr-only">
            {busy ? "Receiving response…" : ""}
          </p>
          {error && (
            <div
              role="alert"
              className="flex flex-wrap items-center gap-3 text-sm"
            >
              <p>The response failed. Your draft is still here.</p>
              <Button
                type="button"
                variant="outline"
                size="sm"
                disabled={!active || busy}
                onClick={() => void submit(true)}
              >
                Retry response
              </Button>
            </div>
          )}
        </form>
        <Mention.Popover
          container={null}
          anchorRef={composer}
          placement="top-start"
          matchAnchorWidth
          aria-label="Documents"
        >
          <Mention.List<ContextDocument>>
            {(document) => (
              <Mention.Item value={document}>
                <span className="flex min-w-0 flex-col gap-1">
                  <span>{document.name}</span>
                  {document.description && (
                    <span className="text-xs">{document.description}</span>
                  )}
                </span>
              </Mention.Item>
            )}
          </Mention.List>
          <Mention.Empty>No documents found. Try another name.</Mention.Empty>
        </Mention.Popover>
      </Mention.Root>
    </div>
  );
}

```

```ts title="context-message.ts"
import type { UIMessage } from "ai";

export type MentionReference = { id: string; name: string };
export type ContextDocument = MentionReference & { description?: string };
export type ContextMessage = UIMessage<never, { mentions: MentionReference[] }>;

```

```ts title="resolve-context.ts"
import { convertToModelMessages, validateUIMessages } from "ai";
import { z } from "zod";
import type { ContextMessage } from "@/registry/default/ai-composer/context-message";

const references = z
  .array(
    z.object({
      id: z.string().min(1).max(200),
      name: z.string().min(1).max(200),
    }),
  )
  .max(20);

/** Call on the server. The resolver must enforce the signed-in user's access. */
export async function resolveContextMessages(
  input: unknown,
  resolve: (id: string) => Promise<{ name: string; content: string } | null>,
) {
  const messages = await validateUIMessages<ContextMessage>({
    messages: input,
    dataSchemas: { mentions: references },
  });
  const ids = new Set<string>();
  for (const message of messages) {
    if (message.role !== "user" && message.role !== "assistant") {
      throw new Error("Only user and assistant messages are accepted.");
    }
    for (const part of message.parts) {
      // Normal SDK text streams include step boundaries in assistant history.
      if (part.type === "step-start" && message.role === "assistant") continue;
      if (part.type === "data-mentions" && message.role === "user") {
        for (const reference of part.data) ids.add(reference.id);
      } else if (part.type !== "text") {
        throw new Error(
          "This example accepts text and user document references.",
        );
      }
    }
  }
  const documents = new Map<string, { name: string; content: string }>();
  await Promise.all(
    [...ids].map(async (id) => {
      const document = await resolve(id);
      if (!document) throw new Error("A referenced document is unavailable.");
      documents.set(id, document);
    }),
  );
  return convertToModelMessages<ContextMessage>(messages, {
    convertDataPart(part) {
      // UI data parts are otherwise discarded by AI SDK. Use server-owned names/content.
      return {
        type: "text",
        text: part.data
          .map(({ id }) => {
            const document = documents.get(id);
            if (!document)
              throw new Error("A referenced document is unavailable.");
            return JSON.stringify({
              reference: id,
              name: document.name,
              content: document.content,
            });
          })
          .join("\n"),
      };
    },
  });
}

```

```tsx title="mention-editor.tsx"
"use client";

import { type EditorAdapter, useMentionContext } from "@danielivanov/mention";
import { HistoryExtension } from "@lexical/history";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import { LexicalExtensionComposer } from "@lexical/react/LexicalExtensionComposer";
import { RichTextExtension } from "@lexical/rich-text";
import {
  $create,
  $createParagraphNode,
  $createRangeSelectionFromDom,
  $createTextNode,
  $getDocument,
  $getRoot,
  $getSelection,
  $getState,
  $isElementNode,
  $isLineBreakNode,
  $isRangeSelection,
  $isTextNode,
  $setState,
  createState,
  defineExtension,
  type EditorConfig,
  HISTORY_PUSH_TAG,
  type LexicalEditor,
  type LexicalNode,
  TextNode,
} from "lexical";
import {
  type CSSProperties,
  type KeyboardEvent,
  type Ref,
  useImperativeHandle,
  useLayoutEffect,
  useRef,
} from "react";

type MentionValue = { id: string; name: string };
const idState = createState("mentionId", {
  parse: (value) => (typeof value === "string" ? value : ""),
});
const labelState = createState("mentionLabel", {
  parse: (value) => (typeof value === "string" ? value : ""),
});

const triggerState = createState("mentionTrigger", {
  parse: (value) => (typeof value === "string" ? value : "@"),
});

/** Token mode makes the entire mention one deletion while retaining Lexical's text formatting. */
export class MentionNode extends TextNode {
  override $config() {
    return this.config("mention", {
      extends: TextNode,
      stateConfigs: [
        { stateConfig: idState, flat: true },
        { stateConfig: labelState, flat: true },
        { stateConfig: triggerState, flat: true },
      ],
      importDOM: {
        span: (element) =>
          element.hasAttribute("data-mention-id")
            ? {
                priority: 1,
                conversion: (element) => ({
                  node: $createMentionNode(
                    element.getAttribute("data-mention-id") ?? "",
                    element.getAttribute("data-mention-label") ?? "",
                    element.textContent ?? "",
                    element.getAttribute("data-mention-trigger") ?? "@",
                  )
                    .setFormat(
                      Number(element.getAttribute("data-mention-format")) || 0,
                    )
                    .setStyle(element.getAttribute("style") ?? ""),
                }),
              }
            : null,
      },
    });
  }
  override createDOM(config: EditorConfig) {
    const element = super.createDOM(config);
    element.classList.add("mention-token");
    element.dataset.mentionId = $getState(this, idState);
    element.dataset.mentionLabel = $getState(this, labelState);
    element.dataset.mentionTrigger = $getState(this, triggerState);
    return element;
  }
  override exportDOM(editor: LexicalEditor) {
    const { element } = super.exportDOM(editor);
    // TextNode can render strong/em/code. A canonical span keeps HTML-only paste
    // independent of its current formatting tag; JSON clipboard data is optional.
    const wrapper = $getDocument().createElement("span");
    wrapper.dataset.mentionId = $getState(this, idState);
    wrapper.dataset.mentionLabel = $getState(this, labelState);
    wrapper.dataset.mentionTrigger = $getState(this, triggerState);
    wrapper.dataset.mentionFormat = String(this.getFormat());
    wrapper.style.cssText = this.getStyle();
    if (element) wrapper.append(element);
    return { element: wrapper };
  }
  override canInsertTextBefore() {
    return false;
  }
  override canInsertTextAfter() {
    return false;
  }
}

function $createMentionNode(
  id: string,
  label: string,
  text: string,
  trigger: string,
) {
  const node = $create(MentionNode).setTextContent(text).setMode("token");
  $setState(node, idState, id);
  $setState(node, labelState, label);
  $setState(node, triggerState, trigger);
  return node;
}

const extension = defineExtension({
  name: "mention/lexical-example",
  namespace: "mention/lexical-example",
  nodes: [MentionNode],
  dependencies: [RichTextExtension, HistoryExtension],
  $initialEditorState: () => {
    $getRoot().append($createParagraphNode());
  },
});

/** Map one block to UTF-16 offsets without scanning inside existing mention tokens. */
function $readRegion() {
  const selection = $getSelection();
  if (!$isRangeSelection(selection) || !selection.isCollapsed()) return null;
  const anchor = selection.anchor;
  let block: LexicalNode | null = anchor.getNode();
  while (block && (!$isElementNode(block) || block.isInline()))
    block = block.getParent();
  if (!$isElementNode(block) || block.getType() === "root") return null;
  let text = "";
  let caret: number | null = null;
  const segments: { node: TextNode; from: number; to: number }[] = [];
  function visit(node: LexicalNode) {
    const from = text.length;
    if ($isElementNode(node)) {
      const children = node.getChildren();
      children.forEach((child, index) => {
        if (anchor.key === node.getKey() && anchor.offset === index)
          caret = text.length;
        visit(child);
      });
      if (anchor.key === node.getKey() && anchor.offset === children.length)
        caret = text.length;
    } else if ($isTextNode(node) && node.isSimpleText()) {
      text += node.getTextContent();
      segments.push({ node, from, to: text.length });
      if (anchor.key === node.getKey()) caret = from + anchor.offset;
    } else {
      text += $isLineBreakNode(node) ? "\n" : "\ufffc";
      // A token's internal offsets are not editable positions.
      if (anchor.key === node.getKey() && anchor.offset === 0) caret = from;
      else if (
        anchor.key === node.getKey() &&
        anchor.offset === node.getTextContentSize()
      )
        caret = text.length;
    }
  }
  visit(block);
  return caret === null
    ? null
    : { text, caret, key: block.getKey(), segments, selection };
}

export interface LexicalMentionEditorHandle {
  focus(): void;
  clear(): void;
  insertTrigger(trigger: string): void;
  getJSON(): string;
  restoreJSON(json: string): void;
  getSnapshot(): { text: string; references: { id: string; name: string }[] };
}

type EditorProps = {
  id?: string;
  label: string;
  className?: string;
  style?: CSSProperties;
  "aria-describedby"?: string;
  "aria-labelledby"?: string;
  placeholder?: string;
  active?: boolean;
  ref?: Ref<LexicalMentionEditorHandle>;
  onEmptyChange?: (empty: boolean) => void;
  onDocument?: (json: string) => void;
  onSubmit?: () => void;
  "data-slot"?: string;
};

function Editor<T extends MentionValue>({
  ref,
  active = true,
  onDocument,
  onEmptyChange,
  onSubmit,
  placeholder,
  label,
  ...props
}: EditorProps) {
  const [editor] = useLexicalComposerContext();
  const mention = useMentionContext<T>();
  const latest = useRef({ mention, active, onDocument, onEmptyChange });
  const composing = useRef(false);
  useLayoutEffect(() => {
    latest.current = { mention, active, onDocument, onEmptyChange };
  });

  useLayoutEffect(() => {
    editor.setEditable(active);
    if (!active) mention.setOpen(false);
  }, [active, editor, mention.setOpen]);

  useLayoutEffect(() => {
    const removeRoot = editor.registerRootListener((element) => {
      if (!element) {
        latest.current.mention.setEditor(null);
        return;
      }
      const adapter: EditorAdapter<T> = {
        element,
        read() {
          if (
            !latest.current.active ||
            composing.current ||
            editor.isComposing() ||
            element.ownerDocument.activeElement !== element
          )
            return null;
          return editor.getEditorState().read(
            () => {
              // Selectionchange can trail a native caret move. Never commit against the old model selection.
              const domSelection = $createRangeSelectionFromDom(
                element.ownerDocument.getSelection(),
                editor,
              );
              const selection = $getSelection();
              if (
                !$isRangeSelection(selection) ||
                !domSelection?.anchor.is(selection.anchor) ||
                !domSelection.focus.is(selection.focus)
              )
                return null;
              const region = $readRegion();
              return (
                region && {
                  text: region.text,
                  caret: region.caret,
                  key: region.key,
                }
              );
            },
            { editor },
          );
        },
        getCaretRect() {
          const selection = element.ownerDocument.getSelection();
          if (
            !selection?.rangeCount ||
            !selection.isCollapsed ||
            !element.contains(selection.anchorNode)
          )
            return null;
          return selection.getRangeAt(0).getBoundingClientRect();
        },
        replace(edit, item, meta) {
          let applied = false;
          editor.update(
            () => {
              const region = $readRegion();
              if (
                !region ||
                !latest.current.active ||
                composing.current ||
                editor.isComposing()
              )
                return;
              const start = region.segments.find(
                ({ from, to }) => edit.from >= from && edit.from < to,
              );
              const end = region.segments.find(
                ({ from, to }) => edit.to > from && edit.to <= to,
              );
              if (!start || !end) return;
              const format = region.selection.format;
              region.selection.setTextNodeRange(
                start.node,
                edit.from - start.from,
                end.node,
                edit.to - end.from,
              );
              const separator = edit.text.endsWith(" ") ? " " : "";
              const text = separator ? edit.text.slice(0, -1) : edit.text;
              const node = $createMentionNode(
                item.id,
                item.name,
                text,
                meta.trigger,
              ).setFormat(format);
              region.selection.insertNodes(
                separator
                  ? [node, $createTextNode(separator).setFormat(format)]
                  : [node],
              );
              applied = true;
            },
            { discrete: true, tag: HISTORY_PUSH_TAG },
          );
          return applied;
        },
      };
      latest.current.mention.setEditor(adapter);
    });
    const removeUpdate = editor.registerUpdateListener(({ editorState }) => {
      editorState.read(() => {
        latest.current.onEmptyChange?.($getRoot().getTextContentSize() === 0);
      });
      latest.current.onDocument?.(JSON.stringify(editorState.toJSON()));
      latest.current.mention.refresh();
    });
    return () => {
      removeUpdate();
      removeRoot();
      latest.current.mention.setEditor(null);
    };
  }, [editor]);

  useImperativeHandle(
    ref,
    () => ({
      focus() {
        if (latest.current.active) editor.focus();
      },
      clear() {
        editor.update(
          () => {
            $getRoot().clear().append($createParagraphNode()).selectEnd();
          },
          { discrete: true, tag: HISTORY_PUSH_TAG },
        );
        if (latest.current.active) editor.focus();
        latest.current.mention.setOpen(false);
      },
      insertTrigger(trigger) {
        if (!latest.current.active) return;
        editor.focus(() => {
          editor.update(
            () => {
              let selection = $getSelection();
              if (!$isRangeSelection(selection)) {
                $getRoot().selectEnd();
                selection = $getSelection();
              }
              if (!$isRangeSelection(selection)) return;
              const region = $readRegion();
              const prefix =
                region &&
                region.caret > 0 &&
                !/\s/.test(region.text[region.caret - 1] ?? "")
                  ? " "
                  : "";
              selection.insertText(prefix + trigger);
            },
            { discrete: true, tag: HISTORY_PUSH_TAG },
          );
          latest.current.mention.setOpen(true);
        });
      },
      getJSON() {
        return JSON.stringify(editor.getEditorState().toJSON());
      },
      getSnapshot() {
        return editor.getEditorState().read(() => {
          const references = new Map<string, { id: string; name: string }>();
          function visit(node: LexicalNode) {
            if (node instanceof MentionNode) {
              const id = $getState(node, idState);
              references.set(id, { id, name: $getState(node, labelState) });
            } else if ($isElementNode(node)) {
              node.getChildren().forEach(visit);
            }
          }
          visit($getRoot());
          return {
            text: $getRoot().getTextContent(),
            references: [...references.values()],
          };
        });
      },
      restoreJSON(json) {
        editor.setEditorState(editor.parseEditorState(json), {
          tag: HISTORY_PUSH_TAG,
        });
        latest.current.mention.setOpen(false);
      },
    }),
    [editor],
  );

  const relationships = mention.getEditorProps();
  return (
    <ContentEditable
      {...props}
      aria-autocomplete={relationships["aria-autocomplete"]}
      aria-haspopup={relationships["aria-haspopup"]}
      aria-controls={relationships["aria-controls"]}
      aria-activedescendant={relationships["aria-activedescendant"]}
      aria-label={label}
      role="textbox"
      aria-multiline="true"
      data-placeholder={placeholder}
      onFocus={() => mention.refresh()}
      onKeyDownCapture={(event) => {
        if (mention.handleKeyDown(event)) {
          event.stopPropagation();
          return;
        }
        if (
          onSubmit &&
          isSubmitKey(event) &&
          !composing.current &&
          !editor.isComposing()
        ) {
          event.preventDefault();
          event.stopPropagation();
          onSubmit();
        }
      }}
      onCompositionStart={() => {
        composing.current = true;
        mention.refresh();
      }}
      onCompositionEnd={() => {
        queueMicrotask(() => {
          composing.current = false;
          latest.current.mention.refresh();
        });
      }}
    />
  );
}

function isSubmitKey(event: KeyboardEvent) {
  return (
    event.key === "Enter" &&
    !event.shiftKey &&
    !event.altKey &&
    !event.ctrlKey &&
    !event.metaKey &&
    !event.nativeEvent.isComposing &&
    !event.defaultPrevented
  );
}

/** Example-level host integration, not an additional Mention package API. */
export function LexicalMentionEditor<T extends MentionValue>(
  props: EditorProps,
) {
  return (
    <LexicalExtensionComposer extension={extension} contentEditable={null}>
      <Editor<T> {...props} />
    </LexicalExtensionComposer>
  );
}

```

The website demo uses `@shadcn/helpers/ai-sdk` for scripted responses through `useChat`. Its sample data and transport stay in this wrapper, which is excluded from the registry item:

```tsx title="AIComposerDemo.tsx"
// biome-ignore-all lint/a11y/noNoninteractiveTabindex: The bounded request preview must be keyboard-scrollable.
"use client";

import { createChat } from "@shadcn/helpers/ai-sdk";
import { useMemo, useRef, useState } from "react";
import { AiComposer } from "@/registry/default/ai-composer/ai-composer";
import type {
  ContextDocument,
  ContextMessage,
} from "@/registry/default/ai-composer/context-message";

const sampleDocuments: ContextDocument[] = [
  {
    id: "doc-pricing",
    name: "pricing.md",
    description: "Plans and pricing assumptions",
  },
  {
    id: "doc-research",
    name: "customer research.md",
    description: "Interview notes and open questions",
  },
  {
    id: "doc-roadmap",
    name: "roadmap.md",
    description: "What we are building next",
  },
];

/** The scripted transport belongs to this demo; it is excluded from the registry item. */
export function AIComposerDemo({ active = true }: { active?: boolean }) {
  const [request, setRequest] = useState<ContextMessage | null>(null);
  const [failNext, setFailNext] = useState(false);
  const failure = useRef(false);
  const transport = useMemo(() => {
    const scripted = createChat<ContextMessage>().transport({
      delayMs: 45,
      fallback: ({ writer, messages }) => {
        const message = [...messages]
          .reverse()
          .find((item) => item.role === "user");
        const refs =
          message?.parts.flatMap((part) =>
            part.type === "data-mentions" ? part.data : [],
          ) ?? [];
        writer
          .sleep(200)
          .text(
            refs.length
              ? `Your message includes references to ${refs.map((ref) => ref.name).join(" and ")}.`
              : "Your message has no document references.",
          );
      },
    });
    return {
      ...scripted,
      async sendMessages(options: Parameters<typeof scripted.sendMessages>[0]) {
        setRequest(
          [...options.messages]
            .reverse()
            .find((message) => message.role === "user") ?? null,
        );
        if (failure.current) {
          failure.current = false;
          setFailNext(false);
          throw new Error("Example connection failure");
        }
        return scripted.sendMessages(options);
      },
    };
  }, []);
  return (
    <div className="ai-composer-demo flex min-w-0 flex-col gap-4">
      <div className="ai-demo-heading">
        <span>Choose what the assistant receives</span>
        <span>Sample responses</span>
      </div>
      <AiComposer
        documents={sampleDocuments}
        transport={transport}
        active={active}
      />
      <div className="ai-demo-controls">
        <label>
          <input
            type="checkbox"
            checked={failNext}
            onChange={(event) => {
              failure.current = event.target.checked;
              setFailNext(event.target.checked);
            }}
          />{" "}
          Fail the next response
        </label>
        <span>No model or network request</span>
      </div>
      {request && (
        <details className="ai-request">
          <summary>View submitted context</summary>
          <pre data-testid="submitted-context" tabIndex={0}>
            {JSON.stringify(request.parts, null, 2)}
          </pre>
        </details>
      )}
    </div>
  );
}

```
