feat(ai): add current context to ai chat (#13315)

## TODO

- [ ] add dropdown to use records from outside the context
- [x] add loader for files chip
- [x] add roleId where it's necessary
- [x] Split AvatarChip in two components. One with the icon that will
call the second with leftComponent.
- [ ] Fix tests
- [x] Fix UI regression on Search
This commit is contained in:
Antoine Moreaux
2025-07-22 17:27:19 +02:00
committed by GitHub
parent d46a076aa0
commit 153739b9c3
69 changed files with 1111 additions and 819 deletions

View File

@ -1,38 +1,114 @@
import { AvatarChipsLeftComponent } from '@ui/components/avatar-chip/AvatarChipLeftComponent';
import { AvatarChipsCommonProps } from '@ui/components/avatar-chip/types/AvatarChipsCommonProps.type';
import { Chip, ChipVariant } from '@ui/components/chip/Chip';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Avatar } from '@ui/display/avatar/components/Avatar';
import { AvatarType } from '@ui/display/avatar/types/AvatarType';
import { IconComponent } from '@ui/display/icon/types/IconComponent';
import { Nullable } from '@ui/utilities';
import { isDefined } from 'twenty-shared/utils';
const StyledIconWithBackgroundContainer = styled.div<{
backgroundColor: string;
}>`
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border-radius: 4px;
background-color: ${({ backgroundColor }) => backgroundColor};
`;
const StyledAvatarChipWrapper = styled.div<{
isClickable: boolean;
divider: AvatarChipProps['divider'];
theme: any;
}>`
${({ divider, theme }) => {
const borderStyle = (side: 'left' | 'right') =>
`border-${side}: 1px solid ${theme.border.color.light};`;
return divider ? borderStyle(divider) : '';
}}
cursor: ${({ isClickable }) => (isClickable ? 'pointer' : 'inherit')};
display: flex;
`;
export type AvatarChipProps = {
placeholder?: string;
avatarUrl?: string;
avatarType?: Nullable<AvatarType>;
Icon?: IconComponent;
IconColor?: string;
IconBackgroundColor?: string;
isIconInverted?: boolean;
placeholderColorSeed?: string;
divider?: 'right' | 'left';
onClick?: () => void;
};
export type AvatarChipProps = AvatarChipsCommonProps;
export const AvatarChip = ({
name,
LeftIcon,
LeftIconColor,
Icon,
placeholderColorSeed,
avatarType,
avatarUrl,
className,
isIconInverted,
maxWidth,
placeholderColorSeed,
size,
variant = ChipVariant.Transparent,
}: AvatarChipProps) => (
<Chip
label={name}
variant={variant}
size={size}
leftComponent={
<AvatarChipsLeftComponent
name={name}
LeftIcon={LeftIcon}
LeftIconColor={LeftIconColor}
avatarType={avatarType}
placeholder,
isIconInverted = false,
IconColor,
IconBackgroundColor,
onClick,
divider,
}: AvatarChipProps) => {
const theme = useTheme();
if (!isDefined(Icon)) {
return (
<Avatar
avatarUrl={avatarUrl}
isIconInverted={isIconInverted}
placeholderColorSeed={placeholderColorSeed}
placeholder={placeholder}
size="sm"
type={avatarType}
onClick={onClick}
/>
}
clickable={false}
className={className}
maxWidth={maxWidth}
/>
);
);
}
const isClickable = isDefined(onClick);
if (isIconInverted || isDefined(IconBackgroundColor)) {
return (
<StyledAvatarChipWrapper
isClickable={isClickable}
divider={divider}
theme={theme}
onClick={onClick}
>
<StyledIconWithBackgroundContainer
backgroundColor={
IconBackgroundColor ?? theme.background.invertedSecondary
}
>
<Icon
color={theme.font.color.inverted}
size={theme.icon.size.sm}
stroke={theme.icon.stroke.sm}
/>
</StyledIconWithBackgroundContainer>
</StyledAvatarChipWrapper>
);
}
return (
<StyledAvatarChipWrapper
isClickable={isClickable}
divider={divider}
theme={theme}
onClick={onClick}
>
<Icon
size={theme.icon.size.sm}
stroke={theme.icon.stroke.sm}
color={IconColor || 'currentColor'}
/>
</StyledAvatarChipWrapper>
);
};

