From 80cf598d9fa63a8a2671a50e710f9ff00608051e Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 21 May 2026 15:42:35 +0300 Subject: [PATCH] fix(ui): preserve reasoning collapse animation --- .../components/chat/message/MessageBody.tsx | 2 + .../chat/message/parts/ProgressiveGroup.tsx | 8 ++- .../chat/message/parts/ReasoningPart.tsx | 70 +++++++++---------- 3 files changed, 40 insertions(+), 40 deletions(-) diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index 05ef2c75..76bdd79e 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -1595,6 +1595,7 @@ const AssistantMessageBody = React.memo(({ key={`reasoning-merged-${messageId}`} parts={flatReasoningParts} messageId={messageId} + streamPhase={streamPhase} onContentChange={onContentChange} /> ); @@ -1606,6 +1607,7 @@ const AssistantMessageBody = React.memo(({ key={`reasoning-${messageId}-${i}`} part={part} messageId={messageId} + streamPhase={streamPhase} onContentChange={onContentChange} /> ); diff --git a/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx b/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx index 929e1cbe..5974d7e8 100644 --- a/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx +++ b/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx @@ -803,14 +803,16 @@ export const StaticToolRow = React.memo(StaticToolRowInner, (prev, next) => { /** * Inline reasoning text block — rendered as dimmed italic markdown. */ -const InlineReasoningBlock = React.memo(({ activity, onContentChange }: { +const InlineReasoningBlock = React.memo(({ activity, onContentChange, streamPhase }: { activity: TurnActivityPart; onContentChange?: (reason?: ContentChangeReason) => void; + streamPhase: StreamPhase; }) => { return ( ); @@ -845,13 +847,12 @@ const ProgressiveGroup: React.FC = ({ onToggleTool, onShowPopup, onContentChange, - streamPhase: _streamPhase, + streamPhase, showHeader, animateRows = true, animatedToolIds, renderJustificationActions, }) => { - void _streamPhase; const previewCount = showHeader && !isExpanded ? Math.max(0, Math.floor(collapsedPreviewCount)) : 0; @@ -905,6 +906,7 @@ const ProgressiveGroup: React.FC = ({ <> diff --git a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx index 272552fe..fdd0f289 100644 --- a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx @@ -10,6 +10,7 @@ import { useI18n } from '@/lib/i18n'; import { useUIStore } from '@/stores/useUIStore'; import { MarkdownRenderer } from '../../MarkdownRenderer'; import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle'; +import type { StreamPhase } from '../types'; type PartWithText = Part & { text?: string; content?: string; time?: { start?: number; end?: number } }; @@ -97,8 +98,6 @@ export const ReasoningTimelineBlock: React.FC = ({ const { t } = useI18n(); const hasEnded = typeof time?.end === 'number'; const [isExpanded, setIsExpanded] = React.useState(hasEnded ? false : (defaultExpanded ?? isStreaming)); - const userToggledRef = React.useRef(false); - const effectiveIsExpanded = hasEnded && !userToggledRef.current ? false : isExpanded; const contentId = React.useId(); const scrollRef = React.useRef(null); const contentRef = React.useRef(null); @@ -109,12 +108,11 @@ export const ReasoningTimelineBlock: React.FC = ({ const prevIsStreamingRef = React.useRef(isStreaming); const summary = React.useMemo(() => getReasoningSummary(text), [text]); - const toggleAriaLabel = effectiveIsExpanded + const toggleAriaLabel = isExpanded ? t('chat.reasoningTrace.collapseAria') : t('chat.reasoningTrace.expandAria'); const handleToggle = React.useCallback(() => { - userToggledRef.current = true; setIsExpanded((prev) => !prev); onContentChange?.('structural'); }, [onContentChange]); @@ -129,8 +127,8 @@ export const ReasoningTimelineBlock: React.FC = ({ React.useEffect(() => { const wasStreaming = prevIsStreamingRef.current; prevIsStreamingRef.current = isStreaming; - // Auto-collapse only when streaming ends (true → false). - // Do not fire on mount so that defaultExpanded is respected. + // 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); } @@ -144,10 +142,10 @@ export const ReasoningTimelineBlock: React.FC = ({ }, [onContentChange, text]); React.useEffect(() => { - if (isStreaming && effectiveIsExpanded && scrollRef.current) { + if (isStreaming && isExpanded && scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } - }, [text, isStreaming, effectiveIsExpanded]); + }, [text, isStreaming, isExpanded]); React.useLayoutEffect(() => { const element = contentRef.current; @@ -157,25 +155,17 @@ export const ReasoningTimelineBlock: React.FC = ({ contentAnimationRef.current?.stop(); - if (hasEnded && !userToggledRef.current) { - element.style.height = '0px'; - element.style.opacity = '0'; - element.style.overflow = 'hidden'; - contentMountedRef.current = true; - return; - } - if (!contentMountedRef.current) { contentMountedRef.current = true; - element.style.height = effectiveIsExpanded ? 'auto' : '0px'; - element.style.opacity = effectiveIsExpanded ? '1' : '0'; - element.style.overflow = effectiveIsExpanded ? 'visible' : 'hidden'; + element.style.height = isExpanded ? 'auto' : '0px'; + element.style.opacity = isExpanded ? '1' : '0'; + element.style.overflow = isExpanded ? 'visible' : 'hidden'; return; } element.style.overflow = 'hidden'; - if (effectiveIsExpanded) { + if (isExpanded) { element.style.height = '0px'; element.style.opacity = '0'; } else { @@ -185,7 +175,7 @@ export const ReasoningTimelineBlock: React.FC = ({ const animation = animate( element, - { height: effectiveIsExpanded ? 'auto' : '0px', opacity: effectiveIsExpanded ? 1 : 0 }, + { height: isExpanded ? 'auto' : '0px', opacity: isExpanded ? 1 : 0 }, EXPANDED_CONTENT_SPRING, ); contentAnimationRef.current = animation; @@ -195,7 +185,7 @@ export const ReasoningTimelineBlock: React.FC = ({ return; } contentAnimationRef.current = null; - if (effectiveIsExpanded) { + if (isExpanded) { element.style.overflow = 'visible'; element.style.height = 'auto'; } else { @@ -209,7 +199,7 @@ export const ReasoningTimelineBlock: React.FC = ({ contentAnimationRef.current = null; } }; - }, [effectiveIsExpanded, hasEnded]); + }, [isExpanded]); React.useEffect(() => { return () => { @@ -253,7 +243,7 @@ export const ReasoningTimelineBlock: React.FC = ({
= ({
@@ -277,12 +267,12 @@ export const ReasoningTimelineBlock: React.FC = ({
- {effectiveIsExpanded ? : } + {isExpanded ? : }
@@ -291,7 +281,7 @@ export const ReasoningTimelineBlock: React.FC = ({ {t(variant === 'justification' ? 'chat.reasoningTrace.justification' : 'chat.reasoningTrace.thinking')} - ) : effectiveIsExpanded ? ( + ) : isExpanded ? ( = ({
- {!isStreaming && !effectiveIsExpanded && summary ? ( + {!isStreaming && !isExpanded && summary ? ( = ({
@@ -377,19 +367,22 @@ type ReasoningPartProps = { part: Part; onContentChange?: (reason?: ContentChangeReason) => void; messageId: string; + streamPhase?: StreamPhase; }; const ReasoningPart = React.memo(({ part, onContentChange, messageId, + streamPhase, }: ReasoningPartProps) => { const chatRenderMode = useUIStore((state) => state.chatRenderMode); const partWithText = part as PartWithText; const rawText = partWithText.text || partWithText.content || ''; const textContent = React.useMemo(() => cleanReasoningText(rawText), [rawText]); const time = partWithText.time; - const isStreaming = chatRenderMode === 'live' && typeof time?.end !== 'number'; + const canBeStreaming = streamPhase === undefined || streamPhase !== 'completed'; + const isStreaming = chatRenderMode === 'live' && canBeStreaming && typeof time?.end !== 'number'; const throttledText = useStreamingTextThrottle({ text: textContent, isStreaming, @@ -418,6 +411,7 @@ type MergedReasoningPartProps = { parts: Part[]; onContentChange?: (reason?: ContentChangeReason) => void; messageId: string; + streamPhase?: StreamPhase; }; /** @@ -429,6 +423,7 @@ export const MergedReasoningPart = React.memo(({ parts, onContentChange, messageId, + streamPhase, }: MergedReasoningPartProps) => { const chatRenderMode = useUIStore((state) => state.chatRenderMode); @@ -463,7 +458,8 @@ export const MergedReasoningPart = React.memo(({ return earliestStart !== undefined ? { start: earliestStart, end: latestEnd } : undefined; }, [parts]); - const isStreaming = chatRenderMode === 'live' && parts.some( + const canBeStreaming = streamPhase === undefined || streamPhase !== 'completed'; + const isStreaming = chatRenderMode === 'live' && canBeStreaming && parts.some( (part) => typeof (part as PartWithText).time?.end !== 'number', );