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:
committed by
GitHub
parent
aac032e517
commit
9477bb3677
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+28
-35
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
+188
@@ -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();
|
||||
});
|
||||
});
|
||||
+242
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+39
-66
@@ -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>
|
||||
)}
|
||||
|
||||
+4
-1
@@ -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;
|
||||
}
|
||||
|
||||
+134
@@ -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;
|
||||
+3
-10
@@ -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)}`}
|
||||
|
||||
+7
-1
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 = ({
|
||||
|
||||
Reference in New Issue
Block a user