Skip to content

Commit 15586d3

Browse files
Fixed auto question timer unmount (#5368)
* fixed bug * expanded tests and made better mock * code review: refactor & race conditions * code review, some refactor and reset timer on task switch * rename to onCancelAutoApproval
1 parent 917b2cc commit 15586d3

File tree

4 files changed

+514
-18
lines changed

4 files changed

+514
-18
lines changed

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ interface ChatRowProps {
5353
onSuggestionClick?: (suggestion: SuggestionItem, event?: React.MouseEvent) => void
5454
onBatchFileResponse?: (response: { [key: string]: boolean }) => void
5555
onFollowUpUnmount?: () => void
56+
isFollowUpAnswered?: boolean
5657
editable?: boolean
5758
}
5859

@@ -104,6 +105,7 @@ export const ChatRowContent = ({
104105
onSuggestionClick,
105106
onFollowUpUnmount,
106107
onBatchFileResponse,
108+
isFollowUpAnswered,
107109
editable,
108110
}: ChatRowContentProps) => {
109111
const { t } = useTranslation()
@@ -1298,7 +1300,8 @@ export const ChatRowContent = ({
12981300
suggestions={followUpData?.suggest}
12991301
onSuggestionClick={onSuggestionClick}
13001302
ts={message?.ts}
1301-
onUnmount={onFollowUpUnmount}
1303+
onCancelAutoApproval={onFollowUpUnmount}
1304+
isAnswered={isFollowUpAnswered}
13021305
/>
13031306
</>
13041307
)

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

Lines changed: 73 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
169169
}),
170170
)
171171
const autoApproveTimeoutRef = useRef<NodeJS.Timeout | null>(null)
172+
const userRespondedRef = useRef<boolean>(false)
173+
const [currentFollowUpTs, setCurrentFollowUpTs] = useState<number | null>(null)
172174

