Skip to content

Commit d03bf9a

Browse files
committed
fix: optimize ReasoningBlock performance by isolating timer re-renders
- Extract timer logic into memoized ElapsedTime component - Prevent full component tree re-renders every second - Only render markdown when content exists - Fixes #7999: CPU spike from 1Hz re-renders in long conversations
1 parent 1b4819c commit d03bf9a

File tree

1 file changed

+42
-19
lines changed

1 file changed

+42
-19
lines changed

webview-ui/src/components/chat/ReasoningBlock.tsx

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useEffect, useRef, useState } from "react"
1+
import React, { memo, useEffect, useRef, useState } from "react"
22
import { useTranslation } from "react-i18next"
33

44
import MarkdownBlock from "../common/MarkdownBlock"
@@ -12,45 +12,68 @@ interface ReasoningBlockProps {
1212
metadata?: any
1313
}
1414

15+
interface ElapsedTimeProps {
16+
isActive: boolean
17+
startTime: number
18+
}
19+
1520
/**
16-
* Render reasoning with a heading and a simple timer.
17-
* - Heading uses i18n key chat:reasoning.thinking
18-
* - Timer runs while reasoning is active (no persistence)
21+
* Memoized timer component that only re-renders itself
22+
* This prevents the entire ReasoningBlock from re-rendering every second
1923
*/
20-
export const ReasoningBlock = ({ content, isStreaming, isLast }: ReasoningBlockProps) => {
24+
const ElapsedTime = memo(({ isActive, startTime }: ElapsedTimeProps) => {
2125
const { t } = useTranslation()
22-
23-
const startTimeRef = useRef<number>(Date.now())
2426
const [elapsed, setElapsed] = useState<number>(0)
2527

26-
// Simple timer that runs while streaming
2728
useEffect(() => {
28-
if (isLast && isStreaming) {
29-
const tick = () => setElapsed(Date.now() - startTimeRef.current)
30-
tick()
29+
if (isActive) {
30+
const tick = () => setElapsed(Date.now() - startTime)
31+
tick() // Initial tick
3132
const id = setInterval(tick, 1000)
3233
return () => clearInterval(id)
34+
} else {
35+
setElapsed(0)
3336
}
34-
}, [isLast, isStreaming])
37+
}, [isActive, startTime])
38+
39+
if (elapsed === 0) return null
3540

3641
const seconds = Math.floor(elapsed / 1000)
3742
const secondsLabel = t("chat:reasoning.seconds", { count: seconds })
3843

44+
return (
45+
<span className="text-vscode-foreground tabular-nums flex items-center gap-1">
46+
<Clock className="w-4" />
47+
{secondsLabel}
48+
</span>
49+
)
50+
})
51+
52+
ElapsedTime.displayName = "ElapsedTime"
53+
54+
/**
55+
* Render reasoning with a heading and a simple timer.
56+
* - Heading uses i18n key chat:reasoning.thinking
57+
* - Timer runs while reasoning is active (no persistence)
58+
* - Timer is isolated in a memoized component to prevent full re-renders
59+
*/
60+
export const ReasoningBlock = ({ content, isStreaming, isLast }: ReasoningBlockProps) => {
61+
const { t } = useTranslation()
62+
const startTimeRef = useRef<number>(Date.now())
63+
64+
// Only render markdown when there's actual content
65+
const hasContent = (content?.trim()?.length ?? 0) > 0
66+
3967
return (
4068
<div className="py-1">
4169
<div className="flex items-center justify-between mb-2.5 pr-2">
4270
<div className="flex items-center gap-2">
4371
<Lightbulb className="w-4" />
4472
<span className="font-bold text-vscode-foreground">{t("chat:reasoning.thinking")}</span>
4573
</div>
46-
{elapsed > 0 && (
47-
<span className="text-vscode-foreground tabular-nums flex items-center gap-1">
48-
<Clock className="w-4" />
49-
{secondsLabel}
50-
</span>
51-
)}
74+
<ElapsedTime isActive={isLast && isStreaming} startTime={startTimeRef.current} />
5275
</div>
53-
{(content?.trim()?.length ?? 0) > 0 && (
76+
{hasContent && (
5477
<div className="px-3 italic text-vscode-descriptionForeground">
5578
<MarkdownBlock markdown={content} />
5679
</div>

0 commit comments

Comments
 (0)