Skip to content

Commit bec28dc

Browse files
committed
feat: add framer-motion chat animations + error classifier with toast routing
- Messages.client.tsx: slide/fade entrance animations for chat messages - User messages slide from right (x:20), assistant from left (x:-20) - 200-250ms duration, only new messages animated (history skips) - Spinner has fade+slide entrance/exit with opacity pulse - error-classifier.ts: classify errors into 6 categories with 4 severity levels - Categories: network, auth, validation, build, runtime, unknown - Returns recovery suggestions and user-facing messages - error-toast.ts: Sonner toast wrapper mapping severity to toast type - terminalErrorDetector.ts: route warnings/info to toast, fatal/error to ChatAlert - previewErrorHandler.ts: same severity-based routing
1 parent 9557f75 commit bec28dc

5 files changed

Lines changed: 446 additions & 70 deletions

File tree

app/components/chat/Messages.client.tsx

Lines changed: 78 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { Message } from 'ai';
2-
import { Fragment, useCallback, useEffect, useState } from 'react';
2+
import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
33
import { atom } from 'nanostores';
44
import { useStore } from '@nanostores/react';
5+
import { motion, AnimatePresence } from 'framer-motion';
56
import { cn } from '~/utils/cn';
67
import { AssistantMessage } from './AssistantMessage';
78
import { UserMessage } from './UserMessage';
@@ -211,6 +212,9 @@ export function Messages(props: MessagesProps) {
211212
const { id, isStreaming = false, messages = [], ref } = props;
212213
const location = useLocation();
213214

215+
// Track the initial message count so we only animate messages that arrive after mount
216+
const initialMessageCountRef = useRef(messages.length);
217+
214218
// Subscribe to branch state atoms
215219
const branchParent = useStore(currentBranchParentAtom);
216220
const branchCounts = useStore(messageBranchCountsAtom);
@@ -298,60 +302,80 @@ export function Messages(props: MessagesProps) {
298302
return (
299303
<div id={id} className={props.className} ref={ref}>
300304
{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>
348358
{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>
355379
</div>
356380
);
357381
}

0 commit comments

Comments
 (0)