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:
@@ -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>
|
||||
|
||||
+239
@@ -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,
|
||||
},
|
||||
};
|
||||
+209
@@ -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,
|
||||
},
|
||||
};
|
||||
+146
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user