fix(ui): prevent completed reasoning collapse animation

This commit is contained in:
Bohdan Triapitsyn
2026-05-21 15:45:44 +03:00
parent 40210917c2
commit 3f4259379d
@@ -97,6 +97,8 @@ 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);
@@ -107,11 +109,12 @@ 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 = isExpanded const toggleAriaLabel = effectiveIsExpanded
? 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]);
@@ -141,10 +144,10 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
}, [onContentChange, text]); }, [onContentChange, text]);
React.useEffect(() => { React.useEffect(() => {
if (isStreaming && isExpanded && scrollRef.current) { if (isStreaming && effectiveIsExpanded && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight; scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
} }
}, [text, isStreaming, isExpanded]); }, [text, isStreaming, effectiveIsExpanded]);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
const element = contentRef.current; const element = contentRef.current;
@@ -154,17 +157,25 @@ 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 = isExpanded ? 'auto' : '0px'; element.style.height = effectiveIsExpanded ? 'auto' : '0px';
element.style.opacity = isExpanded ? '1' : '0'; element.style.opacity = effectiveIsExpanded ? '1' : '0';
element.style.overflow = isExpanded ? 'visible' : 'hidden'; element.style.overflow = effectiveIsExpanded ? 'visible' : 'hidden';
return; return;
} }
element.style.overflow = 'hidden'; element.style.overflow = 'hidden';
if (isExpanded) { if (effectiveIsExpanded) {
element.style.height = '0px'; element.style.height = '0px';
element.style.opacity = '0'; element.style.opacity = '0';
} else { } else {
@@ -174,7 +185,7 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
const animation = animate( const animation = animate(
element, element,
{ height: isExpanded ? 'auto' : '0px', opacity: isExpanded ? 1 : 0 }, { height: effectiveIsExpanded ? 'auto' : '0px', opacity: effectiveIsExpanded ? 1 : 0 },
EXPANDED_CONTENT_SPRING, EXPANDED_CONTENT_SPRING,
); );
contentAnimationRef.current = animation; contentAnimationRef.current = animation;
@@ -184,7 +195,7 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
return; return;
} }
contentAnimationRef.current = null; contentAnimationRef.current = null;
if (isExpanded) { if (effectiveIsExpanded) {
element.style.overflow = 'visible'; element.style.overflow = 'visible';
element.style.height = 'auto'; element.style.height = 'auto';
} else { } else {
@@ -198,7 +209,7 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
contentAnimationRef.current = null; contentAnimationRef.current = null;
} }
}; };
}, [isExpanded]); }, [effectiveIsExpanded, hasEnded]);
React.useEffect(() => { React.useEffect(() => {
return () => { return () => {
@@ -242,7 +253,7 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
aria-expanded={isExpanded} aria-expanded={effectiveIsExpanded}
aria-controls={contentId} aria-controls={contentId}
aria-label={toggleAriaLabel} aria-label={toggleAriaLabel}
className={cn( className={cn(
@@ -256,8 +267,8 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
<div <div
className={cn( className={cn(
'absolute inset-0 transition-opacity', 'absolute inset-0 transition-opacity',
isExpanded && 'opacity-0', effectiveIsExpanded && 'opacity-0',
!isExpanded && 'group-hover/tool:opacity-0', !effectiveIsExpanded && 'group-hover/tool:opacity-0',
)} )}
style={{ color: 'var(--tools-icon)' }} style={{ color: 'var(--tools-icon)' }}
> >
@@ -266,12 +277,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',
isExpanded && 'opacity-100', effectiveIsExpanded && 'opacity-100',
!isExpanded && 'opacity-0 group-hover/tool:opacity-100', !effectiveIsExpanded && 'opacity-0 group-hover/tool:opacity-100',
)} )}
style={{ color: 'var(--tools-icon)' }} style={{ color: 'var(--tools-icon)' }}
> >
{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" />} {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" />}
</div> </div>
</div> </div>
@@ -280,7 +291,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>
) : isExpanded ? ( ) : effectiveIsExpanded ? (
<span <span
className="typography-meta font-medium" className="typography-meta font-medium"
style={{ color: 'var(--tools-title)' }} style={{ color: 'var(--tools-title)' }}
@@ -298,7 +309,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 && !isExpanded && summary ? ( {!isStreaming && !effectiveIsExpanded && 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 }}
@@ -316,11 +327,11 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
<div <div
ref={contentRef} ref={contentRef}
id={contentId} id={contentId}
aria-hidden={!isExpanded} aria-hidden={!effectiveIsExpanded}
style={{ style={{
height: isExpanded ? 'auto' : '0px', height: effectiveIsExpanded ? 'auto' : '0px',
opacity: isExpanded ? 1 : 0, opacity: effectiveIsExpanded ? 1 : 0,
overflow: isExpanded ? 'visible' : 'hidden', overflow: effectiveIsExpanded ? 'visible' : 'hidden',
overflowAnchor: 'none', overflowAnchor: 'none',
}} }}
> >