diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/MessageList/MessageList.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/MessageList/MessageList.tsx index 01d107c64e..240210aa17 100644 --- a/autogpt_platform/frontend/src/components/contextual/Chat/components/MessageList/MessageList.tsx +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/MessageList/MessageList.tsx @@ -1,6 +1,7 @@ "use client"; import { cn } from "@/lib/utils"; +import { useEffect, useState } from "react"; import type { ChatMessageData } from "../ChatMessage/useChatMessage"; import { StreamingMessage } from "../StreamingMessage/StreamingMessage"; import { ThinkingMessage } from "../ThinkingMessage/ThinkingMessage"; @@ -31,6 +32,29 @@ export function MessageList({ isStreaming, }); + const [showThinkingMessage, setShowThinkingMessage] = useState(false); + const [thinkingComplete, setThinkingComplete] = useState(false); + + // Manage thinking message visibility and completion state + useEffect(() => { + if (isStreaming && streamingChunks.length === 0) { + // Start showing thinking message + setShowThinkingMessage(true); + setThinkingComplete(false); + } else if (streamingChunks.length > 0 && showThinkingMessage) { + // Chunks arrived - trigger completion animation + setThinkingComplete(true); + } else if (!isStreaming) { + // Streaming ended completely - reset state + setShowThinkingMessage(false); + setThinkingComplete(false); + } + }, [isStreaming, streamingChunks.length, showThinkingMessage]); + + function handleThinkingAnimationComplete() { + setShowThinkingMessage(false); + } + return (
{/* Top fade shadow */} @@ -92,10 +116,15 @@ export function MessageList({ })()} {/* Render thinking message when streaming but no chunks yet */} - {isStreaming && streamingChunks.length === 0 && } + {showThinkingMessage && ( + + )} - {/* Render streaming message if active */} - {isStreaming && streamingChunks.length > 0 && ( + {/* Render streaming message if active (wait for thinking animation to complete) */} + {isStreaming && streamingChunks.length > 0 && !showThinkingMessage && ( void; } -export function ThinkingMessage({ className }: ThinkingMessageProps) { +export function ThinkingMessage({ + className, + isComplete = false, + onAnimationComplete, +}: ThinkingMessageProps) { const [showSlowLoader, setShowSlowLoader] = useState(false); const [showCoffeeMessage, setShowCoffeeMessage] = useState(false); const timerRef = useRef(null); const coffeeTimerRef = useRef(null); - const progress = useAsymptoticProgress(showCoffeeMessage); + const delayTimerRef = useRef(null); + const { progress, isAnimationDone } = useAsymptoticProgress( + showCoffeeMessage, + isComplete, + ); useEffect(() => { if (timerRef.current === null) { timerRef.current = setTimeout(() => { setShowSlowLoader(true); - }, 8000); + }, 3000); } if (coffeeTimerRef.current === null) { coffeeTimerRef.current = setTimeout(() => { setShowCoffeeMessage(true); - }, 10000); + }, 8000); } return () => { @@ -40,6 +50,22 @@ export function ThinkingMessage({ className }: ThinkingMessageProps) { }; }, []); + // Handle completion animation delay before unmounting + useEffect(() => { + if (isAnimationDone && onAnimationComplete) { + delayTimerRef.current = setTimeout(() => { + onAnimationComplete(); + }, 200); // 200ms delay after animation completes + } + + return () => { + if (delayTimerRef.current) { + clearTimeout(delayTimerRef.current); + delayTimerRef.current = null; + } + }; + }, [isAnimationDone, onAnimationComplete]); + return (
(null); + const animationFrameRef = useRef(null); + // Handle asymptotic progress when active but not complete useEffect(() => { - if (!isActive) { - setProgress(0); - elapsedTimeRef.current = 0; + if (!isActive || isComplete) { + if (!isComplete) { + setProgress(0); + elapsedTimeRef.current = 0; + setIsAnimationDone(false); + completionStartProgressRef.current = null; + } return; } @@ -44,7 +70,48 @@ export function useAsymptoticProgress( }, intervalMs); return () => clearInterval(interval); - }, [isActive, halfLifeSeconds, maxProgress, intervalMs]); + }, [isActive, isComplete, halfLifeSeconds, maxProgress, intervalMs]); - return progress; + // Handle completion animation + useEffect(() => { + if (!isComplete) { + return; + } + + // Capture the starting progress when completion begins + if (completionStartProgressRef.current === null) { + completionStartProgressRef.current = progress; + } + + const startProgress = completionStartProgressRef.current; + const animationDuration = 300; // 300ms + const startTime = performance.now(); + + function animate(currentTime: number) { + const elapsed = currentTime - startTime; + const t = Math.min(elapsed / animationDuration, 1); + + // Cubic Ease Out from current progress to maxProgress + const easedProgress = + startProgress + (maxProgress - startProgress) * cubicEaseOut(t); + setProgress(easedProgress); + + if (t < 1) { + animationFrameRef.current = requestAnimationFrame(animate); + } else { + setProgress(maxProgress); + setIsAnimationDone(true); + } + } + + animationFrameRef.current = requestAnimationFrame(animate); + + return () => { + if (animationFrameRef.current !== null) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + }, [isComplete, maxProgress]); + + return { progress, isAnimationDone }; }