diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 91b868796689..45b26945c466 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -141,6 +141,26 @@ export class ClineProvider private recentTasksCache?: string[] private pendingOperations: Map = new Map() private static readonly PENDING_OPERATION_TIMEOUT_MS = 30000 // 30 seconds + private static readonly STATE_UPDATE_DEBOUNCE_MS = 50 // Default debounce delay for state updates + + /** + * Soft reload mechanism to prevent UI flickering during task recreation. + * When true, the UI preserves its state (scroll position, input values) during updates. + * This is used during cancel operations and checkpoint restoration to maintain UI stability. + */ + public isSoftReloading = false + + /** + * Debounce timer for state updates to prevent rapid consecutive updates + * that can cause UI flickering and performance issues. + */ + private stateUpdateDebounceTimer: NodeJS.Timeout | null = null + + /** + * Configurable debounce delay in milliseconds, mainly for testing purposes. + * In production, this uses STATE_UPDATE_DEBOUNCE_MS. + */ + private stateUpdateDebounceDelay: number = ClineProvider.STATE_UPDATE_DEBOUNCE_MS public isViewLaunched = false public settingsImportedAt?: number @@ -583,6 +603,12 @@ export class ClineProvider this.clearAllPendingEditOperations() this.log("Cleared pending operations") + // Clear debounce timer if it exists + if (this.stateUpdateDebounceTimer) { + clearTimeout(this.stateUpdateDebounceTimer) + this.stateUpdateDebounceTimer = null + } + if (this.view && "dispose" in this.view) { this.view.dispose() this.log("Disposed webview") @@ -1602,14 +1628,66 @@ export class ClineProvider } async postStateToWebview() { - const state = await this.getStateToPostToWebview() - this.postMessageToWebview({ type: "state", state }) + // Clear existing debounce timer if it exists + if (this.stateUpdateDebounceTimer) { + clearTimeout(this.stateUpdateDebounceTimer) + this.stateUpdateDebounceTimer = null + } + + // If we're in soft reload mode, send state immediately without debouncing + if (this.isSoftReloading) { + const state = await this.getStateToPostToWebview() + // Include soft reload flag to prevent UI flickering + this.postMessageToWebview({ + type: "state", + state, + isSoftReload: this.isSoftReloading, + }) + + // 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" }) + } + return + } + + // If debounce delay is 0 (for tests), execute immediately + if (this.stateUpdateDebounceDelay === 0) { + const state = await this.getStateToPostToWebview() + // Include soft reload flag to prevent UI flickering + this.postMessageToWebview({ + type: "state", + state, + isSoftReload: this.isSoftReloading, + }) - // 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" }) + // 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" }) + } + return } + + // Debounce state updates to prevent rapid flickering + this.stateUpdateDebounceTimer = setTimeout(async () => { + const state = await this.getStateToPostToWebview() + // Include soft reload flag to prevent UI flickering + this.postMessageToWebview({ + type: "state", + state, + isSoftReload: this.isSoftReloading, + }) + + // 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" }) + } + + this.stateUpdateDebounceTimer = null + }, this.stateUpdateDebounceDelay) } /** @@ -2595,6 +2673,9 @@ export class ClineProvider // Capture the current instance to detect if rehydrate already occurred elsewhere const originalInstanceId = task.instanceId + // Set soft reload flag to prevent UI flickering + this.isSoftReloading = true + // Begin abort (non-blocking) task.abortTask() @@ -2623,6 +2704,8 @@ export class ClineProvider this.log( `[cancelTask] Skipping rehydrate: current instance ${current.instanceId} != original ${originalInstanceId}`, ) + // Reset soft reload flag + this.isSoftReloading = false return } @@ -2633,12 +2716,20 @@ export class ClineProvider this.log( `[cancelTask] Skipping rehydrate after final check: current instance ${currentAfterCheck.instanceId} != original ${originalInstanceId}`, ) + // Reset soft reload flag + this.isSoftReloading = false return } } // Clears task again, so we need to abortTask manually above. await this.createTaskWithHistoryItem({ ...historyItem, rootTask, parentTask }) + + // Reset soft reload flag after task is recreated + this.isSoftReloading = false + + // Send a refresh without flickering + await this.postStateToWebview() } // Clear the current task without treating it as a subtask. diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index bcc9d544c290..39b85f2b7861 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -418,7 +418,10 @@ describe("ClineProvider", () => { onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), } as unknown as vscode.WebviewView + // Create provider with immediate state updates for tests (no debouncing) provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + // Set debounce delay to 0 for tests to ensure synchronous behavior + ;(provider as any).stateUpdateDebounceDelay = 0 defaultTaskOptions = { provider, diff --git a/src/core/webview/__tests__/checkpointRestoreHandler.spec.ts b/src/core/webview/__tests__/checkpointRestoreHandler.spec.ts index 98773feb6cf4..d5a42f286f42 100644 --- a/src/core/webview/__tests__/checkpointRestoreHandler.spec.ts +++ b/src/core/webview/__tests__/checkpointRestoreHandler.spec.ts @@ -48,6 +48,7 @@ describe("checkpointRestoreHandler", () => { mockProvider = { getCurrentTask: vi.fn(() => mockCline), postMessageToWebview: vi.fn(), + postStateToWebview: vi.fn().mockResolvedValue(undefined), getTaskWithId: vi.fn(() => ({ historyItem: { id: "test-task-123", messages: mockCline.clineMessages }, })), @@ -56,6 +57,7 @@ describe("checkpointRestoreHandler", () => { contextProxy: { globalStorageUri: { fsPath: "/test/storage" }, }, + isSoftReloading: false, } // Mock pWaitFor to resolve immediately diff --git a/src/core/webview/checkpointRestoreHandler.ts b/src/core/webview/checkpointRestoreHandler.ts index a3f62f74f317..c4aca66f1773 100644 --- a/src/core/webview/checkpointRestoreHandler.ts +++ b/src/core/webview/checkpointRestoreHandler.ts @@ -22,11 +22,18 @@ export interface CheckpointRestoreConfig { /** * Handles checkpoint restoration for both delete and edit operations. * This consolidates the common logic while handling operation-specific behavior. + * + * The soft reload mechanism prevents UI flickering by maintaining state during + * checkpoint restoration operations. This ensures the chat window doesn't flash + * or lose scroll position when restoring to a previous checkpoint. */ export async function handleCheckpointRestoreOperation(config: CheckpointRestoreConfig): Promise { const { provider, currentCline, messageTs, checkpoint, operation, editData } = config try { + // Set soft reload flag to prevent UI flickering during checkpoint restoration + provider.isSoftReloading = true + // For delete operations, ensure the task is properly aborted to handle any pending ask operations // This prevents "Current ask promise was ignored" errors // For edit operations, we don't abort because the checkpoint restore will handle it @@ -78,7 +85,16 @@ export async function handleCheckpointRestoreOperation(config: CheckpointRestore } // For edit operations, the task cancellation in checkpointRestore // will trigger reinitialization, which will process pendingEditAfterRestore + + // Reset soft reload flag after operation completes + provider.isSoftReloading = false + + // Send a refresh without flickering + await provider.postStateToWebview() } catch (error) { + // Reset soft reload flag on error + provider.isSoftReloading = false + console.error(`Error in checkpoint restore (${operation}):`, error) vscode.window.showErrorMessage( `Error during checkpoint restore: ${error instanceof Error ? error.message : String(error)}`, diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 66f389f81c10..09e4d786c3ee 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -205,6 +205,7 @@ export interface ExtensionMessage { queuedMessages?: QueuedMessage[] list?: string[] // For dismissedUpsells organizationId?: string | null // For organizationSwitchResult + isSoftReload?: boolean // Flag to indicate soft reload state (cancel/checkpoint restore) to prevent UI flickering } export type ExtensionState = Pick< diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index d358c68f1cff..3dc009062180 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -728,6 +728,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { const message: ExtensionMessage = e.data + // Check for soft reload flag to prevent UI flickering + if (message.isSoftReload === true) { + // During soft reload, we preserve UI state and skip certain operations + // that would cause flickering + return + } + switch (message.type) { case "action": switch (message.action!) {