diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index f7e4946f53c..c13efa17a63 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -278,6 +278,7 @@ export class Task extends EventEmitter 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 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 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 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) } diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index e6164eec256..c08504c576d 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -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() diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index b22e7ab3f64..eb109166c8c 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -68,6 +68,7 @@ export interface WebviewMessage { | "openFile" | "openMention" | "cancelTask" + | "cancelAutoApproval" | "updateVSCodeSetting" | "getVSCodeSetting" | "vsCodeSetting" diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 6c9b5551637..e8eae99855d 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -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} /> diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 6ee163fe41b..97d49e396b8 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -189,6 +189,20 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + 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 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(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) => { diff --git a/webview-ui/src/components/chat/__tests__/FollowUpSuggest.spec.tsx b/webview-ui/src/components/chat/__tests__/FollowUpSuggest.spec.tsx index 20cc54cf651..c2f2d56f34c 100644 --- a/webview-ui/src/components/chat/__tests__/FollowUpSuggest.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/FollowUpSuggest.spec.tsx @@ -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( + , + 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( + , + defaultTestState, + ) + + // Initially should show countdown + expect(screen.getByText(/3s/)).toBeInTheDocument() + + // Simulate user starting to type by setting isFollowUpAutoApprovalPaused to true + rerender( + + + + + , + ) + + // 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( + , + defaultTestState, + ) + + // Should not show countdown when paused + expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument() + + // Simulate user clearing input by setting isFollowUpAutoApprovalPaused to false + rerender( + + + + + , + ) + + // 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( + , + defaultTestState, + ) + + // Should not show countdown + expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument() + }) + + it("should handle pause during countdown progress", async () => { + const { rerender } = renderWithTestProviders( + , + 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( + + + + + , + ) + + // 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( + + + + + , + ) + + // Countdown should restart from full timeout (3s) + expect(screen.getByText(/3s/)).toBeInTheDocument() + }) + }) })