Mentions

A controlled mention picker for the Composer, and the inline chip a selection leaves behind. The TipTap editor stays mounted and focused while the panel filters on the name typed after @.

Mention
OOperateYour agent
DSDana ScullyAccount executive
FMFox MulderSolutions engineer
WSWalter SkinnerSales lead

Nothing selected yet.

Installation

import {
  Mention,
  MentionChip,
  MentionChipExtension,
  getMentionChipsFromDoc,
  insertMentionChipAt,
} from "@o/pipeline/experimental/components/composer/mentions";
import { useComposerSuggestion } from "@o/pipeline/experimental/components/composer";

This is not the page editor’s MentionExtension. That one renders its own list into document.body for rich-text documents. This one is a composer picker: the panel sits on the composer surface, the host owns visibility, and the chip carries a principal rather than an href.

Usage

Mention.Root is controlled. The host supplies the query from the active TipTap suggestion and forwards keyboard events through handleRef.

<Mention.Root
  items={mentionables}
  query={query}
  onMentionSelect={selectMention}
  handleRef={mentionListHandleRef}
  listId={listId}
  onActiveOptionChange={setActiveOptionId}
>
  <Mention.Header>
    <Mention.HeaderTitle>Mention</Mention.HeaderTitle>
  </Mention.Header>
  <Mention.List>
    {(item, index) => <Mention.Item key={item.id} item={item} index={index} />}
  </Mention.List>
  <Mention.Empty>
    <Mention.EmptyMessage />
  </Mention.Empty>
</Mention.Root>

Mount it inside Composer.Editor, directly above Composer.EditorBody, and wrap it in a div whose onPointerDown calls preventDefault() so clicking a row never steals the editor’s focus or dissolves the active match.

Each MentionItem carries a principal ("user" for a person, "agent" for an agent), an id, a label, and optionally a description and an avatar node. The host owns the avatar because the design system has no opinion about where a face comes from.

Accessibility

The list is a listbox of option rows, and the editor is the combobox input — it never loses focus, so the highlight has to be announced rather than focused. Pass a listId, put it on the editor’s aria-controls, and mirror onActiveOptionChange onto the editor’s aria-activedescendant; set aria-expanded from your own panel visibility. Mention.Empty stays mounted with role="status" so an empty result set is announced.

Opening the picker

useComposerSuggestion (shared with the skill picker) drives the trigger. Pass char: "@" and a distinct pluginName so both triggers can run in one composer.

const { matchRange, insertTrigger } = useComposerSuggestion({
  char: "@",
  pluginName: "composerMentionSuggestion",
  onStart: (query) => setSuggestion({ query, hiddenBy: null }),
  onUpdate: (query) => setSuggestion({ query, hiddenBy: null }),
  onExit: () => setSuggestion(null),
  onKeyDown: (event) => mentionListHandleRef.current?.onKeyDown(event) ?? false,
});

Register the mention hook after the submit hook and after the skill hook: each prepends an Enter keymap, and whichever registers last gets first refusal.

Chips

Register MentionChipExtension on Editor.Root. A selection is an inline atom inserted where the trigger was typed: insertMentionChipAt(editor, matchRange() ?? collapsedSelection, { principal, id, label }) replaces the @query with the chip and a separator space.

Unlike a skill chip — which serializes to nothing, because the skill is carried out of band — a mention is part of what was said, so it serializes to @Name in both editor.getText() and composerSegmentsText. getMentionChipsFromDoc returns every mention in document order at send time, duplicates intact; getComposerSegmentsFromDoc returns them in place among the prose.

@Dana Scully@Operate