Elements

Elements · AUI connected · AUI

Composer trigger popover

A character-triggered picker for mentions, slash commands, and nested composer actions.

Mention · directive behavior
Slash · action behavior
Back
fig. 01

Installation

npx shadcn@latest add "@assistant-ui/composer-trigger-popover"
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.

Composer trigger popover is the picker UI behind a character-triggered composer feature: type the trigger character and a positioned popover offers matching items, drilling into categories when the adapter has more than one. With a runtime, an adapter resolves items from live data and one of two behaviors decides what selecting an item does; standalone, the trigger detection watches the composer's live cursor position and text, so there is no equivalent outside a runtime.

Getting started

Wrap the composer

Place ComposerPrimitive.Unstable_TriggerPopoverRoot around the composer. Any number of ComposerTriggerPopover declarations can live inside, each with its own trigger character, adapter, and behavior.

components/assistant-ui/elements/thread.aui.tsx
import { ComposerPrimitive } from "@assistant-ui/react";

const Composer = () => (
  <ComposerPrimitive.Unstable_TriggerPopoverRoot>
    <ComposerPrimitive.Root>
      <ComposerPrimitive.Input placeholder="Type @ to mention..." />
      <ComposerPrimitive.Send />
      {/* triggers declared here */}
    </ComposerPrimitive.Root>
  </ComposerPrimitive.Unstable_TriggerPopoverRoot>
);

Declare a mention trigger

Pair ComposerTriggerPopover with unstable_useMentionAdapter, which spreads into an { adapter, directive } bundle. Selecting an item writes a directive into the composer text.

import { unstable_useMentionAdapter } from "@assistant-ui/react";
import { ComposerTriggerPopover } from "@/components/assistant-ui/elements/composer-trigger-popover.aui";

function MentionTrigger() {
  const mention = unstable_useMentionAdapter();
  return <ComposerTriggerPopover char="@" {...mention} />;
}

With no items or categories passed, unstable_useMentionAdapter lists whatever tools are registered in model context. Render the mentions it inserts as chips in the sent message with Directive text.

Anatomy

<div data-slot="composer-trigger-popover" role="listbox">
  {/* categories has entries, query is empty, and no category is active */}
  <div data-slot="composer-trigger-popover-categories" role="group">
    <button role="option" /> {/* one per category */}
  </div>

  {/* a category is active, or the query is non-empty, or the adapter has no categories */}
  <div data-slot="composer-trigger-popover-items" role="group">
    <button /> {/* Back, only while a category is active and not searching */}
    <button role="option" /> {/* one per item */}
  </div>
</div>

The two views are exclusive. Categories show only when the adapter reports at least one and the query is empty; typing narrows the visible categories by label. Anything else, from drilling into a category to a category-less adapter to a non-empty query, shows items: filtered locally against a category's items, or from adapter.search(query) (falling back to a manual cross-category filter when the adapter omits search). Backspace on an empty query returns from a drilled-into category to the list; it does nothing for a category-less adapter, since there is nothing to return to.

Examples

Slash command

Use unstable_useSlashCommandAdapter to bundle commands (each with its own execute) into { adapter, action }. By default a directive chip stays in the composer as an audit trail after the command runs; pass removeOnExecute to strip the /command text instead. iconMap maps each item's metadata.icon string to a component.

import {
  unstable_useSlashCommandAdapter,
  type Unstable_SlashCommand,
} from "@assistant-ui/react";
import { FileTextIcon, GlobeIcon, SlashIcon } from "lucide-react";

const SLASH_COMMANDS: readonly Unstable_SlashCommand[] = [
  { id: "summarize", description: "Summarize the conversation", icon: "FileText", execute: () => {} },
  { id: "search", description: "Search the web", icon: "Globe", execute: () => {} },
];

function SlashTrigger() {
  const slash = unstable_useSlashCommandAdapter({ commands: SLASH_COMMANDS });
  return (
    <ComposerTriggerPopover
      char="/"
      {...slash}
      iconMap={{ FileText: FileTextIcon, Globe: GlobeIcon }}
      fallbackIcon={SlashIcon}
    />
  );
}

Async items with a loading state

unstable_useLiveCompletionAdapter bridges an async source (a server search, a gateway RPC) into the synchronous adapter shape, debouncing fetches and caching results per query. Its isLoading feeds the popover's own isLoading prop, which swaps the empty-items message for loadingLabel while a fetch is in flight.

const mentions = unstable_useLiveCompletionAdapter({
  fetcher: (query) => searchUsers(query),
});

