@relevaince/mentions
v0.6.0
Published
Structured mention engine for Relevaince OMNI chat input
Downloads
885
Maintainers
Readme
@relevaince/mentions
A structured mention engine for React. Built on Tiptap/ProseMirror internally, exposed as a single component externally.
This is not a simple @mention dropdown. It is a resource-addressing language inside text — typed entity tokens that look like text but carry structured data. Think Slack, Notion, or Linear mentions.
Features
- Multiple trigger characters —
@,#,:, or any custom trigger - Nested suggestions — drill into workspaces, then pick a file inside
- Cross-group search —
searchAllfinds items across every level with a single query - Nested search — search input inside the dropdown to filter children in real time
- Async providers — fetch suggestions from any API
- Debounced fetching — per-provider
debounceMsprevents API floods - Structured output — returns
{ markdown, tokens, plainText }on every change - Markdown serialization —
@[label](id)token syntax for storage and LLM context - Markdown parsing —
extractFromMarkdown()returns tokens + plain text from a markdown string - Headless styling — zero bundled CSS, style via
data-*attributes with Tailwind or plain CSS - Accessible — full ARIA combobox pattern with keyboard navigation
- SSR compatible — safe for Next.js with
"use client"directive - Grouped suggestions — section items under headers (e.g. "Active", "Pending")
- Recently used items — show recent mentions when the query is empty
- Empty state — customizable "No results" message
- Mention lifecycle —
onMentionAdd/onMentionRemovecallbacks - Mention interaction —
onMentionClick/onMentionHoverhandlers on chips - Mention validation — mark stale/invalid mentions with
validateMention - Controlled value — reactive
valueprop for external content updates - Auto-resize —
minHeight/maxHeightfor growing editor - Submit key customization — choose Enter, Cmd+Enter, or none
- Edge-aware popover — flips above when near viewport bottom
- Portal support — render the dropdown into a custom container
- Tab to complete — Tab selects the active suggestion
- Trigger gating —
allowTriggerto conditionally suppress the dropdown - Streaming support — stream AI-generated text into the editor with automatic mention parsing
- Multi-instance — unique ARIA IDs per component instance
Install
npm install @relevaince/mentionsPeer dependencies:
npm install react react-domQuick start
import { MentionsInput, type MentionProvider } from "@relevaince/mentions";
const workspaceProvider: MentionProvider = {
trigger: "@",
name: "Workspaces",
debounceMs: 200,
async getRootItems(query) {
const res = await fetch(`/api/workspaces?q=${query}`);
return res.json();
},
async getChildren(parent, query) {
const res = await fetch(`/api/workspaces/${parent.id}/files?q=${query}`);
return res.json();
},
async getRecentItems() {
const res = await fetch("/api/workspaces/recent");
return res.json();
},
};
function Chat() {
return (
<MentionsInput
providers={[workspaceProvider]}
onChange={(output) => {
console.log(output.markdown); // "Summarize @[Marketing](ws_123)"
console.log(output.tokens); // [{ id: "ws_123", type: "workspace", label: "Marketing" }]
console.log(output.plainText); // "Summarize Marketing"
}}
onSubmit={(output) => sendMessage(output)}
onMentionAdd={(token) => console.log("Added:", token)}
onMentionRemove={(token) => console.log("Removed:", token)}
placeholder="Ask anything..."
minHeight={40}
maxHeight={200}
/>
);
}Core concepts
MentionToken
The fundamental data model. Every mention in the editor is a typed token:
type MentionToken = {
id: string; // unique entity identifier
type: string; // "workspace" | "contract" | "file" | "web" | custom
label: string; // display text
data?: unknown; // optional payload, passed through untouched
};MentionProvider
Register one provider per trigger character. Each provider fetches suggestions and optionally supports nested drill-down, cross-group search, debouncing, and recent items:
type MentionProvider = {
trigger: string;
name: string;
getRootItems: (query: string) => Promise<MentionItem[]>;
getChildren?: (parent: MentionItem, query: string) => Promise<MentionItem[]>;
searchAll?: (query: string) => Promise<MentionItem[]>;
debounceMs?: number;
getRecentItems?: () => Promise<MentionItem[]>;
};| Property | Type | Required | Description |
|----------|------|----------|-------------|
| trigger | string | Yes | Character(s) that activate this provider |
| name | string | Yes | Human-readable name for ARIA labels |
| getRootItems | (query) => Promise<MentionItem[]> | Yes | Top-level suggestions |
| getChildren | (parent, query) => Promise<MentionItem[]> | No | Child suggestions for drill-down |
| searchAll | (query) => Promise<MentionItem[]> | No | Flat search across all levels |
| debounceMs | number | No | Delay before fetching (prevents API floods) |
| getRecentItems | () => Promise<MentionItem[]> | No | Recently used items, shown on empty query |
MentionItem
Items returned by providers:
type MentionItem = {
id: string;
type: string;
label: string;
icon?: ReactNode;
description?: string;
hasChildren?: boolean;
data?: unknown;
rootLabel?: string;
group?: string; // group items under section headers
};Set group on items to render them under section headers in the dropdown (e.g. "Active", "Pending", "Recent").
MentionsOutput
Structured output returned on every change and submit:
type MentionsOutput = {
markdown: string;
tokens: MentionToken[];
plainText: string;
};Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| value | string | — | Controlled markdown content (reactive — updates editor on change) |
| providers | MentionProvider[] | required | Suggestion providers, one per trigger |
| onChange | (output: MentionsOutput) => void | — | Called on every content change |
| onSubmit | (output: MentionsOutput) => void | — | Called on submit shortcut |
| placeholder | string | "Type a message..." | Placeholder text |
| autoFocus | boolean | false | Focus editor on mount |
| disabled | boolean | false | Disable editing |
| className | string | — | CSS class on the wrapper |
| maxLength | number | — | Max plain text character count |
| clearOnSubmit | boolean | true | Auto-clear after onSubmit |
| submitKey | "enter" \| "mod+enter" \| "none" | "enter" | Which key combo triggers submit |
| minHeight | number | — | Minimum editor height in px |
| maxHeight | number | — | Maximum editor height in px (enables scroll) |
| onFocus | () => void | — | Called when editor gains focus |
| onBlur | () => void | — | Called when editor loses focus |
| onMentionAdd | (token: MentionToken) => void | — | Called when a mention is inserted |
| onMentionRemove | (token: MentionToken) => void | — | Called when a mention is deleted |
| onMentionClick | (token: MentionToken, event: MouseEvent) => void | — | Called when a mention chip is clicked |
| onMentionHover | (token: MentionToken) => ReactNode | — | Return content for a hover tooltip |
| renderItem | (item, depth) => ReactNode | — | Custom suggestion item renderer |
| renderChip | (token) => ReactNode | — | Custom inline mention chip renderer |
| renderEmpty | (query: string) => ReactNode | — | Custom empty state (no results) |
| renderLoading | () => ReactNode | — | Custom loading indicator |
| renderGroupHeader | (group: string) => ReactNode | — | Custom section header renderer |
| allowTrigger | (trigger, { textBefore }) => boolean | — | Conditionally suppress the dropdown |
| validateMention | (token) => boolean \| Promise<boolean> | — | Validate mentions; invalid ones get data-mention-invalid |
| portalContainer | HTMLElement | — | Render dropdown into a custom DOM node |
| streaming | boolean | false | Signals the editor is receiving streamed content (suppresses triggers, blocks user input, throttles onChange) |
| onStreamingComplete | (output: MentionsOutput) => void | — | Fires once when streaming transitions from true to false with the final output |
Imperative ref API
MentionsInput supports forwardRef for programmatic control:
import { useRef } from "react";
import { MentionsInput, type MentionsInputHandle } from "@relevaince/mentions";
function Chat() {
const ref = useRef<MentionsInputHandle>(null);
return (
<>
<MentionsInput ref={ref} providers={providers} />
<button onClick={() => ref.current?.clear()}>Clear</button>
<button onClick={() => {
ref.current?.setContent("Summarize @[NDA](contract:c_1) risks");
ref.current?.focus();
}}>Use Prompt</button>
<button onClick={() => {
const output = ref.current?.getOutput();
console.log(output?.tokens);
}}>Read Output</button>
</>
);
}Handle methods
| Method | Signature | Description |
|--------|-----------|-------------|
| clear | () => void | Clears all editor content |
| setContent | (markdown: string) => void | Replaces content with a markdown string (mention tokens are parsed) |
| appendText | (text: string) => void | Appends plain text at the end (no mention parsing — use for plain-text streaming) |
| focus | () => void | Focuses the editor and places the cursor at the end |
| getOutput | () => MentionsOutput \| null | Reads the current structured output without waiting for onChange |
Streaming
Stream AI-generated text into the editor while maintaining mention state. Set streaming={true} to enter streaming mode: the suggestion dropdown is suppressed, user keyboard/paste input is blocked, and onChange is throttled (~150 ms). Call ref.current.setContent(accumulated) on each chunk — completed mention tokens are parsed into chips automatically.
const ref = useRef<MentionsInputHandle>(null);
const [isStreaming, setIsStreaming] = useState(false);
async function enhancePrompt() {
setIsStreaming(true);
let accumulated = "";
const stream = await fetchEnhancedPrompt(currentPrompt);
for await (const chunk of stream) {
accumulated += chunk;
ref.current?.setContent(accumulated);
}
setIsStreaming(false);
}
<MentionsInput
ref={ref}
streaming={isStreaming}
providers={providers}
onChange={handleChange}
onStreamingComplete={(output) => {
console.log("Final tokens:", output.tokens);
}}
/>How it works:
- Set
streaming={true}— the editor enters streaming mode - On each chunk, accumulate the full text and call
ref.current.setContent(accumulated) - Incomplete mention tokens (e.g.
@[NDA) render as plain text until the full@[label](id)syntax is received, then snap into mention chips - Set
streaming={false}— the editor exits streaming mode, fires a finalonChangeandonStreamingComplete
For plain-text-only streaming (no mention syntax in chunks), use ref.current.appendText(chunk) instead for better performance.
Keyboard shortcuts
| Key | Context | Action |
|-----|---------|--------|
| ↑ ↓ | Suggestions open | Navigate suggestions |
| Enter | Suggestions open | Select / drill into children |
| Tab | Suggestions open | Select the active suggestion |
| → | Suggestions open | Drill into children (if item has children) |
| ← | Nested level | Go back one level |
| Backspace | Nested level, empty search | Go back one level |
| Escape | Suggestions open | Close suggestions |
| Enter | Editor (default submitKey) | Submit message |
| Shift+Enter | Editor (default submitKey) | New line |
| Cmd/Ctrl+Enter | Editor | Submit message |
Markdown format
Mentions serialize to a compact token syntax:
@[Marketing Workspace](ws_123) summarize the latest files
@Marketing[Q4 Strategy.pdf](file:file_1) review this documentUse the standalone helpers for server-side processing:
import {
serializeToMarkdown,
parseFromMarkdown,
extractFromMarkdown,
} from "@relevaince/mentions";
const doc = parseFromMarkdown("Check @[NDA](contract:c_44) for risks");
const md = serializeToMarkdown(doc);
const { tokens, plainText } = extractFromMarkdown(
"Summarize @Marketing[Q4 Strategy.pdf](file:file_1) for the team"
);Styling
Zero bundled CSS. Every element exposes data-* attributes:
/* Mention chips */
[data-mention] { /* base chip */ }
[data-mention][data-type="workspace"] { /* workspace chip */ }
[data-mention][data-type="contract"] { /* contract chip */ }
[data-mention-clickable] { /* clickable chip (when onMentionClick set) */ }
[data-mention-invalid] { /* invalid/stale mention */ }
[data-mention-tooltip] { /* hover tooltip container */ }
/* Suggestion popover */
[data-suggestions] { /* popover wrapper */ }
[data-suggestions-position="above"] { /* when popover flips above */ }
[data-suggestions-position="below"] { /* when popover is below */ }
[data-suggestion-item] { /* each item */ }
[data-suggestion-item-active] { /* highlighted item */ }
[data-suggestion-group-header] { /* group section header */ }
[data-suggestion-empty] { /* "no results" state */ }
[data-suggestion-loading] { /* loading indicator */ }
[data-suggestion-breadcrumb] { /* breadcrumb bar (nested) */ }
[data-suggestion-search] { /* search input wrapper */ }
[data-suggestion-search-input] { /* search input field */ }Example with Tailwind:
[data-mention] {
@apply bg-blue-500/10 text-blue-600 rounded px-1 py-0.5 font-medium text-sm;
}
[data-suggestions] {
@apply bg-white border border-neutral-200 rounded-lg shadow-lg
min-w-[240px] max-h-[280px] overflow-y-auto py-1;
}
[data-suggestion-item] {
@apply flex items-center gap-2 px-3 py-1.5 cursor-pointer text-sm;
}
[data-suggestion-item-active] {
@apply bg-neutral-100;
}
[data-suggestion-group-header] {
@apply px-3 py-1 text-[10px] font-semibold text-neutral-400 uppercase tracking-wider;
}
[data-suggestion-empty] {
@apply px-3 py-2 text-xs text-neutral-400 italic;
}
[data-mention-invalid] {
@apply opacity-50 line-through;
}Architecture
<MentionsInput>
├── Tiptap Editor (ProseMirror)
│ ├── MentionNode Extension — inline atom nodes with id/label/type + click/hover
│ ├── Suggestion Plugin — multi-trigger detection + allowTrigger gating
│ ├── Enter/Submit Extension — configurable submit key (Enter/Mod+Enter/none)
│ ├── Mention Remove Detector — transaction-based onMentionRemove
│ └── Markdown Parser/Serializer — doc ↔ @[label](id) + extractFromMarkdown
└── SuggestionList (React) — headless popover with ARIA, groups, edge-aware positioningTesting
npm test # run all tests
npm run test:watch # watch modeDevelopment
npm install # install dependencies
npm run build # build the library
npm test # run tests
cd demo && npm install && npx vite # run the demo appLicense
MIT
