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:
@@ -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}</>;
|
||||
};
|
||||
+69
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user