@@ -414,7 +414,7 @@ export const AiChatQuestionCard = ({
color={themeCssVariables.font.color.tertiary}
/>
-
+
{option.isRecommended === true && (
ยท {t`Recommended`}
diff --git a/packages/twenty-front/src/modules/ai/components/ChatReferenceChip.tsx b/packages/twenty-front/src/modules/ai/components/ChatReferenceChip.tsx
new file mode 100644
index 0000000000..81573032b1
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/components/ChatReferenceChip.tsx
@@ -0,0 +1,46 @@
+import { FieldMetadataLink } from '@/ai/components/FieldMetadataLink';
+import { ObjectMetadataLink } from '@/ai/components/ObjectMetadataLink';
+import { RecordLink } from '@/ai/components/RecordLink';
+import { ViewLink } from '@/ai/components/ViewLink';
+import { type ChatReferenceMatch } from '@/ai/types/ChatReferenceMatch';
+import { assertUnreachable } from 'twenty-shared/utils';
+
+type ChatReferenceChipProps = {
+ reference: ChatReferenceMatch;
+};
+
+export const ChatReferenceChip = ({ reference }: ChatReferenceChipProps) => {
+ switch (reference.kind) {
+ case 'record':
+ return (
+
+ );
+ case 'object':
+ return (
+
+ );
+ case 'field':
+ return (
+
+ );
+ case 'view':
+ return (
+
+ );
+ default:
+ return assertUnreachable(reference);
+ }
+};
diff --git a/packages/twenty-front/src/modules/ai/components/ChatReferenceChipDisplay.tsx b/packages/twenty-front/src/modules/ai/components/ChatReferenceChipDisplay.tsx
new file mode 100644
index 0000000000..9d8a25046c
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/components/ChatReferenceChipDisplay.tsx
@@ -0,0 +1,37 @@
+import { t } from '@lingui/core/macro';
+import { type ReactNode } from 'react';
+import { isDefined } from 'twenty-shared/utils';
+import { Chip, ChipVariant, LinkChip } from 'twenty-ui/data-display';
+
+type ChatReferenceChipDisplayProps = {
+ displayName: string;
+ leftComponent: ReactNode;
+ to?: string;
+};
+
+export const ChatReferenceChipDisplay = ({
+ displayName,
+ leftComponent,
+ to,
+}: ChatReferenceChipDisplayProps) => {
+ if (!isDefined(to)) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/ai/components/FieldMetadataLink.tsx b/packages/twenty-front/src/modules/ai/components/FieldMetadataLink.tsx
new file mode 100644
index 0000000000..7074e2caeb
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/components/FieldMetadataLink.tsx
@@ -0,0 +1,57 @@
+import { ChatReferenceChipDisplay } from '@/ai/components/ChatReferenceChipDisplay';
+import { fieldMetadataItemByIdSelector } from '@/object-metadata/states/fieldMetadataItemByIdSelector';
+import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
+import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
+import { SettingsPath } from 'twenty-shared/types';
+import { getSettingsPath, isDefined } from 'twenty-shared/utils';
+import { useIcons } from 'twenty-ui/icon';
+import { useTheme } from 'twenty-ui/theme-constants';
+import { PermissionFlagType } from '~/generated-metadata/graphql';
+
+type FieldMetadataLinkProps = {
+ fieldMetadataItemId: string;
+ displayName: string;
+};
+
+export const FieldMetadataLink = ({
+ fieldMetadataItemId,
+ displayName,
+}: FieldMetadataLinkProps) => {
+ const theme = useTheme();
+ const { getIcon } = useIcons();
+
+ const { foundFieldMetadataItem, foundObjectMetadataItem } =
+ useAtomFamilySelectorValue(fieldMetadataItemByIdSelector, {
+ fieldMetadataItemId,
+ });
+
+ const hasDataModelPermission = useHasPermissionFlag(
+ PermissionFlagType.DATA_MODEL,
+ );
+
+ if (
+ !isDefined(foundFieldMetadataItem) ||
+ !isDefined(foundObjectMetadataItem)
+ ) {
+ return {displayName};
+ }
+
+ const Icon = getIcon(foundFieldMetadataItem.icon);
+
+ return (
+
+ }
+ />
+ );
+};
diff --git a/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx b/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx
index 9c9036b0d5..c4b49e51f2 100644
--- a/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx
+++ b/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx
@@ -6,8 +6,8 @@ import {
StyledTableScrollContainer,
} from '@/ai/components/LazyMarkdownRendererStyledComponents';
import { MarkdownCodeBlock } from '@/ai/components/MarkdownCodeBlock';
-import { TextWithRecordLinks } from '@/ai/components/TextWithRecordLinks';
-import { protectRecordReferencesForMarkdown } from '@/ai/utils/protectRecordReferencesForMarkdown';
+import { TextWithChatReferences } from '@/ai/components/TextWithChatReferences';
+import { protectChatReferencesForMarkdown } from '@/ai/utils/protectChatReferencesForMarkdown';
import { marked } from 'marked';
import {
cloneElement,
@@ -22,16 +22,16 @@ import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { getSafeUrl, isDefined } from 'twenty-shared/utils';
import { ThemeContext } from 'twenty-ui/theme-constants';
-const processChildrenForRecordLinks = (
+const processChildrenForChatReferences = (
children: React.ReactNode,
): React.ReactNode => {
if (typeof children === 'string') {
- return ;
+ return ;
}
if (Array.isArray(children)) {
return children.map((child, index) => (
- {processChildrenForRecordLinks(child)}
+ {processChildrenForChatReferences(child)}
));
}
@@ -40,7 +40,7 @@ const processChildrenForRecordLinks = (
if (isDefined(childProps.children)) {
return cloneElement(children, {
- children: processChildrenForRecordLinks(childProps.children),
+ children: processChildrenForChatReferences(childProps.children),
});
}
}
@@ -48,85 +48,88 @@ const processChildrenForRecordLinks = (
return children;
};
+// react-markdown uses each entry as the JSX element type, so rebuilding this map
+// per render would remount every node on every streamed chunk.
+const MARKDOWN_COMPONENTS = {
+ table: ({ children }: { children?: React.ReactNode }) => (
+
+
+
+ ),
+ p: ({ children }: { children?: React.ReactNode }) => (
+
+ {processChildrenForChatReferences(children)}
+
+ ),
+ td: ({ children }: { children?: React.ReactNode }) => (
+ | {processChildrenForChatReferences(children)} |
+ ),
+ th: ({ children }: { children?: React.ReactNode }) => (
+ {processChildrenForChatReferences(children)} |
+ ),
+ li: ({ children }: { children?: React.ReactNode }) => (
+ {processChildrenForChatReferences(children)}
+ ),
+ h1: ({ children }: { children?: React.ReactNode }) => (
+ {processChildrenForChatReferences(children)}
+ ),
+ h2: ({ children }: { children?: React.ReactNode }) => (
+ {processChildrenForChatReferences(children)}
+ ),
+ h3: ({ children }: { children?: React.ReactNode }) => (
+ {processChildrenForChatReferences(children)}
+ ),
+ h4: ({ children }: { children?: React.ReactNode }) => (
+ {processChildrenForChatReferences(children)}
+ ),
+ h5: ({ children }: { children?: React.ReactNode }) => (
+ {processChildrenForChatReferences(children)}
+ ),
+ h6: ({ children }: { children?: React.ReactNode }) => (
+ {processChildrenForChatReferences(children)}
+ ),
+ a: ({
+ children,
+ href,
+ title,
+ }: {
+ children?: React.ReactNode;
+ href?: string;
+ title?: string;
+ }) => (
+
+ {processChildrenForChatReferences(children)}
+
+ ),
+ code: ({
+ className,
+ children,
+ }: {
+ className?: string;
+ children?: React.ReactNode;
+ }) => {children},
+ pre: ({ children }: { children?: React.ReactNode }) => (
+ {children}
+ ),
+};
+
const MarkdownRenderer = lazy(async () => {
const [{ default: Markdown }, { default: remarkGfm }] = await Promise.all([
import('react-markdown'),
import('remark-gfm'),
]);
+ const remarkPlugins = [remarkGfm];
+
return {
- default: ({
- children,
- TableScrollContainer,
- ParagraphComponent,
- }: {
- children: string;
- TableScrollContainer: React.ComponentType<{ children: React.ReactNode }>;
- ParagraphComponent: React.ComponentType<{ children: React.ReactNode }>;
- }) => (
- (
-
-
-
- ),
- p: ({ children }) => (
-
- {processChildrenForRecordLinks(children)}
-
- ),
- td: ({ children }) => (
- {processChildrenForRecordLinks(children)} |
- ),
- th: ({ children }) => (
- {processChildrenForRecordLinks(children)} |
- ),
- li: ({ children }) => (
- {processChildrenForRecordLinks(children)}
- ),
- h1: ({ children }) => (
- {processChildrenForRecordLinks(children)}
- ),
- h2: ({ children }) => (
- {processChildrenForRecordLinks(children)}
- ),
- h3: ({ children }) => (
- {processChildrenForRecordLinks(children)}
- ),
- h4: ({ children }) => (
- {processChildrenForRecordLinks(children)}
- ),
- h5: ({ children }) => (
- {processChildrenForRecordLinks(children)}
- ),
- h6: ({ children }) => (
- {processChildrenForRecordLinks(children)}
- ),
- a: ({ children, href, title, node: _node }) => (
-
- {processChildrenForRecordLinks(children)}
-
- ),
- code: ({
- className,
- children,
- }: {
- className?: string;
- children?: React.ReactNode;
- }) => {children},
- pre: ({ children }) => (
- {children}
- ),
- }}
- >
+ default: ({ children }: { children: string }) => (
+
{children}
),
@@ -169,19 +172,14 @@ const LoadingSkeleton = () => {
const MemoizedMarkdownBlock = memo(
({ blockText }: { blockText: string }) => (
-
- {blockText}
-
+ {blockText}
),
(previousProps, nextProps) => previousProps.blockText === nextProps.blockText,
);
export const LazyMarkdownRenderer = ({ text }: { text: string }) => {
const protectedText = useMemo(
- () => protectRecordReferencesForMarkdown(text),
+ () => protectChatReferencesForMarkdown(text),
[text],
);
diff --git a/packages/twenty-front/src/modules/ai/components/ObjectMetadataLink.tsx b/packages/twenty-front/src/modules/ai/components/ObjectMetadataLink.tsx
new file mode 100644
index 0000000000..88fd536e27
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/components/ObjectMetadataLink.tsx
@@ -0,0 +1,56 @@
+import { ChatReferenceChipDisplay } from '@/ai/components/ChatReferenceChipDisplay';
+import { ObjectMetadataIcon } from '@/object-metadata/components/ObjectMetadataIcon';
+import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector';
+import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
+import { AppPath } from 'twenty-shared/types';
+import { getAppPath, isDefined } from 'twenty-shared/utils';
+import { useTheme } from 'twenty-ui/theme-constants';
+
+const PROPOSED_OBJECT_METADATA_ICON = 'IconListNumbers';
+
+type ObjectMetadataLinkProps = {
+ objectNameSingular: string;
+ displayName: string;
+};
+
+export const ObjectMetadataLink = ({
+ objectNameSingular,
+ displayName,
+}: ObjectMetadataLinkProps) => {
+ const theme = useTheme();
+
+ const objectMetadataItem = useAtomFamilySelectorValue(
+ objectMetadataItemFamilySelector,
+ {
+ objectName: objectNameSingular,
+ objectNameType: 'singular',
+ },
+ );
+
+ return (
+
+ }
+ />
+ );
+};
diff --git a/packages/twenty-front/src/modules/ai/components/RecordLink.tsx b/packages/twenty-front/src/modules/ai/components/RecordLink.tsx
index eb66599bb9..a732c51546 100644
--- a/packages/twenty-front/src/modules/ai/components/RecordLink.tsx
+++ b/packages/twenty-front/src/modules/ai/components/RecordLink.tsx
@@ -1,9 +1,9 @@
+import { ChatReferenceChipDisplay } from '@/ai/components/ChatReferenceChipDisplay';
import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector';
import { getLinkToShowPage } from '@/object-metadata/utils/getLinkToShowPage';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
-import { t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
-import { AvatarOrIcon, ChipVariant, LinkChip } from 'twenty-ui/data-display';
+import { AvatarOrIcon } from 'twenty-ui/data-display';
type RecordLinkProps = {
objectNameSingular: string;
@@ -28,16 +28,10 @@ export const RecordLink = ({
return {displayName};
}
- const linkToShowPage = getLinkToShowPage(objectNameSingular, {
- id: recordId,
- });
-
return (
- {
- const references = findRecordReferences(text);
+export const TextWithChatReferences = ({
+ text,
+}: TextWithChatReferencesProps) => {
+ const references = findChatReferences(text);
if (references.length === 0) {
return <>{text}>;
@@ -22,12 +24,7 @@ export const TextWithRecordLinks = ({ text }: TextWithRecordLinksProps) => {
}
parts.push(
- ,
+ ,
);
lastIndex = reference.index + reference.fullMatch.length;
diff --git a/packages/twenty-front/src/modules/ai/components/ViewLink.tsx b/packages/twenty-front/src/modules/ai/components/ViewLink.tsx
new file mode 100644
index 0000000000..bdb812b7d0
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/components/ViewLink.tsx
@@ -0,0 +1,47 @@
+import { ChatReferenceChipDisplay } from '@/ai/components/ChatReferenceChipDisplay';
+import { flatObjectMetadataItemsSelector } from '@/object-metadata/states/flatObjectMetadataItemsSelector';
+import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
+import { useViewById } from '@/views/hooks/useViewById';
+import { AppPath } from 'twenty-shared/types';
+import { findById, getAppPath, isDefined } from 'twenty-shared/utils';
+import { useIcons } from 'twenty-ui/icon';
+import { useTheme } from 'twenty-ui/theme-constants';
+
+type ViewLinkProps = {
+ viewId: string;
+ displayName: string;
+};
+
+export const ViewLink = ({ viewId, displayName }: ViewLinkProps) => {
+ const theme = useTheme();
+ const { getIcon } = useIcons();
+
+ const { view } = useViewById(viewId);
+ const flatObjectMetadataItems = useAtomStateValue(
+ flatObjectMetadataItemsSelector,
+ );
+
+ const objectMetadataItem = isDefined(view)
+ ? flatObjectMetadataItems.find(findById(view.objectMetadataId))
+ : undefined;
+
+ if (!isDefined(view) || !isDefined(objectMetadataItem)) {
+ return {displayName};
+ }
+
+ const Icon = getIcon(view.icon);
+
+ return (
+
+ }
+ />
+ );
+};
diff --git a/packages/twenty-front/src/modules/ai/components/__stories__/ChatReferenceChip.stories.tsx b/packages/twenty-front/src/modules/ai/components/__stories__/ChatReferenceChip.stories.tsx
new file mode 100644
index 0000000000..63d878b4a6
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/components/__stories__/ChatReferenceChip.stories.tsx
@@ -0,0 +1,134 @@
+import { type Meta, type StoryObj } from '@storybook/react-vite';
+import { expect, within } from 'storybook/test';
+
+import { LazyMarkdownRenderer } from '@/ai/components/LazyMarkdownRenderer';
+import { formatChatReference } from '@/ai/utils/formatChatReference';
+import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
+import { styled } from '@linaria/react';
+import { useStore } from 'jotai';
+import { type ReactNode, useEffect, useState } from 'react';
+import { type ViewWithRelations } from '@/views/types/ViewWithRelations';
+import { PermissionFlagType } from '~/generated-metadata/graphql';
+import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator';
+import { IconsProviderDecorator } from '~/testing/decorators/IconsProviderDecorator';
+import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
+import { getMockFieldMetadataItemOrThrow } from '~/testing/utils/getMockFieldMetadataItemOrThrow';
+import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow';
+import { setTestViewsInMetadataStore } from '~/testing/utils/setTestViewsInMetadataStore';
+
+const StyledContainer = styled.div`
+ max-width: 640px;
+`;
+
+const companyObjectMetadataItem = getMockObjectMetadataItemOrThrow('company');
+
+const employeesFieldMetadataItem = getMockFieldMetadataItemOrThrow({
+ objectMetadataItem: companyObjectMetadataItem,
+ fieldName: 'employees',
+});
+
+const allCompaniesView = {
+ id: '20202020-4444-4444-4444-444444444444',
+ name: 'All Companies',
+ icon: 'IconList',
+ objectMetadataId: companyObjectMetadataItem.id,
+ isActive: true,
+} as ViewWithRelations;
+
+// Declared locally because ObjectMetadataItemsDecorator drops every mocked view,
+// and importing the generated view mocks breaks the linaria build-time evaluator.
+const ChatReferenceStoreSeeder = ({ children }: { children: ReactNode }) => {
+ const store = useStore();
+ const [isSeeded, setIsSeeded] = useState(false);
+
+ useEffect(() => {
+ setTestViewsInMetadataStore(store, [allCompaniesView]);
+
+ store.set(currentUserWorkspaceState.atom, {
+ permissionFlags: [PermissionFlagType.DATA_MODEL],
+ twoFactorAuthenticationMethodSummary: [],
+ objectsPermissions: [],
+ });
+
+ setIsSeeded(true);
+ }, [store]);
+
+ return isSeeded ? <>{children}> : null;
+};
+
+const meta: Meta = {
+ title: 'Modules/AiChat/ChatReferenceChip',
+ component: LazyMarkdownRenderer,
+ decorators: [
+ (Story) => (
+
+
+
+
+
+ ),
+ ObjectMetadataItemsDecorator,
+ IconsProviderDecorator,
+ ComponentWithRouterDecorator,
+ ],
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const ExistingMetadata: Story = {
+ args: {
+ text: `Your ${formatChatReference({
+ kind: 'object',
+ objectNameSingular: 'company',
+ displayName: 'Companies',
+ })} object is sorted by ${formatChatReference({
+ kind: 'field',
+ fieldMetadataItemId: employeesFieldMetadataItem.id,
+ displayName: 'Employees',
+ })} in the ${formatChatReference({
+ kind: 'view',
+ viewId: allCompaniesView.id,
+ displayName: allCompaniesView.name,
+ })} view.`,
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ expect((await canvas.findByText('Companies')).closest('a')).toHaveAttribute(
+ 'href',
+ `/objects/${companyObjectMetadataItem.namePlural}`,
+ );
+ expect((await canvas.findByText('Employees')).closest('a')).toHaveAttribute(
+ 'href',
+ `/settings/objects/${companyObjectMetadataItem.namePlural}/${employeesFieldMetadataItem.name}`,
+ );
+ expect(
+ (await canvas.findByText('All Companies')).closest('a'),
+ ).toHaveAttribute(
+ 'href',
+ `/objects/${companyObjectMetadataItem.namePlural}?viewId=${allCompaniesView.id}`,
+ );
+ },
+};
+
+export const ProposedObject: Story = {
+ args: {
+ text: `As a Head of Partnerships, you seem to work across partner companies, key contacts, and commercial follow-ups, so I suggest creating a ${formatChatReference(
+ {
+ kind: 'object',
+ objectNameSingular: 'partner',
+ displayName: 'Partners',
+ },
+ )} object to track relationship status, partner type, owner, and next step. Should I create it?`,
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ const proposedObjectChipLabel = await canvas.findByText('Partners');
+
+ expect(proposedObjectChipLabel).toBeVisible();
+ expect(proposedObjectChipLabel.closest('a')).toBeNull();
+ },
+};
diff --git a/packages/twenty-front/src/modules/ai/components/__tests__/FieldMetadataLink.test.tsx b/packages/twenty-front/src/modules/ai/components/__tests__/FieldMetadataLink.test.tsx
new file mode 100644
index 0000000000..b84e7439a7
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/components/__tests__/FieldMetadataLink.test.tsx
@@ -0,0 +1,91 @@
+import { render, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+
+import { FieldMetadataLink } from '@/ai/components/FieldMetadataLink';
+import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
+import { type Store } from 'jotai/vanilla/store';
+import { PermissionFlagType } from '~/generated-metadata/graphql';
+import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
+import { getMockFieldMetadataItemOrThrow } from '~/testing/utils/getMockFieldMetadataItemOrThrow';
+import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow';
+
+const companyObjectMetadataItem = getMockObjectMetadataItemOrThrow('company');
+
+const nameFieldMetadataItem = getMockFieldMetadataItemOrThrow({
+ objectMetadataItem: companyObjectMetadataItem,
+ fieldName: 'name',
+});
+
+const setPermissionFlags = (
+ store: Store,
+ permissionFlags: PermissionFlagType[],
+) => {
+ store.set(currentUserWorkspaceState.atom, {
+ permissionFlags,
+ twoFactorAuthenticationMethodSummary: [],
+ objectsPermissions: [],
+ });
+};
+
+const renderFieldMetadataLink = ({
+ fieldMetadataItemId,
+ displayName,
+ permissionFlags,
+}: {
+ fieldMetadataItemId: string;
+ displayName: string;
+ permissionFlags: PermissionFlagType[];
+}) => {
+ const Wrapper = getJestMetadataAndApolloMocksWrapper({
+ apolloMocks: [],
+ onInitializeJotaiStore: (store) =>
+ setPermissionFlags(store, permissionFlags),
+ });
+
+ return render(
+
+
+ ,
+ { wrapper: Wrapper },
+ );
+};
+
+describe('FieldMetadataLink', () => {
+ it('should link a field to its settings page', () => {
+ renderFieldMetadataLink({
+ fieldMetadataItemId: nameFieldMetadataItem.id,
+ displayName: 'Name',
+ permissionFlags: [PermissionFlagType.DATA_MODEL],
+ });
+
+ expect(screen.getByText('Name').closest('a')).toHaveAttribute(
+ 'href',
+ `/settings/objects/${companyObjectMetadataItem.namePlural}/${nameFieldMetadataItem.name}`,
+ );
+ });
+
+ it('should render a chip without a link when the user cannot access the data model', () => {
+ renderFieldMetadataLink({
+ fieldMetadataItemId: nameFieldMetadataItem.id,
+ displayName: 'Name',
+ permissionFlags: [],
+ });
+
+ expect(screen.getByText('Name').closest('a')).toBeNull();
+ expect(screen.getByTestId('chip')).toBeInTheDocument();
+ });
+
+ it('should render plain text for an unknown field id', () => {
+ renderFieldMetadataLink({
+ fieldMetadataItemId: '33333333-3333-3333-3333-333333333333',
+ displayName: 'Partner type',
+ permissionFlags: [PermissionFlagType.DATA_MODEL],
+ });
+
+ expect(screen.getByText('Partner type')).toBeInTheDocument();
+ expect(screen.queryByTestId('chip')).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/twenty-front/src/modules/ai/components/__tests__/ObjectMetadataLink.test.tsx b/packages/twenty-front/src/modules/ai/components/__tests__/ObjectMetadataLink.test.tsx
new file mode 100644
index 0000000000..6d7ab0148f
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/components/__tests__/ObjectMetadataLink.test.tsx
@@ -0,0 +1,49 @@
+import { render, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+
+import { ObjectMetadataLink } from '@/ai/components/ObjectMetadataLink';
+import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
+
+const Wrapper = getJestMetadataAndApolloMocksWrapper({ apolloMocks: [] });
+
+const renderObjectMetadataLink = ({
+ objectNameSingular,
+ displayName,
+}: {
+ objectNameSingular: string;
+ displayName: string;
+}) =>
+ render(
+
+
+ ,
+ { wrapper: Wrapper },
+ );
+
+describe('ObjectMetadataLink', () => {
+ it('should link an existing object to its record index page', () => {
+ renderObjectMetadataLink({
+ objectNameSingular: 'company',
+ displayName: 'Companies',
+ });
+
+ expect(screen.getByText('Companies').closest('a')).toHaveAttribute(
+ 'href',
+ '/objects/companies',
+ );
+ });
+
+ it('should render a chip without a link for an object that does not exist yet', () => {
+ renderObjectMetadataLink({
+ objectNameSingular: 'partner',
+ displayName: 'Partners',
+ });
+
+ expect(screen.getByText('Partners')).toBeInTheDocument();
+ expect(screen.getByText('Partners').closest('a')).toBeNull();
+ expect(screen.getByTestId('chip')).toBeInTheDocument();
+ });
+});
diff --git a/packages/twenty-front/src/modules/ai/components/__tests__/TextWithChatReferences.test.tsx b/packages/twenty-front/src/modules/ai/components/__tests__/TextWithChatReferences.test.tsx
new file mode 100644
index 0000000000..1a5c753740
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/components/__tests__/TextWithChatReferences.test.tsx
@@ -0,0 +1,138 @@
+import { render, screen } from '@testing-library/react';
+
+import { TextWithChatReferences } from '@/ai/components/TextWithChatReferences';
+
+jest.mock('@/ai/components/RecordLink', () => ({
+ RecordLink: ({
+ displayName,
+ objectNameSingular,
+ recordId,
+ }: {
+ displayName: string;
+ objectNameSingular: string;
+ recordId: string;
+ }) => (
+
+ {displayName}
+
+ ),
+}));
+
+jest.mock('@/ai/components/ObjectMetadataLink', () => ({
+ ObjectMetadataLink: ({
+ displayName,
+ objectNameSingular,
+ }: {
+ displayName: string;
+ objectNameSingular: string;
+ }) => (
+
+ {displayName}
+
+ ),
+}));
+
+jest.mock('@/ai/components/FieldMetadataLink', () => ({
+ FieldMetadataLink: ({
+ displayName,
+ fieldMetadataItemId,
+ }: {
+ displayName: string;
+ fieldMetadataItemId: string;
+ }) => (
+
+ {displayName}
+
+ ),
+}));
+
+jest.mock('@/ai/components/ViewLink', () => ({
+ ViewLink: ({
+ displayName,
+ viewId,
+ }: {
+ displayName: string;
+ viewId: string;
+ }) => (
+
+ {displayName}
+
+ ),
+}));
+
+describe('TextWithChatReferences', () => {
+ it('should render plain text without references as-is', () => {
+ render();
+
+ expect(
+ screen.getByText('Which company should we contact?'),
+ ).toBeInTheDocument();
+ expect(screen.queryByTestId('record-link')).not.toBeInTheDocument();
+ });
+
+ it('should replace tagged record references with RecordLink chips', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId('record-link')).toHaveTextContent('Acme');
+ expect(screen.getByText(/Contact/)).toHaveTextContent('Contact Acme next');
+ expect(screen.queryByText(/\[\[record:company:/)).not.toBeInTheDocument();
+ });
+
+ it('should still replace legacy ]] record references with RecordLink chips', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId('record-link')).toHaveTextContent('Acme');
+ expect(screen.getByText(/Contact/)).toHaveTextContent('Contact Acme next');
+ });
+
+ it('should replace multiple record references in option-style labels', () => {
+ render(
+ ,
+ );
+
+ const recordLinks = screen.getAllByTestId('record-link');
+
+ expect(recordLinks).toHaveLength(2);
+ expect(recordLinks[0]).toHaveTextContent('Alice');
+ expect(recordLinks[1]).toHaveTextContent('Bob');
+ expect(screen.queryByText(/\[\[/)).not.toBeInTheDocument();
+ });
+
+ it('should chip tagged labels that contain backticks, brackets, colons, and ]]', () => {
+ render(
+ ,
+ );
+
+ const recordLinks = screen.getAllByTestId('record-link');
+
+ expect(recordLinks).toHaveLength(3);
+ expect(recordLinks[0]).toHaveTextContent('Workflow `UPDATE_RECORD` step');
+ expect(recordLinks[1]).toHaveTextContent('[test] ]] [test] [test] ###');
+ expect(recordLinks[2]).toHaveTextContent('Doe: Jane');
+ expect(screen.queryByText(/\[\[record:/)).not.toBeInTheDocument();
+ });
+
+ it('should route each reference kind to its own chip', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId('view-link')).toHaveAttribute(
+ 'href',
+ '/views/44444444-4444-4444-4444-444444444444',
+ );
+ expect(screen.getByTestId('object-link')).toHaveAttribute(
+ 'href',
+ '/objects/partner',
+ );
+ expect(screen.getByTestId('record-link')).toHaveTextContent('Alice');
+ expect(screen.getByTestId('field-link')).toHaveAttribute(
+ 'href',
+ '/fields/33333333-3333-3333-3333-333333333333',
+ );
+ });
+});
diff --git a/packages/twenty-front/src/modules/ai/components/__tests__/TextWithRecordLinks.test.tsx b/packages/twenty-front/src/modules/ai/components/__tests__/TextWithRecordLinks.test.tsx
deleted file mode 100644
index ca6a9a837c..0000000000
--- a/packages/twenty-front/src/modules/ai/components/__tests__/TextWithRecordLinks.test.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-import { render, screen } from '@testing-library/react';
-
-import { TextWithRecordLinks } from '@/ai/components/TextWithRecordLinks';
-
-jest.mock('@/ai/components/RecordLink', () => ({
- RecordLink: ({
- displayName,
- objectNameSingular,
- recordId,
- }: {
- displayName: string;
- objectNameSingular: string;
- recordId: string;
- }) => (
-
- {displayName}
-
- ),
-}));
-
-describe('TextWithRecordLinks', () => {
- it('should render plain text without record references as-is', () => {
- render();
-
- expect(
- screen.getByText('Which company should we contact?'),
- ).toBeInTheDocument();
- expect(screen.queryByTestId('record-link')).not.toBeInTheDocument();
- });
-
- it('should replace tagged record references with RecordLink chips', () => {
- render(
- ,
- );
-
- expect(screen.getByTestId('record-link')).toHaveTextContent('Acme');
- expect(screen.getByText(/Contact/)).toHaveTextContent('Contact Acme next');
- expect(screen.queryByText(/\[\[record:company:/)).not.toBeInTheDocument();
- });
-
- it('should still replace legacy ]] record references with RecordLink chips', () => {
- render(
- ,
- );
-
- expect(screen.getByTestId('record-link')).toHaveTextContent('Acme');
- expect(screen.getByText(/Contact/)).toHaveTextContent('Contact Acme next');
- });
-
- it('should replace multiple record references in option-style labels', () => {
- render(
- ,
- );
-
- const recordLinks = screen.getAllByTestId('record-link');
-
- expect(recordLinks).toHaveLength(2);
- expect(recordLinks[0]).toHaveTextContent('Alice');
- expect(recordLinks[1]).toHaveTextContent('Bob');
- expect(screen.queryByText(/\[\[/)).not.toBeInTheDocument();
- });
-
- it('should chip tagged labels that contain backticks, brackets, colons, and ]]', () => {
- render(
- ,
- );
-
- const recordLinks = screen.getAllByTestId('record-link');
-
- expect(recordLinks).toHaveLength(3);
- expect(recordLinks[0]).toHaveTextContent('Workflow `UPDATE_RECORD` step');
- expect(recordLinks[1]).toHaveTextContent('[test] ]] [test] [test] ###');
- expect(recordLinks[2]).toHaveTextContent('Doe: Jane');
- expect(screen.queryByText(/\[\[record:/)).not.toBeInTheDocument();
- });
-});
diff --git a/packages/twenty-front/src/modules/ai/components/__tests__/ViewLink.test.tsx b/packages/twenty-front/src/modules/ai/components/__tests__/ViewLink.test.tsx
new file mode 100644
index 0000000000..1ef439254b
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/components/__tests__/ViewLink.test.tsx
@@ -0,0 +1,79 @@
+import { render, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+
+import { ViewLink } from '@/ai/components/ViewLink';
+import { type ViewWithRelations } from '@/views/types/ViewWithRelations';
+import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
+import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow';
+import { setTestViewsInMetadataStore } from '~/testing/utils/setTestViewsInMetadataStore';
+
+const companyObjectMetadataItem = getMockObjectMetadataItemOrThrow('company');
+
+const VIEW_ID = '44444444-4444-4444-4444-444444444444';
+
+const allCompaniesView = {
+ id: VIEW_ID,
+ name: 'All Companies',
+ icon: 'IconBuildingSkyscraper',
+ objectMetadataId: companyObjectMetadataItem.id,
+ isActive: true,
+} as ViewWithRelations;
+
+const renderViewLink = ({
+ viewId,
+ displayName,
+ views,
+}: {
+ viewId: string;
+ displayName: string;
+ views: ViewWithRelations[];
+}) => {
+ const Wrapper = getJestMetadataAndApolloMocksWrapper({
+ apolloMocks: [],
+ onInitializeJotaiStore: (store) =>
+ setTestViewsInMetadataStore(store, views),
+ });
+
+ return render(
+
+
+ ,
+ { wrapper: Wrapper },
+ );
+};
+
+describe('ViewLink', () => {
+ it('should link a view to its object index page', () => {
+ renderViewLink({
+ viewId: VIEW_ID,
+ displayName: 'All Companies',
+ views: [allCompaniesView],
+ });
+
+ expect(screen.getByText('All Companies').closest('a')).toHaveAttribute(
+ 'href',
+ `/objects/${companyObjectMetadataItem.namePlural}?viewId=${VIEW_ID}`,
+ );
+ });
+
+ it('should render plain text for an unknown view id', () => {
+ renderViewLink({
+ viewId: VIEW_ID,
+ displayName: 'All Companies',
+ views: [],
+ });
+
+ expect(screen.getByText('All Companies')).toBeInTheDocument();
+ expect(screen.queryByTestId('chip')).not.toBeInTheDocument();
+ });
+
+ it('should render plain text for an archived view', () => {
+ renderViewLink({
+ viewId: VIEW_ID,
+ displayName: 'All Companies',
+ views: [{ ...allCompaniesView, isActive: false }],
+ });
+
+ expect(screen.queryByTestId('chip')).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/twenty-front/src/modules/ai/constants/AnyChatReferenceCloseTagRegex.ts b/packages/twenty-front/src/modules/ai/constants/AnyChatReferenceCloseTagRegex.ts
new file mode 100644
index 0000000000..a3dbaba49f
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/constants/AnyChatReferenceCloseTagRegex.ts
@@ -0,0 +1,5 @@
+import { CHAT_REFERENCE_KINDS } from '@/ai/constants/ChatReferenceKinds';
+
+export const ANY_CHAT_REFERENCE_CLOSE_TAG_REGEX = new RegExp(
+ `\\[\\[/(?:${CHAT_REFERENCE_KINDS.join('|')})\\]\\]`,
+);
diff --git a/packages/twenty-front/src/modules/ai/constants/ChatReferenceKinds.ts b/packages/twenty-front/src/modules/ai/constants/ChatReferenceKinds.ts
new file mode 100644
index 0000000000..82334264ec
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/constants/ChatReferenceKinds.ts
@@ -0,0 +1,6 @@
+export const CHAT_REFERENCE_KINDS = [
+ 'record',
+ 'object',
+ 'field',
+ 'view',
+] as const;
diff --git a/packages/twenty-front/src/modules/ai/constants/ChatReferenceMetadataNamePattern.ts b/packages/twenty-front/src/modules/ai/constants/ChatReferenceMetadataNamePattern.ts
new file mode 100644
index 0000000000..200a0262eb
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/constants/ChatReferenceMetadataNamePattern.ts
@@ -0,0 +1 @@
+export const CHAT_REFERENCE_METADATA_NAME_PATTERN = '[a-zA-Z][a-zA-Z0-9]*';
diff --git a/packages/twenty-front/src/modules/ai/constants/ChatReferenceStartRegex.ts b/packages/twenty-front/src/modules/ai/constants/ChatReferenceStartRegex.ts
new file mode 100644
index 0000000000..ea049fc9c4
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/constants/ChatReferenceStartRegex.ts
@@ -0,0 +1,14 @@
+import { CHAT_REFERENCE_METADATA_NAME_PATTERN } from '@/ai/constants/ChatReferenceMetadataNamePattern';
+import { CHAT_REFERENCE_UUID_PATTERN } from '@/ai/constants/ChatReferenceUuidPattern';
+
+// The record alternative must stay last: its `record:` prefix is optional, so it
+// matches the metadata markers too and would swallow them if tried first.
+export const CHAT_REFERENCE_START_REGEX = new RegExp(
+ [
+ `\\[\\[object:(?${CHAT_REFERENCE_METADATA_NAME_PATTERN}):`,
+ `\\[\\[field:(?${CHAT_REFERENCE_UUID_PATTERN}):`,
+ `\\[\\[view:(?${CHAT_REFERENCE_UUID_PATTERN}):`,
+ `\\[\\[(?:record:)?(?${CHAT_REFERENCE_METADATA_NAME_PATTERN}):(?${CHAT_REFERENCE_UUID_PATTERN}):`,
+ ].join('|'),
+ 'g',
+);
diff --git a/packages/twenty-front/src/modules/ai/constants/ChatReferenceUuidPattern.ts b/packages/twenty-front/src/modules/ai/constants/ChatReferenceUuidPattern.ts
new file mode 100644
index 0000000000..409c464d42
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/constants/ChatReferenceUuidPattern.ts
@@ -0,0 +1,2 @@
+export const CHAT_REFERENCE_UUID_PATTERN =
+ '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
diff --git a/packages/twenty-front/src/modules/ai/constants/RecordReferenceCloseTag.ts b/packages/twenty-front/src/modules/ai/constants/RecordReferenceCloseTag.ts
deleted file mode 100644
index 80c87936f4..0000000000
--- a/packages/twenty-front/src/modules/ai/constants/RecordReferenceCloseTag.ts
+++ /dev/null
@@ -1 +0,0 @@
-export const RECORD_REFERENCE_CLOSE_TAG = '[[/record]]';
diff --git a/packages/twenty-front/src/modules/ai/types/ChatReferenceClosing.ts b/packages/twenty-front/src/modules/ai/types/ChatReferenceClosing.ts
new file mode 100644
index 0000000000..a4f422612f
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/types/ChatReferenceClosing.ts
@@ -0,0 +1,4 @@
+export type ChatReferenceClosing = {
+ index: number;
+ length: number;
+};
diff --git a/packages/twenty-front/src/modules/ai/types/ChatReferenceIdentity.ts b/packages/twenty-front/src/modules/ai/types/ChatReferenceIdentity.ts
new file mode 100644
index 0000000000..3b94c825c4
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/types/ChatReferenceIdentity.ts
@@ -0,0 +1,5 @@
+export type ChatReferenceIdentity =
+ | { kind: 'record'; objectNameSingular: string; recordId: string }
+ | { kind: 'object'; objectNameSingular: string }
+ | { kind: 'field'; fieldMetadataItemId: string }
+ | { kind: 'view'; viewId: string };
diff --git a/packages/twenty-front/src/modules/ai/types/ChatReferenceKind.ts b/packages/twenty-front/src/modules/ai/types/ChatReferenceKind.ts
new file mode 100644
index 0000000000..c6ddeab25c
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/types/ChatReferenceKind.ts
@@ -0,0 +1,3 @@
+import { type CHAT_REFERENCE_KINDS } from '@/ai/constants/ChatReferenceKinds';
+
+export type ChatReferenceKind = (typeof CHAT_REFERENCE_KINDS)[number];
diff --git a/packages/twenty-front/src/modules/ai/types/ChatReferenceMatch.ts b/packages/twenty-front/src/modules/ai/types/ChatReferenceMatch.ts
new file mode 100644
index 0000000000..e849c2e7a1
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/types/ChatReferenceMatch.ts
@@ -0,0 +1,7 @@
+import { type ChatReferenceIdentity } from '@/ai/types/ChatReferenceIdentity';
+
+export type ChatReferenceMatch = ChatReferenceIdentity & {
+ fullMatch: string;
+ index: number;
+ displayName: string;
+};
diff --git a/packages/twenty-front/src/modules/ai/types/ChatReferenceStart.ts b/packages/twenty-front/src/modules/ai/types/ChatReferenceStart.ts
new file mode 100644
index 0000000000..5ff0f5295e
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/types/ChatReferenceStart.ts
@@ -0,0 +1,7 @@
+import { type ChatReferenceIdentity } from '@/ai/types/ChatReferenceIdentity';
+
+export type ChatReferenceStart = {
+ index: number;
+ prefixLength: number;
+ identity: ChatReferenceIdentity;
+};
diff --git a/packages/twenty-front/src/modules/ai/types/RecordReferenceMatch.ts b/packages/twenty-front/src/modules/ai/types/RecordReferenceMatch.ts
deleted file mode 100644
index 89ad453af3..0000000000
--- a/packages/twenty-front/src/modules/ai/types/RecordReferenceMatch.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-export type RecordReferenceMatch = {
- fullMatch: string;
- index: number;
- objectNameSingular: string;
- recordId: string;
- displayName: string;
-};
diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/findChatReferences.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/findChatReferences.test.ts
new file mode 100644
index 0000000000..8bd57ad54d
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/utils/__tests__/findChatReferences.test.ts
@@ -0,0 +1,263 @@
+import { findChatReferences } from '@/ai/utils/findChatReferences';
+
+describe('findChatReferences', () => {
+ it('should leave plain text without matches', () => {
+ expect(findChatReferences('Which company should we contact?')).toEqual([]);
+ });
+
+ it('should find a tagged record reference', () => {
+ expect(
+ findChatReferences(
+ 'Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme[[/record]] next',
+ ),
+ ).toEqual([
+ {
+ kind: 'record',
+ fullMatch:
+ '[[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme[[/record]]',
+ index: 8,
+ objectNameSingular: 'company',
+ recordId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
+ displayName: 'Acme',
+ },
+ ]);
+ });
+
+ it('should include ]] inside a tagged display name', () => {
+ expect(
+ findChatReferences(
+ 'The company is [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test] [test] ###[[/record]], created on July 21',
+ ),
+ ).toEqual([
+ {
+ kind: 'record',
+ fullMatch:
+ '[[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test] [test] ###[[/record]]',
+ index: 15,
+ objectNameSingular: 'company',
+ recordId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
+ displayName: '[test] ]] [test] [test] ###',
+ },
+ ]);
+ });
+
+ it('should still support legacy ]] terminators', () => {
+ expect(
+ findChatReferences(
+ 'Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme]] next',
+ ),
+ ).toEqual([
+ {
+ kind: 'record',
+ fullMatch:
+ '[[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme]]',
+ index: 8,
+ objectNameSingular: 'company',
+ recordId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
+ displayName: 'Acme',
+ },
+ ]);
+ });
+
+ it('should still support legacy ]] inside display names', () => {
+ expect(
+ findChatReferences(
+ 'The company is [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test] [test] ###]], created on July 21',
+ ),
+ ).toEqual([
+ {
+ kind: 'record',
+ fullMatch:
+ '[[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test] [test] ###]]',
+ index: 15,
+ objectNameSingular: 'company',
+ recordId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
+ displayName: '[test] ]] [test] [test] ###',
+ },
+ ]);
+ });
+
+ it('should find multiple tagged references without consuming into the next one', () => {
+ expect(
+ findChatReferences(
+ 'Merge [[person:11111111-1111-1111-1111-111111111111:Alice[[/record]] into [[record:person:22222222-2222-2222-2222-222222222222:Bob[[/record]]',
+ ),
+ ).toEqual([
+ {
+ kind: 'record',
+ fullMatch:
+ '[[person:11111111-1111-1111-1111-111111111111:Alice[[/record]]',
+ index: 6,
+ objectNameSingular: 'person',
+ recordId: '11111111-1111-1111-1111-111111111111',
+ displayName: 'Alice',
+ },
+ {
+ kind: 'record',
+ fullMatch:
+ '[[record:person:22222222-2222-2222-2222-222222222222:Bob[[/record]]',
+ index: 74,
+ objectNameSingular: 'person',
+ recordId: '22222222-2222-2222-2222-222222222222',
+ displayName: 'Bob',
+ },
+ ]);
+ });
+
+ it('should find an object reference', () => {
+ expect(
+ findChatReferences('Open [[object:partner:Partners[[/object]] to start'),
+ ).toEqual([
+ {
+ kind: 'object',
+ fullMatch: '[[object:partner:Partners[[/object]]',
+ index: 5,
+ objectNameSingular: 'partner',
+ displayName: 'Partners',
+ },
+ ]);
+ });
+
+ it('should find a field reference instead of reading it as a record', () => {
+ expect(
+ findChatReferences(
+ 'The [[field:33333333-3333-3333-3333-333333333333:Stage[[/field]] field',
+ ),
+ ).toEqual([
+ {
+ kind: 'field',
+ fullMatch:
+ '[[field:33333333-3333-3333-3333-333333333333:Stage[[/field]]',
+ index: 4,
+ fieldMetadataItemId: '33333333-3333-3333-3333-333333333333',
+ displayName: 'Stage',
+ },
+ ]);
+ });
+
+ it('should find a view reference instead of reading it as a record', () => {
+ expect(
+ findChatReferences(
+ 'See [[view:44444444-4444-4444-4444-444444444444:All Companies[[/view]]',
+ ),
+ ).toEqual([
+ {
+ kind: 'view',
+ fullMatch:
+ '[[view:44444444-4444-4444-4444-444444444444:All Companies[[/view]]',
+ index: 4,
+ viewId: '44444444-4444-4444-4444-444444444444',
+ displayName: 'All Companies',
+ },
+ ]);
+ });
+
+ it('should read an explicit record prefix as a record even when the object is named view', () => {
+ expect(
+ findChatReferences(
+ 'Open [[record:view:44444444-4444-4444-4444-444444444444:Quarterly[[/record]]',
+ ),
+ ).toEqual([
+ {
+ kind: 'record',
+ fullMatch:
+ '[[record:view:44444444-4444-4444-4444-444444444444:Quarterly[[/record]]',
+ index: 5,
+ objectNameSingular: 'view',
+ recordId: '44444444-4444-4444-4444-444444444444',
+ displayName: 'Quarterly',
+ },
+ ]);
+ });
+
+ it('should drop a metadata reference closed by a foreign tag', () => {
+ expect(
+ findChatReferences(
+ 'See [[view:44444444-4444-4444-4444-444444444444:All Companies[[/record]]',
+ ),
+ ).toEqual([]);
+ });
+
+ it('should drop a metadata reference closed by a bare legacy terminator', () => {
+ expect(
+ findChatReferences('Open [[object:partner:Partners]] to start'),
+ ).toEqual([]);
+ });
+
+ it('should not let a legacy record swallow a foreign close tag', () => {
+ expect(
+ findChatReferences(
+ '[[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme]] blah [[/object]]',
+ ),
+ ).toEqual([
+ {
+ kind: 'record',
+ fullMatch:
+ '[[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme]]',
+ index: 0,
+ objectNameSingular: 'company',
+ recordId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
+ displayName: 'Acme',
+ },
+ ]);
+ });
+
+ it('should match object names containing digits', () => {
+ expect(
+ findChatReferences(
+ '[[record:company2:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme[[/record]] and [[object:company2:Companies 2[[/object]]',
+ ),
+ ).toEqual([
+ {
+ kind: 'record',
+ fullMatch:
+ '[[record:company2:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme[[/record]]',
+ index: 0,
+ objectNameSingular: 'company2',
+ recordId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
+ displayName: 'Acme',
+ },
+ {
+ kind: 'object',
+ fullMatch: '[[object:company2:Companies 2[[/object]]',
+ index: 75,
+ objectNameSingular: 'company2',
+ displayName: 'Companies 2',
+ },
+ ]);
+ });
+
+ it('should find every kind in a single string', () => {
+ const references = findChatReferences(
+ 'The [[view:44444444-4444-4444-4444-444444444444:Pipeline[[/view]] view of [[object:partner:Partners[[/object]] groups [[record:person:11111111-1111-1111-1111-111111111111:Alice[[/record]] by [[field:33333333-3333-3333-3333-333333333333:Stage[[/field]]',
+ );
+
+ expect(references.map((reference) => reference.kind)).toEqual([
+ 'view',
+ 'object',
+ 'record',
+ 'field',
+ ]);
+ expect(references.map((reference) => reference.displayName)).toEqual([
+ 'Pipeline',
+ 'Partners',
+ 'Alice',
+ 'Stage',
+ ]);
+ });
+
+ it('should find adjacent references without a separator', () => {
+ const references = findChatReferences(
+ '[[object:partner:Partners[[/object]][[object:company:Companies[[/object]]',
+ );
+
+ expect(references).toHaveLength(2);
+ expect(references[0].displayName).toBe('Partners');
+ expect(references[1].displayName).toBe('Companies');
+ expect(references[1].index).toBe(36);
+ });
+
+ it('should drop an unclosed reference', () => {
+ expect(findChatReferences('Open [[object:partner:Partners')).toEqual([]);
+ });
+});
diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/findRecordReferences.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/findRecordReferences.test.ts
deleted file mode 100644
index f524aa9c2a..0000000000
--- a/packages/twenty-front/src/modules/ai/utils/__tests__/findRecordReferences.test.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-import { findRecordReferences } from '@/ai/utils/findRecordReferences';
-
-describe('findRecordReferences', () => {
- it('should leave plain text without matches', () => {
- expect(findRecordReferences('Which company should we contact?')).toEqual(
- [],
- );
- });
-
- it('should find a tagged record reference', () => {
- expect(
- findRecordReferences(
- 'Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme[[/record]] next',
- ),
- ).toEqual([
- {
- fullMatch:
- '[[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme[[/record]]',
- index: 8,
- objectNameSingular: 'company',
- recordId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
- displayName: 'Acme',
- },
- ]);
- });
-
- it('should include ]] inside a tagged display name', () => {
- expect(
- findRecordReferences(
- 'The company is [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test] [test] ###[[/record]], created on July 21',
- ),
- ).toEqual([
- {
- fullMatch:
- '[[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test] [test] ###[[/record]]',
- index: 15,
- objectNameSingular: 'company',
- recordId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
- displayName: '[test] ]] [test] [test] ###',
- },
- ]);
- });
-
- it('should still support legacy ]] terminators', () => {
- expect(
- findRecordReferences(
- 'Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme]] next',
- ),
- ).toEqual([
- {
- fullMatch:
- '[[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme]]',
- index: 8,
- objectNameSingular: 'company',
- recordId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
- displayName: 'Acme',
- },
- ]);
- });
-
- it('should still support legacy ]] inside display names', () => {
- expect(
- findRecordReferences(
- 'The company is [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test] [test] ###]], created on July 21',
- ),
- ).toEqual([
- {
- fullMatch:
- '[[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test] [test] ###]]',
- index: 15,
- objectNameSingular: 'company',
- recordId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
- displayName: '[test] ]] [test] [test] ###',
- },
- ]);
- });
-
- it('should find multiple tagged references without consuming into the next one', () => {
- expect(
- findRecordReferences(
- 'Merge [[person:11111111-1111-1111-1111-111111111111:Alice[[/record]] into [[record:person:22222222-2222-2222-2222-222222222222:Bob[[/record]]',
- ),
- ).toEqual([
- {
- fullMatch:
- '[[person:11111111-1111-1111-1111-111111111111:Alice[[/record]]',
- index: 6,
- objectNameSingular: 'person',
- recordId: '11111111-1111-1111-1111-111111111111',
- displayName: 'Alice',
- },
- {
- fullMatch:
- '[[record:person:22222222-2222-2222-2222-222222222222:Bob[[/record]]',
- index: 74,
- objectNameSingular: 'person',
- recordId: '22222222-2222-2222-2222-222222222222',
- displayName: 'Bob',
- },
- ]);
- });
-});
diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/protectChatReferencesForMarkdown.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/protectChatReferencesForMarkdown.test.ts
new file mode 100644
index 0000000000..c0de47f92a
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/utils/__tests__/protectChatReferencesForMarkdown.test.ts
@@ -0,0 +1,87 @@
+import { protectChatReferencesForMarkdown } from '@/ai/utils/protectChatReferencesForMarkdown';
+
+describe('protectChatReferencesForMarkdown', () => {
+ it('should leave plain text unchanged', () => {
+ expect(
+ protectChatReferencesForMarkdown('Which company should we contact?'),
+ ).toBe('Which company should we contact?');
+ });
+
+ it('should rewrite legacy refs to the tagged format', () => {
+ expect(
+ protectChatReferencesForMarkdown(
+ 'Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme]] next',
+ ),
+ ).toBe(
+ 'Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme[[/record]] next',
+ );
+ });
+
+ it('should escape backticks in labels and close with the tag', () => {
+ expect(
+ protectChatReferencesForMarkdown(
+ 'See [[record:workflow:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Workflow `UPDATE_RECORD` step[[/record]]',
+ ),
+ ).toBe(
+ 'See [[record:workflow:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Workflow \\`UPDATE\\_RECORD\\` step[[/record]]',
+ );
+ });
+
+ it('should escape square brackets and ]] inside labels', () => {
+ expect(
+ protectChatReferencesForMarkdown(
+ 'Open [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test] [test] ###[[/record]]',
+ ),
+ ).toBe(
+ 'Open [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:\\[test\\] \\]\\] \\[test\\] \\[test\\] \\#\\#\\#[[/record]]',
+ );
+ });
+
+ it('should leave colons in labels unchanged', () => {
+ expect(
+ protectChatReferencesForMarkdown(
+ 'Ping [[record:person:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Doe: Jane[[/record]]',
+ ),
+ ).toBe(
+ 'Ping [[record:person:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Doe: Jane[[/record]]',
+ );
+ });
+
+ it('should escape an object label without touching its name', () => {
+ expect(
+ protectChatReferencesForMarkdown(
+ 'Open [[object:partner:Partners (EMEA)[[/object]]',
+ ),
+ ).toBe('Open [[object:partner:Partners \\(EMEA\\)[[/object]]');
+ });
+
+ it('should escape a field label without touching its id', () => {
+ expect(
+ protectChatReferencesForMarkdown(
+ 'The [[field:33333333-3333-3333-3333-333333333333:Next step[[/field]] field',
+ ),
+ ).toBe(
+ 'The [[field:33333333-3333-3333-3333-333333333333:Next step[[/field]] field',
+ );
+ });
+
+ it('should escape a view label without touching its id', () => {
+ expect(
+ protectChatReferencesForMarkdown(
+ 'See [[view:44444444-4444-4444-4444-444444444444:Q1 - pipeline[[/view]]',
+ ),
+ ).toBe(
+ 'See [[view:44444444-4444-4444-4444-444444444444:Q1 \\- pipeline[[/view]]',
+ );
+ });
+
+ it('should rewrite every kind in a mixed string', () => {
+ expect(
+ protectChatReferencesForMarkdown(
+ 'The [[view:44444444-4444-4444-4444-444444444444:Pipeline[[/view]] view of [[object:partner:Partners[[/object]] groups [[record:person:11111111-1111-1111-1111-111111111111:Alice]] by [[field:33333333-3333-3333-3333-333333333333:Stage[[/field]]',
+ ),
+ ).toBe(
+ 'The [[view:44444444-4444-4444-4444-444444444444:Pipeline[[/view]] view of [[object:partner:Partners[[/object]] groups [[record:person:11111111-1111-1111-1111-111111111111:Alice[[/record]] by [[field:33333333-3333-3333-3333-333333333333:Stage[[/field]]',
+ );
+ });
+});
diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/protectRecordReferencesForMarkdown.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/protectRecordReferencesForMarkdown.test.ts
deleted file mode 100644
index f08435f3d1..0000000000
--- a/packages/twenty-front/src/modules/ai/utils/__tests__/protectRecordReferencesForMarkdown.test.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import { protectRecordReferencesForMarkdown } from '@/ai/utils/protectRecordReferencesForMarkdown';
-
-describe('protectRecordReferencesForMarkdown', () => {
- it('should leave plain text unchanged', () => {
- expect(
- protectRecordReferencesForMarkdown('Which company should we contact?'),
- ).toBe('Which company should we contact?');
- });
-
- it('should rewrite legacy refs to the tagged format', () => {
- expect(
- protectRecordReferencesForMarkdown(
- 'Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme]] next',
- ),
- ).toBe(
- 'Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme[[/record]] next',
- );
- });
-
- it('should escape backticks in labels and close with the tag', () => {
- expect(
- protectRecordReferencesForMarkdown(
- 'See [[record:workflow:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Workflow `UPDATE_RECORD` step[[/record]]',
- ),
- ).toBe(
- 'See [[record:workflow:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Workflow \\`UPDATE\\_RECORD\\` step[[/record]]',
- );
- });
-
- it('should escape square brackets and ]] inside labels', () => {
- expect(
- protectRecordReferencesForMarkdown(
- 'Open [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test] [test] ###[[/record]]',
- ),
- ).toBe(
- 'Open [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:\\[test\\] \\]\\] \\[test\\] \\[test\\] \\#\\#\\#[[/record]]',
- );
- });
-
- it('should leave colons in labels unchanged', () => {
- expect(
- protectRecordReferencesForMarkdown(
- 'Ping [[record:person:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Doe: Jane[[/record]]',
- ),
- ).toBe(
- 'Ping [[record:person:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Doe: Jane[[/record]]',
- );
- });
-});
diff --git a/packages/twenty-front/src/modules/ai/utils/escapeMarkdownForChatReference.ts b/packages/twenty-front/src/modules/ai/utils/escapeMarkdownForChatReference.ts
new file mode 100644
index 0000000000..104bbe3659
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/utils/escapeMarkdownForChatReference.ts
@@ -0,0 +1,2 @@
+export const escapeMarkdownForChatReference = (displayName: string): string =>
+ displayName.replace(/([\\`*_{}[\]()#+\-.!|~>])/g, '\\$1');
diff --git a/packages/twenty-front/src/modules/ai/utils/findChatReferenceClosing.ts b/packages/twenty-front/src/modules/ai/utils/findChatReferenceClosing.ts
new file mode 100644
index 0000000000..25e7ee4650
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/utils/findChatReferenceClosing.ts
@@ -0,0 +1,44 @@
+import { ANY_CHAT_REFERENCE_CLOSE_TAG_REGEX } from '@/ai/constants/AnyChatReferenceCloseTagRegex';
+import { type ChatReferenceClosing } from '@/ai/types/ChatReferenceClosing';
+import { type ChatReferenceKind } from '@/ai/types/ChatReferenceKind';
+import { getChatReferenceCloseTag } from '@/ai/utils/getChatReferenceCloseTag';
+import { isDefined } from 'twenty-shared/utils';
+
+const LEGACY_RECORD_REFERENCE_CLOSE_TAG = ']]';
+
+export const findChatReferenceClosing = ({
+ displayNameWindow,
+ kind,
+}: {
+ displayNameWindow: string;
+ kind: ChatReferenceKind;
+}): ChatReferenceClosing | undefined => {
+ const closeTag = getChatReferenceCloseTag(kind);
+ const closeTagIndex = displayNameWindow.indexOf(closeTag);
+
+ if (closeTagIndex !== -1) {
+ return { index: closeTagIndex, length: closeTag.length };
+ }
+
+ if (kind !== 'record') {
+ return undefined;
+ }
+
+ const foreignCloseTagMatch =
+ ANY_CHAT_REFERENCE_CLOSE_TAG_REGEX.exec(displayNameWindow);
+ const legacySearchSpace = isDefined(foreignCloseTagMatch)
+ ? displayNameWindow.slice(0, foreignCloseTagMatch.index)
+ : displayNameWindow;
+ const legacyCloseIndex = legacySearchSpace.lastIndexOf(
+ LEGACY_RECORD_REFERENCE_CLOSE_TAG,
+ );
+
+ if (legacyCloseIndex === -1) {
+ return undefined;
+ }
+
+ return {
+ index: legacyCloseIndex,
+ length: LEGACY_RECORD_REFERENCE_CLOSE_TAG.length,
+ };
+};
diff --git a/packages/twenty-front/src/modules/ai/utils/findChatReferences.ts b/packages/twenty-front/src/modules/ai/utils/findChatReferences.ts
new file mode 100644
index 0000000000..7df580ce4c
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/utils/findChatReferences.ts
@@ -0,0 +1,48 @@
+import { CHAT_REFERENCE_START_REGEX } from '@/ai/constants/ChatReferenceStartRegex';
+import { type ChatReferenceMatch } from '@/ai/types/ChatReferenceMatch';
+import { type ChatReferenceStart } from '@/ai/types/ChatReferenceStart';
+import { findChatReferenceClosing } from '@/ai/utils/findChatReferenceClosing';
+import { getChatReferenceStartFromMatch } from '@/ai/utils/getChatReferenceStartFromMatch';
+import { isDefined } from 'twenty-shared/utils';
+
+export const findChatReferences = (text: string): ChatReferenceMatch[] => {
+ const starts: ChatReferenceStart[] = [];
+
+ CHAT_REFERENCE_START_REGEX.lastIndex = 0;
+
+ let startMatch;
+
+ while ((startMatch = CHAT_REFERENCE_START_REGEX.exec(text)) !== null) {
+ starts.push(getChatReferenceStartFromMatch(startMatch));
+ }
+
+ return starts.flatMap((start, startIndex) => {
+ const displayNameStart = start.index + start.prefixLength;
+ const windowEnd =
+ startIndex + 1 < starts.length
+ ? starts[startIndex + 1].index
+ : text.length;
+ const displayNameWindow = text.slice(displayNameStart, windowEnd);
+
+ const closing = findChatReferenceClosing({
+ displayNameWindow,
+ kind: start.identity.kind,
+ });
+
+ if (!isDefined(closing)) {
+ return [];
+ }
+
+ return [
+ {
+ ...start.identity,
+ fullMatch: text.slice(
+ start.index,
+ displayNameStart + closing.index + closing.length,
+ ),
+ index: start.index,
+ displayName: displayNameWindow.slice(0, closing.index),
+ },
+ ];
+ });
+};
diff --git a/packages/twenty-front/src/modules/ai/utils/findRecordReferences.ts b/packages/twenty-front/src/modules/ai/utils/findRecordReferences.ts
deleted file mode 100644
index 7465938e38..0000000000
--- a/packages/twenty-front/src/modules/ai/utils/findRecordReferences.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import { RECORD_REFERENCE_CLOSE_TAG } from '@/ai/constants/RecordReferenceCloseTag';
-import { type RecordReferenceMatch } from '@/ai/types/RecordReferenceMatch';
-
-const RECORD_REFERENCE_START_REGEX =
- /\[\[(?:record:)?([a-zA-Z]+):([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}):/g;
-
-export const findRecordReferences = (text: string): RecordReferenceMatch[] => {
- const starts: Array<{
- index: number;
- prefixLength: number;
- objectNameSingular: string;
- recordId: string;
- }> = [];
-
- RECORD_REFERENCE_START_REGEX.lastIndex = 0;
-
- let startMatch;
-
- while ((startMatch = RECORD_REFERENCE_START_REGEX.exec(text)) !== null) {
- starts.push({
- index: startMatch.index,
- prefixLength: startMatch[0].length,
- objectNameSingular: startMatch[1],
- recordId: startMatch[2],
- });
- }
-
- return starts.flatMap((start, startIndex) => {
- const displayNameStart = start.index + start.prefixLength;
- const windowEnd =
- startIndex + 1 < starts.length
- ? starts[startIndex + 1].index
- : text.length;
- const window = text.slice(displayNameStart, windowEnd);
-
- const closeTagIndexInWindow = window.indexOf(RECORD_REFERENCE_CLOSE_TAG);
-
- const closingIndexInWindow =
- closeTagIndexInWindow !== -1
- ? closeTagIndexInWindow
- : window.lastIndexOf(']]');
-
- if (closingIndexInWindow === -1) {
- return [];
- }
-
- const closeLength =
- closeTagIndexInWindow !== -1 ? RECORD_REFERENCE_CLOSE_TAG.length : 2;
- const displayName = window.slice(0, closingIndexInWindow);
- const fullMatchEnd = displayNameStart + closingIndexInWindow + closeLength;
-
- return [
- {
- fullMatch: text.slice(start.index, fullMatchEnd),
- index: start.index,
- objectNameSingular: start.objectNameSingular,
- recordId: start.recordId,
- displayName,
- },
- ];
- });
-};
diff --git a/packages/twenty-front/src/modules/ai/utils/formatChatReference.ts b/packages/twenty-front/src/modules/ai/utils/formatChatReference.ts
new file mode 100644
index 0000000000..272f69d813
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/utils/formatChatReference.ts
@@ -0,0 +1,8 @@
+import { type ChatReferenceIdentity } from '@/ai/types/ChatReferenceIdentity';
+import { getChatReferenceCloseTag } from '@/ai/utils/getChatReferenceCloseTag';
+import { getChatReferenceIdentitySegment } from '@/ai/utils/getChatReferenceIdentitySegment';
+
+export const formatChatReference = (
+ reference: ChatReferenceIdentity & { displayName: string },
+): string =>
+ `[[${reference.kind}:${getChatReferenceIdentitySegment(reference)}:${reference.displayName}${getChatReferenceCloseTag(reference.kind)}`;
diff --git a/packages/twenty-front/src/modules/ai/utils/formatRecordReference.ts b/packages/twenty-front/src/modules/ai/utils/formatRecordReference.ts
index 8ddcb49850..457f37e00f 100644
--- a/packages/twenty-front/src/modules/ai/utils/formatRecordReference.ts
+++ b/packages/twenty-front/src/modules/ai/utils/formatRecordReference.ts
@@ -1,4 +1,4 @@
-import { RECORD_REFERENCE_CLOSE_TAG } from '@/ai/constants/RecordReferenceCloseTag';
+import { formatChatReference } from '@/ai/utils/formatChatReference';
export const formatRecordReference = ({
objectNameSingular,
@@ -9,4 +9,9 @@ export const formatRecordReference = ({
recordId: string;
displayName: string;
}): string =>
- `[[record:${objectNameSingular}:${recordId}:${displayName}${RECORD_REFERENCE_CLOSE_TAG}`;
+ formatChatReference({
+ kind: 'record',
+ objectNameSingular,
+ recordId,
+ displayName,
+ });
diff --git a/packages/twenty-front/src/modules/ai/utils/getChatReferenceCloseTag.ts b/packages/twenty-front/src/modules/ai/utils/getChatReferenceCloseTag.ts
new file mode 100644
index 0000000000..3eea347fac
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/utils/getChatReferenceCloseTag.ts
@@ -0,0 +1,4 @@
+import { type ChatReferenceKind } from '@/ai/types/ChatReferenceKind';
+
+export const getChatReferenceCloseTag = (kind: ChatReferenceKind): string =>
+ `[[/${kind}]]`;
diff --git a/packages/twenty-front/src/modules/ai/utils/getChatReferenceIdentitySegment.ts b/packages/twenty-front/src/modules/ai/utils/getChatReferenceIdentitySegment.ts
new file mode 100644
index 0000000000..f36c18eee5
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/utils/getChatReferenceIdentitySegment.ts
@@ -0,0 +1,19 @@
+import { type ChatReferenceIdentity } from '@/ai/types/ChatReferenceIdentity';
+import { assertUnreachable } from 'twenty-shared/utils';
+
+export const getChatReferenceIdentitySegment = (
+ identity: ChatReferenceIdentity,
+): string => {
+ switch (identity.kind) {
+ case 'record':
+ return `${identity.objectNameSingular}:${identity.recordId}`;
+ case 'object':
+ return identity.objectNameSingular;
+ case 'field':
+ return identity.fieldMetadataItemId;
+ case 'view':
+ return identity.viewId;
+ default:
+ return assertUnreachable(identity);
+ }
+};
diff --git a/packages/twenty-front/src/modules/ai/utils/getChatReferenceStartFromMatch.ts b/packages/twenty-front/src/modules/ai/utils/getChatReferenceStartFromMatch.ts
new file mode 100644
index 0000000000..a9266827aa
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/utils/getChatReferenceStartFromMatch.ts
@@ -0,0 +1,37 @@
+import { type ChatReferenceStart } from '@/ai/types/ChatReferenceStart';
+import { isDefined } from 'twenty-shared/utils';
+
+export const getChatReferenceStartFromMatch = (
+ match: RegExpExecArray,
+): ChatReferenceStart => {
+ const {
+ objectNameSingular,
+ fieldMetadataItemId,
+ viewId,
+ recordObjectNameSingular,
+ recordId,
+ } = match.groups ?? {};
+
+ const position = { index: match.index, prefixLength: match[0].length };
+
+ if (isDefined(objectNameSingular)) {
+ return { ...position, identity: { kind: 'object', objectNameSingular } };
+ }
+
+ if (isDefined(fieldMetadataItemId)) {
+ return { ...position, identity: { kind: 'field', fieldMetadataItemId } };
+ }
+
+ if (isDefined(viewId)) {
+ return { ...position, identity: { kind: 'view', viewId } };
+ }
+
+ return {
+ ...position,
+ identity: {
+ kind: 'record',
+ objectNameSingular: recordObjectNameSingular,
+ recordId,
+ },
+ };
+};
diff --git a/packages/twenty-front/src/modules/ai/utils/protectChatReferencesForMarkdown.ts b/packages/twenty-front/src/modules/ai/utils/protectChatReferencesForMarkdown.ts
new file mode 100644
index 0000000000..b6cced8e3b
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/utils/protectChatReferencesForMarkdown.ts
@@ -0,0 +1,30 @@
+import { escapeMarkdownForChatReference } from '@/ai/utils/escapeMarkdownForChatReference';
+import { findChatReferences } from '@/ai/utils/findChatReferences';
+import { formatChatReference } from '@/ai/utils/formatChatReference';
+
+export const protectChatReferencesForMarkdown = (text: string): string => {
+ const references = findChatReferences(text);
+
+ if (references.length === 0) {
+ return text;
+ }
+
+ const parts: string[] = [];
+ let lastIndex = 0;
+
+ for (const reference of references) {
+ parts.push(text.slice(lastIndex, reference.index));
+ parts.push(
+ formatChatReference({
+ ...reference,
+ displayName: escapeMarkdownForChatReference(reference.displayName),
+ }),
+ );
+
+ lastIndex = reference.index + reference.fullMatch.length;
+ }
+
+ parts.push(text.slice(lastIndex));
+
+ return parts.join('');
+};
diff --git a/packages/twenty-front/src/modules/ai/utils/protectRecordReferencesForMarkdown.ts b/packages/twenty-front/src/modules/ai/utils/protectRecordReferencesForMarkdown.ts
deleted file mode 100644
index d9cbcabfca..0000000000
--- a/packages/twenty-front/src/modules/ai/utils/protectRecordReferencesForMarkdown.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { findRecordReferences } from '@/ai/utils/findRecordReferences';
-import { formatRecordReference } from '@/ai/utils/formatRecordReference';
-
-const escapeMarkdown = (text: string): string =>
- text.replace(/([\\`*_{}[\]()#+\-.!|~>])/g, '\\$1');
-
-export const protectRecordReferencesForMarkdown = (text: string): string => {
- const references = findRecordReferences(text);
- let result = text;
-
- for (let index = references.length - 1; index >= 0; index--) {
- const reference = references[index];
- const replacement = formatRecordReference({
- objectNameSingular: reference.objectNameSingular,
- recordId: reference.recordId,
- displayName: escapeMarkdown(reference.displayName),
- });
-
- result =
- result.slice(0, reference.index) +
- replacement +
- result.slice(reference.index + reference.fullMatch.length);
- }
-
- return result;
-};
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const.ts
index 7e3d37994c..09f7145609 100644
--- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const.ts
@@ -85,5 +85,24 @@ Record References - IMPORTANT:
- The recordId MUST be a real UUID (like "abc12345-1234-5678-abcd-123456789012")
- DO NOT create record references before calling the tool
- DO NOT use placeholder IDs like "rec-snowflake" or "rec-person-1"
-- If a tool hasn't been called yet, don't reference records that don't exist`,
+- If a tool hasn't been called yet, don't reference records that don't exist
+
+Metadata References:
+Whenever you name an object, a field, or a view in your prose, write it as a metadata reference instead of plain text. Each one becomes a chip the user can click.
+
+- Object: [[object:objectNameSingular:displayName[[/object]]
+ - Example: [[object:company:Companies[[/object]]
+ - Use the \`nameSingular\` from \`get_object_metadata\` or \`create_object_metadata\` (NOT the label, NOT the plural, NOT the id)
+ - This is the only reference you may write for something that does not exist yet: when you propose creating an object, reference it with the \`nameSingular\` you intend to use and it renders as a chip without a link
+- Field: [[field:fieldMetadataId:displayName[[/field]]
+ - Example: [[field:abc12345-1234-5678-abcd-123456789012:Annual Recurring Revenue[[/field]]
+ - Use the \`id\` returned by \`get_field_metadata\`, \`create_field_metadata\`, or the \`fields\` array of \`get_object_metadata\`
+- View: [[view:viewId:displayName[[/view]]
+ - Example: [[view:abc12345-1234-5678-abcd-123456789012:All Companies[[/view]]
+ - Use the \`id\` returned by \`get_views\`, \`create_view\`, or \`upsert_complete_view\`
+
+- The displayName is what the user reads, so use the human-readable label ("Annual Recurring Revenue"), not the technical name
+- Field and view ids MUST be real UUIDs copied from a tool response - never invent one, and never reference a field or view before the tool that returns it has run
+- Always close a reference with its own tag: \`[[/object]]\`, \`[[/field]]\`, \`[[/view]]\`. A mismatched closing tag drops the chip
+- Use metadata references only in paragraphs, lists, or markdown tables (\`| ... |\`); never in headings, code, links, or raw HTML`,
};