Skip to content

Commit 16bc514

Browse files
committed
feat: add content debouncing to ReasoningBlock for improved performance
- Implement debounced content updates during streaming (100ms debounce) - Combine with existing memoized ElapsedTime component for timer isolation - Prevents excessive re-renders during rapid content streaming - Immediate updates when streaming completes for final content - Addresses performance concerns raised in issue #7999
1 parent 513fce3 commit 16bc514

File tree

1 file changed

+73
-20
lines changed

1 file changed

+73
-20
lines changed

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

Lines changed: 73 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import React, { useEffect, useRef, useState } from "react"
1+
import React, { useEffect, useRef, useState, useMemo, memo } from "react"
22
import { useTranslation } from "react-i18next"
3+
import debounce from "debounce"
34

45
import MarkdownBlock from "../common/MarkdownBlock"
56
import { Clock, Lightbulb } from "lucide-react"
@@ -12,47 +13,99 @@ interface ReasoningBlockProps {
1213
metadata?: any
1314
}
1415

16+
interface ElapsedTimeProps {
17+
isActive: boolean
18+
startTime: number
19+
}
20+
1521
/**
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)
22+
* Memoized timer component that only re-renders itself
23+
* This prevents the entire ReasoningBlock from re-rendering every second
1924
*/
20-
export const ReasoningBlock = ({ content, isStreaming, isLast }: ReasoningBlockProps) => {
25+
const ElapsedTime = memo(({ isActive, startTime }: ElapsedTimeProps) => {
2126
const { t } = useTranslation()
22-
23-
const startTimeRef = useRef<number>(Date.now())
2427
const [elapsed, setElapsed] = useState<number>(0)
2528

26-
// Simple timer that runs while streaming
2729
useEffect(() => {
28-
if (isLast && isStreaming) {
29-
const tick = () => setElapsed(Date.now() - startTimeRef.current)
30-
tick()
30+
if (isActive) {
31+
const tick = () => setElapsed(Date.now() - startTime)
32+
tick() // Initial tick
3133
const id = setInterval(tick, 1000)
3234
return () => clearInterval(id)
35+
} else {
36+
setElapsed(0)
3337
}
34-
}, [isLast, isStreaming])
38+
}, [isActive, startTime])
39+
40+
if (elapsed === 0) return null
3541

3642
const seconds = Math.floor(elapsed / 1000)
3743
const secondsLabel = t("chat:reasoning.seconds", { count: seconds })
3844

45+
return (
46+
<span className="text-vscode-foreground tabular-nums flex items-center gap-1">
47+
<Clock className="w-4" />
48+
{secondsLabel}
49+
</span>
50+
)
51+
})
52+
53+
ElapsedTime.displayName = "ElapsedTime"
54+
55+
/**
56+
* Render reasoning with a heading and a simple timer.
57+
* - Heading uses i18n key chat:reasoning.thinking
58+
* - Timer runs while reasoning is active (no persistence)
59+
* - Timer is isolated in a memoized component to prevent full re-renders
60+
* - Content updates are debounced to prevent excessive re-renders during streaming
61+
*/
62+
export const ReasoningBlock = ({ content, isStreaming, isLast }: ReasoningBlockProps) => {
63+
const { t } = useTranslation()
64+
const startTimeRef = useRef<number>(Date.now())
65+
const [debouncedContent, setDebouncedContent] = useState<string>(content || "")
66+
67+
// Create a debounced function to update content
68+
// This limits content updates to a maximum of ~10 per second (100ms debounce)
69+
const updateDebouncedContent = useMemo(
70+
() =>
71+
debounce((newContent: string) => {
72+
setDebouncedContent(newContent)
73+
}, 100),
74+
[],
75+
)
76+
77+
// Update debounced content when content changes
78+
useEffect(() => {
79+
if (isStreaming) {
80+
// During streaming, use debounced updates
81+
updateDebouncedContent(content || "")
82+
} else {
83+
// When not streaming, update immediately for final content
84+
setDebouncedContent(content || "")
85+
// Cancel any pending debounced updates
86+
updateDebouncedContent.clear()
87+
}
88+
}, [content, isStreaming, updateDebouncedContent])
89+
90+
// Cleanup debounce on unmount
91+
useEffect(() => {
92+
return () => {
93+
updateDebouncedContent.clear()
94+
}
95+
}, [updateDebouncedContent])
96+
3997
return (
4098
<div className="py-1">
4199
<div className="flex items-center justify-between mb-2.5 pr-2">
42100
<div className="flex items-center gap-2">
43101
<Lightbulb className="w-4" />
44102
<span className="font-bold text-vscode-foreground">{t("chat:reasoning.thinking")}</span>
45103
</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-
)}
104+
<ElapsedTime isActive={isLast && isStreaming} startTime={startTimeRef.current} />
52105
</div>
53-
{(content?.trim()?.length ?? 0) > 0 && (
106+
{(debouncedContent?.trim()?.length ?? 0) > 0 && (
54107
<div className="px-3 italic text-vscode-descriptionForeground">
55-
<MarkdownBlock markdown={content} />
108+
<MarkdownBlock markdown={debouncedContent} />
56109
</div>
57110
)}
58111
</div>

0 commit comments

Comments
 (0)