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
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { z } from 'zod';
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
import { formatValidationErrors } from 'src/engine/core-modules/tool-provider/utils/format-validation-errors.util';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { ViewType } from 'src/engine/metadata-modules/view/enums/view-type.enum';
@@ -56,6 +57,22 @@ const CreateViewInputSchema = z.object({
.optional()
.default(ViewVisibility.WORKSPACE)
.describe('View visibility'),
mainGroupByFieldName: z
.string()
.optional()
.describe(
'Field name to group by (required for KANBAN views, must be a SELECT field, e.g., "stage", "status")',
),
kanbanAggregateOperation: z
.enum(Object.values(AggregateOperations) as [string, ...string[]])
.optional()
.describe(
'Aggregate operation for kanban columns (e.g., "SUM", "AVG", "COUNT")',
),
kanbanAggregateOperationFieldName: z
.string()
.optional()
.describe('Field name for the kanban aggregate operation (e.g., "amount")'),
});
const UpdateViewInputSchema = z.object({
@@ -101,6 +118,36 @@ export class ViewToolsFactory {
return objectMetadata.id;
}
private async resolveFieldMetadataId(
workspaceId: string,
objectMetadataId: string,
fieldName: string,
): Promise<string> {
const { flatFieldMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatFieldMetadataMaps'],
},
);
const fieldMetadata = Object.values(
flatFieldMetadataMaps.byUniversalIdentifier,
).find(
(field) =>
field?.name === fieldName &&
field?.objectMetadataId === objectMetadataId,
);
if (!fieldMetadata) {
throw new Error(
`Field "${fieldName}" not found on this object. Use get_field_metadata to list available fields.`,
);
}
return fieldMetadata.id;
}
generateReadTools(
workspaceId: string,
userWorkspaceId?: string,
@@ -167,7 +214,7 @@ export class ViewToolsFactory {
return {
create_view: {
description:
'Create a new view for an object. Views define how records are displayed.',
'Create a new view for an object. Views define how records are displayed. For KANBAN views, mainGroupByFieldName is required and must be a SELECT field (e.g., "stage", "status").',
inputSchema: CreateViewInputSchema,
execute: async (parameters: {
name: string;
@@ -175,6 +222,9 @@ export class ViewToolsFactory {
icon?: string;
type?: ViewType;
visibility?: ViewVisibility;
mainGroupByFieldName?: string;
kanbanAggregateOperation?: string;
kanbanAggregateOperationFieldName?: string;
}) => {
try {
const objectMetadataId = await this.resolveObjectMetadataId(
@@ -182,6 +232,26 @@ export class ViewToolsFactory {
parameters.objectNameSingular,
);
let mainGroupByFieldMetadataId: string | undefined;
let kanbanAggregateOperationFieldMetadataId: string | undefined;
if (parameters.mainGroupByFieldName) {
mainGroupByFieldMetadataId = await this.resolveFieldMetadataId(
workspaceId,
objectMetadataId,
parameters.mainGroupByFieldName,
);
}
if (parameters.kanbanAggregateOperationFieldName) {
kanbanAggregateOperationFieldMetadataId =
await this.resolveFieldMetadataId(
workspaceId,
objectMetadataId,
parameters.kanbanAggregateOperationFieldName,
);
}
const view = await this.viewService.createOne({
createViewInput: {
name: parameters.name,
@@ -189,6 +259,10 @@ export class ViewToolsFactory {
icon: parameters.icon ?? 'IconList',
type: parameters.type ?? ViewType.TABLE,
visibility: parameters.visibility ?? ViewVisibility.WORKSPACE,
mainGroupByFieldMetadataId,
kanbanAggregateOperation:
parameters.kanbanAggregateOperation as AggregateOperations,
kanbanAggregateOperationFieldMetadataId,
},
workspaceId,
createdByUserWorkspaceId: userWorkspaceId,