Show tool execution messages in AI agent chat (#13117)

https://github.com/user-attachments/assets/c0a42726-50ac-496e-a993-9d6076a84a6a

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Abdul Rahman
2025-07-10 11:15:05 +05:30
committed by GitHub
parent e6cdae5c27
commit 8310b4ff01
62 changed files with 1304 additions and 227 deletions
@@ -1,5 +1,5 @@
import { TextArea } from '@/ui/input/components/TextArea';
import { useTheme } from '@emotion/react';
import { keyframes, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import React from 'react';
import { Avatar, IconDotsVertical, IconSparkles } from 'twenty-ui/display';
@@ -11,6 +11,7 @@ import { t } from '@lingui/core/macro';
import { Button } from 'twenty-ui/input';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
import { useAgentChat } from '../hooks/useAgentChat';
import { AgentChatMessage } from '../hooks/useAgentChatMessages';
import { AIChatSkeletonLoader } from './AIChatSkeletonLoader';
const StyledContainer = styled.div`
@@ -86,9 +87,11 @@ const StyledMessageBubble = styled.div<{ isUser?: boolean }>`
}
`;
const StyledMessageRow = styled.div`
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%;
`;
@@ -152,6 +155,23 @@ 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;
}
`;
type AIChatTabProps = {
agentId: string;
};
@@ -168,16 +188,49 @@ export const AIChatTab: React.FC<AIChatTabProps> = ({ agentId }) => {
agentStreamingMessage,
} = useAgentChat(agentId);
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={agentId}>
<StyledScrollWrapper
componentInstanceId={`scroll-wrapper-ai-chat-${agentId}`}
>
{messages.map((msg) => (
<StyledMessageBubble
key={msg.id}
isUser={msg.role === AgentChatMessageRole.USER}
>
<StyledMessageRow>
<StyledMessageRow
isShowingToolCall={
msg.role === AgentChatMessageRole.ASSISTANT &&
msg.content === '' &&
agentStreamingMessage.streamingText === '' &&
agentStreamingMessage.toolCall !== ''
}
>
{msg.role === AgentChatMessageRole.ASSISTANT && (
<StyledAvatarContainer>
<Avatar
@@ -197,12 +250,8 @@ export const AIChatTab: React.FC<AIChatTabProps> = ({ agentId }) => {
<StyledMessageText
isUser={msg.role === AgentChatMessageRole.USER}
>
{msg.role === AgentChatMessageRole.ASSISTANT && !msg.content
? agentStreamingMessage || (
<StyledDotsIconContainer>
<StyledDotsIcon size={theme.icon.size.xl} />
</StyledDotsIconContainer>
)
{msg.role === AgentChatMessageRole.ASSISTANT
? getAssistantMessageContent(msg)
: msg.content}
</StyledMessageText>
{msg.content && (
@@ -8,11 +8,11 @@ import { useScrollWrapperElement } from '@/ui/utilities/scroll/hooks/useScrollWr
import { STREAM_CHAT_QUERY } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/api/agent-chat-apollo.api';
import { AgentChatMessageRole } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/agent-chat-message-role';
import { useApolloClient } from '@apollo/client';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { agentChatInputState } from '../states/agentChatInputState';
import { agentChatMessagesComponentState } from '../states/agentChatMessagesComponentState';
import { agentStreamingMessageState } from '../states/agentStreamingMessageState';
import { parseAgentStreamingChunk } from '../utils/parseAgentStreamingChunk';
import { AgentChatMessage, useAgentChatMessages } from './useAgentChatMessages';
import { useAgentChatThreads } from './useAgentChatThreads';
@@ -50,21 +50,14 @@ export const useAgentChat = (agentId: string) => {
useAgentChatThreads(agentId);
const currentThreadId = threads[0]?.id;
const {
data: messagesData,
loading: messagesLoading,
refetch: refetchMessages,
} = useAgentChatMessages(currentThreadId);
const { loading: messagesLoading, refetch: refetchMessages } =
useAgentChatMessages(currentThreadId, ({ messages }) => {
setAgentChatMessages(messages);
scrollToBottom();
});
const isLoading = messagesLoading || threadsLoading || isStreaming;
if (
agentChatMessages.length === 0 &&
isDefined(messagesData?.messages?.length)
) {
setAgentChatMessages(messagesData.messages);
}
const createOptimisticMessages = (content: string): AgentChatMessage[] => {
const optimisticUserMessage: OptimisticMessage = {
id: v4(),
@@ -104,8 +97,22 @@ export const useAgentChat = (agentId: string) => {
},
context: {
onChunk: (chunk: string) => {
setAgentStreamingMessage(chunk);
scrollToBottom();
parseAgentStreamingChunk(chunk, {
onTextDelta: (message: string) => {
setAgentStreamingMessage((prev) => ({
...prev,
streamingText: prev.streamingText + message,
}));
scrollToBottom();
},
onToolCall: (message: string) => {
setAgentStreamingMessage((prev) => ({
...prev,
toolCall: message,
}));
scrollToBottom();
},
});
},
},
});
@@ -128,7 +135,10 @@ export const useAgentChat = (agentId: string) => {
const { data } = await refetchMessages();
setAgentChatMessages(data?.messages);
setAgentStreamingMessage('');
setAgentStreamingMessage({
toolCall: '',
streamingText: '',
});
scrollToBottom();
};
@@ -11,9 +11,13 @@ export type AgentChatMessage = {
createdAt: string;
};
export const useAgentChatMessages = (threadId: string) => {
export const useAgentChatMessages = (
threadId: string,
onCompleted?: (data: { messages: AgentChatMessage[] }) => void,
) => {
return useQuery<{ messages: AgentChatMessage[] }>(GET_AGENT_CHAT_MESSAGES, {
variables: { threadId },
skip: !isDefined(threadId),
onCompleted,
});
};
@@ -1,6 +1,12 @@
import { atom } from 'recoil';
export const agentStreamingMessageState = atom<string>({
export const agentStreamingMessageState = atom<{
toolCall: string;
streamingText: string;
}>({
key: 'agentStreamingMessageState',
default: '',
default: {
toolCall: '',
streamingText: '',
},
});
@@ -0,0 +1,43 @@
export type AgentStreamingEvent = {
type: 'text-delta' | 'tool-call';
message: string;
};
export type AgentStreamingParserCallbacks = {
onTextDelta?: (message: string) => void;
onToolCall?: (message: string) => void;
onError?: (error: Error, rawLine: string) => void;
};
export const parseAgentStreamingChunk = (
chunk: string,
callbacks: AgentStreamingParserCallbacks,
): void => {
const lines = chunk.split('\n');
for (const line of lines) {
if (line.trim() !== '') {
try {
const event = JSON.parse(line) as AgentStreamingEvent;
switch (event.type) {
case 'text-delta':
callbacks.onTextDelta?.(event.message);
break;
case 'tool-call':
callbacks.onToolCall?.(event.message);
break;
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to parse stream event:', error);
const errorMessage =
error instanceof Error
? error
: new Error(`Unknown parsing error: ${String(error)}`);
callbacks.onError?.(errorMessage, line);
}
}
}
};