Improve AI chat UX (#17974)

## Summary
- update AI chat message typography and list line-height for readability
- apply richer markdown-section styling for headings, spacing,
separators, and inline code
- keep links non-underlined by default with underline on hover, using
accent11 for link color
- preserve previous AI chat table design while keeping other markdown
improvements

## Validation
- yarn eslint
packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx
packages/twenty-front/src/modules/ai/components/AIChatMessage.tsx

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
Thomas des Francs
2026-02-17 11:24:33 +01:00
committed by GitHub
parent aac032e517
commit 9477bb3677
29 changed files with 1636 additions and 165 deletions
-1
View File
@@ -14,7 +14,6 @@ process.env.TZ = 'GMT';
// eslint-disable-next-line no-undef
process.env.LC_ALL = 'en_US.UTF-8';
const jestConfig = {
silent: true,
// For more information please have a look to official docs https://jestjs.io/docs/configuration/#prettierpath-string
// Prettier v3 will should be supported in jest v30 https://github.com/jestjs/jest/releases/tag/v30.0.0-alpha.1
prettierPath: null,
+26
View File
@@ -3,6 +3,11 @@
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
import {
ReadableStream as NodeReadableStream,
TransformStream as NodeTransformStream,
WritableStream as NodeWritableStream,
} from 'node:stream/web';
import { i18n } from '@lingui/core';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
@@ -12,6 +17,27 @@ import { messages as enMessages } from '~/locales/generated/en';
i18n.load({ [SOURCE_LOCALE]: enMessages });
i18n.activate(SOURCE_LOCALE);
const globalWithWebStreams = globalThis as Record<string, unknown>;
if (globalWithWebStreams.TransformStream === undefined) {
globalWithWebStreams.TransformStream = NodeTransformStream;
}
if (globalWithWebStreams.ReadableStream === undefined) {
globalWithWebStreams.ReadableStream = NodeReadableStream;
}
if (globalWithWebStreams.WritableStream === undefined) {
globalWithWebStreams.WritableStream = NodeWritableStream;
}
if (typeof window !== 'undefined') {
Object.defineProperty(window, 'scrollTo', {
value: () => {},
writable: true,
});
}
// Add Jest matchers for toThrowError and other missing methods
declare global {
namespace jest {
@@ -1,11 +1,12 @@
import { CodeExecutionDisplay } from '@/ai/components/CodeExecutionDisplay';
import { ReasoningSummaryDisplay } from '@/ai/components/ReasoningSummaryDisplay';
import { RoutingStatusDisplay } from '@/ai/components/RoutingStatusDisplay';
import { ThinkingStepsDisplay } from '@/ai/components/ThinkingStepsDisplay';
import { IconDotsVertical } from 'twenty-ui/display';
import { LazyMarkdownRenderer } from '@/ai/components/LazyMarkdownRenderer';
import { ToolStepRenderer } from '@/ai/components/ToolStepRenderer';
import { keyframes, useTheme } from '@emotion/react';
import { groupContiguousThinkingStepParts } from '@/ai/utils/groupContiguousThinkingStepParts';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { isToolUIPart } from 'ai';
import { type ExtendedUIMessagePart } from 'twenty-shared/ai';
@@ -30,23 +31,6 @@ const StyledLoadingIcon = styled(IconDotsVertical)`
transform: rotate(90deg);
`;
const streamingDotsAnimation = keyframes`
0% { content: ''; }
33% { content: '.'; }
66% { content: '..'; }
100% { content: '...'; }
`;
const StyledStreamingIndicator = styled.div`
&::after {
display: inline-block;
content: '';
animation: ${streamingDotsAnimation} 750ms steps(3, end) infinite;
width: 2ch;
text-align: left;
}
`;
const InitialLoadingIndicator = () => {
const theme = useTheme();
@@ -65,13 +49,6 @@ const MessagePartRenderer = ({
isStreaming: boolean;
}) => {
switch (part.type) {
case 'reasoning':
return (
<ReasoningSummaryDisplay
content={part.text}
isThinking={part.state === 'streaming'}
/>
);
case 'text':
return <LazyMarkdownRenderer text={part.text} />;
case 'data-routing-status':
@@ -114,23 +91,39 @@ export const AIChatAssistantMessageRenderer = ({
const filteredParts = hasCodeInterpreterTool
? messageParts.filter((part) => part.type !== 'data-code-execution')
: messageParts;
const renderItems = groupContiguousThinkingStepParts(filteredParts);
if (!filteredParts.length && !hasError) {
if (!renderItems.length && !hasError) {
return <InitialLoadingIndicator />;
}
return (
<div>
<StyledMessagePartsContainer>
{filteredParts.map((part, index) => (
<MessagePartRenderer
key={index}
part={part}
isStreaming={isLastMessageStreaming}
/>
))}
{renderItems.map((renderItem, index) =>
renderItem.type === 'thinking-steps' ? (
<ThinkingStepsDisplay
key={index}
parts={renderItem.parts}
isLastMessageStreaming={isLastMessageStreaming}
hasAssistantTextResponseStarted={renderItems
.slice(index + 1)
.some(
(nextRenderItem) =>
nextRenderItem.type === 'part' &&
nextRenderItem.part.type === 'text' &&
nextRenderItem.part.text.trim().length > 0,
)}
/>
) : (
<MessagePartRenderer
key={index}
part={renderItem.part}
isStreaming={isLastMessageStreaming}
/>
),
)}
</StyledMessagePartsContainer>
{isLastMessageStreaming && !hasError && <StyledStreamingIndicator />}
</div>
);
};
@@ -33,8 +33,9 @@ const StyledMessageText = styled.div<{ isUser?: boolean }>`
color: ${({ theme, isUser }) =>
isUser ? theme.font.color.secondary : theme.font.color.primary};
font-weight: ${({ isUser }) => (isUser ? 500 : 400)};
line-height: 1.4em;
max-width: 100%;
padding: ${({ theme, isUser }) => (isUser ? theme.spacing(1, 2) : 0)};
padding: ${({ theme, isUser }) => (isUser ? `0 ${theme.spacing(2)}` : 0)};
width: fit-content;
word-wrap: break-word;
overflow-wrap: break-word;
@@ -48,7 +49,7 @@ const StyledMessageText = styled.div<{ isUser?: boolean }>`
word-wrap: break-word;
max-width: 100%;
line-height: 1.4;
padding: ${({ theme }) => theme.spacing(1)};
padding: ${({ theme }) => `${theme.spacing(0.25)} ${theme.spacing(0.75)}`};
border-radius: ${({ theme }) => theme.border.radius.sm};
background: ${({ theme }) => theme.background.tertiary};
}
@@ -70,17 +71,25 @@ const StyledMessageText = styled.div<{ isUser?: boolean }>`
p {
margin-block: ${({ isUser, theme }) =>
isUser ? '0' : `${theme.spacing(1)}`};
line-height: 1.5;
line-height: 1.4em;
}
ul,
ol {
line-height: 1.4em;
margin: ${({ theme }) => theme.spacing(1)} 0;
padding-left: ${({ theme }) => theme.spacing(4)};
}
ul {
list-style-type: disc;
}
li {
line-height: 1.4em;
margin: ${({ theme }) => theme.spacing(0.5)} 0;
padding-bottom: ${({ theme }) => theme.spacing(0.5)};
padding-top: ${({ theme }) => theme.spacing(0.5)};
}
blockquote {
@@ -100,10 +109,15 @@ const StyledMessageFooter = styled.div`
margin-top: ${({ theme }) => theme.spacing(1)};
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease-in-out;
transition: opacity ${({ theme }) => theme.animation.duration.normal}s
ease-in-out;
width: 100%;
`;
const StyledMessageTimestamp = styled.span`
color: ${({ theme }) => theme.font.color.light};
`;
const StyledMessageContainer = styled.div<{ isUser?: boolean }>`
max-width: 100%;
min-width: 0;
@@ -156,12 +170,12 @@ export const AIChatMessage = ({
</StyledMessageContainer>
{message.parts.length > 0 && message.metadata?.createdAt && (
<StyledMessageFooter className="message-footer">
<span>
<StyledMessageTimestamp>
{beautifyPastDateRelativeToNow(
message.metadata?.createdAt,
localeCatalog,
)}
</span>
</StyledMessageTimestamp>
<LightCopyIconButton
copyText={
message.parts.find((part) => part.type === 'text')?.text ?? ''
@@ -1,12 +1,12 @@
import styled from '@emotion/styled';
import { EditorContent } from '@tiptap/react';
import { IconHistory } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import { useState } from 'react';
import { useRecoilValue } from 'recoil';
import { LightButton } from 'twenty-ui/input';
import { DropZone } from '@/activities/files/components/DropZone';
import { AgentChatFileUploadButton } from '@/ai/components/internal/AgentChatFileUploadButton';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { useAiModelLabel } from '@/ai/hooks/useAiModelOptions';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { AIChatEmptyState } from '@/ai/components/AIChatEmptyState';
@@ -21,9 +21,8 @@ import { AI_CHAT_SCROLL_WRAPPER_ID } from '@/ai/constants/AiChatScrollWrapperId'
import { useAIChatEditor } from '@/ai/hooks/useAIChatEditor';
import { useAIChatFileUpload } from '@/ai/hooks/useAIChatFileUpload';
import { useAgentChatContextOrThrow } from '@/ai/hooks/useAgentChatContextOrThrow';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
const StyledContainer = styled.div<{ isDraggingFile: boolean }>`
background: ${({ theme }) => theme.background.primary};
@@ -103,7 +102,7 @@ const StyledScrollWrapper = styled(ScrollWrapper)`
display: flex;
flex: 1;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(5)};
gap: ${({ theme }) => theme.spacing(2)};
overflow-y: auto;
padding: ${({ theme }) => theme.spacing(3)};
width: calc(100% - 24px);
@@ -112,9 +111,29 @@ const StyledScrollWrapper = styled(ScrollWrapper)`
const StyledButtonsContainer = styled.div`
align-items: center;
display: flex;
flex-direction: row;
justify-content: space-between;
width: 100%;
`;
const StyledLeftButtonsContainer = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(0.5)};
justify-content: flex-end;
`;
const StyledRightButtonsContainer = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledReadOnlyModelButton = styled(LightButton)`
cursor: default;
&:hover,
&:active {
background: transparent;
}
`;
export const AIChatTab = () => {
@@ -122,9 +141,11 @@ export const AIChatTab = () => {
const isMobile = useIsMobile();
const { isLoading, messages, isStreaming, error, handleSendMessage } =
useAgentChatContextOrThrow();
const hasMessages = messages.length > 0;
const { uploadFiles } = useAIChatFileUpload();
const { navigateCommandMenu } = useCommandMenu();
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const smartModelLabel = useAiModelLabel(currentWorkspace?.smartModel, false);
const { editor, handleSendAndClear } = useAIChatEditor({
onSendMessage: handleSendMessage,
@@ -143,7 +164,7 @@ export const AIChatTab = () => {
)}
{!isDraggingFile && (
<>
{messages.length !== 0 && (
{hasMessages && (
<StyledScrollWrapper
componentInstanceId={AI_CHAT_SCROLL_WRAPPER_ID}
>
@@ -170,13 +191,13 @@ export const AIChatTab = () => {
)}
</StyledScrollWrapper>
)}
{messages.length === 0 && !error && !isLoading && (
{!hasMessages && !error && !isLoading && (
<AIChatEmptyState editor={editor} />
)}
{messages.length === 0 && error && !isLoading && (
{!hasMessages && error && !isLoading && (
<AIChatStandaloneError error={error} />
)}
{isLoading && messages.length === 0 && <AIChatSkeletonLoader />}
{isLoading && !hasMessages && <AIChatSkeletonLoader />}
<StyledInputArea isMobile={isMobile}>
<AgentChatContextPreview />
@@ -185,22 +206,17 @@ export const AIChatTab = () => {
<EditorContent editor={editor} />
</StyledEditorWrapper>
<StyledButtonsContainer>
<AIChatContextUsageButton />
<IconButton
Icon={IconHistory}
variant="tertiary"
size="small"
onClick={() =>
navigateCommandMenu({
page: CommandMenuPages.ViewPreviousAIChats,
pageTitle: t`View Previous AI Chats`,
pageIcon: IconHistory,
})
}
ariaLabel={t`View Previous AI Chats`}
/>
<AgentChatFileUploadButton />
<SendMessageButton onSend={handleSendAndClear} />
<StyledLeftButtonsContainer>
<AgentChatFileUploadButton />
{hasMessages && <AIChatContextUsageButton />}
</StyledLeftButtonsContainer>
<StyledRightButtonsContainer>
<StyledReadOnlyModelButton
accent="tertiary"
title={smartModelLabel}
/>
<SendMessageButton onSend={handleSendAndClear} />
</StyledRightButtonsContainer>
</StyledButtonsContainer>
</StyledInputBox>
</StyledInputArea>
@@ -94,6 +94,29 @@ const MarkdownRenderer = lazy(async () => {
li: ({ children }) => (
<li>{processChildrenForRecordLinks(children)}</li>
),
a: ({ children, href, title, target, rel, node: _node }) => (
<a
className="markdown-link"
href={href}
title={title}
target={target}
rel={rel}
>
{processChildrenForRecordLinks(children)}
</a>
),
code: ({
className,
children,
}: {
className?: string;
children?: React.ReactNode;
}) => <code className={className}>{children}</code>,
pre: ({ children }) => (
<div className="markdown-code-outer-container">
<pre className="markdown-block-code">{children}</pre>
</div>
),
}}
>
{children}
@@ -103,6 +126,144 @@ const MarkdownRenderer = lazy(async () => {
});
const StyledMarkdownContainer = styled.div`
border-radius: ${({ theme }) => theme.border.radius.sm};
line-height: 150%;
margin: ${({ theme }) => `${theme.spacing(1.5)} 0`};
position: relative;
scroll-margin-top: ${({ theme }) => theme.spacing(10)};
scroll-margin-bottom: ${({ theme }) => theme.spacing(10)};
&:empty {
display: none;
}
.markdown-link {
color: ${({ theme }) => theme.accent.accent11};
text-decoration: none;
-webkit-text-decoration: none;
}
.markdown-link:visited {
color: ${({ theme }) => theme.accent.accent11};
}
.markdown-link:hover {
text-decoration: underline !important;
}
strong,
b {
font-weight: ${({ theme }) => theme.font.weight.semiBold};
}
h1,
h2,
h3 {
font-weight: ${({ theme }) => theme.font.weight.semiBold} !important;
}
h1 {
font-size: 1.6em;
line-height: 1.25;
margin-bottom: 12px;
margin-top: 24px;
}
h2 {
font-size: 1.3em;
line-height: 1.25;
margin-bottom: 10px;
margin-top: 20px;
}
h3 {
font-size: 1.15em;
line-height: 1.25;
margin-bottom: 8px;
margin-top: 18px;
}
h4 {
font-size: 1.05em;
line-height: 1.25;
margin-bottom: 8px;
margin-top: 16px;
}
h5 {
font-size: 0.95em;
line-height: 1.25;
margin-bottom: 6px;
margin-top: 14px;
}
h6 {
font-size: 0.85em;
line-height: 1.25;
margin-bottom: 6px;
margin-top: 12px;
}
hr {
background-color: ${({ theme }) => theme.border.color.light} !important;
border: none;
height: 1px;
margin: ${({ theme }) => theme.spacing(4)} 0;
}
ol:first-of-type:not(.nested),
ul:first-of-type:not(.nested) {
margin-top: ${({ theme }) => theme.spacing(1)} !important;
}
ol:last-of-type:not(.nested),
ul:last-of-type:not(.nested) {
margin-bottom: ${({ theme }) => theme.spacing(1)} !important;
}
li {
line-height: 150%;
margin-bottom: ${({ theme }) => theme.spacing(0.5)} !important;
margin-top: ${({ theme }) => theme.spacing(0.5)} !important;
padding-bottom: ${({ theme }) => theme.spacing(0.5)} !important;
padding-top: ${({ theme }) => theme.spacing(0.5)} !important;
}
:not(pre) > code {
background-color: ${({ theme }) => theme.background.tertiary};
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ theme }) => theme.font.color.primary};
font-family: ${({ theme }) => `${theme.code.font.family}, monospace`};
font-size: 0.9em;
padding: 1.5px 3px;
transition: all ${({ theme }) => theme.animation.duration.fast}s ease;
}
:not(pre) > code[style*='cursor: pointer'] {
background-color: ${({ theme }) => theme.background.secondary};
border: ${({ theme }) => `1px solid ${theme.accent.accent10}`};
color: ${({ theme }) => theme.accent.accent10};
}
:not(pre) > code[style*='cursor: pointer']:hover {
background-color: ${({ theme }) => theme.background.transparent.blue};
}
.markdown-code-outer-container {
border-radius: ${({ theme }) => theme.border.radius.md} !important;
overflow: hidden;
}
.markdown-block-code {
background-color: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md} !important;
}
.markdown-block-code * {
animation: none !important;
}
img {
height: auto;
max-width: 100%;
@@ -111,7 +272,8 @@ const StyledMarkdownContainer = styled.div`
// Using div instead of p to allow RecordLink (which contains div elements) as children
const StyledParagraph = styled.div`
margin-block: 1em;
line-height: inherit;
margin-block: ${({ theme }) => theme.spacing(2)};
&:first-child {
margin-block-start: 0;
@@ -186,7 +348,7 @@ const LoadingSkeleton = () => {
export const LazyMarkdownRenderer = ({ text }: { text: string }) => {
return (
<StyledMarkdownContainer>
<StyledMarkdownContainer className="markdown-section">
<Suspense fallback={<LoadingSkeleton />}>
<MarkdownRenderer
TableScrollContainer={StyledTableScrollContainer}
@@ -0,0 +1,466 @@
import styled from '@emotion/styled';
import { plural, t } from '@lingui/core/macro';
import { useState } from 'react';
import { type ToolUIPart } from 'ai';
import { isDefined } from 'twenty-shared/utils';
import {
IconChevronRight,
IconCpu,
OverflowingTextWithTooltip,
ThinkingOrbitLoaderIcon,
TooltipDelay,
} from 'twenty-ui/display';
import { JsonTree } from 'twenty-ui/json-visualizer';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
import { type JsonValue } from 'type-fest';
import { ToolOutputResultSchema } from '@/ai/schemas/toolOutputResultSchema';
import { getToolIcon } from '@/ai/utils/getToolIcon';
import {
getToolDisplayMessage,
resolveToolInput,
} from '@/ai/utils/getToolDisplayMessage';
import { getActiveReasoningContent } from '@/ai/utils/getActiveReasoningContent';
import { getLastReasoningContent } from '@/ai/utils/getLastReasoningContent';
import { isThinkingStepPartActive } from '@/ai/utils/isThinkingStepPartActive';
import { type ThinkingStepPart } from '@/ai/utils/thinkingStepPart';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
font-family: ${({ theme }) => theme.font.family};
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledStepsContentContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(1)};
padding-top: ${({ theme }) => theme.spacing(1)};
padding-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledSummaryText = styled.span`
color: inherit;
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.regular};
line-height: ${({ theme }) => theme.text.lineHeight.md};
transition: color ${({ theme }) => theme.animation.duration.fast}s ease-in-out;
`;
const StyledSummaryButton = styled.button`
align-items: center;
background: none;
border: none;
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ theme }) => theme.font.color.tertiary};
cursor: pointer;
display: flex;
font-family: inherit;
gap: ${({ theme }) => theme.spacing(2)};
min-height: 24px;
padding: 0;
width: fit-content;
&:hover {
color: ${({ theme }) => theme.font.color.primary};
}
&:focus-visible {
outline: 2px solid ${({ theme }) => theme.color.blue};
outline-offset: 2px;
}
`;
const StyledSummaryChevronContainer = styled.div<{ isExpanded: boolean }>`
align-items: center;
color: ${({ theme }) => theme.font.color.light};
display: flex;
justify-content: center;
transform: rotate(${({ isExpanded }) => (isExpanded ? '90deg' : '0deg')});
transition: transform ${({ theme }) => theme.animation.duration.fast}s
ease-in-out;
`;
const StyledRowsContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledRow = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
min-height: 24px;
`;
const StyledRowLabel = styled.span`
color: inherit;
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.regular};
line-height: ${({ theme }) => theme.text.lineHeight.md};
transition: color ${({ theme }) => theme.animation.duration.fast}s ease-in-out;
`;
const StyledToolRowLabel = styled.div`
color: inherit;
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.regular};
line-height: ${({ theme }) => theme.text.lineHeight.md};
max-width: 100%;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
transition: color ${({ theme }) => theme.animation.duration.fast}s ease-in-out;
white-space: nowrap;
`;
const StyledReasoningContainer = styled.div`
padding-left: calc(
${({ theme }) => theme.icon.size.sm}px + ${({ theme }) => theme.spacing(2)}
);
`;
const StyledReasoningText = styled.p`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.regular};
line-height: ${({ theme }) => theme.text.lineHeight.lg};
margin: 0;
white-space: pre-wrap;
`;
const StyledOrbitLoaderIcon = styled(ThinkingOrbitLoaderIcon)`
color: ${({ theme }) => theme.font.color.tertiary};
`;
const StyledIconContainer = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.light};
display: flex;
justify-content: center;
min-width: ${({ theme }) => theme.icon.size.sm}px;
`;
const StyledRowLabelContainer = styled.div`
align-items: center;
display: flex;
flex: 1;
gap: ${({ theme }) => theme.spacing(1)};
min-width: 0;
`;
const StyledChevronContainer = styled.div<{ isExpanded: boolean }>`
align-items: center;
color: ${({ theme }) => theme.font.color.light};
display: flex;
justify-content: center;
transform: rotate(${({ isExpanded }) => (isExpanded ? '90deg' : '0deg')});
transition: transform ${({ theme }) => theme.animation.duration.fast}s
ease-in-out;
`;
const StyledToolRowContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledToolRowButton = styled.button<{ isExpandable: boolean }>`
align-items: center;
background: none;
border: none;
color: ${({ theme }) => theme.font.color.tertiary};
cursor: ${({ isExpandable }) => (isExpandable ? 'pointer' : 'default')};
display: flex;
font-family: inherit;
gap: ${({ theme }) => theme.spacing(2)};
min-height: 24px;
padding: 0;
text-align: left;
transition: color ${({ theme }) => theme.animation.duration.fast}s ease-in-out;
width: 100%;
&:hover {
color: ${({ theme, isExpandable }) =>
isExpandable ? theme.font.color.primary : theme.font.color.tertiary};
}
&:focus-visible {
outline: 2px solid ${({ theme }) => theme.color.blue};
outline-offset: 2px;
}
`;
const StyledToolDetailsContainer = styled.div`
background: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.light};
border-radius: ${({ theme }) => theme.border.radius.sm};
margin-left: ${({ theme }) => theme.spacing(3)};
min-width: 0;
overflow: hidden;
`;
const StyledToolTabList = styled(TabList)`
background-color: ${({ theme }) => theme.background.secondary};
padding-left: ${({ theme }) => theme.spacing(1)};
`;
const StyledToolDetailsContent = styled.div`
min-width: 0;
`;
const StyledToolJsonContent = styled.div`
padding: ${({ theme }) => theme.spacing(1)};
`;
const StyledJsonTreeContainer = styled.div`
font-size: ${({ theme }) => theme.font.size.md};
overflow-x: auto;
li,
span {
line-height: 1;
}
ul {
min-width: 0;
}
`;
const StyledToolErrorText = styled.p`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.regular};
line-height: ${({ theme }) => theme.text.lineHeight.lg};
margin: 0;
white-space: pre-wrap;
`;
type ToolDetailsTab = 'output' | 'input';
const ThinkingToolStepRow = ({
isActive,
part,
rowIndex,
}: {
isActive: boolean;
part: ToolUIPart;
rowIndex: number;
}) => {
const { copyToClipboard } = useCopyToClipboard();
const [isExpanded, setIsExpanded] = useState(false);
const rawToolName = part.type.split('-')[1];
const { resolvedInput: toolInput, resolvedToolName } = resolveToolInput(
part.input,
rawToolName,
);
const ToolIcon = getToolIcon(resolvedToolName);
const label = getToolDisplayMessage(part.input, rawToolName, !isActive);
const hasError = isDefined(part.errorText);
const isExpandable = isDefined(part.output) || hasError;
const outputResult = ToolOutputResultSchema.safeParse(part.output);
const unwrappedOutput =
rawToolName === 'execute_tool' && outputResult.success
? outputResult.data.result
: part.output;
const unwrappedResult = ToolOutputResultSchema.safeParse(unwrappedOutput);
const toolOutput = unwrappedResult.success
? unwrappedResult.data.result
: unwrappedOutput;
const toolTabListComponentInstanceId = `ai-thinking-tool-tabs-${part.toolCallId ?? rawToolName}-${rowIndex}`;
const activeToolTabId = useRecoilComponentValue(
activeTabIdComponentState,
toolTabListComponentInstanceId,
);
const activeTab: ToolDetailsTab =
activeToolTabId === 'input' ? 'input' : 'output';
const toolTabs = [
{ id: 'output', title: t`Output` },
{ id: 'input', title: t`Input` },
];
return (
<StyledToolRowContainer>
<StyledToolRowButton
type="button"
isExpandable={isExpandable}
onClick={() => {
if (!isExpandable) {
return;
}
setIsExpanded((previousValue) => !previousValue);
}}
aria-expanded={isExpandable ? isExpanded : undefined}
>
<StyledIconContainer>
<ToolIcon size={14} />
</StyledIconContainer>
<StyledRowLabelContainer>
<StyledToolRowLabel>
<OverflowingTextWithTooltip
text={label}
tooltipDelay={TooltipDelay.shortDelay}
/>
</StyledToolRowLabel>
{isExpandable && (
<StyledChevronContainer isExpanded={isExpanded}>
<IconChevronRight size={14} />
</StyledChevronContainer>
)}
</StyledRowLabelContainer>
</StyledToolRowButton>
{isExpandable && (
<AnimatedExpandableContainer isExpanded={isExpanded} mode="fit-content">
<StyledToolDetailsContainer>
{hasError ? (
<StyledToolErrorText>{part.errorText}</StyledToolErrorText>
) : (
<StyledToolDetailsContent>
<StyledToolTabList
tabs={toolTabs}
behaveAsLinks={false}
componentInstanceId={toolTabListComponentInstanceId}
/>
<StyledToolJsonContent>
<StyledJsonTreeContainer>
<JsonTree
value={
(activeTab === 'output'
? toolOutput
: toolInput) as JsonValue
}
shouldExpandNodeInitially={() => false}
emptyArrayLabel={t`Empty Array`}
emptyObjectLabel={t`Empty Object`}
emptyStringLabel={t`[empty string]`}
arrowButtonCollapsedLabel={t`Expand`}
arrowButtonExpandedLabel={t`Collapse`}
onNodeValueClick={copyToClipboard}
/>
</StyledJsonTreeContainer>
</StyledToolJsonContent>
</StyledToolDetailsContent>
)}
</StyledToolDetailsContainer>
</AnimatedExpandableContainer>
)}
</StyledToolRowContainer>
);
};
const ThinkingStepRow = ({
isActive,
part,
rowIndex,
}: {
isActive: boolean;
part: ThinkingStepPart;
rowIndex: number;
}) => {
if (part.type !== 'reasoning') {
return (
<ThinkingToolStepRow
part={part}
isActive={isActive}
rowIndex={rowIndex}
/>
);
}
return (
<StyledRow>
<StyledIconContainer>
{isActive ? <StyledOrbitLoaderIcon /> : <IconCpu size={14} />}
</StyledIconContainer>
<StyledRowLabelContainer>
<StyledRowLabel>{isActive ? t`Thinking` : t`Thought`}</StyledRowLabel>
</StyledRowLabelContainer>
</StyledRow>
);
};
export const ThinkingStepsDisplay = ({
parts,
isLastMessageStreaming,
hasAssistantTextResponseStarted,
}: {
parts: ThinkingStepPart[];
isLastMessageStreaming: boolean;
hasAssistantTextResponseStarted: boolean;
}) => {
const [isExpanded, setIsExpanded] = useState(false);
const stepCount = parts.length;
const isThinking = parts.some((part) =>
isThinkingStepPartActive(part, isLastMessageStreaming),
);
const activeReasoningContent = getActiveReasoningContent(parts);
const finalReasoningContent = getLastReasoningContent(parts);
const reasoningContent = isThinking
? activeReasoningContent
: finalReasoningContent;
const shouldDisplayReasoningContent = reasoningContent?.trim().length;
const shouldKeepExpandedBeforeAnswer = !hasAssistantTextResponseStarted;
const shouldShowSummaryButton =
!isThinking && !shouldKeepExpandedBeforeAnswer;
const shouldRenderRows =
isThinking || isExpanded || shouldKeepExpandedBeforeAnswer;
return (
<StyledContainer>
{shouldShowSummaryButton && (
<StyledSummaryButton
type="button"
aria-expanded={isExpanded}
onClick={() => setIsExpanded((previousValue) => !previousValue)}
>
<StyledSummaryChevronContainer isExpanded={isExpanded}>
<IconChevronRight size={14} />
</StyledSummaryChevronContainer>
<StyledSummaryText>
{plural(stepCount, {
one: '# step',
other: '# steps',
})}
</StyledSummaryText>
</StyledSummaryButton>
)}
{shouldRenderRows && (
<StyledStepsContentContainer>
<StyledRowsContainer>
{parts.map((part, index) => (
<ThinkingStepRow
key={index}
part={part}
rowIndex={index}
isActive={isThinkingStepPartActive(
part,
isLastMessageStreaming,
)}
/>
))}
</StyledRowsContainer>
{!!shouldDisplayReasoningContent && (
<StyledReasoningContainer>
<StyledReasoningText>{reasoningContent}</StyledReasoningText>
</StyledReasoningContainer>
)}
</StyledStepsContentContainer>
)}
</StyledContainer>
);
};
@@ -24,6 +24,7 @@ import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
font-family: ${({ theme }) => theme.font.family};
gap: ${({ theme }) => theme.spacing(2)};
`;
@@ -52,12 +53,12 @@ const StyledToggleButton = styled.div<{ isExpandable: boolean }>`
color: ${({ theme }) => theme.font.color.tertiary};
gap: ${({ theme }) => theme.spacing(1)};
padding: ${({ theme }) => theme.spacing(1)} 0;
transition: color ${({ theme }) => theme.animation.duration.normal}s;
transition: color ${({ theme }) => theme.animation.duration.fast}s ease-in-out;
justify-content: space-between;
width: 100%;
&:hover {
color: ${({ theme }) => theme.font.color.secondary};
color: ${({ theme }) => theme.font.color.primary};
}
`;
@@ -113,11 +114,11 @@ const StyledTab = styled.div<{ isActive: boolean }>`
font-weight: ${({ theme, isActive }) =>
isActive ? theme.font.weight.medium : theme.font.weight.regular};
cursor: pointer;
transition: color ${({ theme }) => theme.animation.duration.normal}s;
transition: color ${({ theme }) => theme.animation.duration.fast}s ease-in-out;
padding-bottom: ${({ theme }) => theme.spacing(2)};
&:hover {
color: ${({ theme }) => theme.font.color.secondary};
color: ${({ theme }) => theme.font.color.primary};
}
`;
@@ -1,5 +1,6 @@
import styled from '@emotion/styled';
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { userEvent, within } from 'storybook/test';
import { type ExtendedUIMessage } from 'twenty-shared/ai';
import { ComponentDecorator } from 'twenty-ui/testing';
@@ -174,6 +175,54 @@ print(df.head())`,
},
};
const mockThinkingStepsStreaming: ExtendedUIMessage = {
id: 'msg-thinking-streaming',
role: 'assistant',
parts: [
{
type: 'tool-web_search',
toolCallId: 'tool-web-search-streaming',
input: { query: 'top leads status' },
output: { result: { ok: true } },
state: 'output-available',
},
{
type: 'reasoning',
text: 'I need to evaluate the latest lead activity and pipeline stage changes before I can answer accurately.',
state: 'streaming',
},
],
metadata: {
createdAt: new Date().toISOString(),
},
};
const mockThinkingStepsDone: ExtendedUIMessage = {
id: 'msg-thinking-done',
role: 'assistant',
parts: [
{
type: 'tool-web_search',
toolCallId: 'tool-web-search-done',
input: { query: 'top leads status' },
output: { result: { ok: true } },
state: 'output-available',
},
{
type: 'reasoning',
text: 'I filtered the most engaged leads and checked the latest interactions to determine which opportunities are moving forward.',
state: 'done',
},
{
type: 'text',
text: 'You currently have 5 top leads in active stages. Two are in proposal review and three are in scheduled demo follow-up.',
},
],
metadata: {
createdAt: new Date().toISOString(),
},
};
const meta: Meta<typeof AIChatMessage> = {
title: 'Modules/AI/AIChatMessage',
component: AIChatMessage,
@@ -233,3 +282,32 @@ export const CodeExecutionWithError: Story = {
isLastMessageStreaming: false,
},
};
export const ThinkingStepsThinkingState: Story = {
args: {
message: mockThinkingStepsStreaming,
isLastMessageStreaming: true,
},
};
export const ThinkingStepsDoneCollapsed: Story = {
args: {
message: mockThinkingStepsDone,
isLastMessageStreaming: false,
},
};
export const ThinkingStepsDoneExpanded: Story = {
args: {
message: mockThinkingStepsDone,
isLastMessageStreaming: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const summaryButton = await canvas.findByRole('button', {
name: /2 steps/i,
});
await userEvent.click(summaryButton);
},
};
@@ -0,0 +1,188 @@
import { render, screen } from '@testing-library/react';
import {
THEME_LIGHT,
ThemeContextProvider,
ThemeProvider,
} from 'twenty-ui/theme';
import { type ExtendedUIMessagePart } from 'twenty-shared/ai';
import { AIChatAssistantMessageRenderer } from '@/ai/components/AIChatAssistantMessageRenderer';
jest.mock('@/ai/components/ThinkingStepsDisplay', () => ({
ThinkingStepsDisplay: ({
hasAssistantTextResponseStarted,
parts,
}: {
parts: unknown[];
hasAssistantTextResponseStarted: boolean;
}) => (
<div data-testid="thinking-steps-display">
{`thinking-${parts.length}-${hasAssistantTextResponseStarted ? 'answer-started' : 'answer-pending'}`}
</div>
),
}));
jest.mock('@/ai/components/ToolStepRenderer', () => ({
ToolStepRenderer: ({ toolPart }: { toolPart: { type: string } }) => (
<div data-testid="tool-step-renderer">{toolPart.type}</div>
),
}));
jest.mock('@/ai/components/LazyMarkdownRenderer', () => ({
LazyMarkdownRenderer: ({ text }: { text: string }) => (
<div data-testid="markdown-renderer">{text}</div>
),
}));
jest.mock('@/ai/components/RoutingStatusDisplay', () => ({
RoutingStatusDisplay: ({ data }: { data: { text: string } }) => (
<div data-testid="routing-status-display">{data.text}</div>
),
}));
jest.mock('@/ai/components/CodeExecutionDisplay', () => ({
CodeExecutionDisplay: () => <div data-testid="code-execution-display" />,
}));
const renderAssistantRenderer = (messageParts: ExtendedUIMessagePart[]) => {
return render(
<ThemeProvider theme={THEME_LIGHT}>
<ThemeContextProvider theme={THEME_LIGHT}>
<AIChatAssistantMessageRenderer
messageParts={messageParts}
isLastMessageStreaming={false}
/>
</ThemeContextProvider>
</ThemeProvider>,
);
};
describe('AIChatAssistantMessageRenderer', () => {
it('should group reasoning and tool steps into ThinkingStepsDisplay', () => {
const messageParts = [
{
type: 'reasoning',
text: 'Reasoning content',
state: 'done',
},
{
type: 'tool-web_search',
toolCallId: 'tool-1',
input: { query: 'crm software' },
output: { result: { ok: true } },
state: 'output-available',
},
{
type: 'text',
text: 'Final answer',
},
] as ExtendedUIMessagePart[];
renderAssistantRenderer(messageParts);
expect(screen.getByTestId('thinking-steps-display')).toHaveTextContent(
'thinking-2-answer-started',
);
expect(screen.getByTestId('markdown-renderer')).toHaveTextContent(
'Final answer',
);
});
it('should keep answer-started false for thinking blocks with no following text', () => {
const messageParts = [
{
type: 'text',
text: 'Preamble',
},
{
type: 'reasoning',
text: 'Reasoning content',
state: 'done',
},
{
type: 'tool-web_search',
toolCallId: 'tool-1',
input: { query: 'crm software' },
output: { result: { ok: true } },
state: 'output-available',
},
] as ExtendedUIMessagePart[];
renderAssistantRenderer(messageParts);
expect(screen.getByTestId('thinking-steps-display')).toHaveTextContent(
'thinking-2-answer-pending',
);
});
it('should keep code interpreter rendering path unchanged and out of thinking grouping', () => {
const messageParts = [
{
type: 'tool-code_interpreter',
toolCallId: 'tool-code-1',
input: { code: 'print(1)' },
output: { result: { stdout: '1' } },
state: 'output-available',
},
{
type: 'data-code-execution',
data: {
executionId: 'exec-1',
state: 'running',
code: 'print(1)',
language: 'python',
stdout: '',
stderr: '',
files: [],
},
},
] as ExtendedUIMessagePart[];
renderAssistantRenderer(messageParts);
expect(screen.queryByTestId('thinking-steps-display')).toBeNull();
expect(screen.getByTestId('tool-step-renderer')).toHaveTextContent(
'tool-code_interpreter',
);
expect(screen.queryByTestId('code-execution-display')).toBeNull();
});
it('should render non-thinking parts directly when there are no thinking steps', () => {
const messageParts = [
{
type: 'text',
text: 'Simple answer',
},
{
type: 'data-routing-status',
data: {
text: 'Routing complete',
state: 'routed',
},
},
{
type: 'data-code-execution',
data: {
executionId: 'exec-2',
state: 'completed',
code: 'print(2)',
language: 'python',
stdout: '2',
stderr: '',
files: [],
},
},
] as ExtendedUIMessagePart[];
renderAssistantRenderer(messageParts);
expect(screen.queryByTestId('thinking-steps-display')).toBeNull();
expect(screen.getByTestId('markdown-renderer')).toHaveTextContent(
'Simple answer',
);
expect(screen.getByTestId('routing-status-display')).toHaveTextContent(
'Routing complete',
);
expect(screen.getByTestId('code-execution-display')).toBeInTheDocument();
});
});
@@ -0,0 +1,242 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
THEME_LIGHT,
ThemeContextProvider,
ThemeProvider,
} from 'twenty-ui/theme';
import { ThinkingStepsDisplay } from '@/ai/components/ThinkingStepsDisplay';
import { type ThinkingStepPart } from '@/ai/utils/thinkingStepPart';
jest.mock('~/hooks/useCopyToClipboard', () => ({
useCopyToClipboard: () => ({
copyToClipboard: jest.fn(),
}),
}));
jest.mock(
'@/ui/utilities/state/component-state/hooks/useRecoilComponentValue',
() => ({
useRecoilComponentValue: () => 'output',
}),
);
jest.mock('@/ui/layout/tab-list/components/TabList', () => ({
TabList: ({
tabs,
onTabChange,
}: {
tabs: Array<{ id: string; title: string }>;
onTabChange?: (tabId: string) => void;
}) => (
<div>
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => onTabChange?.(tab.id)}
>
{tab.title}
</button>
))}
</div>
),
}));
const createReasoningPart = ({
state = 'done',
text = 'Reasoning content',
}: {
state?: string;
text?: string;
} = {}): ThinkingStepPart =>
({
type: 'reasoning',
text,
state,
}) as ThinkingStepPart;
const createToolPart = ({
input = { query: 'crm software' },
output = { result: { ok: true } },
type = 'tool-web_search',
}: {
type?: `tool-${string}`;
input?: Record<string, unknown>;
output?: unknown;
} = {}): ThinkingStepPart =>
({
type,
toolCallId: `${type}-call-id`,
input,
output,
state: 'output-available',
}) as ThinkingStepPart;
const renderThinkingStepsDisplay = ({
hasAssistantTextResponseStarted = false,
isLastMessageStreaming,
parts,
}: {
parts: ThinkingStepPart[];
isLastMessageStreaming: boolean;
hasAssistantTextResponseStarted?: boolean;
}) => {
return render(
<ThemeProvider theme={THEME_LIGHT}>
<ThemeContextProvider theme={THEME_LIGHT}>
<ThinkingStepsDisplay
parts={parts}
isLastMessageStreaming={isLastMessageStreaming}
hasAssistantTextResponseStarted={hasAssistantTextResponseStarted}
/>
</ThemeContextProvider>
</ThemeProvider>,
);
};
describe('ThinkingStepsDisplay', () => {
it('should render expanded thinking rows with active loader while streaming', () => {
renderThinkingStepsDisplay({
isLastMessageStreaming: true,
parts: [
createToolPart(),
createReasoningPart({
state: 'streaming',
text: 'Active reasoning content',
}),
],
});
expect(screen.queryByRole('button', { name: /steps/i })).toBeNull();
expect(screen.getByText('Thinking')).toBeInTheDocument();
expect(screen.getByText('Active reasoning content')).toBeInTheDocument();
expect(
screen.getByText('Searched the web for crm software'),
).toBeInTheDocument();
expect(document.querySelector('svg[viewBox="0 0 14 14"]')).not.toBeNull();
});
it('should render done state collapsed by default', () => {
renderThinkingStepsDisplay({
isLastMessageStreaming: false,
hasAssistantTextResponseStarted: true,
parts: [
createToolPart(),
createReasoningPart({
state: 'done',
text: 'Completed reasoning content',
}),
],
});
const summaryButton = screen.getByRole('button', { name: /2 steps/i });
expect(summaryButton).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByText('Thought')).toBeNull();
expect(screen.queryByText('Completed reasoning content')).toBeNull();
});
it('should keep done state expanded while streaming before answer text starts', () => {
renderThinkingStepsDisplay({
isLastMessageStreaming: true,
hasAssistantTextResponseStarted: false,
parts: [
createToolPart(),
createReasoningPart({
state: 'done',
text: 'Completed reasoning content',
}),
],
});
expect(screen.queryByRole('button', { name: /steps/i })).toBeNull();
expect(screen.getByText('Thought')).toBeInTheDocument();
expect(screen.getByText('Completed reasoning content')).toBeInTheDocument();
});
it('should collapse done state once answer text starts while streaming', () => {
renderThinkingStepsDisplay({
isLastMessageStreaming: true,
hasAssistantTextResponseStarted: true,
parts: [
createToolPart(),
createReasoningPart({
state: 'done',
text: 'Completed reasoning content',
}),
],
});
const summaryButton = screen.getByRole('button', { name: /2 steps/i });
expect(summaryButton).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByText('Thought')).toBeNull();
expect(screen.queryByText('Completed reasoning content')).toBeNull();
});
it('should render rows and full reasoning content after expanding done state', async () => {
renderThinkingStepsDisplay({
isLastMessageStreaming: false,
hasAssistantTextResponseStarted: true,
parts: [
createToolPart(),
createReasoningPart({
state: 'done',
text: 'Completed reasoning content',
}),
],
});
const summaryButton = screen.getByRole('button', { name: /2 steps/i });
await userEvent.click(summaryButton);
expect(summaryButton).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByText('Thought')).toBeInTheDocument();
expect(screen.getByText('Completed reasoning content')).toBeInTheDocument();
expect(
screen.getByText('Searched the web for crm software'),
).toBeInTheDocument();
});
it('should toggle tool details and display output/input tabs', async () => {
renderThinkingStepsDisplay({
isLastMessageStreaming: false,
hasAssistantTextResponseStarted: true,
parts: [
createToolPart(),
createReasoningPart({
state: 'done',
text: 'Completed reasoning content',
}),
],
});
const summaryButton = screen.getByRole('button', { name: /2 steps/i });
await userEvent.click(summaryButton);
const toolButton = screen.getByRole('button', {
name: /searched the web for crm software/i,
});
expect(toolButton).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByRole('button', { name: 'Output' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Input' })).toBeNull();
await userEvent.click(toolButton);
expect(toolButton).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('button', { name: 'Output' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Input' })).toBeInTheDocument();
await userEvent.click(toolButton);
expect(toolButton).toHaveAttribute('aria-expanded', 'false');
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'Output' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Input' })).toBeNull();
});
});
});
@@ -5,9 +5,11 @@ import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { HorizontalSeparator } from 'twenty-ui/display';
import { ProgressBar } from 'twenty-ui/feedback';
import { ContextUsageProgressRing } from '@/ai/components/internal/ContextUsageProgressRing';
import { SettingsBillingLabelValueItem } from '@/billing/components/internal/SettingsBillingLabelValueItem';
import {
agentChatUsageState,
type AgentChatLastMessageUsage,
@@ -24,7 +26,7 @@ const StyledTrigger = styled.div<{ hasUsage: boolean }>`
height: 24px;
justify-content: center;
min-width: 24px;
transition: background 0.1s ease;
transition: background ${({ theme }) => theme.animation.duration.fast}s ease;
&:hover {
background: ${({ theme, hasUsage }) =>
@@ -32,12 +34,6 @@ const StyledTrigger = styled.div<{ hasUsage: boolean }>`
}
`;
const StyledPercentage = styled.span`
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
const StyledHoverCard = styled.div`
background: ${({ theme }) => theme.background.primary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
@@ -45,7 +41,7 @@ const StyledHoverCard = styled.div`
box-shadow: ${({ theme }) => theme.boxShadow.strong};
min-width: 280px;
position: absolute;
right: 0;
left: 0;
bottom: calc(100% + 8px);
z-index: ${({ theme }) => theme.lastLayerZIndex};
`;
@@ -63,26 +59,17 @@ const StyledRow = styled.div`
justify-content: space-between;
`;
const StyledLabel = styled.span`
const StyledContextWindowValue = styled.span`
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.sm};
`;
const StyledValue = styled.span`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
const StyledSectionTitle = styled.span`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
text-transform: uppercase;
letter-spacing: 0.5px;
`;
const StyledDivider = styled.div`
border-top: 1px solid ${({ theme }) => theme.border.color.light};
padding-bottom: ${({ theme }) => theme.spacing(2)};
`;
const formatTokenCount = (count: number): string => {
@@ -163,12 +150,14 @@ export const AIChatContextUsageButton = () => {
<StyledSection>
<StyledSectionTitle>{t`Context window`}</StyledSectionTitle>
<StyledRow>
<StyledPercentage>{formattedPercentage}%</StyledPercentage>
<StyledValue>
<StyledContextWindowValue>
{formattedPercentage}%
</StyledContextWindowValue>
<StyledContextWindowValue>
{formatTokenCount(agentChatUsage.conversationSize)} /{' '}
{formatTokenCount(agentChatUsage.contextWindowTokens)}{' '}
{t`tokens`}
</StyledValue>
</StyledContextWindowValue>
</StyledRow>
<ProgressBar
value={percentage}
@@ -179,63 +168,47 @@ export const AIChatContextUsageButton = () => {
? theme.color.orange
: theme.color.blue
}
backgroundColor={theme.background.quaternary}
backgroundColor={theme.background.tertiary}
withBorderRadius
/>
</StyledSection>
{isDefined(lastMessage) && (
<>
<StyledDivider />
<HorizontalSeparator noMargin color={theme.background.tertiary} />
<StyledSection>
<StyledSectionTitle>{t`Last message`}</StyledSectionTitle>
<StyledRow>
<StyledLabel>{t`Input tokens`}</StyledLabel>
<StyledValue>
{formatTokenCount(lastMessage.inputTokens)}
{getCachedLabel(lastMessage)}
</StyledValue>
</StyledRow>
<StyledRow>
<StyledLabel>{t`Output tokens`}</StyledLabel>
<StyledValue>
{formatTokenCount(lastMessage.outputTokens)}
</StyledValue>
</StyledRow>
<StyledRow>
<StyledLabel>{t`Cost`}</StyledLabel>
<StyledValue>
{formatCredits(
lastMessage.inputCredits + lastMessage.outputCredits,
)}{' '}
{t`credits`}
</StyledValue>
</StyledRow>
<SettingsBillingLabelValueItem
label={t`Input tokens`}
value={`${formatTokenCount(lastMessage.inputTokens)}${getCachedLabel(lastMessage)}`}
/>
<SettingsBillingLabelValueItem
label={t`Output tokens`}
value={formatTokenCount(lastMessage.outputTokens)}
/>
<SettingsBillingLabelValueItem
label={t`Cost`}
value={`${formatCredits(lastMessage.inputCredits + lastMessage.outputCredits)} ${t`credits`}`}
/>
</StyledSection>
</>
)}
<StyledDivider />
<HorizontalSeparator noMargin color={theme.background.tertiary} />
<StyledSection>
<StyledSectionTitle>{t`Conversation`}</StyledSectionTitle>
<StyledRow>
<StyledLabel>{t`Input tokens`}</StyledLabel>
<StyledValue>
{formatTokenCount(agentChatUsage.inputTokens)}
</StyledValue>
</StyledRow>
<StyledRow>
<StyledLabel>{t`Output tokens`}</StyledLabel>
<StyledValue>
{formatTokenCount(agentChatUsage.outputTokens)}
</StyledValue>
</StyledRow>
<StyledRow>
<StyledLabel>{t`Total cost`}</StyledLabel>
<StyledValue>
{formatCredits(totalCredits)} {t`credits`}
</StyledValue>
</StyledRow>
<SettingsBillingLabelValueItem
label={t`Input tokens`}
value={formatTokenCount(agentChatUsage.inputTokens)}
/>
<SettingsBillingLabelValueItem
label={t`Output tokens`}
value={formatTokenCount(agentChatUsage.outputTokens)}
/>
<SettingsBillingLabelValueItem
label={t`Total cost`}
value={`${formatCredits(totalCredits)} ${t`credits`}`}
/>
</StyledSection>
</StyledHoverCard>
)}
@@ -19,14 +19,17 @@ const StyledContainer = styled.div`
`;
const StyledTitle = styled.div`
align-content: center;
color: ${({ theme }) => theme.font.color.primary};
display: grid;
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
height: 24px;
padding: ${({ theme }) => `0 ${theme.spacing(2)}`};
`;
const StyledSuggestedPromptButton = styled(LightButton)`
width: 100%;
align-self: flex-start;
`;
const pickRandom = <T,>(items: T[]): T =>
@@ -23,7 +23,10 @@ export const useAiModelOptions = (
.sort((a, b) => a.label.localeCompare(b.label));
};
export const useAiModelLabel = (modelId: string | undefined): string => {
export const useAiModelLabel = (
modelId: string | undefined,
includeProvider = true,
): string => {
const aiModels = useRecoilValueV2(aiModelsState);
if (!modelId) {
@@ -38,7 +41,8 @@ export const useAiModelLabel = (modelId: string | undefined): string => {
if (
model.modelId === DEFAULT_FAST_MODEL ||
model.modelId === DEFAULT_SMART_MODEL
model.modelId === DEFAULT_SMART_MODEL ||
!includeProvider
) {
return model.label;
}
@@ -0,0 +1,134 @@
import { type ExtendedUIMessagePart } from 'twenty-shared/ai';
import { getActiveReasoningContent } from '@/ai/utils/getActiveReasoningContent';
import { getLastReasoningContent } from '@/ai/utils/getLastReasoningContent';
import { groupContiguousThinkingStepParts } from '@/ai/utils/groupContiguousThinkingStepParts';
import { isThinkingStepPartActive } from '@/ai/utils/isThinkingStepPartActive';
import { type ThinkingStepPart } from '@/ai/utils/thinkingStepPart';
const createReasoningPart = ({
state = 'done',
text = 'Reasoning content',
}: {
state?: string;
text?: string;
} = {}): ThinkingStepPart =>
({
type: 'reasoning',
text,
state,
}) as ThinkingStepPart;
const createToolPart = ({
errorText,
input = {},
output,
type = 'tool-web_search',
}: {
type?: `tool-${string}`;
input?: Record<string, unknown>;
output?: unknown;
errorText?: string;
} = {}): ThinkingStepPart =>
({
type,
toolCallId: 'tool-call-id',
input,
output,
errorText,
state: 'output-available',
}) as ThinkingStepPart;
describe('thinkingStepsDisplayState', () => {
describe('groupContiguousThinkingStepParts', () => {
it('should group contiguous reasoning and non-code-interpreter tool parts', () => {
const parts = [
{ type: 'text', text: 'hello' } as ExtendedUIMessagePart,
{ type: 'step-start' } as ExtendedUIMessagePart,
createReasoningPart({ text: 'reasoning-1' }) as ExtendedUIMessagePart,
createToolPart({
type: 'tool-web_search',
input: { query: 'crm software' },
}) as ExtendedUIMessagePart,
createToolPart({
type: 'tool-create_task',
output: { result: { id: 'task-1' } },
}) as ExtendedUIMessagePart,
{ type: 'step-start' } as ExtendedUIMessagePart,
{ type: 'text', text: 'final answer' } as ExtendedUIMessagePart,
createToolPart({
type: 'tool-code_interpreter',
output: { result: { stdout: 'done' } },
}) as ExtendedUIMessagePart,
];
const groupedParts = groupContiguousThinkingStepParts(parts);
expect(groupedParts).toHaveLength(4);
expect(groupedParts[0]).toEqual({
type: 'part',
part: parts[0],
});
expect(groupedParts[1]).toMatchObject({
type: 'thinking-steps',
parts: [parts[2], parts[3], parts[4]],
});
expect(groupedParts[2]).toEqual({
type: 'part',
part: parts[6],
});
expect(groupedParts[3]).toEqual({
type: 'part',
part: parts[7],
});
});
});
describe('isThinkingStepPartActive', () => {
it('should mark streaming reasoning parts as active', () => {
const reasoningPart = createReasoningPart({ state: 'streaming' });
expect(isThinkingStepPartActive(reasoningPart, false)).toBe(true);
});
it('should mark tool parts without output as active while message is streaming', () => {
const toolPart = createToolPart({
type: 'tool-web_search',
output: undefined,
errorText: undefined,
});
expect(isThinkingStepPartActive(toolPart, true)).toBe(true);
expect(isThinkingStepPartActive(toolPart, false)).toBe(false);
});
it('should mark tool parts with output or error as inactive', () => {
const completedToolPart = createToolPart({
output: { result: { ok: true } },
});
const failedToolPart = createToolPart({
output: undefined,
errorText: 'Tool failed',
});
expect(isThinkingStepPartActive(completedToolPart, true)).toBe(false);
expect(isThinkingStepPartActive(failedToolPart, true)).toBe(false);
});
});
describe('reasoning content helpers', () => {
const parts = [
createReasoningPart({ state: 'done', text: 'Initial reasoning' }),
createToolPart({ type: 'tool-web_search' }),
createReasoningPart({ state: 'streaming', text: 'Active reasoning' }),
];
it('should return active reasoning content', () => {
expect(getActiveReasoningContent(parts)).toBe('Active reasoning');
});
it('should return the latest reasoning content', () => {
expect(getLastReasoningContent(parts)).toBe('Active reasoning');
});
});
});
@@ -0,0 +1,13 @@
import { type ExtendedUIMessagePart } from 'twenty-shared/ai';
import { type ThinkingStepPart } from '@/ai/utils/thinkingStepPart';
export type AssistantMessageRenderItem =
| {
type: 'thinking-steps';
parts: ThinkingStepPart[];
}
| {
type: 'part';
part: ExtendedUIMessagePart;
};
@@ -0,0 +1,14 @@
import { type ReasoningUIPart } from 'ai';
import { type ThinkingStepPart } from '@/ai/utils/thinkingStepPart';
export const getActiveReasoningContent = (
parts: ThinkingStepPart[],
): string | null => {
const activeReasoningPart = parts.find(
(part): part is ReasoningUIPart =>
part.type === 'reasoning' && part.state === 'streaming',
);
return activeReasoningPart?.text ?? null;
};
@@ -0,0 +1,13 @@
import { type ReasoningUIPart } from 'ai';
import { type ThinkingStepPart } from '@/ai/utils/thinkingStepPart';
export const getLastReasoningContent = (
parts: ThinkingStepPart[],
): string | null => {
const reasoningParts = parts.filter(
(part): part is ReasoningUIPart => part.type === 'reasoning',
);
return reasoningParts.at(-1)?.text ?? null;
};
@@ -0,0 +1,44 @@
import { type ExtendedUIMessagePart } from 'twenty-shared/ai';
import { type AssistantMessageRenderItem } from '@/ai/utils/assistantMessageRenderItem';
import { isThinkingStepPart } from '@/ai/utils/isThinkingStepPart';
import { type ThinkingStepPart } from '@/ai/utils/thinkingStepPart';
export const groupContiguousThinkingStepParts = (
parts: ExtendedUIMessagePart[],
): AssistantMessageRenderItem[] => {
const renderItems: AssistantMessageRenderItem[] = [];
let currentThinkingParts: ThinkingStepPart[] = [];
const flushThinkingParts = () => {
if (currentThinkingParts.length > 0) {
renderItems.push({
type: 'thinking-steps',
parts: currentThinkingParts,
});
currentThinkingParts = [];
}
};
for (const part of parts) {
if (part.type === 'step-start') {
continue;
}
if (isThinkingStepPart(part)) {
currentThinkingParts.push(part);
continue;
}
flushThinkingParts();
renderItems.push({
type: 'part',
part,
});
}
flushThinkingParts();
return renderItems;
};
@@ -0,0 +1,14 @@
import { isToolUIPart } from 'ai';
import { type ExtendedUIMessagePart } from 'twenty-shared/ai';
import { type ThinkingStepPart } from '@/ai/utils/thinkingStepPart';
export const isThinkingStepPart = (
part: ExtendedUIMessagePart,
): part is ThinkingStepPart => {
if (part.type === 'reasoning') {
return true;
}
return isToolUIPart(part) && part.type !== 'tool-code_interpreter';
};
@@ -0,0 +1,18 @@
import { isDefined } from 'twenty-shared/utils';
import { type ThinkingStepPart } from '@/ai/utils/thinkingStepPart';
export const isThinkingStepPartActive = (
part: ThinkingStepPart,
isLastMessageStreaming: boolean,
): boolean => {
if (part.type === 'reasoning') {
return part.state === 'streaming';
}
return (
isLastMessageStreaming &&
!isDefined(part.output) &&
!isDefined(part.errorText)
);
};
@@ -0,0 +1,3 @@
import { type ReasoningUIPart, type ToolUIPart } from 'ai';
export type ThinkingStepPart = ReasoningUIPart | ToolUIPart;
@@ -9,20 +9,13 @@ import { useGetWorkflowNodeExecutionUsage } from '@/billing/hooks/useGetWorkflow
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { formatToShortNumber } from 'twenty-shared/utils';
import { H2Title } from 'twenty-ui/display';
import { H2Title, HorizontalSeparator } from 'twenty-ui/display';
import { ProgressBar } from 'twenty-ui/feedback';
import { Section } from 'twenty-ui/layout';
import { SubscriptionStatus } from '~/generated-metadata/graphql';
const StyledLineSeparator = styled.div`
width: 100%;
height: 1px;
background-color: ${({ theme }) => theme.background.tertiary};
`;
export const SettingsBillingCreditsSection = ({
currentBillingSubscription,
}: {
@@ -91,7 +84,7 @@ export const SettingsBillingCreditsSection = ({
{!isTrialing && (
<>
<StyledLineSeparator />
<HorizontalSeparator noMargin color={theme.background.tertiary} />
<SettingsBillingLabelValueItem
label={t`Base Credits`}
value={formatNumber(grantedCredits, {
@@ -117,7 +110,7 @@ export const SettingsBillingCreditsSection = ({
})}
/>
)}
<StyledLineSeparator />
<HorizontalSeparator noMargin color={theme.background.tertiary} />
<SettingsBillingLabelValueItem
label={t`Extra Credits Used`}
value={`${formatToShortNumber(extraCreditsUsed)}`}
@@ -256,8 +256,14 @@ export const generateRecordPropertiesZodSchema = (
}
if (field.name === 'position') {
fieldSchema = z.union([
z.number(),
z.literal('first'),
z.literal('last'),
]);
fieldSchema = fieldSchema.describe(
'Leave empty to place at the top of the list (recommended).',
'Use "first" to insert at the top, "last" for the bottom, or a number for explicit ordering. Leave empty to place at the top (recommended).',
);
} else if (field.description) {
fieldSchema = fieldSchema.describe(field.description);
@@ -0,0 +1,46 @@
import { type IconComponentProps } from '@ui/display/icon/types/IconComponent';
type ThinkingOrbitLoaderIconProps = Pick<IconComponentProps, 'className' | 'size'>;
export const ThinkingOrbitLoaderIcon = ({
className,
size = 14,
}: ThinkingOrbitLoaderIconProps) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 14 14"
aria-hidden="true"
focusable="false"
className={className}
>
<path
d="M3.1 7 C3.1 4.4 6.0 4.4 7.0 7 C8.0 9.6 10.9 9.6 10.9 7 C10.9 4.4 8.0 4.4 7.0 7 C6.0 9.6 3.1 9.6 3.1 7"
fill="none"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
shapeRendering="geometricPrecision"
pathLength={100}
strokeDasharray="14 86"
strokeDashoffset="0"
>
<animate
attributeName="stroke-dashoffset"
values="0;-100"
dur="1.05s"
repeatCount="indefinite"
/>
<animate
attributeName="stroke-dasharray"
values="10 90;16 84;10 90"
dur="1.05s"
repeatCount="indefinite"
/>
</path>
</svg>
);
};
+1
View File
@@ -472,6 +472,7 @@ export {
IconWorld,
IconX,
} from './icon/components/TablerIcons';
export { ThinkingOrbitLoaderIcon } from './icon/components/ThinkingOrbitLoaderIcon';
export { useIcons } from './icon/hooks/useIcons';
export { IconsProvider } from './icon/providers/IconsProvider';
export { iconsState } from './icon/states/iconsState';
@@ -63,6 +63,7 @@ type OverflowingTextWithTooltipProps = {
size?: 'large' | 'small';
isTooltipMultiline?: boolean;
displayedMaxRows?: number;
tooltipDelay?: TooltipDelay;
} & (
| {
text: string | null | undefined;
@@ -80,6 +81,7 @@ export const OverflowingTextWithTooltip = ({
isTooltipMultiline,
displayedMaxRows,
tooltipContent,
tooltipDelay = TooltipDelay.mediumDelay,
}: OverflowingTextWithTooltipProps) => {
const textElementId = `title-id-${+new Date()}`;
@@ -154,7 +156,7 @@ export const OverflowingTextWithTooltip = ({
noArrow
place="bottom"
positionStrategy="absolute"
delay={TooltipDelay.mediumDelay}
delay={tooltipDelay}
isOpen={true}
>
{isTooltipMultiline ? (
@@ -6,7 +6,6 @@ import { type JsonNodeHighlighting } from '@ui/json-visualizer/types/JsonNodeHig
const StyledLabelContainer = styled.span<{
highlighting?: JsonNodeHighlighting;
}>`
align-items: center;
background-color: ${({ theme, highlighting }) =>
highlighting === 'blue'
? theme.color.blue3
@@ -28,15 +27,20 @@ const StyledLabelContainer = styled.span<{
border-radius: ${({ theme }) => theme.border.radius.sm};
border-style: solid;
border-width: 1px;
column-gap: ${({ theme }) => theme.spacing(2)};
display: inline-flex;
align-items: center;
height: 24px;
box-sizing: border-box;
column-gap: ${({ theme }) => theme.spacing(2)};
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
font-size: ${({ theme }) => theme.font.size.md};
white-space: nowrap;
padding-block: ${({ theme }) => theme.spacing(1)};
padding-inline: ${({ theme }) => theme.spacing(2)};
> span {
align-items: center;
display: inline-flex;
line-height: 1;
}
`;
export const JsonNodeLabel = ({
@@ -15,6 +15,7 @@ const StyledText = styled.span<{
: theme.font.color.tertiary};
display: inline-flex;
height: 24px;
line-height: 1;
`;
export const JsonNodeValue = ({