From 8f9f2f390e7b71978b112bba3d9f6ef14472c889 Mon Sep 17 00:00:00 2001 From: Etienne <45695613+etiennejouan@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:07:29 +0200 Subject: [PATCH] fix(ai-chat): show streaming activity during and between steps (#23581) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/user-attachments/assets/e72e313c-66f8-40af-bf48-9225422ffa78 ## Problem During a streaming turn with tool calls, the chat goes completely static in two places: - **Between two steps**: once a tool's output arrives, its row flips to past tense and nothing animates until the model's next chunk arrives (a full LLM round trip, often several seconds). This window is defined by the absence of parts, so no part-driven component can fill it — and the pre-turn "…" indicator can't either, since it's cleared on the turn's first chunk and never comes back. - **During tool execution**: the active tool row in `ThinkingStepsDisplay` is a static icon + label; the only animated element there is the orbit loader on an actively-streaming reasoning part. Users can't tell whether the AI chat is still thinking or blocked. ## Fix - **Pending thinking row between steps.** The renderer flags the trailing thinking-steps group of a streaming, error-free message (`showPendingThinkingRow`), and `ThinkingStepsDisplay` appends the thinking row (orbit loader + "Thinking") inside its rows container when none of its own steps is active (`isThinking`, which it already computes). The row occupies the exact slot where the next real step row materializes, so the handoff happens in place with no layout shift. - **One shared row component.** `AiChatThinkingRow` renders the orbit loader + "Thinking" and is used both for an actively-streaming reasoning step and for the pending row. - **Shimmer on executing tools.** Active tool rows wrap their label ("Searching the web for…") in the existing `ShimmeringText` while awaiting output, with the text as a direct child of the background-clip element so the effect applies reliably. - **Activity derived from the tool lifecycle state.** `isThinkingStepPartActive` now checks `input-streaming` / `input-available` instead of output presence, so a tool completing with a legitimate `null` output is no longer classified as still running. Why the trailing-group check is sufficient: anything in progress outside the group — streaming answer text, a running code execution card, a pending question — is itself a later render item, so the group isn't last and never gets flagged. No message-wide part scanning needed. ## Notes - The row renders only while `agentChatIsStreaming`, which the existing keepalive watchdog force-clears (with a visible connection-lost error) after ~5s of subscription silence — it cannot spin forever on a dead stream. - It never shows while waiting on the user: `ask_questions` renders as its own item after the group, and the server ends the stream on that tool anyway (`stopWhen`). - Consciously not covered, for simplicity: a pause right after a mid-turn text part or right after the routing row. ## Tests - Renderer: trailing group flagged as pending while streaming; not flagged when answer text follows or when not streaming - `ThinkingStepsDisplay`: pending row appended after completed steps, suppressed while a tool step runs, loading label shown on a running tool - `isThinkingStepPartActive`: lifecycle-state cases, including a completed tool with `null` output Lint, format, and `typecheck twenty-front` are clean. --- .../AiChatAssistantMessageRenderer.tsx | 7 ++ .../ai/components/AiChatThinkingRow.tsx | 36 +++++++++ .../ai/components/ThinkingStepsDisplay.tsx | 58 +++++++------- .../AiChatAssistantMessageRenderer.test.tsx | 75 ++++++++++++++++++- .../__tests__/ThinkingStepsDisplay.test.tsx | 50 ++++++++++++- .../thinkingStepsDisplayState.test.ts | 19 +++-- .../ai/utils/isThinkingStepPartActive.ts | 5 +- 7 files changed, 210 insertions(+), 40 deletions(-) create mode 100644 packages/twenty-front/src/modules/ai/components/AiChatThinkingRow.tsx diff --git a/packages/twenty-front/src/modules/ai/components/AiChatAssistantMessageRenderer.tsx b/packages/twenty-front/src/modules/ai/components/AiChatAssistantMessageRenderer.tsx index 43f8614144..48ee249322 100644 --- a/packages/twenty-front/src/modules/ai/components/AiChatAssistantMessageRenderer.tsx +++ b/packages/twenty-front/src/modules/ai/components/AiChatAssistantMessageRenderer.tsx @@ -86,6 +86,8 @@ export const AiChatAssistantMessageRenderer = ({ ); const renderItems = groupContiguousThinkingStepParts(filteredParts); + const lastRenderItemIndex = renderItems.length - 1; + if (!renderItems.length && !hasError) { return ; } @@ -107,6 +109,11 @@ export const AiChatAssistantMessageRenderer = ({ nextRenderItem.part.type === 'text' && nextRenderItem.part.text.trim().length > 0, )} + isTrailingWhileStreaming={ + isLastMessageStreaming && + !hasError && + index === lastRenderItemIndex + } /> ) : ( { + return ( + + + + + {t`Thinking`} + + ); +}; diff --git a/packages/twenty-front/src/modules/ai/components/ThinkingStepsDisplay.tsx b/packages/twenty-front/src/modules/ai/components/ThinkingStepsDisplay.tsx index 8712d2a5d6..61220ceb4a 100644 --- a/packages/twenty-front/src/modules/ai/components/ThinkingStepsDisplay.tsx +++ b/packages/twenty-front/src/modules/ai/components/ThinkingStepsDisplay.tsx @@ -3,17 +3,15 @@ import { plural, t } from '@lingui/core/macro'; import { useState } from 'react'; import { type DynamicToolUIPart, getToolName, type ToolUIPart } from 'ai'; import { isDefined } from 'twenty-shared/utils'; -import { - IconChevronRight, - IconCpu, - ThinkingOrbitLoaderIcon, -} from 'twenty-ui/icon'; +import { IconChevronRight, IconCpu } from 'twenty-ui/icon'; import { OverflowingTextWithTooltip, TooltipDelay } from 'twenty-ui/surfaces'; import { JsonTree } from 'twenty-ui/json-visualizer'; import { AnimatedExpandableContainer } from 'twenty-ui/layout'; import { themeCssVariables } from 'twenty-ui/theme-constants'; import { type JsonValue } from 'type-fest'; +import { AiChatThinkingRow } from '@/ai/components/AiChatThinkingRow'; +import { ShimmeringText } from '@/ai/components/ShimmeringText'; import { useToolDisplayContext } from '@/ai/hooks/useToolDisplayContext'; import { getToolIcon } from '@/ai/utils/getToolIcon'; import { getToolDisplayMessage } from '@/ai/utils/tool-display/get-tool-display-message'; @@ -137,11 +135,6 @@ const StyledReasoningText = styled.p` white-space: pre-wrap; `; -const StyledOrbitLoaderIconContainer = styled.span` - color: ${themeCssVariables.font.color.tertiary}; - display: flex; -`; - const StyledIconContainer = styled.div` align-items: center; color: ${themeCssVariables.font.color.light}; @@ -203,6 +196,12 @@ const StyledToolRowButton = styled.button<{ isExpandable: boolean }>` } `; +const StyledShimmeringLabel = styled(ShimmeringText)` + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + const StyledToolDetailsContainer = styled.div` background: ${themeCssVariables.background.transparent.lighter}; border: 1px solid ${themeCssVariables.border.color.light}; @@ -317,10 +316,14 @@ const ThinkingToolStepRow = ({ - + {isActive ? ( + {displayMessage} + ) : ( + + )} {isExpandable && ( @@ -390,19 +393,17 @@ const ThinkingStepRow = ({ ); } + if (isActive) { + return ; + } + return ( - {isActive ? ( - - - - ) : ( - - )} + - {isActive ? t`Thinking` : t`Thought`} + {t`Thought`} ); @@ -412,30 +413,32 @@ export const ThinkingStepsDisplay = ({ parts, isLastMessageStreaming, hasAssistantTextResponseStarted, + isTrailingWhileStreaming = false, }: { parts: ThinkingStepPart[]; isLastMessageStreaming: boolean; hasAssistantTextResponseStarted: boolean; + isTrailingWhileStreaming?: boolean; }) => { const [isExpanded, setIsExpanded] = useState(false); const stepCount = parts.length; - const isThinking = parts.some((part) => + const hasActiveStep = parts.some((part) => isThinkingStepPartActive(part, isLastMessageStreaming), ); const activeReasoningContent = getActiveReasoningContent(parts); const finalReasoningContent = getLastReasoningContent(parts); - const reasoningContent = isThinking + const reasoningContent = hasActiveStep ? activeReasoningContent : finalReasoningContent; const shouldDisplayReasoningContent = reasoningContent?.trim().length; const shouldKeepExpandedBeforeAnswer = !hasAssistantTextResponseStarted; const shouldShowSummaryButton = - !isThinking && !shouldKeepExpandedBeforeAnswer; + !hasActiveStep && !shouldKeepExpandedBeforeAnswer; const shouldRenderRows = - isThinking || isExpanded || shouldKeepExpandedBeforeAnswer; + hasActiveStep || isExpanded || shouldKeepExpandedBeforeAnswer; return ( @@ -471,6 +474,9 @@ export const ThinkingStepsDisplay = ({ )} /> ))} + {isTrailingWhileStreaming && !hasActiveStep && ( + + )} {!!shouldDisplayReasoningContent && ( 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 index a7f22ec15c..6ac31eb56e 100644 --- a/packages/twenty-front/src/modules/ai/components/__tests__/AiChatAssistantMessageRenderer.test.tsx +++ b/packages/twenty-front/src/modules/ai/components/__tests__/AiChatAssistantMessageRenderer.test.tsx @@ -8,12 +8,14 @@ jest.mock('@/ai/components/ThinkingStepsDisplay', () => ({ ThinkingStepsDisplay: ({ hasAssistantTextResponseStarted, parts, + isTrailingWhileStreaming, }: { parts: unknown[]; hasAssistantTextResponseStarted: boolean; + isTrailingWhileStreaming?: boolean; }) => (
- {`thinking-${parts.length}-${hasAssistantTextResponseStarted ? 'answer-started' : 'answer-pending'}`} + {`thinking-${parts.length}-${hasAssistantTextResponseStarted ? 'answer-started' : 'answer-pending'}${isTrailingWhileStreaming ? '-trailing-while-streaming' : ''}`}
), })); @@ -40,12 +42,15 @@ jest.mock('@/ai/components/CodeExecutionDisplay', () => ({ CodeExecutionDisplay: () =>
, })); -const renderAssistantRenderer = (messageParts: ExtendedUIMessagePart[]) => { +const renderAssistantRenderer = ( + messageParts: ExtendedUIMessagePart[], + { isLastMessageStreaming = false }: { isLastMessageStreaming?: boolean } = {}, +) => { return render( , ); @@ -234,6 +239,70 @@ describe('AiChatAssistantMessageRenderer', () => { expect(screen.getByTestId('code-execution-display')).toBeInTheDocument(); }); + it('should flag the trailing thinking steps group while streaming', () => { + 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', + }, + ] as ExtendedUIMessagePart[]; + + renderAssistantRenderer(messageParts, { isLastMessageStreaming: true }); + + expect(screen.getByTestId('thinking-steps-display')).toHaveTextContent( + 'trailing-while-streaming', + ); + }); + + it('should not flag a thinking steps group when answer text follows it', () => { + const messageParts = [ + { + type: 'tool-web_search', + toolCallId: 'tool-1', + input: { query: 'crm software' }, + output: { result: { ok: true } }, + state: 'output-available', + }, + { + type: 'text', + text: 'Partial answer', + state: 'streaming', + }, + ] as ExtendedUIMessagePart[]; + + renderAssistantRenderer(messageParts, { isLastMessageStreaming: true }); + + expect(screen.getByTestId('thinking-steps-display')).not.toHaveTextContent( + 'trailing-while-streaming', + ); + }); + + it('should not flag the trailing thinking steps group when the message is not streaming', () => { + const messageParts = [ + { + 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')).not.toHaveTextContent( + 'trailing-while-streaming', + ); + }); + it('should group a dynamic-tool part (native web search) into ThinkingStepsDisplay', () => { const messageParts = [ { 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 index 2e4d68695b..47d10bfb4b 100644 --- a/packages/twenty-front/src/modules/ai/components/__tests__/ThinkingStepsDisplay.test.tsx +++ b/packages/twenty-front/src/modules/ai/components/__tests__/ThinkingStepsDisplay.test.tsx @@ -64,28 +64,32 @@ const createReasoningPart = ({ const createToolPart = ({ input = { query: 'crm software' }, output = { result: { ok: true } }, + state = 'output-available', type = 'tool-web_search', }: { type?: `tool-${string}`; input?: Record; output?: unknown; + state?: string; } = {}): ThinkingStepPart => ({ type, toolCallId: `${type}-call-id`, input, output, - state: 'output-available', + state, }) as ThinkingStepPart; const renderThinkingStepsDisplay = ({ hasAssistantTextResponseStarted = false, isLastMessageStreaming, parts, + isTrailingWhileStreaming = false, }: { parts: ThinkingStepPart[]; isLastMessageStreaming: boolean; hasAssistantTextResponseStarted?: boolean; + isTrailingWhileStreaming?: boolean; }) => { return render( @@ -93,6 +97,7 @@ const renderThinkingStepsDisplay = ({ parts={parts} isLastMessageStreaming={isLastMessageStreaming} hasAssistantTextResponseStarted={hasAssistantTextResponseStarted} + isTrailingWhileStreaming={isTrailingWhileStreaming} /> , ); @@ -120,6 +125,49 @@ describe('ThinkingStepsDisplay', () => { expect(document.querySelector('svg[viewBox="0 0 14 14"]')).not.toBeNull(); }); + it('should render the loading label for a tool step awaiting its output while streaming', () => { + renderThinkingStepsDisplay({ + isLastMessageStreaming: true, + parts: [createToolPart({ output: null, state: 'input-available' })], + }); + + expect( + screen.getByText('Searching the web for crm software'), + ).toBeInTheDocument(); + }); + + it('should append the pending thinking row after completed steps when requested', () => { + renderThinkingStepsDisplay({ + isLastMessageStreaming: true, + isTrailingWhileStreaming: true, + parts: [createToolPart()], + }); + + expect(screen.getByText('Thinking')).toBeInTheDocument(); + }); + + it('should not render a thinking row for completed steps by default', () => { + renderThinkingStepsDisplay({ + isLastMessageStreaming: true, + parts: [createToolPart()], + }); + + expect(screen.queryByText('Thinking')).toBeNull(); + }); + + it('should not append the pending thinking row while a tool step is still running', () => { + renderThinkingStepsDisplay({ + isLastMessageStreaming: true, + isTrailingWhileStreaming: true, + parts: [createToolPart({ output: null, state: 'input-available' })], + }); + + expect(screen.queryByText('Thinking')).toBeNull(); + expect( + screen.getByText('Searching the web for crm software'), + ).toBeInTheDocument(); + }); + it('should render done state collapsed by default', () => { renderThinkingStepsDisplay({ isLastMessageStreaming: false, 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 index b094a8114b..2c0ea8a129 100644 --- a/packages/twenty-front/src/modules/ai/utils/__tests__/thinkingStepsDisplayState.test.ts +++ b/packages/twenty-front/src/modules/ai/utils/__tests__/thinkingStepsDisplayState.test.ts @@ -23,12 +23,14 @@ const createToolPart = ({ errorText, input = {}, output, + state = 'output-available', type = 'tool-web_search', }: { type?: `tool-${string}`; input?: Record; output?: unknown; errorText?: string; + state?: string; } = {}): ThinkingStepPart => ({ type, @@ -36,7 +38,7 @@ const createToolPart = ({ input, output, errorText, - state: 'output-available', + state, }) as ThinkingStepPart; describe('thinkingStepsDisplayState', () => { @@ -91,27 +93,32 @@ describe('thinkingStepsDisplayState', () => { expect(isThinkingStepPartActive(reasoningPart, false)).toBe(true); }); - it('should mark tool parts without output as active while message is streaming', () => { + it('should mark tool parts awaiting their output as active while message is streaming', () => { const toolPart = createToolPart({ type: 'tool-web_search', - output: undefined, - errorText: undefined, + state: 'input-available', }); expect(isThinkingStepPartActive(toolPart, true)).toBe(true); expect(isThinkingStepPartActive(toolPart, false)).toBe(false); }); - it('should mark tool parts with output or error as inactive', () => { + it('should mark completed and failed tool parts as inactive', () => { const completedToolPart = createToolPart({ output: { result: { ok: true } }, }); + const completedNullOutputToolPart = createToolPart({ + output: null, + }); const failedToolPart = createToolPart({ - output: undefined, errorText: 'Tool failed', + state: 'output-error', }); expect(isThinkingStepPartActive(completedToolPart, true)).toBe(false); + expect(isThinkingStepPartActive(completedNullOutputToolPart, true)).toBe( + false, + ); expect(isThinkingStepPartActive(failedToolPart, true)).toBe(false); }); }); diff --git a/packages/twenty-front/src/modules/ai/utils/isThinkingStepPartActive.ts b/packages/twenty-front/src/modules/ai/utils/isThinkingStepPartActive.ts index 24c02c1957..a037724235 100644 --- a/packages/twenty-front/src/modules/ai/utils/isThinkingStepPartActive.ts +++ b/packages/twenty-front/src/modules/ai/utils/isThinkingStepPartActive.ts @@ -1,5 +1,3 @@ -import { isDefined } from 'twenty-shared/utils'; - import { type ThinkingStepPart } from '@/ai/utils/thinkingStepPart'; export const isThinkingStepPartActive = ( @@ -12,7 +10,6 @@ export const isThinkingStepPartActive = ( return ( isLastMessageStreaming && - !isDefined(part.output) && - !isDefined(part.errorText) + (part.state === 'input-streaming' || part.state === 'input-available') ); };