Agent chat file drag and drop (#13226)

- Added drag and drop support
- The AIChatTab component has been refactored into smaller, more focused
components to improve readability, maintainability, and scalability of
the codebase.
- Introduced a custom useAIChatFileUpload hook to encapsulate and manage
file upload logic, promoting code reuse and separation of concerns.

### Demo

https://github.com/user-attachments/assets/c4b2a67a-2736-48ae-9ba8-8e124e4b6069

---------

Co-authored-by: Raphaël Bosi <71827178+bosiraphael@users.noreply.github.com>
Co-authored-by: neo773 <62795688+neo773@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@twenty.com>
Co-authored-by: MD Readul Islam <99027968+readul-islam@users.noreply.github.com>
Co-authored-by: readul-islam <developer.readul@gamil.com>
Co-authored-by: Thomas des Francs <tdesfrancs@gmail.com>
Co-authored-by: Guillim <guillim@users.noreply.github.com>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
Co-authored-by: Jean-Baptiste Ronssin <65334819+jbronssin@users.noreply.github.com>
Co-authored-by: kahkashan shaik <93042682+kahkashanshaik@users.noreply.github.com>
Co-authored-by: martmull <martmull@hotmail.fr>
Co-authored-by: Paul Rastoin <45004772+prastoin@users.noreply.github.com>
Co-authored-by: bosiraphael <raphael.bosi@gmail.com>
Co-authored-by: Naifer <161821705+omarNaifer12@users.noreply.github.com>
Co-authored-by: Marie Stoppa <marie.stoppa@essec.edu>
This commit is contained in:
Abdul Rahman
2025-07-16 17:09:54 +05:30
committed by GitHub
parent 47386e92a3
commit 0c73c9df50
6 changed files with 464 additions and 369 deletions

View File

@ -0,0 +1,51 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { IconSparkles } from 'twenty-ui/display';
const StyledEmptyState = styled.div`
display: flex;
flex-direction: column;
flex: 1;
gap: ${({ theme }) => theme.spacing(2)};
align-items: center;
justify-content: center;
height: 100%;
`;
const StyledSparkleIcon = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.transparent.blue};
border-radius: ${({ theme }) => theme.border.radius.sm};
padding: ${({ theme }) => theme.spacing(2.5)};
display: flex;
justify-content: center;
margin-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledTitle = styled.div`
font-size: ${({ theme }) => theme.font.size.lg};
font-weight: 600;
`;
const StyledDescription = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
text-align: center;
max-width: 85%;
font-size: ${({ theme }) => theme.font.size.md};
`;
export const AIChatEmptyState = () => {
const theme = useTheme();
return (
<StyledEmptyState>
<StyledSparkleIcon>
<IconSparkles size={theme.icon.size.lg} color={theme.color.blue} />
</StyledSparkleIcon>
<StyledTitle>{t`Chat`}</StyledTitle>
<StyledDescription>
{t`Start a conversation with your AI agent to get workflow insights, task assistance, and process guidance`}
</StyledDescription>
</StyledEmptyState>
);
};

View File

@ -0,0 +1,204 @@
import { keyframes, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Avatar, IconDotsVertical, IconSparkles } from 'twenty-ui/display';
import { LightCopyIconButton } from '@/object-record/record-field/components/LightCopyIconButton';
import { AgentChatFilePreview } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AgentChatFilePreview';
import { AgentChatMessageRole } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/agent-chat-message-role';
import { AgentChatMessage } from '~/generated/graphql';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
const StyledMessageBubble = styled.div<{ isUser?: boolean }>`
display: flex;
flex-direction: column;
align-items: flex-start;
position: relative;
width: 100%;
&:hover .message-footer {
opacity: 1;
pointer-events: auto;
}
`;
const StyledMessageRow = styled.div<{ isShowingToolCall?: boolean }>`
display: flex;
flex-direction: row;
align-items: ${({ isShowingToolCall }) =>
isShowingToolCall ? 'center' : '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}`};
color: ${({ theme, isUser }) =>
isUser ? theme.font.color.light : theme.font.color.primary};
font-weight: ${({ isUser }) => (isUser ? 500 : 400)};
width: fit-content;
white-space: pre-line;
`;
const StyledMessageFooter = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.secondary};
display: flex;
font-size: ${({ theme }) => theme.font.size.sm};
justify-content: space-between;
margin-top: ${({ theme }) => theme.spacing(1)};
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease-in-out;
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`
width: 100%;
`;
const StyledFilesContainer = styled.div`
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(2)};
flex-wrap: wrap;
margin-top: ${({ theme }) => theme.spacing(2)};
`;
const dots = keyframes`
0% { content: ''; }
33% { content: '.'; }
66% { content: '..'; }
100% { content: '...'; }
`;
const StyledToolCallContainer = styled.div`
&::after {
display: inline-block;
content: '';
animation: ${dots} 750ms steps(3, end) infinite;
width: 2ch;
text-align: left;
}
`;
const StyledDotsIconContainer = styled.div`
align-items: center;
border: ${({ theme }) => `1px solid ${theme.border.color.light}`};
border-radius: ${({ theme }) => theme.border.radius.md};
display: flex;
justify-content: center;
padding-inline: ${({ theme }) => theme.spacing(1)};
`;
const StyledDotsIcon = styled(IconDotsVertical)`
color: ${({ theme }) => theme.font.color.light};
transform: rotate(90deg);
`;
export const AIChatMessage = ({
message,
agentStreamingMessage,
}: {
message: AgentChatMessage;
agentStreamingMessage: { streamingText: string; toolCall: string };
}) => {
const theme = useTheme();
const getAssistantMessageContent = (message: AgentChatMessage) => {
if (message.content !== '') {
return message.content;
}
if (agentStreamingMessage.streamingText !== '') {
return agentStreamingMessage.streamingText;
}
if (agentStreamingMessage.toolCall !== '') {
return (
<StyledToolCallContainer>
{agentStreamingMessage.toolCall}
</StyledToolCallContainer>
);
}
return (
<StyledDotsIconContainer>
<StyledDotsIcon size={theme.icon.size.xl} />
</StyledDotsIconContainer>
);
};
return (
<StyledMessageBubble
key={message.id}
isUser={message.role === AgentChatMessageRole.USER}
>
<StyledMessageRow
isShowingToolCall={
message.role === AgentChatMessageRole.ASSISTANT &&
message.content === '' &&
agentStreamingMessage.streamingText === '' &&
agentStreamingMessage.toolCall !== ''
}
>
{message.role === AgentChatMessageRole.ASSISTANT && (
<StyledAvatarContainer>
<Avatar
size="sm"
placeholder="AI"
Icon={IconSparkles}
iconColor={theme.color.blue}
/>
</StyledAvatarContainer>
)}
{message.role === AgentChatMessageRole.USER && (
<StyledAvatarContainer isUser>
<Avatar size="sm" placeholder="U" type="rounded" />
</StyledAvatarContainer>
)}
<StyledMessageContainer>
<StyledMessageText
isUser={message.role === AgentChatMessageRole.USER}
>
{message.role === AgentChatMessageRole.ASSISTANT
? getAssistantMessageContent(message)
: message.content}
</StyledMessageText>
{message.files.length > 0 && (
<StyledFilesContainer>
{message.files.map((file) => (
<AgentChatFilePreview key={file.id} file={file} />
))}
</StyledFilesContainer>
)}
{message.content && (
<StyledMessageFooter className="message-footer">
<span>{beautifyPastDateRelativeToNow(message.createdAt)}</span>
<LightCopyIconButton copyText={message.content} />
</StyledMessageFooter>
)}
</StyledMessageContainer>
</StyledMessageRow>
</StyledMessageBubble>
);
};

View File

@ -0,0 +1,82 @@
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { agentChatSelectedFilesComponentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentChatSelectedFilesComponentState';
import { agentChatUploadedFilesComponentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentChatUploadedFilesComponentState';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import {
File as FileDocument,
useCreateFileMutation,
} from '~/generated-metadata/graphql';
export const useAIChatFileUpload = ({ agentId }: { agentId: string }) => {
const coreClient = useApolloCoreClient();
const [createFile] = useCreateFileMutation({ client: coreClient });
const { t } = useLingui();
const { enqueueErrorSnackBar } = useSnackBar();
const [agentChatSelectedFiles, setAgentChatSelectedFiles] =
useRecoilComponentStateV2(agentChatSelectedFilesComponentState, agentId);
const [agentChatUploadedFiles, setAgentChatUploadedFiles] =
useRecoilComponentStateV2(agentChatUploadedFilesComponentState, agentId);
const sendFile = async (file: File) => {
try {
const result = await createFile({
variables: {
file,
},
});
const uploadedFile = result?.data?.createFile;
if (!isDefined(uploadedFile)) {
throw new Error(t`Couldn't upload the file.`);
}
setAgentChatSelectedFiles(
agentChatSelectedFiles.filter((f) => f.name !== file.name),
);
return uploadedFile;
} catch (error) {
const fileName = file.name;
enqueueErrorSnackBar({
message: t`Failed to upload file: ${fileName}`,
});
return null;
}
};
const uploadFiles = async (files: File[]) => {
const uploadResults = await Promise.allSettled(
files.map((file) => sendFile(file)),
);
const successfulUploads = uploadResults.reduce<FileDocument[]>(
(acc, result) => {
if (result.status === 'fulfilled' && isDefined(result.value)) {
acc.push(result.value);
}
return acc;
},
[],
);
if (successfulUploads.length > 0) {
setAgentChatUploadedFiles([
...agentChatUploadedFiles,
...successfulUploads,
]);
}
const failedCount = uploadResults.filter(
(result) => result.status === 'rejected',
).length;
if (failedCount > 0) {
enqueueErrorSnackBar({
message: t`${failedCount} file(s) failed to upload`,
});
}
};
return { uploadFiles };
};

