Skip to content
Draft
Show file tree
Hide file tree
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
2 changes: 2 additions & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export interface ExtensionMessage {
| "claudeCodeRateLimits"
| "customToolsResult"
| "modes"
| "ping"
text?: string
payload?: any // eslint-disable-line @typescript-eslint/no-explicit-any
checkpointWarning?: {
Expand Down Expand Up @@ -509,6 +510,7 @@ export interface WebviewMessage {
| "requestModes"
| "switchMode"
| "debugSetting"
| "pong"
text?: string
editedMessageContent?: string
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"
Expand Down
139 changes: 139 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,14 @@ export class ClineProvider
private taskEventListeners: WeakMap<Task, Array<() => void>> = new WeakMap()
private currentWorkspacePath: string | undefined

// Webview heartbeat monitoring for detecting unresponsive webviews
private heartbeatInterval?: ReturnType<typeof setInterval>
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<string, PendingEditOperation> = new Map()
private static readonly PENDING_OPERATION_TIMEOUT_MS = 30000 // 30 seconds
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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<void> {
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<ClineProvider | undefined> {
let visibleProvider = ClineProvider.getVisibleInstance()

Expand Down Expand Up @@ -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(
Expand Down
4 changes: 4 additions & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions webview-ui/src/context/ExtensionStateContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading