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:
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user