From bc724952a40d2a5b9ce1383f6d3c34754653f96b Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 23 Sep 2025 01:22:20 +0000 Subject: [PATCH] fix: isolate timer updates and optimize reasoning block rendering - Extract ElapsedTime component to prevent parent re-renders - Add isExpanded prop to conditionally render markdown content - Implement content debouncing during streaming (~100ms) - Pass isExpanded from ChatRow to ReasoningBlock This addresses the performance regression where the 1Hz timer was causing excessive re-renders of the entire reasoning block component tree. Fixes #7999 --- webview-ui/src/components/chat/ChatRow.tsx | 1 + .../src/components/chat/ElapsedTime.tsx | 41 ++++++++++++++++ .../src/components/chat/ReasoningBlock.tsx | 48 ++++++++++--------- 3 files changed, 68 insertions(+), 22 deletions(-) create mode 100644 webview-ui/src/components/chat/ElapsedTime.tsx diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index dc5b007dab..2b8b4212d3 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -1039,6 +1039,7 @@ export const ChatRowContent = ({ ts={message.ts} isStreaming={isStreaming} isLast={isLast} + isExpanded={isExpanded} metadata={message.metadata as any} /> ) diff --git a/webview-ui/src/components/chat/ElapsedTime.tsx b/webview-ui/src/components/chat/ElapsedTime.tsx new file mode 100644 index 0000000000..ad1d81c6be --- /dev/null +++ b/webview-ui/src/components/chat/ElapsedTime.tsx @@ -0,0 +1,41 @@ +import React, { memo, useEffect, useRef, useState } from "react" +import { useTranslation } from "react-i18next" + +interface ElapsedTimeProps { + isStreaming: boolean + isLast: boolean +} + +/** + * Isolated timer component that updates independently from parent. + * This prevents the entire ReasoningBlock from re-rendering every second. + */ +export const ElapsedTime = memo(({ isStreaming, isLast }: ElapsedTimeProps) => { + const { t } = useTranslation() + const startTimeRef = useRef(Date.now()) + const [elapsed, setElapsed] = useState(0) + + useEffect(() => { + if (isLast && isStreaming) { + const tick = () => setElapsed(Date.now() - startTimeRef.current) + tick() + const id = setInterval(tick, 1000) + return () => clearInterval(id) + } + }, [isLast, isStreaming]) + + const seconds = Math.floor(elapsed / 1000) + const secondsLabel = t("chat:reasoning.seconds", { count: seconds }) + + if (elapsed === 0) { + return null + } + + return ( + + {secondsLabel} + + ) +}) + +ElapsedTime.displayName = "ElapsedTime" diff --git a/webview-ui/src/components/chat/ReasoningBlock.tsx b/webview-ui/src/components/chat/ReasoningBlock.tsx index 3fa46df570..cec9722fc0 100644 --- a/webview-ui/src/components/chat/ReasoningBlock.tsx +++ b/webview-ui/src/components/chat/ReasoningBlock.tsx @@ -1,40 +1,46 @@ -import React, { useEffect, useRef, useState } from "react" +import React, { memo, useState, useEffect } from "react" import { useTranslation } from "react-i18next" import MarkdownBlock from "../common/MarkdownBlock" import { Lightbulb } from "lucide-react" +import { ElapsedTime } from "./ElapsedTime" interface ReasoningBlockProps { content: string ts: number isStreaming: boolean isLast: boolean + isExpanded?: boolean metadata?: any } /** * 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 ElapsedTime component to prevent parent re-renders + * - Content is debounced during streaming to reduce re-render frequency */ -export const ReasoningBlock = ({ content, isStreaming, isLast }: ReasoningBlockProps) => { +export const ReasoningBlock = memo(({ content, isStreaming, isLast, isExpanded = false }: ReasoningBlockProps) => { const { t } = useTranslation() - const startTimeRef = useRef(Date.now()) - const [elapsed, setElapsed] = useState(0) + // Debounce content updates during streaming + const [debouncedContent, setDebouncedContent] = useState(content) - // Simple timer that runs while streaming useEffect(() => { - if (isLast && isStreaming) { - const tick = () => setElapsed(Date.now() - startTimeRef.current) - tick() - const id = setInterval(tick, 1000) - return () => clearInterval(id) + if (isStreaming) { + // Debounce content updates to ~10 updates per second max + const timer = setTimeout(() => { + setDebouncedContent(content) + }, 100) + return () => clearTimeout(timer) + } else { + // Immediately update when streaming ends + setDebouncedContent(content) } - }, [isLast, isStreaming]) + }, [content, isStreaming]) - const seconds = Math.floor(elapsed / 1000) - const secondsLabel = t("chat:reasoning.seconds", { count: seconds }) + // Only render markdown if expanded and content exists + const shouldRenderMarkdown = isExpanded && (debouncedContent?.trim()?.length ?? 0) > 0 return (
@@ -43,17 +49,15 @@ export const ReasoningBlock = ({ content, isStreaming, isLast }: ReasoningBlockP {t("chat:reasoning.thinking")}
- {elapsed > 0 && ( - - {secondsLabel} - - )} + - {(content?.trim()?.length ?? 0) > 0 && ( + {shouldRenderMarkdown && (
- +
)} ) -} +}) + +ReasoningBlock.displayName = "ReasoningBlock"