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:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user