View File

@ -0,0 +1,39 @@
import { AgentChatThread } from '~/generated-metadata/graphql';
import { groupThreadsByDate } from '../groupThreadsByDate';
describe('groupThreadsByDate', () => {
const baseThread: Omit<AgentChatThread, 'createdAt' | 'id'> = {
agentId: 'agent-1',
title: 'Test Thread',
updatedAt: new Date().toISOString(),
};
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(today.getDate() - 1);
const twoDaysAgo = new Date(today);
twoDaysAgo.setDate(today.getDate() - 2);
const threads: AgentChatThread[] = [
{ ...baseThread, id: '1', createdAt: today.toISOString() },
{ ...baseThread, id: '2', createdAt: yesterday.toISOString() },
{ ...baseThread, id: '3', createdAt: twoDaysAgo.toISOString() },
];
it('groups threads into today, yesterday, and older', () => {
const result = groupThreadsByDate(threads);
expect(result.today).toHaveLength(1);
expect(result.today[0].id).toBe('1');
expect(result.yesterday).toHaveLength(1);
expect(result.yesterday[0].id).toBe('2');
expect(result.older).toHaveLength(1);
expect(result.older[0].id).toBe('3');
});
it('returns empty arrays if no threads', () => {
const result = groupThreadsByDate([]);
expect(result.today).toEqual([]);
expect(result.yesterday).toEqual([]);
expect(result.older).toEqual([]);
});
});

