Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 76 additions & 20 deletions webview-ui/src/components/chat/ReasoningBlock.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -12,47 +13,102 @@ interface ReasoningBlockProps {
metadata?: any
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor type safety improvement: The prop is typed as . Consider defining a proper interface for better type safety:

}

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<number>(Date.now())
const [elapsed, setElapsed] = useState<number>(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)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is resetting elapsed to 0 here intentional? This might cause a brief flash if the component transitions from active to inactive and back quickly. Consider keeping the last elapsed value when inactive, or only resetting when the component unmounts.

}
}, [isLast, isStreaming])
}, [isActive, startTime])

if (elapsed === 0) return null

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

return (
<span className="text-vscode-foreground tabular-nums flex items-center gap-1">
<Clock className="w-4" />
{secondsLabel}
</span>
)
})

ElapsedTime.displayName = "ElapsedTime"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great job isolating the timer re-renders! However, this component could benefit from test coverage. Given this is a performance-critical fix, consider adding tests to verify that:

  • ElapsedTime only re-renders when its props change
  • The parent ReasoningBlock doesn't re-render on timer updates
  • Timer cleanup works correctly


/**
* 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) => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider also wrapping the ReasoningBlock component itself with React.memo. While you've isolated the timer, the parent could still benefit from memoization to prevent unnecessary re-renders when none of its props have changed:

const { t } = useTranslation()
const startTimeRef = useRef<number>(Date.now())
const [debouncedContent, setDebouncedContent] = useState<string>(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 (
<div className="py-1">
<div className="flex items-center justify-between mb-2.5 pr-2">
<div className="flex items-center gap-2">
<Lightbulb className="w-4" />
<span className="font-bold text-vscode-foreground">{t("chat:reasoning.thinking")}</span>
</div>
{elapsed > 0 && (
<span className="text-vscode-foreground tabular-nums flex items-center gap-1">
<Clock className="w-4" />
{secondsLabel}
</span>
)}
<ElapsedTime isActive={isLast && isStreaming} startTime={startTimeRef.current} />
</div>
{(content?.trim()?.length ?? 0) > 0 && (
{hasContent && (
<div className="px-3 italic text-vscode-descriptionForeground">
<MarkdownBlock markdown={content} />
<MarkdownBlock markdown={debouncedContent} />
</div>
)}
</div>
Expand Down
Loading