From 6d38b1452012f4e395b8a4297aaa00e8c0f48b5c Mon Sep 17 00:00:00 2001
From: Etienne <45695613+etiennejouan@users.noreply.github.com>
Date: Tue, 21 Jul 2026 15:07:32 +0200
Subject: [PATCH] fix(ai-chat) - fix record chips in AI ask-questions card
(#23106)
## Summary
- Ask-questions cards rendered question text and option labels as plain
strings, so `[[record:...]]` showed up raw instead of as chips
- Extracted `TextWithRecordLinks` from `LazyMarkdownRenderer` and reuse
it in `AiChatQuestionCard` for question text and option labels
- Added unit coverage for plain text, single, and multiple record
references
## Test plan
- [ ] Open an AI chat ask-questions card whose question/options include
`[[record:...]]` mentions
- [ ] Confirm mentions render as record chips (not raw markup)
- [ ] Confirm normal assistant text replies still chip mentions as
before
- [ ] Run `npx jest
packages/twenty-front/src/modules/ai/components/__tests__/TextWithRecordLinks.test.tsx
--config=packages/twenty-front/jest.config.mjs`
fixes:
https://discord.com/channels/1130383047699738754/1526887613867360347
---
.../ai/components/AiChatQuestionCard.tsx | 9 ++-
.../ai/components/LazyMarkdownRenderer.tsx | 42 +----------
.../ai/components/TextWithRecordLinks.tsx | 47 +++++++++++++
.../__tests__/TextWithRecordLinks.test.tsx | 69 +++++++++++++++++++
4 files changed, 124 insertions(+), 43 deletions(-)
create mode 100644 packages/twenty-front/src/modules/ai/components/TextWithRecordLinks.tsx
create mode 100644 packages/twenty-front/src/modules/ai/components/__tests__/TextWithRecordLinks.test.tsx
diff --git a/packages/twenty-front/src/modules/ai/components/AiChatQuestionCard.tsx b/packages/twenty-front/src/modules/ai/components/AiChatQuestionCard.tsx
index b181c10f36..4d1a592aee 100644
--- a/packages/twenty-front/src/modules/ai/components/AiChatQuestionCard.tsx
+++ b/packages/twenty-front/src/modules/ai/components/AiChatQuestionCard.tsx
@@ -29,6 +29,7 @@ import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { AgentChatFileUploadButton } from '@/ai/components/internal/AgentChatFileUploadButton';
import { AiChatContextUsageButton } from '@/ai/components/internal/AiChatContextUsageButton';
+import { TextWithRecordLinks } from '@/ai/components/TextWithRecordLinks';
import { useAgentChatModelId } from '@/ai/hooks/useAgentChatModelId';
import { useAiModelOptions } from '@/ai/hooks/useAiModelOptions';
import { useSubmitQuestionAnswer } from '@/ai/hooks/useSubmitQuestionAnswer';
@@ -345,7 +346,9 @@ export const AiChatQuestionCard = ({
- {currentQuestion.question}
+
+
+
{hasMultipleQuestions && (
- {option.label}
+
+
+
{option.isRecommended === true && (
ยท {t`Recommended`}
)}
diff --git a/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx b/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx
index 20c0a4373f..c4b87dd8e9 100644
--- a/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx
+++ b/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx
@@ -1,9 +1,4 @@
import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
-import {
- parseRecordReference,
- RECORD_REFERENCE_REGEX,
- RecordLink,
-} from '@/ai/components/RecordLink';
import {
StyledMarkdownContainer,
StyledParagraph,
@@ -11,6 +6,7 @@ import {
StyledTableScrollContainer,
} from '@/ai/components/LazyMarkdownRendererStyledComponents';
import { MarkdownCodeBlock } from '@/ai/components/MarkdownCodeBlock';
+import { TextWithRecordLinks } from '@/ai/components/TextWithRecordLinks';
import { marked } from 'marked';
import {
cloneElement,
@@ -25,42 +21,6 @@ import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { getSafeUrl, isDefined } from 'twenty-shared/utils';
import { ThemeContext } from 'twenty-ui/theme-constants';
-const TextWithRecordLinks = ({ text }: { text: string }) => {
- const parts: React.ReactNode[] = [];
- let lastIndex = 0;
-
- RECORD_REFERENCE_REGEX.lastIndex = 0;
-
- let match;
-
- while ((match = RECORD_REFERENCE_REGEX.exec(text)) !== null) {
- if (match.index > lastIndex) {
- parts.push(text.slice(lastIndex, match.index));
- }
-
- const parsed = parseRecordReference(match[0]);
-
- if (isDefined(parsed)) {
- parts.push(
- ,
- );
- }
-
- lastIndex = match.index + match[0].length;
- }
-
- if (lastIndex < text.length) {
- parts.push(text.slice(lastIndex));
- }
-
- return <>{parts}>;
-};
-
const processChildrenForRecordLinks = (
children: React.ReactNode,
): React.ReactNode => {
diff --git a/packages/twenty-front/src/modules/ai/components/TextWithRecordLinks.tsx b/packages/twenty-front/src/modules/ai/components/TextWithRecordLinks.tsx
new file mode 100644
index 0000000000..9deb84ee00
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/components/TextWithRecordLinks.tsx
@@ -0,0 +1,47 @@
+import {
+ parseRecordReference,
+ RECORD_REFERENCE_REGEX,
+ RecordLink,
+} from '@/ai/components/RecordLink';
+import { type ReactNode } from 'react';
+import { isDefined } from 'twenty-shared/utils';
+
+type TextWithRecordLinksProps = {
+ text: string;
+};
+
+export const TextWithRecordLinks = ({ text }: TextWithRecordLinksProps) => {
+ const parts: ReactNode[] = [];
+ let lastIndex = 0;
+
+ RECORD_REFERENCE_REGEX.lastIndex = 0;
+
+ let match;
+
+ while ((match = RECORD_REFERENCE_REGEX.exec(text)) !== null) {
+ if (match.index > lastIndex) {
+ parts.push(text.slice(lastIndex, match.index));
+ }
+
+ const parsed = parseRecordReference(match[0]);
+
+ if (isDefined(parsed)) {
+ parts.push(
+ ,
+ );
+ }
+
+ lastIndex = match.index + match[0].length;
+ }
+
+ if (lastIndex < text.length) {
+ parts.push(text.slice(lastIndex));
+ }
+
+ return <>{parts}>;
+};
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
new file mode 100644
index 0000000000..3789fbbe72
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/components/__tests__/TextWithRecordLinks.test.tsx
@@ -0,0 +1,69 @@
+import { render, screen } from '@testing-library/react';
+
+import { TextWithRecordLinks } from '@/ai/components/TextWithRecordLinks';
+
+jest.mock('@/ai/components/RecordLink', () => ({
+ RECORD_REFERENCE_REGEX:
+ /\[\[(?:record:)?([a-zA-Z]+):([a-f0-9-]+):([^\]]+)\]\]/g,
+ parseRecordReference: (match: string) => {
+ const regex = /\[\[(?:record:)?([a-zA-Z]+):([a-f0-9-]+):([^\]]+)\]\]/;
+ const result = regex.exec(match);
+
+ if (!result) {
+ return null;
+ }
+
+ return {
+ objectNameSingular: result[1],
+ recordId: result[2],
+ displayName: result[3],
+ };
+ },
+ 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 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 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();
+ });
+});