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:
+77
@@ -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');
|
||||
});
|
||||
});
|
||||
+47
@@ -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();
|
||||
});
|
||||
});
|
||||
+46
@@ -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');
|
||||
});
|
||||
});
|
||||
+32
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user