From f6c76239c55ae8c2b962c8d0a3b485f3c4ac8132 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 25 May 2026 02:07:25 +0300 Subject: [PATCH] fix: prevent activity reasoning flash --- .../components/chat/message/MessageBody.tsx | 13 +- .../chat/message/parts/ReasoningPart.test.tsx | 3 +- .../chat/message/parts/ReasoningPart.tsx | 223 +++++++++++------- 3 files changed, 142 insertions(+), 97 deletions(-) diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index 6547528b..bbe14da3 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -1038,6 +1038,7 @@ const AssistantMessageBody = React.memo(({ const collapsedPreviewCount = 7; const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false; const hasStopFinish = messageFinish === 'stop'; + const effectiveStreamPhase: StreamPhase = hasStopFinish ? 'completed' : streamPhase; const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); const currentProjectRef = React.useMemo(() => { @@ -1506,7 +1507,7 @@ const AssistantMessageBody = React.memo(({ onToggleTool={onToggleTool} onShowPopup={onShowPopup} onContentChange={onContentChange} - streamPhase={streamPhase} + streamPhase={effectiveStreamPhase} showHeader={true} animateRows={animateActivityRows} animatedToolIds={animatedToolIdsLookup} @@ -1551,7 +1552,7 @@ const AssistantMessageBody = React.memo(({ part={part} sessionId={sessionId} messageId={messageId} - streamPhase={streamPhase} + streamPhase={effectiveStreamPhase} chatRenderMode={chatRenderMode} onContentChange={onContentChange} /> @@ -1585,7 +1586,7 @@ const AssistantMessageBody = React.memo(({ part={part} sessionId={sessionId} messageId={messageId} - streamPhase={streamPhase} + streamPhase={effectiveStreamPhase} chatRenderMode={chatRenderMode} onContentChange={onContentChange} /> @@ -1599,7 +1600,7 @@ const AssistantMessageBody = React.memo(({ key={`reasoning-merged-${messageId}`} parts={flatReasoningParts} messageId={messageId} - streamPhase={streamPhase} + streamPhase={effectiveStreamPhase} onContentChange={onContentChange} /> ); @@ -1611,7 +1612,7 @@ const AssistantMessageBody = React.memo(({ key={`reasoning-${messageId}-${i}`} part={part} messageId={messageId} - streamPhase={streamPhase} + streamPhase={effectiveStreamPhase} onContentChange={onContentChange} /> ); @@ -1719,7 +1720,7 @@ const AssistantMessageBody = React.memo(({ shouldRenderActivityGroup, shouldShowStandaloneMessageActions, shouldShowTool, - streamPhase, + effectiveStreamPhase, showReasoningTraces, shouldDeferSortedInlineText, syntaxTheme, diff --git a/packages/ui/src/components/chat/message/parts/ReasoningPart.test.tsx b/packages/ui/src/components/chat/message/parts/ReasoningPart.test.tsx index 8485d181..12647cb0 100644 --- a/packages/ui/src/components/chat/message/parts/ReasoningPart.test.tsx +++ b/packages/ui/src/components/chat/message/parts/ReasoningPart.test.tsx @@ -39,7 +39,8 @@ describe('ReasoningTimelineBlock', () => { // Summary preview (beginning of text) is visible in the header expect(markup).toContain('First thought'); - // Expanded body (MarkdownRenderer) is NOT rendered while collapsed + // Historical collapsed blocks do not mount the expanded body, avoiding a + // first-frame flash when Activity reveals previously hidden rows. expect(markup).not.toContain('data-message-text-export-source'); }); diff --git a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx index 367d74a2..e483685c 100644 --- a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx @@ -34,7 +34,7 @@ const cleanReasoningText = (text: string): string => { }; const SUMMARY_MAX_CHARS = 80; -const INLINE_THRESHOLD = 120; +const EXPANDED_CONTENT_UNMOUNT_DELAY_MS = 350; const EXPANDED_CONTENT_SPRING = { type: 'spring' as const, visualDuration: 0.35, bounce: 0 }; /** Strip common markdown syntax so the header preview reads as plain text. */ @@ -89,6 +89,11 @@ type ReasoningTimelineBlockProps = { defaultExpanded?: boolean; }; +type ExpansionState = { + expanded: boolean; + source: 'auto' | 'user'; +}; + export const ReasoningTimelineBlock: React.FC = ({ text, variant, @@ -101,15 +106,22 @@ export const ReasoningTimelineBlock: React.FC = ({ }) => { const { t } = useI18n(); const hasEnded = typeof time?.end === 'number'; - const [isExpanded, setIsExpanded] = React.useState(hasEnded ? false : (defaultExpanded ?? isStreaming)); + const canAutoExpand = isStreaming && !hasEnded; + const [expansion, setExpansion] = React.useState(() => { + if (defaultExpanded === true) { + return { expanded: true, source: 'user' }; + } + return { expanded: canAutoExpand, source: 'auto' }; + }); + const isExpanded = expansion.source === 'auto' + ? canAutoExpand && expansion.expanded + : expansion.expanded; + const [shouldRenderExpandedContent, setShouldRenderExpandedContent] = React.useState(defaultExpanded === true || canAutoExpand); const contentId = React.useId(); const scrollRef = React.useRef(null); const contentRef = React.useRef(null); const contentAnimationRef = React.useRef(null); const contentMountedRef = React.useRef(false); - // Track previous isStreaming so the effect only collapses on true→false - // transitions and does NOT override defaultExpanded on initial mount. - const prevIsStreamingRef = React.useRef(isStreaming); const summary = React.useMemo(() => getReasoningSummary(text), [text]); const toggleAriaLabel = isExpanded @@ -117,9 +129,10 @@ export const ReasoningTimelineBlock: React.FC = ({ : t('chat.reasoningTrace.expandAria'); const handleToggle = React.useCallback(() => { - setIsExpanded((prev) => !prev); + setShouldRenderExpandedContent(true); + setExpansion({ expanded: !isExpanded, source: 'user' }); onContentChange?.('structural'); - }, [onContentChange]); + }, [isExpanded, onContentChange]); const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => { if (event.key === 'Enter' || event.key === ' ') { @@ -128,15 +141,17 @@ export const ReasoningTimelineBlock: React.FC = ({ } }, [handleToggle]); - React.useEffect(() => { - const wasStreaming = prevIsStreamingRef.current; - prevIsStreamingRef.current = isStreaming; - // Auto-collapse when live streaming ends or when an end timestamp arrives. - // Completed blocks initialize collapsed, so historical loads do not animate closed. - if (hasEnded || (wasStreaming && !isStreaming)) { - setIsExpanded(false); - } - }, [hasEnded, isStreaming]); + React.useLayoutEffect(() => { + setExpansion((prev) => { + if (prev.source === 'user') { + return prev; + } + if (prev.expanded === canAutoExpand) { + return prev; + } + return { expanded: canAutoExpand, source: 'auto' }; + }); + }, [canAutoExpand]); React.useEffect(() => { if (text.trim().length === 0) { @@ -151,6 +166,30 @@ export const ReasoningTimelineBlock: React.FC = ({ } }, [text, isStreaming, isExpanded]); + React.useEffect(() => { + if (isExpanded || isStreaming) { + setShouldRenderExpandedContent(true); + return; + } + + if (!shouldRenderExpandedContent) { + return; + } + + if (typeof window === 'undefined') { + setShouldRenderExpandedContent(false); + return; + } + + const timer = window.setTimeout(() => { + setShouldRenderExpandedContent(false); + }, EXPANDED_CONTENT_UNMOUNT_DELAY_MS); + + return () => { + window.clearTimeout(timer); + }; + }, [isExpanded, isStreaming, shouldRenderExpandedContent]); + React.useLayoutEffect(() => { const element = contentRef.current; if (!element) { @@ -161,10 +200,39 @@ export const ReasoningTimelineBlock: React.FC = ({ if (!contentMountedRef.current) { contentMountedRef.current = true; - element.style.height = isExpanded ? 'auto' : '0px'; - element.style.opacity = isExpanded ? '1' : '0'; - element.style.overflow = isExpanded ? 'visible' : 'hidden'; - return; + if (!isExpanded) { + element.style.height = '0px'; + element.style.opacity = '0'; + element.style.overflow = 'hidden'; + return; + } + + element.style.height = '0px'; + element.style.opacity = '0'; + element.style.overflow = 'hidden'; + + const animation = animate( + element, + { height: 'auto', opacity: 1 }, + EXPANDED_CONTENT_SPRING, + ); + contentAnimationRef.current = animation; + + void animation.finished.then(() => { + if (contentAnimationRef.current !== animation) { + return; + } + contentAnimationRef.current = null; + element.style.overflow = 'visible'; + element.style.height = 'auto'; + }).catch(() => undefined); + + return () => { + animation.stop(); + if (contentAnimationRef.current === animation) { + contentAnimationRef.current = null; + } + }; } element.style.overflow = 'hidden'; @@ -216,32 +284,6 @@ export const ReasoningTimelineBlock: React.FC = ({ return null; } - const isShort = !isStreaming && text.trim().length < INLINE_THRESHOLD; - - // Short blocks: render content directly without a collapsible toggle. - if (isShort) { - return ( -
-
- -
- {actions ? ( -
-
- {actions} -
-
- ) : null} -
- ); - } - return (
= ({
- {/* Expanded content — keep mounted so auto-collapse can animate smoothly. */} -
-
-
); };