Add email thread widget and message thread record page layout (#19351)

## Summary
- Move email thread display from side panel to a dedicated record page
with a new `EMAIL_THREAD` widget type
- Add message thread as a standard object with page layout, subject
field, and backfill command
- Add reply-to-email command menu item for message thread records
- Remove old side panel message thread components in favor of the new
widget-based approach

## Type fixes
- Add `EMAIL_THREAD` to `WidgetConfigurationType`, `WidgetType`, and all
configuration/validator maps
- Create `EmailThreadConfigurationDTO` and shared
`EmailThreadConfiguration` type
- Register EMAIL_THREAD in widget type validators, configuration
resolvers, and standard widget mappings

## Test plan
- [ ] Verify message thread record pages render with the email thread
widget
- [ ] Verify email thread preview navigates to the record page instead
of opening side panel
- [ ] Verify reply-to-email command appears for message thread records
- [ ] Verify typecheck passes for both twenty-front and twenty-server
- [ ] Run existing test suites to check for regressions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-04-06 12:02:04 +02:00
committed by GitHub
parent 4aa1d71b12
commit 8acfacc69c
56 changed files with 2441 additions and 2371 deletions
@@ -1,5 +1,6 @@
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { CalendarWidget } from '@/page-layout/widgets/calendar/components/CalendarWidget';
import { EmailThreadWidget } from '@/page-layout/widgets/email-thread/components/EmailThreadWidget';
import { EmailWidget } from '@/page-layout/widgets/emails/components/EmailWidget';
import { FieldRichTextWidgetRenderer } from '@/page-layout/widgets/field-rich-text/components/FieldRichTextWidgetRenderer';
import { FieldWidget } from '@/page-layout/widgets/field/components/FieldWidget';
@@ -77,6 +78,9 @@ export const WidgetContentRenderer = ({
case WidgetType.RECORD_TABLE:
return <RecordTableWidgetRenderer widget={widget} />;
case WidgetType.EMAIL_THREAD:
return <EmailThreadWidget widget={widget} />;
default:
return null;
}
@@ -111,11 +111,14 @@ export const WidgetRenderer = ({ widget }: WidgetRendererProps) => {
// TODO: when we have more widgets without headers, we should use a more generic approach to hide the header
// each widget type could have metadata (e.g., hasHeader: boolean or headerMode: 'always' | 'editOnly' | 'never')
const isRichTextWidget = widget.type === WidgetType.STANDALONE_RICH_TEXT;
const hideRichTextHeader = isRichTextWidget && !isPageLayoutInEditMode;
const isHeaderHiddenInViewMode =
widget.type === WidgetType.STANDALONE_RICH_TEXT ||
widget.type === WidgetType.EMAIL_THREAD;
const hideHeaderInViewMode =
isHeaderHiddenInViewMode && !isPageLayoutInEditMode;
const showHeader =
layoutMode !== PageLayoutTabLayoutMode.CANVAS && !hideRichTextHeader;
layoutMode !== PageLayoutTabLayoutMode.CANVAS && !hideHeaderInViewMode;
const handleClick = () => {
handleEditWidget({
@@ -0,0 +1,48 @@
import { styled } from '@linaria/react';
import { useState } from 'react';
import { EmailThreadMessage } from '@/activities/emails/components/EmailThreadMessage';
import { type EmailThreadMessageWithSender } from '@/activities/emails/types/EmailThreadMessageWithSender';
import { t } from '@lingui/core/macro';
import { IconArrowsVertical } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledButtonContainer = styled.div`
border-bottom: 1px solid ${themeCssVariables.border.color.light};
padding: ${themeCssVariables.spacing[4]} ${themeCssVariables.spacing[6]};
`;
export const EmailThreadIntermediaryMessages = ({
messages,
}: {
messages: EmailThreadMessageWithSender[];
}) => {
const [areMessagesOpen, setAreMessagesOpen] = useState(false);
const messagesLength = messages.length;
if (messagesLength === 0) {
return null;
}
return areMessagesOpen ? (
messages.map((message) => (
<EmailThreadMessage
key={message.id}
sender={message.sender}
participants={message.messageParticipants}
body={message.text}
sentAt={message.receivedAt}
/>
))
) : (
<StyledButtonContainer>
<Button
Icon={IconArrowsVertical}
title={t`${messagesLength} emails`}
size="small"
onClick={() => setAreMessagesOpen(true)}
/>
</StyledButtonContainer>
);
};
@@ -0,0 +1,91 @@
import { styled } from '@linaria/react';
import { CustomResolverFetchMoreLoader } from '@/activities/components/CustomResolverFetchMoreLoader';
import { EmailLoader } from '@/activities/emails/components/EmailLoader';
import { EmailThreadMessage } from '@/activities/emails/components/EmailThreadMessage';
import { useEmailThread } from '@/activities/emails/hooks/useEmailThread';
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { EmailThreadIntermediaryMessages } from '@/page-layout/widgets/email-thread/components/EmailThreadIntermediaryMessages';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import { t } from '@lingui/core/macro';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
width: 100%;
`;
const StyledContainer = styled.div`
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow-y: auto;
`;
type EmailThreadWidgetProps = {
widget: PageLayoutWidget;
};
export const EmailThreadWidget = ({
widget: _widget,
}: EmailThreadWidgetProps) => {
const targetRecord = useTargetRecord();
const { thread, messages, fetchMoreMessages, threadLoading } = useEmailThread(
targetRecord.id,
);
const messagesCount = messages.length;
const is5OrMoreMessages = messagesCount >= 5;
const firstMessages = messages.slice(
0,
is5OrMoreMessages ? 2 : messagesCount - 1,
);
const intermediaryMessages = is5OrMoreMessages
? messages.slice(2, messagesCount - 1)
: [];
const lastMessage = messages[messagesCount - 1];
if (threadLoading || !thread || !messages.length) {
return (
<StyledWrapper>
<StyledContainer>
<EmailLoader loadingText={t`Loading thread`} />
</StyledContainer>
</StyledWrapper>
);
}
return (
<StyledWrapper>
<StyledContainer>
{
<>
{firstMessages.map((message) => (
<EmailThreadMessage
key={message.id}
sender={message.sender}
participants={message.messageParticipants}
body={message.text}
sentAt={message.receivedAt}
/>
))}
<EmailThreadIntermediaryMessages messages={intermediaryMessages} />
<EmailThreadMessage
key={lastMessage.id}
sender={lastMessage.sender}
participants={lastMessage.messageParticipants}
body={lastMessage.text}
sentAt={lastMessage.receivedAt}
isExpanded
/>
<CustomResolverFetchMoreLoader
loading={threadLoading}
onLastRowVisible={fetchMoreMessages}
/>
</>
}
</StyledContainer>
</StyledWrapper>
);
};