Improve AI-chat design to match the one provided in Figma (#17816)

Fixed colors, gaps, component nesting, alignment to match Figma.
This commit is contained in:
Abdullah.
2026-02-09 23:28:25 +05:00
committed by GitHub
parent 5ccf18fdf5
commit ef1464db73
9 changed files with 83 additions and 137 deletions
@@ -1,7 +1,5 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { Avatar, IconSparkles } from 'twenty-ui/display';
import { AgentChatFilePreview } from '@/ai/components/internal/AgentChatFilePreview';
import { AgentMessageRole } from '@/ai/constants/AgentMessageRole';
@@ -13,10 +11,11 @@ import { type ExtendedUIMessage } from 'twenty-shared/ai';
import { isDefined } from 'twenty-shared/utils';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
const StyledMessageBubble = styled.div<{ isUser?: boolean }>`
align-items: ${({ isUser }) => (isUser ? 'flex-end' : 'flex-start')};
display: flex;
flex-direction: column;
align-items: flex-start;
position: relative;
width: 100%;
@@ -26,26 +25,17 @@ const StyledMessageBubble = styled.div<{ isUser?: boolean }>`
}
`;
const StyledMessageRow = styled.div`
display: flex;
flex-direction: row;
align-items: flex-start;
gap: ${({ theme }) => theme.spacing(3)};
width: 100%;
`;
const StyledMessageText = styled.div<{ isUser?: boolean }>`
background: ${({ theme, isUser }) =>
isUser ? theme.background.secondary : theme.background.transparent};
border-radius: ${({ theme }) => theme.border.radius.md};
padding: ${({ theme, isUser }) => (isUser ? theme.spacing(1, 2) : 0)};
border: ${({ isUser, theme }) =>
!isUser ? 'none' : `1px solid ${theme.border.color.light}`};
isUser ? theme.background.tertiary : theme.background.transparent};
border-radius: ${({ theme, isUser }) =>
isUser ? theme.border.radius.sm : '0'};
color: ${({ theme, isUser }) =>
isUser ? theme.font.color.light : theme.font.color.primary};
isUser ? theme.font.color.secondary : theme.font.color.primary};
font-weight: ${({ isUser }) => (isUser ? 500 : 400)};
width: fit-content;
max-width: 100%;
padding: ${({ theme, isUser }) => (isUser ? theme.spacing(1, 2) : 0)};
width: fit-content;
word-wrap: break-word;
overflow-wrap: break-word;
/* Pre-wrap within the whole container turns every newline between block
@@ -114,23 +104,10 @@ const StyledMessageFooter = styled.div`
width: 100%;
`;
const StyledAvatarContainer = styled.div<{ isUser?: boolean }>`
align-items: center;
background: ${({ theme, isUser }) =>
isUser
? theme.background.transparent.light
: theme.background.transparent.blue};
display: flex;
justify-content: center;
height: 24px;
min-width: 24px;
border-radius: ${({ theme }) => theme.border.radius.sm};
padding: 1px;
`;
const StyledMessageContainer = styled.div`
const StyledMessageContainer = styled.div<{ isUser?: boolean }>`
max-width: 100%;
min-width: 0;
width: 100%;
width: ${({ isUser }) => (isUser ? 'fit-content' : '100%')};
`;
const StyledFilesContainer = styled.div`
@@ -150,68 +127,48 @@ export const AIChatMessage = ({
isLastMessageStreaming: boolean;
error?: Error | null;
}) => {
const theme = useTheme();
const { localeCatalog } = useRecoilValue(dateLocaleState);
const isUser = message.role === AgentMessageRole.USER;
const showError =
isDefined(error) && message.role === AgentMessageRole.ASSISTANT;
const fileParts = message.parts.filter((part) => part.type === 'file');
return (
<StyledMessageBubble
key={message.id}
isUser={message.role === AgentMessageRole.USER}
>
<StyledMessageRow>
{message.role === AgentMessageRole.ASSISTANT && (
<StyledAvatarContainer>
<Avatar
size="sm"
placeholder="AI"
Icon={IconSparkles}
iconColor={theme.color.blue}
/>
</StyledAvatarContainer>
<StyledMessageBubble key={message.id} isUser={isUser}>
<StyledMessageContainer isUser={isUser}>
<StyledMessageText isUser={isUser}>
<AIChatAssistantMessageRenderer
isLastMessageStreaming={isLastMessageStreaming}
messageParts={message.parts}
hasError={showError}
/>
</StyledMessageText>
{fileParts.length > 0 && (
<StyledFilesContainer>
{fileParts.map((file) => (
<AgentChatFilePreview key={file.filename} file={file} />
))}
</StyledFilesContainer>
)}
{message.role === AgentMessageRole.USER && (
<StyledAvatarContainer isUser>
<Avatar size="sm" placeholder="U" type="rounded" />
</StyledAvatarContainer>
)}
<StyledMessageContainer>
<StyledMessageText isUser={message.role === AgentMessageRole.USER}>
<AIChatAssistantMessageRenderer
isLastMessageStreaming={isLastMessageStreaming}
messageParts={message.parts}
hasError={showError}
/>
</StyledMessageText>
{fileParts.length > 0 && (
<StyledFilesContainer>
{fileParts.map((file) => (
<AgentChatFilePreview key={file.filename} file={file} />
))}
</StyledFilesContainer>
)}
{showError && <AIChatErrorRenderer error={error} />}
{message.parts.length > 0 && message.metadata?.createdAt && (
<StyledMessageFooter className="message-footer">
<span>
{beautifyPastDateRelativeToNow(
message.metadata?.createdAt,
localeCatalog,
)}
</span>
<LightCopyIconButton
copyText={
message.parts.find((part) => part.type === 'text')?.text ?? ''
}
/>
</StyledMessageFooter>
)}
</StyledMessageContainer>
</StyledMessageRow>
{showError && <AIChatErrorRenderer error={error} />}
</StyledMessageContainer>
{message.parts.length > 0 && message.metadata?.createdAt && (
<StyledMessageFooter className="message-footer">
<span>
{beautifyPastDateRelativeToNow(
message.metadata?.createdAt,
localeCatalog,
)}
</span>
<LightCopyIconButton
copyText={
message.parts.find((part) => part.type === 'text')?.text ?? ''
}
/>
</StyledMessageFooter>
)}
</StyledMessageBubble>
);
};
@@ -1,30 +1,11 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Avatar, IconSparkles } from 'twenty-ui/display';
import { AIChatErrorRenderer } from '@/ai/components/AIChatErrorRenderer';
const StyledErrorContainer = styled.div`
display: flex;
flex-direction: row;
flex-direction: column;
align-items: flex-start;
gap: ${({ theme }) => theme.spacing(3)};
width: 100%;
`;
const StyledAvatarContainer = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.transparent.blue};
display: flex;
justify-content: center;
height: 24px;
min-width: 24px;
border-radius: ${({ theme }) => theme.border.radius.sm};
padding: 1px;
`;
const StyledContent = styled.div`
min-width: 0;
width: 100%;
`;
@@ -35,21 +16,9 @@ type AIChatStandaloneErrorProps = {
export const AIChatStandaloneError = ({
error,
}: AIChatStandaloneErrorProps) => {
const theme = useTheme();
return (
<StyledErrorContainer>
<StyledAvatarContainer>
<Avatar
size="sm"
placeholder="AI"
Icon={IconSparkles}
iconColor={theme.color.blue}
/>
</StyledAvatarContainer>
<StyledContent>
<AIChatErrorRenderer error={error} />
</StyledContent>
<AIChatErrorRenderer error={error} />
</StyledErrorContainer>
);
};
@@ -96,9 +96,10 @@ const StyledScrollWrapper = styled(ScrollWrapper)`
`;
const StyledButtonsContainer = styled.div`
align-items: center;
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(1)};
gap: ${({ theme }) => theme.spacing(0.5)};
justify-content: flex-end;
`;
@@ -4,12 +4,13 @@ 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 } from 'twenty-ui/display';
import { IconArrowUp, IconPlayerStop } from 'twenty-ui/display';
import { RoundedIconButton } from 'twenty-ui/input';
export const SendMessageButton = () => {
const agentChatInput = useRecoilValue(agentChatInputState);
const { handleSendMessage, isLoading } = useAgentChatContextOrThrow();
const { handleSendMessage, handleStop, isLoading, isStreaming } =
useAgentChatContextOrThrow();
useHotkeysOnFocusedElement({
keys: [Key.Enter],
@@ -26,9 +27,20 @@ export const SendMessageButton = () => {
},
});
if (isStreaming) {
return (
<RoundedIconButton
Icon={IconPlayerStop}
size="medium"
onClick={() => handleStop()}
/>
);
}
return (
<RoundedIconButton
Icon={IconArrowUp}
size="medium"
onClick={() => handleSendMessage()}
disabled={!agentChatInput || isLoading}
/>
@@ -13,15 +13,15 @@ import { agentChatInputState } from '@/ai/states/agentChatInputState';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(1)};
padding: ${({ theme }) => theme.spacing(3)};
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(2)};
`;
const StyledTitle = styled.div`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
margin-bottom: ${({ theme }) => theme.spacing(1)};
padding: ${({ theme }) => `0 ${theme.spacing(2)}`};
`;
const StyledSuggestedPromptButton = styled(LightButton)`
@@ -8,7 +8,7 @@ export type AgentChatContextValue = {
error?: Error;
handleSendMessage: () => Promise<void>;
handleStop: () => void;
handleRetry: () => void;
};
@@ -80,7 +80,7 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => {
}
};
const { sendMessage, messages, status, error, regenerate } = useChat({
const { sendMessage, messages, status, error, regenerate, stop } = useChat({
transport: new DefaultChatTransport({
api: `${REST_API_BASE_URL}/agent-chat/stream`,
headers: () => ({
@@ -185,6 +185,7 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => {
return {
messages,
handleSendMessage,
handleStop: stop,
isLoading,
isStreaming,
error,
@@ -2,20 +2,18 @@ import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { type IconComponent } from '@ui/display';
const StyledIconButton = styled.button`
export type RoundedIconButtonSize = 'small' | 'medium';
const StyledIconButton = styled.button<{ size: RoundedIconButtonSize }>`
align-items: center;
background: ${({ theme }) => theme.color.blue};
border: none;
border-radius: 50%;
color: ${({ theme }) => theme.font.color.inverted};
cursor: pointer;
display: flex;
height: 20px;
height: ${({ size }) => (size === 'small' ? '20px' : '24px')};
justify-content: center;
outline: none;
padding: 0;
transition:
@@ -27,11 +25,12 @@ const StyledIconButton = styled.button`
color: ${({ theme }) => theme.font.color.tertiary};
cursor: default;
}
width: 20px;
width: ${({ size }) => (size === 'small' ? '20px' : '24px')};
`;
type RoundedIconButtonProps = {
Icon: IconComponent;
size?: RoundedIconButtonSize;
} & React.ButtonHTMLAttributes<HTMLButtonElement>;
export const RoundedIconButton = ({
@@ -39,12 +38,18 @@ export const RoundedIconButton = ({
onClick,
disabled,
className,
size = 'small',
}: RoundedIconButtonProps) => {
const theme = useTheme();
return (
<StyledIconButton className={className} {...{ disabled, onClick }}>
{<Icon size={theme.icon.size.md} />}
<StyledIconButton
className={className}
disabled={disabled}
onClick={onClick}
size={size}
>
<Icon size={theme.icon.size.md} />
</StyledIconButton>
);
};
+1
View File
@@ -66,6 +66,7 @@ export type { LightIconButtonGroupProps } from './button/components/LightIconBut
export { LightIconButtonGroup } from './button/components/LightIconButtonGroup';
export type { MainButtonVariant } from './button/components/MainButton';
export { MainButton } from './button/components/MainButton';
export type { RoundedIconButtonSize } from './button/components/RoundedIconButton';
export { RoundedIconButton } from './button/components/RoundedIconButton';
export {
StyledTabButton,