fix(webview): throttle state/message updates; drop hidden-panel deltas; throttle indexing updates to prevent grey screens during long tasks

This commit is contained in:
Hannes Rudolph 2025-10-10 17:52:50 -06:00
parent 3a47c55a2e
commit 1f7e78ea2c
2 changed files with 121 additions and 15 deletions

View file

@ -277,6 +277,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
public readonly messageQueueService: MessageQueueService
private messageQueueStateChangedHandler: (() => void) | undefined
// Throttled chat row update batching to reduce webview message flood
private messageUpdateBuffer: Map<number, ClineMessage> = new Map()
private messageUpdateTimer?: NodeJS.Timeout
private readonly MESSAGE_UPDATE_THROTTLE_MS = 33 // ~30 FPS
// Streaming
isWaitingForFirstChunk = false
isStreaming = false
@ -644,9 +649,12 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
private async updateClineMessage(message: ClineMessage) {
const provider = this.providerRef.deref()
await provider?.postMessageToWebview({ type: "messageUpdated", clineMessage: message })
// Emit internal event immediately (used for token usage, etc)
this.emit(RooCodeEventName.Message, { action: "updated", message })
// Telemetry capture remains unchanged below
const shouldCaptureMessage = message.partial !== true && CloudService.isEnabled()
if (shouldCaptureMessage) {
@ -655,6 +663,37 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
properties: { taskId: this.taskId, message },
})
}
// If provider is unavailable, or panel is hidden, skip UI delta updates.
// The UI will resync on next visibility or full state push.
if (!provider || (typeof (provider as any).isVisible === "function" && !(provider as any).isVisible())) {
return
}
// Batch UI updates within a short window to avoid overwhelming the webview
const ts = (message as any)?.ts as number | undefined
if (typeof ts === "number") {
this.messageUpdateBuffer.set(ts, message)
} else {
// Fallback: no timestamp, just send immediately
await provider.postMessageToWebview({ type: "messageUpdated", clineMessage: message })
return
}
if (!this.messageUpdateTimer) {
this.messageUpdateTimer = setTimeout(async () => {
try {
const batch = Array.from(this.messageUpdateBuffer.values())
this.messageUpdateBuffer.clear()
this.messageUpdateTimer = undefined
for (const m of batch) {
await provider.postMessageToWebview({ type: "messageUpdated", clineMessage: m })
}
} catch (e) {
console.error("[Task#updateClineMessage] Failed to flush message updates:", e)
}
}, this.MESSAGE_UPDATE_THROTTLE_MS)
}
}
private async saveClineMessages() {

View file

@ -148,6 +148,17 @@ export class ClineProvider
public readonly providerSettingsManager: ProviderSettingsManager
public readonly customModesManager: CustomModesManager
// Throttling/backpressure for heavy webview messages
private stateFlushQueued = false
private stateQueueDirty = false
private stateFlushPromise?: Promise<void>
private readonly STATE_THROTTLE_MS = 33 // ~30 FPS coalescing
// Throttle noisy indexing status updates
private indexStatusThrottleTimer?: NodeJS.Timeout
private pendingIndexStatus?: IndexProgressUpdate
private readonly INDEX_STATUS_THROTTLE_MS = 100
constructor(
readonly context: vscode.ExtensionContext,
private readonly outputChannel: vscode.OutputChannel,
@ -799,6 +810,8 @@ export class ClineProvider
const viewStateDisposable = webviewView.onDidChangeViewState(() => {
if (this.view?.visible) {
this.postMessageToWebview({ type: "action", action: "didBecomeVisible" })
// Push latest state when tab becomes visible to avoid stale UI without flooding while hidden
void this.postStateToWebview()
}
})
@ -808,6 +821,8 @@ export class ClineProvider
const visibilityDisposable = webviewView.onDidChangeVisibility(() => {
if (this.view?.visible) {
this.postMessageToWebview({ type: "action", action: "didBecomeVisible" })
// Ensure UI sync after becoming visible
void this.postStateToWebview()
}
})
@ -971,7 +986,16 @@ export class ClineProvider
}
public async postMessageToWebview(message: ExtensionMessage) {
await this.view?.webview.postMessage(message)
try {
// Reduce background chatter: drop chat row updates when panel isn't visible
if (message.type === "messageUpdated" && !this.isVisible()) {
return
}
if (!this.view) return
await this.view.webview.postMessage(message)
} catch (error) {
this.log(`[postMessageToWebview] failed: ${error instanceof Error ? error.message : String(error)}`)
}
}
private async getHMRHtmlContent(webview: vscode.Webview): Promise<string> {
@ -1602,13 +1626,40 @@ export class ClineProvider
}
async postStateToWebview() {
const state = await this.getStateToPostToWebview()
this.postMessageToWebview({ type: "state", state })
// Coalesce multiple rapid callers into a single flush, awaited by all
this.stateQueueDirty = true
if (!this.stateFlushQueued) {
this.stateFlushQueued = true
this.stateFlushPromise = this.flushStateQueue()
}
return this.stateFlushPromise
}
// Check MDM compliance and send user to account tab if not compliant
// Only redirect if there's an actual MDM policy requiring authentication
if (this.mdmService?.requiresCloudAuth() && !this.checkMdmCompliance()) {
await this.postMessageToWebview({ type: "action", action: "cloudButtonClicked" })
private async flushStateQueue(): Promise<void> {
try {
// Small delay window to coalesce bursts from multiple sources
await delay(this.STATE_THROTTLE_MS)
// Drain while new calls arrive during awaits
do {
this.stateQueueDirty = false
const state = await this.getStateToPostToWebview()
await this.postMessageToWebview({ type: "state", state })
// Check MDM compliance and send user to account tab if not compliant
// Only redirect if there's an actual MDM policy requiring authentication
if (this.mdmService?.requiresCloudAuth() && !this.checkMdmCompliance()) {
await this.postMessageToWebview({ type: "action", action: "cloudButtonClicked" })
}
// Allow any additional state requests queued during the await to be coalesced
if (this.stateQueueDirty) {
await delay(this.STATE_THROTTLE_MS)
}
} while (this.stateQueueDirty)
} finally {
this.stateFlushQueued = false
}
}
@ -2288,6 +2339,11 @@ export class ClineProvider
return this._workspaceTracker
}
// Public visibility helper for rate limiting background chatter
public isVisible(): boolean {
return this.view?.visible === true
}
get viewLaunched() {
return this.isViewLaunched
}
@ -2409,13 +2465,24 @@ export class ClineProvider
if (currentManager) {
this.codeIndexStatusSubscription = currentManager.onProgressUpdate((update: IndexProgressUpdate) => {
// Only send updates if this manager is still the current one
if (currentManager === this.getCurrentWorkspaceCodeIndexManager()) {
// Get the full status from the manager to ensure we have all fields correctly formatted
const fullStatus = currentManager.getCurrentStatus()
this.postMessageToWebview({
type: "indexingStatusUpdate",
values: fullStatus,
})
if (currentManager !== this.getCurrentWorkspaceCodeIndexManager()) {
return
}
// Throttle: coalesce frequent progress updates into ~10 Hz
this.pendingIndexStatus = currentManager.getCurrentStatus()
if (!this.indexStatusThrottleTimer) {
this.indexStatusThrottleTimer = setTimeout(() => {
const values = this.pendingIndexStatus ?? currentManager.getCurrentStatus()
this.pendingIndexStatus = undefined
this.indexStatusThrottleTimer = undefined
this.postMessageToWebview({
type: "indexingStatusUpdate",
values,
})
}, this.INDEX_STATUS_THROTTLE_MS)
}
})