Display object, field and view links as chips in the AI chat (#23573)

<img width="3840" height="1876" alt="CleanShot 2026-07-30 at 15 37
46@2x"
src="https://github.com/user-attachments/assets/9fe178b9-c2fa-4b05-9c9d-0cdc80270b67"
/>



https://github.com/user-attachments/assets/34c4e486-c462-4300-ae98-da99f614f069



The AI chat already renders record chips from a `[[record:...]]` marker
the model writes in its prose, but naming an object, field or view
produced plain text. This adds three sibling markers so those render as
chips too, as in the [Figma
design](https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=104416-116261).

- `[[object:<nameSingular>:<label>[[/object]]` links to the record index
page. It is name-keyed rather than id-keyed so an object the assistant
only *proposes* to create still renders as a chip, just without a link.
- `[[field:<id>:<label>[[/field]]` links to the field's settings page,
gated on the `DATA_MODEL` permission.
- `[[view:<id>:<label>[[/view]]` links to the object index page for that
view.

Field and view ids must come from a tool, so an unresolvable one falls
back to plain text rather than a chip that goes nowhere.

The record-only parser becomes one scan over all four kinds. Alternative
order is load-bearing: `[[view:<uuid>:` is shaped exactly like the
legacy prefix-less record marker, so metadata kinds are tried first and
only records keep the legacy `]]` terminator.

Server side is prompt-only. The metadata and view tools return bare
objects rather than `ToolOutput`, so there is nowhere to hang a
structured reference array without wrapping every factory, and the names
and ids the markers need are already in those results verbatim.

Also fixes a pre-existing issue in `LazyMarkdownRenderer`: its
`components` map was rebuilt on every render, and react-markdown uses
each entry as the JSX element type, so every node remounted on every
streamed chunk. Harmless before, expensive once the model is told to
chip every metadata name it writes.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23573?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:
Raphaël Bosi
2026-07-30 17:08:21 +02:00
committed by GitHub
parent a9d996ff7e
commit 5848c9bd30
43 changed files with 1456 additions and 436 deletions
@@ -29,7 +29,7 @@ import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { AgentChatFileUploadButton } from '@/ai/components/internal/AgentChatFileUploadButton'; import { AgentChatFileUploadButton } from '@/ai/components/internal/AgentChatFileUploadButton';
import { AiChatContextUsageButton } from '@/ai/components/internal/AiChatContextUsageButton'; import { AiChatContextUsageButton } from '@/ai/components/internal/AiChatContextUsageButton';
import { TextWithRecordLinks } from '@/ai/components/TextWithRecordLinks'; import { TextWithChatReferences } from '@/ai/components/TextWithChatReferences';
import { useAgentChatModelId } from '@/ai/hooks/useAgentChatModelId'; import { useAgentChatModelId } from '@/ai/hooks/useAgentChatModelId';
import { useAiModelOptions } from '@/ai/hooks/useAiModelOptions'; import { useAiModelOptions } from '@/ai/hooks/useAiModelOptions';
import { useSubmitQuestionAnswer } from '@/ai/hooks/useSubmitQuestionAnswer'; import { useSubmitQuestionAnswer } from '@/ai/hooks/useSubmitQuestionAnswer';
@@ -347,7 +347,7 @@ export const AiChatQuestionCard = ({
<StyledQuestionSection> <StyledQuestionSection>
<StyledQuestionHeaderRow> <StyledQuestionHeaderRow>
<StyledQuestionText> <StyledQuestionText>
<TextWithRecordLinks text={currentQuestion.question} /> <TextWithChatReferences text={currentQuestion.question} />
</StyledQuestionText> </StyledQuestionText>
{hasMultipleQuestions && ( {hasMultipleQuestions && (
<StyledPager> <StyledPager>
@@ -414,7 +414,7 @@ export const AiChatQuestionCard = ({
color={themeCssVariables.font.color.tertiary} color={themeCssVariables.font.color.tertiary}
/> />
<StyledOptionLabel> <StyledOptionLabel>
<TextWithRecordLinks text={option.label} /> <TextWithChatReferences text={option.label} />
</StyledOptionLabel> </StyledOptionLabel>
{option.isRecommended === true && ( {option.isRecommended === true && (
<StyledRecommended>· {t`Recommended`}</StyledRecommended> <StyledRecommended>· {t`Recommended`}</StyledRecommended>
@@ -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 (
<RecordLink
objectNameSingular={reference.objectNameSingular}
recordId={reference.recordId}
displayName={reference.displayName}
/>
);
case 'object':
return (
<ObjectMetadataLink
objectNameSingular={reference.objectNameSingular}
displayName={reference.displayName}
/>
);
case 'field':
return (
<FieldMetadataLink
fieldMetadataItemId={reference.fieldMetadataItemId}
displayName={reference.displayName}
/>
);
case 'view':
return (
<ViewLink
viewId={reference.viewId}
displayName={reference.displayName}
/>
);
default:
return assertUnreachable(reference);
}
};
@@ -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 (
<Chip
label={displayName}
emptyLabel={t`Untitled`}
variant={ChipVariant.Highlighted}
leftComponent={leftComponent}
/>
);
}
return (
<LinkChip
label={displayName}
emptyLabel={t`Untitled`}
to={to}
variant={ChipVariant.Highlighted}
leftComponent={leftComponent}
/>
);
};
@@ -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 <span>{displayName}</span>;
}
const Icon = getIcon(foundFieldMetadataItem.icon);
return (
<ChatReferenceChipDisplay
displayName={displayName}
to={
hasDataModelPermission
? getSettingsPath(SettingsPath.ObjectFieldEdit, {
objectNamePlural: foundObjectMetadataItem.namePlural,
fieldName: foundFieldMetadataItem.name,
})
: undefined
}
leftComponent={
<Icon size={theme.icon.size.sm} stroke={theme.icon.stroke.sm} />
}
/>
);
};
@@ -6,8 +6,8 @@ import {
StyledTableScrollContainer, StyledTableScrollContainer,
} from '@/ai/components/LazyMarkdownRendererStyledComponents'; } from '@/ai/components/LazyMarkdownRendererStyledComponents';
import { MarkdownCodeBlock } from '@/ai/components/MarkdownCodeBlock'; import { MarkdownCodeBlock } from '@/ai/components/MarkdownCodeBlock';
import { TextWithRecordLinks } from '@/ai/components/TextWithRecordLinks'; import { TextWithChatReferences } from '@/ai/components/TextWithChatReferences';
import { protectRecordReferencesForMarkdown } from '@/ai/utils/protectRecordReferencesForMarkdown'; import { protectChatReferencesForMarkdown } from '@/ai/utils/protectChatReferencesForMarkdown';
import { marked } from 'marked'; import { marked } from 'marked';
import { import {
cloneElement, cloneElement,
@@ -22,16 +22,16 @@ import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { getSafeUrl, isDefined } from 'twenty-shared/utils'; import { getSafeUrl, isDefined } from 'twenty-shared/utils';
import { ThemeContext } from 'twenty-ui/theme-constants'; import { ThemeContext } from 'twenty-ui/theme-constants';
const processChildrenForRecordLinks = ( const processChildrenForChatReferences = (
children: React.ReactNode, children: React.ReactNode,
): React.ReactNode => { ): React.ReactNode => {
if (typeof children === 'string') { if (typeof children === 'string') {
return <TextWithRecordLinks text={children} />; return <TextWithChatReferences text={children} />;
} }
if (Array.isArray(children)) { if (Array.isArray(children)) {
return children.map((child, index) => ( return children.map((child, index) => (
<span key={index}>{processChildrenForRecordLinks(child)}</span> <span key={index}>{processChildrenForChatReferences(child)}</span>
)); ));
} }
@@ -40,7 +40,7 @@ const processChildrenForRecordLinks = (
if (isDefined(childProps.children)) { if (isDefined(childProps.children)) {
return cloneElement(children, { return cloneElement(children, {
children: processChildrenForRecordLinks(childProps.children), children: processChildrenForChatReferences(childProps.children),
}); });
} }
} }
@@ -48,85 +48,88 @@ const processChildrenForRecordLinks = (
return children; 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 }) => (
<StyledTableScrollContainer>
<table>{children}</table>
</StyledTableScrollContainer>
),
p: ({ children }: { children?: React.ReactNode }) => (
<StyledParagraph>
{processChildrenForChatReferences(children)}
</StyledParagraph>
),
td: ({ children }: { children?: React.ReactNode }) => (
<td>{processChildrenForChatReferences(children)}</td>
),
th: ({ children }: { children?: React.ReactNode }) => (
<th>{processChildrenForChatReferences(children)}</th>
),
li: ({ children }: { children?: React.ReactNode }) => (
<li>{processChildrenForChatReferences(children)}</li>
),
h1: ({ children }: { children?: React.ReactNode }) => (
<h1>{processChildrenForChatReferences(children)}</h1>
),
h2: ({ children }: { children?: React.ReactNode }) => (
<h2>{processChildrenForChatReferences(children)}</h2>
),
h3: ({ children }: { children?: React.ReactNode }) => (
<h3>{processChildrenForChatReferences(children)}</h3>
),
h4: ({ children }: { children?: React.ReactNode }) => (
<h4>{processChildrenForChatReferences(children)}</h4>
),
h5: ({ children }: { children?: React.ReactNode }) => (
<h5>{processChildrenForChatReferences(children)}</h5>
),
h6: ({ children }: { children?: React.ReactNode }) => (
<h6>{processChildrenForChatReferences(children)}</h6>
),
a: ({
children,
href,
title,
}: {
children?: React.ReactNode;
href?: string;
title?: string;
}) => (
<a
className="markdown-link"
href={getSafeUrl(href)}
title={title}
target="_blank"
rel="noopener noreferrer"
>
{processChildrenForChatReferences(children)}
</a>
),
code: ({
className,
children,
}: {
className?: string;
children?: React.ReactNode;
}) => <code className={className}>{children}</code>,
pre: ({ children }: { children?: React.ReactNode }) => (
<MarkdownCodeBlock>{children}</MarkdownCodeBlock>
),
};
const MarkdownRenderer = lazy(async () => { const MarkdownRenderer = lazy(async () => {
const [{ default: Markdown }, { default: remarkGfm }] = await Promise.all([ const [{ default: Markdown }, { default: remarkGfm }] = await Promise.all([
import('react-markdown'), import('react-markdown'),
import('remark-gfm'), import('remark-gfm'),
]); ]);
const remarkPlugins = [remarkGfm];
return { return {
default: ({ default: ({ children }: { children: string }) => (
children, <Markdown remarkPlugins={remarkPlugins} components={MARKDOWN_COMPONENTS}>
TableScrollContainer,
ParagraphComponent,
}: {
children: string;
TableScrollContainer: React.ComponentType<{ children: React.ReactNode }>;
ParagraphComponent: React.ComponentType<{ children: React.ReactNode }>;
}) => (
<Markdown
remarkPlugins={[remarkGfm]}
components={{
table: ({ children }) => (
<TableScrollContainer>
<table>{children}</table>
</TableScrollContainer>
),
p: ({ children }) => (
<ParagraphComponent>
{processChildrenForRecordLinks(children)}
</ParagraphComponent>
),
td: ({ children }) => (
<td>{processChildrenForRecordLinks(children)}</td>
),
th: ({ children }) => (
<th>{processChildrenForRecordLinks(children)}</th>
),
li: ({ children }) => (
<li>{processChildrenForRecordLinks(children)}</li>
),
h1: ({ children }) => (
<h1>{processChildrenForRecordLinks(children)}</h1>
),
h2: ({ children }) => (
<h2>{processChildrenForRecordLinks(children)}</h2>
),
h3: ({ children }) => (
<h3>{processChildrenForRecordLinks(children)}</h3>
),
h4: ({ children }) => (
<h4>{processChildrenForRecordLinks(children)}</h4>
),
h5: ({ children }) => (
<h5>{processChildrenForRecordLinks(children)}</h5>
),
h6: ({ children }) => (
<h6>{processChildrenForRecordLinks(children)}</h6>
),
a: ({ children, href, title, node: _node }) => (
<a
className="markdown-link"
href={getSafeUrl(href)}
title={title}
target="_blank"
rel="noopener noreferrer"
>
{processChildrenForRecordLinks(children)}
</a>
),
code: ({
className,
children,
}: {
className?: string;
children?: React.ReactNode;
}) => <code className={className}>{children}</code>,
pre: ({ children }) => (
<MarkdownCodeBlock>{children}</MarkdownCodeBlock>
),
}}
>
{children} {children}
</Markdown> </Markdown>
), ),
@@ -169,19 +172,14 @@ const LoadingSkeleton = () => {
const MemoizedMarkdownBlock = memo( const MemoizedMarkdownBlock = memo(
({ blockText }: { blockText: string }) => ( ({ blockText }: { blockText: string }) => (
<MarkdownRenderer <MarkdownRenderer>{blockText}</MarkdownRenderer>
TableScrollContainer={StyledTableScrollContainer}
ParagraphComponent={StyledParagraph}
>
{blockText}
</MarkdownRenderer>
), ),
(previousProps, nextProps) => previousProps.blockText === nextProps.blockText, (previousProps, nextProps) => previousProps.blockText === nextProps.blockText,
); );
export const LazyMarkdownRenderer = ({ text }: { text: string }) => { export const LazyMarkdownRenderer = ({ text }: { text: string }) => {
const protectedText = useMemo( const protectedText = useMemo(
() => protectRecordReferencesForMarkdown(text), () => protectChatReferencesForMarkdown(text),
[text], [text],
); );
@@ -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 (
<ChatReferenceChipDisplay
displayName={displayName}
to={
isDefined(objectMetadataItem)
? getAppPath(AppPath.RecordIndexPage, {
objectNamePlural: objectMetadataItem.namePlural,
})
: undefined
}
leftComponent={
<ObjectMetadataIcon
objectMetadataItem={
objectMetadataItem ?? {
icon: PROPOSED_OBJECT_METADATA_ICON,
nameSingular: objectNameSingular,
color: null,
isSystem: false,
}
}
size={theme.icon.size.sm}
stroke={theme.icon.stroke.sm}
/>
}
/>
);
};
@@ -1,9 +1,9 @@
import { ChatReferenceChipDisplay } from '@/ai/components/ChatReferenceChipDisplay';
import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector'; import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector';
import { getLinkToShowPage } from '@/object-metadata/utils/getLinkToShowPage'; import { getLinkToShowPage } from '@/object-metadata/utils/getLinkToShowPage';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue'; import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards'; import { isNonEmptyString } from '@sniptt/guards';
import { AvatarOrIcon, ChipVariant, LinkChip } from 'twenty-ui/data-display'; import { AvatarOrIcon } from 'twenty-ui/data-display';
type RecordLinkProps = { type RecordLinkProps = {
objectNameSingular: string; objectNameSingular: string;
@@ -28,16 +28,10 @@ export const RecordLink = ({
return <span>{displayName}</span>; return <span>{displayName}</span>;
} }
const linkToShowPage = getLinkToShowPage(objectNameSingular, {
id: recordId,
});
return ( return (
<LinkChip <ChatReferenceChipDisplay
label={displayName} displayName={displayName}
emptyLabel={t`Untitled`} to={getLinkToShowPage(objectNameSingular, { id: recordId })}
to={linkToShowPage}
variant={ChipVariant.Highlighted}
leftComponent={ leftComponent={
<AvatarOrIcon <AvatarOrIcon
placeholder={displayName} placeholder={displayName}
@@ -1,13 +1,15 @@
import { RecordLink } from '@/ai/components/RecordLink'; import { ChatReferenceChip } from '@/ai/components/ChatReferenceChip';
import { findRecordReferences } from '@/ai/utils/findRecordReferences'; import { findChatReferences } from '@/ai/utils/findChatReferences';
import { type ReactNode } from 'react'; import { type ReactNode } from 'react';
type TextWithRecordLinksProps = { type TextWithChatReferencesProps = {
text: string; text: string;
}; };
export const TextWithRecordLinks = ({ text }: TextWithRecordLinksProps) => { export const TextWithChatReferences = ({
const references = findRecordReferences(text); text,
}: TextWithChatReferencesProps) => {
const references = findChatReferences(text);
if (references.length === 0) { if (references.length === 0) {
return <>{text}</>; return <>{text}</>;
@@ -22,12 +24,7 @@ export const TextWithRecordLinks = ({ text }: TextWithRecordLinksProps) => {
} }
parts.push( parts.push(
<RecordLink <ChatReferenceChip key={reference.index} reference={reference} />,
key={reference.index}
objectNameSingular={reference.objectNameSingular}
recordId={reference.recordId}
displayName={reference.displayName}
/>,
); );
lastIndex = reference.index + reference.fullMatch.length; lastIndex = reference.index + reference.fullMatch.length;
@@ -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 <span>{displayName}</span>;
}
const Icon = getIcon(view.icon);
return (
<ChatReferenceChipDisplay
displayName={displayName}
to={getAppPath(
AppPath.RecordIndexPage,
{ objectNamePlural: objectMetadataItem.namePlural },
{ viewId },
)}
leftComponent={
<Icon size={theme.icon.size.sm} stroke={theme.icon.stroke.sm} />
}
/>
);
};
@@ -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<typeof LazyMarkdownRenderer> = {
title: 'Modules/AiChat/ChatReferenceChip',
component: LazyMarkdownRenderer,
decorators: [
(Story) => (
<ChatReferenceStoreSeeder>
<StyledContainer>
<Story />
</StyledContainer>
</ChatReferenceStoreSeeder>
),
ObjectMetadataItemsDecorator,
IconsProviderDecorator,
ComponentWithRouterDecorator,
],
};
export default meta;
type Story = StoryObj<typeof LazyMarkdownRenderer>;
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();
},
};
@@ -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(
<MemoryRouter>
<FieldMetadataLink
fieldMetadataItemId={fieldMetadataItemId}
displayName={displayName}
/>
</MemoryRouter>,
{ 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();
});
});
@@ -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(
<MemoryRouter>
<ObjectMetadataLink
objectNameSingular={objectNameSingular}
displayName={displayName}
/>
</MemoryRouter>,
{ 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();
});
});
@@ -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;
}) => (
<a data-testid="record-link" href={`/${objectNameSingular}/${recordId}`}>
{displayName}
</a>
),
}));
jest.mock('@/ai/components/ObjectMetadataLink', () => ({
ObjectMetadataLink: ({
displayName,
objectNameSingular,
}: {
displayName: string;
objectNameSingular: string;
}) => (
<a data-testid="object-link" href={`/objects/${objectNameSingular}`}>
{displayName}
</a>
),
}));
jest.mock('@/ai/components/FieldMetadataLink', () => ({
FieldMetadataLink: ({
displayName,
fieldMetadataItemId,
}: {
displayName: string;
fieldMetadataItemId: string;
}) => (
<a data-testid="field-link" href={`/fields/${fieldMetadataItemId}`}>
{displayName}
</a>
),
}));
jest.mock('@/ai/components/ViewLink', () => ({
ViewLink: ({
displayName,
viewId,
}: {
displayName: string;
viewId: string;
}) => (
<a data-testid="view-link" href={`/views/${viewId}`}>
{displayName}
</a>
),
}));
describe('TextWithChatReferences', () => {
it('should render plain text without references as-is', () => {
render(<TextWithChatReferences 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 tagged record references with RecordLink chips', () => {
render(
<TextWithChatReferences text="Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme[[/record]] 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 still replace legacy ]] record references with RecordLink chips', () => {
render(
<TextWithChatReferences 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');
});
it('should replace multiple record references in option-style labels', () => {
render(
<TextWithChatReferences text="Merge [[person:11111111-1111-1111-1111-111111111111:Alice[[/record]] into [[person:22222222-2222-2222-2222-222222222222:Bob[[/record]]" />,
);
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(
<TextWithChatReferences text="See [[record:workflow:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Workflow `UPDATE_RECORD` step[[/record]] and [[record:company:b1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test] [test] ###[[/record]] then [[record:person:c1b2c3d4-e5f6-7890-abcd-ef1234567890:Doe: Jane[[/record]]" />,
);
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(
<TextWithChatReferences text="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(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',
);
});
});
@@ -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;
}) => (
<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 tagged record references with RecordLink chips', () => {
render(
<TextWithRecordLinks text="Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme[[/record]] 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 still replace legacy ]] 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');
});
it('should replace multiple record references in option-style labels', () => {
render(
<TextWithRecordLinks text="Merge [[person:11111111-1111-1111-1111-111111111111:Alice[[/record]] into [[person:22222222-2222-2222-2222-222222222222:Bob[[/record]]" />,
);
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(
<TextWithRecordLinks text="See [[record:workflow:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Workflow `UPDATE_RECORD` step[[/record]] and [[record:company:b1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test] [test] ###[[/record]] then [[record:person:c1b2c3d4-e5f6-7890-abcd-ef1234567890:Doe: Jane[[/record]]" />,
);
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();
});
});
@@ -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(
<MemoryRouter>
<ViewLink viewId={viewId} displayName={displayName} />
</MemoryRouter>,
{ 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();
});
});
@@ -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('|')})\\]\\]`,
);
@@ -0,0 +1,6 @@
export const CHAT_REFERENCE_KINDS = [
'record',
'object',
'field',
'view',
] as const;
@@ -0,0 +1 @@
export const CHAT_REFERENCE_METADATA_NAME_PATTERN = '[a-zA-Z][a-zA-Z0-9]*';
@@ -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:(?<objectNameSingular>${CHAT_REFERENCE_METADATA_NAME_PATTERN}):`,
`\\[\\[field:(?<fieldMetadataItemId>${CHAT_REFERENCE_UUID_PATTERN}):`,
`\\[\\[view:(?<viewId>${CHAT_REFERENCE_UUID_PATTERN}):`,
`\\[\\[(?:record:)?(?<recordObjectNameSingular>${CHAT_REFERENCE_METADATA_NAME_PATTERN}):(?<recordId>${CHAT_REFERENCE_UUID_PATTERN}):`,
].join('|'),
'g',
);
@@ -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}';
@@ -1 +0,0 @@
export const RECORD_REFERENCE_CLOSE_TAG = '[[/record]]';
@@ -0,0 +1,4 @@
export type ChatReferenceClosing = {
index: number;
length: number;
};
@@ -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 };
@@ -0,0 +1,3 @@
import { type CHAT_REFERENCE_KINDS } from '@/ai/constants/ChatReferenceKinds';
export type ChatReferenceKind = (typeof CHAT_REFERENCE_KINDS)[number];
@@ -0,0 +1,7 @@
import { type ChatReferenceIdentity } from '@/ai/types/ChatReferenceIdentity';
export type ChatReferenceMatch = ChatReferenceIdentity & {
fullMatch: string;
index: number;
displayName: string;
};
@@ -0,0 +1,7 @@
import { type ChatReferenceIdentity } from '@/ai/types/ChatReferenceIdentity';
export type ChatReferenceStart = {
index: number;
prefixLength: number;
identity: ChatReferenceIdentity;
};
@@ -1,7 +0,0 @@
export type RecordReferenceMatch = {
fullMatch: string;
index: number;
objectNameSingular: string;
recordId: string;
displayName: string;
};
@@ -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([]);
});
});
@@ -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',
},
]);
});
});
@@ -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]]',
);
});
});
@@ -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]]',
);
});
});
@@ -0,0 +1,2 @@
export const escapeMarkdownForChatReference = (displayName: string): string =>
displayName.replace(/([\\`*_{}[\]()#+\-.!|~>])/g, '\\$1');
@@ -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,
};
};
@@ -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),
},
];
});
};
@@ -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,
},
];
});
};
@@ -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)}`;
@@ -1,4 +1,4 @@
import { RECORD_REFERENCE_CLOSE_TAG } from '@/ai/constants/RecordReferenceCloseTag'; import { formatChatReference } from '@/ai/utils/formatChatReference';
export const formatRecordReference = ({ export const formatRecordReference = ({
objectNameSingular, objectNameSingular,
@@ -9,4 +9,9 @@ export const formatRecordReference = ({
recordId: string; recordId: string;
displayName: string; displayName: string;
}): string => }): string =>
`[[record:${objectNameSingular}:${recordId}:${displayName}${RECORD_REFERENCE_CLOSE_TAG}`; formatChatReference({
kind: 'record',
objectNameSingular,
recordId,
displayName,
});
@@ -0,0 +1,4 @@
import { type ChatReferenceKind } from '@/ai/types/ChatReferenceKind';
export const getChatReferenceCloseTag = (kind: ChatReferenceKind): string =>
`[[/${kind}]]`;
@@ -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);
}
};
@@ -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,
},
};
};
@@ -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('');
};
@@ -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;
};
@@ -85,5 +85,24 @@ Record References - IMPORTANT:
- The recordId MUST be a real UUID (like "abc12345-1234-5678-abcd-123456789012") - The recordId MUST be a real UUID (like "abc12345-1234-5678-abcd-123456789012")
- DO NOT create record references before calling the tool - DO NOT create record references before calling the tool
- DO NOT use placeholder IDs like "rec-snowflake" or "rec-person-1" - 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`,
}; };