Files
twenty/packages/twenty-front/src/modules/ai/components/AIChatMessage.tsx
T
Charles Bochet 802a5b0af6 chore(twenty-front): migrate auth, activities, AI, pages and small modules from Emotion to Linaria (PR 2-3/10) (#18328)
## Summary

- Migrate ~200 files from `@emotion/styled` / `@emotion/react` to
`@linaria/react` + `themeCssVariables`, continuing the zero-runtime
CSS-in-JS migration (PR 2-3 of the [migration
plan](docs/emotion-to-linaria-migration-plan.md))
- Modules covered: **auth** (19), **activities** (53), **ai** (30),
**pages** (70), **action-menu** (3), **object-metadata** (4),
**onboarding** (2), **workspace** (2), **file** (3), **error-handler**
(2), **front-components** (1), **geo-map** (1), **loading** (5),
**testing** (5), plus a `style` prop addition to `TableRow`
- Handles `styled(FunctionComponent)<Props>` incompatibility with
Linaria by using CSS custom properties via `style` + `var()` references

## Test plan

- [x] `npx nx lint:diff-with-main twenty-front` passes
- [x] `npx nx typecheck twenty-front` passes
- [ ] Visual spot-check of auth, onboarding, settings, activities, and
AI chat screens
- [ ] No remaining `@emotion/styled` or `@emotion/react` imports in
migrated files


Made with [Cursor](https://cursor.com)
2026-03-03 11:17:47 +01:00

193 lines
5.8 KiB
TypeScript

import { styled } from '@linaria/react';
import { AgentChatFilePreview } from '@/ai/components/internal/AgentChatFilePreview';
import { AgentMessageRole } from '@/ai/constants/AgentMessageRole';
import { AIChatAssistantMessageRenderer } from '@/ai/components/AIChatAssistantMessageRenderer';
import { AIChatErrorRenderer } from '@/ai/components/AIChatErrorRenderer';
import { LightCopyIconButton } from '@/object-record/record-field/ui/components/LightCopyIconButton';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { type ExtendedUIMessage } from 'twenty-shared/ai';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
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;
position: relative;
width: 100%;
&:hover .message-footer {
opacity: 1;
pointer-events: auto;
}
`;
const StyledMessageText = styled.div<{ isUser?: boolean }>`
background: ${({ isUser }) =>
isUser ? themeCssVariables.background.tertiary : 'transparent'};
border-radius: ${({ isUser }) =>
isUser ? themeCssVariables.border.radius.sm : '0'};
color: ${({ isUser }) =>
isUser
? themeCssVariables.font.color.secondary
: themeCssVariables.font.color.primary};
font-weight: ${({ isUser }) => (isUser ? 500 : 400)};
line-height: 1.4em;
max-width: 100%;
padding: ${({ isUser }) =>
isUser ? `0 ${themeCssVariables.spacing[2]}` : '0'};
width: fit-content;
word-wrap: break-word;
overflow-wrap: break-word;
/* Pre-wrap within the whole container turns every newline between block
elements into extra spacing; keep normal flow and only pre-wrap code. */
white-space: normal;
code {
overflow: auto;
white-space: pre-wrap;
word-wrap: break-word;
max-width: 100%;
line-height: 1.4;
padding: 1px 3px;
border-radius: ${themeCssVariables.border.radius.sm};
background: ${themeCssVariables.background.tertiary};
}
pre {
background: ${themeCssVariables.background.tertiary};
padding: ${themeCssVariables.spacing[2]};
border-radius: ${themeCssVariables.border.radius.sm};
overflow-x: auto;
max-width: 100%;
code {
padding: 0;
border-radius: 0;
background: none;
}
}
p {
margin-block: ${({ isUser }) =>
isUser ? '0' : themeCssVariables.spacing[1]};
line-height: 1.4em;
}
ul,
ol {
line-height: 1.4em;
margin: ${themeCssVariables.spacing[1]} 0;
padding-left: ${themeCssVariables.spacing[4]};
}
ul {
list-style-type: disc;
}
li {
line-height: 1.4em;
margin: ${themeCssVariables.spacing['0.5']} 0;
padding-bottom: ${themeCssVariables.spacing['0.5']};
padding-top: ${themeCssVariables.spacing['0.5']};
}
blockquote {
border-left: 3px solid ${themeCssVariables.border.color.medium};
margin: ${themeCssVariables.spacing[2]} 0;
padding-left: ${themeCssVariables.spacing[2]};
color: ${themeCssVariables.font.color.secondary};
}
`;
const StyledMessageFooter = styled.div`
align-items: center;
color: ${themeCssVariables.font.color.secondary};
display: flex;
font-size: ${themeCssVariables.font.size.sm};
justify-content: space-between;
margin-top: ${themeCssVariables.spacing[1]};
opacity: 0;
pointer-events: none;
transition: opacity calc(${themeCssVariables.animation.duration.normal} * 1s)
ease-in-out;
width: 100%;
`;
const StyledMessageTimestamp = styled.span`
color: ${themeCssVariables.font.color.light};
`;
const StyledMessageContainer = styled.div<{ isUser?: boolean }>`
max-width: 100%;
min-width: 0;
width: ${({ isUser }) => (isUser ? 'fit-content' : '100%')};
`;
const StyledFilesContainer = styled.div`
display: flex;
flex-direction: row;
gap: ${themeCssVariables.spacing[2]};
flex-wrap: wrap;
margin-top: ${themeCssVariables.spacing[2]};
`;
export const AIChatMessage = ({
message,
isLastMessageStreaming,
error,
}: {
message: ExtendedUIMessage;
isLastMessageStreaming: boolean;
error?: Error | null;
}) => {
const { localeCatalog } = useAtomStateValue(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={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>
)}
{showError && <AIChatErrorRenderer error={error} />}
</StyledMessageContainer>
{message.parts.length > 0 && message.metadata?.createdAt && (
<StyledMessageFooter className="message-footer">
<StyledMessageTimestamp>
{beautifyPastDateRelativeToNow(
message.metadata?.createdAt,
localeCatalog,
)}
</StyledMessageTimestamp>
<LightCopyIconButton
copyText={
message.parts.find((part) => part.type === 'text')?.text ?? ''
}
/>
</StyledMessageFooter>
)}
</StyledMessageBubble>
);
};