Skip to content

Commit d976a9b

Browse files
roomote[bot]roomotemrubens
authored
fix: cancel auto-approval timeout when user starts typing (#9937)
Co-authored-by: Roo Code <[email protected]> Co-authored-by: Matt Rubens <[email protected]>
1 parent 495b5c6 commit d976a9b

File tree

7 files changed

+232
-8
lines changed

7 files changed

+232
-8
lines changed

src/core/task/Task.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
278278
private askResponseText?: string
279279
private askResponseImages?: string[]
280280
public lastMessageTs?: number
281+
private autoApprovalTimeoutRef?: NodeJS.Timeout
281282

282283
// Tool Use
283284
consecutiveMistakeCount: number = 0
@@ -1095,12 +1096,13 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
10951096
} else if (approval.decision === "deny") {
10961097
this.denyAsk()
10971098
} else if (approval.decision === "timeout") {
1098-
timeouts.push(
1099-
setTimeout(() => {
1100-
const { askResponse, text, images } = approval.fn()
1101-
this.handleWebviewAskResponse(askResponse, text, images)
1102-
}, approval.timeout),
1103-
)
1099+
// Store the auto-approval timeout so it can be cancelled if user interacts
1100+
this.autoApprovalTimeoutRef = setTimeout(() => {
1101+
const { askResponse, text, images } = approval.fn()
1102+
this.handleWebviewAskResponse(askResponse, text, images)
1103+
this.autoApprovalTimeoutRef = undefined
1104+
}, approval.timeout)
1105+
timeouts.push(this.autoApprovalTimeoutRef)
11041106
}
11051107

11061108
// The state is mutable if the message is complete and the task will
@@ -1209,6 +1211,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
12091211
}
12101212

12111213
handleWebviewAskResponse(askResponse: ClineAskResponse, text?: string, images?: string[]) {
1214+
// Clear any pending auto-approval timeout when user responds
1215+
this.cancelAutoApprovalTimeout()
1216+
12121217
this.askResponse = askResponse
12131218
this.askResponseText = text
12141219
this.askResponseImages = images
@@ -1239,6 +1244,17 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
12391244
}
12401245
}
12411246

1247+
/**
1248+
* Cancel any pending auto-approval timeout.
1249+
* Called when user interacts (types, clicks buttons, etc.) to prevent the timeout from firing.
1250+
*/
1251+
public cancelAutoApprovalTimeout(): void {
1252+
if (this.autoApprovalTimeoutRef) {
1253+
clearTimeout(this.autoApprovalTimeoutRef)
1254+
this.autoApprovalTimeoutRef = undefined
1255+
}
1256+
}
1257+
12421258
public approveAsk({ text, images }: { text?: string; images?: string[] } = {}) {
12431259
this.handleWebviewAskResponse("yesButtonClicked", text, images)
12441260
}

