Skip to content

Commit bc72495

Browse files
committed
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
1 parent 0e1b23d commit bc72495

File tree

3 files changed

+68
-22
lines changed

3 files changed

+68
-22
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1039,6 +1039,7 @@ export const ChatRowContent = ({
10391039
ts={message.ts}
10401040
isStreaming={isStreaming}
10411041
isLast={isLast}
1042+
isExpanded={isExpanded}
10421043
metadata={message.metadata as any}
10431044
/>
10441045
)
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import React, { memo, useEffect, useRef, useState } from "react"
2+
import { useTranslation } from "react-i18next"
3+
4+
interface ElapsedTimeProps {
5+
isStreaming: boolean
6+
isLast: boolean
7+
}
8+
9+
/**
10+
* Isolated timer component that updates independently from parent.
11+
* This prevents the entire ReasoningBlock from re-rendering every second.
12+
*/
13+
export const ElapsedTime = memo(({ isStreaming, isLast }: ElapsedTimeProps) => {
14+
const { t } = useTranslation()
15+
const startTimeRef = useRef<number>(Date.now())
16+
const [elapsed, setElapsed] = useState<number>(0)
17+
18+
useEffect(() => {
19+
if (isLast && isStreaming) {
20+
const tick = () => setElapsed(Date.now() - startTimeRef.current)
21+
tick()
22+
const id = setInterval(tick, 1000)
23+
return () => clearInterval(id)
24+
}
25+
}, [isLast, isStreaming])
26+
27+
const seconds = Math.floor(elapsed / 1000)
28+
const secondsLabel = t("chat:reasoning.seconds", { count: seconds })
29+
30+
if (elapsed === 0) {
31+
return null
32+
}
33+
34+
return (
35+
<span className="text-sm text-vscode-descriptionForeground tabular-nums flex items-center gap-1">
36+
{secondsLabel}
37+
</span>
38+
)
39+
})
40+
41+
ElapsedTime.displayName = "ElapsedTime"
Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,46 @@
1-
import React, { useEffect, useRef, useState } from "react"
1+
import React, { memo, useState, useEffect } from "react"
22
import { useTranslation } from "react-i18next"
33

44
import MarkdownBlock from "../common/MarkdownBlock"
55
import { Lightbulb } from "lucide-react"
6+
import { ElapsedTime } from "./ElapsedTime"
67

78
interface ReasoningBlockProps {
89
content: string
910
ts: number
1011
isStreaming: boolean
1112
isLast: boolean
13+
isExpanded?: boolean
1214
metadata?: any
1315
}
1416

1517
/**
1618
* Render reasoning with a heading and a simple timer.
1719
* - Heading uses i18n key chat:reasoning.thinking
18-
* - Timer runs while reasoning is active (no persistence)
20+
* - Timer is isolated in ElapsedTime component to prevent parent re-renders
21+
* - Content is debounced during streaming to reduce re-render frequency
1922
*/
20-
export const ReasoningBlock = ({ content, isStreaming, isLast }: ReasoningBlockProps) => {
23+
export const ReasoningBlock = memo(({ content, isStreaming, isLast, isExpanded = false }: ReasoningBlockProps) => {
2124
const { t } = useTranslation()
2225

23-
const startTimeRef = useRef<number>(Date.now())
24-
const [elapsed, setElapsed] = useState<number>(0)
26+
// Debounce content updates during streaming
27+
const [debouncedContent, setDebouncedContent] = useState(content)
2528

26-
// Simple timer that runs while streaming
2729
useEffect(() => {
28-
if (isLast && isStreaming) {
29-
const tick = () => setElapsed(Date.now() - startTimeRef.current)
30-
tick()
31-
const id = setInterval(tick, 1000)
32-
return () => clearInterval(id)
30+
if (isStreaming) {
31+
// Debounce content updates to ~10 updates per second max
32+
const timer = setTimeout(() => {
33+
setDebouncedContent(content)
34+
}, 100)
35+
return () => clearTimeout(timer)
36+
} else {
37+
// Immediately update when streaming ends
38+
setDebouncedContent(content)
3339
}
34-
}, [isLast, isStreaming])
40+
}, [content, isStreaming])
3541

36-
const seconds = Math.floor(elapsed / 1000)
37-
const secondsLabel = t("chat:reasoning.seconds", { count: seconds })
42+
// Only render markdown if expanded and content exists
43+
const shouldRenderMarkdown = isExpanded && (debouncedContent?.trim()?.length ?? 0) > 0
3844

3945
return (
4046
<div>
@@ -43,17 +49,15 @@ export const ReasoningBlock = ({ content, isStreaming, isLast }: ReasoningBlockP
4349
<Lightbulb className="w-4" />
4450
<span className="font-bold text-vscode-foreground">{t("chat:reasoning.thinking")}</span>
4551
</div>
46-
{elapsed > 0 && (
47-
<span className="text-sm text-vscode-descriptionForeground tabular-nums flex items-center gap-1">
48-
{secondsLabel}
49-
</span>
50-
)}
52+
<ElapsedTime isStreaming={isStreaming} isLast={isLast} />
5153
</div>
52-
{(content?.trim()?.length ?? 0) > 0 && (
54+
{shouldRenderMarkdown && (
5355
<div className="border-l border-vscode-descriptionForeground/20 ml-2 pl-4 pb-1 text-vscode-descriptionForeground">
54-
<MarkdownBlock markdown={content} />
56+
<MarkdownBlock markdown={debouncedContent} />
5557
</div>
5658
)}
5759
</div>
5860
)
59-
}
61+
})
62+
63+
ReasoningBlock.displayName = "ReasoningBlock"

0 commit comments

Comments
 (0)