Elements

Mentions

Type @ to pull people and agents into the conversation, filtered as you go.

fig. 01

Installation

npx shadcn@latest add "@assistant-ui/elements-composer"
First time? Set up a runtime

Runtime components read their state from an assistant-ui runtime. Add one to an existing project:

npx assistant-ui@latest init

Then wrap your app in a runtime provider:

import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/ai-sdk";

export default function App() {
  const runtime = useChatRuntime({
    transport: new AssistantChatTransport({ api: "/api/chat" }),
  });

  return (
    <AssistantRuntimeProvider runtime={runtime}>
      {/* your components */}
    </AssistantRuntimeProvider>
  );
}

The installation guide covers new projects, templates, and API routes.

Typing @ opens a floating menu of people above the composer; picking one inserts a mention and typing continues right after it. With a runtime the menu is driven by the same trigger primitive that powers slash commands, wired to a mention adapter instead of a command adapter; standalone you filter a plain list yourself with useMentionMatches and splice the pick in with applyMention.

Getting started

Mentions and slash commands share one trigger system, keyed by which character opens the popover; see Slash commands for the mechanism itself. Mentions use the @ char and the Directive behavior, which inserts text rather than firing a handler.

Define the people

unstable_useMentionAdapter bundles a person list into the { adapter, directive } pair the trigger needs. Left with no items or categories, it defaults to listing the tools registered on the thread's model context instead, so @ can mention a tool as easily as a person:

components/assistant-ui/elements/composer-mentions.tsx
"use client";

import { unstable_useMentionAdapter } from "@assistant-ui/react";

export function useComposerMentions() {
  return unstable_useMentionAdapter({
    items: [
      { id: "ada", type: "person", label: "Ada Lovelace" },
      { id: "grace", type: "person", label: "Grace Hopper" },
    ],
  });
}

Open the popover on @

components/assistant-ui/elements/composer-bar.tsx
"use client";

import { ComposerPrimitive } from "@assistant-ui/react";
import { cn } from "@/lib/utils";
import { floating, mono } from "@/components/assistant-ui/elements/surfaces";
import { useComposerMentions } from "./composer-mentions";

export function ComposerBar() {
  const mention = useComposerMentions();

  return (
    <ComposerPrimitive.Unstable_TriggerPopoverRoot>
      <ComposerPrimitive.Unstable_TriggerPopover char="@" adapter={mention.adapter}>
        <ComposerPrimitive.Unstable_TriggerPopover.Directive
          formatter={mention.directive.formatter}
          onInserted={mention.directive.onInserted}
        />
        <ComposerPrimitive.Unstable_TriggerPopoverItems>
          {(items) => (
            <div className={cn(floating, "absolute bottom-full z-10 mb-2 w-64 rounded-2xl p-1.5")}>
              {items.map((item, index) => (
                <ComposerPrimitive.Unstable_TriggerPopoverItem
                  key={item.id}
                  item={item}
                  index={index}
                  className="data-[highlighted]:bg-foreground/[0.04] flex w-full items-center gap-2.5 rounded-[10px] px-2.5 py-2 outline-none"
                >
                  <span className="bg-foreground/[0.06] text-foreground/45 flex size-5 shrink-0 items-center justify-center rounded-full text-[9px] font-medium">
                    {item.label[0]}
                  </span>
                  <span className="flex-1 truncate text-start">{item.label}</span>
                  <span className={cn(mono, "text-foreground/35")}>{item.type}</span>
                </ComposerPrimitive.Unstable_TriggerPopoverItem>
              ))}
            </div>
          )}
        </ComposerPrimitive.Unstable_TriggerPopoverItems>
      </ComposerPrimitive.Unstable_TriggerPopover>
      <ComposerPrimitive.Root className="flex w-full flex-col gap-2 rounded-[24px] p-2.5">
        <ComposerPrimitive.Input placeholder="Message, or @ to mention..." rows={1} className="min-h-11 w-full resize-none bg-transparent px-3 outline-none" />
      </ComposerPrimitive.Root>
    </ComposerPrimitive.Unstable_TriggerPopoverRoot>
  );
}

.Directive inserts the item's serialized text at the trigger position and calls onInserted afterward, instead of firing a handler the way .Action does. To render a sent mention back out as a chip inside the message (rather than as literal directive text), pair this with Directive text on the message-rendering side.

Anatomy

<div data-slot="composer-menu" data-open={/* true while a trailing "@query" exists */}>
  <button data-slot="composer-menu-item" data-active={/* the highlighted row */}>
    {/* initial-letter avatar, name, and a role label ("agent" or "human") */}
  </button>
</div>

With a runtime, a pick does not insert a chip directly into the textarea; it inserts literal directive text in unstable_defaultDirectiveFormatter's :type[label]{name=id} syntax (the {name=…} part is dropped when id and label match), which only renders as a chip once a message renderer parses it back out. Standalone, applyMention inserts the plain name instead, since there is no message-rendering step to hand a directive to.

Examples

Filtering as you type

The adapter's search matches a person's id, label, and description against the text after @, case-insensitively; with categories instead of items, matching drills down per category rather than searching everything at once.

mention.adapter.search("ada"); // → [{ id: "ada", label: "Ada Lovelace", ... }]

Mentioning a tool instead of a person

Leaving items and categories unset (or passing includeModelContextTools: true alongside them) lists every tool currently registered on aui.thread.getModelContext.tools, so @ doubles as a way to reference a tool the model has access to:

const mention = unstable_useMentionAdapter({
  includeModelContextTools: true,
});

Passing includeModelContextTools: { category } instead puts the tools behind a drill-down entry rather than listing them flat, unless items is also given.

Restyle the menu

Both lanes render into ComposerMenu; the avatar circle, name, and role label are each their own span, so restyling one does not require touching the others.

<ComposerMenu className="w-80" open={open}>
  {/* items */}
</ComposerMenu>

API reference

ComposerPrimitive (trigger)

PartRendersNotes
Unstable_TriggerPopoverRootproviderShared with every trigger char registered inside it; see Slash commands.
Unstable_TriggerPopoverdiv (when open)char="@", adapter.
Unstable_TriggerPopover.Directivebehaviorformatter?, onInserted?; inserts serialized directive text at the trigger position.
Unstable_TriggerPopoverItems / Itemrender prop / buttonSame shape as the slash-command popover; Item gets data-highlighted under keyboard navigation.

unstable_useMentionAdapter

OptionTypeDescription
itemsUnstable_Mention[]Flat list: { id, type, label, description?, icon?, metadata? }; supplying it keeps the adapter flat even when a tool category is configured.
categoriesUnstable_MentionCategory[]Grouped mentions for drill-down navigation; takes precedence over items.
includeModelContextToolsboolean | { category?, formatLabel?, icon? }Include tools from aui.thread.getModelContext(); an object category selects drill-down on its own unless items is given. @default true when neither items nor categories is given
formatterUnstable_DirectiveFormatter@default unstable_defaultDirectiveFormatter
onInserted(item) => voidFires after the directive text lands in the composer.

Returns { adapter, directive, iconMap?, fallbackIcon? }; spread directive onto Unstable_TriggerPopover.Directive.