feat(ai): add code interpreter for AI data analysis (#16559)

## Summary

- Add code interpreter tool that enables AI to execute Python code for
data analysis, CSV processing, and chart generation
- Support for both local (development) and E2B (sandboxed production)
execution drivers
- Real-time streaming of stdout/stderr and generated files
- Frontend components for displaying code execution results with
expandable sections

## Code Quality Improvements

- Extract `getMimeType` to shared utility to reduce code duplication
between drivers
- Fix security issue: escape single quotes/backslashes in E2B driver env
variable injection
- Add `buildExecutionState` helper to reduce duplicated state object
construction
- Add `DEFAULT_CODE_INTERPRETER_TIMEOUT_MS` constant for consistency
- Fix lingui linting warning and TypeScript theme errors in frontend

## Test Plan

- [ ] Test code interpreter with local driver in development
- [ ] Test code interpreter with E2B driver in production environment
- [ ] Verify streaming output displays correctly in chat UI
- [ ] Verify generated files (charts, CSVs) are uploaded and
downloadable
- [ ] Test file upload flow (CSV, Excel) triggers code interpreter

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Updates generated i18n catalogs for Polish and pseudo-English, adding
strings for code execution/output (code interpreter) and various UI
messages, with minor text adjustments.
> 
> - **Localization**:
> - **Generated catalogs**: Refresh `locales/generated/pl-PL.ts` and
`locales/generated/pseudo-en.ts`.
> - Add strings for code execution/output (e.g., code, copy code/output,
running/waiting states, download files, generated files, Python code
execution).
> - Include new UI texts (errors, prompts, menus) and minor text
corrections.
>   - No changes to `pt-BR`; other files unchanged functionally.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
befc13d02c21e5a6647bc1aa6daa2a89f60b7ef8. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
This commit is contained in:
Félix Malfait
2025-12-15 16:11:24 +01:00
committed by GitHub
parent 4281a71f40
commit 2e104c8e76
151 changed files with 10361 additions and 449 deletions
@@ -1,3 +1,4 @@
import { CodeExecutionDisplay } from '@/ai/components/CodeExecutionDisplay';
import { ReasoningSummaryDisplay } from '@/ai/components/ReasoningSummaryDisplay';
import { RoutingStatusDisplay } from '@/ai/components/RoutingStatusDisplay';
import { IconDotsVertical } from 'twenty-ui/display';
@@ -65,6 +66,15 @@ export const AIChatAssistantMessageRenderer = ({
isLastMessageStreaming: boolean;
hasError?: boolean;
}) => {
// Filter out data-code-execution parts when tool-code_interpreter exists
// (the tool part contains the final result, data-code-execution is for streaming updates)
const hasCodeInterpreterTool = messageParts.some(
(part) => part.type === 'tool-code_interpreter',
);
const filteredParts = hasCodeInterpreterTool
? messageParts.filter((part) => part.type !== 'data-code-execution')
: messageParts;
const renderMessagePart = (part: ExtendedUIMessagePart, index: number) => {
switch (part.type) {
case 'reasoning':
@@ -79,6 +89,20 @@ export const AIChatAssistantMessageRenderer = ({
return <LazyMarkdownRenderer key={index} text={part.text} />;
case 'data-routing-status':
return <RoutingStatusDisplay data={part.data} key={index} />;
case 'data-code-execution':
return (
<CodeExecutionDisplay
key={index}
code={part.data.code}
stdout={part.data.stdout}
stderr={part.data.stderr}
exitCode={part.data.exitCode}
files={part.data.files}
isRunning={
part.data.state === 'running' || part.data.state === 'pending'
}
/>
);
default:
{
if (isToolUIPart(part)) {
@@ -89,14 +113,14 @@ export const AIChatAssistantMessageRenderer = ({
}
};
if (!messageParts.length && !hasError) {
if (!filteredParts.length && !hasError) {
return <InitialLoadingIndicator />;
}
return (
<div>
<StyledMessagePartsContainer>
{messageParts.map(renderMessagePart)}
{filteredParts.map(renderMessagePart)}
</StyledMessagePartsContainer>
{isLastMessageStreaming && !hasError && <StyledStreamingIndicator />}
</div>
@@ -0,0 +1,376 @@
import { TerminalOutput } from '@/ai/components/TerminalOutput';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import {
IconChevronDown,
IconChevronUp,
IconCode,
IconCopy,
IconDownload,
IconFile,
IconPlayerPlay,
IconSquareRoundedCheck,
IconSquareRoundedX,
} from 'twenty-ui/display';
import { CodeEditor, LightIconButton } from 'twenty-ui/input';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
const StyledContainer = styled.div`
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
display: flex;
flex-direction: column;
margin: ${({ theme }) => theme.spacing(2)} 0;
overflow: hidden;
`;
const StyledHeader = styled.div<{ status: 'success' | 'error' | 'running' }>`
align-items: center;
background: ${({ theme }) => theme.background.secondary};
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
justify-content: space-between;
padding: ${({ theme }) => theme.spacing(2)} ${({ theme }) => theme.spacing(3)};
`;
const StyledHeaderLeft = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledHeaderRight = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledStatusBadge = styled.div<{
status: 'success' | 'error' | 'running';
}>`
align-items: center;
background: ${({ status, theme }) =>
status === 'success'
? theme.background.transparent.success
: status === 'error'
? theme.background.transparent.danger
: theme.background.transparent.medium};
border-radius: ${({ theme }) => theme.border.radius.pill};
color: ${({ status, theme }) =>
status === 'success'
? theme.color.turquoise
: status === 'error'
? theme.color.red
: theme.font.color.secondary};
display: flex;
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.medium};
gap: ${({ theme }) => theme.spacing(1)};
padding: ${({ theme }) => theme.spacing(0.5)}
${({ theme }) => theme.spacing(2)};
`;
const StyledTitle = styled.span`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
const StyledSection = styled.div`
border-top: 1px solid ${({ theme }) => theme.border.color.light};
`;
const StyledSectionHeader = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.secondary};
cursor: pointer;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
justify-content: space-between;
padding: ${({ theme }) => theme.spacing(2)} ${({ theme }) => theme.spacing(3)};
transition: background ${({ theme }) => theme.animation.duration.fast}s;
&:hover {
background: ${({ theme }) => theme.background.tertiary};
}
`;
const StyledSectionHeaderLeft = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.secondary};
display: flex;
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledCodeEditorContainer = styled.div`
max-height: 300px;
overflow: hidden;
`;
const StyledFilesGrid = styled.div`
display: grid;
gap: ${({ theme }) => theme.spacing(2)};
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
padding: ${({ theme }) => theme.spacing(3)};
`;
const StyledFileCard = styled.div`
border: 1px solid ${({ theme }) => theme.border.color.light};
border-radius: ${({ theme }) => theme.border.radius.sm};
display: flex;
flex-direction: column;
overflow: hidden;
`;
const StyledFilePreview = styled.div`
align-items: center;
aspect-ratio: 16 / 9;
background: ${({ theme }) => theme.background.tertiary};
display: flex;
justify-content: center;
overflow: hidden;
`;
const StyledPreviewImage = styled.img`
height: 100%;
object-fit: contain;
width: 100%;
`;
const StyledFileInfo = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.secondary};
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
justify-content: space-between;
padding: ${({ theme }) => theme.spacing(1.5)}
${({ theme }) => theme.spacing(2)};
`;
const StyledFileName = styled.span`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.sm};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const StyledDownloadLink = styled.a`
align-items: center;
color: ${({ theme }) => theme.font.color.secondary};
display: flex;
&:hover {
color: ${({ theme }) => theme.font.color.primary};
}
`;
type CodeExecutionDisplayProps = {
code: string;
stdout: string;
stderr: string;
exitCode?: number;
files?: Array<{
filename: string;
url: string;
mimeType?: string;
}>;
isRunning?: boolean;
};
const isPreviewableMimeType = (mimeType?: string): boolean => {
if (!mimeType) {
return false;
}
return ['image/png', 'image/jpeg', 'image/gif', 'image/webp'].includes(
mimeType,
);
};
export const CodeExecutionDisplay = ({
code,
stdout,
stderr,
exitCode,
files = [],
isRunning = false,
}: CodeExecutionDisplayProps) => {
const { t } = useLingui();
const theme = useTheme();
const { copyToClipboard } = useCopyToClipboard();
const [isCodeExpanded, setIsCodeExpanded] = useState(false);
const [isOutputExpanded, setIsOutputExpanded] = useState(true);
const [isFilesExpanded, setIsFilesExpanded] = useState(true);
const status: 'success' | 'error' | 'running' = isRunning
? 'running'
: exitCode === 0
? 'success'
: 'error';
const StatusIcon =
status === 'success'
? IconSquareRoundedCheck
: status === 'error'
? IconSquareRoundedX
: IconPlayerPlay;
const statusText = isRunning
? t`Running...`
: exitCode === 0
? t`Completed`
: t`Failed`;
const hasOutput = stdout || stderr;
const hasFiles = files.length > 0;
return (
<StyledContainer>
<StyledHeader status={status}>
<StyledHeaderLeft>
<IconCode size={theme.icon.size.md} />
<StyledTitle>{t`Python Code Execution`}</StyledTitle>
</StyledHeaderLeft>
<StyledHeaderRight>
<StyledStatusBadge status={status}>
<StatusIcon size={theme.icon.size.sm} />
{statusText}
</StyledStatusBadge>
</StyledHeaderRight>
</StyledHeader>
<StyledSection>
<StyledSectionHeader onClick={() => setIsCodeExpanded(!isCodeExpanded)}>
<StyledSectionHeaderLeft>
<IconCode size={theme.icon.size.sm} />
{t`Code`}
</StyledSectionHeaderLeft>
<StyledHeaderRight>
<LightIconButton
Icon={IconCopy}
onClick={(e) => {
e.stopPropagation();
copyToClipboard(code);
}}
title={t`Copy code`}
size="small"
accent="tertiary"
/>
{isCodeExpanded ? (
<IconChevronUp size={theme.icon.size.sm} />
) : (
<IconChevronDown size={theme.icon.size.sm} />
)}
</StyledHeaderRight>
</StyledSectionHeader>
<AnimatedExpandableContainer
isExpanded={isCodeExpanded}
mode="fit-content"
>
<StyledCodeEditorContainer>
<CodeEditor
value={code}
language="python"
height="300px"
options={{
readOnly: true,
domReadOnly: true,
minimap: { enabled: false },
}}
/>
</StyledCodeEditorContainer>
</AnimatedExpandableContainer>
</StyledSection>
{(hasOutput || isRunning) && (
<StyledSection>
<StyledSectionHeader
onClick={() => setIsOutputExpanded(!isOutputExpanded)}
>
<StyledSectionHeaderLeft>{t`Output`}</StyledSectionHeaderLeft>
{isOutputExpanded ? (
<IconChevronUp size={theme.icon.size.sm} />
) : (
<IconChevronDown size={theme.icon.size.sm} />
)}
</StyledSectionHeader>
<AnimatedExpandableContainer
isExpanded={isOutputExpanded}
mode="fit-content"
>
<TerminalOutput
stdout={stdout}
stderr={stderr}
isRunning={isRunning}
/>
</AnimatedExpandableContainer>
</StyledSection>
)}
{hasFiles && (
<StyledSection>
<StyledSectionHeader
onClick={() => setIsFilesExpanded(!isFilesExpanded)}
>
<StyledSectionHeaderLeft>
<IconFile size={theme.icon.size.sm} />
{t`Generated Files`} ({files.length})
</StyledSectionHeaderLeft>
{isFilesExpanded ? (
<IconChevronUp size={theme.icon.size.sm} />
) : (
<IconChevronDown size={theme.icon.size.sm} />
)}
</StyledSectionHeader>
<AnimatedExpandableContainer
isExpanded={isFilesExpanded}
mode="fit-content"
>
<StyledFilesGrid>
{files.map((file) => {
const filename = file.filename;
return (
<StyledFileCard key={file.url}>
<StyledFilePreview>
{isPreviewableMimeType(file.mimeType) ? (
<StyledPreviewImage
src={file.url}
alt={filename}
loading="lazy"
/>
) : (
<IconFile size={48} color={theme.font.color.tertiary} />
)}
</StyledFilePreview>
<StyledFileInfo>
<StyledFileName title={filename}>
{filename}
</StyledFileName>
<StyledDownloadLink
href={file.url}
download={filename}
title={t`Download ${filename}`}
target="_blank"
rel="noopener noreferrer"
>
<IconDownload size={theme.icon.size.sm} />
</StyledDownloadLink>
</StyledFileInfo>
</StyledFileCard>
);
})}
</StyledFilesGrid>
</AnimatedExpandableContainer>
</StyledSection>
)}
</StyledContainer>
);
};
@@ -0,0 +1,174 @@
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { IconCopy, IconTerminal } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
border-radius: ${({ theme }) => theme.border.radius.sm};
overflow: hidden;
`;
const StyledHeader = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.tertiary};
border-bottom: 1px solid ${({ theme }) => theme.border.color.medium};
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
justify-content: space-between;
padding: ${({ theme }) => theme.spacing(1)} ${({ theme }) => theme.spacing(2)};
`;
const StyledHeaderLeft = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.secondary};
display: flex;
font-size: ${({ theme }) => theme.font.size.sm};
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledTabContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledTab = styled.button<{ isActive: boolean; hasError?: boolean }>`
background: ${({ isActive, theme }) =>
isActive ? theme.background.secondary : 'transparent'};
border: none;
border-radius: ${({ theme }) => theme.border.radius.xs};
color: ${({ isActive, hasError, theme }) =>
hasError
? theme.color.red
: isActive
? theme.font.color.primary
: theme.font.color.tertiary};
cursor: pointer;
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ isActive, theme }) =>
isActive ? theme.font.weight.medium : theme.font.weight.regular};
padding: ${({ theme }) => theme.spacing(0.5)}
${({ theme }) => theme.spacing(1)};
&:hover {
background: ${({ theme }) => theme.background.secondary};
color: ${({ hasError, theme }) =>
hasError ? theme.color.red : theme.font.color.primary};
}
`;
const StyledOutputArea = styled.div<{ isError?: boolean }>`
background: ${({ theme }) => theme.background.tertiary};
color: ${({ isError, theme }) =>
isError ? theme.color.red : theme.font.color.primary};
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
font-size: ${({ theme }) => theme.font.size.sm};
line-height: 1.5;
max-height: 300px;
min-height: 100px;
overflow-y: auto;
padding: ${({ theme }) => theme.spacing(2)};
white-space: pre-wrap;
word-break: break-word;
`;
const StyledEmptyMessage = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
font-style: italic;
`;
const StyledCursor = styled.span`
animation: blink 1s step-end infinite;
background: ${({ theme }) => theme.font.color.primary};
display: inline-block;
height: 1em;
margin-left: 2px;
vertical-align: text-bottom;
width: 8px;
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
`;
type TabType = 'stdout' | 'stderr';
type TerminalOutputProps = {
stdout: string;
stderr: string;
isRunning?: boolean;
};
export const TerminalOutput = ({
stdout,
stderr,
isRunning = false,
}: TerminalOutputProps) => {
const { t } = useLingui();
const { copyToClipboard } = useCopyToClipboard();
const hasStderr = stderr.length > 0;
const hasStdout = stdout.length > 0;
const defaultTab: TabType = hasStderr && !hasStdout ? 'stderr' : 'stdout';
const [userSelectedTab, setUserSelectedTab] = useState<TabType | null>(null);
const activeTab = userSelectedTab ?? defaultTab;
const currentOutput = activeTab === 'stdout' ? stdout : stderr;
return (
<StyledContainer>
<StyledHeader>
<StyledHeaderLeft>
<IconTerminal size={14} />
{t`Output`}
</StyledHeaderLeft>
<StyledTabContainer>
<StyledTab
isActive={activeTab === 'stdout'}
onClick={() => setUserSelectedTab('stdout')}
>
stdout
</StyledTab>
{hasStderr && (
<StyledTab
isActive={activeTab === 'stderr'}
hasError
onClick={() => setUserSelectedTab('stderr')}
>
stderr
</StyledTab>
)}
<LightIconButton
Icon={IconCopy}
onClick={() => copyToClipboard(currentOutput)}
title={t`Copy output`}
size="small"
accent="tertiary"
/>
</StyledTabContainer>
</StyledHeader>
<StyledOutputArea isError={activeTab === 'stderr'}>
{currentOutput ? (
<>
{currentOutput}
{isRunning && <StyledCursor />}
</>
) : (
<StyledEmptyMessage>
{isRunning ? t`Waiting for output...` : t`No output`}
</StyledEmptyMessage>
)}
</StyledOutputArea>
</StyledContainer>
);
};
@@ -6,6 +6,7 @@ import { IconChevronDown, IconChevronUp } from 'twenty-ui/display';
import { JsonTree } from 'twenty-ui/json-visualizer';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
import { CodeExecutionDisplay } from '@/ai/components/CodeExecutionDisplay';
import { ShimmeringText } from '@/ai/components/ShimmeringText';
import { getToolIcon } from '@/ai/utils/getToolIcon';
import { getToolDisplayMessage } from '@/ai/utils/getWebSearchToolDisplayMessage';
@@ -141,6 +142,32 @@ export const ToolStepRenderer = ({ toolPart }: { toolPart: ToolUIPart }) => {
const hasError = isDefined(errorText);
const isExpandable = isDefined(output) || hasError;
// Special handling for code_interpreter tool
if (toolName === 'code_interpreter') {
const codeInput = toolInput as { code?: string } | undefined;
const codeOutput = output as {
result?: {
stdout?: string;
stderr?: string;
exitCode?: number;
files?: Array<{ filename: string; url: string; mimeType?: string }>;
};
} | null;
const isRunning = !output && !hasError;
return (
<CodeExecutionDisplay
code={codeInput?.code ?? ''}
stdout={codeOutput?.result?.stdout ?? ''}
stderr={codeOutput?.result?.stderr || errorText || ''}
exitCode={codeOutput?.result?.exitCode}
files={codeOutput?.result?.files}
isRunning={isRunning}
/>
);
}
if (!output && !hasError) {
return (
<StyledContainer>
@@ -0,0 +1,239 @@
import styled from '@emotion/styled';
import { type Meta, type StoryObj } from '@storybook/react';
import { type ExtendedUIMessage } from 'twenty-shared/ai';
import { ComponentDecorator } from 'twenty-ui/testing';
import { AIChatMessage } from '@/ai/components/AIChatMessage';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { RootDecorator } from '~/testing/decorators/RootDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
const StyledConversationContainer = styled.div`
display: flex;
flex-direction: column;
gap: 24px;
max-width: 700px;
padding: 24px;
`;
// Mock messages for the conversation showcase
const mockUserMessage: ExtendedUIMessage = {
id: 'msg-user-1',
role: 'user',
parts: [
{
type: 'text',
text: 'Can you analyze my sales data and create a chart showing the monthly trends?',
},
],
metadata: {
createdAt: new Date(Date.now() - 120000).toISOString(),
},
};
const mockAssistantWithCodeExecution: ExtendedUIMessage = {
id: 'msg-assistant-1',
role: 'assistant',
parts: [
{
type: 'data-code-execution',
data: {
executionId: 'exec-1',
state: 'completed',
code: `import pandas as pd
import matplotlib.pyplot as plt
# Load and process sales data
data = {
'month': ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
'sales': [12500, 15200, 14800, 18900, 21000, 19500]
}
df = pd.DataFrame(data)
# Create the chart
plt.figure(figsize=(10, 6))
plt.bar(df['month'], df['sales'], color='steelblue')
plt.title('Monthly Sales Trends')
plt.xlabel('Month')
plt.ylabel('Sales ($)')
plt.savefig('sales_chart.png', dpi=150)
print(f"Total sales: $" + str(df['sales'].sum()))
print("Chart saved successfully!")`,
language: 'python',
stdout: 'Total sales: $101,900\nChart saved successfully!',
stderr: '',
exitCode: 0,
executionTimeMs: 2340,
files: [
{
filename: 'sales_chart.png',
url: 'https://picsum.photos/800/480',
mimeType: 'image/png',
},
],
},
},
{
type: 'text',
text: "I've analyzed your sales data and created a chart showing the monthly trends. Here are the key insights:\n\n- **Total sales**: $101,900 over 6 months\n- **Peak month**: May with $21,000 in sales\n- **Growth trend**: Overall positive trajectory with 68% growth from January to May\n\nThe chart shows a clear upward trend with a slight dip in March. Would you like me to perform any additional analysis?",
},
],
metadata: {
createdAt: new Date(Date.now() - 60000).toISOString(),
usage: {
inputTokens: 1250,
outputTokens: 890,
inputCredits: 12,
outputCredits: 8,
},
},
};
const mockSimpleTextResponse: ExtendedUIMessage = {
id: 'msg-assistant-text',
role: 'assistant',
parts: [
{
type: 'text',
text: "Hello! I'm your AI assistant. I can help you with:\n\n- **Data analysis** - Analyze your CRM data and generate insights\n- **Code execution** - Run Python code for complex calculations\n- **Record management** - Create, update, or find records\n\nHow can I assist you today?",
},
],
metadata: {
createdAt: new Date().toISOString(),
},
};
const mockStreamingMessage: ExtendedUIMessage = {
id: 'msg-streaming',
role: 'assistant',
parts: [
{
type: 'text',
text: 'Let me look into that for you',
},
],
metadata: {
createdAt: new Date().toISOString(),
},
};
const mockCodeExecutionRunning: ExtendedUIMessage = {
id: 'msg-code-running',
role: 'assistant',
parts: [
{
type: 'data-code-execution',
data: {
executionId: 'exec-running',
state: 'running',
code: `import time
print("Processing data...")
time.sleep(5)
print("Done!")`,
language: 'python',
stdout: 'Processing data...',
stderr: '',
files: [],
},
},
],
metadata: {
createdAt: new Date().toISOString(),
},
};
const mockCodeExecutionError: ExtendedUIMessage = {
id: 'msg-code-error',
role: 'assistant',
parts: [
{
type: 'data-code-execution',
data: {
executionId: 'exec-error',
state: 'error',
code: `import pandas as pd
df = pd.read_csv('missing_file.csv')
print(df.head())`,
language: 'python',
stdout: '',
stderr:
"FileNotFoundError: [Errno 2] No such file or directory: 'missing_file.csv'",
exitCode: 1,
files: [],
error: 'File not found',
},
},
{
type: 'text',
text: "I encountered an error while trying to read the file. It looks like the file `missing_file.csv` doesn't exist. Could you please check the file path or upload the file you'd like me to analyze?",
},
],
metadata: {
createdAt: new Date().toISOString(),
},
};
const meta: Meta<typeof AIChatMessage> = {
title: 'Modules/AI/AIChatMessage',
component: AIChatMessage,
decorators: [
ComponentDecorator,
RootDecorator,
I18nFrontDecorator,
SnackBarDecorator,
],
parameters: {
container: { width: 700 },
},
};
export default meta;
type Story = StoryObj<typeof AIChatMessage>;
// Conversation showcase - demonstrates a full AI chat flow
export const ConversationWithCodeExecution: Story = {
render: () => (
<StyledConversationContainer>
<AIChatMessage message={mockUserMessage} isLastMessageStreaming={false} />
<AIChatMessage
message={mockAssistantWithCodeExecution}
isLastMessageStreaming={false}
/>
</StyledConversationContainer>
),
};
export const UserMessage: Story = {
args: {
message: mockUserMessage,
isLastMessageStreaming: false,
},
};
export const AssistantTextResponse: Story = {
args: {
message: mockSimpleTextResponse,
isLastMessageStreaming: false,
},
};
export const AssistantStreaming: Story = {
args: {
message: mockStreamingMessage,
isLastMessageStreaming: true,
},
};
export const CodeExecutionRunning: Story = {
args: {
message: mockCodeExecutionRunning,
isLastMessageStreaming: false,
},
};
export const CodeExecutionWithError: Story = {
args: {
message: mockCodeExecutionError,
isLastMessageStreaming: false,
},
};
@@ -0,0 +1,209 @@
import { type Meta, type StoryObj } from '@storybook/react';
import { expect, userEvent, within } from '@storybook/test';
import { ComponentDecorator } from 'twenty-ui/testing';
import { CodeExecutionDisplay } from '@/ai/components/CodeExecutionDisplay';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
const samplePythonCode = `import pandas as pd
import matplotlib.pyplot as plt
# Create sample data
data = {
'month': ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
'revenue': [12500, 15200, 14800, 18900, 21000, 19500]
}
df = pd.DataFrame(data)
# Calculate statistics
total = df['revenue'].sum()
average = df['revenue'].mean()
print(f"Total Revenue: $" + f"{total:,.2f}")
print(f"Average Monthly: $" + f"{average:,.2f}")
# Generate chart
plt.figure(figsize=(10, 6))
plt.bar(df['month'], df['revenue'], color='steelblue')
plt.title('Monthly Revenue')
plt.savefig('revenue_chart.png')`;
const meta: Meta<typeof CodeExecutionDisplay> = {
title: 'Modules/AI/CodeExecutionDisplay',
component: CodeExecutionDisplay,
decorators: [I18nFrontDecorator, SnackBarDecorator, ComponentDecorator],
parameters: {
container: { width: 600 },
},
args: {
code: samplePythonCode,
stdout: '',
stderr: '',
isRunning: false,
},
};
export default meta;
type Story = StoryObj<typeof CodeExecutionDisplay>;
export const Running: Story = {
args: {
code: `print("Processing data...")
import time
time.sleep(5)
print("Complete!")`,
stdout: 'Processing data...',
stderr: '',
isRunning: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText('Running...')).toBeVisible();
},
};
export const Success: Story = {
args: {
code: samplePythonCode,
stdout: 'Total Revenue: $101,900.00\nAverage Monthly: $16,983.33',
stderr: '',
exitCode: 0,
isRunning: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText('Completed')).toBeVisible();
// Output content is inside a scrollable container
expect(await canvas.findByText(/Total Revenue/)).toBeInTheDocument();
},
};
export const Error: Story = {
args: {
code: `import pandas as pd
df = pd.read_csv('missing_file.csv')
print(df.head())`,
stdout: '',
stderr:
'Traceback (most recent call last):\n File "<stdin>", line 2, in <module>\nFileNotFoundError: [Errno 2] No such file or directory: \'missing_file.csv\'',
exitCode: 1,
isRunning: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText('Failed')).toBeVisible();
},
};
export const WithImageFiles: Story = {
args: {
code: samplePythonCode,
stdout: 'Chart generated successfully!',
stderr: '',
exitCode: 0,
isRunning: false,
files: [
{
filename: 'revenue_chart.png',
url: 'https://picsum.photos/800/480',
mimeType: 'image/png',
},
{
filename: 'pie_chart.png',
url: 'https://picsum.photos/600/400',
mimeType: 'image/png',
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Text includes file count: "Generated Files (2)"
expect(await canvas.findByText(/Generated Files/)).toBeInTheDocument();
// Filenames may be truncated, check by title attribute
expect(await canvas.findByTitle('revenue_chart.png')).toBeInTheDocument();
expect(await canvas.findByTitle('pie_chart.png')).toBeInTheDocument();
},
};
export const WithDownloadableFiles: Story = {
args: {
code: `import pandas as pd
df = pd.DataFrame({
'name': ['Alice', 'Bob', 'Charlie'],
'sales': [1200, 1500, 980]
})
df.to_csv('report.csv', index=False)
df.to_json('data.json')
print("Files exported successfully!")`,
stdout: 'Files exported successfully!',
stderr: '',
exitCode: 0,
isRunning: false,
files: [
{
filename: 'report.csv',
url: 'data:text/csv,name%2Csales%0AAlice%2C1200%0ABob%2C1500',
mimeType: 'text/csv',
},
{
filename: 'data.json',
url: 'data:application/json,%7B%22name%22%3A%5B%22Alice%22%5D%7D',
mimeType: 'application/json',
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Filenames may be truncated, check by title attribute
expect(await canvas.findByTitle('report.csv')).toBeInTheDocument();
expect(await canvas.findByTitle('data.json')).toBeInTheDocument();
},
};
export const CodeSectionExpanded: Story = {
args: {
code: samplePythonCode,
stdout: 'Total Revenue: $101,900.00',
stderr: '',
exitCode: 0,
isRunning: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Click to expand the code section
const codeHeader = await canvas.findByText('Code');
await userEvent.click(codeHeader);
// The code editor should now be visible
expect(await canvas.findByText('Completed')).toBeVisible();
},
};
export const EmptyOutput: Story = {
args: {
code: `x = 1 + 1
y = x * 2
# No print statements`,
stdout: '',
stderr: '',
exitCode: 0,
isRunning: false,
},
};
export const LongOutput: Story = {
args: {
code: `for i in range(50):
print(f"Processing item {i+1}...")`,
stdout: Array.from(
{ length: 50 },
(_, i) => `Processing item ${i + 1}...`,
).join('\n'),
stderr: '',
exitCode: 0,
isRunning: false,
},
};
@@ -0,0 +1,146 @@
import { type Meta, type StoryObj } from '@storybook/react';
import { expect, userEvent, within } from '@storybook/test';
import { ComponentDecorator } from 'twenty-ui/testing';
import { TerminalOutput } from '@/ai/components/TerminalOutput';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
const meta: Meta<typeof TerminalOutput> = {
title: 'Modules/AI/TerminalOutput',
component: TerminalOutput,
decorators: [I18nFrontDecorator, SnackBarDecorator, ComponentDecorator],
parameters: {
container: { width: 500 },
},
args: {
stdout: '',
stderr: '',
isRunning: false,
},
};
export default meta;
type Story = StoryObj<typeof TerminalOutput>;
export const StdoutOnly: Story = {
args: {
stdout: `Loading data from database...
Processing 1,234 records...
Applying transformations...
Total revenue: $542,890.00
Average order value: $127.50
Export complete!`,
stderr: '',
isRunning: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText(/Total revenue/)).toBeVisible();
expect(await canvas.findByText('stdout')).toBeVisible();
},
};
export const WithStderr: Story = {
args: {
stdout: 'Starting process...\nStep 1 complete.',
stderr:
'Warning: Deprecated function used at line 15\nError: Connection timeout after 30s',
isRunning: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Should show stdout by default
expect(await canvas.findByText(/Starting process/)).toBeVisible();
// Click stderr tab to switch
const stderrTab = await canvas.findByText('stderr');
await userEvent.click(stderrTab);
// Should now show stderr content
expect(await canvas.findByText(/Connection timeout/)).toBeVisible();
},
};
export const StderrOnlyAutoSwitch: Story = {
args: {
stdout: '',
stderr:
'FileNotFoundError: [Errno 2] No such file or directory: \'data.csv\'\nTraceback (most recent call last):\n File "script.py", line 5, in <module>',
isRunning: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// When only stderr exists and no stdout, should auto-switch to stderr
expect(await canvas.findByText(/FileNotFoundError/)).toBeVisible();
},
};
export const Running: Story = {
args: {
stdout: 'Initializing...\nConnecting to server...',
stderr: '',
isRunning: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText(/Connecting to server/)).toBeVisible();
},
};
export const RunningEmpty: Story = {
args: {
stdout: '',
stderr: '',
isRunning: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText('Waiting for output...')).toBeVisible();
},
};
export const Empty: Story = {
args: {
stdout: '',
stderr: '',
isRunning: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText('No output')).toBeVisible();
},
};
export const LongOutput: Story = {
args: {
stdout: Array.from(
{ length: 100 },
(_, i) =>
`[${new Date().toISOString()}] Processing batch ${i + 1}/100...`,
).join('\n'),
stderr: '',
isRunning: false,
},
};
export const MultilineFormatted: Story = {
args: {
stdout: `╔════════════════════════════════════╗
║ SALES REPORT - Q4 2024 ║
╠════════════════════════════════════╣
║ Region │ Revenue │ Growth ║
╠════════════════════════════════════╣
║ North │ $125,000 │ +12.5% ║
║ South │ $98,500 │ +8.2% ║
║ East │ $142,300 │ +15.1% ║
║ West │ $89,200 │ +5.7% ║
╚════════════════════════════════════╝
Total Revenue: $455,000
YoY Growth: +10.4%`,
stderr: '',
isRunning: false,
},
};
@@ -4,7 +4,8 @@ import { useFileCategoryColors } from '@/file/hooks/useFileCategoryColors';
import { IconMapping } from '@/file/utils/fileIconMappings';
import { useTheme } from '@emotion/react';
import { type FileUIPart } from 'ai';
import { AvatarChip, Chip, ChipVariant } from 'twenty-ui/components';
import { isDefined } from 'twenty-shared/utils';
import { AvatarChip, Chip, ChipVariant, LinkChip } from 'twenty-ui/components';
import { type IconComponent, IconX } from 'twenty-ui/display';
import { Loader } from 'twenty-ui/feedback';
@@ -24,35 +25,51 @@ export const AgentChatFilePreview = ({
const fileName =
file instanceof File ? file.name : (file.filename ?? 'Unknown file');
const fileUrl = file instanceof File ? undefined : file.url;
const fileCategory: AttachmentFileCategory = getFileType(fileName);
const FileCategoryIcon: IconComponent = IconMapping[fileCategory];
const iconBackgroundColor: string = iconColors[fileCategory];
const leftComponent = isUploading ? (
<Loader color="yellow" />
) : (
<AvatarChip
Icon={FileCategoryIcon}
IconBackgroundColor={iconBackgroundColor}
/>
);
const rightComponent = onRemove ? (
<AvatarChip
Icon={IconX}
IconColor={theme.font.color.secondary}
onClick={onRemove}
divider="left"
/>
) : undefined;
if (isDefined(fileUrl)) {
return (
<LinkChip
label={fileName}
variant={ChipVariant.Static}
to={fileUrl}
target="_blank"
leftComponent={leftComponent}
rightComponent={rightComponent}
/>
);
}
return (
<Chip
label={fileName}
variant={ChipVariant.Static}
leftComponent={
isUploading ? (
<Loader color="yellow" />
) : (
<AvatarChip
Icon={FileCategoryIcon}
IconBackgroundColor={iconBackgroundColor}
/>
)
}
rightComponent={
onRemove ? (
<AvatarChip
Icon={IconX}
IconColor={theme.font.color.secondary}
onClick={onRemove}
divider="left"
/>
) : undefined
}
clickable={false}
leftComponent={leftComponent}
rightComponent={rightComponent}
/>
);
};