<ComposerTriggerPopover
  char="@"
  adapter={mentions.adapter}
  isLoading={mentions.isLoading}
  directive={{ onInserted: (item) => track("mention", item.id) }}
/>;

Custom query matching

Whitespace closes a trigger query by default. Pass a stable matcher when a picker needs different syntax, such as multi-word names; the same matcher governs both the textarea and Lexical composer inputs.

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

const matchMultiWord: Unstable_TriggerMatcher = (text, triggerChar, cursorPosition) => {
  const upToCursor = text.slice(0, cursorPosition);
  const offset = upToCursor.lastIndexOf(triggerChar);
  if (offset === -1) return null;

  const preceding = upToCursor[offset - 1];
  if (preceding && !/\s/u.test(preceding)) return null;

  const query = upToCursor.slice(offset + triggerChar.length);
  if (/[\n\t]/u.test(query) || query.endsWith("  ")) return null;

  return { query, offset, endOffset: cursorPosition };
};

<ComposerTriggerPopover char="@" matcher={matchMultiWord} {...mention} />;

Combining triggers

Multiple popovers share one TriggerPopoverRoot; each reads its own state from its char, so @ and / never collide.

<ComposerPrimitive.Unstable_TriggerPopoverRoot>
  <ComposerPrimitive.Root>
    <ComposerPrimitive.Input placeholder="Type @ to mention, / for commands..." />
    <MentionTrigger />
    <SlashTrigger />
  </ComposerPrimitive.Root>
</ComposerPrimitive.Unstable_TriggerPopoverRoot>

API reference

ComposerTriggerPopover

PropTypeDefaultDescription
charstringrequiredTrigger character, unique within the root.
matcherUnstable_TriggerMatcherwhitespace-terminatedOverrides trigger detection and the replaced text span.
adapterUnstable_TriggerAdapterrequiredSupplies categories, items, and search.
directive{ formatter?, onInserted? }Inserts a directive chip on selection. Mutually exclusive with action.
action{ formatter?, onExecute, removeOnExecute? }Fires a handler on selection. Mutually exclusive with directive.
iconMapRecord<string, IconComponent>Maps an item's or category's metadata.icon string to a component.
fallbackIconIconComponentSparklesIconUsed when iconMap has no match.
backLabelstring"Back"Label on the button that returns from items to categories.
emptyCategoriesLabelstring"No items available"Shown when the adapter has no categories.
emptyItemsLabelstring"No matching items"Shown when the current view's item list is empty.
isLoadingbooleanfalseSwaps emptyItemsLabel for loadingLabel while true.
loadingLabelstring"Loading…"Shown in place of emptyItemsLabel while isLoading.

All other props forward to the popover's root div.

Unstable_TriggerAdapter

FieldTypeDescription
categories()() => readonly Unstable_TriggerCategory[]Top-level categories. Return [] for a flat, search-only adapter.
categoryItems(id)(categoryId: string) => readonly Unstable_TriggerItem[]Items inside a category.
search(query)(query: string) => readonly Unstable_TriggerItem[]Optional. Without it, a category-less adapter's items come from a manual filter over categoryItems instead.

Unstable_TriggerItem is { id, type, label, description?, metadata? }; the directive formatter's default serialization writes :type[label]{name=id}, omitting {name=…} when id equals label.

Adapter hooks

HookReturnsDescription
unstable_useMentionAdapter(options?){ adapter, directive, iconMap?, fallbackIcon? }Flat or categorized mentions, optionally including tools registered in model context.
unstable_useSlashCommandAdapter({ commands, ... }){ adapter, action, iconMap?, fallbackIcon? }Bundles commands with inline execute callbacks.
unstable_useLiveCompletionAdapter({ fetcher, ... }){ adapter, isLoading }Debounces and caches an async source into a synchronous adapter.

Keyboard navigation

KeyAction
ArrowDown / ArrowUpMove the highlight, wrapping at either end
Enter / TabSelect the highlighted item, or drill into the highlighted category
Shift+EnterPasses through (composer inserts a newline)
Shift+TabPasses through (native focus traversal)
EscapeClose the popover
BackspaceReturn to categories, only while a category is active and the query is empty

Accessibility

The popover implements the WAI-ARIA editable combobox pattern: the list has role="listbox" and each entry role="option" with aria-selected. While a popover is open, the composer's ComposerPrimitive.Input automatically receives aria-controls, aria-expanded="true", aria-haspopup="listbox", and aria-activedescendant pointing at the highlighted option; these are removed when it closes. Rendering ComposerPrimitive.Input outside a TriggerPopoverRoot adds none of these attributes.