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 @.
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.