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
@@ -0,0 +1,77 @@
import type { PartialBlock } from '@blocknote/core';
import { getFirstNonEmptyLineOfRichText } from '@/blocknote-editor/utils/getFirstNonEmptyLineOfRichText';
describe('getFirstNonEmptyLineOfRichText', () => {
it('should return an empty string if the input is null', () => {
const result = getFirstNonEmptyLineOfRichText(null);
expect(result).toBe('');
});
it('should return an empty string if the input is an empty array', () => {
const result = getFirstNonEmptyLineOfRichText([]);
expect(result).toBe('');
});
it('should return the first non-empty line of text', () => {
const input: PartialBlock[] = [
{ content: [{ text: '', type: 'text', styles: {} }] },
{ content: [{ text: ' ', type: 'text', styles: {} }] },
{ content: [{ text: 'First non-empty line', type: 'text', styles: {} }] },
{ content: [{ text: 'Second line', type: 'text', styles: {} }] },
];
const result = getFirstNonEmptyLineOfRichText(input);
expect(result).toBe('First non-empty line');
});
it('should return an empty string if all lines are empty', () => {
const input: PartialBlock[] = [
{ content: [{ text: '', type: 'text', styles: {} }] },
{ content: [{ text: ' ', type: 'text', styles: {} }] },
{ content: [{ text: '\n', type: 'text', styles: {} }] },
];
const result = getFirstNonEmptyLineOfRichText(input);
expect(result).toBe('');
});
it('should handle mixed content correctly', () => {
const input: PartialBlock[] = [
{ content: [{ text: '', type: 'text', styles: {} }] },
{ content: [{ text: ' ', type: 'text', styles: {} }] },
{ content: [{ text: 'First non-empty line', type: 'text', styles: {} }] },
{ content: [{ text: '', type: 'text', styles: {} }] },
{
content: [{ text: 'Second non-empty line', type: 'text', styles: {} }],
},
];
const result = getFirstNonEmptyLineOfRichText(input);
expect(result).toBe('First non-empty line');
});
it('should handle content with multiple text objects correctly', () => {
const input: PartialBlock[] = [
{
content: [
{ text: '', type: 'text', styles: {} },
{ text: ' ', type: 'text', styles: {} },
],
},
{
content: [
{ text: 'First non-empty line', type: 'text', styles: {} },
{ text: 'Second line', type: 'text', styles: {} },
],
},
];
const result = getFirstNonEmptyLineOfRichText(input);
expect(result).toBe('First non-empty line');
});
it('should handle content with undefined or null content', () => {
const input: PartialBlock[] = [
{ content: undefined },
{ content: [{ text: 'First non-empty line', type: 'text', styles: {} }] },
];
const result = getFirstNonEmptyLineOfRichText(input);
expect(result).toBe('First non-empty line');
});
});
@@ -0,0 +1,47 @@
import { parseInitialBlocknote } from '@/blocknote-editor/utils/parseInitialBlocknote';
describe('parseInitialBlocknote', () => {
it('should parse valid JSON array string', () => {
const input = JSON.stringify([{ type: 'paragraph', content: 'test' }]);
const result = parseInitialBlocknote(input);
expect(result).toEqual([{ type: 'paragraph', content: 'test' }]);
});
it('should return undefined for empty string', () => {
expect(parseInitialBlocknote('')).toBeUndefined();
});
it('should return undefined for null', () => {
expect(parseInitialBlocknote(null)).toBeUndefined();
});
it('should return undefined for undefined', () => {
expect(parseInitialBlocknote(undefined)).toBeUndefined();
});
it('should return undefined for empty object string "{}"', () => {
expect(parseInitialBlocknote('{}')).toBeUndefined();
});
it('should return undefined for invalid JSON', () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
expect(parseInitialBlocknote('invalid json')).toBeUndefined();
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
it('should return undefined for empty array', () => {
expect(parseInitialBlocknote('[]')).toBeUndefined();
});
it('should return undefined for non-array JSON', () => {
expect(parseInitialBlocknote('{"key": "value"}')).toBeUndefined();
});
it('should use custom log context when parsing fails', () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
parseInitialBlocknote('invalid', 'Custom context');
expect(consoleSpy).toHaveBeenCalledWith('Custom context');
consoleSpy.mockRestore();
});
});
@@ -0,0 +1,46 @@
import { prepareBodyWithSignedUrls } from '@/blocknote-editor/utils/prepareBodyWithSignedUrls';
describe('prepareBodyWithSignedUrls', () => {
it('should return empty string as-is', () => {
expect(prepareBodyWithSignedUrls('')).toBe('');
});
it('should parse and re-stringify blocks', () => {
const input = JSON.stringify([{ type: 'paragraph', content: 'text' }]);
const result = JSON.parse(prepareBodyWithSignedUrls(input));
expect(result).toEqual([{ type: 'paragraph', content: 'text' }]);
});
it('should pass through non-image blocks unchanged', () => {
const blocks = [
{ type: 'paragraph', content: 'text' },
{ type: 'heading', content: 'title' },
{ type: 'bulletListItem', content: 'item' },
];
const result = JSON.parse(
prepareBodyWithSignedUrls(JSON.stringify(blocks)),
);
expect(result).toEqual(blocks);
});
it('should skip image blocks without props', () => {
const input = JSON.stringify([{ type: 'image' }]);
const result = JSON.parse(prepareBodyWithSignedUrls(input));
expect(result).toEqual([{ type: 'image' }]);
});
it('should skip image blocks without url in props', () => {
const input = JSON.stringify([{ type: 'image', props: { alt: 'test' } }]);
const result = JSON.parse(prepareBodyWithSignedUrls(input));
expect(result).toEqual([{ type: 'image', props: { alt: 'test' } }]);
});
it('should process image blocks with valid URLs', () => {
const input = JSON.stringify([
{ type: 'image', props: { url: 'https://example.com/image.png' } },
]);
const result = JSON.parse(prepareBodyWithSignedUrls(input));
expect(result[0].type).toBe('image');
expect(result[0].props.url).toContain('example.com');
});
});
@@ -0,0 +1,32 @@
import type { PartialBlock } from '@blocknote/core';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
export const getFirstNonEmptyLineOfRichText = (
blocks: PartialBlock[] | null,
): string => {
if (blocks === null) {
return '';
}
for (const block of blocks) {
if (!isUndefinedOrNull(block.content)) {
const contentArray = block.content as Array<
{ text: string } | { link: string }
>;
if (contentArray.length > 0) {
for (const content of contentArray) {
if ('link' in content) {
return content.link;
}
if ('text' in content) {
const value = content.text.trim();
if (value !== '') {
return value;
}
}
}
}
}
}
return '';
};
@@ -0,0 +1,71 @@
import { getDefaultReactSlashMenuItems } from '@blocknote/react';
import { type SuggestionItem } from '@/blocknote-editor/components/CustomSlashMenu';
import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
import {
IconBlockquote,
IconCode,
type IconComponent,
IconFile,
IconH1,
IconH2,
IconH3,
IconHeadphones,
IconList,
IconListCheck,
IconListNumbers,
IconMoodSmile,
IconPhoto,
IconPilcrow,
IconTable,
IconVideo,
} from 'twenty-ui/display';
const Icons: Record<string, IconComponent> = {
'Heading 1': IconH1,
'Heading 2': IconH2,
'Heading 3': IconH3,
Quote: IconBlockquote,
'Numbered List': IconListNumbers,
'Bullet List': IconList,
'Check List': IconListCheck,
'Code Block': IconCode,
Paragraph: IconPilcrow,
Table: IconTable,
Image: IconPhoto,
Video: IconVideo,
Audio: IconHeadphones,
Emoji: IconMoodSmile,
};
export const getSlashMenu = (editor: typeof BLOCK_SCHEMA.BlockNoteEditor) => {
const items: SuggestionItem[] = [
...getDefaultReactSlashMenuItems(editor).map((x) => ({
...x,
Icon: Icons[x.title],
})),
{
title: 'File',
aliases: ['file', 'folder'],
Icon: IconFile,
onItemClick: () => {
const currentBlock = editor.getTextCursorPosition().block;
editor.insertBlocks(
[
{
type: 'file',
props: {
url: undefined,
},
},
],
currentBlock,
'before',
);
},
},
];
return items;
};
@@ -0,0 +1,29 @@
import type { PartialBlock } from '@blocknote/core';
import { isArray, isNonEmptyString } from '@sniptt/guards';
export const parseInitialBlocknote = (
blocknote?: string | null,
logContext?: string,
): PartialBlock[] | undefined => {
if (isNonEmptyString(blocknote) && blocknote !== '{}') {
let parsedBody: PartialBlock[] | undefined = undefined;
// TODO: Remove this once we have removed the old rich text
try {
parsedBody = JSON.parse(blocknote);
} catch {
// eslint-disable-next-line no-console
console.warn(logContext ?? `Failed to parse blocknote body`);
// eslint-disable-next-line no-console
console.warn(blocknote);
}
if (!isArray(parsedBody) || parsedBody.length === 0) {
return undefined;
}
return parsedBody;
}
return undefined;
};
@@ -0,0 +1,30 @@
import type { PartialBlock } from '@blocknote/core';
// TODO: This function is extracted but its not doing what it is supposed to do. It is not signing the urls. It is just parsing the image urls.
// tracking issue - https://github.com/twentyhq/twenty/issues/8351
export const prepareBodyWithSignedUrls = (
newStringifiedBody: string,
): string => {
if (!newStringifiedBody) return newStringifiedBody;
const body: PartialBlock[] = JSON.parse(newStringifiedBody);
const bodyWithSignedPayload = body.map((block) => {
if (block.type !== 'image' || !block.props?.url) {
return block;
}
const imageUrl = block.props.url;
const parsedImageUrl = new URL(imageUrl);
return {
...block,
props: {
...block.props,
url: parsedImageUrl.toString(),
},
};
});
return JSON.stringify(bodyWithSignedPayload);
};