Skip to content

Commit e00765c

Browse files
allquixoticoz-agent
andcommitted
Fix task-scoped stop state
Co-Authored-By: Oz <oz-agent@warp.dev>
1 parent 82e3124 commit e00765c

7 files changed

Lines changed: 203 additions & 11 deletions

File tree

packages/types/src/task.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export interface TaskProviderLike extends EventEmitter<TaskProviderEvents> {
2222
options?: CreateTaskOptions,
2323
configuration?: RooCodeSettings,
2424
): Promise<TaskLike>
25-
cancelTask(): Promise<void>
25+
cancelTask(taskId?: string): Promise<void>
2626
clearTask(): Promise<void>
2727
resumeTask(taskId: string): void
2828

@@ -37,7 +37,6 @@ export interface TaskProviderLike extends EventEmitter<TaskProviderEvents> {
3737
setProviderProfile(providerProfile: string): Promise<void>
3838
readonly cwd: string
3939

40-
4140
// @TODO: Find a better way to do this.
4241
postStateToWebview(): Promise<void>
4342
}

src/core/webview/ClineProvider.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3393,14 +3393,15 @@ export class ClineProvider
33933393
return task
33943394
}
33953395

3396-
public async cancelTask(): Promise<void> {
3397-
const task = this.getCurrentTask()
3396+
public async cancelTask(taskId?: string): Promise<void> {
3397+
const task = this.resolveMessageTask(taskId)
33983398

33993399
if (!task) {
34003400
return
34013401
}
34023402

34033403
console.log(`[cancelTask] cancelling task ${task.taskId}.${task.instanceId}`)
3404+
const wasVisible = this.isTaskVisible(task.taskId)
34043405
const preservedQueuedMessages = task.messageQueueService.messages.map((message) => ({
34053406
...message,
34063407
images: message.images ? [...message.images] : undefined,
@@ -3453,7 +3454,7 @@ export class ClineProvider
34533454
}
34543455
const replacementTask = await this.createTaskWithHistoryItem(
34553456
{ ...historyItem, rootTask, parentTask },
3456-
{ replaceExistingTask: true },
3457+
{ replaceExistingTask: true, focus: wasVisible },
34573458
)
34583459

34593460
if (replacementTask && preservedQueuedMessages.length > 0) {

src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,7 @@ describe("ClineProvider flicker-free cancel", () => {
350350
mockTask1.rootTask = { taskId: "root-task" }
351351
mockTask1.parentTask = { taskId: "parent-task" }
352352
;(provider as any).clineStack = [mockTask1]
353+
;(provider as any).visibleTaskId = "task-1"
353354

354355
const createTaskWithHistoryItemSpy = vi
355356
.spyOn(provider, "createTaskWithHistoryItem")
@@ -371,7 +372,7 @@ describe("ClineProvider flicker-free cancel", () => {
371372
rootTask: mockTask1.rootTask,
372373
parentTask: mockTask1.parentTask,
373374
}),
374-
{ replaceExistingTask: true },
375+
{ replaceExistingTask: true, focus: true },
375376
)
376377
expect(replacementTask.messageQueueService.restoreMessages).toHaveBeenCalledWith([
377378
{
@@ -384,4 +385,53 @@ describe("ClineProvider flicker-free cancel", () => {
384385
expect(replacementTask.setDeferQueuedMessageDrainUntilResume).toHaveBeenCalledWith(true)
385386
expect(removeClineFromStackSpy).not.toHaveBeenCalled()
386387
})
388+
389+
it("hard-stops the requested task without cancelling the visible task", async () => {
390+
const visibleTask = {
391+
taskId: "task-2",
392+
instanceId: "instance-2",
393+
emit: vi.fn(),
394+
abortTask: vi.fn().mockResolvedValue(undefined),
395+
cancelCurrentRequest: vi.fn(),
396+
cancelAutoApprovalTimeout: vi.fn(),
397+
supersedePendingAsk: vi.fn(),
398+
messageQueueService: { messages: [] },
399+
terminalProcess: { abort: vi.fn() },
400+
rootTask: undefined,
401+
parentTask: undefined,
402+
abandoned: false,
403+
abort: false,
404+
on: vi.fn(),
405+
off: vi.fn(),
406+
}
407+
const replacementTask = {
408+
messageQueueService: {
409+
restoreMessages: vi.fn(),
410+
},
411+
setDeferQueuedMessageDrainUntilResume: vi.fn(),
412+
}
413+
414+
;(provider as any).clineStack = [mockTask1, visibleTask]
415+
;(provider as any).visibleTaskId = "task-2"
416+
417+
const createTaskWithHistoryItemSpy = vi
418+
.spyOn(provider, "createTaskWithHistoryItem")
419+
.mockResolvedValue(replacementTask as any)
420+
421+
await provider.cancelTask("task-1")
422+
423+
expect(mockTask1.abortReason).toBe("user_cancelled")
424+
expect(mockTask1.cancelCurrentRequest).toHaveBeenCalledTimes(1)
425+
expect(mockTask1.cancelAutoApprovalTimeout).toHaveBeenCalledTimes(1)
426+
expect(mockTask1.supersedePendingAsk).toHaveBeenCalledTimes(1)
427+
expect(mockTask1.abortTask).toHaveBeenCalledWith(true)
428+
expect(visibleTask.cancelCurrentRequest).not.toHaveBeenCalled()
429+
expect(visibleTask.cancelAutoApprovalTimeout).not.toHaveBeenCalled()
430+
expect(visibleTask.supersedePendingAsk).not.toHaveBeenCalled()
431+
expect(visibleTask.abortTask).not.toHaveBeenCalled()
432+
expect(createTaskWithHistoryItemSpy).toHaveBeenCalledWith(expect.objectContaining({ id: "task-1" }), {
433+
replaceExistingTask: true,
434+
focus: false,
435+
})
436+
})
387437
})

src/core/webview/webviewMessageHandler.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1388,7 +1388,7 @@ export const webviewMessageHandler = async (
13881388
break
13891389
}
13901390
case "cancelTask":
1391-
await provider.cancelTask()
1391+
await provider.cancelTask(message.taskId)
13921392
break
13931393
case "cancelAutoApproval":
13941394
// Cancel any pending auto-approval timeout for the target task

src/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"displayName": "%extension.displayName%",
44
"description": "%extension.description%",
55
"publisher": "allquixotic",
6-
"version": "3.53.16",
6+
"version": "3.53.17",
77
"icon": "assets/icons/icon.png",
88
"galleryBanner": {
99
"color": "#617A91",

webview-ui/src/components/chat/ChatView.tsx

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,23 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
651651
return false
652652
}, [modifiedMessages, clineAsk, enableButtons, primaryButtonText])
653653

654+
useEffect(() => {
655+
// Button/ask state is local React state, while messages are task-scoped.
656+
// When switching from a paused task to a running task whose last message is
657+
// not an ask, clear the previous task's Continue/approval buttons instead
658+
// of carrying them onto the newly selected task.
659+
if (lastMessage?.type === "ask") {
660+
return
661+
}
662+
663+
setClineAsk(undefined)
664+
setEnableButtons(false)
665+
setPrimaryButtonText(undefined)
666+
setSecondaryButtonText(undefined)
667+
setSendingDisabled(isStreaming)
668+
setDidClickCancel(false)
669+
}, [currentTaskId, isStreaming, lastMessage?.type, taskTs])
670+
654671
const markFollowUpAsAnswered = useCallback(() => {
655672
const lastFollowUpMessage = messagesRef.current.findLast((msg: ClineMessage) => msg.ask === "followup")
656673
if (lastFollowUpMessage) {
@@ -915,9 +932,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
915932

916933
// Handle stop button click from textarea
917934
const handleStopTask = useCallback(() => {
918-
vscode.postMessage({ type: "cancelTask" })
935+
vscode.postMessage({ type: "cancelTask", taskId: currentTaskId })
919936
setDidClickCancel(true)
920-
}, [setDidClickCancel])
937+
}, [currentTaskId, setDidClickCancel])
921938

922939
// This logic depends on the useEffect[messages] above to set clineAsk,
923940
// after which buttons are shown and we then send an askResponse to the
@@ -1019,7 +1036,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
10191036
const trimmedInput = text?.trim()
10201037

10211038
if (isStreaming) {
1022-
vscode.postMessage({ type: "cancelTask" })
1039+
vscode.postMessage({ type: "cancelTask", taskId: currentTaskId })
10231040
setDidClickCancel(true)
10241041
return
10251042
}

webview-ui/src/components/chat/__tests__/ChatView.spec.tsx

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ interface ClineMessage {
1414
type: "say" | "ask"
1515
say?: string
1616
ask?: string
17+
task?: string
1718
ts: number
1819
text?: string
1920
partial?: boolean
@@ -153,6 +154,8 @@ interface ChatTextAreaProps {
153154
placeholderText?: string
154155
selectedImages?: string[]
155156
shouldDisableImages?: boolean
157+
isStreaming?: boolean
158+
onStop?: () => void
156159
}
157160

158161
const mockInputRef = React.createRef<HTMLInputElement>()
@@ -195,6 +198,11 @@ vi.mock("../ChatTextArea", () => {
195198
}}
196199
data-sending-disabled={props.sendingDisabled}
197200
/>
201+
{props.isStreaming && props.onStop && (
202+
<button type="button" onClick={props.onStop}>
203+
Stop
204+
</button>
205+
)}
198206
</div>
199207
)
200208
})
@@ -1017,6 +1025,123 @@ describe("ChatView - Message Queueing Tests", () => {
10171025
})
10181026
})
10191027

1028+
describe("ChatView - Task-local Stop and Continue state", () => {
1029+
beforeEach(() => {
1030+
vi.clearAllMocks()
1031+
vi.mocked(vscode.postMessage).mockClear()
1032+
})
1033+
1034+
it("clears a paused task's Continue button when switching to a running task", async () => {
1035+
const { getByText, queryByText, getByTestId } = renderChatView()
1036+
1037+
const activeConversations = [
1038+
{
1039+
rootTaskId: "task-a",
1040+
activeTaskId: "task-a",
1041+
rootTask: "Paused task",
1042+
activeTask: "Paused task",
1043+
ts: 1000,
1044+
status: "resumable",
1045+
queuedMessageCount: 0,
1046+
steerMessageCount: 0,
1047+
},
1048+
{
1049+
rootTaskId: "task-b",
1050+
activeTaskId: "task-b",
1051+
rootTask: "Running task",
1052+
activeTask: "Running task",
1053+
ts: 2000,
1054+
status: "running",
1055+
queuedMessageCount: 0,
1056+
steerMessageCount: 0,
1057+
},
1058+
]
1059+
1060+
mockPostMessage({
1061+
currentTaskId: "task-a",
1062+
currentTaskItem: { id: "task-a" },
1063+
activeConversations,
1064+
clineMessages: [
1065+
{
1066+
type: "say",
1067+
say: "task",
1068+
ts: 100,
1069+
text: "Paused task",
1070+
},
1071+
{
1072+
type: "ask",
1073+
ask: "resume_task",
1074+
ts: 101,
1075+
text: "",
1076+
partial: false,
1077+
},
1078+
],
1079+
})
1080+
1081+
await waitFor(() => {
1082+
expect(getByText("chat:resumeTask.title")).toBeInTheDocument()
1083+
})
1084+
1085+
mockPostMessage({
1086+
currentTaskId: "task-b",
1087+
currentTaskItem: { id: "task-b" },
1088+
activeConversations,
1089+
clineMessages: [
1090+
{
1091+
type: "say",
1092+
say: "task",
1093+
ts: 200,
1094+
text: "Running task",
1095+
},
1096+
{
1097+
type: "say",
1098+
say: "text",
1099+
ts: 201,
1100+
text: "Still running",
1101+
},
1102+
],
1103+
})
1104+
1105+
await waitFor(() => {
1106+
expect(getByTestId("chat-textarea")).toBeInTheDocument()
1107+
expect(queryByText("chat:resumeTask.title")).not.toBeInTheDocument()
1108+
expect(queryByText("chat:terminate.title")).not.toBeInTheDocument()
1109+
})
1110+
})
1111+
1112+
it("includes the current task id when stopping a streaming task", async () => {
1113+
const { getByText } = renderChatView()
1114+
1115+
mockPostMessage({
1116+
currentTaskId: "task-1",
1117+
currentTaskItem: { id: "task-1" },
1118+
clineMessages: [
1119+
{
1120+
type: "say",
1121+
say: "task",
1122+
ts: 100,
1123+
text: "Streaming task",
1124+
},
1125+
{
1126+
type: "say",
1127+
say: "api_req_started",
1128+
ts: 101,
1129+
text: JSON.stringify({ apiProtocol: "anthropic" }),
1130+
},
1131+
],
1132+
})
1133+
1134+
await waitFor(() => {
1135+
expect(getByText("Stop")).toBeInTheDocument()
1136+
})
1137+
1138+
vi.mocked(vscode.postMessage).mockClear()
1139+
fireEvent.click(getByText("Stop"))
1140+
1141+
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "cancelTask", taskId: "task-1" })
1142+
})
1143+
})
1144+
10201145
describe("ChatView - Context Condensing Indicator Tests", () => {
10211146
beforeEach(() => {
10221147
vi.clearAllMocks()

0 commit comments

Comments
 (0)