fix: align chat expansion animations

Use the same spring height animation for thinking and tool details
Reduce mismatch between auto-collapse and manual collapse behavior
Keep existing expanded state behavior unchanged
This commit is contained in:
Bohdan Triapitsyn
2026-05-18 00:58:26 +03:00
parent 9cf0fe0551
commit 233c3bc1ad
2 changed files with 196 additions and 64 deletions
@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { animate, type AnimationPlaybackControls } from 'motion';
import type { Part } from '@opencode-ai/sdk/v2'; import type { Part } from '@opencode-ai/sdk/v2';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow'; import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
@@ -29,6 +30,7 @@ const cleanReasoningText = (text: string): string => {
const SUMMARY_MAX_CHARS = 80; const SUMMARY_MAX_CHARS = 80;
const INLINE_THRESHOLD = 120; const INLINE_THRESHOLD = 120;
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. */ /** Strip common markdown syntax so the header preview reads as plain text. */
const stripMarkdown = (text: string): string => const stripMarkdown = (text: string): string =>
@@ -95,6 +97,9 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
const [isExpanded, setIsExpanded] = React.useState(defaultExpanded ?? isStreaming); const [isExpanded, setIsExpanded] = React.useState(defaultExpanded ?? isStreaming);
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 contentAnimationRef = React.useRef<AnimationPlaybackControls | null>(null);
const contentMountedRef = React.useRef(false);
// Track previous isStreaming so the effect only collapses on true→false // Track previous isStreaming so the effect only collapses on true→false
// transitions and does NOT override defaultExpanded on initial mount. // transitions and does NOT override defaultExpanded on initial mount.
const prevIsStreamingRef = React.useRef(isStreaming); const prevIsStreamingRef = React.useRef(isStreaming);
@@ -139,6 +144,67 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
} }
}, [text, isStreaming, isExpanded]); }, [text, isStreaming, isExpanded]);
React.useLayoutEffect(() => {
const element = contentRef.current;
if (!element) {
return;
}
contentAnimationRef.current?.stop();
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;
}
element.style.overflow = 'hidden';
if (isExpanded) {
element.style.height = '0px';
element.style.opacity = '0';
} else {
element.style.height = `${element.scrollHeight}px`;
element.style.opacity = '1';
}
const animation = animate(
element,
{ height: isExpanded ? 'auto' : '0px', opacity: isExpanded ? 1 : 0 },
EXPANDED_CONTENT_SPRING,
);
contentAnimationRef.current = animation;
void animation.finished.then(() => {
if (contentAnimationRef.current !== animation) {
return;
}
contentAnimationRef.current = null;
if (isExpanded) {
element.style.overflow = 'visible';
element.style.height = 'auto';
} else {
element.style.overflow = 'hidden';
}
}).catch(() => undefined);
return () => {
animation.stop();
if (contentAnimationRef.current === animation) {
contentAnimationRef.current = null;
}
};
}, [isExpanded]);
React.useEffect(() => {
return () => {
contentAnimationRef.current?.stop();
contentAnimationRef.current = null;
};
}, []);
if (!text || text.trim().length === 0) { if (!text || text.trim().length === 0) {
return null; return null;
} }
@@ -246,47 +312,48 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
{/* Expanded content — keep mounted so auto-collapse can animate smoothly. */} {/* Expanded content — keep mounted so auto-collapse can animate smoothly. */}
<div <div
ref={contentRef}
id={contentId} id={contentId}
aria-hidden={!isExpanded} aria-hidden={!isExpanded}
className={cn( style={{
'grid transition-[grid-template-rows,opacity] duration-[350ms] ease-out motion-reduce:transition-none', height: isExpanded ? 'auto' : '0px',
isExpanded ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0', opacity: isExpanded ? 1 : 0,
)} overflow: isExpanded ? 'visible' : 'hidden',
overflowAnchor: 'none',
}}
> >
<div className="min-h-0 overflow-hidden"> <div className="relative ml-2 pl-3 pb-1 pt-0.5">
<div className="relative ml-2 pl-3 pb-1 pt-0.5"> <span
<span aria-hidden="true"
aria-hidden="true" className="pointer-events-none absolute left-0 top-0 bottom-0 w-px"
className="pointer-events-none absolute left-0 top-0 bottom-0 w-px" style={{ backgroundColor: 'var(--tools-border)' }}
style={{ backgroundColor: 'var(--tools-border)' }} />
/> <ScrollableOverlay
<ScrollableOverlay ref={scrollRef}
ref={scrollRef} as="div"
as="div" outerClassName="max-h-80"
outerClassName="max-h-80" className="p-0"
className="p-0" useScrollShadow
useScrollShadow scrollShadowSize={36}
scrollShadowSize={36} userIntentOnly
userIntentOnly >
> <div data-message-text-export-source="true">
<div data-message-text-export-source="true"> <MarkdownRenderer
<MarkdownRenderer content={text}
content={text} messageId={blockId}
messageId={blockId} isAnimated={false}
isAnimated={false} isStreaming={isStreaming}
isStreaming={isStreaming} variant="reasoning"
variant="reasoning" />
/> </div>
</div> {actions ? (
{actions ? ( <div className="mt-2 mb-1 flex items-center justify-start gap-1.5" data-message-actions="true">
<div className="mt-2 mb-1 flex items-center justify-start gap-1.5" data-message-actions="true"> <div className="flex items-center gap-1.5" data-message-action-group="true">
<div className="flex items-center gap-1.5" data-message-action-group="true"> {actions}
{actions}
</div>
</div> </div>
) : null} </div>
</ScrollableOverlay> ) : null}
</div> </ScrollableOverlay>
</div> </div>
</div> </div>
</div> </div>
@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { animate, type AnimationPlaybackControls } from 'motion';
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
import { PatchDiff } from '@pierre/diffs/react'; import { PatchDiff } from '@pierre/diffs/react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -188,6 +189,7 @@ const LiveDuration: React.FC<{ start: number; end?: number; active: boolean }> =
}; };
const EXPANDED_CONTENT_TRANSITION_MS = 350; const EXPANDED_CONTENT_TRANSITION_MS = 350;
const EXPANDED_CONTENT_SPRING = { type: 'spring' as const, visualDuration: 0.35, bounce: 0 };
const useAnimatedExpandedContent = (isExpanded: boolean) => { const useAnimatedExpandedContent = (isExpanded: boolean) => {
const [shouldRender, setShouldRender] = React.useState(isExpanded); const [shouldRender, setShouldRender] = React.useState(isExpanded);
@@ -1900,15 +1902,77 @@ const ToolPart: React.FC<ToolPartProps> = ({
const onContentChangeRef = React.useRef(onContentChange); const onContentChangeRef = React.useRef(onContentChange);
onContentChangeRef.current = onContentChange; onContentChangeRef.current = onContentChange;
const expandedContentRef = React.useRef<HTMLDivElement>(null);
const expandedContentAnimationRef = React.useRef<AnimationPlaybackControls | null>(null);
const expandedContentMountedRef = React.useRef(false);
React.useEffect(() => { React.useLayoutEffect(() => {
if (!shouldNotifyStructuralChange) { if (isTaskTool) {
return; return;
} }
if (typeof isExpanded === 'boolean') {
onContentChangeRef.current?.('structural'); const element = expandedContentRef.current;
if (!element) {
return;
} }
}, [isExpanded, shouldNotifyStructuralChange]);
expandedContentAnimationRef.current?.stop();
if (!expandedContentMountedRef.current) {
expandedContentMountedRef.current = true;
element.style.height = isExpanded ? 'auto' : '0px';
element.style.opacity = isExpanded ? '1' : '0';
element.style.overflow = isExpanded ? 'visible' : 'hidden';
return;
}
element.style.overflow = 'hidden';
if (isExpanded) {
element.style.height = '0px';
element.style.opacity = '0';
} else {
element.style.height = `${element.scrollHeight}px`;
element.style.opacity = '1';
}
const animation = animate(
element,
{ height: isExpanded ? 'auto' : '0px', opacity: isExpanded ? 1 : 0 },
EXPANDED_CONTENT_SPRING,
);
expandedContentAnimationRef.current = animation;
void animation.finished.then(() => {
if (expandedContentAnimationRef.current !== animation) {
return;
}
expandedContentAnimationRef.current = null;
if (isExpanded) {
element.style.overflow = 'visible';
element.style.height = 'auto';
} else {
element.style.overflow = 'hidden';
}
if (shouldNotifyStructuralChange) {
onContentChangeRef.current?.('structural');
}
}).catch(() => undefined);
return () => {
animation.stop();
if (expandedContentAnimationRef.current === animation) {
expandedContentAnimationRef.current = null;
}
};
}, [isExpanded, isTaskTool, shouldNotifyStructuralChange]);
React.useEffect(() => {
return () => {
expandedContentAnimationRef.current?.stop();
expandedContentAnimationRef.current = null;
};
}, []);
const stateWithData = state as ToolStateWithMetadata; const stateWithData = state as ToolStateWithMetadata;
const metadata = stateWithData.metadata; const metadata = stateWithData.metadata;
@@ -2676,30 +2740,31 @@ const ToolPart: React.FC<ToolPartProps> = ({
{!isTaskTool ? ( {!isTaskTool ? (
<div <div
ref={expandedContentRef}
aria-hidden={!isExpanded} aria-hidden={!isExpanded}
className={cn( style={{
'grid transition-[grid-template-rows,opacity] duration-[350ms] ease-out motion-reduce:transition-none', height: isExpanded ? 'auto' : '0px',
isExpanded ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0', opacity: isExpanded ? 1 : 0,
)} overflow: isExpanded ? 'visible' : 'hidden',
overflowAnchor: 'none',
}}
> >
<div className="min-h-0 overflow-hidden"> {shouldRenderExpandedContent ? (
{shouldRenderExpandedContent ? ( <div className="relative ml-2 pl-3">
<div className="relative ml-2 pl-3"> <span
<span aria-hidden="true"
aria-hidden="true" className="pointer-events-none absolute left-0 top-px bottom-0 w-px"
className="pointer-events-none absolute left-0 top-px bottom-0 w-px" style={{ backgroundColor: 'var(--tools-border)' }}
style={{ backgroundColor: 'var(--tools-border)' }} />
/> <ToolExpandedContent
<ToolExpandedContent part={part}
part={part} state={state}
state={state} syntaxTheme={syntaxTheme}
syntaxTheme={syntaxTheme} currentDirectory={currentDirectory}
currentDirectory={currentDirectory} onShowPopup={onShowPopup}
onShowPopup={onShowPopup} />
/> </div>
</div> ) : null}
) : null}
</div>
</div> </div>
) : null} ) : null}
</div> </div>