mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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:
parent
495b5c6c6e
commit
d976a9b296
7 changed files with 232 additions and 8 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ export interface WebviewMessage {
|
|||
| "openFile"
|
||||
| "openMention"
|
||||
| "cancelTask"
|
||||
| "cancelAutoApproval"
|
||||
| "updateVSCodeSetting"
|
||||
| "getVSCodeSetting"
|
||||
| "vsCodeSetting"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue