fix: resolve gray screen of death issue during long chats (#7165)

- Enhanced ErrorBoundary with recovery mechanism and auto-retry
- Added proper error state management and recovery buttons
- Improved memory management in ChatView with better LRUCache settings
- Added periodic memory cleanup for very long chat sessions
- Implemented error handling for Virtuoso virtual scrolling component
- Added fallback UI for scroll rendering failures
- Optimized viewport settings to reduce memory usage
- Added error boundaries around critical rendering components

This fix addresses the gray screen issue that occurs during extended chat sessions by:
1. Preventing the error boundary from showing a gray overlay
2. Adding automatic recovery mechanisms
3. Improving memory management to prevent crashes
4. Providing user-friendly recovery options when errors occur
This commit is contained in:
Roo Code 2025-08-18 01:07:24 +00:00
parent 185365af5d
commit 60910c479f
2 changed files with 302 additions and 59 deletions

View file

@ -2,6 +2,7 @@ import React, { Component } from "react"
import { telemetryClient } from "@src/utils/TelemetryClient"
import { withTranslation, WithTranslation } from "react-i18next"
import { enhanceErrorWithSourceMaps } from "@src/utils/sourceMapUtils"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
type ErrorProps = {
children: React.ReactNode
@ -11,12 +12,19 @@ type ErrorState = {
error?: string
componentStack?: string | null
timestamp?: number
hasError: boolean
errorCount: number
}
class ErrorBoundary extends Component<ErrorProps, ErrorState> {
private retryTimeoutId: NodeJS.Timeout | null = null
constructor(props: ErrorProps) {
super(props)
this.state = {}
this.state = {
hasError: false,
errorCount: 0,
}
}
static getDerivedStateFromError(error: unknown) {
@ -31,6 +39,7 @@ class ErrorBoundary extends Component<ErrorProps, ErrorState> {
return {
error: errorMessage,
timestamp: Date.now(),
hasError: true,
}
}
@ -38,24 +47,63 @@ class ErrorBoundary extends Component<ErrorProps, ErrorState> {
const componentStack = errorInfo.componentStack || ""
const enhancedError = await enhanceErrorWithSourceMaps(error, componentStack)
// Increment error count
this.setState((prevState) => ({
errorCount: prevState.errorCount + 1,
}))
telemetryClient.capture("error_boundary_caught_error", {
error: enhancedError.message,
stack: enhancedError.sourceMappedStack || enhancedError.stack,
componentStack: enhancedError.sourceMappedComponentStack || componentStack,
timestamp: Date.now(),
errorType: enhancedError.name,
errorCount: this.state.errorCount + 1,
})
this.setState({
error: enhancedError.sourceMappedStack || enhancedError.stack,
componentStack: enhancedError.sourceMappedComponentStack || componentStack,
})
// Auto-retry after 5 seconds if this is the first error
if (this.state.errorCount === 0 && !this.retryTimeoutId) {
this.retryTimeoutId = setTimeout(() => {
this.handleReset()
}, 5000)
}
}
componentWillUnmount() {
if (this.retryTimeoutId) {
clearTimeout(this.retryTimeoutId)
this.retryTimeoutId = null
}
}
handleReset = () => {
if (this.retryTimeoutId) {
clearTimeout(this.retryTimeoutId)
this.retryTimeoutId = null
}
this.setState({
error: undefined,
componentStack: undefined,
timestamp: undefined,
hasError: false,
// Don't reset errorCount to track total errors in session
})
}
handleReload = () => {
window.location.reload()
}
render() {
const { t } = this.props
if (!this.state.error) {
if (!this.state.hasError || !this.state.error) {
return this.props.children
}
@ -64,30 +112,65 @@ class ErrorBoundary extends Component<ErrorProps, ErrorState> {
const version = process.env.PKG_VERSION || "unknown"
// Use a white background to ensure visibility and prevent gray screen
return (
<div>
<h2 className="text-lg font-bold mt-0 mb-2">
{t("errorBoundary.title")} (v{version})
</h2>
<p className="mb-4">
{t("errorBoundary.reportText")}{" "}
<a href="https://github.com/RooCodeInc/Roo-Code/issues" target="_blank" rel="noreferrer">
{t("errorBoundary.githubText")}
</a>
</p>
<p className="mb-2">{t("errorBoundary.copyInstructions")}</p>
<div
className="fixed inset-0 bg-vscode-editor-background overflow-auto p-4"
style={{ backgroundColor: "var(--vscode-editor-background, white)", zIndex: 9999 }}>
<div className="max-w-4xl mx-auto">
<h2 className="text-lg font-bold mt-0 mb-2 text-vscode-editor-foreground">
{t("errorBoundary.title")} (v{version})
</h2>
<div className="mb-4">
<h3 className="text-md font-bold mb-1">{t("errorBoundary.errorStack")}</h3>
<pre className="p-2 border rounded text-sm overflow-auto">{errorDisplay}</pre>
</div>
{this.state.errorCount === 1 && (
<div className="mb-4 p-3 bg-vscode-notifications-background border border-vscode-notifications-border rounded">
<p className="text-vscode-notifications-foreground">
The application will attempt to recover automatically in a few seconds...
</p>
</div>
)}
{componentStackDisplay && (
<div>
<h3 className="text-md font-bold mb-1">{t("errorBoundary.componentStack")}</h3>
<pre className="p-2 border rounded text-sm overflow-auto">{componentStackDisplay}</pre>
<div className="flex gap-2 mb-4">
<VSCodeButton appearance="primary" onClick={this.handleReset}>
Try Again
</VSCodeButton>
<VSCodeButton appearance="secondary" onClick={this.handleReload}>
Reload Window
</VSCodeButton>
</div>
)}
<p className="mb-4 text-vscode-editor-foreground">
{t("errorBoundary.reportText")}{" "}
<a
href="https://github.com/RooCodeInc/Roo-Code/issues"
target="_blank"
rel="noreferrer"
className="text-vscode-textLink-foreground hover:text-vscode-textLink-activeForeground">
{t("errorBoundary.githubText")}
</a>
</p>
<p className="mb-2 text-vscode-editor-foreground">{t("errorBoundary.copyInstructions")}</p>
<details className="mb-4">
<summary className="cursor-pointer text-vscode-editor-foreground font-bold mb-2">
{t("errorBoundary.errorStack")} (Click to expand)
</summary>
<pre className="p-2 border border-vscode-panel-border rounded text-sm overflow-auto bg-vscode-editor-background text-vscode-editor-foreground mt-2">
{errorDisplay}
</pre>
</details>
{componentStackDisplay && (
<details>
<summary className="cursor-pointer text-vscode-editor-foreground font-bold mb-2">
{t("errorBoundary.componentStack")} (Click to expand)
</summary>
<pre className="p-2 border border-vscode-panel-border rounded text-sm overflow-auto bg-vscode-editor-background text-vscode-editor-foreground mt-2">
{componentStackDisplay}
</pre>
</details>
)}
</div>
</div>
)
}

View file

@ -181,8 +181,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
const [showAnnouncementModal, setShowAnnouncementModal] = useState(false)
const everVisibleMessagesTsRef = useRef<LRUCache<number, boolean>>(
new LRUCache({
max: 100,
ttl: 1000 * 60 * 5,
max: 200, // Increased from 100 to handle longer conversations
ttl: 1000 * 60 * 10, // Increased from 5 to 10 minutes
}),
)
const autoApproveTimeoutRef = useRef<NodeJS.Timeout | null>(null)
@ -457,6 +457,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
useEffect(() => {
if (isHidden) {
// Clear cache when view is hidden to free memory
everVisibleMessagesTsRef.current.clear()
}
}, [isHidden])
@ -464,10 +465,35 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
useEffect(() => {
const cache = everVisibleMessagesTsRef.current
return () => {
// Ensure cache is cleared on unmount
cache.clear()
}
}, [])
// Add periodic memory cleanup for very long chats
useEffect(() => {
const memoryCleanupInterval = setInterval(() => {
// Only keep recent messages in cache during very long chats
const currentSize = everVisibleMessagesTsRef.current.size
if (currentSize > 150) {
// Force garbage collection by recreating the cache
const oldCache = everVisibleMessagesTsRef.current
everVisibleMessagesTsRef.current = new LRUCache({
max: 200,
ttl: 1000 * 60 * 10,
})
// Copy only the most recent entries
const entries = Array.from(oldCache.entries()).slice(-100)
entries.forEach(([key, value]) => {
everVisibleMessagesTsRef.current.set(key, value)
})
oldCache.clear()
}
}, 60000) // Run every minute
return () => clearInterval(memoryCleanupInterval)
}, [])
useEffect(() => {
const prev = prevExpandedRowsRef.current
let wasAnyRowExpandedByUser = false
@ -900,27 +926,37 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
useMount(() => textAreaRef.current?.focus())
const visibleMessages = useMemo(() => {
// Remove the 500-message limit to prevent array index shifting
// Virtuoso is designed to efficiently handle large lists through virtualization
const newVisibleMessages = modifiedMessages.filter((message: ClineMessage) => {
if (everVisibleMessagesTsRef.current.has(message.ts)) {
const alwaysHiddenOnceProcessedAsk: ClineAsk[] = [
"api_req_failed",
"resume_task",
"resume_completed_task",
]
const alwaysHiddenOnceProcessedSay = [
"api_req_finished",
"api_req_retried",
"api_req_deleted",
"mcp_server_request_started",
]
if (message.ask && alwaysHiddenOnceProcessedAsk.includes(message.ask)) return false
if (message.say && alwaysHiddenOnceProcessedSay.includes(message.say)) return false
if (message.say === "text" && (message.text ?? "") === "" && (message.images?.length ?? 0) === 0) {
return false
// Limit processing for very large message arrays to prevent performance issues
const messagesToProcess =
modifiedMessages.length > 5000
? modifiedMessages.slice(-5000) // Only process last 5000 messages for very long chats
: modifiedMessages
const newVisibleMessages = messagesToProcess.filter((message: ClineMessage) => {
// Check cache with try-catch to handle potential memory issues
try {
if (everVisibleMessagesTsRef.current.has(message.ts)) {
const alwaysHiddenOnceProcessedAsk: ClineAsk[] = [
"api_req_failed",
"resume_task",
"resume_completed_task",
]
const alwaysHiddenOnceProcessedSay = [
"api_req_finished",
"api_req_retried",
"api_req_deleted",
"mcp_server_request_started",
]
if (message.ask && alwaysHiddenOnceProcessedAsk.includes(message.ask)) return false
if (message.say && alwaysHiddenOnceProcessedSay.includes(message.say)) return false
if (message.say === "text" && (message.text ?? "") === "" && (message.images?.length ?? 0) === 0) {
return false
}
return true
}
return true
} catch (error) {
console.error("Error accessing message cache:", error)
// Continue processing without cache on error
}
switch (message.ask) {
@ -938,8 +974,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
case "api_req_deleted":
return false
case "api_req_retry_delayed":
const last1 = modifiedMessages.at(-1)
const last2 = modifiedMessages.at(-2)
const last1 = messagesToProcess.at(-1)
const last2 = messagesToProcess.at(-2)
if (last1?.ask === "resume_task" && last2 === message) {
return true
} else if (message !== last1) {
@ -955,10 +991,19 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
return true
})
const viewportStart = Math.max(0, newVisibleMessages.length - 100)
newVisibleMessages
.slice(viewportStart)
.forEach((msg: ClineMessage) => everVisibleMessagesTsRef.current.set(msg.ts, true))
// Safely update cache with error handling
try {
const viewportStart = Math.max(0, newVisibleMessages.length - 100)
newVisibleMessages.slice(viewportStart).forEach((msg: ClineMessage) => {
try {
everVisibleMessagesTsRef.current.set(msg.ts, true)
} catch (error) {
console.error("Error updating message cache:", error)
}
})
} catch (error) {
console.error("Error processing visible messages:", error)
}
return newVisibleMessages
}, [modifiedMessages])
@ -1867,22 +1912,18 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
{task && (
<>
<div className="grow flex" ref={scrollContainerRef}>
<Virtuoso
ref={virtuosoRef}
key={task.ts}
className="scrollable grow overflow-y-scroll mb-1"
increaseViewportBy={{ top: 3_000, bottom: 1000 }}
data={groupedMessages}
<ErrorBoundaryVirtuoso
virtuosoRef={virtuosoRef}
task={task}
groupedMessages={groupedMessages}
itemContent={itemContent}
atBottomStateChange={(isAtBottom: boolean) => {
onAtBottomChange={(isAtBottom: boolean) => {
setIsAtBottom(isAtBottom)
if (isAtBottom) {
disableAutoScrollRef.current = false
}
setShowScrollToBottom(disableAutoScrollRef.current && !isAtBottom)
}}
atBottomThreshold={10}
initialTopMostItemIndex={groupedMessages.length - 1}
/>
</div>
<div className={`flex-initial min-h-0 ${!areButtonsVisible ? "mb-1" : ""}`}>
@ -2012,6 +2053,125 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
)
}
// Error boundary wrapper for Virtuoso to handle scrolling errors
const ErrorBoundaryVirtuoso: React.FC<{
virtuosoRef: React.RefObject<VirtuosoHandle>
task: any
groupedMessages: any[]
itemContent: (index: number, item: any) => React.ReactNode
onAtBottomChange: (isAtBottom: boolean) => void
}> = ({ virtuosoRef, task, groupedMessages, itemContent, onAtBottomChange }) => {
const [hasError, setHasError] = useState(false)
const [retryCount, setRetryCount] = useState(0)
// Reset error state when task changes
useEffect(() => {
setHasError(false)
setRetryCount(0)
}, [task?.ts])
const handleRetry = useCallback(() => {
setHasError(false)
setRetryCount((prev) => prev + 1)
}, [])
if (hasError) {
return (
<div className="flex-1 flex flex-col items-center justify-center p-4">
<div className="text-center max-w-md">
<h3 className="text-lg font-semibold mb-2 text-vscode-editor-foreground">
Unable to display messages
</h3>
<p className="text-sm text-vscode-descriptionForeground mb-4">
The chat view encountered an issue while rendering messages. This can happen with very long
conversations.
</p>
<div className="flex gap-2 justify-center">
<VSCodeButton appearance="primary" onClick={handleRetry}>
Try Again
</VSCodeButton>
<VSCodeButton appearance="secondary" onClick={() => window.location.reload()}>
Reload Window
</VSCodeButton>
</div>
{retryCount > 0 && (
<p className="text-xs text-vscode-descriptionForeground mt-2">Retry attempts: {retryCount}</p>
)}
</div>
</div>
)
}
return (
<ErrorBoundary
onError={(error: Error) => {
console.error("Virtuoso rendering error:", error)
setHasError(true)
}}
fallback={
<div className="flex-1 flex items-center justify-center">
<p className="text-vscode-descriptionForeground">Loading messages...</p>
</div>
}>
<Virtuoso
ref={virtuosoRef}
key={`${task.ts}-${retryCount}`}
className="scrollable grow overflow-y-scroll mb-1"
increaseViewportBy={{ top: 1500, bottom: 500 }}
data={groupedMessages}
itemContent={itemContent}
atBottomStateChange={onAtBottomChange}
atBottomThreshold={10}
initialTopMostItemIndex={Math.max(0, groupedMessages.length - 1)}
overscan={10}
scrollSeekConfiguration={{
enter: (velocity) => Math.abs(velocity) > 200,
exit: (velocity) => Math.abs(velocity) < 30,
change: () => {},
}}
// Add error handling for item rendering
components={{
ScrollSeekPlaceholder: () => (
<div className="h-20 flex items-center justify-center">
<span className="text-vscode-descriptionForeground">Loading...</span>
</div>
),
}}
/>
</ErrorBoundary>
)
}
// Simple error boundary for Virtuoso
class ErrorBoundary extends React.Component<
{
children: React.ReactNode
onError?: (error: Error) => void
fallback?: React.ReactNode
},
{ hasError: boolean }
> {
constructor(props: any) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError() {
return { hasError: true }
}
componentDidCatch(error: Error) {
this.props.onError?.(error)
}
render() {
if (this.state.hasError) {
return this.props.fallback || <div>Something went wrong</div>
}
return this.props.children
}
}
const ChatView = forwardRef(ChatViewComponent)
export default ChatView