Add @mention support in AI Chat input (#17943)

## Summary
- Add `@mention` support to the AI Chat text input by replacing the
plain textarea with a minimal Tiptap editor and building a shared
`mention` module with reusable Tiptap extensions (`MentionTag`,
`MentionSuggestion`), search hook (`useMentionSearch`), and suggestion
menu — all shared with the existing BlockNote-based Notes mentions to
avoid code duplication
- Mentions are serialized as
`[[record:objectName:recordId:displayName]]` markdown (the format
already understood by the backend and rendered in chat messages), and
displayed using the existing `RecordLink` chip component for visual
consistency
- Fix images in chat messages overflowing their container by
constraining to `max-width: 100%`
- Fix web_search tool display showing literal `{query}` instead of the
actual query (ICU single-quote escaping issue in Lingui `t` tagged
templates)

## Test plan
- [ ] Open AI Chat, type `@` and verify the suggestion menu appears with
searchable records
- [ ] Select a mention from the dropdown (via click or keyboard
Enter/ArrowUp/Down) and verify the record chip renders inline
- [ ] Send a message containing a mention and verify it appears
correctly in the conversation as a clickable `RecordLink`
- [ ] Verify Enter sends the message when the suggestion menu is closed,
and selects a mention when the menu is open
- [ ] Verify images in AI chat responses are constrained to the
container width
- [ ] Verify the web_search tool step shows the actual search query
(e.g. "Searched the web for Salesforce") instead of `{query}`
- [ ] Verify Notes @mentions still work as before


Made with [Cursor](https://cursor.com)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Félix Malfait
2026-02-14 14:37:33 +01:00
committed by GitHub
parent 0876197c8d
commit 6f251a6f8e
72 changed files with 1920 additions and 1043 deletions
@@ -7,7 +7,11 @@ import {
DEFAULT_SLASH_COMMANDS,
type SlashCommandConfig,
} from '@/advanced-text-editor/extensions/slash-command/DefaultSlashCommands';
import { SlashCommandRenderer } from '@/advanced-text-editor/extensions/slash-command/SlashCommandRenderer';
import {
SlashCommandMenu,
type SlashCommandMenuProps,
} from '@/advanced-text-editor/extensions/slash-command/SlashCommandMenu';
import { createSuggestionRenderLifecycle } from '@/ui/suggestion/components/createSuggestionRenderLifecycle';
export type SlashCommandItem = {
id: string;
@@ -20,14 +24,6 @@ export type SlashCommandItem = {
command: (options: { editor: Editor; range: Range }) => void;
};
type SuggestionRenderProps = {
items: SlashCommandItem[];
command: (item: SlashCommandItem) => void;
clientRect?: (() => DOMRect | null) | null;
range: Range;
query: string;
};
const createSlashCommandItem = (
config: SlashCommandConfig,
editor: Editor,
@@ -95,73 +91,23 @@ export const SlashCommand = Extension.create<SlashCommandOptions>({
editor: this.editor,
...this.options.suggestions,
items: ({ query, editor: ed }) => buildItems(ed, query),
render: () => {
let component: SlashCommandRenderer | null = null;
const closeMenu = () => {
if (component !== null) {
component.destroy();
component = null;
}
};
return {
onStart: (props: SuggestionRenderProps) => {
if (!props.clientRect) {
return;
}
component = new SlashCommandRenderer({
items: props.items,
command: (item: SlashCommandItem) => {
props.command(item);
closeMenu();
},
clientRect: props.clientRect,
editor: this.editor,
range: props.range,
query: props.query,
});
render: () =>
createSuggestionRenderLifecycle<
SlashCommandItem,
SlashCommandMenuProps
>(
{
component: SlashCommandMenu,
getMenuProps: ({ items, onSelect, editor, range, query }) => ({
items,
onSelect,
editor,
range,
query,
}),
},
onUpdate: (props: SuggestionRenderProps) => {
if (component === null) {
return;
}
if (!props.clientRect) {
return;
}
if (props.items.length === 0) {
closeMenu();
return;
}
component.updateProps({
items: props.items,
command: (item: SlashCommandItem) => {
props.command(item);
closeMenu();
},
clientRect: props.clientRect,
editor: this.editor,
range: props.range,
query: props.query,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === 'Escape') {
closeMenu();
return true;
}
return component?.ref?.onKeyDown?.(props) ?? false;
},
onExit: () => {
closeMenu();
},
};
},
this.editor,
),
}),
];
},
@@ -1,220 +1,77 @@
import { ThemeProvider } from '@emotion/react';
import {
autoUpdate,
flip,
offset,
shift,
useFloating,
} from '@floating-ui/react';
import { type Editor, type Range } from '@tiptap/core';
import { motion } from 'framer-motion';
import {
forwardRef,
useCallback,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { forwardRef, useCallback, useState } from 'react';
import { MenuItemSuggestion } from 'twenty-ui/navigation';
import { THEME_DARK, THEME_LIGHT } from 'twenty-ui/theme';
import { type SlashCommandItem } from '@/advanced-text-editor/extensions/slash-command/SlashCommand';
import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer';
import { SuggestionMenu } from '@/ui/suggestion/components/SuggestionMenu';
export type SlashCommandMenuProps = {
items: SlashCommandItem[];
onSelect: (item: SlashCommandItem) => void;
clientRect: (() => DOMRect | null) | null;
editor: Editor;
range: Range;
query: string;
};
const getItemKey = (item: SlashCommandItem) => item.id;
export const SlashCommandMenu = forwardRef<unknown, SlashCommandMenuProps>(
(props, parentRef) => {
(props, ref) => {
const { items, onSelect, editor, range, query } = props;
const colorScheme = document.documentElement.className.includes('dark')
? 'Dark'
: 'Light';
const theme = colorScheme === 'Dark' ? THEME_DARK : THEME_LIGHT;
const [selectedIndex, setSelectedIndex] = useState(0);
const [prevSelectedIndex, setPrevSelectedIndex] = useState(0);
const [prevQuery, setPrevQuery] = useState('');
const activeCommandRef = useRef<HTMLDivElement>(null);
const commandListContainerRef = useRef<HTMLDivElement>(null);
const positionReference = useMemo(
() => ({
getBoundingClientRect: () => {
const start = editor.view.coordsAtPos(range.from);
return new DOMRect(
start.left,
start.top,
0,
start.bottom - start.top,
);
},
}),
[editor, range],
);
const { refs, floatingStyles } = useFloating({
placement: 'bottom-start',
strategy: 'fixed',
middleware: [offset(4), flip(), shift()],
whileElementsMounted: (reference, floating, update) => {
return autoUpdate(reference, floating, update, {
animationFrame: true,
});
},
elements: {
reference: positionReference,
},
});
const selectItem = useCallback(
(index: number) => {
const item = items[index];
if (!item) {
return;
const handleKeyDown = useCallback(
(event: KeyboardEvent, selectedIndex: number) => {
if (event.key === 'ArrowLeft') {
event.preventDefault();
editor.chain().focus().insertContentAt(range, `/${prevQuery}`).run();
setTimeout(() => {
setPrevSelectedIndex(prevSelectedIndex);
}, 0);
return true;
}
onSelect(item);
if (event.key === 'ArrowRight') {
return true;
}
if (event.key === 'Enter' && items.length > 0) {
setPrevQuery(query);
setPrevSelectedIndex(selectedIndex);
}
return undefined;
},
[onSelect, items],
[editor, range, prevQuery, prevSelectedIndex, items.length, query],
);
useImperativeHandle(parentRef, () => ({
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
const navigationKeys = [
'ArrowUp',
'ArrowDown',
'Enter',
'ArrowLeft',
'ArrowRight',
];
if (navigationKeys.includes(event.key)) {
let newCommandIndex = selectedIndex;
switch (event.key) {
case 'ArrowLeft':
event.preventDefault();
editor
.chain()
.focus()
.insertContentAt(range, `/${prevQuery}`)
.run();
setTimeout(() => {
setSelectedIndex(prevSelectedIndex);
}, 0);
return true;
case 'ArrowUp':
if (!items.length) {
return false;
}
newCommandIndex = selectedIndex - 1;
if (newCommandIndex < 0) {
newCommandIndex = items.length - 1;
}
setSelectedIndex(newCommandIndex);
return true;
case 'ArrowDown':
if (!items.length) {
return false;
}
newCommandIndex = selectedIndex + 1;
if (newCommandIndex >= items.length) {
newCommandIndex = 0;
}
setSelectedIndex(newCommandIndex);
return true;
case 'Enter':
if (!items.length) {
return false;
}
selectItem(selectedIndex);
setPrevQuery(query);
setPrevSelectedIndex(selectedIndex);
return true;
default:
return false;
}
}
},
}));
useLayoutEffect(() => {
const container = commandListContainerRef?.current;
const activeCommandContainer = activeCommandRef?.current;
if (!container || !activeCommandContainer) {
return;
}
const scrollableContainer =
container.firstElementChild as HTMLElement | null;
if (!scrollableContainer) {
return;
}
const { offsetTop, offsetHeight } = activeCommandContainer;
scrollableContainer.style.transition = 'none';
scrollableContainer.scrollTop = offsetTop - offsetHeight;
}, [selectedIndex]);
const renderItem = useCallback(
(item: SlashCommandItem, isSelected: boolean) => (
<MenuItemSuggestion
LeftIcon={item.icon}
text={item.title}
selected={isSelected}
onClick={() => {
onSelect(item);
}}
/>
),
[onSelect],
);
return (
<ThemeProvider theme={theme}>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.1 }}
data-slash-command-menu
>
<OverlayContainer
ref={refs.setFloating}
style={{
...floatingStyles,
zIndex: RootStackingContextZIndices.DropdownPortalAboveModal,
}}
>
<DropdownContent ref={commandListContainerRef}>
<DropdownMenuItemsContainer hasMaxHeight>
{items.map((item, index) => {
const isSelected = index === selectedIndex;
return (
<div
key={item.id}
ref={isSelected ? activeCommandRef : null}
onMouseDown={(e) => {
e.preventDefault();
}}
>
<MenuItemSuggestion
LeftIcon={item.icon}
text={item.title}
selected={isSelected}
onClick={() => {
onSelect(item);
}}
/>
</div>
);
})}
</DropdownMenuItemsContainer>
</DropdownContent>
</OverlayContainer>
</motion.div>
</ThemeProvider>
<SuggestionMenu
ref={ref}
items={items}
onSelect={onSelect}
editor={editor}
range={range}
getItemKey={getItemKey}
renderItem={renderItem}
onKeyDown={handleKeyDown}
/>
);
},
);
@@ -1,88 +0,0 @@
import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { SlashCommandItem } from '@/advanced-text-editor/extensions/slash-command/SlashCommand';
import {
SlashCommandMenu,
type SlashCommandMenuProps,
} from '@/advanced-text-editor/extensions/slash-command/SlashCommandMenu';
import type { Editor, Range } from '@tiptap/core';
type SlashCommandRendererProps = {
items: SlashCommandItem[];
command: (item: SlashCommandItem) => void;
clientRect: (() => DOMRect | null) | null;
editor: Editor;
range: Range;
query: string;
};
export class SlashCommandRenderer {
componentRoot: Root | null = null;
containerElement: HTMLElement | null = null;
currentProps: SlashCommandRendererProps | null = null;
ref: { onKeyDown?: (props: { event: KeyboardEvent }) => boolean } | null =
null;
constructor(props: SlashCommandRendererProps) {
this.containerElement = document.createElement('div');
document.body.appendChild(this.containerElement);
this.componentRoot = createRoot(this.containerElement);
this.currentProps = props;
this.render(props);
}
render(props: SlashCommandRendererProps): void {
if (!this.componentRoot) {
return;
}
const menuProps: SlashCommandMenuProps = {
items: props.items,
onSelect: props.command,
clientRect: props.clientRect,
editor: props.editor,
range: props.range,
query: props.query,
};
this.componentRoot.render(
createElement(SlashCommandMenu, {
...menuProps,
ref: (
ref: {
onKeyDown?: (props: { event: KeyboardEvent }) => boolean;
} | null,
) => {
this.ref = ref;
},
}),
);
}
updateProps(props: Partial<SlashCommandRendererProps>): void {
if (!this.componentRoot || !this.currentProps) {
return;
}
const updatedProps = { ...this.currentProps, ...props };
this.currentProps = updatedProps;
this.render(updatedProps);
}
destroy(): void {
if (this.componentRoot !== null) {
this.componentRoot.unmount();
this.componentRoot = null;
}
if (this.containerElement !== null) {
this.containerElement.remove();
this.containerElement = null;
}
this.currentProps = null;
this.ref = null;
}
}
@@ -1,327 +0,0 @@
import { Editor } from '@tiptap/core';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { type SlashCommandItem } from '@/advanced-text-editor/extensions/slash-command/SlashCommand';
import { SlashCommandRenderer } from '@/advanced-text-editor/extensions/slash-command/SlashCommandRenderer';
describe('SlashCommandRenderer', () => {
let editor: Editor;
let mockCommand: jest.Mock;
let mockClientRect: () => DOMRect;
const createMockItems = (): SlashCommandItem[] => [
{
id: 'test-1',
title: 'Test Command 1',
description: 'Test description',
command: jest.fn(),
},
{
id: 'test-2',
title: 'Test Command 2',
command: jest.fn(),
},
];
beforeEach(() => {
editor = new Editor({
extensions: [Document, Paragraph, Text],
content: '<p></p>',
});
mockCommand = jest.fn();
mockClientRect = () => new DOMRect(100, 100, 200, 50);
});
afterEach(() => {
editor?.destroy();
});
describe('Lifecycle management', () => {
it('should create container element and append to body', () => {
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
expect(renderer.containerElement).toBeInstanceOf(HTMLElement);
expect(document.body.contains(renderer.containerElement)).toBe(true);
renderer.destroy();
});
it('should create React root on initialization', () => {
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
expect(renderer.componentRoot).not.toBeNull();
renderer.destroy();
});
it('should clean up on destroy', () => {
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
const containerElement = renderer.containerElement;
renderer.destroy();
// After destroy, references should be null
expect(renderer.componentRoot).toBeNull();
expect(renderer.containerElement).toBeNull();
expect(renderer.currentProps).toBeNull();
expect(renderer.ref).toBeNull();
// Container should be removed from DOM
expect(document.body.contains(containerElement)).toBe(false);
});
it('should handle multiple destroy calls gracefully', () => {
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
// First destroy
renderer.destroy();
// Second destroy should not throw
expect(() => renderer.destroy()).not.toThrow();
});
});
describe('Props updates', () => {
it('should update items when updateProps is called', () => {
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
const newItems: SlashCommandItem[] = [
{
id: 'new-item',
title: 'New Item',
command: jest.fn(),
},
];
renderer.updateProps({ items: newItems });
expect(renderer.currentProps?.items).toEqual(newItems);
renderer.destroy();
});
it('should update query when updateProps is called', () => {
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
renderer.updateProps({ query: 'heading' });
expect(renderer.currentProps?.query).toBe('heading');
renderer.destroy();
});
it('should not update props if component is destroyed', () => {
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
renderer.destroy();
// After destroy, updateProps should not throw
expect(() => renderer.updateProps({ query: 'test' })).not.toThrow();
});
it('should preserve unmodified props when updating', () => {
const originalItems = createMockItems();
const renderer = new SlashCommandRenderer({
items: originalItems,
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: 'original',
});
// Update only query
renderer.updateProps({ query: 'updated' });
// Items should remain the same
expect(renderer.currentProps?.items).toEqual(originalItems);
// Query should be updated
expect(renderer.currentProps?.query).toBe('updated');
renderer.destroy();
});
});
describe('Render method', () => {
it('should not throw when render is called', () => {
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
expect(() =>
renderer.render({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
}),
).not.toThrow();
renderer.destroy();
});
it('should not render if component root is null', () => {
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
// Destroy to set componentRoot to null
renderer.destroy();
// Render should not throw even with null componentRoot
expect(() =>
renderer.render({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
}),
).not.toThrow();
});
});
describe('Ref handling', () => {
it('should initialize ref as null', () => {
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
expect(renderer.ref).toBeNull();
renderer.destroy();
});
it('should clear ref on destroy', () => {
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
// Simulate ref being set
renderer.ref = { onKeyDown: jest.fn() };
renderer.destroy();
expect(renderer.ref).toBeNull();
});
});
describe('Memory management', () => {
it('should not leak DOM elements after destroy', () => {
const initialBodyChildCount = document.body.childElementCount;
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
// Should have added one element
expect(document.body.childElementCount).toBe(initialBodyChildCount + 1);
renderer.destroy();
// Should be back to initial count
expect(document.body.childElementCount).toBe(initialBodyChildCount);
});
it('should handle rapid create/destroy cycles', () => {
const initialBodyChildCount = document.body.childElementCount;
// Create and destroy multiple renderers rapidly
for (let i = 0; i < 10; i++) {
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
renderer.destroy();
}
// Should be back to initial count
expect(document.body.childElementCount).toBe(initialBodyChildCount);
});
});
});