View File

@ -1,74 +0,0 @@
import { useTheme } from '@emotion/react';
import { styled } from '@linaria/react';
import { Avatar } from '@ui/display/avatar/components/Avatar';
import { AvatarType } from '@ui/display/avatar/types/AvatarType';
import { IconComponent } from '@ui/display/icon/types/IconComponent';
import { Nullable } from '@ui/utilities';
import { isDefined } from 'twenty-shared/utils';
const StyledInvertedIconContainer = styled.div<{ backgroundColor: string }>`
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border-radius: 4px;
background-color: ${({ backgroundColor }) => backgroundColor};
`;
export type AvatarChipsLeftComponentProps = {
name: string;
avatarUrl?: string;
avatarType?: Nullable<AvatarType>;
LeftIcon?: IconComponent;
LeftIconColor?: string;
isIconInverted?: boolean;
placeholderColorSeed?: string;
};
export const AvatarChipsLeftComponent: React.FC<
AvatarChipsLeftComponentProps
> = ({
LeftIcon,
placeholderColorSeed,
avatarType,
avatarUrl,
name,
isIconInverted = false,
LeftIconColor,
}) => {
const theme = useTheme();
if (!isDefined(LeftIcon)) {
return (
<Avatar
avatarUrl={avatarUrl}
placeholderColorSeed={placeholderColorSeed}
placeholder={name}
size="sm"
type={avatarType}
/>
);
}
if (isIconInverted) {
return (
<StyledInvertedIconContainer
backgroundColor={theme.background.invertedSecondary}
>
<LeftIcon
color={theme.font.color.inverted}
size={theme.icon.size.sm}
stroke={theme.icon.stroke.sm}
/>
</StyledInvertedIconContainer>
);
}
return (
<LeftIcon
size={theme.icon.size.sm}
stroke={theme.icon.stroke.sm}
color={LeftIconColor || 'currentColor'}
/>
);
};

View File

@ -1,64 +0,0 @@
import { AvatarChipsLeftComponent } from '@ui/components/avatar-chip/AvatarChipLeftComponent';
import { AvatarChipsCommonProps } from '@ui/components/avatar-chip/types/AvatarChipsCommonProps.type';
import { AvatarChipVariant } from '@ui/components/avatar-chip/types/AvatarChipsVariant.type';
import { ChipVariant } from '@ui/components/chip/Chip';
import { LinkChip, LinkChipProps } from '@ui/components/chip/LinkChip';
import { TriggerEventType } from '@ui/utilities';
export type LinkAvatarChipProps = Omit<
AvatarChipsCommonProps,
'clickable' | 'variant'
> & {
to: string;
onClick?: LinkChipProps['onClick'];
onMouseDown?: LinkChipProps['onMouseDown'];
variant?: AvatarChipVariant;
isLabelHidden?: boolean;
triggerEvent?: TriggerEventType;
};
export const LinkAvatarChip = ({
to,
onClick,
name,
LeftIcon,
LeftIconColor,
avatarType,
avatarUrl,
className,
isIconInverted,
maxWidth,
placeholderColorSeed,
size,
variant,
isLabelHidden,
triggerEvent,
}: LinkAvatarChipProps) => (
<LinkChip
to={to}
onClick={onClick}
label={name}
isLabelHidden={isLabelHidden}
variant={
//Regular but Highlighted -> missleading
variant === AvatarChipVariant.Regular
? ChipVariant.Highlighted
: ChipVariant.Regular
}
size={size}
leftComponent={
<AvatarChipsLeftComponent
name={name}
LeftIcon={LeftIcon}
LeftIconColor={LeftIconColor}
avatarType={avatarType}
avatarUrl={avatarUrl}
isIconInverted={isIconInverted}
placeholderColorSeed={placeholderColorSeed}
/>
}
className={className}
maxWidth={maxWidth}
triggerEvent={triggerEvent}
/>
);

