Add @mention support in AI Chat input (#17943)

## Summary
- Add `@mention` support to the AI Chat text input by replacing the
plain textarea with a minimal Tiptap editor and building a shared
`mention` module with reusable Tiptap extensions (`MentionTag`,
`MentionSuggestion`), search hook (`useMentionSearch`), and suggestion
menu — all shared with the existing BlockNote-based Notes mentions to
avoid code duplication
- Mentions are serialized as
`[[record:objectName:recordId:displayName]]` markdown (the format
already understood by the backend and rendered in chat messages), and
displayed using the existing `RecordLink` chip component for visual
consistency
- Fix images in chat messages overflowing their container by
constraining to `max-width: 100%`
- Fix web_search tool display showing literal `{query}` instead of the
actual query (ICU single-quote escaping issue in Lingui `t` tagged
templates)

## Test plan
- [ ] Open AI Chat, type `@` and verify the suggestion menu appears with
searchable records
- [ ] Select a mention from the dropdown (via click or keyboard
Enter/ArrowUp/Down) and verify the record chip renders inline
- [ ] Send a message containing a mention and verify it appears
correctly in the conversation as a clickable `RecordLink`
- [ ] Verify Enter sends the message when the suggestion menu is closed,
and selects a mention when the menu is open
- [ ] Verify images in AI chat responses are constrained to the
container width
- [ ] Verify the web_search tool step shows the actual search query
(e.g. "Searched the web for Salesforce") instead of `{query}`
- [ ] Verify Notes @mentions still work as before


Made with [Cursor](https://cursor.com)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Félix Malfait 2026-02-14 14:37:33 +01:00 committed by GitHub
parent 0876197c8d
commit 6f251a6f8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
72 changed files with 1920 additions and 1043 deletions

1
.gitignore vendored
View file

@ -50,3 +50,4 @@ dump.rdb
mcp.json
/.junie/
TRANSLATION_QA_REPORT.md
.playwright-mcp/

View file

@ -14,7 +14,7 @@ import { isNonTextWritingKey } from '@/ui/utilities/hotkey/utils/isNonTextWritin
import { Key } from 'ts-key-enum';
import { useDebouncedCallback } from 'use-debounce';
import { BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
import { BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
import { ActivityRichTextEditorChangeOnActivityIdEffect } from '@/activities/components/ActivityRichTextEditorChangeOnActivityIdEffect';
import { type Attachment } from '@/activities/files/types/Attachment';
import { type Note } from '@/activities/types/Note';
@ -27,11 +27,11 @@ import { useIsRecordFieldReadOnly } from '@/object-record/read-only/hooks/useIsR
import { isTitleCellInEditModeComponentState } from '@/object-record/record-title-cell/states/isTitleCellInEditModeComponentState';
import { RecordTitleCellContainerType } from '@/object-record/record-title-cell/types/RecordTitleCellContainerType';
import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId';
import { BlockEditor } from '@/ui/input/editor/components/BlockEditor';
import { BLOCK_EDITOR_GLOBAL_HOTKEYS_CONFIG } from '@/ui/input/editor/constants/BlockEditorGlobalHotkeysConfig';
import { useAttachmentSync } from '@/ui/input/editor/hooks/useAttachmentSync';
import { parseInitialBlocknote } from '@/ui/input/editor/utils/parseInitialBlocknote';
import { prepareBodyWithSignedUrls } from '@/ui/input/editor/utils/prepareBodyWithSignedUrls';
import { BlockEditor } from '@/blocknote-editor/components/BlockEditor';
import { BLOCK_EDITOR_GLOBAL_HOTKEYS_CONFIG } from '@/blocknote-editor/constants/BlockEditorGlobalHotkeysConfig';
import { useAttachmentSync } from '@/blocknote-editor/hooks/useAttachmentSync';
import { parseInitialBlocknote } from '@/blocknote-editor/utils/parseInitialBlocknote';
import { prepareBodyWithSignedUrls } from '@/blocknote-editor/utils/prepareBodyWithSignedUrls';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';

View file

@ -1,4 +1,4 @@
import { type BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
import { useReplaceActivityBlockEditorContent } from '@/activities/hooks/useReplaceActivityBlockEditorContent';
import { useEffect, useState } from 'react';

View file

@ -1,4 +1,4 @@
import { type BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { isNonEmptyString } from '@sniptt/guards';
import { useRecoilCallback } from 'recoil';

View file

@ -7,7 +7,11 @@ import {
DEFAULT_SLASH_COMMANDS,
type SlashCommandConfig,
} from '@/advanced-text-editor/extensions/slash-command/DefaultSlashCommands';
import { SlashCommandRenderer } from '@/advanced-text-editor/extensions/slash-command/SlashCommandRenderer';
import {
SlashCommandMenu,
type SlashCommandMenuProps,
} from '@/advanced-text-editor/extensions/slash-command/SlashCommandMenu';
import { createSuggestionRenderLifecycle } from '@/ui/suggestion/components/createSuggestionRenderLifecycle';
export type SlashCommandItem = {
id: string;
@ -20,14 +24,6 @@ export type SlashCommandItem = {
command: (options: { editor: Editor; range: Range }) => void;
};
type SuggestionRenderProps = {
items: SlashCommandItem[];
command: (item: SlashCommandItem) => void;
clientRect?: (() => DOMRect | null) | null;
range: Range;
query: string;
};
const createSlashCommandItem = (
config: SlashCommandConfig,
editor: Editor,
@ -95,73 +91,23 @@ export const SlashCommand = Extension.create<SlashCommandOptions>({
editor: this.editor,
...this.options.suggestions,
items: ({ query, editor: ed }) => buildItems(ed, query),
render: () => {
let component: SlashCommandRenderer | null = null;
const closeMenu = () => {
if (component !== null) {
component.destroy();
component = null;
}
};
return {
onStart: (props: SuggestionRenderProps) => {
if (!props.clientRect) {
return;
}
component = new SlashCommandRenderer({
items: props.items,
command: (item: SlashCommandItem) => {
props.command(item);
closeMenu();
},
clientRect: props.clientRect,
editor: this.editor,
range: props.range,
query: props.query,
});
render: () =>
createSuggestionRenderLifecycle<
SlashCommandItem,
SlashCommandMenuProps
>(
{
component: SlashCommandMenu,
getMenuProps: ({ items, onSelect, editor, range, query }) => ({
items,
onSelect,
editor,
range,
query,
}),
},
onUpdate: (props: SuggestionRenderProps) => {
if (component === null) {
return;
}
if (!props.clientRect) {
return;
}
if (props.items.length === 0) {
closeMenu();
return;
}
component.updateProps({
items: props.items,
command: (item: SlashCommandItem) => {
props.command(item);
closeMenu();
},
clientRect: props.clientRect,
editor: this.editor,
range: props.range,
query: props.query,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === 'Escape') {
closeMenu();
return true;
}
return component?.ref?.onKeyDown?.(props) ?? false;
},
onExit: () => {
closeMenu();
},
};
},
this.editor,
),
}),
];
},

View file

@ -1,220 +1,77 @@
import { ThemeProvider } from '@emotion/react';
import {
autoUpdate,
flip,
offset,
shift,
useFloating,
} from '@floating-ui/react';
import { type Editor, type Range } from '@tiptap/core';
import { motion } from 'framer-motion';
import {
forwardRef,
useCallback,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { forwardRef, useCallback, useState } from 'react';
import { MenuItemSuggestion } from 'twenty-ui/navigation';
import { THEME_DARK, THEME_LIGHT } from 'twenty-ui/theme';
import { type SlashCommandItem } from '@/advanced-text-editor/extensions/slash-command/SlashCommand';
import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer';
import { SuggestionMenu } from '@/ui/suggestion/components/SuggestionMenu';
export type SlashCommandMenuProps = {
items: SlashCommandItem[];
onSelect: (item: SlashCommandItem) => void;
clientRect: (() => DOMRect | null) | null;
editor: Editor;
range: Range;
query: string;
};
const getItemKey = (item: SlashCommandItem) => item.id;
export const SlashCommandMenu = forwardRef<unknown, SlashCommandMenuProps>(
(props, parentRef) => {
(props, ref) => {
const { items, onSelect, editor, range, query } = props;
const colorScheme = document.documentElement.className.includes('dark')
? 'Dark'
: 'Light';
const theme = colorScheme === 'Dark' ? THEME_DARK : THEME_LIGHT;
const [selectedIndex, setSelectedIndex] = useState(0);
const [prevSelectedIndex, setPrevSelectedIndex] = useState(0);
const [prevQuery, setPrevQuery] = useState('');
const activeCommandRef = useRef<HTMLDivElement>(null);
const commandListContainerRef = useRef<HTMLDivElement>(null);
const positionReference = useMemo(
() => ({
getBoundingClientRect: () => {
const start = editor.view.coordsAtPos(range.from);
return new DOMRect(
start.left,
start.top,
0,
start.bottom - start.top,
);
},
}),
[editor, range],
);
const { refs, floatingStyles } = useFloating({
placement: 'bottom-start',
strategy: 'fixed',
middleware: [offset(4), flip(), shift()],
whileElementsMounted: (reference, floating, update) => {
return autoUpdate(reference, floating, update, {
animationFrame: true,
});
},
elements: {
reference: positionReference,
},
});
const selectItem = useCallback(
(index: number) => {
const item = items[index];
if (!item) {
return;
const handleKeyDown = useCallback(
(event: KeyboardEvent, selectedIndex: number) => {
if (event.key === 'ArrowLeft') {
event.preventDefault();
editor.chain().focus().insertContentAt(range, `/${prevQuery}`).run();
setTimeout(() => {
setPrevSelectedIndex(prevSelectedIndex);
}, 0);
return true;
}
onSelect(item);
if (event.key === 'ArrowRight') {
return true;
}
if (event.key === 'Enter' && items.length > 0) {
setPrevQuery(query);
setPrevSelectedIndex(selectedIndex);
}
return undefined;
},
[onSelect, items],
[editor, range, prevQuery, prevSelectedIndex, items.length, query],
);
useImperativeHandle(parentRef, () => ({
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
const navigationKeys = [
'ArrowUp',
'ArrowDown',
'Enter',
'ArrowLeft',
'ArrowRight',
];
if (navigationKeys.includes(event.key)) {
let newCommandIndex = selectedIndex;
switch (event.key) {
case 'ArrowLeft':
event.preventDefault();
editor
.chain()
.focus()
.insertContentAt(range, `/${prevQuery}`)
.run();
setTimeout(() => {
setSelectedIndex(prevSelectedIndex);
}, 0);
return true;
case 'ArrowUp':
if (!items.length) {
return false;
}
newCommandIndex = selectedIndex - 1;
if (newCommandIndex < 0) {
newCommandIndex = items.length - 1;
}
setSelectedIndex(newCommandIndex);
return true;
case 'ArrowDown':
if (!items.length) {
return false;
}
newCommandIndex = selectedIndex + 1;
if (newCommandIndex >= items.length) {
newCommandIndex = 0;
}
setSelectedIndex(newCommandIndex);
return true;
case 'Enter':
if (!items.length) {
return false;
}
selectItem(selectedIndex);
setPrevQuery(query);
setPrevSelectedIndex(selectedIndex);
return true;
default:
return false;
}
}
},
}));
useLayoutEffect(() => {
const container = commandListContainerRef?.current;
const activeCommandContainer = activeCommandRef?.current;
if (!container || !activeCommandContainer) {
return;
}
const scrollableContainer =
container.firstElementChild as HTMLElement | null;
if (!scrollableContainer) {
return;
}
const { offsetTop, offsetHeight } = activeCommandContainer;
scrollableContainer.style.transition = 'none';
scrollableContainer.scrollTop = offsetTop - offsetHeight;
}, [selectedIndex]);
const renderItem = useCallback(
(item: SlashCommandItem, isSelected: boolean) => (
<MenuItemSuggestion
LeftIcon={item.icon}
text={item.title}
selected={isSelected}
onClick={() => {
onSelect(item);
}}
/>
),
[onSelect],
);
return (
<ThemeProvider theme={theme}>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.1 }}
data-slash-command-menu
>
<OverlayContainer
ref={refs.setFloating}
style={{
...floatingStyles,
zIndex: RootStackingContextZIndices.DropdownPortalAboveModal,
}}
>
<DropdownContent ref={commandListContainerRef}>
<DropdownMenuItemsContainer hasMaxHeight>
{items.map((item, index) => {
const isSelected = index === selectedIndex;
return (
<div
key={item.id}
ref={isSelected ? activeCommandRef : null}
onMouseDown={(e) => {
e.preventDefault();
}}
>
<MenuItemSuggestion
LeftIcon={item.icon}
text={item.title}
selected={isSelected}
onClick={() => {
onSelect(item);
}}
/>
</div>
);
})}
</DropdownMenuItemsContainer>
</DropdownContent>
</OverlayContainer>
</motion.div>
</ThemeProvider>
<SuggestionMenu
ref={ref}
items={items}
onSelect={onSelect}
editor={editor}
range={range}
getItemKey={getItemKey}
renderItem={renderItem}
onKeyDown={handleKeyDown}
/>
);
},
);

View file

@ -1,88 +0,0 @@
import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { SlashCommandItem } from '@/advanced-text-editor/extensions/slash-command/SlashCommand';
import {
SlashCommandMenu,
type SlashCommandMenuProps,
} from '@/advanced-text-editor/extensions/slash-command/SlashCommandMenu';
import type { Editor, Range } from '@tiptap/core';
type SlashCommandRendererProps = {
items: SlashCommandItem[];
command: (item: SlashCommandItem) => void;
clientRect: (() => DOMRect | null) | null;
editor: Editor;
range: Range;
query: string;
};
export class SlashCommandRenderer {
componentRoot: Root | null = null;
containerElement: HTMLElement | null = null;
currentProps: SlashCommandRendererProps | null = null;
ref: { onKeyDown?: (props: { event: KeyboardEvent }) => boolean } | null =
null;
constructor(props: SlashCommandRendererProps) {
this.containerElement = document.createElement('div');
document.body.appendChild(this.containerElement);
this.componentRoot = createRoot(this.containerElement);
this.currentProps = props;
this.render(props);
}
render(props: SlashCommandRendererProps): void {
if (!this.componentRoot) {
return;
}
const menuProps: SlashCommandMenuProps = {
items: props.items,
onSelect: props.command,
clientRect: props.clientRect,
editor: props.editor,
range: props.range,
query: props.query,
};
this.componentRoot.render(
createElement(SlashCommandMenu, {
...menuProps,
ref: (
ref: {
onKeyDown?: (props: { event: KeyboardEvent }) => boolean;
} | null,
) => {
this.ref = ref;
},
}),
);
}
updateProps(props: Partial<SlashCommandRendererProps>): void {
if (!this.componentRoot || !this.currentProps) {
return;
}
const updatedProps = { ...this.currentProps, ...props };
this.currentProps = updatedProps;
this.render(updatedProps);
}
destroy(): void {
if (this.componentRoot !== null) {
this.componentRoot.unmount();
this.componentRoot = null;
}
if (this.containerElement !== null) {
this.containerElement.remove();
this.containerElement = null;
}
this.currentProps = null;
this.ref = null;
}
}

View file

@ -1,327 +0,0 @@
import { Editor } from '@tiptap/core';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { type SlashCommandItem } from '@/advanced-text-editor/extensions/slash-command/SlashCommand';
import { SlashCommandRenderer } from '@/advanced-text-editor/extensions/slash-command/SlashCommandRenderer';
describe('SlashCommandRenderer', () => {
let editor: Editor;
let mockCommand: jest.Mock;
let mockClientRect: () => DOMRect;
const createMockItems = (): SlashCommandItem[] => [
{
id: 'test-1',
title: 'Test Command 1',
description: 'Test description',
command: jest.fn(),
},
{
id: 'test-2',
title: 'Test Command 2',
command: jest.fn(),
},
];
beforeEach(() => {
editor = new Editor({
extensions: [Document, Paragraph, Text],
content: '<p></p>',
});
mockCommand = jest.fn();
mockClientRect = () => new DOMRect(100, 100, 200, 50);
});
afterEach(() => {
editor?.destroy();
});
describe('Lifecycle management', () => {
it('should create container element and append to body', () => {
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
expect(renderer.containerElement).toBeInstanceOf(HTMLElement);
expect(document.body.contains(renderer.containerElement)).toBe(true);
renderer.destroy();
});
it('should create React root on initialization', () => {
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
expect(renderer.componentRoot).not.toBeNull();
renderer.destroy();
});
it('should clean up on destroy', () => {
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
const containerElement = renderer.containerElement;
renderer.destroy();
// After destroy, references should be null
expect(renderer.componentRoot).toBeNull();
expect(renderer.containerElement).toBeNull();
expect(renderer.currentProps).toBeNull();
expect(renderer.ref).toBeNull();
// Container should be removed from DOM
expect(document.body.contains(containerElement)).toBe(false);
});
it('should handle multiple destroy calls gracefully', () => {
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
// First destroy
renderer.destroy();
// Second destroy should not throw
expect(() => renderer.destroy()).not.toThrow();
});
});
describe('Props updates', () => {
it('should update items when updateProps is called', () => {
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
const newItems: SlashCommandItem[] = [
{
id: 'new-item',
title: 'New Item',
command: jest.fn(),
},
];
renderer.updateProps({ items: newItems });
expect(renderer.currentProps?.items).toEqual(newItems);
renderer.destroy();
});
it('should update query when updateProps is called', () => {
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
renderer.updateProps({ query: 'heading' });
expect(renderer.currentProps?.query).toBe('heading');
renderer.destroy();
});
it('should not update props if component is destroyed', () => {
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
renderer.destroy();
// After destroy, updateProps should not throw
expect(() => renderer.updateProps({ query: 'test' })).not.toThrow();
});
it('should preserve unmodified props when updating', () => {
const originalItems = createMockItems();
const renderer = new SlashCommandRenderer({
items: originalItems,
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: 'original',
});
// Update only query
renderer.updateProps({ query: 'updated' });
// Items should remain the same
expect(renderer.currentProps?.items).toEqual(originalItems);
// Query should be updated
expect(renderer.currentProps?.query).toBe('updated');
renderer.destroy();
});
});
describe('Render method', () => {
it('should not throw when render is called', () => {
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
expect(() =>
renderer.render({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
}),
).not.toThrow();
renderer.destroy();
});
it('should not render if component root is null', () => {
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
// Destroy to set componentRoot to null
renderer.destroy();
// Render should not throw even with null componentRoot
expect(() =>
renderer.render({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
}),
).not.toThrow();
});
});
describe('Ref handling', () => {
it('should initialize ref as null', () => {
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
expect(renderer.ref).toBeNull();
renderer.destroy();
});
it('should clear ref on destroy', () => {
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
// Simulate ref being set
renderer.ref = { onKeyDown: jest.fn() };
renderer.destroy();
expect(renderer.ref).toBeNull();
});
});
describe('Memory management', () => {
it('should not leak DOM elements after destroy', () => {
const initialBodyChildCount = document.body.childElementCount;
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
// Should have added one element
expect(document.body.childElementCount).toBe(initialBodyChildCount + 1);
renderer.destroy();
// Should be back to initial count
expect(document.body.childElementCount).toBe(initialBodyChildCount);
});
it('should handle rapid create/destroy cycles', () => {
const initialBodyChildCount = document.body.childElementCount;
// Create and destroy multiple renderers rapidly
for (let i = 0; i < 10; i++) {
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
renderer.destroy();
}
// Should be back to initial count
expect(document.body.childElementCount).toBe(initialBodyChildCount);
});
});
});

View file

@ -57,7 +57,13 @@ const InitialLoadingIndicator = () => {
);
};
const MessagePartRenderer = ({ part }: { part: ExtendedUIMessagePart }) => {
const MessagePartRenderer = ({
part,
isStreaming,
}: {
part: ExtendedUIMessagePart;
isStreaming: boolean;
}) => {
switch (part.type) {
case 'reasoning':
return (
@ -85,7 +91,7 @@ const MessagePartRenderer = ({ part }: { part: ExtendedUIMessagePart }) => {
);
default:
if (isToolUIPart(part)) {
return <ToolStepRenderer toolPart={part} />;
return <ToolStepRenderer toolPart={part} isStreaming={isStreaming} />;
}
return null;
}
@ -117,7 +123,11 @@ export const AIChatAssistantMessageRenderer = ({
<div>
<StyledMessagePartsContainer>
{filteredParts.map((part, index) => (
<MessagePartRenderer key={index} part={part} />
<MessagePartRenderer
key={index}
part={part}
isStreaming={isLastMessageStreaming}
/>
))}
</StyledMessagePartsContainer>
{isLastMessageStreaming && !hasError && <StyledStreamingIndicator />}

View file

@ -1,4 +1,5 @@
import styled from '@emotion/styled';
import { type Editor } from '@tiptap/react';
import { AIChatSuggestedPrompts } from '@/ai/components/suggested-prompts/AIChatSuggestedPrompts';
@ -10,10 +11,14 @@ const StyledEmptyState = styled.div`
height: 100%;
`;
export const AIChatEmptyState = () => {
type AIChatEmptyStateProps = {
editor: Editor | null;
};
export const AIChatEmptyState = ({ editor }: AIChatEmptyStateProps) => {
return (
<StyledEmptyState>
<AIChatSuggestedPrompts />
<AIChatSuggestedPrompts editor={editor} />
</StyledEmptyState>
);
};

View file

@ -1,5 +1,5 @@
import { TextArea } from '@/ui/input/components/TextArea';
import styled from '@emotion/styled';
import { EditorContent } from '@tiptap/react';
import { IconHistory } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
@ -17,15 +17,13 @@ import { AIChatSkeletonLoader } from '@/ai/components/internal/AIChatSkeletonLoa
import { AgentChatContextPreview } from '@/ai/components/internal/AgentChatContextPreview';
import { SendMessageButton } from '@/ai/components/internal/SendMessageButton';
import { AgentMessageRole } from '@/ai/constants/AgentMessageRole';
import { AI_CHAT_INPUT_ID } from '@/ai/constants/AiChatInputId';
import { AI_CHAT_SCROLL_WRAPPER_ID } from '@/ai/constants/AiChatScrollWrapperId';
import { useAIChatEditor } from '@/ai/hooks/useAIChatEditor';
import { useAIChatFileUpload } from '@/ai/hooks/useAIChatFileUpload';
import { useAgentChatContextOrThrow } from '@/ai/hooks/useAgentChatContextOrThrow';
import { agentChatInputState } from '@/ai/states/agentChatInputState';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { useRecoilState } from 'recoil';
const StyledContainer = styled.div<{ isDraggingFile: boolean }>`
background: ${({ theme }) => theme.background.primary};
@ -65,25 +63,39 @@ const StyledInputBox = styled.div`
}
`;
const StyledTextAreaWrapper = styled.div`
const StyledEditorWrapper = styled.div`
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
`;
const StyledChatTextArea = styled(TextArea)`
&& {
.tiptap {
background: transparent;
border: none;
border-radius: 0;
box-shadow: none;
color: ${({ theme }) => theme.font.color.primary};
font-family: inherit;
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.regular};
line-height: 16px;
outline: none;
padding: 0;
}
min-height: 48px;
max-height: 320px;
overflow-y: auto;
&&:focus {
border: none;
box-shadow: none;
p {
margin: 0;
}
p.is-editor-empty:first-of-type::before {
color: ${({ theme }) => theme.font.color.light};
content: attr(data-placeholder);
float: left;
font-weight: ${({ theme }) => theme.font.weight.regular};
height: 0;
pointer-events: none;
}
}
`;
@ -108,15 +120,16 @@ const StyledButtonsContainer = styled.div`
export const AIChatTab = () => {
const [isDraggingFile, setIsDraggingFile] = useState(false);
const isMobile = useIsMobile();
const { isLoading, messages, isStreaming, error } =
const { isLoading, messages, isStreaming, error, handleSendMessage } =
useAgentChatContextOrThrow();
const [agentChatInput, setAgentChatInput] =
useRecoilState(agentChatInputState);
const { uploadFiles } = useAIChatFileUpload();
const { navigateCommandMenu } = useCommandMenu();
const { editor, handleSendAndClear } = useAIChatEditor({
onSendMessage: handleSendMessage,
});
return (
<StyledContainer
isDraggingFile={isDraggingFile}
@ -158,7 +171,7 @@ export const AIChatTab = () => {
</StyledScrollWrapper>
)}
{messages.length === 0 && !error && !isLoading && (
<AIChatEmptyState />
<AIChatEmptyState editor={editor} />
)}
{messages.length === 0 && error && !isLoading && (
<AIChatStandaloneError error={error} />
@ -168,16 +181,9 @@ export const AIChatTab = () => {
<StyledInputArea isMobile={isMobile}>
<AgentChatContextPreview />
<StyledInputBox>
<StyledTextAreaWrapper>
<StyledChatTextArea
textAreaId={AI_CHAT_INPUT_ID}
placeholder={t`Ask, search or make anything...`}
value={agentChatInput}
onChange={(value) => setAgentChatInput(value)}
minRows={3}
maxRows={20}
/>
</StyledTextAreaWrapper>
<StyledEditorWrapper>
<EditorContent editor={editor} />
</StyledEditorWrapper>
<StyledButtonsContainer>
<AIChatContextUsageButton />
<IconButton
@ -194,7 +200,7 @@ export const AIChatTab = () => {
ariaLabel={t`View Previous AI Chats`}
/>
<AgentChatFileUploadButton />
<SendMessageButton />
<SendMessageButton onSend={handleSendAndClear} />
</StyledButtonsContainer>
</StyledInputBox>
</StyledInputArea>

View file

@ -102,23 +102,10 @@ const MarkdownRenderer = lazy(async () => {
};
});
const StyledTableScrollContainer = styled.div`
overflow-x: auto;
table {
border-collapse: collapse;
margin-block: ${({ theme }) => theme.spacing(2)};
}
th,
td {
border: ${({ theme }) => `1px solid ${theme.border.color.light}`};
padding: ${({ theme }) => theme.spacing(2)};
}
th {
background-color: ${({ theme }) => theme.background.secondary};
font-weight: ${({ theme }) => theme.font.weight.medium};
const StyledMarkdownContainer = styled.div`
img {
height: auto;
max-width: 100%;
}
`;
@ -142,6 +129,26 @@ const StyledSkeletonContainer = styled.div`
width: 100%;
`;
const StyledTableScrollContainer = styled.div`
overflow-x: auto;
table {
border-collapse: collapse;
margin-block: ${({ theme }) => theme.spacing(2)};
}
th,
td {
border: ${({ theme }) => `1px solid ${theme.border.color.light}`};
padding: ${({ theme }) => theme.spacing(2)};
}
th {
background-color: ${({ theme }) => theme.background.secondary};
font-weight: ${({ theme }) => theme.font.weight.medium};
}
`;
const LoadingSkeleton = () => {
const theme = useTheme();
@ -179,13 +186,15 @@ const LoadingSkeleton = () => {
export const LazyMarkdownRenderer = ({ text }: { text: string }) => {
return (
<Suspense fallback={<LoadingSkeleton />}>
<MarkdownRenderer
TableScrollContainer={StyledTableScrollContainer}
StyledParagraph={StyledParagraph}
>
{text}
</MarkdownRenderer>
</Suspense>
<StyledMarkdownContainer>
<Suspense fallback={<LoadingSkeleton />}>
<MarkdownRenderer
TableScrollContainer={StyledTableScrollContainer}
StyledParagraph={StyledParagraph}
>
{text}
</MarkdownRenderer>
</Suspense>
</StyledMarkdownContainer>
);
};

View file

@ -123,7 +123,13 @@ const StyledTab = styled.div<{ isActive: boolean }>`
type TabType = 'output' | 'input';
export const ToolStepRenderer = ({ toolPart }: { toolPart: ToolUIPart }) => {
export const ToolStepRenderer = ({
toolPart,
isStreaming,
}: {
toolPart: ToolUIPart;
isStreaming: boolean;
}) => {
const { t } = useLingui();
const theme = useTheme();
const { copyToClipboard } = useCopyToClipboard();
@ -151,7 +157,7 @@ export const ToolStepRenderer = ({ toolPart }: { toolPart: ToolUIPart }) => {
};
} | null;
const isRunning = !output && !hasError;
const isRunning = !output && !hasError && isStreaming;
return (
<CodeExecutionDisplay
@ -166,17 +172,23 @@ export const ToolStepRenderer = ({ toolPart }: { toolPart: ToolUIPart }) => {
}
if (!output && !hasError) {
const displayText = isStreaming
? getToolDisplayMessage(input, rawToolName, false)
: getToolDisplayMessage(input, rawToolName, true);
return (
<StyledContainer>
<StyledToggleButton isExpandable={false}>
<StyledLeftContent>
<StyledIconTextContainer>
<ToolIcon size={theme.icon.size.sm} />
<ShimmeringText>
<StyledDisplayMessage>
{getToolDisplayMessage(input, rawToolName, false)}
</StyledDisplayMessage>
</ShimmeringText>
{isStreaming ? (
<ShimmeringText>
<StyledDisplayMessage>{displayText}</StyledDisplayMessage>
</ShimmeringText>
) : (
<StyledDisplayMessage>{displayText}</StyledDisplayMessage>
)}
</StyledIconTextContainer>
</StyledLeftContent>
<StyledRightContent>

View file

@ -161,6 +161,7 @@ export const AIChatContextUsageButton = () => {
{isHovered && (
<StyledHoverCard>
<StyledSection>
<StyledSectionTitle>{t`Context window`}</StyledSectionTitle>
<StyledRow>
<StyledPercentage>{formattedPercentage}%</StyledPercentage>
<StyledValue>

View file

@ -1,31 +1,16 @@
import { AI_CHAT_INPUT_ID } from '@/ai/constants/AiChatInputId';
import { useAgentChatContextOrThrow } from '@/ai/hooks/useAgentChatContextOrThrow';
import { agentChatInputState } from '@/ai/states/agentChatInputState';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';
import { IconArrowUp, IconPlayerStop } from 'twenty-ui/display';
import { RoundedIconButton } from 'twenty-ui/input';
export const SendMessageButton = () => {
const agentChatInput = useRecoilValue(agentChatInputState);
const { handleSendMessage, handleStop, isLoading, isStreaming } =
useAgentChatContextOrThrow();
type SendMessageButtonProps = {
onSend: () => void;
};
useHotkeysOnFocusedElement({
keys: [Key.Enter],
callback: (event: KeyboardEvent) => {
if (!event.ctrlKey && !event.metaKey) {
event.preventDefault();
handleSendMessage();
}
},
focusId: AI_CHAT_INPUT_ID,
dependencies: [agentChatInput, isLoading],
options: {
enableOnFormTags: true,
},
});
export const SendMessageButton = ({ onSend }: SendMessageButtonProps) => {
const agentChatInput = useRecoilValue(agentChatInputState);
const { handleStop, isLoading, isStreaming } = useAgentChatContextOrThrow();
if (isStreaming) {
return (
@ -41,7 +26,7 @@ export const SendMessageButton = () => {
<RoundedIconButton
Icon={IconArrowUp}
size="medium"
onClick={() => handleSendMessage()}
onClick={onSend}
disabled={!agentChatInput || isLoading}
/>
);

View file

@ -1,6 +1,7 @@
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { type Editor } from '@tiptap/react';
import { useSetRecoilState } from 'recoil';
import { LightButton } from 'twenty-ui/input';
@ -31,14 +32,26 @@ const StyledSuggestedPromptButton = styled(LightButton)`
const pickRandom = <T,>(items: T[]): T =>
items[Math.floor(Math.random() * items.length)];
export const AIChatSuggestedPrompts = () => {
type AIChatSuggestedPromptsProps = {
editor: Editor | null;
};
export const AIChatSuggestedPrompts = ({
editor,
}: AIChatSuggestedPromptsProps) => {
const { t: resolveMessage } = useLingui();
const setAgentChatInput = useSetRecoilState(agentChatInputState);
const handleClick = (prompt: SuggestedPrompt) => {
const picked = pickRandom(prompt.prefillPrompts);
const text = resolveMessage(picked);
setAgentChatInput(resolveMessage(picked));
setAgentChatInput(text);
editor?.commands.setContent({
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
});
editor?.commands.focus('end');
};
return (

View file

@ -0,0 +1,116 @@
import { t } from '@lingui/core/macro';
import { Document } from '@tiptap/extension-document';
import { HardBreak } from '@tiptap/extension-hard-break';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { Placeholder } from '@tiptap/extensions';
import { useEditor } from '@tiptap/react';
import { useCallback, useMemo } from 'react';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { AI_CHAT_INPUT_ID } from '@/ai/constants/AiChatInputId';
import { agentChatInputState } from '@/ai/states/agentChatInputState';
import { MENTION_SUGGESTION_PLUGIN_KEY } from '@/mention/constants/MentionSuggestionPluginKey';
import { MentionSuggestion } from '@/mention/extensions/MentionSuggestion';
import { MentionTag } from '@/mention/extensions/MentionTag';
import { useMentionSearch } from '@/mention/hooks/useMentionSearch';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly';
type UseAIChatEditorProps = {
onSendMessage: () => void;
};
export const useAIChatEditor = ({ onSendMessage }: UseAIChatEditorProps) => {
const setAgentChatInput = useSetRecoilState(agentChatInputState);
const { searchMentionRecords } = useMentionSearch();
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { removeFocusItemFromFocusStackById } =
useRemoveFocusItemFromFocusStackById();
const extensions = useMemo(
() => [
Document,
Paragraph,
Text,
Placeholder.configure({
placeholder: t`Ask, search or make anything...`,
}),
HardBreak.configure({
keepMarks: false,
}),
MentionTag,
MentionSuggestion,
],
[],
);
const editor = useEditor({
extensions,
editorProps: {
handleKeyDown: (view, event) => {
if (event.key === 'Enter' && !event.shiftKey) {
const suggestionState = MENTION_SUGGESTION_PLUGIN_KEY.getState(
view.state,
);
if (suggestionState?.active === true) {
return false;
}
event.preventDefault();
onSendMessage();
const { state } = view;
view.dispatch(state.tr.delete(0, state.doc.content.size));
return true;
}
return false;
},
},
onUpdate: ({ editor: currentEditor }) => {
const text = turnIntoEmptyStringIfWhitespacesOnly(
currentEditor.getText({ blockSeparator: '\n' }),
);
setAgentChatInput(text);
},
onFocus: () => {
pushFocusItemToFocusStack({
focusId: AI_CHAT_INPUT_ID,
component: {
type: FocusComponentType.TEXT_AREA,
instanceId: AI_CHAT_INPUT_ID,
},
globalHotkeysConfig: {
enableGlobalHotkeysConflictingWithKeyboard: false,
},
});
},
onBlur: () => {
removeFocusItemFromFocusStackById({ focusId: AI_CHAT_INPUT_ID });
},
injectCSS: false,
});
// Keep search function in sync via Tiptap extension storage,
// avoiding stale closures without useRef
if (isDefined(editor)) {
const storage = editor.extensionStorage as unknown as Record<
string,
unknown
>;
const mentionStorage = storage['mention-suggestion'] as {
searchMentionRecords: typeof searchMentionRecords;
};
mentionStorage.searchMentionRecords = searchMentionRecords;
}
const handleSendAndClear = useCallback(() => {
onSendMessage();
editor?.commands.clearContent();
}, [onSendMessage, editor]);
return { editor, handleSendAndClear };
};

View file

@ -0,0 +1,175 @@
import { i18n } from '@lingui/core';
import {
getToolDisplayMessage,
resolveToolInput,
} from '@/ai/utils/getToolDisplayMessage';
beforeEach(() => {
i18n.load('en', {});
i18n.activate('en');
});
describe('resolveToolInput', () => {
it('should pass through non-execute_tool inputs unchanged', () => {
const input = { query: 'test' };
const result = resolveToolInput(input, 'web_search');
expect(result).toEqual({
resolvedInput: input,
resolvedToolName: 'web_search',
});
});
it('should unwrap execute_tool input', () => {
const input = {
toolName: 'find_companies',
arguments: { filter: { name: 'Acme' } },
};
const result = resolveToolInput(input, 'execute_tool');
expect(result).toEqual({
resolvedInput: { filter: { name: 'Acme' } },
resolvedToolName: 'find_companies',
});
});
it('should return original input for non-execute_tool even with toolName field', () => {
const input = { toolName: 'inner', arguments: {} };
const result = resolveToolInput(input, 'web_search');
expect(result).toEqual({
resolvedInput: input,
resolvedToolName: 'web_search',
});
});
});
describe('getToolDisplayMessage', () => {
describe('web_search', () => {
it('should show finished message with query', () => {
const message = getToolDisplayMessage(
{ query: 'CRM tools' },
'web_search',
true,
);
expect(message).toContain('Searched');
expect(message).toContain('CRM tools');
});
it('should show in-progress message with query', () => {
const message = getToolDisplayMessage(
{ query: 'CRM tools' },
'web_search',
false,
);
expect(message).toContain('Searching');
expect(message).toContain('CRM tools');
});
it('should handle nested query format', () => {
const message = getToolDisplayMessage(
{ action: { query: 'nested query' } },
'web_search',
true,
);
expect(message).toContain('nested query');
});
it('should handle missing query', () => {
const message = getToolDisplayMessage({}, 'web_search', true);
expect(message).toContain('Searched the web');
});
});
describe('learn_tools', () => {
it('should show tool names when provided', () => {
const message = getToolDisplayMessage(
{ toolNames: ['find_companies', 'create_task'] },
'learn_tools',
true,
);
expect(message).toContain('Learned');
expect(message).toContain('find_companies, create_task');
});
it('should show generic message without tool names', () => {
const message = getToolDisplayMessage({}, 'learn_tools', true);
expect(message).toContain('Learned tools');
});
});
describe('load_skills', () => {
it('should show skill names when provided', () => {
const message = getToolDisplayMessage(
{ skillNames: ['data-manipulation'] },
'load_skills',
false,
);
expect(message).toContain('Loading');
expect(message).toContain('data-manipulation');
});
it('should show generic message without skill names', () => {
const message = getToolDisplayMessage({}, 'load_skills', true);
expect(message).toContain('Loaded skills');
});
});
describe('custom loading message', () => {
it('should use loadingMessage when provided', () => {
const message = getToolDisplayMessage(
{ loadingMessage: 'Building dashboard...' },
'some_tool',
false,
);
expect(message).toBe('Building dashboard...');
});
});
describe('generic tools', () => {
it('should format tool name with spaces for finished state', () => {
const message = getToolDisplayMessage(
{},
'create_complete_dashboard',
true,
);
expect(message).toContain('Ran');
expect(message).toContain('create complete dashboard');
});
it('should format tool name with spaces for in-progress state', () => {
const message = getToolDisplayMessage(
{},
'create_complete_dashboard',
false,
);
expect(message).toContain('Running');
expect(message).toContain('create complete dashboard');
});
});
describe('execute_tool wrapper', () => {
it('should unwrap execute_tool and display inner tool name', () => {
const message = getToolDisplayMessage(
{ toolName: 'find_companies', arguments: { limit: 10 } },
'execute_tool',
true,
);
expect(message).toContain('Ran');
expect(message).toContain('find companies');
});
});
});

View file

@ -91,8 +91,8 @@ export const getToolDisplayMessage = (
if (isNonEmptyString(query)) {
return byStatus(
t`Searched the web for '${query}'`,
t`Searching the web for '${query}'`,
t`Searched the web for ${query}`,
t`Searching the web for ${query}`,
);
}

View file

@ -1,3 +1,4 @@
import { MentionRecordChip } from '@/mention/components/MentionRecordChip';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { RecordChip } from '@/object-record/components/RecordChip';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
@ -15,7 +16,14 @@ const StyledRecordChip = styled(RecordChip)`
padding: ${({ theme }) => `0 ${theme.spacing(1)}`};
`;
const MentionInlineContentRenderer = ({
const StyledInlineMentionRecordChip = styled(MentionRecordChip)`
height: auto;
margin: 0;
padding: ${({ theme }) => `0 ${theme.spacing(1)}`};
`;
// Backward-compatible renderer for legacy notes that only stored objectMetadataId + recordId
const LegacyMentionRenderer = ({
recordId,
objectMetadataId,
}: {
@ -79,15 +87,43 @@ export const MentionInlineContent = createReactInlineContentSpec(
objectMetadataId: {
default: '' as const,
},
objectNameSingular: {
default: '' as const,
},
label: {
default: '' as const,
},
imageUrl: {
default: '' as const,
},
},
content: 'none',
},
{
render: (props) => {
const { recordId, objectMetadataId } = props.inlineContent.props;
const {
recordId,
objectMetadataId,
objectNameSingular,
label,
imageUrl,
} = props.inlineContent.props;
// New notes store objectNameSingular + label + imageUrl directly
if (isNonEmptyString(objectNameSingular) && isNonEmptyString(label)) {
return (
<StyledInlineMentionRecordChip
recordId={recordId}
objectNameSingular={objectNameSingular}
label={label}
imageUrl={imageUrl}
/>
);
}
// Legacy notes only have objectMetadataId + recordId: fetch data
return (
<MentionInlineContentRenderer
<LegacyMentionRenderer
recordId={recordId}
objectMetadataId={objectMetadataId}
/>

View file

@ -4,8 +4,8 @@ import {
defaultInlineContentSpecs,
} from '@blocknote/core';
import { FileBlock } from '@/activities/blocks/components/FileBlock';
import { MentionInlineContent } from '@/activities/blocks/components/MentionInlineContent';
import { FileBlock } from '@/blocknote-editor/blocks/FileBlock';
import { MentionInlineContent } from '@/blocknote-editor/blocks/MentionInlineContent';
export const BLOCK_SCHEMA = BlockNoteSchema.create({
blockSpecs: {

View file

@ -5,15 +5,15 @@ import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { type ClipboardEvent } from 'react';
import { type BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
import { getSlashMenu } from '@/activities/blocks/utils/getSlashMenu';
import { CustomMentionMenu } from '@/ui/input/editor/components/CustomMentionMenu';
import { CustomSideMenu } from '@/ui/input/editor/components/CustomSideMenu';
import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
import { getSlashMenu } from '@/blocknote-editor/utils/getSlashMenu';
import { CustomMentionMenu } from '@/blocknote-editor/components/CustomMentionMenu';
import { CustomSideMenu } from '@/blocknote-editor/components/CustomSideMenu';
import {
CustomSlashMenu,
type SuggestionItem,
} from '@/ui/input/editor/components/CustomSlashMenu';
import { useMentionMenu } from '@/ui/input/editor/hooks/useMentionMenu';
} from '@/blocknote-editor/components/CustomSlashMenu';
import { useMentionMenu } from '@/mention/hooks/useMentionMenu';
interface BlockEditorProps {
editor: typeof BLOCK_SCHEMA.BlockNoteEditor;

View file

@ -1,5 +1,5 @@
import { type BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
import { isSlashMenuOpenComponentState } from '@/ui/input/editor/states/isSlashMenuOpenComponentState';
import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
import { isSlashMenuOpenComponentState } from '@/blocknote-editor/states/isSlashMenuOpenComponentState';
import { useGoBackToPreviousDropdownFocusId } from '@/ui/layout/dropdown/hooks/useGoBackToPreviousDropdownFocusId';
import { useSetActiveDropdownFocusIdAndMemorizePrevious } from '@/ui/layout/dropdown/hooks/useSetFocusedDropdownIdAndMemorizePrevious';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';

View file

@ -1,4 +1,4 @@
import { type BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
import { useComponentsContext } from '@blocknote/react';

View file

@ -5,17 +5,14 @@ import { type MouseEvent as ReactMouseEvent } from 'react';
import { createPortal } from 'react-dom';
import { MENTION_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/ui/input/constants/MentionMenuDropdownClickOutsideId';
import { MENTION_MENU_LIST_ID } from '@/ui/input/constants/MentionMenuListId';
import { CustomMentionMenuListItem } from '@/ui/input/editor/components/CustomMentionMenuListItem';
import { CustomMentionMenuSelectedIndexSyncEffect } from '@/ui/input/editor/components/CustomMentionMenuSelectedIndexSyncEffect';
import { MentionMenuListItem } from '@/mention/components/MentionMenuListItem';
import {
type CustomMentionMenuProps,
type MentionItem,
} from '@/ui/input/editor/components/types';
} from '@/blocknote-editor/types/types';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { isDefined } from 'twenty-shared/utils';
export type { MentionItem };
@ -54,10 +51,6 @@ export const CustomMentionMenu = ({
return (
<StyledContainer ref={refs.setReference}>
<CustomMentionMenuSelectedIndexSyncEffect
items={filteredItems}
selectedIndex={selectedIndex}
/>
<>
{createPortal(
<motion.div
@ -73,22 +66,18 @@ export const CustomMentionMenu = ({
>
<DropdownContent widthInPixels={MenuPixelWidth}>
<DropdownMenuItemsContainer hasMaxHeight>
<SelectableList
focusId={MENTION_MENU_DROPDOWN_CLICK_OUTSIDE_ID}
selectableListInstanceId={MENTION_MENU_LIST_ID}
selectableItemIdArray={filteredItems.map(
(item) => item.recordId!,
)}
>
{filteredItems.map((item) => (
<CustomMentionMenuListItem
key={item.recordId!}
recordId={item.recordId!}
onClick={() => onItemClick?.(item)}
objectNameSingular={item.objectNameSingular!}
/>
))}
</SelectableList>
{filteredItems.map((item, index) => (
<MentionMenuListItem
key={item.recordId!}
recordId={item.recordId!}
objectNameSingular={item.objectNameSingular!}
label={item.label ?? item.title}
imageUrl={item.imageUrl ?? ''}
objectLabelSingular={item.objectLabelSingular ?? ''}
isSelected={index === selectedIndex}
onClick={() => onItemClick?.(item)}
/>
))}
</DropdownMenuItemsContainer>
</DropdownContent>
</OverlayContainer>

View file

@ -1,6 +1,6 @@
import { type BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
import { CustomAddBlockItem } from '@/ui/input/editor/components/CustomAddBlockItem';
import { CustomSideMenuOptions } from '@/ui/input/editor/components/CustomSideMenuOptions';
import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
import { CustomAddBlockItem } from '@/blocknote-editor/components/CustomAddBlockItem';
import { CustomSideMenuOptions } from '@/blocknote-editor/components/CustomSideMenuOptions';
import {
BlockColorsItem,
DragHandleButton,

View file

@ -6,12 +6,12 @@ import { createPortal } from 'react-dom';
import { SLASH_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/ui/input/constants/SlashMenuDropdownClickOutsideId';
import { SLASH_MENU_LIST_ID } from '@/ui/input/constants/SlashMenuListId';
import { CustomSlashMenuListItem } from '@/ui/input/editor/components/CustomSlashMenuListItem';
import { CustomSlashMenuSelectedIndexSyncEffect } from '@/ui/input/editor/components/CustomSlashMenuSelectedIndexSyncEffect';
import { CustomSlashMenuListItem } from '@/blocknote-editor/components/CustomSlashMenuListItem';
import { CustomSlashMenuSelectedIndexSyncEffect } from '@/blocknote-editor/components/CustomSlashMenuSelectedIndexSyncEffect';
import type {
CustomSlashMenuProps,
SuggestionItem,
} from '@/ui/input/editor/components/types';
} from '@/blocknote-editor/types/types';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer';

View file

@ -1,5 +1,5 @@
import { SLASH_MENU_LIST_ID } from '@/ui/input/constants/SlashMenuListId';
import { type SuggestionItem } from '@/ui/input/editor/components/types';
import { type SuggestionItem } from '@/blocknote-editor/types/types';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { isSelectedItemIdComponentFamilySelector } from '@/ui/layout/selectable-list/states/selectors/isSelectedItemIdComponentFamilySelector';

View file

@ -1,5 +1,5 @@
import { SLASH_MENU_LIST_ID } from '@/ui/input/constants/SlashMenuListId';
import { type SuggestionItem } from '@/ui/input/editor/components/types';
import { type SuggestionItem } from '@/blocknote-editor/types/types';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { useEffect } from 'react';
import { isDefined } from 'twenty-shared/utils';

View file

@ -1,4 +1,4 @@
import { BlockEditorComponentInstanceContext } from '@/ui/input/editor/contexts/BlockEditorCompoponeInstanceContext';
import { BlockEditorComponentInstanceContext } from '@/blocknote-editor/contexts/BlockEditorCompoponeInstanceContext';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
export const isSlashMenuOpenComponentState = createComponentState<boolean>({

View file

@ -15,6 +15,9 @@ export type MentionItem = DefaultReactSuggestionItem & {
recordId?: string;
objectNameSingular?: string;
objectMetadataId?: string;
label?: string;
imageUrl?: string;
objectLabelSingular?: string;
};
export type CustomMentionMenuProps = SuggestionMenuProps<MentionItem>;

View file

@ -1,5 +1,5 @@
import type { PartialBlock } from '@blocknote/core';
import { getFirstNonEmptyLineOfRichText } from '@/ui/input/editor/utils/getFirstNonEmptyLineOfRichText';
import { getFirstNonEmptyLineOfRichText } from '@/blocknote-editor/utils/getFirstNonEmptyLineOfRichText';
describe('getFirstNonEmptyLineOfRichText', () => {
it('should return an empty string if the input is null', () => {

View file

@ -1,4 +1,4 @@
import { parseInitialBlocknote } from '@/ui/input/editor/utils/parseInitialBlocknote';
import { parseInitialBlocknote } from '@/blocknote-editor/utils/parseInitialBlocknote';
describe('parseInitialBlocknote', () => {
it('should parse valid JSON array string', () => {

View file

@ -1,4 +1,4 @@
import { prepareBodyWithSignedUrls } from '@/ui/input/editor/utils/prepareBodyWithSignedUrls';
import { prepareBodyWithSignedUrls } from '@/blocknote-editor/utils/prepareBodyWithSignedUrls';
describe('prepareBodyWithSignedUrls', () => {
it('should return empty string as-is', () => {

View file

@ -1,8 +1,8 @@
import { getDefaultReactSlashMenuItems } from '@blocknote/react';
import { type SuggestionItem } from '@/ui/input/editor/components/CustomSlashMenu';
import { type SuggestionItem } from '@/blocknote-editor/components/CustomSlashMenu';
import { type BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
import {
IconBlockquote,
IconCode,

View file

@ -0,0 +1,24 @@
import { type NodeViewProps } from '@tiptap/core';
import { NodeViewWrapper } from '@tiptap/react';
import { MentionRecordChip } from '@/mention/components/MentionRecordChip';
type MentionChipProps = Pick<NodeViewProps, 'node'>;
export const MentionChip = ({ node }: MentionChipProps) => {
const recordId = node.attrs.recordId as string;
const objectNameSingular = node.attrs.objectNameSingular as string;
const label = node.attrs.label as string;
const imageUrl = (node.attrs.imageUrl as string) ?? '';
return (
<NodeViewWrapper as="span" style={{ display: 'inline' }}>
<MentionRecordChip
recordId={recordId}
objectNameSingular={objectNameSingular}
label={label}
imageUrl={imageUrl}
/>
</NodeViewWrapper>
);
};

View file

@ -0,0 +1,50 @@
import { type MouseEvent } from 'react';
import { getAvatarType } from '@/object-metadata/utils/getAvatarType';
import { Avatar } from 'twenty-ui/display';
import { MenuItemSuggestion } from 'twenty-ui/navigation';
type MentionMenuListItemProps = {
recordId: string;
objectNameSingular: string;
label: string;
imageUrl: string;
objectLabelSingular: string;
isSelected: boolean;
onClick: () => void;
};
export const MentionMenuListItem = ({
recordId,
objectNameSingular,
label,
imageUrl,
objectLabelSingular,
isSelected,
onClick,
}: MentionMenuListItemProps) => {
const handleClick = (event?: MouseEvent) => {
event?.preventDefault();
event?.stopPropagation();
onClick();
};
return (
<MenuItemSuggestion
selected={isSelected}
onClick={handleClick}
text={label}
contextualText={objectLabelSingular}
contextualTextPosition="left"
LeftIcon={() => (
<Avatar
placeholder={label}
placeholderColorSeed={recordId}
avatarUrl={imageUrl}
type={getAvatarType(objectNameSingular) ?? 'rounded'}
size="sm"
/>
)}
/>
);
};

View file

@ -0,0 +1,62 @@
import { getLinkToShowPage } from '@/object-metadata/utils/getLinkToShowPage';
import { t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { AvatarChip, Chip, ChipVariant, LinkChip } from 'twenty-ui/components';
type MentionRecordChipProps = {
recordId: string;
objectNameSingular: string;
label: string;
imageUrl: string;
className?: string;
};
export const MentionRecordChip = ({
recordId,
objectNameSingular,
label,
imageUrl,
className,
}: MentionRecordChipProps) => {
if (!isNonEmptyString(objectNameSingular)) {
return (
<Chip
label={t`Unknown object`}
variant={ChipVariant.Transparent}
disabled
/>
);
}
if (!isNonEmptyString(recordId)) {
return (
<Chip
label={t`Deleted record`}
variant={ChipVariant.Transparent}
disabled
/>
);
}
const linkToShowPage = getLinkToShowPage(objectNameSingular, {
id: recordId,
});
return (
<LinkChip
label={label}
emptyLabel={t`Untitled`}
to={linkToShowPage}
variant={ChipVariant.Highlighted}
className={className}
leftComponent={
<AvatarChip
placeholder={label}
placeholderColorSeed={recordId}
avatarType="rounded"
avatarUrl={imageUrl}
/>
}
/>
);
};

View file

@ -0,0 +1,52 @@
import { forwardRef } from 'react';
import { AvatarChip } from 'twenty-ui/components';
import { MenuItemSuggestion } from 'twenty-ui/navigation';
import type { MentionSearchResult } from '@/mention/types/MentionSearchResult';
import type { MentionSuggestionMenuProps } from '@/mention/types/MentionSuggestionMenuProps';
import { SuggestionMenu } from '@/ui/suggestion/components/SuggestionMenu';
const getItemKey = (item: MentionSearchResult) =>
`${item.objectNameSingular}-${item.recordId}`;
const renderItem = (
item: MentionSearchResult,
isSelected: boolean,
onSelect: (item: MentionSearchResult) => void,
) => (
<MenuItemSuggestion
LeftIcon={() => (
<AvatarChip
placeholder={item.label}
placeholderColorSeed={item.recordId}
avatarType="rounded"
avatarUrl={item.imageUrl}
/>
)}
text={item.label}
contextualText={item.objectLabelSingular}
selected={isSelected}
onClick={() => {
onSelect(item);
}}
/>
);
export const MentionSuggestionMenu = forwardRef<
unknown,
MentionSuggestionMenuProps
>((props, ref) => {
const { items, onSelect, editor, range } = props;
return (
<SuggestionMenu
ref={ref}
items={items}
onSelect={onSelect}
editor={editor}
range={range}
getItemKey={getItemKey}
renderItem={(item, isSelected) => renderItem(item, isSelected, onSelect)}
/>
);
});

View file

@ -0,0 +1,5 @@
import { PluginKey } from '@tiptap/pm/state';
export const MENTION_SUGGESTION_PLUGIN_KEY = new PluginKey(
'mention-suggestion',
);

View file

@ -0,0 +1,72 @@
import { Extension } from '@tiptap/core';
import Suggestion from '@tiptap/suggestion';
import { MentionSuggestionMenu } from '@/mention/components/MentionSuggestionMenu';
import { MENTION_SUGGESTION_PLUGIN_KEY } from '@/mention/constants/MentionSuggestionPluginKey';
import type { MentionSearchResult } from '@/mention/types/MentionSearchResult';
import { createSuggestionRenderLifecycle } from '@/ui/suggestion/components/createSuggestionRenderLifecycle';
type MentionSuggestionOptions = {
searchMentionRecords: (query: string) => Promise<MentionSearchResult[]>;
};
export const MentionSuggestion = Extension.create<MentionSuggestionOptions>({
name: 'mention-suggestion',
addOptions: () => ({
searchMentionRecords: async () => [],
}),
addStorage() {
return {
searchMentionRecords: this.options.searchMentionRecords,
};
},
addProseMirrorPlugins() {
return [
Suggestion<MentionSearchResult>({
pluginKey: MENTION_SUGGESTION_PLUGIN_KEY,
editor: this.editor,
char: '@',
items: async ({ query }) => {
try {
return await this.storage.searchMentionRecords(query);
} catch {
return [];
}
},
command: ({ editor, range, props: selectedItem }) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertContent({
type: 'mentionTag',
attrs: {
recordId: selectedItem.recordId,
objectNameSingular: selectedItem.objectNameSingular,
label: selectedItem.label,
imageUrl: selectedItem.imageUrl,
},
})
.insertContent(' ')
.run();
},
render: () =>
createSuggestionRenderLifecycle(
{
component: MentionSuggestionMenu,
getMenuProps: ({ items, onSelect, editor, range }) => ({
items,
onSelect,
editor,
range,
}),
},
this.editor,
),
}),
];
},
});

View file

@ -0,0 +1,62 @@
import { MentionChip } from '@/mention/components/MentionChip';
import { Node } from '@tiptap/core';
import { mergeAttributes, ReactNodeViewRenderer } from '@tiptap/react';
export const MentionTag = Node.create({
name: 'mentionTag',
group: 'inline',
inline: true,
atom: true,
addAttributes: () => ({
recordId: {
default: null,
parseHTML: (element) => element.getAttribute('data-record-id'),
renderHTML: (attributes) => ({
'data-record-id': attributes.recordId,
}),
},
objectNameSingular: {
default: null,
parseHTML: (element) => element.getAttribute('data-object-name-singular'),
renderHTML: (attributes) => ({
'data-object-name-singular': attributes.objectNameSingular,
}),
},
label: {
default: '',
parseHTML: (element) => element.getAttribute('data-label'),
renderHTML: (attributes) => ({
'data-label': attributes.label,
}),
},
imageUrl: {
default: '',
parseHTML: (element) => element.getAttribute('data-image-url'),
renderHTML: (attributes) => ({
'data-image-url': attributes.imageUrl,
}),
},
}),
renderHTML: ({ node, HTMLAttributes }) => {
return [
'span',
mergeAttributes(HTMLAttributes, {
'data-type': 'mentionTag',
class: 'mention-tag',
}),
`@${node.attrs.label}`,
];
},
addNodeView: () => {
return ReactNodeViewRenderer(MentionChip);
},
renderText: ({ node }) => {
const { objectNameSingular, recordId, label } = node.attrs;
return `[[record:${objectNameSingular}:${recordId}:${label}]]`;
},
});

View file

@ -0,0 +1,110 @@
import { Editor } from '@tiptap/core';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { MentionSuggestion } from '@/mention/extensions/MentionSuggestion';
import { MentionTag } from '@/mention/extensions/MentionTag';
// Mock ReactNodeViewRenderer and ReactRenderer (DOM-dependent)
jest.mock('@tiptap/react', () => ({
mergeAttributes: jest.requireActual('@tiptap/react').mergeAttributes,
ReactNodeViewRenderer: () => () => ({}),
ReactRenderer: jest.fn().mockImplementation(() => ({
element: document.createElement('div'),
ref: null,
updateProps: jest.fn(),
destroy: jest.fn(),
})),
}));
describe('MentionSuggestion', () => {
let editor: Editor;
let mockSearchFn: jest.Mock;
beforeEach(() => {
mockSearchFn = jest.fn().mockResolvedValue([]);
editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
MentionTag,
MentionSuggestion.configure({
searchMentionRecords: mockSearchFn,
}),
],
content: '<p></p>',
});
});
afterEach(() => {
editor?.destroy();
});
it('should register the extension', () => {
const extension = editor.extensionManager.extensions.find(
(ext) => ext.name === 'mention-suggestion',
);
expect(extension).toBeDefined();
});
it('should add ProseMirror plugins', () => {
const plugins = editor.state.plugins;
const hasSuggestionPlugin = plugins.some(
(plugin) =>
(plugin as unknown as { key: string }).key === 'mention-suggestion$',
);
expect(hasSuggestionPlugin).toBe(true);
});
it('should insert mention content via editor commands', () => {
editor.commands.setContent('<p>Hello </p>');
editor.commands.focus('end');
editor
.chain()
.focus()
.insertContent({
type: 'mentionTag',
attrs: {
recordId: 'test-id',
objectNameSingular: 'company',
label: 'Acme',
imageUrl: '',
},
})
.insertContent(' ')
.run();
const text = editor.getText();
expect(text).toContain('[[record:company:test-id:Acme]]');
});
it('should accept @ character in editor content', () => {
editor.commands.setContent('<p></p>');
editor.commands.focus();
editor.commands.insertContent('@');
expect(editor.getText()).toContain('@');
});
it('should use default empty search function when not configured', () => {
const unconfiguredEditor = new Editor({
extensions: [Document, Paragraph, Text, MentionTag, MentionSuggestion],
content: '<p></p>',
});
const extension = unconfiguredEditor.extensionManager.extensions.find(
(ext) => ext.name === 'mention-suggestion',
);
expect(extension?.options.searchMentionRecords).toBeDefined();
unconfiguredEditor.destroy();
});
});

View file

@ -0,0 +1,195 @@
import { Editor } from '@tiptap/core';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { MentionTag } from '@/mention/extensions/MentionTag';
// Mock ReactNodeViewRenderer since we're testing in a non-DOM environment
jest.mock('@tiptap/react', () => ({
mergeAttributes: jest.requireActual('@tiptap/react').mergeAttributes,
ReactNodeViewRenderer: () => () => ({}),
}));
describe('MentionTag', () => {
let editor: Editor;
beforeEach(() => {
editor = new Editor({
extensions: [Document, Paragraph, Text, MentionTag],
content: '<p></p>',
});
});
afterEach(() => {
editor?.destroy();
});
describe('node spec', () => {
it('should register as an inline atom node', () => {
const mentionTagType = editor.schema.nodes.mentionTag;
expect(mentionTagType).toBeDefined();
expect(mentionTagType.isInline).toBe(true);
expect(mentionTagType.isAtom).toBe(true);
});
it('should define all required attributes with defaults', () => {
const attrs = editor.schema.nodes.mentionTag.spec.attrs;
expect(attrs).toBeDefined();
});
});
describe('renderText', () => {
it('should serialize a mention to [[record:...]] markdown format', () => {
editor.commands.setContent({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Hello ' },
{
type: 'mentionTag',
attrs: {
recordId: 'abc-123',
objectNameSingular: 'company',
label: 'Acme Corp',
imageUrl: 'https://example.com/logo.png',
},
},
{ type: 'text', text: ' world' },
],
},
],
});
const text = editor.getText();
expect(text).toBe('Hello [[record:company:abc-123:Acme Corp]] world');
});
it('should handle mentions with empty label', () => {
editor.commands.setContent({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'mentionTag',
attrs: {
recordId: 'id-456',
objectNameSingular: 'person',
label: '',
imageUrl: '',
},
},
],
},
],
});
const text = editor.getText();
expect(text).toBe('[[record:person:id-456:]]');
});
it('should serialize multiple mentions in the same paragraph', () => {
editor.commands.setContent({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'mentionTag',
attrs: {
recordId: 'r1',
objectNameSingular: 'person',
label: 'Alice',
imageUrl: '',
},
},
{ type: 'text', text: ' and ' },
{
type: 'mentionTag',
attrs: {
recordId: 'r2',
objectNameSingular: 'company',
label: 'Beta Inc',
imageUrl: '',
},
},
],
},
],
});
const text = editor.getText();
expect(text).toBe(
'[[record:person:r1:Alice]] and [[record:company:r2:Beta Inc]]',
);
});
});
describe('insertContent command', () => {
it('should insert a mention tag via editor commands', () => {
editor.commands.setContent('<p></p>');
editor.commands.focus();
editor
.chain()
.focus()
.insertContent({
type: 'mentionTag',
attrs: {
recordId: 'test-id',
objectNameSingular: 'opportunity',
label: 'Big Deal',
imageUrl: 'https://example.com/img.png',
},
})
.run();
const text = editor.getText();
expect(text).toContain('[[record:opportunity:test-id:Big Deal]]');
});
});
describe('renderHTML', () => {
it('should produce HTML with data attributes', () => {
editor.commands.setContent({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'mentionTag',
attrs: {
recordId: 'html-id',
objectNameSingular: 'task',
label: 'My Task',
imageUrl: 'https://example.com/task.png',
},
},
],
},
],
});
const html = editor.getHTML();
expect(html).toContain('data-record-id="html-id"');
expect(html).toContain('data-object-name-singular="task"');
expect(html).toContain('data-label="My Task"');
expect(html).toContain('data-image-url="https://example.com/task.png"');
expect(html).toContain('data-type="mentionTag"');
expect(html).toContain('class="mention-tag"');
});
});
});

View file

@ -0,0 +1,49 @@
import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
import { useMentionSearch } from '@/mention/hooks/useMentionSearch';
import { type MentionItem } from '@/blocknote-editor/types/types';
import { useCallback } from 'react';
export const useMentionMenu = (editor: typeof BLOCK_SCHEMA.BlockNoteEditor) => {
const { searchMentionRecords, searchableObjectMetadataItems } =
useMentionSearch();
const getMentionItems = useCallback(
async (query: string): Promise<MentionItem[]> => {
const results = await searchMentionRecords(query);
return results.map((result) => {
const objectMetadataItem = searchableObjectMetadataItems.find(
(item) => item.nameSingular === result.objectNameSingular,
);
return {
title: result.label,
recordId: result.recordId,
objectNameSingular: result.objectNameSingular,
objectMetadataId: objectMetadataItem?.id,
label: result.label,
imageUrl: result.imageUrl,
objectLabelSingular: result.objectLabelSingular,
onItemClick: () => {
editor.insertInlineContent([
{
type: 'mention',
props: {
recordId: result.recordId,
objectMetadataId: objectMetadataItem?.id ?? '',
objectNameSingular: result.objectNameSingular,
label: result.label,
imageUrl: result.imageUrl,
},
},
' ',
]);
},
};
});
},
[editor, searchMentionRecords, searchableObjectMetadataItems],
);
return getMentionItems;
};

View file

@ -0,0 +1,67 @@
import { SEARCH_QUERY } from '@/command-menu/graphql/queries/search';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { getObjectPermissionsFromMapByObjectMetadataId } from '@/settings/roles/role-permissions/objects-permissions/utils/getObjectPermissionsFromMapByObjectMetadataId';
import { useCallback, useMemo } from 'react';
import {
type SearchQuery,
type SearchQueryVariables,
} from '~/generated/graphql';
import type { MentionSearchResult } from '@/mention/types/MentionSearchResult';
const MENTION_SEARCH_LIMIT = 50;
export const useMentionSearch = () => {
const { activeObjectMetadataItems } = useFilteredObjectMetadataItems();
const apolloCoreClient = useApolloCoreClient();
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const searchableObjectMetadataItems = useMemo(
() =>
activeObjectMetadataItems.filter(
(item) =>
!item.isSystem &&
item.isSearchable &&
getObjectPermissionsFromMapByObjectMetadataId({
objectPermissionsByObjectMetadataId,
objectMetadataId: item.id,
}).canReadObjectRecords === true,
),
[activeObjectMetadataItems, objectPermissionsByObjectMetadataId],
);
const objectsToSearch = useMemo(
() => searchableObjectMetadataItems.map(({ nameSingular }) => nameSingular),
[searchableObjectMetadataItems],
);
const searchMentionRecords = useCallback(
async (query: string): Promise<MentionSearchResult[]> => {
const { data } = await apolloCoreClient.query<
SearchQuery,
SearchQueryVariables
>({
query: SEARCH_QUERY,
variables: {
searchInput: query,
limit: MENTION_SEARCH_LIMIT,
includedObjectNameSingulars: objectsToSearch,
},
});
const searchRecords = data?.search.edges.map((edge) => edge.node) || [];
return searchRecords.map((searchRecord) => ({
recordId: searchRecord.recordId,
objectNameSingular: searchRecord.objectNameSingular,
objectLabelSingular: searchRecord.objectLabelSingular,
label: searchRecord.label,
imageUrl: searchRecord.imageUrl ?? '',
}));
},
[apolloCoreClient, objectsToSearch],
);
return { searchMentionRecords, searchableObjectMetadataItems };
};

View file

@ -0,0 +1,7 @@
export type MentionSearchResult = {
recordId: string;
objectNameSingular: string;
objectLabelSingular: string;
label: string;
imageUrl: string;
};

View file

@ -0,0 +1,10 @@
import type { Editor, Range } from '@tiptap/core';
import type { MentionSearchResult } from '@/mention/types/MentionSearchResult';
export type MentionSuggestionMenuProps = {
items: MentionSearchResult[];
onSelect: (item: MentionSearchResult) => void;
editor: Editor;
range: Range;
};

View file

@ -1,5 +1,5 @@
import { useRichTextFieldDisplay } from '@/object-record/record-field/ui/meta-types/hooks/useRichTextFieldDisplay';
import { getFirstNonEmptyLineOfRichText } from '@/ui/input/editor/utils/getFirstNonEmptyLineOfRichText';
import { getFirstNonEmptyLineOfRichText } from '@/blocknote-editor/utils/getFirstNonEmptyLineOfRichText';
export const RichTextFieldDisplay = () => {
const { fieldValue } = useRichTextFieldDisplay();

View file

@ -1,5 +1,5 @@
import { useRichTextV2FieldDisplay } from '@/object-record/record-field/ui/meta-types/hooks/useRichTextV2FieldDisplay';
import { getFirstNonEmptyLineOfRichText } from '@/ui/input/editor/utils/getFirstNonEmptyLineOfRichText';
import { getFirstNonEmptyLineOfRichText } from '@/blocknote-editor/utils/getFirstNonEmptyLineOfRichText';
import type { PartialBlock } from '@blocknote/core';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined, parseJson } from 'twenty-shared/utils';

View file

@ -13,7 +13,7 @@ import { createPortal } from 'react-dom';
import { IconColorSwatch, IconPlus, IconTrash } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
import { type BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
import { DashboardBlockColorPicker } from '@/page-layout/widgets/standalone-rich-text/components/DashboardBlockColorPicker';
import { DRAG_HANDLE_MENU_FLOATING_CONFIG } from '@/page-layout/widgets/standalone-rich-text/constants/DragHandleMenuFloatingConfig';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';

View file

@ -6,7 +6,7 @@ import { useState } from 'react';
import { createPortal } from 'react-dom';
import { IconGripVertical } from 'twenty-ui/display';
import { type BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
import { DashboardBlockDragHandleMenu } from '@/page-layout/widgets/standalone-rich-text/components/DashboardBlockDragHandleMenu';
import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices';
import { isDefined } from 'twenty-shared/utils';

View file

@ -5,14 +5,14 @@ import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { type ClipboardEvent } from 'react';
import { type BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
import { getSlashMenu } from '@/activities/blocks/utils/getSlashMenu';
import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
import { getSlashMenu } from '@/blocknote-editor/utils/getSlashMenu';
import { DashboardEditorSideMenu } from '@/page-layout/widgets/standalone-rich-text/components/DashboardEditorSideMenu';
import { DashboardFormattingToolbar } from '@/page-layout/widgets/standalone-rich-text/components/DashboardFormattingToolbar';
import {
CustomSlashMenu,
type SuggestionItem,
} from '@/ui/input/editor/components/CustomSlashMenu';
} from '@/blocknote-editor/components/CustomSlashMenu';
interface DashboardsBlockEditorProps {
editor: typeof BLOCK_SCHEMA.BlockNoteEditor;

View file

@ -1,6 +1,6 @@
import { useCallback, useMemo } from 'react';
import { BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
import { BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
import { useUploadAttachmentFile } from '@/activities/files/hooks/useUploadAttachmentFile';
import { type Attachment } from '@/activities/files/types/Attachment';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
@ -10,10 +10,10 @@ import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pa
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { DashboardsBlockEditor } from '@/page-layout/widgets/standalone-rich-text/components/DashboardsBlockEditor';
import { StandaloneRichTextWidgetAutoFocusEffect } from '@/page-layout/widgets/standalone-rich-text/components/StandaloneRichTextWidgetAutoFocusEffect';
import { BLOCK_EDITOR_GLOBAL_HOTKEYS_CONFIG } from '@/ui/input/editor/constants/BlockEditorGlobalHotkeysConfig';
import { useAttachmentSync } from '@/ui/input/editor/hooks/useAttachmentSync';
import { parseInitialBlocknote } from '@/ui/input/editor/utils/parseInitialBlocknote';
import { prepareBodyWithSignedUrls } from '@/ui/input/editor/utils/prepareBodyWithSignedUrls';
import { BLOCK_EDITOR_GLOBAL_HOTKEYS_CONFIG } from '@/blocknote-editor/constants/BlockEditorGlobalHotkeysConfig';
import { useAttachmentSync } from '@/blocknote-editor/hooks/useAttachmentSync';
import { parseInitialBlocknote } from '@/blocknote-editor/utils/parseInitialBlocknote';
import { prepareBodyWithSignedUrls } from '@/blocknote-editor/utils/prepareBodyWithSignedUrls';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';

View file

@ -1,4 +1,4 @@
import { type BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
import { useEffect } from 'react';
type StandaloneRichTextWidgetAutoFocusEffectProps = {

View file

@ -1 +0,0 @@
export const MENTION_MENU_LIST_ID = 'mention-menu-list-id';

View file

@ -1,69 +0,0 @@
import { type MouseEvent } from 'react';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { getAvatarType } from '@/object-metadata/utils/getAvatarType';
import { searchRecordStoreFamilyState } from '@/object-record/record-picker/multiple-record-picker/states/searchRecordStoreComponentFamilyState';
import { MENTION_MENU_LIST_ID } from '@/ui/input/constants/MentionMenuListId';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { isSelectedItemIdComponentFamilySelector } from '@/ui/layout/selectable-list/states/selectors/isSelectedItemIdComponentFamilySelector';
import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { Avatar } from 'twenty-ui/display';
import { MenuItemSuggestion } from 'twenty-ui/navigation';
type CustomMentionMenuListItemProps = {
recordId: string;
onClick: () => void;
objectNameSingular: string;
};
export const CustomMentionMenuListItem = ({
recordId,
onClick,
objectNameSingular,
}: CustomMentionMenuListItemProps) => {
const { resetSelectedItem } = useSelectableList(MENTION_MENU_LIST_ID);
const isSelectedItem = useRecoilComponentFamilyValue(
isSelectedItemIdComponentFamilySelector,
recordId,
);
const searchRecord = useRecoilValue(searchRecordStoreFamilyState(recordId));
const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular });
const handleClick = (event?: MouseEvent) => {
event?.preventDefault();
event?.stopPropagation();
resetSelectedItem();
onClick();
};
if (!isDefined(searchRecord)) {
return null;
}
return (
<SelectableListItem itemId={recordId} onEnter={handleClick}>
<MenuItemSuggestion
selected={isSelectedItem}
onClick={handleClick}
text={`${searchRecord.label}`}
contextualText={objectMetadataItem.labelSingular}
contextualTextPosition="left"
LeftIcon={() => (
<Avatar
placeholder={searchRecord.label}
placeholderColorSeed={recordId}
avatarUrl={searchRecord.imageUrl}
type={getAvatarType(objectNameSingular) ?? 'rounded'}
size="sm"
/>
)}
/>
</SelectableListItem>
);
};

View file

@ -1,29 +0,0 @@
import { MENTION_MENU_LIST_ID } from '@/ui/input/constants/MentionMenuListId';
import { type MentionItem } from '@/ui/input/editor/components/types';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { useEffect } from 'react';
import { isDefined } from 'twenty-shared/utils';
type CustomMentionMenuSelectedIndexSyncEffectProps = {
items: MentionItem[];
selectedIndex: number | undefined;
};
export const CustomMentionMenuSelectedIndexSyncEffect = ({
items,
selectedIndex,
}: CustomMentionMenuSelectedIndexSyncEffectProps) => {
const { setSelectedItemId } = useSelectableList(MENTION_MENU_LIST_ID);
useEffect(() => {
if (!isDefined(selectedIndex) || !isDefined(items)) return;
const selectedItem = items[selectedIndex];
if (isDefined(selectedItem) && isDefined(selectedItem.recordId)) {
setSelectedItemId(selectedItem.recordId);
}
}, [items, selectedIndex, setSelectedItemId]);
return <></>;
};

View file

@ -1,96 +0,0 @@
import { type BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
import { SEARCH_QUERY } from '@/command-menu/graphql/queries/search';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { searchRecordStoreFamilyState } from '@/object-record/record-picker/multiple-record-picker/states/searchRecordStoreComponentFamilyState';
import { getObjectPermissionsFromMapByObjectMetadataId } from '@/settings/roles/role-permissions/objects-permissions/utils/getObjectPermissionsFromMapByObjectMetadataId';
import { type MentionItem } from '@/ui/input/editor/components/types';
import { useMemo } from 'react';
import { useRecoilCallback } from 'recoil';
import {
type SearchQuery,
type SearchQueryVariables,
} from '~/generated/graphql';
const MENTION_SEARCH_LIMIT = 50;
export const useMentionMenu = (editor: typeof BLOCK_SCHEMA.BlockNoteEditor) => {
const { activeObjectMetadataItems } = useFilteredObjectMetadataItems();
const apolloCoreClient = useApolloCoreClient();
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const searchableObjectMetadataItems = useMemo(
() =>
activeObjectMetadataItems.filter(
(item) =>
!item.isSystem &&
item.isSearchable &&
getObjectPermissionsFromMapByObjectMetadataId({
objectPermissionsByObjectMetadataId,
objectMetadataId: item.id,
}).canReadObjectRecords === true,
),
[activeObjectMetadataItems, objectPermissionsByObjectMetadataId],
);
const objectsToSearch = useMemo(
() => searchableObjectMetadataItems.map(({ nameSingular }) => nameSingular),
[searchableObjectMetadataItems],
);
const getMentionItems = useRecoilCallback(
({ set }) =>
async (query: string): Promise<MentionItem[]> => {
const { data } = await apolloCoreClient.query<
SearchQuery,
SearchQueryVariables
>({
query: SEARCH_QUERY,
variables: {
searchInput: query,
limit: MENTION_SEARCH_LIMIT,
includedObjectNameSingulars: objectsToSearch,
},
});
const searchRecords = data?.search.edges.map((edge) => edge.node) || [];
searchRecords.forEach((searchRecord) => {
set(searchRecordStoreFamilyState(searchRecord.recordId), {
...searchRecord,
record: undefined,
});
});
return searchRecords.map((searchRecord) => {
const objectMetadataItem = searchableObjectMetadataItems.find(
(item) => item.nameSingular === searchRecord.objectNameSingular,
);
return {
title: searchRecord.label,
recordId: searchRecord.recordId,
objectNameSingular: searchRecord.objectNameSingular,
objectMetadataId: objectMetadataItem?.id,
onItemClick: () => {
editor.insertInlineContent([
{
type: 'mention',
props: {
recordId: searchRecord.recordId,
objectMetadataId: objectMetadataItem?.id ?? '',
},
},
' ',
]);
},
};
});
},
[apolloCoreClient, editor, objectsToSearch, searchableObjectMetadataItems],
);
return getMentionItems;
};

View file

@ -0,0 +1,180 @@
import {
autoUpdate,
flip,
offset,
shift,
useFloating,
} from '@floating-ui/react';
import { motion } from 'framer-motion';
import {
forwardRef,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer';
import type { SuggestionMenuProps } from '@/ui/suggestion/types/SuggestionMenuProps';
type SuggestionMenuInnerProps<TItem> = SuggestionMenuProps<TItem>;
// forwardRef does not natively support generics, so we use a helper + cast
const SuggestionMenuInner = <TItem,>(
props: SuggestionMenuInnerProps<TItem>,
parentRef: React.ForwardedRef<unknown>,
) => {
const { items, onSelect, editor, range, getItemKey, renderItem, onKeyDown } =
props;
const [selectedIndex, setSelectedIndex] = useState(0);
const clampedSelectedIndex =
items.length > 0 ? Math.min(selectedIndex, items.length - 1) : 0;
const activeItemRef = useRef<HTMLDivElement>(null);
const listContainerRef = useRef<HTMLDivElement>(null);
const positionReference = useMemo(
() => ({
getBoundingClientRect: () => {
const start = editor.view.coordsAtPos(range.from);
return new DOMRect(start.left, start.top, 0, start.bottom - start.top);
},
}),
[editor, range],
);
const { refs, floatingStyles } = useFloating({
placement: 'bottom-start',
strategy: 'fixed',
middleware: [offset(4), flip(), shift()],
whileElementsMounted: (reference, floating, update) => {
return autoUpdate(reference, floating, update, {
animationFrame: true,
});
},
elements: {
reference: positionReference,
},
});
const selectItem = (index: number) => {
const item = items[index];
if (!item) {
return;
}
onSelect(item);
};
useImperativeHandle(parentRef, () => ({
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
const customResult = onKeyDown?.(event, clampedSelectedIndex);
if (customResult === true) {
return true;
}
const navigationKeys = ['ArrowUp', 'ArrowDown', 'Enter'];
if (navigationKeys.includes(event.key)) {
switch (event.key) {
case 'ArrowUp': {
if (!items.length) {
return false;
}
let newIndex = clampedSelectedIndex - 1;
if (newIndex < 0) {
newIndex = items.length - 1;
}
setSelectedIndex(newIndex);
return true;
}
case 'ArrowDown': {
if (!items.length) {
return false;
}
let newIndex = clampedSelectedIndex + 1;
if (newIndex >= items.length) {
newIndex = 0;
}
setSelectedIndex(newIndex);
return true;
}
case 'Enter':
if (!items.length) {
return false;
}
selectItem(clampedSelectedIndex);
return true;
default:
return false;
}
}
return false;
},
}));
useLayoutEffect(() => {
const container = listContainerRef?.current;
const activeItemContainer = activeItemRef?.current;
if (!container || !activeItemContainer) {
return;
}
const scrollableContainer =
container.firstElementChild as HTMLElement | null;
if (!scrollableContainer) {
return;
}
const { offsetTop, offsetHeight } = activeItemContainer;
scrollableContainer.style.transition = 'none';
scrollableContainer.scrollTop = offsetTop - offsetHeight;
}, [clampedSelectedIndex]);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.1 }}
data-suggestion-menu
>
<OverlayContainer
ref={refs.setFloating}
style={{
...floatingStyles,
zIndex: RootStackingContextZIndices.DropdownPortalAboveModal,
}}
>
<DropdownContent ref={listContainerRef}>
<DropdownMenuItemsContainer hasMaxHeight>
{items.map((item, index) => {
const isSelected = index === clampedSelectedIndex;
return (
<div
key={getItemKey(item)}
ref={isSelected ? activeItemRef : null}
onMouseDown={(event) => {
event.preventDefault();
}}
>
{renderItem(item, isSelected)}
</div>
);
})}
</DropdownMenuItemsContainer>
</DropdownContent>
</OverlayContainer>
</motion.div>
);
};
export const SuggestionMenu = forwardRef(SuggestionMenuInner) as <TItem>(
props: SuggestionMenuProps<TItem> & { ref?: React.Ref<unknown> },
) => ReturnType<typeof SuggestionMenuInner>;

View file

@ -0,0 +1,186 @@
import { type Editor, type Range } from '@tiptap/core';
const mockUpdateProps = jest.fn();
const mockDestroy = jest.fn();
let mockElement: HTMLElement;
jest.mock('@tiptap/react', () => ({
ReactRenderer: jest.fn().mockImplementation(() => {
mockElement = document.createElement('div');
return {
element: mockElement,
ref: null,
updateProps: mockUpdateProps,
destroy: mockDestroy,
};
}),
}));
import { createSuggestionRenderLifecycle } from '@/ui/suggestion/components/createSuggestionRenderLifecycle';
type TestItem = { id: string; label: string };
type TestMenuProps = {
items: TestItem[];
onSelect: (item: TestItem) => void;
editor: Editor;
range: Range;
};
const mockEditor = {} as Editor;
const createTestLifecycle = () =>
createSuggestionRenderLifecycle<TestItem, TestMenuProps>(
{
component: (() => null) as unknown as React.ComponentType<TestMenuProps>,
getMenuProps: ({ items, onSelect, editor, range }) => ({
items,
onSelect,
editor,
range,
}),
},
mockEditor,
);
const createMockCallbackProps = (
overrides: Partial<{
items: TestItem[];
command: (item: TestItem) => void;
clientRect: (() => DOMRect | null) | null;
range: Range;
query: string;
}> = {},
) => ({
items: [{ id: '1', label: 'Item A' }],
command: jest.fn(),
clientRect: () => new DOMRect(0, 0, 100, 20),
range: { from: 0, to: 5 } as Range,
query: '',
...overrides,
});
describe('createSuggestionRenderLifecycle', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('onStart', () => {
it('should not create renderer when clientRect is missing', () => {
const lifecycle = createTestLifecycle();
lifecycle.onStart(createMockCallbackProps({ clientRect: undefined }));
const { ReactRenderer } = jest.requireMock('@tiptap/react');
expect(ReactRenderer).not.toHaveBeenCalled();
});
it('should not create renderer when items are empty', () => {
const lifecycle = createTestLifecycle();
lifecycle.onStart(createMockCallbackProps({ items: [] }));
const { ReactRenderer } = jest.requireMock('@tiptap/react');
expect(ReactRenderer).not.toHaveBeenCalled();
});
it('should create renderer and append element to body', () => {
const lifecycle = createTestLifecycle();
lifecycle.onStart(createMockCallbackProps());
const { ReactRenderer } = jest.requireMock('@tiptap/react');
expect(ReactRenderer).toHaveBeenCalledTimes(1);
expect(document.body.contains(mockElement)).toBe(true);
lifecycle.onExit();
});
});
describe('onUpdate', () => {
it('should close menu when items become empty', () => {
const lifecycle = createTestLifecycle();
lifecycle.onStart(createMockCallbackProps());
lifecycle.onUpdate(createMockCallbackProps({ items: [] }));
expect(mockDestroy).toHaveBeenCalled();
});
it('should update props on existing renderer', () => {
const lifecycle = createTestLifecycle();
lifecycle.onStart(createMockCallbackProps());
const newItems = [{ id: '2', label: 'Item B' }];
lifecycle.onUpdate(createMockCallbackProps({ items: newItems }));
expect(mockUpdateProps).toHaveBeenCalled();
lifecycle.onExit();
});
});
describe('onKeyDown', () => {
it('should close menu and return true on Escape', () => {
const lifecycle = createTestLifecycle();
lifecycle.onStart(createMockCallbackProps());
const result = lifecycle.onKeyDown({
event: new KeyboardEvent('keydown', { key: 'Escape' }),
});
expect(result).toBe(true);
expect(mockDestroy).toHaveBeenCalled();
});
it('should return false when no renderer exists', () => {
const lifecycle = createTestLifecycle();
const result = lifecycle.onKeyDown({
event: new KeyboardEvent('keydown', { key: 'ArrowDown' }),
});
expect(result).toBe(false);
});
});
describe('onExit', () => {
it('should clean up renderer', () => {
const lifecycle = createTestLifecycle();
lifecycle.onStart(createMockCallbackProps());
lifecycle.onExit();
expect(mockDestroy).toHaveBeenCalled();
});
it('should handle multiple onExit calls gracefully', () => {
const lifecycle = createTestLifecycle();
lifecycle.onStart(createMockCallbackProps());
lifecycle.onExit();
expect(() => lifecycle.onExit()).not.toThrow();
});
});
describe('command wrapping', () => {
it('should call original command and close menu on select', () => {
const lifecycle = createTestLifecycle();
const originalCommand = jest.fn();
lifecycle.onStart(createMockCallbackProps({ command: originalCommand }));
// Extract the onSelect callback from the props passed to ReactRenderer
const { ReactRenderer } = jest.requireMock('@tiptap/react');
const constructorCall = ReactRenderer.mock.calls[0];
const menuProps = constructorCall[1].props;
menuProps.onSelect({ id: '1', label: 'Item A' });
expect(originalCommand).toHaveBeenCalledWith({
id: '1',
label: 'Item A',
});
expect(mockDestroy).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,103 @@
import type { Editor, Range } from '@tiptap/core';
import { ReactRenderer } from '@tiptap/react';
type SuggestionMenuRef = {
onKeyDown?: (props: { event: KeyboardEvent }) => boolean;
};
type SuggestionCallbackProps<TItem> = {
items: TItem[];
command: (item: TItem) => void;
clientRect?: (() => DOMRect | null) | null;
range: Range;
query: string;
};
// Matches Tiptap's ReactRenderer generic constraint
type AnyRecord = Record<string, any>;
type SuggestionRenderLifecycleConfig<TItem, TMenuProps extends AnyRecord> = {
component: React.ComponentType<TMenuProps>;
getMenuProps: (args: {
items: TItem[];
onSelect: (item: TItem) => void;
editor: Editor;
range: Range;
query: string;
}) => TMenuProps;
};
export const createSuggestionRenderLifecycle = <
TItem,
TMenuProps extends AnyRecord,
>(
config: SuggestionRenderLifecycleConfig<TItem, TMenuProps>,
editor: Editor,
) => {
let renderer: ReactRenderer<SuggestionMenuRef, TMenuProps> | null = null;
const closeMenu = () => {
if (renderer !== null) {
renderer.destroy();
renderer = null;
}
};
const buildMenuProps = (props: SuggestionCallbackProps<TItem>) =>
config.getMenuProps({
items: props.items,
onSelect: (item: TItem) => {
props.command(item);
closeMenu();
},
editor,
range: props.range,
query: props.query,
});
const createRenderer = (props: SuggestionCallbackProps<TItem>) => {
renderer = new ReactRenderer(config.component, {
editor,
props: buildMenuProps(props),
});
document.body.appendChild(renderer.element);
};
return {
onStart: (props: SuggestionCallbackProps<TItem>) => {
if (!props.clientRect || props.items.length === 0) {
return;
}
createRenderer(props);
},
onUpdate: (props: SuggestionCallbackProps<TItem>) => {
if (!props.clientRect) {
return;
}
if (props.items.length === 0) {
closeMenu();
return;
}
if (renderer === null) {
createRenderer(props);
return;
}
renderer.updateProps(buildMenuProps(props));
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === 'Escape') {
closeMenu();
return true;
}
return renderer?.ref?.onKeyDown?.(props) ?? false;
},
onExit: () => {
closeMenu();
},
};
};

View file

@ -0,0 +1,15 @@
import type { Editor, Range } from '@tiptap/core';
import type { ReactNode } from 'react';
export type SuggestionMenuProps<TItem> = {
items: TItem[];
onSelect: (item: TItem) => void;
editor: Editor;
range: Range;
getItemKey: (item: TItem) => string;
renderItem: (item: TItem, isSelected: boolean) => ReactNode;
onKeyDown?: (
event: KeyboardEvent,
selectedIndex: number,
) => boolean | undefined;
};

View file

@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { z } from 'zod';
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
import { formatValidationErrors } from 'src/engine/core-modules/tool-provider/utils/format-validation-errors.util';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { ViewType } from 'src/engine/metadata-modules/view/enums/view-type.enum';
@ -56,6 +57,22 @@ const CreateViewInputSchema = z.object({
.optional()
.default(ViewVisibility.WORKSPACE)
.describe('View visibility'),
mainGroupByFieldName: z
.string()
.optional()
.describe(
'Field name to group by (required for KANBAN views, must be a SELECT field, e.g., "stage", "status")',
),
kanbanAggregateOperation: z
.enum(Object.values(AggregateOperations) as [string, ...string[]])
.optional()
.describe(
'Aggregate operation for kanban columns (e.g., "SUM", "AVG", "COUNT")',
),
kanbanAggregateOperationFieldName: z
.string()
.optional()
.describe('Field name for the kanban aggregate operation (e.g., "amount")'),
});
const UpdateViewInputSchema = z.object({
@ -101,6 +118,36 @@ export class ViewToolsFactory {
return objectMetadata.id;
}
private async resolveFieldMetadataId(
workspaceId: string,
objectMetadataId: string,
fieldName: string,
): Promise<string> {
const { flatFieldMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatFieldMetadataMaps'],
},
);
const fieldMetadata = Object.values(
flatFieldMetadataMaps.byUniversalIdentifier,
).find(
(field) =>
field?.name === fieldName &&
field?.objectMetadataId === objectMetadataId,
);
if (!fieldMetadata) {
throw new Error(
`Field "${fieldName}" not found on this object. Use get_field_metadata to list available fields.`,
);
}
return fieldMetadata.id;
}
generateReadTools(
workspaceId: string,
userWorkspaceId?: string,
@ -167,7 +214,7 @@ export class ViewToolsFactory {
return {
create_view: {
description:
'Create a new view for an object. Views define how records are displayed.',
'Create a new view for an object. Views define how records are displayed. For KANBAN views, mainGroupByFieldName is required and must be a SELECT field (e.g., "stage", "status").',
inputSchema: CreateViewInputSchema,
execute: async (parameters: {
name: string;
@ -175,6 +222,9 @@ export class ViewToolsFactory {
icon?: string;
type?: ViewType;
visibility?: ViewVisibility;
mainGroupByFieldName?: string;
kanbanAggregateOperation?: string;
kanbanAggregateOperationFieldName?: string;
}) => {
try {
const objectMetadataId = await this.resolveObjectMetadataId(
@ -182,6 +232,26 @@ export class ViewToolsFactory {
parameters.objectNameSingular,
);
let mainGroupByFieldMetadataId: string | undefined;
let kanbanAggregateOperationFieldMetadataId: string | undefined;
if (parameters.mainGroupByFieldName) {
mainGroupByFieldMetadataId = await this.resolveFieldMetadataId(
workspaceId,
objectMetadataId,
parameters.mainGroupByFieldName,
);
}
if (parameters.kanbanAggregateOperationFieldName) {
kanbanAggregateOperationFieldMetadataId =
await this.resolveFieldMetadataId(
workspaceId,
objectMetadataId,
parameters.kanbanAggregateOperationFieldName,
);
}
const view = await this.viewService.createOne({
createViewInput: {
name: parameters.name,
@ -189,6 +259,10 @@ export class ViewToolsFactory {
icon: parameters.icon ?? 'IconList',
type: parameters.type ?? ViewType.TABLE,
visibility: parameters.visibility ?? ViewVisibility.WORKSPACE,
mainGroupByFieldMetadataId,
kanbanAggregateOperation:
parameters.kanbanAggregateOperation as AggregateOperations,
kanbanAggregateOperationFieldMetadataId,
},
workspaceId,
createdByUserWorkspaceId: userWorkspaceId,