diff --git a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx index 6fb906c1..e36505cd 100644 --- a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { animate, type AnimationPlaybackControls } from 'motion'; import type { Part } from '@opencode-ai/sdk/v2'; import { cn } from '@/lib/utils'; import type { ContentChangeReason } from '@/hooks/useChatAutoFollow'; @@ -29,6 +30,7 @@ const cleanReasoningText = (text: string): string => { const SUMMARY_MAX_CHARS = 80; 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. */ const stripMarkdown = (text: string): string => @@ -95,6 +97,9 @@ export const ReasoningTimelineBlock: React.FC = ({ const [isExpanded, setIsExpanded] = React.useState(defaultExpanded ?? isStreaming); const contentId = React.useId(); const scrollRef = React.useRef(null); + const contentRef = React.useRef(null); + const contentAnimationRef = React.useRef(null); + const contentMountedRef = React.useRef(false); // Track previous isStreaming so the effect only collapses on true→false // transitions and does NOT override defaultExpanded on initial mount. const prevIsStreamingRef = React.useRef(isStreaming); @@ -139,6 +144,67 @@ export const ReasoningTimelineBlock: React.FC = ({ } }, [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) { return null; } @@ -246,47 +312,48 @@ export const ReasoningTimelineBlock: React.FC = ({ {/* Expanded content — keep mounted so auto-collapse can animate smoothly. */}
-
-
-
diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index ba89a771..0a48e596 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import { animate, type AnimationPlaybackControls } from 'motion'; import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; import { PatchDiff } from '@pierre/diffs/react'; 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_SPRING = { type: 'spring' as const, visualDuration: 0.35, bounce: 0 }; const useAnimatedExpandedContent = (isExpanded: boolean) => { const [shouldRender, setShouldRender] = React.useState(isExpanded); @@ -1900,15 +1902,77 @@ const ToolPart: React.FC = ({ const onContentChangeRef = React.useRef(onContentChange); onContentChangeRef.current = onContentChange; + const expandedContentRef = React.useRef(null); + const expandedContentAnimationRef = React.useRef(null); + const expandedContentMountedRef = React.useRef(false); - React.useEffect(() => { - if (!shouldNotifyStructuralChange) { + React.useLayoutEffect(() => { + if (isTaskTool) { 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 metadata = stateWithData.metadata; @@ -2676,30 +2740,31 @@ const ToolPart: React.FC = ({ {!isTaskTool ? (
-
- {shouldRenderExpandedContent ? ( -
-
- ) : null} -
+ {shouldRenderExpandedContent ? ( +
+
+ ) : null}
) : null}