Unverified 提交 e991d732 authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

Add delightful streaming loading animation (#2425)

## Summary - Create new `StreamingLoadingAnimation` component with two variants for chat streaming states - **Initial variant**: Flowing wave animation with 5 glowing orbs, staggered bounce, and gradient fills - **Streaming variant**: Pulsing indicator with rotating ring and animated "generating..." text - Replace inline animations in ChatMessage with the new component for cleaner code ## Test plan - Start the app and send a message to trigger chat streaming - Verify the initial loading animation shows glowing bouncing orbs when waiting for first response - Verify the streaming indicator shows pulsing "generating..." text while content is being generated - Check animations work correctly in both light and dark modes 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2425"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > UI-only changes that replace existing loading indicators; main risk is visual/regression or performance issues from the new animations. > > **Overview** > Adds a new `StreamingLoadingAnimation` component with two variants (`initial` and `streaming`) using `framer-motion`, rotating verbs, and a scrambled text effect. > > Updates `ChatMessage` to replace the previous inline loading/spinner animations with this shared component for both the pre-response and in-stream indicators. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 65fac50463768be2e56810c498ca5b4c694754bd. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Add a reusable StreamingLoadingAnimation component with “initial” and “streaming” variants to improve chat loading feedback. Replaces ad‑hoc animations in ChatMessage for a cleaner, consistent UI. - **New Features** - StreamingLoadingAnimation component: - initial: glowing orb wave - streaming: organic equalizer bars - Rotating verbs with scramble text reveal; theme-aware colors - **Refactors** - ChatMessage now uses the new component instead of inline framer-motion blocks - Centralizes animation styles; no changes to streaming logic <sup>Written for commit 65fac50463768be2e56810c498ca5b4c694754bd. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: 's avatarClaude Opus 4.5 <noreply@anthropic.com>
上级 8b9a1cba
...@@ -3,8 +3,8 @@ import { ...@@ -3,8 +3,8 @@ import {
DyadMarkdownParser, DyadMarkdownParser,
VanillaMarkdownParser, VanillaMarkdownParser,
} from "./DyadMarkdownParser"; } from "./DyadMarkdownParser";
import { motion } from "framer-motion";
import { useStreamChat } from "@/hooks/useStreamChat"; import { useStreamChat } from "@/hooks/useStreamChat";
import { StreamingLoadingAnimation } from "./StreamingLoadingAnimation";
import { import {
CheckCircle, CheckCircle,
XCircle, XCircle,
...@@ -98,40 +98,7 @@ const ChatMessage = ({ message, isLastMessage }: ChatMessageProps) => { ...@@ -98,40 +98,7 @@ const ChatMessage = ({ message, isLastMessage }: ChatMessageProps) => {
!message.content && !message.content &&
isStreaming && isStreaming &&
isLastMessage ? ( isLastMessage ? (
<div className="flex h-6 items-center space-x-2 p-2"> <StreamingLoadingAnimation variant="initial" />
<motion.div
className="h-3 w-3 rounded-full bg-(--primary) dark:bg-blue-500"
animate={{ y: [0, -12, 0] }}
transition={{
repeat: Number.POSITIVE_INFINITY,
duration: 0.4,
ease: "easeOut",
repeatDelay: 1.2,
}}
/>
<motion.div
className="h-3 w-3 rounded-full bg-(--primary) dark:bg-blue-500"
animate={{ y: [0, -12, 0] }}
transition={{
repeat: Number.POSITIVE_INFINITY,
duration: 0.4,
ease: "easeOut",
delay: 0.4,
repeatDelay: 1.2,
}}
/>
<motion.div
className="h-3 w-3 rounded-full bg-(--primary) dark:bg-blue-500"
animate={{ y: [0, -12, 0] }}
transition={{
repeat: Number.POSITIVE_INFINITY,
duration: 0.4,
ease: "easeOut",
delay: 0.8,
repeatDelay: 1.2,
}}
/>
</div>
) : ( ) : (
<div <div
className="prose dark:prose-invert prose-headings:mb-2 prose-p:my-1 prose-pre:my-0 max-w-none break-words" className="prose dark:prose-invert prose-headings:mb-2 prose-p:my-1 prose-pre:my-0 max-w-none break-words"
...@@ -141,11 +108,7 @@ const ChatMessage = ({ message, isLastMessage }: ChatMessageProps) => { ...@@ -141,11 +108,7 @@ const ChatMessage = ({ message, isLastMessage }: ChatMessageProps) => {
<> <>
<DyadMarkdownParser content={message.content} /> <DyadMarkdownParser content={message.content} />
{isLastMessage && isStreaming && ( {isLastMessage && isStreaming && (
<div className="mt-4 ml-4 relative w-5 h-5 animate-spin"> <StreamingLoadingAnimation variant="streaming" />
<div className="absolute top-0 left-1/2 transform -translate-x-1/2 w-2 h-2 bg-(--primary) dark:bg-blue-500 rounded-full"></div>
<div className="absolute bottom-0 left-0 w-2 h-2 bg-(--primary) dark:bg-blue-500 rounded-full opacity-80"></div>
<div className="absolute bottom-0 right-0 w-2 h-2 bg-(--primary) dark:bg-blue-500 rounded-full opacity-60"></div>
</div>
)} )}
</> </>
) : ( ) : (
......
import { motion } from "framer-motion";
import { useCallback, useEffect, useRef, useState } from "react";
interface StreamingLoadingAnimationProps {
variant: "initial" | "streaming";
}
/**
* A delightful loading animation for chat streaming.
* - "initial" variant: Shown when waiting for the first response (no content yet)
* - "streaming" variant: Shown inline when content is being streamed
*/
export function StreamingLoadingAnimation({
variant,
}: StreamingLoadingAnimationProps) {
if (variant === "initial") {
return <InitialLoadingAnimation />;
}
return <StreamingIndicator />;
}
const INITIAL_VERBS = [
"thinking",
"pondering",
"reasoning",
"mulling",
"noodling",
"contemplating",
"daydreaming",
"meditating",
"ruminating",
"wondering",
"imagining",
"brainstorming",
];
const STREAMING_VERBS = [
"brewing",
"conjuring",
"cooking",
"crafting",
"weaving",
"assembling",
"forging",
"composing",
"sculpting",
"distilling",
"sketching",
"mixing",
"painting",
"stitching",
"wiring",
"molding",
"tuning",
"polishing",
"building",
"shaping",
"spinning",
"tinkering",
"whittling",
"arranging",
"rendering",
"summoning",
"channeling",
"unspooling",
"manifesting",
"crystallizing",
];
const SCRAMBLE_CHARS = "abcdefghijklmnopqrstuvwxyz";
const SCRAMBLE_SPEED_MS = 30;
const REVEAL_STAGGER_MS = 60;
function useRotatingVerb(verbs: string[]) {
const [index, setIndex] = useState(() =>
Math.floor(Math.random() * verbs.length),
);
useEffect(() => {
const id = setInterval(() => {
setIndex((prev) => (prev + 1) % verbs.length);
}, 5000);
return () => clearInterval(id);
}, [verbs]);
return verbs[index];
}
function useScrambleText(text: string) {
const [display, setDisplay] = useState(text + "...");
const rafRef = useRef<number>(0);
const prevTextRef = useRef(text);
const scramble = useCallback((target: string) => {
const len = Math.max(target.length, prevTextRef.current.length);
const startTime = performance.now();
cancelAnimationFrame(rafRef.current);
const tick = (now: number) => {
const elapsed = now - startTime;
const revealed = Math.floor(elapsed / REVEAL_STAGGER_MS);
let result = "";
for (let i = 0; i < len; i++) {
if (i < revealed) {
result += i < target.length ? target[i] : "";
} else {
const scrambleCycle = Math.floor(elapsed / SCRAMBLE_SPEED_MS + i);
result += SCRAMBLE_CHARS[scrambleCycle % SCRAMBLE_CHARS.length];
}
}
if (revealed >= len) {
setDisplay(target + "...");
prevTextRef.current = target;
return;
}
setDisplay(result + "...");
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
}, []);
useEffect(() => {
if (text !== prevTextRef.current) {
scramble(text);
}
return () => cancelAnimationFrame(rafRef.current);
}, [text, scramble]);
return display;
}
function ScrambleVerb({ verb }: { verb: string }) {
const display = useScrambleText(verb);
return (
<span className="inline-block text-sm text-muted-foreground">
{display}
</span>
);
}
/**
* A snappy wave animation with monochrome glowing orbs and a rotating verb.
* Uses spring-like timing for a crisp, bouncy feel.
*/
function InitialLoadingAnimation() {
const orbs = [0, 1, 2, 3, 4];
const verb = useRotatingVerb(INITIAL_VERBS);
return (
<div className="flex items-center gap-3 p-2">
<div className="relative flex h-10 items-center justify-start gap-1.5">
{orbs.map((index) => (
<motion.div
key={index}
className="relative"
animate={{
y: [0, -10, 3, -1, 0],
}}
transition={{
duration: 0.8,
repeat: Number.POSITIVE_INFINITY,
repeatDelay: 0.3,
ease: [0.22, 1.2, 0.36, 1],
delay: index * 0.07,
}}
>
{/* Soft halo glow */}
<motion.div
className="absolute -inset-1 rounded-full blur-md"
style={{
background:
"radial-gradient(circle, color-mix(in srgb, var(--primary) 35%, transparent), transparent 70%)",
}}
animate={{
scale: [1, 1.8, 1],
opacity: [0.15, 0.4, 0.15],
}}
transition={{
duration: 0.8,
repeat: Number.POSITIVE_INFINITY,
repeatDelay: 0.3,
ease: "easeOut",
delay: index * 0.07,
}}
/>
{/* Core orb */}
<motion.div
className="h-2 w-2 rounded-full bg-primary"
style={{
boxShadow:
"0 0 6px color-mix(in srgb, var(--primary) 30%, transparent)",
}}
animate={{
scale: [1, 1.3, 0.9, 1],
opacity: [0.6, 1, 0.8, 0.6],
}}
transition={{
duration: 0.8,
repeat: Number.POSITIVE_INFINITY,
repeatDelay: 0.3,
ease: [0.22, 1.2, 0.36, 1],
delay: index * 0.07,
}}
/>
</motion.div>
))}
</div>
<ScrambleVerb verb={verb} />
</div>
);
}
// Each bar has its own personality: height range, speed, and phase offset
const BARS = [
{ minH: 5, maxH: 15, duration: 1.0, delay: 0 },
{ minH: 7, maxH: 19, duration: 1.2, delay: 0.12 },
{ minH: 4, maxH: 13, duration: 1.1, delay: 0.25 },
{ minH: 8, maxH: 20, duration: 1.05, delay: 0.08 },
{ minH: 5, maxH: 11, duration: 1.3, delay: 0.3 },
];
/**
* An organic equalizer-bar animation for the streaming state.
* Each bar has unique height, speed, and rhythm for a lively, musical feel.
*/
function StreamingIndicator() {
const verb = useRotatingVerb(STREAMING_VERBS);
return (
<div className="mt-3 ml-1 flex items-center gap-2.5">
<div className="flex h-6 items-end gap-[3px]">
{BARS.map((bar, i) => (
<motion.div
key={i}
className="w-[3px] rounded-full bg-primary"
animate={{
height: [
bar.minH,
bar.maxH,
bar.minH * 1.3,
bar.maxH * 0.8,
bar.minH,
],
opacity: [0.45, 1, 0.6, 0.9, 0.45],
}}
transition={{
duration: bar.duration,
repeat: Number.POSITIVE_INFINITY,
ease: [0.22, 1.2, 0.36, 1],
delay: bar.delay,
}}
/>
))}
</div>
<ScrambleVerb verb={verb} />
</div>
);
}
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论