fix: cancel auto-approval timeout when user starts typing (#9937)

Co-authored-by: Roo Code <roomote@roocode.com>
Co-authored-by: Matt Rubens <mrubens@users.noreply.github.com>
This commit is contained in:
roomote[bot] 2025-12-12 10:25:14 -05:00 committed by GitHub
parent 495b5c6c6e
commit d976a9b296
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 232 additions and 8 deletions

View file

@ -278,6 +278,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
private askResponseText?: string
private askResponseImages?: string[]
public lastMessageTs?: number
private autoApprovalTimeoutRef?: NodeJS.Timeout
// Tool Use
consecutiveMistakeCount: number = 0
@ -1095,12 +1096,13 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
} else if (approval.decision === "deny") {
this.denyAsk()
} else if (approval.decision === "timeout") {
timeouts.push(
setTimeout(() => {
const { askResponse, text, images } = approval.fn()
this.handleWebviewAskResponse(askResponse, text, images)
}, approval.timeout),
)
// Store the auto-approval timeout so it can be cancelled if user interacts
this.autoApprovalTimeoutRef = setTimeout(() => {
const { askResponse, text, images } = approval.fn()
this.handleWebviewAskResponse(askResponse, text, images)
this.autoApprovalTimeoutRef = undefined
}, approval.timeout)
timeouts.push(this.autoApprovalTimeoutRef)
}
// 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 {
}
handleWebviewAskResponse(askResponse: ClineAskResponse, text?: string, images?: string[]) {
// Clear any pending auto-approval timeout when user responds
this.cancelAutoApprovalTimeout()
this.askResponse = askResponse
this.askResponseText = text
this.askResponseImages = images
@ -1239,6 +1244,17 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}
}
/**
* Cancel any pending auto-approval timeout.
* Called when user interacts (types, clicks buttons, etc.) to prevent the timeout from firing.
*/
public cancelAutoApprovalTimeout(): void {
if (this.autoApprovalTimeoutRef) {
clearTimeout(this.autoApprovalTimeoutRef)
this.autoApprovalTimeoutRef = undefined
}
}
public approveAsk({ text, images }: { text?: string; images?: string[] } = {}) {
this.handleWebviewAskResponse("yesButtonClicked", text, images)
}

View file

