fix(ai-chat): show streaming activity during and between steps (#23581)

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.
This commit is contained in:
Etienne
2026-07-31 11:07:29 +02:00
committed by GitHub
parent a9084604b4
commit 8f9f2f390e
7 changed files with 210 additions and 40 deletions
@@ -86,6 +86,8 @@ export const AiChatAssistantMessageRenderer = ({
);
const renderItems = groupContiguousThinkingStepParts(filteredParts);
const lastRenderItemIndex = renderItems.length - 1;
if (!renderItems.length && !hasError) {
return <AiChatInitialLoadingIndicator />;
}
@@ -107,6 +109,11 @@ export const AiChatAssistantMessageRenderer = ({
nextRenderItem.part.type === 'text' &&
nextRenderItem.part.text.trim().length > 0,
)}
isTrailingWhileStreaming={
isLastMessageStreaming &&
!hasError &&
index === lastRenderItemIndex
}
/>
) : (
<MessagePartRenderer
@@ -0,0 +1,36 @@
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { ThinkingOrbitLoaderIcon } from 'twenty-ui/icon';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledRow = styled.div`
align-items: center;
color: ${themeCssVariables.font.color.tertiary};
display: flex;
gap: ${themeCssVariables.spacing[2]};
min-height: ${themeCssVariables.spacing[6]};
`;
const StyledLoaderIconContainer = styled.div`
align-items: center;
display: flex;
justify-content: center;
min-width: calc(${themeCssVariables.icon.size.sm} * 1px);
`;
const StyledLabel = styled.span`
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.regular};
line-height: ${themeCssVariables.text.lineHeight.md};
`;
export const AiChatThinkingRow = () => {
return (
<StyledRow>
<StyledLoaderIconContainer>
<ThinkingOrbitLoaderIcon />
</StyledLoaderIconContainer>
<StyledLabel>{t`Thinking`}</StyledLabel>
</StyledRow>
);
};
@@ -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 = ({
</StyledIconContainer>
<StyledRowLabelContainer>
<StyledToolRowLabel>
<OverflowingTextWithTooltip
text={displayMessage}
tooltipDelay={TooltipDelay.shortDelay}
/>
{isActive ? (
<StyledShimmeringLabel>{displayMessage}</StyledShimmeringLabel>
) : (
<OverflowingTextWithTooltip
text={displayMessage}
tooltipDelay={TooltipDelay.shortDelay}
/>
)}
</StyledToolRowLabel>
{isExpandable && (
<StyledChevronContainer isExpanded={isExpanded}>
@@ -390,19 +393,17 @@ const ThinkingStepRow = ({
);
}
if (isActive) {
return <AiChatThinkingRow />;
}
return (
<StyledRow>
<StyledIconContainer>
{isActive ? (
<StyledOrbitLoaderIconContainer>
<ThinkingOrbitLoaderIcon />
</StyledOrbitLoaderIconContainer>
) : (
<IconCpu size={14} />
)}
<IconCpu size={14} />
</StyledIconContainer>
<StyledRowLabelContainer>
<StyledRowLabel>{isActive ? t`Thinking` : t`Thought`}</StyledRowLabel>
<StyledRowLabel>{t`Thought`}</StyledRowLabel>
</StyledRowLabelContainer>
</StyledRow>
);
@@ -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 (
<StyledContainer>
@@ -471,6 +474,9 @@ export const ThinkingStepsDisplay = ({
)}
/>
))}
{isTrailingWhileStreaming && !hasActiveStep && (
<AiChatThinkingRow />
)}
</StyledRowsContainer>
{!!shouldDisplayReasoningContent && (
<StyledReasoningContainer>
@@ -8,12 +8,14 @@ jest.mock('@/ai/components/ThinkingStepsDisplay', () => ({
ThinkingStepsDisplay: ({
hasAssistantTextResponseStarted,
parts,
isTrailingWhileStreaming,
}: {
parts: unknown[];
hasAssistantTextResponseStarted: boolean;
isTrailingWhileStreaming?: boolean;
}) => (
<div data-testid="thinking-steps-display">
{`thinking-${parts.length}-${hasAssistantTextResponseStarted ? 'answer-started' : 'answer-pending'}`}
{`thinking-${parts.length}-${hasAssistantTextResponseStarted ? 'answer-started' : 'answer-pending'}${isTrailingWhileStreaming ? '-trailing-while-streaming' : ''}`}
</div>
),
}));
@@ -40,12 +42,15 @@ jest.mock('@/ai/components/CodeExecutionDisplay', () => ({
CodeExecutionDisplay: () => <div data-testid="code-execution-display" />,
}));
const renderAssistantRenderer = (messageParts: ExtendedUIMessagePart[]) => {
const renderAssistantRenderer = (
messageParts: ExtendedUIMessagePart[],
{ isLastMessageStreaming = false }: { isLastMessageStreaming?: boolean } = {},
) => {
return render(
<ThemeProvider colorScheme="light">
<AiChatAssistantMessageRenderer
messageParts={messageParts}
isLastMessageStreaming={false}
isLastMessageStreaming={isLastMessageStreaming}
/>
</ThemeProvider>,
);
@@ -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 = [
{
@@ -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<string, unknown>;
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(
<ThemeProvider colorScheme="light">
@@ -93,6 +97,7 @@ const renderThinkingStepsDisplay = ({
parts={parts}
isLastMessageStreaming={isLastMessageStreaming}
hasAssistantTextResponseStarted={hasAssistantTextResponseStarted}
isTrailingWhileStreaming={isTrailingWhileStreaming}
/>
</ThemeProvider>,
);
@@ -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,
@@ -23,12 +23,14 @@ const createToolPart = ({
errorText,
input = {},
output,
state = 'output-available',
type = 'tool-web_search',
}: {
type?: `tool-${string}`;
input?: Record<string, unknown>;
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);
});
});
@@ -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')
);
};