diff --git a/packages/twenty-front/jest.config.mjs b/packages/twenty-front/jest.config.mjs
index 98bd2e6012..806bc10ad7 100644
--- a/packages/twenty-front/jest.config.mjs
+++ b/packages/twenty-front/jest.config.mjs
@@ -27,8 +27,8 @@ const jestConfig = {
testEnvironmentOptions: {},
transformIgnorePatterns: [
- '/node_modules/(?!(twenty-ui|apollo-upload-client|extract-files|is-plain-obj|@preact/signals-core)/.*)',
- '../../node_modules/(?!(twenty-ui|apollo-upload-client|extract-files|is-plain-obj|@preact/signals-core)/.*)',
+ '/node_modules/(?!(twenty-ui|apollo-upload-client|extract-files|is-plain-obj|@preact/signals-core|marked)/.*)',
+ '../../node_modules/(?!(twenty-ui|apollo-upload-client|extract-files|is-plain-obj|@preact/signals-core|marked)/.*)',
'../../twenty-ui/',
],
transform: {
diff --git a/packages/twenty-front/src/modules/ai/components/AiChatNonLastMessageIdsList.tsx b/packages/twenty-front/src/modules/ai/components/AiChatNonLastMessageIdsList.tsx
index 0bd93b5b42..ea78826e09 100644
--- a/packages/twenty-front/src/modules/ai/components/AiChatNonLastMessageIdsList.tsx
+++ b/packages/twenty-front/src/modules/ai/components/AiChatNonLastMessageIdsList.tsx
@@ -1,6 +1,18 @@
import { AiChatMessage } from '@/ai/components/AiChatMessage';
import { agentChatNonLastMessageIdsComponentSelector } from '@/ai/states/selectors/agentChatNonLastMessageIdsComponentSelector';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
+import { styled } from '@linaria/react';
+
+// Settled messages never change, so let the browser skip layout and paint for
+// the ones scrolled out of view. `auto` in contain-intrinsic-size keeps the
+// last rendered height once a message has been painted; the estimate only
+// applies to never-painted messages, where scroll anchoring absorbs the
+// correction. 160px approximates a typical assistant message so the scrollbar
+// stays close to truth on long resumed threads.
+const StyledSettledMessage = styled.div`
+ contain-intrinsic-size: auto 160px;
+ content-visibility: auto;
+`;
export const AiChatNonLastMessageIdsList = () => {
const agentChatNonLastMessageIds = useAtomComponentSelectorValue(
@@ -8,6 +20,8 @@ export const AiChatNonLastMessageIdsList = () => {
);
return agentChatNonLastMessageIds.map((messageId) => (
-
+
+
+
));
};
diff --git a/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx b/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx
index c4b49e51f2..2550988679 100644
--- a/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx
+++ b/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx
@@ -7,8 +7,9 @@ import {
} from '@/ai/components/LazyMarkdownRendererStyledComponents';
import { MarkdownCodeBlock } from '@/ai/components/MarkdownCodeBlock';
import { TextWithChatReferences } from '@/ai/components/TextWithChatReferences';
+import { EMPTY_MARKDOWN_BLOCK_SPLIT_CACHE } from '@/ai/constants/EmptyMarkdownBlockSplitCache';
+import { getMarkdownBlocksIncrementally } from '@/ai/utils/getMarkdownBlocksIncrementally';
import { protectChatReferencesForMarkdown } from '@/ai/utils/protectChatReferencesForMarkdown';
-import { marked } from 'marked';
import {
cloneElement,
isValidElement,
@@ -16,7 +17,7 @@ import {
memo,
Suspense,
useContext,
- useMemo,
+ useRef,
} from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { getSafeUrl, isDefined } from 'twenty-shared/utils';
@@ -48,6 +49,12 @@ const processChildrenForChatReferences = (
return children;
};
+const createChatReferenceElement =
+ (Element: React.ElementType) =>
+ ({ children }: { children?: React.ReactNode }) => (
+ {processChildrenForChatReferences(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 = {
@@ -56,38 +63,16 @@ const MARKDOWN_COMPONENTS = {
),
- p: ({ children }: { children?: React.ReactNode }) => (
-
- {processChildrenForChatReferences(children)}
-
- ),
- td: ({ children }: { children?: React.ReactNode }) => (
- {processChildrenForChatReferences(children)} |
- ),
- th: ({ children }: { children?: React.ReactNode }) => (
- {processChildrenForChatReferences(children)} |
- ),
- li: ({ children }: { children?: React.ReactNode }) => (
- {processChildrenForChatReferences(children)}
- ),
- h1: ({ children }: { children?: React.ReactNode }) => (
- {processChildrenForChatReferences(children)}
- ),
- h2: ({ children }: { children?: React.ReactNode }) => (
- {processChildrenForChatReferences(children)}
- ),
- h3: ({ children }: { children?: React.ReactNode }) => (
- {processChildrenForChatReferences(children)}
- ),
- h4: ({ children }: { children?: React.ReactNode }) => (
- {processChildrenForChatReferences(children)}
- ),
- h5: ({ children }: { children?: React.ReactNode }) => (
- {processChildrenForChatReferences(children)}
- ),
- h6: ({ children }: { children?: React.ReactNode }) => (
- {processChildrenForChatReferences(children)}
- ),
+ p: createChatReferenceElement(StyledParagraph),
+ td: createChatReferenceElement('td'),
+ th: createChatReferenceElement('th'),
+ li: createChatReferenceElement('li'),
+ h1: createChatReferenceElement('h1'),
+ h2: createChatReferenceElement('h2'),
+ h3: createChatReferenceElement('h3'),
+ h4: createChatReferenceElement('h4'),
+ h5: createChatReferenceElement('h5'),
+ h6: createChatReferenceElement('h6'),
a: ({
children,
href,
@@ -170,23 +155,29 @@ const LoadingSkeleton = () => {
);
};
+// Protecting per block behind the memo means only the streaming tail blocks
+// pay the reference-parsing cost on each flush; settled blocks never re-run it.
const MemoizedMarkdownBlock = memo(
({ blockText }: { blockText: string }) => (
- {blockText}
+
+ {protectChatReferencesForMarkdown(blockText)}
+
),
(previousProps, nextProps) => previousProps.blockText === nextProps.blockText,
);
export const LazyMarkdownRenderer = ({ text }: { text: string }) => {
- const protectedText = useMemo(
- () => protectChatReferencesForMarkdown(text),
- [text],
- );
+ // Not state: the blocks are a pure function of `text`, the ref only caches
+ // the previous split so streaming appends skip re-tokenizing settled blocks.
+ // oxlint-disable-next-line twenty/no-state-useref
+ const blockSplitCacheRef = useRef(EMPTY_MARKDOWN_BLOCK_SPLIT_CACHE);
- const markdownBlocks = useMemo(
- () => marked.lexer(protectedText).map((token) => token.raw),
- [protectedText],
- );
+ const { blocks: markdownBlocks, cache } = getMarkdownBlocksIncrementally({
+ text,
+ cache: blockSplitCacheRef.current,
+ });
+
+ blockSplitCacheRef.current = cache;
return (
{
- const { theme } = useContext(ThemeContext);
- const [isExpanded, setIsExpanded] = useState(false);
-
- const hasContent = content.trim().length > 0;
-
- if (!hasContent) {
- return null;
- }
-
- return (
-
- {isThinking && (
- <>
-
-
-
- {t`Thinking...`}
-
-
-
- {content}
-
- >
- )}
-
- {hasContent && !isThinking && (
- <>
- setIsExpanded(!isExpanded)}>
-
-
- {t`Finished thinking`}
-
- {isExpanded ? (
-
- ) : (
-
- )}
-
-
-
-
- {content}
-
-
- >
- )}
-
- );
-};
diff --git a/packages/twenty-front/src/modules/ai/components/TextWithChatReferences.tsx b/packages/twenty-front/src/modules/ai/components/TextWithChatReferences.tsx
index aa93f696dd..b917f7811b 100644
--- a/packages/twenty-front/src/modules/ai/components/TextWithChatReferences.tsx
+++ b/packages/twenty-front/src/modules/ai/components/TextWithChatReferences.tsx
@@ -1,6 +1,5 @@
import { ChatReferenceChip } from '@/ai/components/ChatReferenceChip';
-import { findChatReferences } from '@/ai/utils/findChatReferences';
-import { type ReactNode } from 'react';
+import { getChatReferenceSegments } from '@/ai/utils/getChatReferenceSegments';
type TextWithChatReferencesProps = {
text: string;
@@ -9,30 +8,15 @@ type TextWithChatReferencesProps = {
export const TextWithChatReferences = ({
text,
}: TextWithChatReferencesProps) => {
- const references = findChatReferences(text);
-
- if (references.length === 0) {
- return <>{text}>;
- }
-
- const parts: ReactNode[] = [];
- let lastIndex = 0;
-
- for (const reference of references) {
- if (reference.index > lastIndex) {
- parts.push(text.slice(lastIndex, reference.index));
- }
-
- parts.push(
- ,
- );
-
- lastIndex = reference.index + reference.fullMatch.length;
- }
-
- if (lastIndex < text.length) {
- parts.push(text.slice(lastIndex));
- }
-
- return <>{parts}>;
+ return (
+ <>
+ {getChatReferenceSegments(text).map((segment) =>
+ typeof segment === 'string' ? (
+ segment
+ ) : (
+
+ ),
+ )}
+ >
+ );
};
diff --git a/packages/twenty-front/src/modules/ai/components/ViewLink.tsx b/packages/twenty-front/src/modules/ai/components/ViewLink.tsx
index bdb812b7d0..1e7f1bf1e0 100644
--- a/packages/twenty-front/src/modules/ai/components/ViewLink.tsx
+++ b/packages/twenty-front/src/modules/ai/components/ViewLink.tsx
@@ -1,9 +1,9 @@
import { ChatReferenceChipDisplay } from '@/ai/components/ChatReferenceChipDisplay';
-import { flatObjectMetadataItemsSelector } from '@/object-metadata/states/flatObjectMetadataItemsSelector';
+import { objectMetadataItemsByIdMapSelector } from '@/object-metadata/states/objectMetadataItemsByIdMapSelector';
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 { getAppPath, isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/icon';
import { useTheme } from 'twenty-ui/theme-constants';
@@ -17,12 +17,12 @@ export const ViewLink = ({ viewId, displayName }: ViewLinkProps) => {
const { getIcon } = useIcons();
const { view } = useViewById(viewId);
- const flatObjectMetadataItems = useAtomStateValue(
- flatObjectMetadataItemsSelector,
+ const objectMetadataItemsByIdMap = useAtomStateValue(
+ objectMetadataItemsByIdMapSelector,
);
const objectMetadataItem = isDefined(view)
- ? flatObjectMetadataItems.find(findById(view.objectMetadataId))
+ ? objectMetadataItemsByIdMap.get(view.objectMetadataId)
: undefined;
if (!isDefined(view) || !isDefined(objectMetadataItem)) {
diff --git a/packages/twenty-front/src/modules/ai/constants/ChatReferenceOpenPattern.ts b/packages/twenty-front/src/modules/ai/constants/ChatReferenceOpenPattern.ts
index b55da758a6..3edde75f9c 100644
--- a/packages/twenty-front/src/modules/ai/constants/ChatReferenceOpenPattern.ts
+++ b/packages/twenty-front/src/modules/ai/constants/ChatReferenceOpenPattern.ts
@@ -1 +1,5 @@
-export const CHAT_REFERENCE_OPEN_PATTERN = '\\[\\[+';
+// The lookbehind anchors the match to the start of a bracket run. Without it,
+// every position inside a long run is a candidate start and the greedy + makes
+// the scan quadratic in the run length; a run start always yields the same
+// match, so no valid marker is lost.
+export const CHAT_REFERENCE_OPEN_PATTERN = '(?({
- key: 'agentChatMessagesComponentState',
- defaultValue: [],
- componentInstanceContext: AgentChatComponentInstanceContext,
-});
diff --git a/packages/twenty-front/src/modules/ai/types/AgentResponseFormat.ts b/packages/twenty-front/src/modules/ai/types/AgentResponseFormat.ts
deleted file mode 100644
index 84f422fde9..0000000000
--- a/packages/twenty-front/src/modules/ai/types/AgentResponseFormat.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { type BaseOutputSchemaV2 } from 'twenty-shared/workflow';
-
-export type AgentResponseFormat =
- | { type: 'text' }
- | {
- type: 'json';
- schema: BaseOutputSchemaV2;
- };
diff --git a/packages/twenty-front/src/modules/ai/types/MarkdownBlockSplitCache.ts b/packages/twenty-front/src/modules/ai/types/MarkdownBlockSplitCache.ts
new file mode 100644
index 0000000000..e22dcb301f
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/types/MarkdownBlockSplitCache.ts
@@ -0,0 +1,6 @@
+export type MarkdownBlockSplitCache = {
+ text: string;
+ blocks: string[];
+ stablePrefix: string;
+ stableBlocks: string[];
+};
diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/getFieldIcon.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/getFieldIcon.test.ts
deleted file mode 100644
index 371eaff161..0000000000
--- a/packages/twenty-front/src/modules/ai/utils/__tests__/getFieldIcon.test.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { getFieldIcon } from '@/ai/utils/getFieldIcon';
-
-describe('getFieldIcon', () => {
- describe('supported field types', () => {
- it('should return IconAbc for string field type', () => {
- expect(getFieldIcon('string')).toBe('IconAbc');
- });
- it('should return IconText for number field type', () => {
- expect(getFieldIcon('number')).toBe('IconText');
- });
- it('should return IconCheckbox for boolean field type', () => {
- expect(getFieldIcon('boolean')).toBe('IconCheckbox');
- });
- });
-
- describe('unsupported and edge cases', () => {
- it('should return IconQuestionMark for an unsupported field type', () => {
- expect(getFieldIcon('totally-unknown-type' as any)).toBe(
- 'IconQuestionMark',
- );
- });
- it('should return IconQuestionMark for undefined', () => {
- expect(getFieldIcon(undefined)).toBe('IconQuestionMark');
- });
- it('should return IconQuestionMark for null', () => {
- expect(getFieldIcon(null as any)).toBe('IconQuestionMark');
- });
- it('should return IconQuestionMark for empty string', () => {
- expect(getFieldIcon('' as any)).toBe('IconQuestionMark');
- });
- });
-
- describe('consistency', () => {
- it('should return the same icon for the same field type', () => {
- const result1 = getFieldIcon('string');
- const result2 = getFieldIcon('string');
- expect(result1).toBe(result2);
- });
- });
-});
diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/getMarkdownBlocksIncrementally.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/getMarkdownBlocksIncrementally.test.ts
new file mode 100644
index 0000000000..a6928ab13e
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/utils/__tests__/getMarkdownBlocksIncrementally.test.ts
@@ -0,0 +1,109 @@
+import { marked } from 'marked';
+
+import { EMPTY_MARKDOWN_BLOCK_SPLIT_CACHE } from '@/ai/constants/EmptyMarkdownBlockSplitCache';
+import { type MarkdownBlockSplitCache } from '@/ai/types/MarkdownBlockSplitCache';
+import { getMarkdownBlocksIncrementally } from '@/ai/utils/getMarkdownBlocksIncrementally';
+
+const lexerBlocks = (text: string): string[] =>
+ marked.lexer(text).map((token) => token.raw);
+
+const streamThrough = (text: string, chunkSize: number): string[] => {
+ let cache: MarkdownBlockSplitCache = EMPTY_MARKDOWN_BLOCK_SPLIT_CACHE;
+ let blocks: string[] = [];
+
+ for (let end = chunkSize; end < text.length + chunkSize; end += chunkSize) {
+ const result = getMarkdownBlocksIncrementally({
+ text: text.slice(0, Math.min(end, text.length)),
+ cache,
+ });
+
+ cache = result.cache;
+ blocks = result.blocks;
+ }
+
+ return blocks;
+};
+
+const FIXTURES: Record = {
+ paragraphs: 'First paragraph.\n\nSecond paragraph.\n\nThird paragraph.',
+ looseList: '- a\n\n- b\n\n- c\n\nplain paragraph after list',
+ listWithLooseContinuation:
+ '- item one\n\n continued loose content\n\n- item two',
+ nestedList: '1. first\n - sub a\n\n - sub b\n2. second',
+ table: '| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n\nafter table',
+ codeFence: 'before\n\n```js\nconst x = 1;\n\nconst y = 2;\n```\n\nafter',
+ unclosedCodeFence: 'before\n\n```js\nconst x = 1;\n\nstill code',
+ setextHeading: 'Title\n=====\n\nBody text\n\nSub\n-----\n\nmore',
+ mixedBlocks:
+ '# H1\n\ntext\n\n## H2\n\n- list\n- items\n\n> quote\n> more quote\n\nend',
+ blockquotes: '> a\n\n> b\n\ntext',
+ chatReferences:
+ 'Check [[field:12345678-1234-5678-abcd-123456789012:Annual Revenue[[/field]] and\n\n- [[view:12345678-1234-5678-abcd-123456789012:All[[/view]]\n\n| [[object:company:Companies[[/object]] | x |\n|---|---|\n| a | b |',
+ windowsLineEndings: 'line one\r\n\r\n- a\r\n\r\n- b\r\n\r\nend',
+};
+
+describe('getMarkdownBlocksIncrementally', () => {
+ it.each(Object.entries(FIXTURES))(
+ 'should match a full marked.lexer split when streamed char by char for %s',
+ (_name, text) => {
+ let cache: MarkdownBlockSplitCache = EMPTY_MARKDOWN_BLOCK_SPLIT_CACHE;
+
+ for (let end = 1; end <= text.length; end++) {
+ const prefix = text.slice(0, end);
+ const result = getMarkdownBlocksIncrementally({ text: prefix, cache });
+
+ cache = result.cache;
+
+ expect(result.blocks).toEqual(lexerBlocks(prefix));
+ }
+ },
+ );
+
+ it.each(Object.entries(FIXTURES))(
+ 'should match a full marked.lexer split when streamed in chunks for %s',
+ (_name, text) => {
+ for (const chunkSize of [3, 7, 20]) {
+ expect(streamThrough(text, chunkSize)).toEqual(lexerBlocks(text));
+ }
+ },
+ );
+
+ it('should return the cached blocks when the text is unchanged', () => {
+ const text = 'A paragraph.\n\nAnother paragraph.';
+ const firstResult = getMarkdownBlocksIncrementally({
+ text,
+ cache: EMPTY_MARKDOWN_BLOCK_SPLIT_CACHE,
+ });
+ const secondResult = getMarkdownBlocksIncrementally({
+ text,
+ cache: firstResult.cache,
+ });
+
+ expect(secondResult.blocks).toBe(firstResult.blocks);
+ expect(secondResult.cache).toBe(firstResult.cache);
+ });
+
+ it('should recover with a full split when the text is not an append', () => {
+ const firstResult = getMarkdownBlocksIncrementally({
+ text: 'First paragraph.\n\nSecond paragraph.\n\nThird one.',
+ cache: EMPTY_MARKDOWN_BLOCK_SPLIT_CACHE,
+ });
+
+ const replacedText = 'Completely different text.\n\nWith new blocks.';
+ const secondResult = getMarkdownBlocksIncrementally({
+ text: replacedText,
+ cache: firstResult.cache,
+ });
+
+ expect(secondResult.blocks).toEqual(lexerBlocks(replacedText));
+ });
+
+ it('should handle an empty text', () => {
+ const result = getMarkdownBlocksIncrementally({
+ text: '',
+ cache: EMPTY_MARKDOWN_BLOCK_SPLIT_CACHE,
+ });
+
+ expect(result.blocks).toEqual([]);
+ });
+});
diff --git a/packages/twenty-front/src/modules/ai/utils/findChatReferences.ts b/packages/twenty-front/src/modules/ai/utils/findChatReferences.ts
index 0eea298426..6deb3f0969 100644
--- a/packages/twenty-front/src/modules/ai/utils/findChatReferences.ts
+++ b/packages/twenty-front/src/modules/ai/utils/findChatReferences.ts
@@ -7,6 +7,10 @@ import { getSurplusCloseBracketLength } from '@/ai/utils/getSurplusCloseBracketL
import { isDefined } from 'twenty-shared/utils';
export const findChatReferences = (text: string): ChatReferenceMatch[] => {
+ if (!text.includes('[[')) {
+ return [];
+ }
+
const starts: ChatReferenceStart[] = [];
CHAT_REFERENCE_START_REGEX.lastIndex = 0;
diff --git a/packages/twenty-front/src/modules/ai/utils/getChatReferenceSegments.ts b/packages/twenty-front/src/modules/ai/utils/getChatReferenceSegments.ts
new file mode 100644
index 0000000000..0c04cc918e
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/utils/getChatReferenceSegments.ts
@@ -0,0 +1,25 @@
+import { type ChatReferenceMatch } from '@/ai/types/ChatReferenceMatch';
+import { findChatReferences } from '@/ai/utils/findChatReferences';
+
+export const getChatReferenceSegments = (
+ text: string,
+): Array => {
+ const references = findChatReferences(text);
+ const segments: Array = [];
+ let lastIndex = 0;
+
+ for (const reference of references) {
+ if (reference.index > lastIndex) {
+ segments.push(text.slice(lastIndex, reference.index));
+ }
+
+ segments.push(reference);
+ lastIndex = reference.index + reference.fullMatch.length;
+ }
+
+ if (lastIndex < text.length) {
+ segments.push(text.slice(lastIndex));
+ }
+
+ return segments;
+};
diff --git a/packages/twenty-front/src/modules/ai/utils/getFieldIcon.ts b/packages/twenty-front/src/modules/ai/utils/getFieldIcon.ts
deleted file mode 100644
index 71994e4401..0000000000
--- a/packages/twenty-front/src/modules/ai/utils/getFieldIcon.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { type AgentResponseFieldType } from 'twenty-shared/ai';
-
-export const getFieldIcon = (fieldType?: AgentResponseFieldType): string => {
- switch (fieldType) {
- case 'string':
- return 'IconAbc';
- case 'number':
- return 'IconText';
- case 'boolean':
- return 'IconCheckbox';
- default:
- return 'IconQuestionMark';
- }
-};
diff --git a/packages/twenty-front/src/modules/ai/utils/getMarkdownBlocksIncrementally.ts b/packages/twenty-front/src/modules/ai/utils/getMarkdownBlocksIncrementally.ts
new file mode 100644
index 0000000000..e0fc7b91ce
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/utils/getMarkdownBlocksIncrementally.ts
@@ -0,0 +1,73 @@
+import { type MarkdownBlockSplitCache } from '@/ai/types/MarkdownBlockSplitCache';
+import { Lexer } from 'marked';
+
+// Appended text can reopen the last block, and a list followed by a blank-line
+// token merges with a later item ("- a\n\n" + "- b" is one loose list), so the
+// two trailing blocks must be re-tokenized on every flush. Anything before them
+// was terminated by content that is still present, so it can never change.
+const UNSTABLE_TRAILING_BLOCK_COUNT = 2;
+
+// marked.lexer normalizes line endings before tokenizing; blockTokens does not,
+// so normalize here to keep raw offsets aligned with the sliced text.
+const normalizeLineEndings = (text: string): string =>
+ text.replace(/\r\n|\r/g, '\n');
+
+const splitIntoBlocks = (text: string): string[] =>
+ new Lexer().blockTokens(text, []).map((token) => token.raw);
+
+// A streamed message only grows, so instead of re-tokenizing the whole message
+// on every flush, reuse the blocks that can no longer change and re-tokenize
+// only the unstable tail, keeping each flush O(appended text) instead of
+// O(whole message). Any non-append change misses the stablePrefix guard and
+// falls back to a full split.
+export const getMarkdownBlocksIncrementally = ({
+ text,
+ cache,
+}: {
+ text: string;
+ cache: MarkdownBlockSplitCache;
+}): { blocks: string[]; cache: MarkdownBlockSplitCache } => {
+ const normalizedText = normalizeLineEndings(text);
+
+ if (normalizedText === cache.text) {
+ return { blocks: cache.blocks, cache };
+ }
+
+ const canReuseStableBlocks =
+ cache.stablePrefix.length > 0 &&
+ normalizedText.startsWith(cache.stablePrefix);
+
+ const stableBlocks = canReuseStableBlocks ? cache.stableBlocks : [];
+ const stablePrefix = canReuseStableBlocks ? cache.stablePrefix : '';
+
+ const tailBlocks = splitIntoBlocks(normalizedText.slice(stablePrefix.length));
+
+ const blocks = [...stableBlocks, ...tailBlocks];
+ const nextStableBlocks = blocks.slice(
+ 0,
+ Math.max(0, blocks.length - UNSTABLE_TRAILING_BLOCK_COUNT),
+ );
+ // Append newly stabilized raws instead of re-joining the whole prefix on
+ // every flush. The stable set shrinks when the tail collapses into fewer
+ // than two blocks; then trim the raws that fell out of it.
+ const nextStablePrefix =
+ nextStableBlocks.length >= stableBlocks.length
+ ? stablePrefix + nextStableBlocks.slice(stableBlocks.length).join('')
+ : stablePrefix.slice(
+ 0,
+ stablePrefix.length -
+ stableBlocks
+ .slice(nextStableBlocks.length)
+ .reduce((removedLength, raw) => removedLength + raw.length, 0),
+ );
+
+ return {
+ blocks,
+ cache: {
+ text: normalizedText,
+ blocks,
+ stablePrefix: nextStablePrefix,
+ stableBlocks: nextStableBlocks,
+ },
+ };
+};
diff --git a/packages/twenty-front/src/modules/ai/utils/protectChatReferencesForMarkdown.ts b/packages/twenty-front/src/modules/ai/utils/protectChatReferencesForMarkdown.ts
index b6cced8e3b..2c3f89fbe2 100644
--- a/packages/twenty-front/src/modules/ai/utils/protectChatReferencesForMarkdown.ts
+++ b/packages/twenty-front/src/modules/ai/utils/protectChatReferencesForMarkdown.ts
@@ -1,30 +1,22 @@
import { escapeMarkdownForChatReference } from '@/ai/utils/escapeMarkdownForChatReference';
-import { findChatReferences } from '@/ai/utils/findChatReferences';
import { formatChatReference } from '@/ai/utils/formatChatReference';
+import { getChatReferenceSegments } from '@/ai/utils/getChatReferenceSegments';
export const protectChatReferencesForMarkdown = (text: string): string => {
- const references = findChatReferences(text);
+ const segments = getChatReferenceSegments(text);
- if (references.length === 0) {
+ if (segments.length === 1 && typeof segments[0] === 'string') {
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('');
+ return segments
+ .map((segment) =>
+ typeof segment === 'string'
+ ? segment
+ : formatChatReference({
+ ...segment,
+ displayName: escapeMarkdownForChatReference(segment.displayName),
+ }),
+ )
+ .join('');
};
diff --git a/packages/twenty-front/src/modules/object-metadata/states/fieldMetadataItemByIdSelector.ts b/packages/twenty-front/src/modules/object-metadata/states/fieldMetadataItemByIdSelector.ts
index e878c18747..dabd4cfa7e 100644
--- a/packages/twenty-front/src/modules/object-metadata/states/fieldMetadataItemByIdSelector.ts
+++ b/packages/twenty-front/src/modules/object-metadata/states/fieldMetadataItemByIdSelector.ts
@@ -1,32 +1,29 @@
-import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
-import { objectMetadataItemsWithFieldsSelector } from '@/object-metadata/states/objectMetadataItemsWithFieldsSelector';
+import { fieldMetadataItemByIdMapSelector } from '@/object-metadata/states/fieldMetadataItemByIdMapSelector';
+import { objectMetadataItemsByIdMapSelector } from '@/object-metadata/states/objectMetadataItemsByIdMapSelector';
import { createAtomFamilySelector } from '@/ui/utilities/state/jotai/utils/createAtomFamilySelector';
-import { findById, isDefined } from 'twenty-shared/utils';
+import { isDefined } from 'twenty-shared/utils';
export const fieldMetadataItemByIdSelector = createAtomFamilySelector({
key: 'fieldMetadataItemByIdSelector',
get:
({ fieldMetadataItemId }: { fieldMetadataItemId: string }) =>
({ get }) => {
- const objectMetadataItems = get(objectMetadataItemsWithFieldsSelector);
- const flattenedFieldMetadataItems = get(
- flattenedFieldMetadataItemsSelector,
+ const foundFieldMetadataItem = get(fieldMetadataItemByIdMapSelector).get(
+ fieldMetadataItemId,
);
- const foundObjectMetadataItem = objectMetadataItems.find(
- (objectMetadataItem) =>
- objectMetadataItem.fields.some(findById(fieldMetadataItemId)),
- );
-
- if (!isDefined(foundObjectMetadataItem)) {
+ if (
+ !isDefined(foundFieldMetadataItem) ||
+ !isDefined(foundFieldMetadataItem.objectMetadataId)
+ ) {
return {};
}
- const foundFieldMetadataItem = flattenedFieldMetadataItems.find(
- findById(fieldMetadataItemId),
- );
+ const foundObjectMetadataItem = get(
+ objectMetadataItemsByIdMapSelector,
+ ).get(foundFieldMetadataItem.objectMetadataId);
- if (!isDefined(foundFieldMetadataItem)) {
+ if (!isDefined(foundObjectMetadataItem)) {
return {};
}
@@ -35,4 +32,7 @@ export const fieldMetadataItemByIdSelector = createAtomFamilySelector({
foundObjectMetadataItem,
};
},
+ areEqual: (previous, next) =>
+ previous.foundFieldMetadataItem === next.foundFieldMetadataItem &&
+ previous.foundObjectMetadataItem === next.foundObjectMetadataItem,
});
diff --git a/packages/twenty-front/src/modules/object-metadata/states/objectMetadataItemFamilySelector.ts b/packages/twenty-front/src/modules/object-metadata/states/objectMetadataItemFamilySelector.ts
index bff15a9ef1..3fad4fd25c 100644
--- a/packages/twenty-front/src/modules/object-metadata/states/objectMetadataItemFamilySelector.ts
+++ b/packages/twenty-front/src/modules/object-metadata/states/objectMetadataItemFamilySelector.ts
@@ -1,4 +1,5 @@
-import { objectMetadataItemsWithFieldsSelector } from '@/object-metadata/states/objectMetadataItemsWithFieldsSelector';
+import { objectMetadataItemsByNamePluralMapSelector } from '@/object-metadata/states/objectMetadataItemsByNamePluralMapSelector';
+import { objectMetadataItemsByNameSingularMapSelector } from '@/object-metadata/states/objectMetadataItemsByNameSingularMapSelector';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { createAtomFamilySelector } from '@/ui/utilities/state/jotai/utils/createAtomFamilySelector';
@@ -15,23 +16,18 @@ export const objectMetadataItemFamilySelector = createAtomFamilySelector<
get:
({ objectNameType, objectName }: ObjectMetadataItemSelector) =>
({ get }) => {
- const objectMetadataItems = get(objectMetadataItemsWithFieldsSelector);
-
if (objectNameType === 'singular') {
return (
- objectMetadataItems.find(
- (objectMetadataItem) =>
- objectMetadataItem.nameSingular === objectName,
- ) ?? null
+ get(objectMetadataItemsByNameSingularMapSelector).get(objectName) ??
+ null
);
} else if (objectNameType === 'plural') {
return (
- objectMetadataItems.find(
- (objectMetadataItem) =>
- objectMetadataItem.namePlural === objectName,
- ) ?? null
+ get(objectMetadataItemsByNamePluralMapSelector).get(objectName) ??
+ null
);
}
return null;
},
+ areEqual: (previous, next) => previous === next,
});
diff --git a/packages/twenty-front/src/modules/object-metadata/states/objectMetadataItemsByIdMapSelector.ts b/packages/twenty-front/src/modules/object-metadata/states/objectMetadataItemsByIdMapSelector.ts
new file mode 100644
index 0000000000..b1132e1369
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-metadata/states/objectMetadataItemsByIdMapSelector.ts
@@ -0,0 +1,19 @@
+import { objectMetadataItemsWithFieldsSelector } from '@/object-metadata/states/objectMetadataItemsWithFieldsSelector';
+import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
+import { createAtomSelector } from '@/ui/utilities/state/jotai/utils/createAtomSelector';
+
+export const objectMetadataItemsByIdMapSelector = createAtomSelector<
+ Map
+>({
+ key: 'objectMetadataItemsByIdMapSelector',
+ get: ({ get }) => {
+ const objectMetadataItems = get(objectMetadataItemsWithFieldsSelector);
+
+ return new Map(
+ objectMetadataItems.map((objectMetadataItem) => [
+ objectMetadataItem.id,
+ objectMetadataItem,
+ ]),
+ );
+ },
+});
diff --git a/packages/twenty-front/src/modules/views/states/selectors/viewFromViewIdFamilySelector.ts b/packages/twenty-front/src/modules/views/states/selectors/viewFromViewIdFamilySelector.ts
index a51fc49595..6c3aa8f788 100644
--- a/packages/twenty-front/src/modules/views/states/selectors/viewFromViewIdFamilySelector.ts
+++ b/packages/twenty-front/src/modules/views/states/selectors/viewFromViewIdFamilySelector.ts
@@ -1,5 +1,5 @@
import { createAtomFamilySelector } from '@/ui/utilities/state/jotai/utils/createAtomFamilySelector';
-import { viewsSelector } from '@/views/states/selectors/viewsSelector';
+import { viewsByIdMapSelector } from '@/views/states/selectors/viewsByIdMapSelector';
import { type View } from '@/views/types/View';
export const viewFromViewIdFamilySelector = createAtomFamilySelector<
@@ -10,7 +10,7 @@ export const viewFromViewIdFamilySelector = createAtomFamilySelector<
get:
({ viewId }) =>
({ get }) => {
- const views = get(viewsSelector);
- return views?.find((view) => view.id === viewId);
+ return get(viewsByIdMapSelector).get(viewId);
},
+ areEqual: (previous, next) => previous === next,
});
diff --git a/packages/twenty-front/src/modules/views/states/selectors/viewsByIdMapSelector.ts b/packages/twenty-front/src/modules/views/states/selectors/viewsByIdMapSelector.ts
new file mode 100644
index 0000000000..73ba98eb8a
--- /dev/null
+++ b/packages/twenty-front/src/modules/views/states/selectors/viewsByIdMapSelector.ts
@@ -0,0 +1,10 @@
+import { createAtomSelector } from '@/ui/utilities/state/jotai/utils/createAtomSelector';
+import { viewsSelector } from '@/views/states/selectors/viewsSelector';
+import { type ViewWithRelations } from '@/views/types/ViewWithRelations';
+
+export const viewsByIdMapSelector = createAtomSelector<
+ Map
+>({
+ key: 'viewsByIdMapSelector',
+ get: ({ get }) => new Map(get(viewsSelector).map((view) => [view.id, view])),
+});