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

<img width="532" height="242" alt="Screenshot 2026-07-21 at 14 37 43"
src="https://github.com/user-attachments/assets/a4bbd386-7757-4f09-a74d-c7eb3d0f74b9"
/>


## 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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23106?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Etienne
2026-07-21 15:07:32 +02:00
committed by GitHub
parent a0e8d48656
commit 6d38b14520
4 changed files with 124 additions and 43 deletions
@@ -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 = ({
<StyledCard>
<StyledQuestionSection>
<StyledQuestionHeaderRow>
<StyledQuestionText>{currentQuestion.question}</StyledQuestionText>
<StyledQuestionText>
<TextWithRecordLinks text={currentQuestion.question} />
</StyledQuestionText>
{hasMultipleQuestions && (
<StyledPager>
<LightIconButton
@@ -410,7 +413,9 @@ export const AiChatQuestionCard = ({
size={theme.icon.size.sm}
color={themeCssVariables.font.color.tertiary}
/>
<StyledOptionLabel>{option.label}</StyledOptionLabel>
<StyledOptionLabel>
<TextWithRecordLinks text={option.label} />
</StyledOptionLabel>
{option.isRecommended === true && (
<StyledRecommended>· {t`Recommended`}</StyledRecommended>
)}
@@ -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(
<RecordLink
key={match.index}
objectNameSingular={parsed.objectNameSingular}
recordId={parsed.recordId}
displayName={parsed.displayName}
/>,
);
}
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return <>{parts}</>;
};
const processChildrenForRecordLinks = (
children: React.ReactNode,
): React.ReactNode => {
@@ -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(
<RecordLink
key={match.index}
objectNameSingular={parsed.objectNameSingular}
recordId={parsed.recordId}
displayName={parsed.displayName}
/>,
);
}
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return <>{parts}</>;
};
@@ -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;
}) => (
<a data-testid="record-link" href={`/${objectNameSingular}/${recordId}`}>
{displayName}
</a>
),
}));
describe('TextWithRecordLinks', () => {
it('should render plain text without record references as-is', () => {
render(<TextWithRecordLinks text="Which company should we contact?" />);
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(
<TextWithRecordLinks text="Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme]] next" />,
);
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(
<TextWithRecordLinks text="Merge [[person:11111111-1111-1111-1111-111111111111:Alice]] into [[person:22222222-2222-2222-2222-222222222222:Bob]]" />,
);
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();
});
});