802a5b0af6
## 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)
193 lines
5.8 KiB
TypeScript
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>
|
|
);
|
|
};
|