Skip to content

Commit e25b4e6

Browse files
authored
fix: eliminate UI flicker during task cancellation (#9037)
* fix: eliminate UI flicker during task cancellation - Modify ClineProvider.createTaskWithHistoryItem() to detect when rehydrating current task - Implement in-place task replacement to avoid empty stack state that causes UI flicker - Add comprehensive unit tests for flicker-free cancel behavior - Maintain backward compatibility and proper event listener cleanup Fixes the jarring navigation to home view when cancelling tasks * fix: ensure proper garbage collection of old task during flicker-free rehydration - Call abortTask(true) on old task before replacement to stop processes and mark as abandoned - This ensures proper cleanup and prevents memory leaks during task cancellation - Add test verification for abortTask cleanup
1 parent 54745fc commit e25b4e6

File tree

2 files changed

+367
-5
lines changed

2 files changed

+367
-5
lines changed

src/core/webview/ClineProvider.ts

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -858,7 +858,13 @@ export class ClineProvider
858858
}
859859

860860
public async createTaskWithHistoryItem(historyItem: HistoryItem & { rootTask?: Task; parentTask?: Task }) {
861-
await this.removeClineFromStack()
861+
// Check if we're rehydrating the current task to avoid flicker
862+
const currentTask = this.getCurrentTask()
863+
const isRehydratingCurrentTask = currentTask && currentTask.taskId === historyItem.id
864+
865+
if (!isRehydratingCurrentTask) {
866+
await this.removeClineFromStack()
867+
}
862868

863869
// If the history item has a saved mode, restore it and its associated API configuration.
864870
if (historyItem.mode) {
@@ -932,11 +938,46 @@ export class ClineProvider
932938
enableBridge: BridgeOrchestrator.isEnabled(cloudUserInfo, taskSyncEnabled),
933939
})
934940

935-
await this.addClineToStack(task)
941+
if (isRehydratingCurrentTask) {
942+
// Replace the current task in-place to avoid UI flicker
943+
const stackIndex = this.clineStack.length - 1
936944

937-
this.log(
938-
`[createTaskWithHistoryItem] ${task.parentTask ? "child" : "parent"} task ${task.taskId}.${task.instanceId} instantiated`,
939-
)
945+
// Properly dispose of the old task to ensure garbage collection
946+
const oldTask = this.clineStack[stackIndex]
947+
948+
// Abort the old task to stop running processes and mark as abandoned
949+
try {
950+
await oldTask.abortTask(true)
951+
} catch (e) {
952+
this.log(
953+
`[createTaskWithHistoryItem] abortTask() failed for old task ${oldTask.taskId}.${oldTask.instanceId}: ${e.message}`,
954+
)
955+
}
956+
957+
// Remove event listeners from the old task
958+
const cleanupFunctions = this.taskEventListeners.get(oldTask)
959+
if (cleanupFunctions) {
960+
cleanupFunctions.forEach((cleanup) => cleanup())
961+
this.taskEventListeners.delete(oldTask)
962+
}
963+
964+
// Replace the task in the stack
965+
this.clineStack[stackIndex] = task
966+
task.emit(RooCodeEventName.TaskFocused)
967+
968+
// Perform preparation tasks and set up event listeners
969+
await this.performPreparationTasks(task)
970+
971+
this.log(
972+
`[createTaskWithHistoryItem] rehydrated task ${task.taskId}.${task.instanceId} in-place (flicker-free)`,
973+
)
974+
} else {
975+
await this.addClineToStack(task)
976+
977+
this.log(
978+
`[createTaskWithHistoryItem] ${task.parentTask ? "child" : "parent"} task ${task.taskId}.${task.instanceId} instantiated`,
979+
)
980+
}
940981