View File

@ -1,77 +1,39 @@
import { TextArea } from '@/ui/input/components/TextArea';
import { keyframes, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import {
Avatar,
IconDotsVertical,
IconHistory,
IconMessageCirclePlus,
IconSparkles,
} from 'twenty-ui/display';
import { IconHistory, IconMessageCirclePlus } from 'twenty-ui/display';
import { DropZone } from '@/activities/files/components/DropZone';
import { useCreateNewAIChatThread } from '@/ai/hooks/useCreateNewAIChatThread';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { LightCopyIconButton } from '@/object-record/record-field/components/LightCopyIconButton';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { AgentChatFilePreview } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AgentChatFilePreview';
import { AgentChatFileUpload } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AgentChatFileUpload';
import { AgentChatMessageRole } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/agent-chat-message-role';
import { AIChatEmptyState } from '@/ai/components/AIChatEmptyState';
import { AIChatMessage } from '@/ai/components/AIChatMessage';
import { useAIChatFileUpload } from '@/ai/hooks/useAIChatFileUpload';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { Button } from 'twenty-ui/input';
import { AgentChatMessage } from '~/generated/graphql';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
import { useAgentChat } from '../hooks/useAgentChat';
import { AIChatSkeletonLoader } from './AIChatSkeletonLoader';
import { AgentChatSelectedFilesPreview } from './AgentChatSelectedFilesPreview';
const StyledContainer = styled.div`
const StyledContainer = styled.div<{ isDraggingFile: boolean }>`
background: ${({ theme }) => theme.background.primary};
height: calc(100% - 154px);
`;
const StyledEmptyState = styled.div`
height: ${({ isDraggingFile }) =>
isDraggingFile ? `calc(100% - 24px)` : '100%'};
padding: ${({ isDraggingFile, theme }) =>
isDraggingFile ? theme.spacing(3) : '0'};
display: flex;
flex-direction: column;
flex: 1;
gap: ${({ theme }) => theme.spacing(2)};
align-items: center;
justify-content: center;
height: 100%;
`;
const StyledSparkleIcon = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.transparent.blue};
border-radius: ${({ theme }) => theme.border.radius.sm};
display: flex;
height: 40px;
justify-content: center;
margin-bottom: ${({ theme }) => theme.spacing(2)};
width: 40px;
`;
const StyledTitle = styled.div`
font-size: ${({ theme }) => theme.font.size.lg};
font-weight: 600;
`;
const StyledDescription = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
text-align: center;
max-width: 85%;
font-size: ${({ theme }) => theme.font.size.md};
`;
const StyledInputArea = styled.div`
align-items: flex-end;
bottom: 0;
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
position: absolute;
width: calc(100% - 24px);
padding: ${({ theme }) => theme.spacing(3)};
background: ${({ theme }) => theme.background.primary};
`;
@ -86,118 +48,12 @@ const StyledScrollWrapper = styled(ScrollWrapper)`
width: calc(100% - 24px);
`;
const StyledMessageBubble = styled.div<{ isUser?: boolean }>`
display: flex;
flex-direction: column;
align-items: flex-start;
position: relative;
width: 100%;
&:hover .message-footer {
opacity: 1;
pointer-events: auto;
}
`;
const StyledMessageRow = styled.div<{ isShowingToolCall?: boolean }>`
display: flex;
flex-direction: row;
align-items: ${({ isShowingToolCall }) =>
isShowingToolCall ? 'center' : '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}`};
color: ${({ theme, isUser }) =>
isUser ? theme.font.color.light : theme.font.color.primary};
font-weight: ${({ isUser }) => (isUser ? 500 : 400)};
width: fit-content;
white-space: pre-line;
`;
const StyledMessageFooter = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.secondary};
display: flex;
font-size: ${({ theme }) => theme.font.size.sm};
justify-content: space-between;
margin-top: ${({ theme }) => theme.spacing(1)};
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease-in-out;
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 StyledDotsIconContainer = styled.div`
align-items: center;
border: ${({ theme }) => `1px solid ${theme.border.color.light}`};
border-radius: ${({ theme }) => theme.border.radius.md};
display: flex;
justify-content: center;
padding-inline: ${({ theme }) => theme.spacing(1)};
`;
const StyledDotsIcon = styled(IconDotsVertical)`
color: ${({ theme }) => theme.font.color.light};
transform: rotate(90deg);
`;
const StyledMessageContainer = styled.div`
width: 100%;
`;
const dots = keyframes`
0% { content: ''; }
33% { content: '.'; }
66% { content: '..'; }
100% { content: '...'; }
`;
const StyledToolCallContainer = styled.div`
&::after {
display: inline-block;
content: '';
animation: ${dots} 750ms steps(3, end) infinite;
width: 2ch;
text-align: left;
}
`;
const StyledButtonsContainer = styled.div`
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledFilesContainer = styled.div`
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(2)};
flex-wrap: wrap;
margin-top: ${({ theme }) => theme.spacing(2)};
`;
export const AIChatTab = ({
agentId,
isWorkflowAgentNodeChat,
@ -205,7 +61,7 @@ export const AIChatTab = ({
agentId: string;
isWorkflowAgentNodeChat?: boolean;
}) => {
const theme = useTheme();
const [isDraggingFile, setIsDraggingFile] = useState(false);
const {
messages,
@ -216,151 +72,83 @@ export const AIChatTab = ({
agentStreamingMessage,
scrollWrapperId,
} = useAgentChat(agentId);
const { uploadFiles } = useAIChatFileUpload({ agentId });
const { createAgentChatThread } = useCreateNewAIChatThread({ agentId });
const { navigateCommandMenu } = useCommandMenu();
const getAssistantMessageContent = (message: AgentChatMessage) => {
if (message.content !== '') {
return message.content;
}
if (agentStreamingMessage.streamingText !== '') {
return agentStreamingMessage.streamingText;
}
if (agentStreamingMessage.toolCall !== '') {
return (
<StyledToolCallContainer>
{agentStreamingMessage.toolCall}
</StyledToolCallContainer>
);
}
return (
<StyledDotsIconContainer>
<StyledDotsIcon size={theme.icon.size.xl} />
</StyledDotsIconContainer>
);
};
return (
<StyledContainer>
{messages.length !== 0 && (
<StyledScrollWrapper componentInstanceId={scrollWrapperId}>
{messages.map((msg) => (
<StyledMessageBubble
key={msg.id}
isUser={msg.role === AgentChatMessageRole.USER}
>
<StyledMessageRow
isShowingToolCall={
msg.role === AgentChatMessageRole.ASSISTANT &&
msg.content === '' &&
agentStreamingMessage.streamingText === '' &&
agentStreamingMessage.toolCall !== ''
}
>
{msg.role === AgentChatMessageRole.ASSISTANT && (
<StyledAvatarContainer>
<Avatar
size="sm"
placeholder="AI"
Icon={IconSparkles}
iconColor={theme.color.blue}
/>
</StyledAvatarContainer>
)}
{msg.role === AgentChatMessageRole.USER && (
<StyledAvatarContainer isUser>
<Avatar size="sm" placeholder="U" type="rounded" />
</StyledAvatarContainer>
)}
<StyledMessageContainer>
<StyledMessageText
isUser={msg.role === AgentChatMessageRole.USER}
>
{msg.role === AgentChatMessageRole.ASSISTANT
? getAssistantMessageContent(msg)
: msg.content}
</StyledMessageText>
{msg.files.length > 0 && (
<StyledFilesContainer>
{msg.files.map((file) => (
<AgentChatFilePreview key={file.id} file={file} />
))}
</StyledFilesContainer>
)}
{msg.content && (
<StyledMessageFooter className="message-footer">
<span>
{beautifyPastDateRelativeToNow(msg.createdAt)}
</span>
<LightCopyIconButton copyText={msg.content} />
</StyledMessageFooter>
)}
</StyledMessageContainer>
</StyledMessageRow>
</StyledMessageBubble>
))}
</StyledScrollWrapper>
)}
{messages.length === 0 && !isLoading && (
<StyledEmptyState>
<StyledSparkleIcon>
<IconSparkles size={theme.icon.size.lg} color={theme.color.blue} />
</StyledSparkleIcon>
<StyledTitle>{t`Chat`}</StyledTitle>
<StyledDescription>
{t`Start a conversation with your AI agent to get workflow insights, task assistance, and process guidance`}
</StyledDescription>
</StyledEmptyState>
)}
{isLoading && messages.length === 0 && <AIChatSkeletonLoader />}
<StyledInputArea>
<AgentChatSelectedFilesPreview agentId={agentId} />
<TextArea
textAreaId={`${agentId}-chat-input`}
placeholder={t`Enter a question...`}
value={input}
onChange={handleInputChange}
<StyledContainer
isDraggingFile={isDraggingFile}
onDragEnter={() => setIsDraggingFile(true)}
>
{isDraggingFile && (
<DropZone
setIsDraggingFile={setIsDraggingFile}
onUploadFile={(files) => uploadFiles([files])}
/>
<StyledButtonsContainer>
{!isWorkflowAgentNodeChat && (
<>
<Button
variant="secondary"
size="small"
Icon={IconHistory}
onClick={() =>
navigateCommandMenu({
page: CommandMenuPages.ViewPreviousAIChats,
pageTitle: t`View Previous AI Chats`,
pageIcon: IconHistory,
})
}
/>
<Button
variant="secondary"
size="small"
Icon={IconMessageCirclePlus}
onClick={() => createAgentChatThread()}
/>
</>
)}
{!isDraggingFile && (
<>
{messages.length !== 0 && (
<StyledScrollWrapper componentInstanceId={scrollWrapperId}>
{messages.map((message) => (
<AIChatMessage
agentStreamingMessage={agentStreamingMessage}
message={message}
key={message.id}
/>
))}
</StyledScrollWrapper>
)}
<AgentChatFileUpload agentId={agentId} />
<Button
variant="primary"
accent="blue"
size="small"
hotkeys={input && !isLoading ? ['⏎'] : undefined}
disabled={!input || isLoading}
title={t`Send`}
onClick={handleSendMessage}
/>
</StyledButtonsContainer>
</StyledInputArea>
{messages.length === 0 && !isLoading && <AIChatEmptyState />}
{isLoading && messages.length === 0 && <AIChatSkeletonLoader />}
<StyledInputArea>
<AgentChatSelectedFilesPreview agentId={agentId} />
<TextArea
textAreaId={`${agentId}-chat-input`}
placeholder={t`Enter a question...`}
value={input}
onChange={handleInputChange}
/>
<StyledButtonsContainer>
{!isWorkflowAgentNodeChat && (
<>
<Button
variant="secondary"
size="small"
Icon={IconHistory}
onClick={() =>
navigateCommandMenu({
page: CommandMenuPages.ViewPreviousAIChats,
pageTitle: t`View Previous AI Chats`,
pageIcon: IconHistory,
})
}
/>
<Button
variant="secondary"
size="small"
Icon={IconMessageCirclePlus}
onClick={() => createAgentChatThread()}
/>
</>
)}
<AgentChatFileUpload agentId={agentId} />
<Button
variant="primary"
accent="blue"
size="small"
hotkeys={input && !isLoading ? ['⏎'] : undefined}
disabled={!input || isLoading}
title={t`Send`}
onClick={handleSendMessage}
/>
</StyledButtonsContainer>
</StyledInputArea>
</>
)}
</StyledContainer>
);
};

View File

@ -1,18 +1,10 @@
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useAIChatFileUpload } from '@/ai/hooks/useAIChatFileUpload';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { agentChatSelectedFilesComponentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentChatSelectedFilesComponentState';
import { agentChatUploadedFilesComponentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/agentChatUploadedFilesComponentState';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import React, { useRef } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { IconPaperclip } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import {
File as FileDocument,
useCreateFileMutation,
} from '~/generated-metadata/graphql';
const StyledFileUploadContainer = styled.div`
display: flex;
@ -25,73 +17,12 @@ const StyledFileInput = styled.input`
`;
export const AgentChatFileUpload = ({ agentId }: { agentId: string }) => {
const coreClient = useApolloCoreClient();
const [createFile] = useCreateFileMutation({ client: coreClient });
const { t } = useLingui();
const { enqueueErrorSnackBar } = useSnackBar();
const [agentChatSelectedFiles, setAgentChatSelectedFiles] =
useRecoilComponentStateV2(agentChatSelectedFilesComponentState, agentId);
const [agentChatUploadedFiles, setAgentChatUploadedFiles] =
useRecoilComponentStateV2(agentChatUploadedFilesComponentState, agentId);
const [, setAgentChatSelectedFiles] = useRecoilComponentStateV2(
agentChatSelectedFilesComponentState,
agentId,
);
const fileInputRef = useRef<HTMLInputElement>(null);
const sendFile = async (file: File) => {
try {
const result = await createFile({
variables: {
file,
},
});
const uploadedFile = result?.data?.createFile;
if (!isDefined(uploadedFile)) {
throw new Error("Couldn't upload the file.");
}
setAgentChatSelectedFiles(
agentChatSelectedFiles.filter((f) => f.name !== file.name),
);
return uploadedFile;
} catch (error) {
const fileName = file.name;
enqueueErrorSnackBar({
message: t`Failed to upload file: ${fileName}`,
});
return null;
}
};
const uploadFiles = async (files: File[]) => {
const uploadResults = await Promise.allSettled(
files.map((file) => sendFile(file)),
);
const successfulUploads = uploadResults.reduce<FileDocument[]>(
(acc, result) => {
if (result.status === 'fulfilled' && isDefined(result.value)) {
acc.push(result.value);
}
return acc;
},
[],
);
if (successfulUploads.length > 0) {
setAgentChatUploadedFiles([
...agentChatUploadedFiles,
...successfulUploads,
]);
}
const failedCount = uploadResults.filter(
(result) => result.status === 'rejected',
).length;
if (failedCount > 0) {
enqueueErrorSnackBar({
message: t`${failedCount} file(s) failed to upload`,
});
}
};
const { uploadFiles } = useAIChatFileUpload({ agentId });
const handleFileInputChange = (
event: React.ChangeEvent<HTMLInputElement>,