@ -1107,6 +1107,10 @@ export const webviewMessageHandler = async (
case "cancelTask":
await provider.cancelTask()
break
case "cancelAutoApproval":
// Cancel any pending auto-approval timeout for the current task
provider.getCurrentTask()?.cancelAutoApprovalTimeout()
break
case "killBrowserSession":
{
const task = provider.getCurrentTask()

View file

@ -68,6 +68,7 @@ export interface WebviewMessage {
| "openFile"
| "openMention"
| "cancelTask"
| "cancelAutoApproval"
| "updateVSCodeSetting"
| "getVSCodeSetting"
| "vsCodeSetting"

View file

@ -109,6 +109,7 @@ interface ChatRowProps {
onBatchFileResponse?: (response: { [key: string]: boolean }) => void
onFollowUpUnmount?: () => void
isFollowUpAnswered?: boolean
isFollowUpAutoApprovalPaused?: boolean
editable?: boolean
hasCheckpoint?: boolean
}
@ -162,6 +163,7 @@ export const ChatRowContent = ({
onFollowUpUnmount,
onBatchFileResponse,
isFollowUpAnswered,
isFollowUpAutoApprovalPaused,
}: ChatRowContentProps) => {
const { t, i18n } = useTranslation()
@ -1544,6 +1546,7 @@ export const ChatRowContent = ({
ts={message?.ts}
onCancelAutoApproval={onFollowUpUnmount}
isAnswered={isFollowUpAnswered}
isFollowUpAutoApprovalPaused={isFollowUpAutoApprovalPaused}
/>
</div>
</>

View file

@ -189,6 +189,20 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
inputValueRef.current = inputValue
}, [inputValue])
// Compute whether auto-approval is paused (user is typing in a followup)
const isFollowUpAutoApprovalPaused = useMemo(() => {
return !!(inputValue && inputValue.trim().length > 0 && clineAsk === "followup")
}, [inputValue, clineAsk])
// Cancel auto-approval timeout when user starts typing
useEffect(() => {
// Only send cancel if there's actual input (user is typing)
// and we have a pending follow-up question
if (isFollowUpAutoApprovalPaused) {
vscode.postMessage({ type: "cancelAutoApproval" })
}
}, [isFollowUpAutoApprovalPaused])
useEffect(() => {
isMountedRef.current = true
return () => {
@ -1272,6 +1286,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
onSuggestionClick={handleSuggestionClickInRow} // This was already stabilized
onBatchFileResponse={handleBatchFileResponse}
isFollowUpAnswered={messageOrGroup.isAnswered === true || messageOrGroup.ts === currentFollowUpTs}
isFollowUpAutoApprovalPaused={isFollowUpAutoApprovalPaused}
editable={
messageOrGroup.type === "ask" &&
messageOrGroup.ask === "tool" &&
@ -1304,6 +1319,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
handleSuggestionClickInRow,
handleBatchFileResponse,
currentFollowUpTs,
isFollowUpAutoApprovalPaused,
alwaysAllowUpdateTodoList,
enableButtons,
primaryButtonText,

View file

@ -17,6 +17,7 @@ interface FollowUpSuggestProps {
ts: number
onCancelAutoApproval?: () => void
isAnswered?: boolean
isFollowUpAutoApprovalPaused?: boolean
}
export const FollowUpSuggest = ({
@ -25,6 +26,7 @@ export const FollowUpSuggest = ({
ts = 1,
onCancelAutoApproval,
isAnswered = false,
isFollowUpAutoApprovalPaused = false,
}: FollowUpSuggestProps) => {
const { autoApprovalEnabled, alwaysAllowFollowupQuestions, followupAutoApproveTimeoutMs } = useExtensionState()
const [countdown, setCountdown] = useState<number | null>(null)
@ -34,13 +36,14 @@ export const FollowUpSuggest = ({
// Start countdown timer when auto-approval is enabled for follow-up questions
useEffect(() => {
// Only start countdown if auto-approval is enabled for follow-up questions and no suggestion has been selected
// Also stop countdown if the question has been answered
// Also stop countdown if the question has been answered or auto-approval is paused (user is typing)
if (
autoApprovalEnabled &&
alwaysAllowFollowupQuestions &&
suggestions.length > 0 &&
!suggestionSelected &&
!isAnswered
!isAnswered &&
!isFollowUpAutoApprovalPaused
) {
// Start with the configured timeout in seconds
const timeoutMs =
@ -80,6 +83,7 @@ export const FollowUpSuggest = ({
suggestionSelected,
onCancelAutoApproval,
isAnswered,
isFollowUpAutoApprovalPaused,
])
const handleSuggestionClick = useCallback(
(suggestion: SuggestionItem, event: React.MouseEvent) => {

View file

@ -412,4 +412,184 @@ describe("FollowUpSuggest", () => {
// onSuggestionClick should NOT have been called (component doesn't auto-select)
expect(mockOnSuggestionClick).not.toHaveBeenCalled()
})
describe("isFollowUpAutoApprovalPaused prop", () => {
it("should not display countdown timer when isFollowUpAutoApprovalPaused is true", () => {
renderWithTestProviders(
<FollowUpSuggest
suggestions={mockSuggestions}
onSuggestionClick={mockOnSuggestionClick}
ts={123}
onCancelAutoApproval={mockOnCancelAutoApproval}
isFollowUpAutoApprovalPaused={true}
/>,
defaultTestState,
)
// Should not show countdown when user is typing
expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument()
})
it("should stop countdown when user starts typing (isFollowUpAutoApprovalPaused becomes true)", () => {
const { rerender } = renderWithTestProviders(
<FollowUpSuggest
suggestions={mockSuggestions}
onSuggestionClick={mockOnSuggestionClick}
ts={123}
onCancelAutoApproval={mockOnCancelAutoApproval}
isFollowUpAutoApprovalPaused={false}
/>,
defaultTestState,
)
// Initially should show countdown
expect(screen.getByText(/3s/)).toBeInTheDocument()
// Simulate user starting to type by setting isFollowUpAutoApprovalPaused to true
rerender(
<TestExtensionStateProvider value={defaultTestState}>
<TooltipProvider>
<FollowUpSuggest
suggestions={mockSuggestions}
onSuggestionClick={mockOnSuggestionClick}
ts={123}
onCancelAutoApproval={mockOnCancelAutoApproval}
isFollowUpAutoApprovalPaused={true}
/>
</TooltipProvider>
</TestExtensionStateProvider>,
)
// Countdown should be hidden immediately when user starts typing
expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument()
// Advance timer to ensure countdown doesn't continue
vi.advanceTimersByTime(5000)
// onSuggestionClick should not have been called
expect(mockOnSuggestionClick).not.toHaveBeenCalled()
// Countdown should still not be visible
expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument()
})
it("should resume countdown when user clears input (isFollowUpAutoApprovalPaused becomes false)", async () => {
const { rerender } = renderWithTestProviders(
<FollowUpSuggest
suggestions={mockSuggestions}
onSuggestionClick={mockOnSuggestionClick}
ts={123}
onCancelAutoApproval={mockOnCancelAutoApproval}
isFollowUpAutoApprovalPaused={true}
/>,
defaultTestState,
)
// Should not show countdown when paused
expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument()
// Simulate user clearing input by setting isFollowUpAutoApprovalPaused to false
rerender(
<TestExtensionStateProvider value={defaultTestState}>
<TooltipProvider>
<FollowUpSuggest
suggestions={mockSuggestions}
onSuggestionClick={mockOnSuggestionClick}
ts={123}
onCancelAutoApproval={mockOnCancelAutoApproval}
isFollowUpAutoApprovalPaused={false}
/>
</TooltipProvider>
</TestExtensionStateProvider>,
)
// Countdown should resume from the full timeout
expect(screen.getByText(/3s/)).toBeInTheDocument()
})
it("should not show countdown when both isAnswered and isFollowUpAutoApprovalPaused are true", () => {
renderWithTestProviders(
<FollowUpSuggest
suggestions={mockSuggestions}
onSuggestionClick={mockOnSuggestionClick}
ts={123}
onCancelAutoApproval={mockOnCancelAutoApproval}
isAnswered={true}
isFollowUpAutoApprovalPaused={true}
/>,
defaultTestState,
)
// Should not show countdown
expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument()
})
it("should handle pause during countdown progress", async () => {
const { rerender } = renderWithTestProviders(
<FollowUpSuggest
suggestions={mockSuggestions}
onSuggestionClick={mockOnSuggestionClick}
ts={123}
onCancelAutoApproval={mockOnCancelAutoApproval}
isFollowUpAutoApprovalPaused={false}
/>,
defaultTestState,
)
// Initially should show 3s
expect(screen.getByText(/3s/)).toBeInTheDocument()
// Advance timer by 1 second
await act(async () => {
vi.advanceTimersByTime(1000)
})
// Should show 2s
expect(screen.getByText(/2s/)).toBeInTheDocument()
// User starts typing (pause)
rerender(
<TestExtensionStateProvider value={defaultTestState}>
<TooltipProvider>
<FollowUpSuggest
suggestions={mockSuggestions}
onSuggestionClick={mockOnSuggestionClick}
ts={123}
onCancelAutoApproval={mockOnCancelAutoApproval}
isFollowUpAutoApprovalPaused={true}
/>
</TooltipProvider>
</TestExtensionStateProvider>,
)
// Countdown should be hidden
expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument()
// Advance timer while paused
await act(async () => {
vi.advanceTimersByTime(2000)
})
// Countdown should still be hidden
expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument()
// User clears input (unpause) - countdown should restart from full duration
rerender(
<TestExtensionStateProvider value={defaultTestState}>
<TooltipProvider>
<FollowUpSuggest
suggestions={mockSuggestions}
onSuggestionClick={mockOnSuggestionClick}
ts={123}
onCancelAutoApproval={mockOnCancelAutoApproval}
isFollowUpAutoApprovalPaused={false}
/>
</TooltipProvider>
</TestExtensionStateProvider>,
)
// Countdown should restart from full timeout (3s)
expect(screen.getByText(/3s/)).toBeInTheDocument()
})
})
})