npm package: @relevaince/mentions
Description: Structured mention engine for Relevaince OMNI chat input
Author: Relevaince
Latest version: 0.6.2

# @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** — `searchAll` finds 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 `debounceMs` prevents API floods
- **Structured output** — returns `{ markdown, tokens, plainText }` on every change
- **`useMentionsContent`** — hook that pairs `onChange` with reactive `output` and `hasContent` (e.g. disable submit until there is text)
- **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` / `onMentionRemove` callbacks
- **Mention interaction** — `onMentionClick` / `onMentionHover` handlers on chips
- **Mention validation** — mark stale/invalid mentions with `validateMention`
- **Controlled value** — reactive `value` prop for external content updates
- **Auto-resize** — `minHeight` / `maxHeight` for 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** — `allowTrigger` to 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
- **Long mention labels** — `maxMentionLabelLength` truncates chip display with an ellipsis (markdown / tokens stay full)

## Install

```bash
npm install @relevaince/mentions
```

Peer dependencies:

```bash
npm install react react-dom
```

## Quick start

```tsx
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:

```ts
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:

```ts
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:

```ts
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:

```ts
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 (not yet enforced by the editor) |
| `maxMentionLabelLength` | `number` | — | Truncate each chip’s label with `...`; markdown / tokens / plainText stay full |
| `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:

```tsx
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 |

## Hooks

### `useMentionsContent`

Keeps the latest `MentionsOutput` in React state so UI can react to editor content. `ref.current.getOutput()` does not trigger re-renders; this hook wires `onChange` for you and exposes `hasContent` from trimmed `plainText`.

```ts
function useMentionsContent(): {
  output: MentionsOutput | null;
  onChange: (output: MentionsOutput) => void;
  hasContent: boolean;
};
```

**Submit button disabled when empty:**

```tsx
import { useRef } from "react";
import {
  MentionsInput,
  useMentionsContent,
  type MentionsInputHandle,
} from "@relevaince/mentions";

function Composer() {
  const textareaRef = useRef<MentionsInputHandle>(null);
  const { output, onChange, hasContent } = useMentionsContent();

  function onSubmit() {
    if (!output) return;
    sendMessage(output);
  }

  return (
    <>
      <MentionsInput
        ref={textareaRef}
        providers={providers}
        onChange={onChange}
        onSubmit={onSubmit}
      />
      <button type="button" disabled={!hasContent} onClick={onSubmit}>
        Send
      </button>
    </>
  );
}
```

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

```tsx
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:**

1. Set `streaming={true}` — the editor enters streaming mode
2. On each chunk, accumulate the full text and call `ref.current.setContent(accumulated)`
3. Incomplete mention tokens (e.g. `@[NDA`) render as plain text until the full `@[label](id)` syntax is received, then snap into mention chips
4. Set `streaming={false}` — the editor exits streaming mode, fires a final `onChange` and `onStreamingComplete`

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 document
```

Use the standalone helpers for server-side processing:

```ts
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"
);
```

`MentionChip` is exported for presentational chips (e.g. token lists). Pass `maxLabelLength` for the same truncation behavior as `maxMentionLabelLength` on `MentionsInput`.

## Styling

Zero bundled CSS. Every element exposes `data-*` attributes:

```css
/* 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:

```css
[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 positioning
```

## Testing

```bash
npm test           # run all tests
npm run test:watch # watch mode
```

## Development

```bash
npm install        # install dependencies
npm run build      # build the library
npm test           # run tests

cd demo && npm install && npx vite  # run the demo app
```

## License

MIT