Browse Source

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
Will Li 7 months ago
parent
commit
15586d3846

+ 4 - 1
webview-ui/src/components/chat/ChatRow.tsx

@@ -53,6 +53,7 @@ interface ChatRowProps {
 	onSuggestionClick?: (suggestion: SuggestionItem, event?: React.MouseEvent) => void
 	onBatchFileResponse?: (response: { [key: string]: boolean }) => void
 	onFollowUpUnmount?: () => void
+	isFollowUpAnswered?: boolean
 	editable?: boolean
 }
 
@@ -104,6 +105,7 @@ export const ChatRowContent = ({
 	onSuggestionClick,
 	onFollowUpUnmount,
 	onBatchFileResponse,
+	isFollowUpAnswered,
 	editable,
 }: ChatRowContentProps) => {
 	const { t } = useTranslation()
@@ -1298,7 +1300,8 @@ export const ChatRowContent = ({
 								suggestions={followUpData?.suggest}
 								onSuggestionClick={onSuggestionClick}
 								ts={message?.ts}
-								onUnmount={onFollowUpUnmount}
+								onCancelAutoApproval={onFollowUpUnmount}
+								isAnswered={isFollowUpAnswered}
 							/>
 						</>
 					)

+ 73 - 9
webview-ui/src/components/chat/ChatView.tsx

@@ -169,6 +169,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 		}),
 	)
 	const autoApproveTimeoutRef = useRef<NodeJS.Timeout | null>(null)
