diff --git a/webview-ui/src/components/chat/ReasoningBlock.tsx b/webview-ui/src/components/chat/ReasoningBlock.tsx index 3c981126ef..f706935a26 100644 --- a/webview-ui/src/components/chat/ReasoningBlock.tsx +++ b/webview-ui/src/components/chat/ReasoningBlock.tsx @@ -1,5 +1,6 @@ -import React, { useEffect, useRef, useState } from "react" +import React, { memo, useEffect, useRef, useState, useMemo } from "react" import { useTranslation } from "react-i18next" +import debounce from "debounce" import MarkdownBlock from "../common/MarkdownBlock" import { Clock, Lightbulb } from "lucide-react" @@ -12,30 +13,90 @@ interface ReasoningBlockProps { metadata?: any } +interface ElapsedTimeProps { + isActive: boolean + startTime: number +} + /** - * Render reasoning with a heading and a simple timer. - * - Heading uses i18n key chat:reasoning.thinking - * - Timer runs while reasoning is active (no persistence) + * Memoized timer component that only re-renders itself + * This prevents the entire ReasoningBlock from re-rendering every second */ -export const ReasoningBlock = ({ content, isStreaming, isLast }: ReasoningBlockProps) => { +const ElapsedTime = memo(({ isActive, startTime }: ElapsedTimeProps) => { const { t } = useTranslation() - - const startTimeRef = useRef(Date.now()) const [elapsed, setElapsed] = useState(0) - // Simple timer that runs while streaming useEffect(() => { - if (isLast && isStreaming) { - const tick = () => setElapsed(Date.now() - startTimeRef.current) - tick() + if (isActive) { + const tick = () => setElapsed(Date.now() - startTime) + tick() // Initial tick const id = setInterval(tick, 1000) return () => clearInterval(id) + } else { + setElapsed(0) } - }, [isLast, isStreaming]) + }, [isActive, startTime]) + + if (elapsed === 0) return null const seconds = Math.floor(elapsed / 1000) const secondsLabel = t("chat:reasoning.seconds", { count: seconds }) + return ( + + + {secondsLabel} + + ) +}) + +ElapsedTime.displayName = "ElapsedTime" + +/** + * Render reasoning with a heading and a simple timer. + * - Heading uses i18n key chat:reasoning.thinking + * - Timer runs while reasoning is active (no persistence) + * - Timer is isolated in a memoized component to prevent full re-renders + * - Content updates are debounced to prevent excessive re-renders during streaming + */ +export const ReasoningBlock = ({ content, isStreaming, isLast }: ReasoningBlockProps) => { + const { t } = useTranslation() + const startTimeRef = useRef(Date.now()) + const [debouncedContent, setDebouncedContent] = useState(content || "") + + // Create a debounced function to update content + // This limits content updates to a maximum of ~10 per second (100ms debounce) + const updateDebouncedContent = useMemo( + () => + debounce((newContent: string) => { + setDebouncedContent(newContent) + }, 100), + [], + ) + + // Update debounced content when content changes + useEffect(() => { + if (isStreaming) { + // During streaming, use debounced updates + updateDebouncedContent(content || "") + } else { + // When not streaming, update immediately for final content + setDebouncedContent(content || "") + // Cancel any pending debounced updates + updateDebouncedContent.clear() + } + }, [content, isStreaming, updateDebouncedContent]) + + // Cleanup debounce on unmount + useEffect(() => { + return () => { + updateDebouncedContent.clear() + } + }, [updateDebouncedContent]) + + // Only render markdown when there's actual content + const hasContent = (debouncedContent?.trim()?.length ?? 0) > 0 + return (
@@ -43,16 +104,11 @@ export const ReasoningBlock = ({ content, isStreaming, isLast }: ReasoningBlockP {t("chat:reasoning.thinking")}
- {elapsed > 0 && ( - - - {secondsLabel} - - )} +
- {(content?.trim()?.length ?? 0) > 0 && ( + {hasContent && (
- +
)}