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(); + }); +});