173175
const clineAskRef = useRef(clineAsk)
174176
useEffect(() => {
@@ -415,6 +417,15 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
415417
useEffect(() => {
416418
setExpandedRows({})
417419
everVisibleMessagesTsRef.current.clear() // Clear for new task
420+
setCurrentFollowUpTs(null) // Clear follow-up answered state for new task
421+
422+
// Clear any pending auto-approval timeout from previous task
423+
if (autoApproveTimeoutRef.current) {
424+
clearTimeout(autoApproveTimeoutRef.current)
425+
autoApproveTimeoutRef.current = null
426+
}
427+
// Reset user response flag for new task
428+
userRespondedRef.current = false
418429
}, [task?.ts])
419430

420431
useEffect(() => {
@@ -486,7 +497,22 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
486497
return false
487498
}, [modifiedMessages, clineAsk, enableButtons, primaryButtonText])
488499

500+
const markFollowUpAsAnswered = useCallback(() => {
501+
const lastFollowUpMessage = messagesRef.current.findLast((msg) => msg.ask === "followup")
502+
if (lastFollowUpMessage) {
503+
setCurrentFollowUpTs(lastFollowUpMessage.ts)
504+
}
505+
}, [])
506+
489507
const handleChatReset = useCallback(() => {
508+
// Clear any pending auto-approval timeout
509+
if (autoApproveTimeoutRef.current) {
510+
clearTimeout(autoApproveTimeoutRef.current)
511+
autoApproveTimeoutRef.current = null
512+
}
513+
// Reset user response flag for new message
514+
userRespondedRef.current = false
515+
490516
// Only reset message-specific state, preserving mode.
491517
setInputValue("")
492518
setSendingDisabled(true)
@@ -504,9 +530,16 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
504530
text = text.trim()
505531

506532
if (text || images.length > 0) {
533+
// Mark that user has responded - this prevents any pending auto-approvals
534+
userRespondedRef.current = true
535+
507536
if (messagesRef.current.length === 0) {
508537
vscode.postMessage({ type: "newTask", text, images })
509538
} else if (clineAskRef.current) {
539+
if (clineAskRef.current === "followup") {
540+
markFollowUpAsAnswered()
541+
}
542+
510543
// Use clineAskRef.current
511544
switch (
512545
clineAskRef.current // Use clineAskRef.current
@@ -530,7 +563,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
530563
handleChatReset()
531564
}
532565
},
533-
[handleChatReset], // messagesRef and clineAskRef are stable
566+
[handleChatReset, markFollowUpAsAnswered], // messagesRef and clineAskRef are stable
534567
)
535568

536569
const handleSetChatBoxMessage = useCallback(
@@ -555,6 +588,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
555588
// extension.
556589
const handlePrimaryButtonClick = useCallback(
557590
(text?: string, images?: string[]) => {
591+
// Mark that user has responded
592+
userRespondedRef.current = true
593+
558594
const trimmedInput = text?.trim()
559595

560596
switch (clineAsk) {
@@ -599,6 +635,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
599635

600636
const handleSecondaryButtonClick = useCallback(
601637
(text?: string, images?: string[]) => {
638+
// Mark that user has responded
639+
userRespondedRef.current = true
640+
602641
const trimmedInput = text?.trim()
603642

604643
if (isStreaming) {
@@ -1219,6 +1258,16 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
12191258

12201259
const handleSuggestionClickInRow = useCallback(
12211260
(suggestion: SuggestionItem, event?: React.MouseEvent) => {
1261+
// Mark that user has responded if this is a manual click (not auto-approval)
1262+
if (event) {
1263+
userRespondedRef.current = true
1264+
}
1265+
1266+
// Mark the current follow-up question as answered when a suggestion is clicked
1267+
if (clineAsk === "followup" && !event?.shiftKey) {
1268+
markFollowUpAsAnswered()
1269+
}
1270+
12221271
// Check if we need to switch modes
12231272
if (suggestion.mode) {
12241273
// Only switch modes if it's a manual click (event exists) or auto-approval is allowed
@@ -1238,7 +1287,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
12381287
handleSendMessage(suggestion.answer, [])
12391288
}
12401289
},
1241-
[handleSendMessage, setInputValue, switchToMode, alwaysAllowModeSwitch],
1290+
[handleSendMessage, setInputValue, switchToMode, alwaysAllowModeSwitch, clineAsk, markFollowUpAsAnswered],
12421291
)
12431292

12441293
const handleBatchFileResponse = useCallback((response: { [key: string]: boolean }) => {
@@ -1248,11 +1297,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
12481297

12491298
// Handler for when FollowUpSuggest component unmounts
12501299
const handleFollowUpUnmount = useCallback(() => {
1251-
// Clear the auto-approve timeout to prevent race conditions
1252-
if (autoApproveTimeoutRef.current) {
1253-
clearTimeout(autoApproveTimeoutRef.current)
1254-
autoApproveTimeoutRef.current = null
1255-
}
1300+
// Mark that user has responded
1301+
userRespondedRef.current = true
12561302
}, [])
12571303

12581304
const itemContent = useCallback(
@@ -1291,6 +1337,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
12911337
onSuggestionClick={handleSuggestionClickInRow} // This was already stabilized
12921338
onBatchFileResponse={handleBatchFileResponse}
12931339
onFollowUpUnmount={handleFollowUpUnmount}
1340+
isFollowUpAnswered={messageOrGroup.ts === currentFollowUpTs}
12941341
editable={
12951342
messageOrGroup.type === "ask" &&
12961343
messageOrGroup.ask === "tool" &&
@@ -1322,6 +1369,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
13221369
handleSuggestionClickInRow,
13231370
handleBatchFileResponse,
13241371
handleFollowUpUnmount,
1372+
currentFollowUpTs,
13251373
alwaysAllowUpdateTodoList,
13261374
enableButtons,
13271375
primaryButtonText,
@@ -1338,6 +1386,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
13381386
return
13391387
}
13401388

1389+
// Exit early if user has already responded
1390+
if (userRespondedRef.current) {
1391+
return
1392+
}
1393+
13411394
const autoApprove = async () => {
13421395
if (lastMessage?.ask && isAutoApproved(lastMessage)) {
13431396
// Special handling for follow-up questions
@@ -1354,9 +1407,17 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
13541407
if (followUpData && followUpData.suggest && followUpData.suggest.length > 0) {
13551408
// Wait for the configured timeout before auto-selecting the first suggestion
13561409
await new Promise<void>((resolve) => {
1357-
autoApproveTimeoutRef.current = setTimeout(resolve, followupAutoApproveTimeoutMs)
1410+
autoApproveTimeoutRef.current = setTimeout(() => {
1411+
autoApproveTimeoutRef.current = null
1412+
resolve()
1413+
}, followupAutoApproveTimeoutMs)
13581414
})
13591415

1416+
// Check if user responded manually
1417+
if (userRespondedRef.current) {
1418+
return
1419+
}
1420+
13601421
// Get the first suggestion
13611422
const firstSuggestion = followUpData.suggest[0]
13621423

@@ -1366,7 +1427,10 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
13661427
}
13671428
} else if (lastMessage.ask === "tool" && isWriteToolAction(lastMessage)) {
13681429
await new Promise<void>((resolve) => {
1369-
autoApproveTimeoutRef.current = setTimeout(resolve, writeDelayMs)
1430+
autoApproveTimeoutRef.current = setTimeout(() => {
1431+
autoApproveTimeoutRef.current = null
1432+
resolve()
1433+
}, writeDelayMs)
13701434
})
13711435
}
13721436

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

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,17 @@ interface FollowUpSuggestProps {
1414
suggestions?: SuggestionItem[]
1515
onSuggestionClick?: (suggestion: SuggestionItem, event?: React.MouseEvent) => void
1616
ts: number
17-
onUnmount?: () => void
17+
onCancelAutoApproval?: () => void
18+
isAnswered?: boolean
1819
}
1920

20-
export const FollowUpSuggest = ({ suggestions = [], onSuggestionClick, ts = 1, onUnmount }: FollowUpSuggestProps) => {
21+
export const FollowUpSuggest = ({
22+
suggestions = [],
23+
onSuggestionClick,
24+
ts = 1,
25+
onCancelAutoApproval,
26+
isAnswered = false,
27+
}: FollowUpSuggestProps) => {
2128
const { autoApprovalEnabled, alwaysAllowFollowupQuestions, followupAutoApproveTimeoutMs } = useExtensionState()
2229
const [countdown, setCountdown] = useState<number | null>(null)
2330
const [suggestionSelected, setSuggestionSelected] = useState(false)
@@ -26,7 +33,14 @@ export const FollowUpSuggest = ({ suggestions = [], onSuggestionClick, ts = 1, o
2633
// Start countdown timer when auto-approval is enabled for follow-up questions
2734
useEffect(() => {
2835
// Only start countdown if auto-approval is enabled for follow-up questions and no suggestion has been selected
29-
if (autoApprovalEnabled && alwaysAllowFollowupQuestions && suggestions.length > 0 && !suggestionSelected) {
36+
// Also stop countdown if the question has been answered
37+
if (
38+
autoApprovalEnabled &&
39+
alwaysAllowFollowupQuestions &&
40+
suggestions.length > 0 &&
41+
!suggestionSelected &&
42+
!isAnswered
43+
) {
3044
// Start with the configured timeout in seconds
3145
const timeoutMs =
3246
typeof followupAutoApproveTimeoutMs === "number" && !isNaN(followupAutoApproveTimeoutMs)
@@ -52,7 +66,7 @@ export const FollowUpSuggest = ({ suggestions = [], onSuggestionClick, ts = 1, o
5266
clearInterval(intervalId)
5367
// Notify parent component that this component is unmounting
5468
// so it can clear any related timeouts
55-
onUnmount?.()
69+
onCancelAutoApproval?.()
5670
}
5771
} else {
5872
setCountdown(null)
@@ -63,7 +77,8 @@ export const FollowUpSuggest = ({ suggestions = [], onSuggestionClick, ts = 1, o
6377
suggestions,
6478
followupAutoApproveTimeoutMs,
6579
suggestionSelected,
66-
onUnmount,
80+
onCancelAutoApproval,
81+
isAnswered,
6782
])
6883
const handleSuggestionClick = useCallback(
6984
(suggestion: SuggestionItem, event: React.MouseEvent) => {
@@ -72,14 +87,14 @@ export const FollowUpSuggest = ({ suggestions = [], onSuggestionClick, ts = 1, o
7287
setSuggestionSelected(true)
7388
// Also notify parent component to cancel auto-approval timeout
7489
// This prevents race conditions between visual countdown and actual timeout
75-
onUnmount?.()
90+
onCancelAutoApproval?.()
7691
}
7792

7893
// Pass the suggestion object to the parent component
7994
// The parent component will handle mode switching if needed
8095
onSuggestionClick?.(suggestion, event)
8196
},
82-
[onSuggestionClick, onUnmount],
97+
[onSuggestionClick, onCancelAutoApproval],
8398
)
8499

85100
// Don't render if there are no suggestions or no click handler.
@@ -100,7 +115,7 @@ export const FollowUpSuggest = ({ suggestions = [], onSuggestionClick, ts = 1, o
100115
onClick={(event) => handleSuggestionClick(suggestion, event)}
101116
aria-label={suggestion.answer}>
102117
{suggestion.answer}
103-
{isFirstSuggestion && countdown !== null && !suggestionSelected && (
118+
{isFirstSuggestion && countdown !== null && !suggestionSelected && !isAnswered && (
104119
<span
105120
className="ml-2 px-1.5 py-0.5 text-xs rounded-full bg-vscode-badge-background text-vscode-badge-foreground"
106121
title={t("chat:followUpSuggest.autoSelectCountdown", { count: countdown })}>

0 commit comments

Comments
 (0)