View File

@ -0,0 +1,61 @@
import { Fragment } from 'react/jsx-runtime';
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { Chip, ChipVariant } from '@ui/components/chip/Chip';
const StyledIconsContainer = styled.div`
align-items: center;
display: flex;
`;
const StyledChipContainer = styled.div`
display: inline-flex;
font-size: ${({ theme }) => theme.font.size.sm};
`;
export type MultipleAvatarChipProps = {
Icons: React.ReactNode[];
text?: string;
onClick?: () => void;
testId?: string;
maxWidth?: number;
forceEmptyText?: boolean;
variant?: ChipVariant;
rightComponent?: React.ReactNode;
};
export const MultipleAvatarChip = ({
Icons,
text,
onClick,
testId,
maxWidth,
rightComponent,
variant = ChipVariant.Static,
forceEmptyText = false,
}: MultipleAvatarChipProps) => {
const leftComponent = (
<StyledIconsContainer>
{Icons.map((Icon, index) => (
<Fragment key={index}>{Icon}</Fragment>
))}
</StyledIconsContainer>
);
return (
<StyledChipContainer onClick={onClick} data-testid={testId}>
<Chip
label={text || ''}
forceEmptyText={forceEmptyText}
isLabelHidden={!isNonEmptyString(text) && forceEmptyText}
variant={variant}
leftComponent={leftComponent}
rightComponent={rightComponent}
clickable={isDefined(onClick)}
maxWidth={maxWidth}
/>
</StyledChipContainer>
);
};

View File

@ -0,0 +1,79 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from '@ui/testing';
import { IconBuildingSkyscraper, IconUser } from '@ui/display';
import { AvatarChip } from '../AvatarChip';
const meta: Meta<typeof AvatarChip> = {
title: 'UI/Components/AvatarChip',
component: AvatarChip,
decorators: [ComponentDecorator],
};
export default meta;
type Story = StoryObj<typeof AvatarChip>;
export const Default: Story = {
args: {
placeholder: 'JD',
placeholderColorSeed: 'John Doe',
},
};
export const WithAvatar: Story = {
args: {
avatarUrl: 'https://i.pravatar.cc/300',
placeholder: 'JD',
placeholderColorSeed: 'John Doe',
},
};
export const WithIcon: Story = {
args: {
Icon: IconUser,
},
};
export const WithIconBackground: Story = {
args: {
Icon: IconBuildingSkyscraper,
isIconInverted: true,
},
};
export const WithInvertedIcon: Story = {
args: {
Icon: IconUser,
isIconInverted: true,
},
};
export const WithRightDivider: Story = {
args: {
placeholder: 'JD',
placeholderColorSeed: 'John Doe',
divider: 'right',
},
};
export const WithLeftDivider: Story = {
args: {
Icon: IconUser,
divider: 'left',
},
};
export const Clickable: Story = {
args: {
placeholder: 'JD',
placeholderColorSeed: 'John Doe',
onClick: () => alert('AvatarChip clicked'),
},
};
export const ClickableIcon: Story = {
args: {
Icon: IconBuildingSkyscraper,
isIconInverted: true,
onClick: () => alert('Icon AvatarChip clicked'),
},
};

View File

@ -0,0 +1,33 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from '@ui/testing';
import { IconBuildingSkyscraper, IconUser } from '@ui/display';
import { MultipleAvatarChip } from '@ui/components';
const meta: Meta<typeof MultipleAvatarChip> = {
title: 'UI/Components/MultipleAvatarChip',
component: MultipleAvatarChip,
decorators: [ComponentDecorator],
};
export default meta;
type Story = StoryObj<typeof MultipleAvatarChip>;
export const SingleIcon: Story = {
args: {
Icons: [<IconUser size={16} />],
text: 'Person',
},
};
export const MultipleIcons: Story = {
args: {
Icons: [<IconUser size={16} />, <IconBuildingSkyscraper size={16} />],
text: 'Person & Company',
},
};
export const IconsOnly: Story = {
args: {
Icons: [<IconUser size={16} />, <IconBuildingSkyscraper size={16} />],
},
};

