fix(ui): preserve reasoning collapse animation

This commit is contained in:
Bohdan Triapitsyn
2026-05-21 15:45:44 +03:00
parent 3f4259379d
commit 80cf598d9f
3 changed files with 40 additions and 40 deletions
@@ -1595,6 +1595,7 @@ const AssistantMessageBody = React.memo(({
key={`reasoning-merged-${messageId}`} key={`reasoning-merged-${messageId}`}
parts={flatReasoningParts} parts={flatReasoningParts}
messageId={messageId} messageId={messageId}
streamPhase={streamPhase}
onContentChange={onContentChange} onContentChange={onContentChange}
/> />
); );
@@ -1606,6 +1607,7 @@ const AssistantMessageBody = React.memo(({
key={`reasoning-${messageId}-${i}`} key={`reasoning-${messageId}-${i}`}
part={part} part={part}
messageId={messageId} messageId={messageId}
streamPhase={streamPhase}
onContentChange={onContentChange} onContentChange={onContentChange}
/> />
); );
@@ -803,14 +803,16 @@ export const StaticToolRow = React.memo(StaticToolRowInner, (prev, next) => {
/** /**
* Inline reasoning text block — rendered as dimmed italic markdown. * Inline reasoning text block — rendered as dimmed italic markdown.
*/ */
const InlineReasoningBlock = React.memo(({ activity, onContentChange }: { const InlineReasoningBlock = React.memo(({ activity, onContentChange, streamPhase }: {
activity: TurnActivityPart; activity: TurnActivityPart;
onContentChange?: (reason?: ContentChangeReason) => void; onContentChange?: (reason?: ContentChangeReason) => void;
streamPhase: StreamPhase;
}) => { }) => {
return ( return (
<ReasoningPart <ReasoningPart
part={activity.part} part={activity.part}
messageId={activity.messageId} messageId={activity.messageId}
streamPhase={streamPhase}
onContentChange={onContentChange} onContentChange={onContentChange}
/> />
); );
@@ -845,13 +847,12 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
onToggleTool, onToggleTool,
onShowPopup, onShowPopup,
onContentChange, onContentChange,
streamPhase: _streamPhase, streamPhase,
showHeader, showHeader,
animateRows = true, animateRows = true,
animatedToolIds, animatedToolIds,
renderJustificationActions, renderJustificationActions,
}) => { }) => {
void _streamPhase;
const previewCount = showHeader && !isExpanded const previewCount = showHeader && !isExpanded
? Math.max(0, Math.floor(collapsedPreviewCount)) ? Math.max(0, Math.floor(collapsedPreviewCount))
: 0; : 0;
@@ -905,6 +906,7 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
<> <>
<InlineReasoningBlock <InlineReasoningBlock
activity={row.activity} activity={row.activity}
streamPhase={streamPhase}
onContentChange={onContentChange} onContentChange={onContentChange}
/> />
</> </>
@@ -10,6 +10,7 @@ import { useI18n } from '@/lib/i18n';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { MarkdownRenderer } from '../../MarkdownRenderer'; import { MarkdownRenderer } from '../../MarkdownRenderer';
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle'; import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
import type { StreamPhase } from '../types';
type PartWithText = Part & { text?: string; content?: string; time?: { start?: number; end?: number } }; type PartWithText = Part & { text?: string; content?: string; time?: { start?: number; end?: number } };
@@ -97,8 +98,6 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
const { t } = useI18n(); const { t } = useI18n();
const hasEnded = typeof time?.end === 'number'; const hasEnded = typeof time?.end === 'number';
const [isExpanded, setIsExpanded] = React.useState(hasEnded ? false : (defaultExpanded ?? isStreaming)); 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 contentId = React.useId();
const scrollRef = React.useRef<HTMLElement>(null); const scrollRef = React.useRef<HTMLElement>(null);
const contentRef = React.useRef<HTMLDivElement>(null); const contentRef = React.useRef<HTMLDivElement>(null);
@@ -109,12 +108,11 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
const prevIsStreamingRef = React.useRef(isStreaming); const prevIsStreamingRef = React.useRef(isStreaming);
const summary = React.useMemo(() => getReasoningSummary(text), [text]); const summary = React.useMemo(() => getReasoningSummary(text), [text]);
const toggleAriaLabel = effectiveIsExpanded const toggleAriaLabel = isExpanded
? t('chat.reasoningTrace.collapseAria') ? t('chat.reasoningTrace.collapseAria')
: t('chat.reasoningTrace.expandAria'); : t('chat.reasoningTrace.expandAria');
const handleToggle = React.useCallback(() => { const handleToggle = React.useCallback(() => {
userToggledRef.current = true;
setIsExpanded((prev) => !prev); setIsExpanded((prev) => !prev);
onContentChange?.('structural'); onContentChange?.('structural');
}, [onContentChange]); }, [onContentChange]);
@@ -129,8 +127,8 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
React.useEffect(() => { React.useEffect(() => {
const wasStreaming = prevIsStreamingRef.current; const wasStreaming = prevIsStreamingRef.current;
prevIsStreamingRef.current = isStreaming; prevIsStreamingRef.current = isStreaming;
// Auto-collapse only when streaming ends (true → false). // Auto-collapse when live streaming ends or when an end timestamp arrives.
// Do not fire on mount so that defaultExpanded is respected. // Completed blocks initialize collapsed, so historical loads do not animate closed.
if (hasEnded || (wasStreaming && !isStreaming)) { if (hasEnded || (wasStreaming && !isStreaming)) {
setIsExpanded(false); setIsExpanded(false);
} }
@@ -144,10 +142,10 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
}, [onContentChange, text]); }, [onContentChange, text]);
React.useEffect(() => { React.useEffect(() => {
if (isStreaming && effectiveIsExpanded && scrollRef.current) { if (isStreaming && isExpanded && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight; scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
} }
}, [text, isStreaming, effectiveIsExpanded]); }, [text, isStreaming, isExpanded]);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
const element = contentRef.current; const element = contentRef.current;
@@ -157,25 +155,17 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
contentAnimationRef.current?.stop(); 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) { if (!contentMountedRef.current) {
contentMountedRef.current = true; contentMountedRef.current = true;
element.style.height = effectiveIsExpanded ? 'auto' : '0px'; element.style.height = isExpanded ? 'auto' : '0px';
element.style.opacity = effectiveIsExpanded ? '1' : '0'; element.style.opacity = isExpanded ? '1' : '0';
element.style.overflow = effectiveIsExpanded ? 'visible' : 'hidden'; element.style.overflow = isExpanded ? 'visible' : 'hidden';
return; return;
} }
element.style.overflow = 'hidden'; element.style.overflow = 'hidden';
if (effectiveIsExpanded) { if (isExpanded) {
element.style.height = '0px'; element.style.height = '0px';
element.style.opacity = '0'; element.style.opacity = '0';
} else { } else {
@@ -185,7 +175,7 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
const animation = animate( const animation = animate(
element, element,
{ height: effectiveIsExpanded ? 'auto' : '0px', opacity: effectiveIsExpanded ? 1 : 0 }, { height: isExpanded ? 'auto' : '0px', opacity: isExpanded ? 1 : 0 },
EXPANDED_CONTENT_SPRING, EXPANDED_CONTENT_SPRING,
); );
contentAnimationRef.current = animation; contentAnimationRef.current = animation;
@@ -195,7 +185,7 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
return; return;
} }
contentAnimationRef.current = null; contentAnimationRef.current = null;
if (effectiveIsExpanded) { if (isExpanded) {
element.style.overflow = 'visible'; element.style.overflow = 'visible';
element.style.height = 'auto'; element.style.height = 'auto';
} else { } else {
@@ -209,7 +199,7 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
contentAnimationRef.current = null; contentAnimationRef.current = null;
} }
}; };
}, [effectiveIsExpanded, hasEnded]); }, [isExpanded]);
React.useEffect(() => { React.useEffect(() => {
return () => { return () => {
@@ -253,7 +243,7 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
aria-expanded={effectiveIsExpanded} aria-expanded={isExpanded}
aria-controls={contentId} aria-controls={contentId}
aria-label={toggleAriaLabel} aria-label={toggleAriaLabel}
className={cn( className={cn(
@@ -267,8 +257,8 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
<div <div
className={cn( className={cn(
'absolute inset-0 transition-opacity', 'absolute inset-0 transition-opacity',
effectiveIsExpanded && 'opacity-0', isExpanded && 'opacity-0',
!effectiveIsExpanded && 'group-hover/tool:opacity-0', !isExpanded && 'group-hover/tool:opacity-0',
)} )}
style={{ color: 'var(--tools-icon)' }} style={{ color: 'var(--tools-icon)' }}
> >
@@ -277,12 +267,12 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
<div <div
className={cn( className={cn(
'absolute inset-0 transition-opacity flex items-center justify-center', 'absolute inset-0 transition-opacity flex items-center justify-center',
effectiveIsExpanded && 'opacity-100', isExpanded && 'opacity-100',
!effectiveIsExpanded && 'opacity-0 group-hover/tool:opacity-100', !isExpanded && 'opacity-0 group-hover/tool:opacity-100',
)} )}
style={{ color: 'var(--tools-icon)' }} style={{ color: 'var(--tools-icon)' }}
> >
{effectiveIsExpanded ? <Icon name="arrow-down-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-right-s" className="h-3.5 w-3.5" />} {isExpanded ? <Icon name="arrow-down-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-right-s" className="h-3.5 w-3.5" />}
</div> </div>
</div> </div>
@@ -291,7 +281,7 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
<span>{t(variant === 'justification' ? 'chat.reasoningTrace.justification' : 'chat.reasoningTrace.thinking')}</span> <span>{t(variant === 'justification' ? 'chat.reasoningTrace.justification' : 'chat.reasoningTrace.thinking')}</span>
<BusyDots /> <BusyDots />
</span> </span>
) : effectiveIsExpanded ? ( ) : isExpanded ? (
<span <span
className="typography-meta font-medium" className="typography-meta font-medium"
style={{ color: 'var(--tools-title)' }} style={{ color: 'var(--tools-title)' }}
@@ -309,7 +299,7 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
</div> </div>
<div className="flex items-center gap-1 flex-1 min-w-0 typography-meta" style={{ color: 'var(--tools-description)' }}> <div className="flex items-center gap-1 flex-1 min-w-0 typography-meta" style={{ color: 'var(--tools-description)' }}>
{!isStreaming && !effectiveIsExpanded && summary ? ( {!isStreaming && !isExpanded && summary ? (
<span <span
className="min-w-0 truncate typography-meta" className="min-w-0 truncate typography-meta"
style={{ color: 'var(--tools-description)', opacity: 0.8 }} style={{ color: 'var(--tools-description)', opacity: 0.8 }}
@@ -327,11 +317,11 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
<div <div
ref={contentRef} ref={contentRef}
id={contentId} id={contentId}
aria-hidden={!effectiveIsExpanded} aria-hidden={!isExpanded}
style={{ style={{
height: effectiveIsExpanded ? 'auto' : '0px', height: isExpanded ? 'auto' : '0px',
opacity: effectiveIsExpanded ? 1 : 0, opacity: isExpanded ? 1 : 0,
overflow: effectiveIsExpanded ? 'visible' : 'hidden', overflow: isExpanded ? 'visible' : 'hidden',
overflowAnchor: 'none', overflowAnchor: 'none',
}} }}
> >
@@ -377,19 +367,22 @@ type ReasoningPartProps = {
part: Part; part: Part;
onContentChange?: (reason?: ContentChangeReason) => void; onContentChange?: (reason?: ContentChangeReason) => void;
messageId: string; messageId: string;
streamPhase?: StreamPhase;
}; };
const ReasoningPart = React.memo(({ const ReasoningPart = React.memo(({
part, part,
onContentChange, onContentChange,
messageId, messageId,
streamPhase,
}: ReasoningPartProps) => { }: ReasoningPartProps) => {
const chatRenderMode = useUIStore((state) => state.chatRenderMode); const chatRenderMode = useUIStore((state) => state.chatRenderMode);
const partWithText = part as PartWithText; const partWithText = part as PartWithText;
const rawText = partWithText.text || partWithText.content || ''; const rawText = partWithText.text || partWithText.content || '';
const textContent = React.useMemo(() => cleanReasoningText(rawText), [rawText]); const textContent = React.useMemo(() => cleanReasoningText(rawText), [rawText]);
const time = partWithText.time; 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({ const throttledText = useStreamingTextThrottle({
text: textContent, text: textContent,
isStreaming, isStreaming,
@@ -418,6 +411,7 @@ type MergedReasoningPartProps = {
parts: Part[]; parts: Part[];
onContentChange?: (reason?: ContentChangeReason) => void; onContentChange?: (reason?: ContentChangeReason) => void;
messageId: string; messageId: string;
streamPhase?: StreamPhase;
}; };
/** /**
@@ -429,6 +423,7 @@ export const MergedReasoningPart = React.memo(({
parts, parts,
onContentChange, onContentChange,
messageId, messageId,
streamPhase,
}: MergedReasoningPartProps) => { }: MergedReasoningPartProps) => {
const chatRenderMode = useUIStore((state) => state.chatRenderMode); const chatRenderMode = useUIStore((state) => state.chatRenderMode);
@@ -463,7 +458,8 @@ export const MergedReasoningPart = React.memo(({
return earliestStart !== undefined ? { start: earliestStart, end: latestEnd } : undefined; return earliestStart !== undefined ? { start: earliestStart, end: latestEnd } : undefined;
}, [parts]); }, [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', (part) => typeof (part as PartWithText).time?.end !== 'number',
); );