Skip to content
Open
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
103 changes: 97 additions & 6 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,26 @@ export class ClineProvider
private recentTasksCache?: string[]
private pendingOperations: Map<string, PendingEditOperation> = 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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
}

/**
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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
}

Expand All @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions src/core/webview/__tests__/ClineProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/core/webview/__tests__/checkpointRestoreHandler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
})),
Expand All @@ -56,6 +57,7 @@ describe("checkpointRestoreHandler", () => {
contextProxy: {
globalStorageUri: { fsPath: "/test/storage" },
},
isSoftReloading: false,
}

// Mock pWaitFor to resolve immediately
Expand Down
16 changes: 16 additions & 0 deletions src/core/webview/checkpointRestoreHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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
Expand Down Expand Up @@ -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)}`,
Expand Down
1 change: 1 addition & 0 deletions src/shared/ExtensionMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand Down
8 changes: 8 additions & 0 deletions webview-ui/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
const trimmedInput = text?.trim()

if (isStreaming) {
// Cancel the streaming task
vscode.postMessage({ type: "cancelTask" })
setDidClickCancel(true)
return
Expand Down Expand Up @@ -780,6 +781,13 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
(e: MessageEvent) => {
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!) {
Expand Down