fix: smooth tool expansion animation

This commit is contained in:
Bohdan Triapitsyn
2026-05-27 00:41:04 +03:00
parent 52ffe9daef
commit e8e4df5ea9
2 changed files with 26 additions and 21 deletions
@@ -34,8 +34,8 @@ const cleanReasoningText = (text: string): string => {
}; };
const SUMMARY_MAX_CHARS = 80; const SUMMARY_MAX_CHARS = 80;
const EXPANDED_CONTENT_UNMOUNT_DELAY_MS = 350; const EXPANDED_CONTENT_UNMOUNT_DELAY_MS = 200;
const EXPANDED_CONTENT_SPRING = { type: 'spring' as const, visualDuration: 0.35, bounce: 0 }; const EXPANDED_CONTENT_TRANSITION = { duration: 0.2, ease: 'easeOut' as const };
/** 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 =>
@@ -202,19 +202,17 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
contentMountedRef.current = true; contentMountedRef.current = true;
if (!isExpanded) { if (!isExpanded) {
element.style.height = '0px'; element.style.height = '0px';
element.style.opacity = '0';
element.style.overflow = 'hidden'; element.style.overflow = 'hidden';
return; return;
} }
element.style.height = '0px'; element.style.height = '0px';
element.style.opacity = '0';
element.style.overflow = 'hidden'; element.style.overflow = 'hidden';
const animation = animate( const animation = animate(
element, element,
{ height: 'auto', opacity: 1 }, { height: 'auto' },
EXPANDED_CONTENT_SPRING, EXPANDED_CONTENT_TRANSITION,
); );
contentAnimationRef.current = animation; contentAnimationRef.current = animation;
@@ -239,16 +237,14 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
if (isExpanded) { if (isExpanded) {
element.style.height = '0px'; element.style.height = '0px';
element.style.opacity = '0';
} else { } else {
element.style.height = `${element.scrollHeight}px`; element.style.height = `${element.scrollHeight}px`;
element.style.opacity = '1';
} }
const animation = animate( const animation = animate(
element, element,
{ height: isExpanded ? 'auto' : '0px', opacity: isExpanded ? 1 : 0 }, { height: isExpanded ? 'auto' : '0px' },
EXPANDED_CONTENT_SPRING, EXPANDED_CONTENT_TRANSITION,
); );
contentAnimationRef.current = animation; contentAnimationRef.current = animation;
@@ -366,12 +362,18 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
aria-hidden={!isExpanded} aria-hidden={!isExpanded}
style={{ style={{
height: isExpanded ? 'auto' : '0px', height: isExpanded ? 'auto' : '0px',
opacity: isExpanded ? 1 : 0,
overflow: isExpanded ? 'visible' : 'hidden', overflow: isExpanded ? 'visible' : 'hidden',
overflowAnchor: 'none', overflowAnchor: 'none',
}} }}
> >
<div className="relative ml-2 pl-3 pb-1 pt-0.5"> <div
className="relative ml-2 pl-3 pb-1 pt-0.5"
style={{
opacity: isExpanded ? 1 : 0,
transform: isExpanded ? 'translateY(0)' : 'translateY(-4px)',
transition: 'opacity 180ms ease-out, transform 180ms ease-out',
}}
>
<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"
@@ -197,8 +197,8 @@ const LiveDuration: React.FC<{ start: number; end?: number; active: boolean }> =
return <>{formatDuration(start, end, now)}</>; return <>{formatDuration(start, end, now)}</>;
}; };
const EXPANDED_CONTENT_TRANSITION_MS = 350; const EXPANDED_CONTENT_TRANSITION_MS = 200;
const EXPANDED_CONTENT_SPRING = { type: 'spring' as const, visualDuration: 0.35, bounce: 0 }; const EXPANDED_CONTENT_TRANSITION = { duration: 0.2, ease: 'easeOut' as const };
const useAnimatedExpandedContent = (isExpanded: boolean) => { const useAnimatedExpandedContent = (isExpanded: boolean) => {
const [shouldRender, setShouldRender] = React.useState(isExpanded); const [shouldRender, setShouldRender] = React.useState(isExpanded);
@@ -1876,7 +1876,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
if (!expandedContentMountedRef.current) { if (!expandedContentMountedRef.current) {
expandedContentMountedRef.current = true; expandedContentMountedRef.current = true;
element.style.height = isExpanded ? 'auto' : '0px'; element.style.height = isExpanded ? 'auto' : '0px';
element.style.opacity = isExpanded ? '1' : '0';
element.style.overflow = isExpanded ? 'visible' : 'hidden'; element.style.overflow = isExpanded ? 'visible' : 'hidden';
return; return;
} }
@@ -1885,16 +1884,14 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
if (isExpanded) { if (isExpanded) {
element.style.height = '0px'; element.style.height = '0px';
element.style.opacity = '0';
} else { } else {
element.style.height = `${element.scrollHeight}px`; element.style.height = `${element.scrollHeight}px`;
element.style.opacity = '1';
} }
const animation = animate( const animation = animate(
element, element,
{ height: isExpanded ? 'auto' : '0px', opacity: isExpanded ? 1 : 0 }, { height: isExpanded ? 'auto' : '0px' },
EXPANDED_CONTENT_SPRING, EXPANDED_CONTENT_TRANSITION,
); );
expandedContentAnimationRef.current = animation; expandedContentAnimationRef.current = animation;
@@ -2725,13 +2722,19 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
aria-hidden={!isExpanded} aria-hidden={!isExpanded}
style={{ style={{
height: isExpanded ? 'auto' : '0px', height: isExpanded ? 'auto' : '0px',
opacity: isExpanded ? 1 : 0,
overflow: isExpanded ? 'visible' : 'hidden', overflow: isExpanded ? 'visible' : 'hidden',
overflowAnchor: 'none', overflowAnchor: 'none',
}} }}
> >
{shouldRenderExpandedContent ? ( {shouldRenderExpandedContent ? (
<div className="relative ml-2 pl-3"> <div
className="relative ml-2 pl-3"
style={{
opacity: isExpanded ? 1 : 0,
transform: isExpanded ? 'translateY(0)' : 'translateY(-4px)',
transition: 'opacity 180ms ease-out, transform 180ms ease-out',
}}
>
<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"