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,116 @@
import { createReactBlockSpec } from '@blocknote/react';
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { type ChangeEvent, useRef } from 'react';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { type AttachmentFileCategory } from '@/activities/files/types/AttachmentFileCategory';
import { getFileType } from '@/activities/files/utils/getFileType';
import { FileIcon } from '@/file/components/FileIcon';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { Button } from 'twenty-ui/input';
const StyledFileInput = styled.input`
display: none;
`;
const StyledFileLine = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledLink = styled.a`
align-items: center;
color: ${({ theme }) => theme.font.color.primary};
display: flex;
text-decoration: none;
:hover {
color: ${({ theme }) => theme.font.color.secondary};
}
`;
const StyledUploadFileContainer = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
`;
export const FileBlock = createReactBlockSpec(
{
type: 'file',
propSchema: {
url: {
default: '' as string,
},
name: {
default: '' as string,
},
fileCategory: {
default: 'OTHER' as AttachmentFileCategory,
},
},
content: 'none',
},
{
render: ({ block, editor }) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const inputFileRef = useRef<HTMLInputElement>(null);
const handleUploadAttachment = async (file: File) => {
if (isUndefinedOrNull(file)) {
return '';
}
const fileUrl = await editor.uploadFile?.(file);
if (!isNonEmptyString(fileUrl)) {
return '';
}
editor.updateBlock(block.id, {
props: {
...block.props,
url: fileUrl,
fileCategory: getFileType(file.name),
name: file.name,
},
});
};
const handleUploadFileClick = () => {
inputFileRef?.current?.click?.();
};
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
if (isDefined(e.target.files))
handleUploadAttachment?.(e.target.files[0]);
};
if (isNonEmptyString(block.props.url)) {
return (
<StyledFileLine>
<FileIcon
fileCategory={block.props.fileCategory as AttachmentFileCategory}
/>
<StyledLink href={block.props.url} target="__blank">
{block.props.name}
</StyledLink>
</StyledFileLine>
);
}
return (
<StyledUploadFileContainer>
<StyledFileInput
ref={inputFileRef}
onChange={handleFileChange}
type="file"
/>
<Button
onClick={handleUploadFileClick}
title={t`Upload File`}
></Button>
</StyledUploadFileContainer>
);
},
},
);
@@ -0,0 +1,133 @@
import { MentionRecordChip } from '@/mention/components/MentionRecordChip';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { RecordChip } from '@/object-record/components/RecordChip';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { createReactInlineContentSpec } from '@blocknote/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { Chip, ChipVariant } from 'twenty-ui/components';
const StyledRecordChip = styled(RecordChip)`
height: auto;
margin: 0;
padding: ${({ theme }) => `0 ${theme.spacing(1)}`};
`;
const StyledInlineMentionRecordChip = styled(MentionRecordChip)`
height: auto;
margin: 0;
padding: ${({ theme }) => `0 ${theme.spacing(1)}`};
`;
// Backward-compatible renderer for legacy notes that only stored objectMetadataId + recordId
const LegacyMentionRenderer = ({
recordId,
objectMetadataId,
}: {
recordId: string;
objectMetadataId: string;
}) => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const objectMetadataItem = objectMetadataItems.find(
(item) => item.id === objectMetadataId,
);
const objectNameSingular = objectMetadataItem?.nameSingular;
const { record, loading } = useFindOneRecord({
objectNameSingular: objectNameSingular ?? '',
objectRecordId: recordId,
skip: !isNonEmptyString(objectNameSingular) || !isNonEmptyString(recordId),
});
if (loading) {
return null;
}
if (!isDefined(objectMetadataItem)) {
return (
<Chip
label={t`Unknown object`}
variant={ChipVariant.Transparent}
disabled
/>
);
}
if (!isDefined(record)) {
return (
<Chip
label={t`Deleted record`}
variant={ChipVariant.Transparent}
disabled
/>
);
}
return (
<StyledRecordChip
objectNameSingular={objectMetadataItem.nameSingular}
record={record}
forceDisableClick={false}
/>
);
};
export const MentionInlineContent = createReactInlineContentSpec(
{
type: 'mention' as const,
propSchema: {
recordId: {
default: '' as const,
},
objectMetadataId: {
default: '' as const,
},
objectNameSingular: {
default: '' as const,
},
label: {
default: '' as const,
},
imageUrl: {
default: '' as const,
},
},
content: 'none',
},
{
render: (props) => {
const {
recordId,
objectMetadataId,
objectNameSingular,
label,
imageUrl,
} = props.inlineContent.props;
// New notes store objectNameSingular + label + imageUrl directly
if (isNonEmptyString(objectNameSingular) && isNonEmptyString(label)) {
return (
<StyledInlineMentionRecordChip
recordId={recordId}
objectNameSingular={objectNameSingular}
label={label}
imageUrl={imageUrl}
/>
);
}
// Legacy notes only have objectMetadataId + recordId: fetch data
return (
<LegacyMentionRenderer
recordId={recordId}
objectMetadataId={objectMetadataId}
/>
);
},
},
);
@@ -0,0 +1,19 @@
import {
BlockNoteSchema,
defaultBlockSpecs,
defaultInlineContentSpecs,
} from '@blocknote/core';
import { FileBlock } from '@/blocknote-editor/blocks/FileBlock';
import { MentionInlineContent } from '@/blocknote-editor/blocks/MentionInlineContent';
export const BLOCK_SCHEMA = BlockNoteSchema.create({
blockSpecs: {
...defaultBlockSpecs,
file: FileBlock,
},
inlineContentSpecs: {
...defaultInlineContentSpecs,
mention: MentionInlineContent,
},
});