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:
@@ -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}
|
||||
/>
|
||||
|
||||
+3
-3
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user