From 60910c479f2340f92210769eb2db5ecfdc164fa7 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Mon, 18 Aug 2025 01:07:24 +0000 Subject: [PATCH] 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 --- webview-ui/src/components/ErrorBoundary.tsx | 131 +++++++++-- webview-ui/src/components/chat/ChatView.tsx | 234 ++++++++++++++++---- 2 files changed, 304 insertions(+), 61 deletions(-) diff --git a/webview-ui/src/components/ErrorBoundary.tsx b/webview-ui/src/components/ErrorBoundary.tsx index c1281963ba..6156fda857 100644 --- a/webview-ui/src/components/ErrorBoundary.tsx +++ b/webview-ui/src/components/ErrorBoundary.tsx @@ -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 { + 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 { return { error: errorMessage, timestamp: Date.now(), + hasError: true, } } @@ -38,24 +47,63 @@ class ErrorBoundary extends Component { 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 { const version = process.env.PKG_VERSION || "unknown" + // Use a white background to ensure visibility and prevent gray screen return ( -
-

- {t("errorBoundary.title")} (v{version}) -

-

- {t("errorBoundary.reportText")}{" "} - - {t("errorBoundary.githubText")} - -

-

{t("errorBoundary.copyInstructions")}

- -
-

{t("errorBoundary.errorStack")}

-
{errorDisplay}
-
- - {componentStackDisplay && ( -
-

{t("errorBoundary.componentStack")}

-
{componentStackDisplay}
+
+
+

+ {t("errorBoundary.title")} (v{version}) +

+ + {this.state.errorCount === 1 && ( +
+

+ The application will attempt to recover automatically in a few seconds... +

+
+ )} + +
+ + Try Again + + + Reload Window +
- )} + +

+ {t("errorBoundary.reportText")}{" "} + + {t("errorBoundary.githubText")} + +

+

{t("errorBoundary.copyInstructions")}

+ +
+ + {t("errorBoundary.errorStack")} (Click to expand) + +
+							{errorDisplay}
+						
+
+ + {componentStackDisplay && ( +
+ + {t("errorBoundary.componentStack")} (Click to expand) + +
+								{componentStackDisplay}
+							
+
+ )} +
) } diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 3b77bbdb7d..f17dd5caa0 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -181,8 +181,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction>( 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(null) @@ -457,6 +457,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { if (isHidden) { + // Clear cache when view is hidden to free memory everVisibleMessagesTsRef.current.clear() } }, [isHidden]) @@ -464,10 +465,35 @@ const ChatViewComponent: React.ForwardRefRenderFunction { 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 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 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
- { + onAtBottomChange={(isAtBottom: boolean) => { setIsAtBottom(isAtBottom) if (isAtBottom) { disableAutoScrollRef.current = false } setShowScrollToBottom(disableAutoScrollRef.current && !isAtBottom) }} - atBottomThreshold={10} - initialTopMostItemIndex={groupedMessages.length - 1} />
@@ -2012,6 +2053,125 @@ const ChatViewComponent: React.ForwardRefRenderFunction + 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 ( +
+
+

+ Unable to display messages +

+

+ The chat view encountered an issue while rendering messages. This can happen with very long + conversations. +

+
+ + Try Again + + window.location.reload()}> + Reload Window + +
+ {retryCount > 0 && ( +

Retry attempts: {retryCount}

+ )} +
+
+ ) + } + + return ( + { + console.error("Virtuoso rendering error:", error) + setHasError(true) + }} + fallback={ +
+

Loading messages...

+
+ }> + Math.abs(velocity) > 200, + exit: (velocity) => Math.abs(velocity) < 30, + change: () => {}, + }} + // Add error handling for item rendering + components={{ + ScrollSeekPlaceholder: () => ( +
+ Loading... +
+ ), + }} + /> +
+ ) +} + +// 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 ||
Something went wrong
+ } + return this.props.children + } +} + const ChatView = forwardRef(ChatViewComponent) export default ChatView