|
1 | 1 | import type { Message } from 'ai'; |
2 | | -import { Fragment, useCallback, useEffect, useState } from 'react'; |
| 2 | +import { Fragment, useCallback, useEffect, useRef, useState } from 'react'; |
3 | 3 | import { atom } from 'nanostores'; |
4 | 4 | import { useStore } from '@nanostores/react'; |
| 5 | +import { motion, AnimatePresence } from 'framer-motion'; |
5 | 6 | import { cn } from '~/utils/cn'; |
6 | 7 | import { AssistantMessage } from './AssistantMessage'; |
7 | 8 | import { UserMessage } from './UserMessage'; |
@@ -211,6 +212,9 @@ export function Messages(props: MessagesProps) { |
211 | 212 | const { id, isStreaming = false, messages = [], ref } = props; |
212 | 213 | const location = useLocation(); |
213 | 214 |
|
| 215 | + // Track the initial message count so we only animate messages that arrive after mount |
| 216 | + const initialMessageCountRef = useRef(messages.length); |
| 217 | + |
214 | 218 | // Subscribe to branch state atoms |
215 | 219 | const branchParent = useStore(currentBranchParentAtom); |
216 | 220 | const branchCounts = useStore(messageBranchCountsAtom); |
@@ -298,60 +302,80 @@ export function Messages(props: MessagesProps) { |
298 | 302 | return ( |
299 | 303 | <div id={id} className={props.className} ref={ref}> |
300 | 304 | {branchParent && <BranchIndicator parentChatId={branchParent.parentChatId} />} |
301 | | - {messages.length > 0 |
302 | | - ? messages.map((message, index) => { |
303 | | - const { role, content, id: messageId, annotations, parts } = message; |
304 | | - const isUserMessage = role === 'user'; |
305 | | - const isFirst = index === 0; |
306 | | - const isHidden = annotations?.includes('hidden'); |
307 | | - |
308 | | - if (isHidden) { |
309 | | - return <Fragment key={message.id} />; |
310 | | - } |
311 | | - |
312 | | - const branchCount = messageId ? (branchCounts[messageId] ?? 0) : 0; |
313 | | - |
314 | | - return ( |
315 | | - <div |
316 | | - key={message.id} |
317 | | - className={cn('flex gap-4 py-3 w-full rounded-lg', { |
318 | | - 'mt-4': !isFirst, |
319 | | - })} |
320 | | - > |
321 | | - <div className="grid grid-col-1 w-full"> |
322 | | - {isUserMessage ? ( |
323 | | - <UserMessage content={content} parts={parts} /> |
324 | | - ) : ( |
325 | | - <AssistantMessage |
326 | | - content={content} |
327 | | - annotations={message.annotations} |
328 | | - messageId={messageId} |
329 | | - onRewind={handleRewind} |
330 | | - onFork={handleFork} |
331 | | - append={props.append} |
332 | | - chatMode={props.chatMode} |
333 | | - setChatMode={props.setChatMode} |
334 | | - model={props.model} |
335 | | - provider={props.provider} |
336 | | - parts={parts} |
337 | | - addToolResult={props.addToolResult} |
338 | | - /> |
339 | | - )} |
340 | | - {!isUserMessage && messageId && branchCount > 0 && ( |
341 | | - <BranchSelector messageId={messageId} count={branchCount} /> |
342 | | - )} |
343 | | - </div> |
344 | | - </div> |
345 | | - ); |
346 | | - }) |
347 | | - : null} |
| 305 | + <AnimatePresence initial={false}> |
| 306 | + {messages.length > 0 |
| 307 | + ? messages.map((message, index) => { |
| 308 | + const { role, content, id: messageId, annotations, parts } = message; |
| 309 | + const isUserMessage = role === 'user'; |
| 310 | + const isFirst = index === 0; |
| 311 | + const isHidden = annotations?.includes('hidden'); |
| 312 | + |
| 313 | + if (isHidden) { |
| 314 | + return <Fragment key={message.id} />; |
| 315 | + } |
| 316 | + |
| 317 | + const branchCount = messageId ? (branchCounts[messageId] ?? 0) : 0; |
| 318 | + const isNewMessage = index >= initialMessageCountRef.current; |
| 319 | + |
| 320 | + return ( |
| 321 | + <motion.div |
| 322 | + key={message.id} |
| 323 | + initial={isNewMessage ? { opacity: 0, x: isUserMessage ? 20 : -20 } : false} |
| 324 | + animate={{ opacity: 1, x: 0 }} |
| 325 | + transition={{ duration: isUserMessage ? 0.2 : 0.25, ease: 'easeOut' }} |
| 326 | + className={cn('flex gap-4 py-3 w-full rounded-lg', { |
| 327 | + 'mt-4': !isFirst, |
| 328 | + })} |
| 329 | + > |
| 330 | + <div className="grid grid-col-1 w-full"> |
| 331 | + {isUserMessage ? ( |
| 332 | + <UserMessage content={content} parts={parts} /> |
| 333 | + ) : ( |
| 334 | + <AssistantMessage |
| 335 | + content={content} |
| 336 | + annotations={message.annotations} |
| 337 | + messageId={messageId} |
| 338 | + onRewind={handleRewind} |
| 339 | + onFork={handleFork} |
| 340 | + append={props.append} |
| 341 | + chatMode={props.chatMode} |
| 342 | + setChatMode={props.setChatMode} |
| 343 | + model={props.model} |
| 344 | + provider={props.provider} |
| 345 | + parts={parts} |
| 346 | + addToolResult={props.addToolResult} |
| 347 | + /> |
| 348 | + )} |
| 349 | + {!isUserMessage && messageId && branchCount > 0 && ( |
| 350 | + <BranchSelector messageId={messageId} count={branchCount} /> |
| 351 | + )} |
| 352 | + </div> |
| 353 | + </motion.div> |
| 354 | + ); |
| 355 | + }) |
| 356 | + : null} |
| 357 | + </AnimatePresence> |
348 | 358 | {isStreaming && agentState.planPhase !== 'idle' && <AgentPhaseIndicator phase={agentState.planPhase} />} |
349 | | - {isStreaming && ( |
350 | | - <div className="flex items-center justify-center gap-2 w-full mt-4"> |
351 | | - <div className="i-svg-spinners:3-dots-fade text-xl text-devonz-elements-item-contentAccent" /> |
352 | | - <span className="text-sm text-devonz-elements-textSecondary animate-pulse">Generating...</span> |
353 | | - </div> |
354 | | - )} |
| 359 | + <AnimatePresence> |
| 360 | + {isStreaming && ( |
| 361 | + <motion.div |
| 362 | + initial={{ opacity: 0, y: 8 }} |
| 363 | + animate={{ opacity: 1, y: 0 }} |
| 364 | + exit={{ opacity: 0, y: -8 }} |
| 365 | + transition={{ duration: 0.2, ease: 'easeOut' }} |
| 366 | + className="flex items-center justify-center gap-2 w-full mt-4" |
| 367 | + > |
| 368 | + <div className="i-svg-spinners:3-dots-fade text-xl text-devonz-elements-item-contentAccent" /> |
| 369 | + <motion.span |
| 370 | + animate={{ opacity: [0.5, 1, 0.5] }} |
| 371 | + transition={{ duration: 1.5, repeat: Infinity, ease: 'easeInOut' }} |
| 372 | + className="text-sm text-devonz-elements-textSecondary" |
| 373 | + > |
| 374 | + Generating... |
| 375 | + </motion.span> |
| 376 | + </motion.div> |
| 377 | + )} |
| 378 | + </AnimatePresence> |
355 | 379 | </div> |
356 | 380 | ); |
357 | 381 | } |
0 commit comments