diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index c0bb8726d7d..4808dce840f 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -94,6 +94,7 @@ export interface ExtensionMessage { | "claudeCodeRateLimits" | "customToolsResult" | "modes" + | "ping" text?: string payload?: any // eslint-disable-line @typescript-eslint/no-explicit-any checkpointWarning?: { @@ -509,6 +510,7 @@ export interface WebviewMessage { | "requestModes" | "switchMode" | "debugSetting" + | "pong" text?: string editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 64b044bb352..fb394664b49 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -147,6 +147,14 @@ export class ClineProvider private taskEventListeners: WeakMap void>> = new WeakMap() private currentWorkspacePath: string | undefined + // Webview heartbeat monitoring for detecting unresponsive webviews + private heartbeatInterval?: ReturnType + private lastPongTimestamp: number = 0 + private heartbeatMissCount: number = 0 + private static readonly HEARTBEAT_INTERVAL_MS = 5000 // Send ping every 5 seconds + private static readonly HEARTBEAT_TIMEOUT_MS = 15000 // Consider unresponsive after 15 seconds (3 missed pings) + private static readonly MAX_HEARTBEAT_MISSES = 3 // Number of missed heartbeats before showing recovery option + private recentTasksCache?: string[] private pendingOperations: Map = new Map() private static readonly PENDING_OPERATION_TIMEOUT_MS = 30000 // 30 seconds @@ -578,6 +586,9 @@ export class ClineProvider async dispose() { this.log("Disposing ClineProvider...") + // Stop heartbeat monitoring + this.stopHeartbeatMonitoring() + // Clear all tasks from the stack. while (this.clineStack.length > 0) { await this.removeClineFromStack() @@ -630,6 +641,131 @@ export class ClineProvider return findLast(Array.from(this.activeInstances), (instance) => instance.view?.visible === true) } + // Webview Heartbeat Monitoring Methods + + /** + * Starts heartbeat monitoring for the webview. + * Sends periodic ping messages to the webview and monitors for pong responses. + * If the webview becomes unresponsive (no pong received), offers recovery options. + */ + private startHeartbeatMonitoring(): void { + // Clear any existing heartbeat interval + this.stopHeartbeatMonitoring() + + // Initialize heartbeat state + this.lastPongTimestamp = Date.now() + this.heartbeatMissCount = 0 + + this.heartbeatInterval = setInterval(() => { + // Only monitor when the webview is visible + if (!this.view?.visible) { + // Reset miss count when not visible - the webview might be hidden + this.heartbeatMissCount = 0 + return + } + + // Send ping to webview + this.postMessageToWebview({ type: "ping" }) + + // Check if we've received a pong recently + const timeSinceLastPong = Date.now() - this.lastPongTimestamp + + if (timeSinceLastPong > ClineProvider.HEARTBEAT_TIMEOUT_MS) { + this.heartbeatMissCount++ + this.log( + `[Heartbeat] Webview unresponsive - no pong received for ${Math.round(timeSinceLastPong / 1000)}s (miss count: ${this.heartbeatMissCount})`, + ) + + // After MAX_HEARTBEAT_MISSES, offer recovery option + if (this.heartbeatMissCount >= ClineProvider.MAX_HEARTBEAT_MISSES) { + this.handleWebviewUnresponsive() + } + } + }, ClineProvider.HEARTBEAT_INTERVAL_MS) + + this.log("[Heartbeat] Monitoring started") + } + + /** + * Stops heartbeat monitoring for the webview. + */ + private stopHeartbeatMonitoring(): void { + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval) + this.heartbeatInterval = undefined + this.log("[Heartbeat] Monitoring stopped") + } + } + + /** + * Handles pong response from the webview, indicating it's still responsive. + * Called by webviewMessageHandler when a "pong" message is received. + */ + public handlePong(): void { + this.lastPongTimestamp = Date.now() + this.heartbeatMissCount = 0 + } + + /** + * Handles the case when the webview becomes unresponsive. + * Shows a notification with options to reload the webview. + */ + private handleWebviewUnresponsive(): void { + // Stop sending more pings while handling the unresponsive state + this.stopHeartbeatMonitoring() + + this.log("[Heartbeat] Webview detected as unresponsive, showing recovery notification") + + // Show notification with reload option + vscode.window + .showWarningMessage( + "Roo Code panel has become unresponsive. This can happen during long-running tasks with auto-approval enabled.", + "Reload Panel", + "Dismiss", + ) + .then((selection) => { + if (selection === "Reload Panel") { + this.reloadWebview() + } else { + // If dismissed, restart monitoring in case the user wants to continue + this.startHeartbeatMonitoring() + } + }) + } + + /** + * Reloads the webview to recover from an unresponsive state. + * This preserves the current task and state. + */ + public async reloadWebview(): Promise { + this.log("[Heartbeat] Reloading webview...") + + if (!this.view?.webview) { + this.log("[Heartbeat] Cannot reload - no webview available") + return + } + + try { + // Regenerate and set the HTML content + this.view.webview.html = + this.contextProxy.extensionMode === vscode.ExtensionMode.Development + ? await this.getHMRHtmlContent(this.view.webview) + : await this.getHtmlContent(this.view.webview) + + // The webview will re-initialize and request state via "webviewDidLaunch" + // which will trigger postStateToWebview() and restore the UI + + // Restart heartbeat monitoring after reload + this.startHeartbeatMonitoring() + + this.log("[Heartbeat] Webview reloaded successfully") + vscode.window.showInformationMessage("Roo Code panel has been reloaded.") + } catch (error) { + this.log(`[Heartbeat] Failed to reload webview: ${error instanceof Error ? error.message : String(error)}`) + vscode.window.showErrorMessage("Failed to reload Roo Code panel. Please try reopening the panel manually.") + } + } + public static async getInstance(): Promise { let visibleProvider = ClineProvider.getVisibleInstance() @@ -861,6 +997,9 @@ export class ClineProvider // If the extension is starting a new session, clear previous task state. await this.removeClineFromStack() + + // Start heartbeat monitoring to detect unresponsive webviews + this.startHeartbeatMonitoring() } public async createTaskWithHistoryItem( diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 5d3c9e01522..cfa31e967ff 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -447,6 +447,10 @@ export const webviewMessageHandler = async ( } switch (message.type) { + case "pong": + // Heartbeat response from webview - delegate to provider + provider.handlePong() + break case "webviewDidLaunch": // Load custom modes first const customModes = await provider.customModesManager.getCustomModes() diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index d2ff79a8e02..60f9c11a00c 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -321,6 +321,10 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode (event: MessageEvent) => { const message: ExtensionMessage = event.data switch (message.type) { + case "ping": + // Respond to heartbeat ping immediately + vscode.postMessage({ type: "pong" }) + break case "state": { const newState = message.state! setState((prevState) => mergeExtensionState(prevState, newState))