View File

@ -1,9 +0,0 @@
import { AvatarChipsLeftComponentProps } from '@ui/components/avatar-chip/AvatarChipLeftComponent';
import { ChipSize, ChipVariant } from '@ui/components/chip/Chip';
export type AvatarChipsCommonProps = {
size?: ChipSize;
className?: string;
maxWidth?: number;
variant?: ChipVariant;
} & AvatarChipsLeftComponentProps;

View File

@ -1,4 +0,0 @@
export enum AvatarChipVariant {
Regular = 'regular',
Transparent = 'transparent',
}

View File

@ -32,8 +32,9 @@ export type ChipProps = {
variant?: ChipVariant;
accent?: ChipAccent;
leftComponent?: ReactNode | null;
rightComponent?: (() => ReactNode) | null;
rightComponent?: (() => ReactNode) | ReactNode | null;
className?: string;
forceEmptyText?: boolean;
};
const StyledDiv = withTheme(styled.div<{ theme: Theme }>`
@ -125,6 +126,18 @@ const StyledContainer = withTheme(styled.div<
: 'var(--chip-horizontal-padding)'};
`);
const renderRightComponent = (
rightComponent: (() => ReactNode) | ReactNode | null,
) => {
if (!rightComponent) {
return null;
}
return typeof rightComponent === 'function'
? rightComponent()
: rightComponent;
};
export const Chip = ({
size = ChipSize.Small,
label,
@ -137,6 +150,7 @@ export const Chip = ({
accent = ChipAccent.TextPrimary,
className,
maxWidth,
forceEmptyText = false,
}: ChipProps) => {
return (
<StyledContainer
@ -152,10 +166,12 @@ export const Chip = ({
{leftComponent}
{!isLabelHidden && label && label.trim() ? (
<OverflowingTextWithTooltip size={size} text={label} />
) : (
) : !forceEmptyText ? (
<StyledDiv>Untitled</StyledDiv>
) : (
''
)}
{rightComponent?.()}
{renderRightComponent(rightComponent)}
</StyledContainer>
);
};

View File

@ -1,29 +0,0 @@
import { Meta, StoryObj } from '@storybook/react';
import { AvatarChip } from '@ui/components/avatar-chip/AvatarChip';
import {
ComponentDecorator,
RecoilRootDecorator,
RouterDecorator,
} from '@ui/testing';
const meta: Meta<typeof AvatarChip> = {
title: 'UI/Display/Chip/AvatarChip',
component: AvatarChip,
decorators: [RouterDecorator, ComponentDecorator, RecoilRootDecorator],
args: {
name: 'Entity name',
avatarType: 'squared',
},
};
export default meta;
type Story = StoryObj<typeof AvatarChip>;
export const Default: Story = {};
export const Empty: Story = {
args: {
name: '',
},
};

View File

@ -9,12 +9,8 @@
export type { AvatarChipProps } from './avatar-chip/AvatarChip';
export { AvatarChip } from './avatar-chip/AvatarChip';
export type { AvatarChipsLeftComponentProps } from './avatar-chip/AvatarChipLeftComponent';
export { AvatarChipsLeftComponent } from './avatar-chip/AvatarChipLeftComponent';
export type { LinkAvatarChipProps } from './avatar-chip/LinkAvatarChip';
export { LinkAvatarChip } from './avatar-chip/LinkAvatarChip';
export type { AvatarChipsCommonProps } from './avatar-chip/types/AvatarChipsCommonProps.type';
export { AvatarChipVariant } from './avatar-chip/types/AvatarChipsVariant.type';
export type { MultipleAvatarChipProps } from './avatar-chip/MultipleAvatarChip';
export { MultipleAvatarChip } from './avatar-chip/MultipleAvatarChip';
export type { ChipProps } from './chip/Chip';
export { ChipSize, ChipAccent, ChipVariant, Chip } from './chip/Chip';
export { LINK_CHIP_CLICK_OUTSIDE_ID } from './chip/constants/LinkChipClickOutsideId';