fix: optimize animations to prevent browser freeze with many research steps (#630)

Fixes #570 where browser freezes when research plan has 8+ steps.

Performance optimizations:
- Add animation throttling: only animate first 10 activity items
- Reduce animation durations (0.4s → 0.3s for activities, 0.2s → 0.15s for results)
- Remove scale animations (GPU-intensive) from search results
- Limit displayed results (20 pages, 10 images max)
- Add conditional animations based on item index
- Cap animation delays to prevent excessive staggering
- Add React.memo to ActivityMessage and ActivityListItem components

These changes significantly improve performance when rendering multiple
research steps while maintaining visual appeal for smaller lists.
This commit is contained in:
Willem Jiang
2025-10-19 19:24:57 +08:00
committed by GitHub
parent 5af036f19f
commit 984aa69acf

View File

@@ -7,7 +7,7 @@ import { LRUCache } from "lru-cache";
import { BookOpenText, FileText, PencilRuler, Search } from "lucide-react"; import { BookOpenText, FileText, PencilRuler, Search } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { useMemo } from "react"; import React, { useMemo } from "react";
import SyntaxHighlighter from "react-syntax-highlighter"; import SyntaxHighlighter from "react-syntax-highlighter";
import { docco } from "react-syntax-highlighter/dist/esm/styles/hljs"; import { docco } from "react-syntax-highlighter/dist/esm/styles/hljs";
import { dark } from "react-syntax-highlighter/dist/esm/styles/prism"; import { dark } from "react-syntax-highlighter/dist/esm/styles/prism";
@@ -31,6 +31,10 @@ import { useMessage, useStore } from "~/core/store";
import { parseJSON } from "~/core/utils"; import { parseJSON } from "~/core/utils";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
// Performance optimization constants
const MAX_ANIMATED_ITEMS = 10; // Only animate first 10 items
const ANIMATION_DELAY_MULTIPLIER = 0.05; // Reduced delay between animations
export function ResearchActivitiesBlock({ export function ResearchActivitiesBlock({
className, className,
researchId, researchId,
@@ -42,27 +46,36 @@ export function ResearchActivitiesBlock({
state.researchActivityIds.get(researchId), state.researchActivityIds.get(researchId),
)!; )!;
const ongoing = useStore((state) => state.ongoingResearchId === researchId); const ongoing = useStore((state) => state.ongoingResearchId === researchId);
return ( return (
<> <>
<ul className={cn("flex flex-col py-4", className)}> <ul className={cn("flex flex-col py-4", className)}>
{activityIds.map( {activityIds.map(
(activityId, i) => (activityId, i) => {
i !== 0 && ( if (i === 0) return null;
// Performance optimization: limit animations for large lists
const shouldAnimate = i < MAX_ANIMATED_ITEMS;
const animationDelay = shouldAnimate ? Math.min(i * ANIMATION_DELAY_MULTIPLIER, 0.5) : 0;
return (
<motion.li <motion.li
key={activityId} key={activityId}
style={{ transition: "all 0.4s ease-out" }} style={{ transition: shouldAnimate ? "all 0.3s ease-out" : "none" }}
initial={{ opacity: 0, y: 24 }} initial={shouldAnimate ? { opacity: 0, y: 24 } : { opacity: 1, y: 0 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ transition={shouldAnimate ? {
duration: 0.4, duration: 0.3, // Reduced from 0.4
delay: animationDelay,
ease: "easeOut", ease: "easeOut",
}} } : undefined}
> >
<ActivityMessage messageId={activityId} /> <ActivityMessage messageId={activityId} />
<ActivityListItem messageId={activityId} /> <ActivityListItem messageId={activityId} />
{i !== activityIds.length - 1 && <hr className="my-8" />} {i !== activityIds.length - 1 && <hr className="my-8" />}
</motion.li> </motion.li>
), );
},
)} )}
</ul> </ul>
{ongoing && <LoadingAnimation className="mx-4 my-12" />} {ongoing && <LoadingAnimation className="mx-4 my-12" />}
@@ -70,7 +83,7 @@ export function ResearchActivitiesBlock({
); );
} }
function ActivityMessage({ messageId }: { messageId: string }) { const ActivityMessage = React.memo(({ messageId }: { messageId: string }) => {
const message = useMessage(messageId); const message = useMessage(messageId);
if (message?.agent && message.content) { if (message?.agent && message.content) {
if (message.agent !== "reporter" && message.agent !== "planner") { if (message.agent !== "reporter" && message.agent !== "planner") {
@@ -84,9 +97,10 @@ function ActivityMessage({ messageId }: { messageId: string }) {
} }
} }
return null; return null;
} });
ActivityMessage.displayName = "ActivityMessage";
function ActivityListItem({ messageId }: { messageId: string }) { const ActivityListItem = React.memo(({ messageId }: { messageId: string }) => {
const message = useMessage(messageId); const message = useMessage(messageId);
if (message) { if (message) {
if (!message.isStreaming && message.toolCalls?.length) { if (!message.isStreaming && message.toolCalls?.length) {
@@ -109,7 +123,8 @@ function ActivityListItem({ messageId }: { messageId: string }) {
} }
} }
return null; return null;
} });
ActivityListItem.displayName = "ActivityListItem";
const __pageCache = new LRUCache<string, string>({ max: 100 }); const __pageCache = new LRUCache<string, string>({ max: 100 });
type SearchResult = type SearchResult =
@@ -187,38 +202,46 @@ function WebSearchToolCall({ toolCall }: { toolCall: ToolCallRuntime }) {
))} ))}
{pageResults {pageResults
.filter((result) => result.type === "page") .filter((result) => result.type === "page")
.map((searchResult, i) => ( .slice(0, 20) // Limit displayed results for performance
<motion.li .map((searchResult, i) => {
key={`search-result-${i}`} const shouldAnimate = i < 6; // Only animate first 6 results
className="text-muted-foreground bg-accent flex max-w-40 gap-2 rounded-md px-2 py-1 text-sm" return (
initial={{ opacity: 0, y: 10, scale: 0.66 }} <motion.li
animate={{ opacity: 1, y: 0, scale: 1 }} key={`search-result-${i}`}
transition={{ className="text-muted-foreground bg-accent flex max-w-40 gap-2 rounded-md px-2 py-1 text-sm"
duration: 0.2, initial={shouldAnimate ? { opacity: 0, y: 10 } : { opacity: 1, y: 0 }}
delay: i * 0.1, animate={{ opacity: 1, y: 0 }}
ease: "easeOut", transition={shouldAnimate ? {
}} duration: 0.15, // Reduced from 0.2
> delay: Math.min(i * 0.05, 0.3), // Cap delay at 0.3s
<FavIcon ease: "easeOut",
className="mt-1" } : undefined}
url={searchResult.url} >
title={searchResult.title} <FavIcon
/> className="mt-1"
<a href={searchResult.url} target="_blank"> url={searchResult.url}
{searchResult.title} title={searchResult.title}
</a> />
</motion.li> <a href={searchResult.url} target="_blank">
))} {searchResult.title}
{imageResults.map((searchResult, i) => ( </a>
<motion.li </motion.li>
key={`search-result-${i}`} );
initial={{ opacity: 0, y: 10, scale: 0.66 }} })}
animate={{ opacity: 1, y: 0, scale: 1 }} {imageResults
transition={{ .slice(0, 10) // Limit displayed images for performance
duration: 0.2, .map((searchResult, i) => {
delay: i * 0.1, const shouldAnimate = i < 4; // Only animate first 4 images
ease: "easeOut", return (
}} <motion.li
key={`search-result-${i}`}
initial={shouldAnimate ? { opacity: 0, y: 10 } : { opacity: 1, y: 0 }}
animate={{ opacity: 1, y: 0 }}
transition={shouldAnimate ? {
duration: 0.15,
delay: Math.min(i * 0.05, 0.2),
ease: "easeOut",
} : undefined}
> >
<a <a
className="flex flex-col gap-2 overflow-hidden rounded-md opacity-75 transition-opacity duration-300 hover:opacity-100" className="flex flex-col gap-2 overflow-hidden rounded-md opacity-75 transition-opacity duration-300 hover:opacity-100"
@@ -234,7 +257,8 @@ function WebSearchToolCall({ toolCall }: { toolCall: ToolCallRuntime }) {
/> />
</a> </a>
</motion.li> </motion.li>
))} );
})}
</ul> </ul>
)} )}
</div> </div>
@@ -263,10 +287,10 @@ function CrawlToolCall({ toolCall }: { toolCall: ToolCallRuntime }) {
<ul className="mt-2 flex flex-wrap gap-4"> <ul className="mt-2 flex flex-wrap gap-4">
<motion.li <motion.li
className="text-muted-foreground bg-accent flex h-40 w-40 gap-2 rounded-md px-2 py-1 text-sm" className="text-muted-foreground bg-accent flex h-40 w-40 gap-2 rounded-md px-2 py-1 text-sm"
initial={{ opacity: 0, y: 10, scale: 0.66 }} initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0 }}
transition={{ transition={{
duration: 0.2, duration: 0.15, // Reduced for better performance
ease: "easeOut", ease: "easeOut",
}} }}
> >
@@ -320,22 +344,25 @@ function RetrieverToolCall({ toolCall }: { toolCall: ToolCallRuntime }) {
/> />
</li> </li>
))} ))}
{documents?.map((doc, i) => ( {documents?.map((doc, i) => {
<motion.li const shouldAnimate = i < 4; // Only animate first 4 documents
key={`search-result-${i}`} return (
className="text-muted-foreground bg-accent flex max-w-40 gap-2 rounded-md px-2 py-1 text-sm" <motion.li
initial={{ opacity: 0, y: 10, scale: 0.66 }} key={`search-result-${i}`}
animate={{ opacity: 1, y: 0, scale: 1 }} className="text-muted-foreground bg-accent flex max-w-40 gap-2 rounded-md px-2 py-1 text-sm"
transition={{ initial={shouldAnimate ? { opacity: 0, y: 10 } : { opacity: 1, y: 0 }}
duration: 0.2, animate={{ opacity: 1, y: 0 }}
delay: i * 0.1, transition={shouldAnimate ? {
ease: "easeOut", duration: 0.15,
}} delay: Math.min(i * 0.05, 0.2),
> ease: "easeOut",
<FileText size={32} /> } : undefined}
{doc.title} (chunk-{i},size-{doc.content.length}) >
</motion.li> <FileText size={32} />
))} {doc.title} (chunk-{i},size-{doc.content.length})
</motion.li>
);
})}
</ul> </ul>
)} )}
</div> </div>