From 9477bb3677e9c86085e664dba6333df65339baaa Mon Sep 17 00:00:00 2001 From: Thomas des Francs Date: Tue, 17 Feb 2026 11:24:33 +0100 Subject: [PATCH] Improve AI chat UX (#17974) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- packages/twenty-front/jest.config.mjs | 1 - packages/twenty-front/setupTests.ts | 26 + .../AIChatAssistantMessageRenderer.tsx | 63 ++- .../modules/ai/components/AIChatMessage.tsx | 26 +- .../src/modules/ai/components/AIChatTab.tsx | 76 +-- .../ai/components/LazyMarkdownRenderer.tsx | 166 ++++++- .../ai/components/ThinkingStepsDisplay.tsx | 466 ++++++++++++++++++ .../ai/components/ToolStepRenderer.tsx | 9 +- .../__stories__/AIChatMessage.stories.tsx | 78 +++ .../AIChatAssistantMessageRenderer.test.tsx | 188 +++++++ .../__tests__/ThinkingStepsDisplay.test.tsx | 242 +++++++++ .../internal/AIChatContextUsageButton.tsx | 105 ++-- .../AIChatSuggestedPrompts.tsx | 5 +- .../src/modules/ai/hooks/useAiModelOptions.ts | 8 +- .../thinkingStepsDisplayState.test.ts | 134 +++++ .../ai/utils/assistantMessageRenderItem.ts | 13 + .../ai/utils/getActiveReasoningContent.ts | 14 + .../ai/utils/getLastReasoningContent.ts | 13 + .../utils/groupContiguousThinkingStepParts.ts | 44 ++ .../modules/ai/utils/isThinkingStepPart.ts | 14 + .../ai/utils/isThinkingStepPartActive.ts | 18 + .../src/modules/ai/utils/thinkingStepPart.ts | 3 + .../SettingsBillingCreditsSection.tsx | 13 +- .../record-properties.zod-schema.ts | 8 +- .../components/ThinkingOrbitLoaderIcon.tsx | 46 ++ packages/twenty-ui/src/display/index.ts | 1 + .../tooltip/OverflowingTextWithTooltip.tsx | 4 +- .../components/internal/JsonNodeLabel.tsx | 16 +- .../components/internal/JsonNodeValue.tsx | 1 + 29 files changed, 1636 insertions(+), 165 deletions(-) create mode 100644 packages/twenty-front/src/modules/ai/components/ThinkingStepsDisplay.tsx create mode 100644 packages/twenty-front/src/modules/ai/components/__tests__/AIChatAssistantMessageRenderer.test.tsx create mode 100644 packages/twenty-front/src/modules/ai/components/__tests__/ThinkingStepsDisplay.test.tsx create mode 100644 packages/twenty-front/src/modules/ai/utils/__tests__/thinkingStepsDisplayState.test.ts create mode 100644 packages/twenty-front/src/modules/ai/utils/assistantMessageRenderItem.ts create mode 100644 packages/twenty-front/src/modules/ai/utils/getActiveReasoningContent.ts create mode 100644 packages/twenty-front/src/modules/ai/utils/getLastReasoningContent.ts create mode 100644 packages/twenty-front/src/modules/ai/utils/groupContiguousThinkingStepParts.ts create mode 100644 packages/twenty-front/src/modules/ai/utils/isThinkingStepPart.ts create mode 100644 packages/twenty-front/src/modules/ai/utils/isThinkingStepPartActive.ts create mode 100644 packages/twenty-front/src/modules/ai/utils/thinkingStepPart.ts create mode 100644 packages/twenty-ui/src/display/icon/components/ThinkingOrbitLoaderIcon.tsx diff --git a/packages/twenty-front/jest.config.mjs b/packages/twenty-front/jest.config.mjs index b4c612ac90..aa2adb6765 100644 --- a/packages/twenty-front/jest.config.mjs +++ b/packages/twenty-front/jest.config.mjs @@ -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, diff --git a/packages/twenty-front/setupTests.ts b/packages/twenty-front/setupTests.ts index 17c127607a..360355548e 100644 --- a/packages/twenty-front/setupTests.ts +++ b/packages/twenty-front/setupTests.ts @@ -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; + +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 { diff --git a/packages/twenty-front/src/modules/ai/components/AIChatAssistantMessageRenderer.tsx b/packages/twenty-front/src/modules/ai/components/AIChatAssistantMessageRenderer.tsx index dc623de8a2..c128bfbe78 100644 --- a/packages/twenty-front/src/modules/ai/components/AIChatAssistantMessageRenderer.tsx +++ b/packages/twenty-front/src/modules/ai/components/AIChatAssistantMessageRenderer.tsx @@ -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 ( - - ); case 'text': return ; 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 ; } return (
- {filteredParts.map((part, index) => ( - - ))} + {renderItems.map((renderItem, index) => + renderItem.type === 'thinking-steps' ? ( + + nextRenderItem.type === 'part' && + nextRenderItem.part.type === 'text' && + nextRenderItem.part.text.trim().length > 0, + )} + /> + ) : ( + + ), + )} - {isLastMessageStreaming && !hasError && }
); }; diff --git a/packages/twenty-front/src/modules/ai/components/AIChatMessage.tsx b/packages/twenty-front/src/modules/ai/components/AIChatMessage.tsx index b161c229f1..7852554930 100644 --- a/packages/twenty-front/src/modules/ai/components/AIChatMessage.tsx +++ b/packages/twenty-front/src/modules/ai/components/AIChatMessage.tsx @@ -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 = ({ {message.parts.length > 0 && message.metadata?.createdAt && ( - + {beautifyPastDateRelativeToNow( message.metadata?.createdAt, localeCatalog, )} - + part.type === 'text')?.text ?? '' diff --git a/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx b/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx index b83ed5d42e..2d79036623 100644 --- a/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx +++ b/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx @@ -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 && ( @@ -170,13 +191,13 @@ export const AIChatTab = () => { )} )} - {messages.length === 0 && !error && !isLoading && ( + {!hasMessages && !error && !isLoading && ( )} - {messages.length === 0 && error && !isLoading && ( + {!hasMessages && error && !isLoading && ( )} - {isLoading && messages.length === 0 && } + {isLoading && !hasMessages && } @@ -185,22 +206,17 @@ export const AIChatTab = () => { - - - navigateCommandMenu({ - page: CommandMenuPages.ViewPreviousAIChats, - pageTitle: t`View Previous AI Chats`, - pageIcon: IconHistory, - }) - } - ariaLabel={t`View Previous AI Chats`} - /> - - + + + {hasMessages && } + + + + + diff --git a/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx b/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx index 545d4253fc..abfefdc6a6 100644 --- a/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx +++ b/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx @@ -94,6 +94,29 @@ const MarkdownRenderer = lazy(async () => { li: ({ children }) => (
  • {processChildrenForRecordLinks(children)}
  • ), + a: ({ children, href, title, target, rel, node: _node }) => ( + + {processChildrenForRecordLinks(children)} + + ), + code: ({ + className, + children, + }: { + className?: string; + children?: React.ReactNode; + }) => {children}, + pre: ({ children }) => ( +
    +
    {children}
    +
    + ), }} > {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 ( - + }> 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 ( + + { + if (!isExpandable) { + return; + } + + setIsExpanded((previousValue) => !previousValue); + }} + aria-expanded={isExpandable ? isExpanded : undefined} + > + + + + + + + + {isExpandable && ( + + + + )} + + + + {isExpandable && ( + + + {hasError ? ( + {part.errorText} + ) : ( + + + + + false} + emptyArrayLabel={t`Empty Array`} + emptyObjectLabel={t`Empty Object`} + emptyStringLabel={t`[empty string]`} + arrowButtonCollapsedLabel={t`Expand`} + arrowButtonExpandedLabel={t`Collapse`} + onNodeValueClick={copyToClipboard} + /> + + + + )} + + + )} + + ); +}; + +const ThinkingStepRow = ({ + isActive, + part, + rowIndex, +}: { + isActive: boolean; + part: ThinkingStepPart; + rowIndex: number; +}) => { + if (part.type !== 'reasoning') { + return ( + + ); + } + + return ( + + + {isActive ? : } + + + {isActive ? t`Thinking` : t`Thought`} + + + ); +}; + +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 ( + + {shouldShowSummaryButton && ( + setIsExpanded((previousValue) => !previousValue)} + > + + + + + {plural(stepCount, { + one: '# step', + other: '# steps', + })} + + + )} + + {shouldRenderRows && ( + + + {parts.map((part, index) => ( + + ))} + + {!!shouldDisplayReasoningContent && ( + + {reasoningContent} + + )} + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/ai/components/ToolStepRenderer.tsx b/packages/twenty-front/src/modules/ai/components/ToolStepRenderer.tsx index 2aee2339d7..eadbdb6b4e 100644 --- a/packages/twenty-front/src/modules/ai/components/ToolStepRenderer.tsx +++ b/packages/twenty-front/src/modules/ai/components/ToolStepRenderer.tsx @@ -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}; } `; diff --git a/packages/twenty-front/src/modules/ai/components/__stories__/AIChatMessage.stories.tsx b/packages/twenty-front/src/modules/ai/components/__stories__/AIChatMessage.stories.tsx index 10f2597385..fff098b58e 100644 --- a/packages/twenty-front/src/modules/ai/components/__stories__/AIChatMessage.stories.tsx +++ b/packages/twenty-front/src/modules/ai/components/__stories__/AIChatMessage.stories.tsx @@ -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 = { 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); + }, +}; diff --git a/packages/twenty-front/src/modules/ai/components/__tests__/AIChatAssistantMessageRenderer.test.tsx b/packages/twenty-front/src/modules/ai/components/__tests__/AIChatAssistantMessageRenderer.test.tsx new file mode 100644 index 0000000000..f175ad5aaf --- /dev/null +++ b/packages/twenty-front/src/modules/ai/components/__tests__/AIChatAssistantMessageRenderer.test.tsx @@ -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; + }) => ( +
    + {`thinking-${parts.length}-${hasAssistantTextResponseStarted ? 'answer-started' : 'answer-pending'}`} +
    + ), +})); + +jest.mock('@/ai/components/ToolStepRenderer', () => ({ + ToolStepRenderer: ({ toolPart }: { toolPart: { type: string } }) => ( +
    {toolPart.type}
    + ), +})); + +jest.mock('@/ai/components/LazyMarkdownRenderer', () => ({ + LazyMarkdownRenderer: ({ text }: { text: string }) => ( +
    {text}
    + ), +})); + +jest.mock('@/ai/components/RoutingStatusDisplay', () => ({ + RoutingStatusDisplay: ({ data }: { data: { text: string } }) => ( +
    {data.text}
    + ), +})); + +jest.mock('@/ai/components/CodeExecutionDisplay', () => ({ + CodeExecutionDisplay: () =>
    , +})); + +const renderAssistantRenderer = (messageParts: ExtendedUIMessagePart[]) => { + return render( + + + + + , + ); +}; + +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(); + }); +}); diff --git a/packages/twenty-front/src/modules/ai/components/__tests__/ThinkingStepsDisplay.test.tsx b/packages/twenty-front/src/modules/ai/components/__tests__/ThinkingStepsDisplay.test.tsx new file mode 100644 index 0000000000..794d668468 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/components/__tests__/ThinkingStepsDisplay.test.tsx @@ -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; + }) => ( +
    + {tabs.map((tab) => ( + + ))} +
    + ), +})); + +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; + 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( + + + + + , + ); +}; + +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(); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/ai/components/internal/AIChatContextUsageButton.tsx b/packages/twenty-front/src/modules/ai/components/internal/AIChatContextUsageButton.tsx index e90ce60156..d1776c63c9 100644 --- a/packages/twenty-front/src/modules/ai/components/internal/AIChatContextUsageButton.tsx +++ b/packages/twenty-front/src/modules/ai/components/internal/AIChatContextUsageButton.tsx @@ -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 = () => { {t`Context window`} - {formattedPercentage}% - + + {formattedPercentage}% + + {formatTokenCount(agentChatUsage.conversationSize)} /{' '} {formatTokenCount(agentChatUsage.contextWindowTokens)}{' '} {t`tokens`} - + { ? theme.color.orange : theme.color.blue } - backgroundColor={theme.background.quaternary} + backgroundColor={theme.background.tertiary} withBorderRadius /> {isDefined(lastMessage) && ( <> - + {t`Last message`} - - {t`Input tokens`} - - {formatTokenCount(lastMessage.inputTokens)} - {getCachedLabel(lastMessage)} - - - - {t`Output tokens`} - - {formatTokenCount(lastMessage.outputTokens)} - - - - {t`Cost`} - - {formatCredits( - lastMessage.inputCredits + lastMessage.outputCredits, - )}{' '} - {t`credits`} - - + + + )} - + {t`Conversation`} - - {t`Input tokens`} - - {formatTokenCount(agentChatUsage.inputTokens)} - - - - {t`Output tokens`} - - {formatTokenCount(agentChatUsage.outputTokens)} - - - - {t`Total cost`} - - {formatCredits(totalCredits)} {t`credits`} - - + + + )} diff --git a/packages/twenty-front/src/modules/ai/components/suggested-prompts/AIChatSuggestedPrompts.tsx b/packages/twenty-front/src/modules/ai/components/suggested-prompts/AIChatSuggestedPrompts.tsx index d9aad02047..91ea817864 100644 --- a/packages/twenty-front/src/modules/ai/components/suggested-prompts/AIChatSuggestedPrompts.tsx +++ b/packages/twenty-front/src/modules/ai/components/suggested-prompts/AIChatSuggestedPrompts.tsx @@ -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 = (items: T[]): T => diff --git a/packages/twenty-front/src/modules/ai/hooks/useAiModelOptions.ts b/packages/twenty-front/src/modules/ai/hooks/useAiModelOptions.ts index ceb19d2cf9..dbc81fad1a 100644 --- a/packages/twenty-front/src/modules/ai/hooks/useAiModelOptions.ts +++ b/packages/twenty-front/src/modules/ai/hooks/useAiModelOptions.ts @@ -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; } diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/thinkingStepsDisplayState.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/thinkingStepsDisplayState.test.ts new file mode 100644 index 0000000000..c546f51bd9 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/__tests__/thinkingStepsDisplayState.test.ts @@ -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; + 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'); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/ai/utils/assistantMessageRenderItem.ts b/packages/twenty-front/src/modules/ai/utils/assistantMessageRenderItem.ts new file mode 100644 index 0000000000..639b67174c --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/assistantMessageRenderItem.ts @@ -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; + }; diff --git a/packages/twenty-front/src/modules/ai/utils/getActiveReasoningContent.ts b/packages/twenty-front/src/modules/ai/utils/getActiveReasoningContent.ts new file mode 100644 index 0000000000..65ccbf21e5 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/getActiveReasoningContent.ts @@ -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; +}; diff --git a/packages/twenty-front/src/modules/ai/utils/getLastReasoningContent.ts b/packages/twenty-front/src/modules/ai/utils/getLastReasoningContent.ts new file mode 100644 index 0000000000..18cc1c8a1a --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/getLastReasoningContent.ts @@ -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; +}; diff --git a/packages/twenty-front/src/modules/ai/utils/groupContiguousThinkingStepParts.ts b/packages/twenty-front/src/modules/ai/utils/groupContiguousThinkingStepParts.ts new file mode 100644 index 0000000000..663d4c2cbb --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/groupContiguousThinkingStepParts.ts @@ -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; +}; diff --git a/packages/twenty-front/src/modules/ai/utils/isThinkingStepPart.ts b/packages/twenty-front/src/modules/ai/utils/isThinkingStepPart.ts new file mode 100644 index 0000000000..676246abc3 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/isThinkingStepPart.ts @@ -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'; +}; diff --git a/packages/twenty-front/src/modules/ai/utils/isThinkingStepPartActive.ts b/packages/twenty-front/src/modules/ai/utils/isThinkingStepPartActive.ts new file mode 100644 index 0000000000..24c02c1957 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/isThinkingStepPartActive.ts @@ -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) + ); +}; diff --git a/packages/twenty-front/src/modules/ai/utils/thinkingStepPart.ts b/packages/twenty-front/src/modules/ai/utils/thinkingStepPart.ts new file mode 100644 index 0000000000..cff13a070b --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/thinkingStepPart.ts @@ -0,0 +1,3 @@ +import { type ReasoningUIPart, type ToolUIPart } from 'ai'; + +export type ThinkingStepPart = ReasoningUIPart | ToolUIPart; diff --git a/packages/twenty-front/src/modules/billing/components/SettingsBillingCreditsSection.tsx b/packages/twenty-front/src/modules/billing/components/SettingsBillingCreditsSection.tsx index 21ce641689..2f7dda323b 100644 --- a/packages/twenty-front/src/modules/billing/components/SettingsBillingCreditsSection.tsx +++ b/packages/twenty-front/src/modules/billing/components/SettingsBillingCreditsSection.tsx @@ -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 && ( <> - + )} - + ; + +export const ThinkingOrbitLoaderIcon = ({ + className, + size = 14, +}: ThinkingOrbitLoaderIconProps) => { + return ( + + ); +}; diff --git a/packages/twenty-ui/src/display/index.ts b/packages/twenty-ui/src/display/index.ts index c91fba14c2..952758b883 100644 --- a/packages/twenty-ui/src/display/index.ts +++ b/packages/twenty-ui/src/display/index.ts @@ -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'; diff --git a/packages/twenty-ui/src/display/tooltip/OverflowingTextWithTooltip.tsx b/packages/twenty-ui/src/display/tooltip/OverflowingTextWithTooltip.tsx index 520ed7b776..0e6ddcc8da 100644 --- a/packages/twenty-ui/src/display/tooltip/OverflowingTextWithTooltip.tsx +++ b/packages/twenty-ui/src/display/tooltip/OverflowingTextWithTooltip.tsx @@ -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 ? ( diff --git a/packages/twenty-ui/src/json-visualizer/components/internal/JsonNodeLabel.tsx b/packages/twenty-ui/src/json-visualizer/components/internal/JsonNodeLabel.tsx index c99f27f225..6d9459fcb3 100644 --- a/packages/twenty-ui/src/json-visualizer/components/internal/JsonNodeLabel.tsx +++ b/packages/twenty-ui/src/json-visualizer/components/internal/JsonNodeLabel.tsx @@ -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 = ({ diff --git a/packages/twenty-ui/src/json-visualizer/components/internal/JsonNodeValue.tsx b/packages/twenty-ui/src/json-visualizer/components/internal/JsonNodeValue.tsx index 6498a222ee..486b654127 100644 --- a/packages/twenty-ui/src/json-visualizer/components/internal/JsonNodeValue.tsx +++ b/packages/twenty-ui/src/json-visualizer/components/internal/JsonNodeValue.tsx @@ -15,6 +15,7 @@ const StyledText = styled.span<{ : theme.font.color.tertiary}; display: inline-flex; height: 24px; + line-height: 1; `; export const JsonNodeValue = ({