src/core/webview/webviewMessageHandler.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1107,6 +1107,10 @@ export const webviewMessageHandler = async (
11071107
case "cancelTask":
11081108
await provider.cancelTask()
11091109
break
1110+
case "cancelAutoApproval":
1111+
// Cancel any pending auto-approval timeout for the current task
1112+
provider.getCurrentTask()?.cancelAutoApprovalTimeout()
1113+
break
11101114
case "killBrowserSession":
11111115
{
11121116
const task = provider.getCurrentTask()

src/shared/WebviewMessage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export interface WebviewMessage {
6868
| "openFile"
6969
| "openMention"
7070
| "cancelTask"
71+
| "cancelAutoApproval"
7172
| "updateVSCodeSetting"
7273
| "getVSCodeSetting"
7374
| "vsCodeSetting"

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ interface ChatRowProps {
109109
onBatchFileResponse?: (response: { [key: string]: boolean }) => void
110110
onFollowUpUnmount?: () => void
111111
isFollowUpAnswered?: boolean
112+
isFollowUpAutoApprovalPaused?: boolean
112113
editable?: boolean
113114
hasCheckpoint?: boolean
114115
}
@@ -162,6 +163,7 @@ export const ChatRowContent = ({
162163
onFollowUpUnmount,
163164
onBatchFileResponse,
164165
isFollowUpAnswered,
166+
isFollowUpAutoApprovalPaused,
165167
}: ChatRowContentProps) => {
166168
const { t, i18n } = useTranslation()
167169

@@ -1544,6 +1546,7 @@ export const ChatRowContent = ({
15441546
ts={message?.ts}
15451547
onCancelAutoApproval={onFollowUpUnmount}
15461548
isAnswered={isFollowUpAnswered}
1549+
isFollowUpAutoApprovalPaused={isFollowUpAutoApprovalPaused}
15471550
/>
15481551
</div>
15491552
</>

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,20 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
189189
inputValueRef.current = inputValue
190190
}, [inputValue])
191191

192+
// Compute whether auto-approval is paused (user is typing in a followup)
193+
const isFollowUpAutoApprovalPaused = useMemo(() => {
194+
return !!(inputValue && inputValue.trim().length > 0 && clineAsk === "followup")
195+
}, [inputValue, clineAsk])
196+
197+
// Cancel auto-approval timeout when user starts typing
198+
useEffect(() => {
199+
// Only send cancel if there's actual input (user is typing)
200+
// and we have a pending follow-up question
201+
if (isFollowUpAutoApprovalPaused) {
202+
vscode.postMessage({ type: "cancelAutoApproval" })
203+
}
204+
}, [isFollowUpAutoApprovalPaused])
205+
192206
useEffect(() => {
193207
isMountedRef.current = true
194208
return () => {
@@ -1272,6 +1286,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
12721286
onSuggestionClick={handleSuggestionClickInRow} // This was already stabilized
12731287
onBatchFileResponse={handleBatchFileResponse}
12741288
isFollowUpAnswered={messageOrGroup.isAnswered === true || messageOrGroup.ts === currentFollowUpTs}
1289+
isFollowUpAutoApprovalPaused={isFollowUpAutoApprovalPaused}
12751290
editable={
12761291
messageOrGroup.type === "ask" &&
12771292
messageOrGroup.ask === "tool" &&
@@ -1304,6 +1319,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
13041319
handleSuggestionClickInRow,
13051320
handleBatchFileResponse,
13061321
currentFollowUpTs,
1322+
isFollowUpAutoApprovalPaused,
13071323
alwaysAllowUpdateTodoList,
13081324
enableButtons,
13091325
primaryButtonText,

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ interface FollowUpSuggestProps {
1717
ts: number
1818
onCancelAutoApproval?: () => void
1919
isAnswered?: boolean
20+
isFollowUpAutoApprovalPaused?: boolean
2021
}
2122

2223
export const FollowUpSuggest = ({
@@ -25,6 +26,7 @@ export const FollowUpSuggest = ({
2526
ts = 1,
2627
onCancelAutoApproval,
2728
isAnswered = false,
29+
isFollowUpAutoApprovalPaused = false,
2830
}: FollowUpSuggestProps) => {
2931
const { autoApprovalEnabled, alwaysAllowFollowupQuestions, followupAutoApproveTimeoutMs } = useExtensionState()
3032
const [countdown, setCountdown] = useState<number | null>(null)
@@ -34,13 +36,14 @@ export const FollowUpSuggest = ({
3436
// Start countdown timer when auto-approval is enabled for follow-up questions
3537
useEffect(() => {
3638
// Only start countdown if auto-approval is enabled for follow-up questions and no suggestion has been selected
37-
// Also stop countdown if the question has been answered
39+
// Also stop countdown if the question has been answered or auto-approval is paused (user is typing)
3840
if (
3941
autoApprovalEnabled &&
4042
alwaysAllowFollowupQuestions &&
4143
suggestions.length > 0 &&
4244
!suggestionSelected &&
43-
!isAnswered
45+
!isAnswered &&
46+
!isFollowUpAutoApprovalPaused
4447
) {
4548
// Start with the configured timeout in seconds
4649
const timeoutMs =
@@ -80,6 +83,7 @@ export const FollowUpSuggest = ({
8083
suggestionSelected,
8184
onCancelAutoApproval,
8285
isAnswered,
86+
isFollowUpAutoApprovalPaused,
8387
])
8488
const handleSuggestionClick = useCallback(
8589
(suggestion: SuggestionItem, event: React.MouseEvent) => {

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

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,4 +412,184 @@ describe("FollowUpSuggest", () => {
412412
// onSuggestionClick should NOT have been called (component doesn't auto-select)
413413
expect(mockOnSuggestionClick).not.toHaveBeenCalled()
414414
})
415+
416+
describe("isFollowUpAutoApprovalPaused prop", () => {
417+
it("should not display countdown timer when isFollowUpAutoApprovalPaused is true", () => {
418+
renderWithTestProviders(
419+
<FollowUpSuggest
420+
suggestions={mockSuggestions}
421+
onSuggestionClick={mockOnSuggestionClick}
422+
ts={123}
423+
onCancelAutoApproval={mockOnCancelAutoApproval}
424+
isFollowUpAutoApprovalPaused={true}
425+
/>,
426+
defaultTestState,
427+
)
428+
429+
// Should not show countdown when user is typing
430+
expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument()
431+
})
432+
433+
it("should stop countdown when user starts typing (isFollowUpAutoApprovalPaused becomes true)", () => {
434+
const { rerender } = renderWithTestProviders(
435+
<FollowUpSuggest
436+
suggestions={mockSuggestions}
437+
onSuggestionClick={mockOnSuggestionClick}
438+
ts={123}
439+
onCancelAutoApproval={mockOnCancelAutoApproval}
440+
isFollowUpAutoApprovalPaused={false}
441+
/>,
442+
defaultTestState,
443+
)
444+
445+
// Initially should show countdown
446+
expect(screen.getByText(/3s/)).toBeInTheDocument()
447+
448+
// Simulate user starting to type by setting isFollowUpAutoApprovalPaused to true
449+
rerender(
450+
<TestExtensionStateProvider value={defaultTestState}>
451+
<TooltipProvider>
452+
<FollowUpSuggest
453+
suggestions={mockSuggestions}
454+
onSuggestionClick={mockOnSuggestionClick}
455+
ts={123}
456+
onCancelAutoApproval={mockOnCancelAutoApproval}
457+
isFollowUpAutoApprovalPaused={true}
458+
/>
459+
</TooltipProvider>
460+
</TestExtensionStateProvider>,
461+
)
462+
463+
// Countdown should be hidden immediately when user starts typing
464+
expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument()
465+
466+
// Advance timer to ensure countdown doesn't continue
467+
vi.advanceTimersByTime(5000)
468+
469+
// onSuggestionClick should not have been called
470+
expect(mockOnSuggestionClick).not.toHaveBeenCalled()
471+
472+
// Countdown should still not be visible
473+
expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument()
474+
})
475+
476+
it("should resume countdown when user clears input (isFollowUpAutoApprovalPaused becomes false)", async () => {
477+
const { rerender } = renderWithTestProviders(
478+
<FollowUpSuggest
479+
suggestions={mockSuggestions}
480+
onSuggestionClick={mockOnSuggestionClick}
481+
ts={123}
482+
onCancelAutoApproval={mockOnCancelAutoApproval}
483+
isFollowUpAutoApprovalPaused={true}
484+
/>,
485+
defaultTestState,
486+
)
487+
488+
// Should not show countdown when paused
489+
expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument()
490+
491+
// Simulate user clearing input by setting isFollowUpAutoApprovalPaused to false
492+
rerender(
493+
<TestExtensionStateProvider value={defaultTestState}>
494+
<TooltipProvider>
495+
<FollowUpSuggest
496+
suggestions={mockSuggestions}
497+
onSuggestionClick={mockOnSuggestionClick}
498+
ts={123}
499+
onCancelAutoApproval={mockOnCancelAutoApproval}
500+
isFollowUpAutoApprovalPaused={false}
501+
/>
502+
</TooltipProvider>
503+
</TestExtensionStateProvider>,
504+
)
505+
506+
// Countdown should resume from the full timeout
507+
expect(screen.getByText(/3s/)).toBeInTheDocument()
508+
})
509+
510+
it("should not show countdown when both isAnswered and isFollowUpAutoApprovalPaused are true", () => {
511+
renderWithTestProviders(
512+
<FollowUpSuggest
513+
suggestions={mockSuggestions}
514+
onSuggestionClick={mockOnSuggestionClick}
515+
ts={123}
516+
onCancelAutoApproval={mockOnCancelAutoApproval}
517+
isAnswered={true}
518+
isFollowUpAutoApprovalPaused={true}
519+
/>,
520+
defaultTestState,
521+
)
522+
523+
// Should not show countdown
524+
expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument()
525+
})
526+
527+
it("should handle pause during countdown progress", async () => {
528+
const { rerender } = renderWithTestProviders(
529+
<FollowUpSuggest
530+
suggestions={mockSuggestions}
531+
onSuggestionClick={mockOnSuggestionClick}
532+
ts={123}
533+
onCancelAutoApproval={mockOnCancelAutoApproval}
534+
isFollowUpAutoApprovalPaused={false}
535+
/>,
536+
defaultTestState,
537+
)
538+
539+
// Initially should show 3s
540+
expect(screen.getByText(/3s/)).toBeInTheDocument()
541+
542+
// Advance timer by 1 second
543+
await act(async () => {
544+
vi.advanceTimersByTime(1000)
545+
})
546+
547+
// Should show 2s
548+
expect(screen.getByText(/2s/)).toBeInTheDocument()
549+
550+
// User starts typing (pause)
551+
rerender(
552+
<TestExtensionStateProvider value={defaultTestState}>
553+
<TooltipProvider>
554+
<FollowUpSuggest
555+
suggestions={mockSuggestions}
556+
onSuggestionClick={mockOnSuggestionClick}
557+
ts={123}
558+
onCancelAutoApproval={mockOnCancelAutoApproval}
559+
isFollowUpAutoApprovalPaused={true}
560+
/>
561+
</TooltipProvider>
562+
</TestExtensionStateProvider>,
563+
)
564+
565+
// Countdown should be hidden
566+
expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument()
567+
568+
// Advance timer while paused
569+
await act(async () => {
570+
vi.advanceTimersByTime(2000)
571+
})
572+
573+
// Countdown should still be hidden
574+
expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument()
575+
576+
// User clears input (unpause) - countdown should restart from full duration
577+
rerender(
578+
<TestExtensionStateProvider value={defaultTestState}>
579+
<TooltipProvider>
580+
<FollowUpSuggest
581+
suggestions={mockSuggestions}
582+
onSuggestionClick={mockOnSuggestionClick}
583+
ts={123}
584+
onCancelAutoApproval={mockOnCancelAutoApproval}
585+
isFollowUpAutoApprovalPaused={false}
586+
/>
587+
</TooltipProvider>
588+
</TestExtensionStateProvider>,
589+
)
590+
591+
// Countdown should restart from full timeout (3s)
592+
expect(screen.getByText(/3s/)).toBeInTheDocument()
593+
})
594+
})
415595
})

0 commit comments

Comments
 (0)