+	const userRespondedRef = useRef<boolean>(false)
+	const [currentFollowUpTs, setCurrentFollowUpTs] = useState<number | null>(null)
 
 	const clineAskRef = useRef(clineAsk)
 	useEffect(() => {
@@ -415,6 +417,15 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 	useEffect(() => {
 		setExpandedRows({})
 		everVisibleMessagesTsRef.current.clear() // Clear for new task
+		setCurrentFollowUpTs(null) // Clear follow-up answered state for new task
+
+		// Clear any pending auto-approval timeout from previous task
+		if (autoApproveTimeoutRef.current) {
+			clearTimeout(autoApproveTimeoutRef.current)
+			autoApproveTimeoutRef.current = null
+		}
+		// Reset user response flag for new task
+		userRespondedRef.current = false
 	}, [task?.ts])
 
 	useEffect(() => {
@@ -486,7 +497,22 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 		return false
 	}, [modifiedMessages, clineAsk, enableButtons, primaryButtonText])
 
+	const markFollowUpAsAnswered = useCallback(() => {
+		const lastFollowUpMessage = messagesRef.current.findLast((msg) => msg.ask === "followup")
+		if (lastFollowUpMessage) {
+			setCurrentFollowUpTs(lastFollowUpMessage.ts)
+		}
+	}, [])
+
 	const handleChatReset = useCallback(() => {
+		// Clear any pending auto-approval timeout
+		if (autoApproveTimeoutRef.current) {
+			clearTimeout(autoApproveTimeoutRef.current)
+			autoApproveTimeoutRef.current = null
+		}
+		// Reset user response flag for new message
+		userRespondedRef.current = false
+
 		// Only reset message-specific state, preserving mode.
 		setInputValue("")
 		setSendingDisabled(true)
@@ -504,9 +530,16 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 			text = text.trim()
 
 			if (text || images.length > 0) {
+				// Mark that user has responded - this prevents any pending auto-approvals
+				userRespondedRef.current = true
+
 				if (messagesRef.current.length === 0) {
 					vscode.postMessage({ type: "newTask", text, images })
 				} else if (clineAskRef.current) {
+					if (clineAskRef.current === "followup") {
+						markFollowUpAsAnswered()
+					}
+
 					// Use clineAskRef.current
 					switch (
 						clineAskRef.current // Use clineAskRef.current
@@ -530,7 +563,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 				handleChatReset()
 			}
 		},
-		[handleChatReset], // messagesRef and clineAskRef are stable
+		[handleChatReset, markFollowUpAsAnswered], // messagesRef and clineAskRef are stable
 	)
 
 	const handleSetChatBoxMessage = useCallback(
@@ -555,6 +588,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 	// extension.
 	const handlePrimaryButtonClick = useCallback(
 		(text?: string, images?: string[]) => {
+			// Mark that user has responded
+			userRespondedRef.current = true
+
 			const trimmedInput = text?.trim()
 
 			switch (clineAsk) {
@@ -599,6 +635,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 
 	const handleSecondaryButtonClick = useCallback(
 		(text?: string, images?: string[]) => {
+			// Mark that user has responded
+			userRespondedRef.current = true
+
 			const trimmedInput = text?.trim()
 
 			if (isStreaming) {
@@ -1219,6 +1258,16 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 
 	const handleSuggestionClickInRow = useCallback(
 		(suggestion: SuggestionItem, event?: React.MouseEvent) => {
+			// Mark that user has responded if this is a manual click (not auto-approval)
+			if (event) {
+				userRespondedRef.current = true
+			}
+
+			// Mark the current follow-up question as answered when a suggestion is clicked
+			if (clineAsk === "followup" && !event?.shiftKey) {
+				markFollowUpAsAnswered()
+			}
+
 			// Check if we need to switch modes
 			if (suggestion.mode) {
 				// 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
 				handleSendMessage(suggestion.answer, [])
 			}
 		},
-		[handleSendMessage, setInputValue, switchToMode, alwaysAllowModeSwitch],
+		[handleSendMessage, setInputValue, switchToMode, alwaysAllowModeSwitch, clineAsk, markFollowUpAsAnswered],
 	)
 
 	const handleBatchFileResponse = useCallback((response: { [key: string]: boolean }) => {
@@ -1248,11 +1297,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 
 	// Handler for when FollowUpSuggest component unmounts
 	const handleFollowUpUnmount = useCallback(() => {
-		// Clear the auto-approve timeout to prevent race conditions
-		if (autoApproveTimeoutRef.current) {
-			clearTimeout(autoApproveTimeoutRef.current)
-			autoApproveTimeoutRef.current = null
-		}
+		// Mark that user has responded
+		userRespondedRef.current = true
 	}, [])
 
 	const itemContent = useCallback(
@@ -1291,6 +1337,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 					onSuggestionClick={handleSuggestionClickInRow} // This was already stabilized
 					onBatchFileResponse={handleBatchFileResponse}
 					onFollowUpUnmount={handleFollowUpUnmount}
+					isFollowUpAnswered={messageOrGroup.ts === currentFollowUpTs}
 					editable={
 						messageOrGroup.type === "ask" &&
 						messageOrGroup.ask === "tool" &&
@@ -1322,6 +1369,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 			handleSuggestionClickInRow,
 			handleBatchFileResponse,
 			handleFollowUpUnmount,
+			currentFollowUpTs,
 			alwaysAllowUpdateTodoList,
 			enableButtons,
 			primaryButtonText,
@@ -1338,6 +1386,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 			return
 		}
 
+		// Exit early if user has already responded
+		if (userRespondedRef.current) {
+			return
+		}
+
 		const autoApprove = async () => {
 			if (lastMessage?.ask && isAutoApproved(lastMessage)) {
 				// Special handling for follow-up questions
@@ -1354,9 +1407,17 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 					if (followUpData && followUpData.suggest && followUpData.suggest.length > 0) {
 						// Wait for the configured timeout before auto-selecting the first suggestion
 						await new Promise<void>((resolve) => {
-							autoApproveTimeoutRef.current = setTimeout(resolve, followupAutoApproveTimeoutMs)
+							autoApproveTimeoutRef.current = setTimeout(() => {
+								autoApproveTimeoutRef.current = null
+								resolve()
+							}, followupAutoApproveTimeoutMs)
 						})
 
+						// Check if user responded manually
+						if (userRespondedRef.current) {
+							return
+						}
+
 						// Get the first suggestion
 						const firstSuggestion = followUpData.suggest[0]
 
@@ -1366,7 +1427,10 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 					}
 				} else if (lastMessage.ask === "tool" && isWriteToolAction(lastMessage)) {
 					await new Promise<void>((resolve) => {
-						autoApproveTimeoutRef.current = setTimeout(resolve, writeDelayMs)
+						autoApproveTimeoutRef.current = setTimeout(() => {
+							autoApproveTimeoutRef.current = null
+							resolve()
+						}, writeDelayMs)
 					})
 				}
 

+ 23 - 8
webview-ui/src/components/chat/FollowUpSuggest.tsx

@@ -14,10 +14,17 @@ interface FollowUpSuggestProps {
 	suggestions?: SuggestionItem[]
 	onSuggestionClick?: (suggestion: SuggestionItem, event?: React.MouseEvent) => void
 	ts: number
-	onUnmount?: () => void
+	onCancelAutoApproval?: () => void
+	isAnswered?: boolean
 }
 
-export const FollowUpSuggest = ({ suggestions = [], onSuggestionClick, ts = 1, onUnmount }: FollowUpSuggestProps) => {
+export const FollowUpSuggest = ({
+	suggestions = [],
+	onSuggestionClick,
+	ts = 1,
+	onCancelAutoApproval,
+	isAnswered = false,
+}: FollowUpSuggestProps) => {
 	const { autoApprovalEnabled, alwaysAllowFollowupQuestions, followupAutoApproveTimeoutMs } = useExtensionState()
 	const [countdown, setCountdown] = useState<number | null>(null)
 	const [suggestionSelected, setSuggestionSelected] = useState(false)
@@ -26,7 +33,14 @@ export const FollowUpSuggest = ({ suggestions = [], onSuggestionClick, ts = 1, o
 	// 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
-		if (autoApprovalEnabled && alwaysAllowFollowupQuestions && suggestions.length > 0 && !suggestionSelected) {
+		// Also stop countdown if the question has been answered
+		if (
+			autoApprovalEnabled &&
+			alwaysAllowFollowupQuestions &&
+			suggestions.length > 0 &&
+			!suggestionSelected &&
+			!isAnswered
+		) {
 			// Start with the configured timeout in seconds
 			const timeoutMs =
 				typeof followupAutoApproveTimeoutMs === "number" && !isNaN(followupAutoApproveTimeoutMs)
@@ -52,7 +66,7 @@ export const FollowUpSuggest = ({ suggestions = [], onSuggestionClick, ts = 1, o
 				clearInterval(intervalId)
 				// Notify parent component that this component is unmounting
 				// so it can clear any related timeouts
-				onUnmount?.()
+				onCancelAutoApproval?.()
 			}
 		} else {
 			setCountdown(null)
@@ -63,7 +77,8 @@ export const FollowUpSuggest = ({ suggestions = [], onSuggestionClick, ts = 1, o
 		suggestions,
 		followupAutoApproveTimeoutMs,
 		suggestionSelected,
-		onUnmount,
+		onCancelAutoApproval,
+		isAnswered,
 	])
 	const handleSuggestionClick = useCallback(
 		(suggestion: SuggestionItem, event: React.MouseEvent) => {
@@ -72,14 +87,14 @@ export const FollowUpSuggest = ({ suggestions = [], onSuggestionClick, ts = 1, o
 				setSuggestionSelected(true)
 				// Also notify parent component to cancel auto-approval timeout
 				// This prevents race conditions between visual countdown and actual timeout
-				onUnmount?.()
+				onCancelAutoApproval?.()
 			}
 
 			// Pass the suggestion object to the parent component
 			// The parent component will handle mode switching if needed
 			onSuggestionClick?.(suggestion, event)
 		},
-		[onSuggestionClick, onUnmount],
+		[onSuggestionClick, onCancelAutoApproval],
 	)
 
 	// Don't render if there are no suggestions or no click handler.
@@ -100,7 +115,7 @@ export const FollowUpSuggest = ({ suggestions = [], onSuggestionClick, ts = 1, o
 							onClick={(event) => handleSuggestionClick(suggestion, event)}
 							aria-label={suggestion.answer}>
 							{suggestion.answer}
-							{isFirstSuggestion && countdown !== null && !suggestionSelected && (
+							{isFirstSuggestion && countdown !== null && !suggestionSelected && !isAnswered && (
 								<span
 									className="ml-2 px-1.5 py-0.5 text-xs rounded-full bg-vscode-badge-background text-vscode-badge-foreground"
 									title={t("chat:followUpSuggest.autoSelectCountdown", { count: countdown })}>

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

@@ -0,0 +1,414 @@
+import React, { createContext, useContext } from "react"
+import { render, screen, act } from "@testing-library/react"
+
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
+import { FollowUpSuggest } from "../FollowUpSuggest"
+import { TooltipProvider } from "@radix-ui/react-tooltip"
+
+// Mock the translation hook
+vi.mock("@src/i18n/TranslationContext", () => ({
+	TranslationProvider: ({ children }: { children: React.ReactNode }) => children,
+	useAppTranslation: () => ({
+		t: (key: string, options?: any) => {
+			if (key === "chat:followUpSuggest.countdownDisplay" && options?.count !== undefined) {
+				return `${options.count}s`
+			}
+			if (key === "chat:followUpSuggest.autoSelectCountdown" && options?.count !== undefined) {
+				return `Auto-selecting in ${options.count} seconds`
+			}
+			if (key === "chat:followUpSuggest.copyToInput") {
+				return "Copy to input"
+			}
+			return key
+		},
+	}),
+}))
+
+// Test-specific extension state context that only provides the values needed by FollowUpSuggest
+interface TestExtensionState {
+	autoApprovalEnabled: boolean
+	alwaysAllowFollowupQuestions: boolean
+	followupAutoApproveTimeoutMs: number
+}
+
+const TestExtensionStateContext = createContext<TestExtensionState | undefined>(undefined)
+
+// Mock the useExtensionState hook to use our test context
+vi.mock("@src/context/ExtensionStateContext", () => ({
+	useExtensionState: () => {
+		const context = useContext(TestExtensionStateContext)
+		if (!context) {
+			throw new Error("useExtensionState must be used within TestExtensionStateProvider")
+		}
+		return context
+	},
+}))
+
+// Test provider that only provides the specific values needed by FollowUpSuggest
+const TestExtensionStateProvider: React.FC<{
+	children: React.ReactNode
+	value: TestExtensionState
+}> = ({ children, value }) => {
+	return <TestExtensionStateContext.Provider value={value}>{children}</TestExtensionStateContext.Provider>
+}
+
+// Helper function to render component with test providers
+const renderWithTestProviders = (component: React.ReactElement, extensionState: TestExtensionState) => {
+	return render(
+		<TestExtensionStateProvider value={extensionState}>
+			<TooltipProvider>{component}</TooltipProvider>
+		</TestExtensionStateProvider>,
+	)
+}
+
+describe("FollowUpSuggest", () => {
+	const mockSuggestions = [{ answer: "First suggestion" }, { answer: "Second suggestion" }]
+
+	const mockOnSuggestionClick = vi.fn()
+	const mockOnCancelAutoApproval = vi.fn()
+
+	// Default test state with auto-approval enabled
+	const defaultTestState: TestExtensionState = {
+		autoApprovalEnabled: true,
+		alwaysAllowFollowupQuestions: true,
+		followupAutoApproveTimeoutMs: 3000, // 3 seconds for testing
+	}
+
+	beforeEach(() => {
+		vi.clearAllMocks()
+		vi.useFakeTimers()
+	})
+
+	afterEach(() => {
+		vi.useRealTimers()
+	})
+
+	it("should display countdown timer when auto-approval is enabled", () => {
+		renderWithTestProviders(
+			<FollowUpSuggest
+				suggestions={mockSuggestions}
+				onSuggestionClick={mockOnSuggestionClick}
+				ts={123}
+				onCancelAutoApproval={mockOnCancelAutoApproval}
+			/>,
+			defaultTestState,
+		)
+
+		// Should show initial countdown (3 seconds)
+		expect(screen.getByText(/3s/)).toBeInTheDocument()
+	})
+
+	it("should not display countdown timer when isAnswered is true", () => {
+		renderWithTestProviders(
+			<FollowUpSuggest
+				suggestions={mockSuggestions}
+				onSuggestionClick={mockOnSuggestionClick}
+				ts={123}
+				onCancelAutoApproval={mockOnCancelAutoApproval}
+				isAnswered={true}
+			/>,
+			defaultTestState,
+		)
+
+		// Should not show countdown
+		expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument()
+	})
+
+	it("should clear interval and call onCancelAutoApproval when component unmounts", () => {
+		const { unmount } = renderWithTestProviders(
+			<FollowUpSuggest
+				suggestions={mockSuggestions}
+				onSuggestionClick={mockOnSuggestionClick}
+				ts={123}
+				onCancelAutoApproval={mockOnCancelAutoApproval}
+			/>,
+			defaultTestState,
+		)
+
+		// Unmount the component
+		unmount()
+
+		// onCancelAutoApproval should have been called
+		expect(mockOnCancelAutoApproval).toHaveBeenCalled()
+	})
+
+	it("should not show countdown when auto-approval is disabled", () => {
+		const testState: TestExtensionState = {
+			...defaultTestState,
+			autoApprovalEnabled: false,
+		}
+
+		renderWithTestProviders(
+			<FollowUpSuggest
+				suggestions={mockSuggestions}
+				onSuggestionClick={mockOnSuggestionClick}
+				ts={123}
+				onCancelAutoApproval={mockOnCancelAutoApproval}
+			/>,
+			testState,
+		)
+
+		// Should not show countdown
+		expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument()
+	})
+
+	it("should not show countdown when alwaysAllowFollowupQuestions is false", () => {
+		const testState: TestExtensionState = {
+			...defaultTestState,
+			alwaysAllowFollowupQuestions: false,
+		}
+
+		renderWithTestProviders(
+			<FollowUpSuggest
+				suggestions={mockSuggestions}
+				onSuggestionClick={mockOnSuggestionClick}
+				ts={123}
+				onCancelAutoApproval={mockOnCancelAutoApproval}
+			/>,
+			testState,
+		)
+
+		// Should not show countdown
+		expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument()
+	})
+
+	it("should use custom timeout value from extension state", () => {
+		const testState: TestExtensionState = {
+			...defaultTestState,
+			followupAutoApproveTimeoutMs: 5000, // 5 seconds
+		}
+
+		renderWithTestProviders(
+			<FollowUpSuggest
+				suggestions={mockSuggestions}
+				onSuggestionClick={mockOnSuggestionClick}
+				ts={123}
+				onCancelAutoApproval={mockOnCancelAutoApproval}
+			/>,
+			testState,
+		)
+
+		// Should show initial countdown (5 seconds)
+		expect(screen.getByText(/5s/)).toBeInTheDocument()
+	})
+
+	it("should render suggestions without countdown when both auto-approval settings are disabled", () => {
+		const testState: TestExtensionState = {
+			autoApprovalEnabled: false,
+			alwaysAllowFollowupQuestions: false,
+			followupAutoApproveTimeoutMs: 3000,
+		}
+
+		renderWithTestProviders(
+			<FollowUpSuggest
+				suggestions={mockSuggestions}
+				onSuggestionClick={mockOnSuggestionClick}
+				ts={123}
+				onCancelAutoApproval={mockOnCancelAutoApproval}
+			/>,
+			testState,
+		)
+
+		// Should render suggestions
+		expect(screen.getByText("First suggestion")).toBeInTheDocument()
+		expect(screen.getByText("Second suggestion")).toBeInTheDocument()
+
+		// Should not show countdown
+		expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument()
+	})
+
+	it("should not render when no suggestions are provided", () => {
+		const { container } = renderWithTestProviders(
+			<FollowUpSuggest
+				suggestions={[]}
+				onSuggestionClick={mockOnSuggestionClick}
+				ts={123}
+				onCancelAutoApproval={mockOnCancelAutoApproval}
+			/>,
+			defaultTestState,
+		)
+
+		// Component should not render anything
+		expect(container.firstChild).toBeNull()
+	})
+
+	it("should not render when onSuggestionClick is not provided", () => {
+		const { container } = renderWithTestProviders(
+			<FollowUpSuggest suggestions={mockSuggestions} ts={123} onCancelAutoApproval={mockOnCancelAutoApproval} />,
+			defaultTestState,
+		)
+
+		// Component should not render anything
+		expect(container.firstChild).toBeNull()
+	})
+
+	it("should stop countdown when user manually responds (isAnswered becomes true)", () => {
+		const { rerender } = renderWithTestProviders(
+			<FollowUpSuggest
+				suggestions={mockSuggestions}
+				onSuggestionClick={mockOnSuggestionClick}
+				ts={123}
+				onCancelAutoApproval={mockOnCancelAutoApproval}
+				isAnswered={false}
+			/>,
+			defaultTestState,
+		)
+
+		// Initially should show countdown
+		expect(screen.getByText(/3s/)).toBeInTheDocument()
+
+		// Simulate user manually responding by setting isAnswered to true
+		rerender(
+			<TestExtensionStateProvider value={defaultTestState}>
+				<TooltipProvider>
+					<FollowUpSuggest
+						suggestions={mockSuggestions}
+						onSuggestionClick={mockOnSuggestionClick}
+						ts={123}
+						onCancelAutoApproval={mockOnCancelAutoApproval}
+						isAnswered={true}
+					/>
+				</TooltipProvider>
+			</TestExtensionStateProvider>,
+		)
+
+		// Countdown should no longer be visible immediately after isAnswered becomes true
+		expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument()
+
+		// Advance timer to ensure countdown doesn't restart or continue
+		vi.advanceTimersByTime(5000)
+
+		// onSuggestionClick should not have been called (auto-selection stopped)
+		expect(mockOnSuggestionClick).not.toHaveBeenCalled()
+
+		// Countdown should still not be visible
+		expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument()
+
+		// Verify onCancelAutoApproval was called when the countdown was stopped
+		expect(mockOnCancelAutoApproval).toHaveBeenCalled()
+	})
+
+	it("should handle race condition when timeout fires but user has already responded", () => {
+		// This test simulates the scenario where:
+		// 1. Auto-approval countdown starts
+		// 2. User manually responds (isAnswered becomes true)
+		// 3. The timeout still fires (because it was already scheduled)
+		// 4. The auto-selection should NOT happen because user already responded
+
+		const { rerender } = renderWithTestProviders(
+			<FollowUpSuggest
+				suggestions={mockSuggestions}
+				onSuggestionClick={mockOnSuggestionClick}
+				ts={123}
+				onCancelAutoApproval={mockOnCancelAutoApproval}
+				isAnswered={false}
+			/>,
+			defaultTestState,
+		)
+
+		// Initially should show countdown
+		expect(screen.getByText(/3s/)).toBeInTheDocument()
+
+		// Advance timer to just before timeout completes (2.5 seconds)
+		vi.advanceTimersByTime(2500)
+
+		// User manually responds before timeout completes
+		rerender(
+			<TestExtensionStateProvider value={defaultTestState}>
+				<TooltipProvider>
+					<FollowUpSuggest
+						suggestions={mockSuggestions}
+						onSuggestionClick={mockOnSuggestionClick}
+						ts={123}
+						onCancelAutoApproval={mockOnCancelAutoApproval}
+						isAnswered={true}
+					/>
+				</TooltipProvider>
+			</TestExtensionStateProvider>,
+		)
+
+		// Countdown should be hidden immediately
+		expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument()
+
+		// Now advance timer past the original timeout duration
+		vi.advanceTimersByTime(1000) // Total: 3.5 seconds
+
+		// onSuggestionClick should NOT have been called
+		// This verifies the fix for the race condition
+		expect(mockOnSuggestionClick).not.toHaveBeenCalled()
+	})
+
+	it("should update countdown display as time progresses", async () => {
+		renderWithTestProviders(
+			<FollowUpSuggest
+				suggestions={mockSuggestions}
+				onSuggestionClick={mockOnSuggestionClick}
+				ts={123}
+				onCancelAutoApproval={mockOnCancelAutoApproval}
+				isAnswered={false}
+			/>,
+			defaultTestState,
+		)
+
+		// Initially should show 3s
+		expect(screen.getByText(/3s/)).toBeInTheDocument()
+
+		// Advance timer by 1 second and wait for React to update
+		await act(async () => {
+			vi.advanceTimersByTime(1000)
+		})
+
+		// Check countdown updated to 2s
+		expect(screen.getByText(/2s/)).toBeInTheDocument()
+
+		// Advance timer by another second
+		await act(async () => {
+			vi.advanceTimersByTime(1000)
+		})
+
+		// Check countdown updated to 1s
+		expect(screen.getByText(/1s/)).toBeInTheDocument()
+
+		// Advance timer to completion - countdown should disappear
+		await act(async () => {
+			vi.advanceTimersByTime(1000)
+		})
+
+		// Countdown should no longer be visible after reaching 0
+		expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument()
+
+		// The component itself doesn't trigger auto-selection, that's handled by ChatView
+		expect(mockOnSuggestionClick).not.toHaveBeenCalled()
+	})
+
+	it("should handle component unmounting during countdown", () => {
+		const { unmount } = renderWithTestProviders(
+			<FollowUpSuggest
+				suggestions={mockSuggestions}
+				onSuggestionClick={mockOnSuggestionClick}
+				ts={123}
+				onCancelAutoApproval={mockOnCancelAutoApproval}
+				isAnswered={false}
+			/>,
+			defaultTestState,
+		)
+
+		// Initially should show countdown
+		expect(screen.getByText(/3s/)).toBeInTheDocument()
+
+		// Advance timer partially
+		vi.advanceTimersByTime(1500)
+
+		// Unmount component before countdown completes
+		unmount()
+
+		// onCancelAutoApproval should have been called
+		expect(mockOnCancelAutoApproval).toHaveBeenCalled()
+
+		// Advance timer past the original timeout
+		vi.advanceTimersByTime(2000)
+
+		// onSuggestionClick should NOT have been called (component doesn't auto-select)
+		expect(mockOnSuggestionClick).not.toHaveBeenCalled()
+	})
+})