Skip to content

Commit 048ea9e

Browse files
committed
fix: eliminate UI rerender during task cancellation
- Add resetToResumableState() method to Task class to reset internal state without recreation - Simplify cancellation flow to handle everything within Task class - Update abortStream() to properly mark API requests as cancelled - Remove initClineWithHistoryItem() call from ClineProvider.cancelTask() - Task instance now persists through cancellation, preventing UI flicker The task now handles its own cancellation and resumption internally, maintaining the same instance throughout. This provides a seamless user experience with no visual disruption when cancelling and resuming tasks.
1 parent c52fdc4 commit 048ea9e

File tree

2 files changed

+114
-60
lines changed

2 files changed

+114
-60
lines changed

src/core/task/Task.ts

Lines changed: 110 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1273,7 +1273,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
12731273
}
12741274
}
12751275

1276-
public async abortTask(isAbandoned = false) {
1276+
public async abortTask(isAbandoned = false, skipSave = false) {
12771277
console.log(`[subtasks] aborting task ${this.taskId}.${this.instanceId}`)
12781278

12791279
// Will stop any autonomously running promises.
@@ -1290,15 +1290,74 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
12901290
console.error(`Error during task ${this.taskId}.${this.instanceId} disposal:`, error)
12911291
// Don't rethrow - we want abort to always succeed
12921292
}
1293-
// Save the countdown message in the automatic retry or other content.
1294-
try {
1295-
// Save the countdown message in the automatic retry or other content.
1296-
await this.saveClineMessages()
1297-
} catch (error) {
1298-
console.error(`Error saving messages during abort for task ${this.taskId}.${this.instanceId}:`, error)
1293+
1294+
// Only save messages if not skipping (e.g., during user cancellation where messages are already saved)
1295+
if (!skipSave) {
1296+
try {
1297+
// Save the countdown message in the automatic retry or other content.
1298+
await this.saveClineMessages()
1299+
} catch (error) {
1300+
console.error(`Error saving messages during abort for task ${this.taskId}.${this.instanceId}:`, error)
1301+
}
12991302
}
13001303
}
13011304

1305+
/**
1306+
* Reset the task to a resumable state without recreating the instance.
1307+
* This is used when canceling a task to avoid unnecessary rerenders.
1308+
*/
1309+
public async resetToResumableState() {
1310+
console.log(`[subtasks] resetting task ${this.taskId}.${this.instanceId} to resumable state`)
1311+
1312+
// Reset abort flags
1313+
this.abort = false
1314+
this.abandoned = false
1315+
1316+
// Reset streaming state
1317+
this.isStreaming = false
1318+
this.isWaitingForFirstChunk = false
1319+
this.didFinishAbortingStream = true
1320+
this.didCompleteReadingStream = false
1321+
1322+
// Clear streaming content
1323+
this.currentStreamingContentIndex = 0
1324+
this.currentStreamingDidCheckpoint = false
1325+
this.assistantMessageContent = []
1326+
this.userMessageContent = []
1327+
this.userMessageContentReady = false
1328+
this.didRejectTool = false
1329+
this.didAlreadyUseTool = false
1330+
this.presentAssistantMessageLocked = false
1331+
this.presentAssistantMessageHasPendingUpdates = false
1332+
1333+
// Reset API state
1334+
this.consecutiveMistakeCount = 0
1335+
1336+
// Reset ask response state to allow new messages
1337+
this.askResponse = undefined
1338+
this.askResponseText = undefined
1339+
this.askResponseImages = undefined
1340+
this.blockingAsk = undefined
1341+
1342+
// Reset parser if exists
1343+
if (this.assistantMessageParser) {
1344+
this.assistantMessageParser.reset()
1345+
}
1346+
1347+
// Only reset diff view if it's actively editing
1348+
// This avoids unnecessary operations when diff view is not in use
1349+
if (this.diffViewProvider && this.diffViewProvider.isEditing) {
1350+
await this.diffViewProvider.reset()
1351+
}
1352+
1353+
// The task is now ready to be resumed
1354+
// The API request status has already been updated by abortStream
1355+
// We don't add the resume_task message here because ask() will add it
1356+
1357+
// Keep messages and history intact for resumption
1358+
// The task is now ready to be resumed without recreation
1359+
}
1360+
13021361
// Used when a sub-task is launched and the parent task is waiting for it to
13031362
// finish.
13041363
// TBD: The 1s should be added to the settings, also should add a timeout to
@@ -1502,20 +1561,22 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
15021561
} satisfies ClineApiReqInfo)
15031562
}
15041563