941982
// Check if there's a pending edit after checkpoint restoration
942983
const operationId = `task-${task.taskId}`
Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest"
2+
import * as vscode from "vscode"
3+
4+
import { ClineProvider } from "../ClineProvider"
5+
import { Task } from "../../task/Task"
6+
import { ContextProxy } from "../../config/ContextProxy"
7+
import type { ProviderSettings, HistoryItem } from "@roo-code/types"
8+
9+
// Mock dependencies
10+
vi.mock("vscode", () => {
11+
const mockDisposable = { dispose: vi.fn() }
12+
return {
13+
workspace: {
14+
getConfiguration: vi.fn(() => ({
15+
get: vi.fn().mockReturnValue([]),
16+
update: vi.fn().mockResolvedValue(undefined),
17+
})),
18+
workspaceFolders: [],
19+
onDidChangeConfiguration: vi.fn(() => mockDisposable),
20+
},
21+
env: {
22+
uriScheme: "vscode",
23+
language: "en",
24+
},
25+
EventEmitter: vi.fn().mockImplementation(() => ({
26+
event: vi.fn(),
27+
fire: vi.fn(),
28+
})),
29+
Disposable: {
30+
from: vi.fn(),
31+
},
32+
window: {
33+
showErrorMessage: vi.fn(),
34+
createTextEditorDecorationType: vi.fn().mockReturnValue({
35+
dispose: vi.fn(),
36+
}),
37+
onDidChangeActiveTextEditor: vi.fn(() => mockDisposable),
38+
},
39+
Uri: {
40+
file: vi.fn().mockReturnValue({ toString: () => "file://test" }),
41+
},
42+
}
43+
})
44+
45+
vi.mock("../../task/Task")
46+
vi.mock("../../config/ContextProxy")
47+
vi.mock("../../../services/mcp/McpServerManager", () => ({
48+
McpServerManager: {
49+
getInstance: vi.fn().mockResolvedValue({
50+
registerClient: vi.fn(),
51+
}),
52+
unregisterProvider: vi.fn(),
53+
},
54+
}))
55+
vi.mock("../../../services/marketplace")
56+
vi.mock("../../../integrations/workspace/WorkspaceTracker")
57+
vi.mock("../../config/ProviderSettingsManager")
58+
vi.mock("../../config/CustomModesManager")
59+
vi.mock("../../../utils/path", () => ({
60+
getWorkspacePath: vi.fn().mockReturnValue("/test/workspace"),
61+
}))
62+
63+
// Mock TelemetryService
64+
vi.mock("@roo-code/telemetry", () => ({
65+
TelemetryService: {
66+
instance: {
67+
setProvider: vi.fn(),
68+
captureTaskCreated: vi.fn(),
69+
},
70+
},
71+
}))
72+
73+
// Mock CloudService
74+
vi.mock("@roo-code/cloud", () => ({
75+
CloudService: {
76+
hasInstance: vi.fn().mockReturnValue(false),
77+
instance: {
78+
isAuthenticated: vi.fn().mockReturnValue(false),
79+
},
80+
},
81+
BridgeOrchestrator: {
82+
isEnabled: vi.fn().mockReturnValue(false),
83+
},
84+
getRooCodeApiUrl: vi.fn().mockReturnValue("https://api.roo-code.com"),
85+
}))
86+
87+
vi.mock("../../../shared/embeddingModels", () => ({
88+
EMBEDDING_MODEL_PROFILES: [],
89+
}))
90+
91+
describe("ClineProvider flicker-free cancel", () => {
92+
let provider: ClineProvider
93+
let mockContext: any
94+
let mockOutputChannel: any
95+
let mockTask1: any
96+
let mockTask2: any
97+
98+
const mockApiConfig: ProviderSettings = {
99+
apiProvider: "anthropic",
100+
apiKey: "test-key",
101+
} as ProviderSettings
102+
103+
beforeEach(() => {
104+
vi.clearAllMocks()
105+
106+
// Setup mock extension context
107+
mockContext = {
108+
globalState: {
109+
get: vi.fn().mockReturnValue(undefined),
110+
update: vi.fn().mockResolvedValue(undefined),
111+
keys: vi.fn().mockReturnValue([]),
112+
},
113+
globalStorageUri: { fsPath: "/test/storage" },
114+
secrets: {
115+
get: vi.fn().mockResolvedValue(undefined),
116+
store: vi.fn().mockResolvedValue(undefined),
117+
delete: vi.fn().mockResolvedValue(undefined),
118+
},
119+
workspaceState: {
120+
get: vi.fn().mockReturnValue(undefined),
121+
update: vi.fn().mockResolvedValue(undefined),
122+
keys: vi.fn().mockReturnValue([]),
123+
},
124+
extensionUri: { fsPath: "/test/extension" },
125+
}
126+
127+
// Setup mock output channel
128+
mockOutputChannel = {
129+
appendLine: vi.fn(),
130+
dispose: vi.fn(),
131+
}
132+
133+
// Setup mock context proxy
134+
const mockContextProxy = {
135+
getValues: vi.fn().mockReturnValue({}),
136+
getValue: vi.fn().mockReturnValue(undefined),
137+
setValue: vi.fn().mockResolvedValue(undefined),
138+
getProviderSettings: vi.fn().mockReturnValue(mockApiConfig),
139+
extensionUri: mockContext.extensionUri,
140+
globalStorageUri: mockContext.globalStorageUri,
141+
}
142+
143+
// Create provider instance
144+
provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", mockContextProxy as any)
145+
146+
// Mock provider methods
147+
provider.getState = vi.fn().mockResolvedValue({
148+
apiConfiguration: mockApiConfig,
149+
mode: "code",
150+
})
151+
152+
provider.postStateToWebview = vi.fn().mockResolvedValue(undefined)
153+
// Mock private method using any cast
154+
;(provider as any).updateGlobalState = vi.fn().mockResolvedValue(undefined)
155+
provider.activateProviderProfile = vi.fn().mockResolvedValue(undefined)
156+
provider.performPreparationTasks = vi.fn().mockResolvedValue(undefined)
157+
provider.getTaskWithId = vi.fn().mockImplementation((id) =>
158+
Promise.resolve({
159+
historyItem: {
160+
id,
161+
number: 1,
162+
ts: Date.now(),
163+
task: "test task",
164+
tokensIn: 100,
165+
tokensOut: 200,
166+
totalCost: 0.001,
167+
workspace: "/test/workspace",
168+
},
169+
}),
170+
)
171+
172+
// Setup mock tasks
173+
mockTask1 = {
174+
taskId: "task-1",
175+
instanceId: "instance-1",
176+
emit: vi.fn(),
177+
abortTask: vi.fn().mockResolvedValue(undefined),
178+
abandoned: false,
179+
dispose: vi.fn(),
180+
on: vi.fn(),
181+
off: vi.fn(),
182+
}
183+
184+
mockTask2 = {
185+
taskId: "task-1", // Same ID for rehydration scenario
186+
instanceId: "instance-2", // Different instance
187+
emit: vi.fn(),
188+
on: vi.fn(),
189+
off: vi.fn(),
190+
}
191+
192+
// Mock Task constructor
193+
vi.mocked(Task).mockImplementation(() => mockTask2 as any)
194+
})
195+
196+
it("should not remove current task from stack when rehydrating same taskId", async () => {
197+
// Setup: Add a task to the stack first
198+
;(provider as any).clineStack = [mockTask1]
199+
200+
// Mock event listeners for cleanup
201+
;(provider as any).taskEventListeners = new WeakMap()
202+
const mockCleanupFunctions = [vi.fn(), vi.fn()]
203+
;(provider as any).taskEventListeners.set(mockTask1, mockCleanupFunctions)
204+
205+
// Spy on removeClineFromStack to verify it's NOT called
206+
const removeClineFromStackSpy = vi.spyOn(provider, "removeClineFromStack")
207+
208+
// Create history item with same taskId as current task
209+
const historyItem: HistoryItem = {
210+
id: "task-1", // Same as mockTask1.taskId
211+
number: 1,
212+
task: "test task",
213+
ts: Date.now(),
214+
tokensIn: 100,
215+
tokensOut: 200,
216+
totalCost: 0.001,
217+
workspace: "/test/workspace",
218+
}
219+
220+
// Act: Create task with history item (should rehydrate in-place)
221+
await provider.createTaskWithHistoryItem(historyItem)
222+
223+
// Assert: removeClineFromStack should NOT be called
224+
expect(removeClineFromStackSpy).not.toHaveBeenCalled()
225+
226+
// Verify the task was replaced in-place
227+
expect((provider as any).clineStack).toHaveLength(1)
228+
expect((provider as any).clineStack[0]).toBe(mockTask2)
229+
230+
// Verify old event listeners were cleaned up
231+
expect(mockCleanupFunctions[0]).toHaveBeenCalled()
232+
expect(mockCleanupFunctions[1]).toHaveBeenCalled()
233+
234+
// Verify new task received focus event
235+
expect(mockTask2.emit).toHaveBeenCalledWith("taskFocused")
236+
})
237+
238+
it("should remove task from stack when creating different task", async () => {
239+
// Setup: Add a task to the stack first
240+
;(provider as any).clineStack = [mockTask1]
241+
242+
// Spy on removeClineFromStack to verify it IS called
243+
const removeClineFromStackSpy = vi.spyOn(provider, "removeClineFromStack").mockResolvedValue(undefined)
244+
245+
// Create history item with different taskId
246+
const historyItem: HistoryItem = {
247+
id: "task-2", // Different from mockTask1.taskId
248+
number: 2,
249+
task: "different task",
250+
ts: Date.now(),
251+
tokensIn: 150,
252+
tokensOut: 250,
253+
totalCost: 0.002,
254+
workspace: "/test/workspace",
255+
}
256+
257+
// Act: Create task with different history item
258+
await provider.createTaskWithHistoryItem(historyItem)
259+
260+
// Assert: removeClineFromStack should be called
261+
expect(removeClineFromStackSpy).toHaveBeenCalled()
262+
})
263+
264+
it("should handle empty stack gracefully during rehydration attempt", async () => {
265+
// Setup: Empty stack
266+
;(provider as any).clineStack = []
267+
268+
// Spy on removeClineFromStack
269+
const removeClineFromStackSpy = vi.spyOn(provider, "removeClineFromStack").mockResolvedValue(undefined)
270+
271+
// Create history item
272+
const historyItem: HistoryItem = {
273+
id: "task-1",
274+
number: 1,
275+
task: "test task",
276+
ts: Date.now(),
277+
tokensIn: 100,
278+
tokensOut: 200,
279+
totalCost: 0.001,
280+
workspace: "/test/workspace",
281+
}
282+
283+
// Act: Should not error and should call removeClineFromStack
284+
await provider.createTaskWithHistoryItem(historyItem)
285+
286+
// Assert: removeClineFromStack should be called (no current task to rehydrate)
287+
expect(removeClineFromStackSpy).toHaveBeenCalled()
288+
})
289+
290+
it("should maintain task stack integrity during flicker-free replacement", async () => {
291+
// Setup: Stack with multiple tasks
292+
const mockParentTask = {
293+
taskId: "parent-task",
294+
instanceId: "parent-instance",
295+
emit: vi.fn(),
296+
}
297+
298+
;(provider as any).clineStack = [mockParentTask, mockTask1]
299+
;(provider as any).taskEventListeners = new WeakMap()
300+
;(provider as any).taskEventListeners.set(mockTask1, [vi.fn()])
301+
302+
// Act: Rehydrate the current (top) task
303+
const historyItem: HistoryItem = {
304+
id: "task-1",
305+
number: 1,
306+
task: "test task",
307+
ts: Date.now(),
308+
tokensIn: 100,
309+
tokensOut: 200,
310+
totalCost: 0.001,
311+
workspace: "/test/workspace",
312+
}
313+
314+
await provider.createTaskWithHistoryItem(historyItem)
315+
316+
// Assert: Stack should maintain parent task and replace current task
317+
expect((provider as any).clineStack).toHaveLength(2)
318+
expect((provider as any).clineStack[0]).toBe(mockParentTask)
319+
expect((provider as any).clineStack[1]).toBe(mockTask2)
320+
})
321+
})

0 commit comments

Comments
 (0)