1505-
const abortStream = async (cancelReason: ClineApiReqCancelReason, streamingFailedMessage?: string) => {
1564+
const abortStream = async (
1565+
cancelReason: ClineApiReqCancelReason,
1566+
streamingFailedMessage?: string,
1567+
skipUIUpdates: boolean = false,
1568+
) => {
15061569
if (this.diffViewProvider.isEditing) {
15071570
await this.diffViewProvider.revertChanges() // closes diff view
15081571
}
15091572

1510-
// if last message is a partial we need to update and save it
1573+
// if last message is a partial we need to update it
15111574
const lastMessage = this.clineMessages.at(-1)
15121575

15131576
if (lastMessage && lastMessage.partial) {
15141577
// lastMessage.ts = Date.now() DO NOT update ts since it is used as a key for virtuoso list
15151578
lastMessage.partial = false
1516-
// instead of streaming partialMessage events, we do a save and post like normal to persist to disk
15171579
console.log("updating partial message", lastMessage)
1518-
// await this.saveClineMessages()
15191580
}
15201581

15211582
// Let assistant know their response was interrupted for when task is resumed
@@ -1538,8 +1599,15 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
15381599
// Update `api_req_started` to have cancelled and cost, so that
15391600
// we can display the cost of the partial stream.
15401601
updateApiReqMsg(cancelReason, streamingFailedMessage)
1602+
1603+
// Always save messages to ensure the API request status is updated
15411604
await this.saveClineMessages()
15421605

1606+
// Only post to webview if we're not skipping UI updates
1607+
if (!skipUIUpdates) {
1608+
await this.providerRef.deref()?.postStateToWebview()
1609+
}
1610+
15431611
// Signals to provider that it can retrieve the saved messages
15441612
// from disk, as abortTask can not be awaited on in nature.
15451613
this.didFinishAbortingStream = true
@@ -1622,7 +1690,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
16221690
// isn't abandoned (sometimes OpenRouter stream
16231691
// hangs, in which case this would affect future
16241692
// instances of Cline).
1625-
await abortStream("user_cancelled")
1693+
// Don't skip UI updates - we need to update the API request status
1694+
await abortStream("user_cancelled", undefined, false)
16261695
}
16271696

16281697
break // Aborts the stream.
@@ -1659,24 +1728,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
16591728
// may have executed), so we just resort to replicating a
16601729
// cancel task.
16611730

1662-
// Check if this was a user-initiated cancellation BEFORE calling abortTask
1663-
// If this.abort is already true, it means the user clicked cancel, so we should
1664-
// treat this as "user_cancelled" rather than "streaming_failed"
1665-
const cancelReason = this.abort ? "user_cancelled" : "streaming_failed"
1731+
const streamingFailedMessage = error.message ?? JSON.stringify(serializeError(error), null, 2)
16661732

1667-
const streamingFailedMessage = this.abort
1668-
? undefined
1669-
: (error.message ?? JSON.stringify(serializeError(error), null, 2))
1670-
1671-
// Now call abortTask after determining the cancel reason.
1672-
await this.abortTask()
1673-
await abortStream(cancelReason, streamingFailedMessage)
1674-
1675-
const history = await provider?.getTaskWithId(this.taskId)
1676-
1677-
if (history) {
1678-
await provider?.initClineWithHistoryItem(history.historyItem)
1679-
}
1733+
// For streaming failures, use the original abort flow
1734+
await this.abortTask(false, false)
1735+
await abortStream("streaming_failed", streamingFailedMessage, false)
16801736
}
16811737
} finally {
16821738
this.isStreaming = false
@@ -1702,6 +1758,31 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
17021758

17031759
// Need to call here in case the stream was aborted.
17041760
if (this.abort || this.abandoned) {
1761+
// If this was a user cancellation, reset and show resume prompt
1762+
if (this.abort && !this.abandoned) {
1763+
// Reset the task to a resumable state
1764+
this.abort = false
1765+
await this.resetToResumableState()
1766+
1767+
// Show the resume prompt
1768+
const { response, text, images } = await this.ask("resume_task")
1769+
1770+
if (response === "messageResponse") {
1771+
await this.say("user_feedback", text, images)
1772+
// Continue with the new user input
1773+
const newUserContent: Anthropic.Messages.ContentBlockParam[] = []
1774+
if (text) {
1775+
newUserContent.push({ type: "text", text })
1776+
}
1777+
if (images && images.length > 0) {
1778+
newUserContent.push(...formatResponse.imageBlocks(images))
1779+
}
1780+
// Recursively continue with the new content
1781+
return await this.recursivelyMakeClineRequests(newUserContent)
1782+
}
1783+
// If not messageResponse, the task will end
1784+
return true
1785+
}
17051786
throw new Error(`[RooCode#recursivelyMakeRooRequests] task ${this.taskId}.${this.instanceId} aborted`)
17061787
}
17071788

src/core/webview/ClineProvider.ts

Lines changed: 4 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1150,38 +1150,11 @@ export class ClineProvider
11501150

11511151
console.log(`[subtasks] cancelling task ${cline.taskId}.${cline.instanceId}`)
11521152

1153-
const { historyItem } = await this.getTaskWithId(cline.taskId)
1154-
// Preserve parent and root task information for history item.
1155-
const rootTask = cline.rootTask
1156-
const parentTask = cline.parentTask
1157-
1158-
cline.abortTask()
1159-
1160-
await pWaitFor(
1161-
() =>
1162-
this.getCurrentCline()! === undefined ||
1163-
this.getCurrentCline()!.isStreaming === false ||
1164-
this.getCurrentCline()!.didFinishAbortingStream ||
1165-
// If only the first chunk is processed, then there's no
1166-
// need to wait for graceful abort (closes edits, browser,
1167-
// etc).
1168-
this.getCurrentCline()!.isWaitingForFirstChunk,
1169-
{
1170-
timeout: 3_000,
1171-
},
1172-
).catch(() => {
1173-
console.error("Failed to abort task")
1174-
})
1175-
1176-
if (this.getCurrentCline()) {
1177-
// 'abandoned' will prevent this Cline instance from affecting
1178-
// future Cline instances. This may happen if its hanging on a
1179-
// streaming request.
1180-
this.getCurrentCline()!.abandoned = true
1181-
}
1153+
// Just set the abort flag - the task will handle its own resumption
1154+
cline.abort = true
11821155

1183-
// Clears task again, so we need to abortTask manually above.
1184-
await this.initClineWithHistoryItem({ ...historyItem, rootTask, parentTask })
1156+
// The task's streaming loop will detect the abort flag and handle the resumption
1157+
// No need to wait or do anything else here
11851158
}
11861159

11871160
async updateCustomInstructions(instructions?: string) {

0 commit comments

Comments
 (0)