Skip to content

Commit aeea95b

Browse files
committed
fixed bug
1 parent c72f4a4 commit aeea95b

File tree

4 files changed

+255
-4
lines changed

4 files changed

+255
-4
lines changed

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ interface ChatRowProps {
5252
onSuggestionClick?: (suggestion: SuggestionItem, event?: React.MouseEvent) => void
5353
onBatchFileResponse?: (response: { [key: string]: boolean }) => void
5454
onFollowUpUnmount?: () => void
55+
isFollowUpAnswered?: boolean
5556
}
5657

5758
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
@@ -102,6 +103,7 @@ export const ChatRowContent = ({
102103
onSuggestionClick,
103104
onFollowUpUnmount,
104105
onBatchFileResponse,
106+
isFollowUpAnswered,
105107
}: ChatRowContentProps) => {
106108
const { t } = useTranslation()
107109
const { mcpServers, alwaysAllowMcp, currentCheckpoint } = useExtensionState()
@@ -1219,6 +1221,7 @@ export const ChatRowContent = ({
12191221
onSuggestionClick={onSuggestionClick}
12201222
ts={message?.ts}
12211223
onUnmount={onFollowUpUnmount}
1224+
isAnswered={isFollowUpAnswered}
12221225
/>
12231226
</>
12241227
)

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

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
163163
}),
164164
)
165165
const autoApproveTimeoutRef = useRef<NodeJS.Timeout | null>(null)
166+
const [followUpAnswered, setFollowUpAnswered] = useState<Set<number>>(new Set())
166167

167168
const clineAskRef = useRef(clineAsk)
168169
useEffect(() => {
@@ -415,6 +416,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
415416
useEffect(() => {
416417
setExpandedRows({})
417418
everVisibleMessagesTsRef.current.clear() // Clear for new task
419+
setFollowUpAnswered(new Set()) // Clear follow-up answered state for new task
418420
}, [task?.ts])
419421

420422
useEffect(() => {
@@ -487,6 +489,12 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
487489
}, [modifiedMessages, clineAsk, enableButtons, primaryButtonText])
488490

489491
const handleChatReset = useCallback(() => {
492+
// Clear any pending auto-approval timeout to prevent race conditions
493+
if (autoApproveTimeoutRef.current) {
494+
clearTimeout(autoApproveTimeoutRef.current)
495+
autoApproveTimeoutRef.current = null
496+
}
497+
490498
// Only reset message-specific state, preserving mode.
491499
setInputValue("")
492500
setSendingDisabled(true)
@@ -507,6 +515,21 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
507515
if (messagesRef.current.length === 0) {
508516
vscode.postMessage({ type: "newTask", text, images })
509517
} else if (clineAskRef.current) {
518+
// Clear auto-approval timeout when user sends a message for follow-up questions
519+
// This ensures the countdown timer is properly dismounted when users submit custom responses
520+
if (clineAskRef.current === "followup") {
521+
if (autoApproveTimeoutRef.current) {
522+
clearTimeout(autoApproveTimeoutRef.current)
523+
autoApproveTimeoutRef.current = null
524+
}
525+
526+
// Mark the current follow-up question as answered
527+
const lastFollowUpMessage = messagesRef.current.findLast((msg) => msg.ask === "followup")
528+
if (lastFollowUpMessage) {
529+
setFollowUpAnswered((prev) => new Set(prev).add(lastFollowUpMessage.ts))
530+
}
531+
}
532+
510533
// Use clineAskRef.current
511534
switch (
512535
clineAskRef.current // Use clineAskRef.current
@@ -1214,6 +1237,14 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
12141237

12151238
const handleSuggestionClickInRow = useCallback(
12161239
(suggestion: SuggestionItem, event?: React.MouseEvent) => {
1240+
// Mark the current follow-up question as answered when a suggestion is clicked
1241+
if (clineAsk === "followup" && !event?.shiftKey) {
1242+
const lastFollowUpMessage = messagesRef.current.findLast((msg) => msg.ask === "followup")
1243+
if (lastFollowUpMessage) {
1244+
setFollowUpAnswered((prev) => new Set(prev).add(lastFollowUpMessage.ts))
1245+
}
1246+
}
1247+
12171248
// Check if we need to switch modes
12181249
if (suggestion.mode) {
12191250
// Only switch modes if it's a manual click (event exists) or auto-approval is allowed
@@ -1233,7 +1264,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
12331264
handleSendMessage(suggestion.answer, [])
12341265
}
12351266
},
1236-
[handleSendMessage, setInputValue, switchToMode, alwaysAllowModeSwitch],
1267+
[handleSendMessage, setInputValue, switchToMode, alwaysAllowModeSwitch, clineAsk],
12371268
)
12381269

12391270
const handleBatchFileResponse = useCallback((response: { [key: string]: boolean }) => {
@@ -1286,6 +1317,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
12861317
onSuggestionClick={handleSuggestionClickInRow} // This was already stabilized
12871318
onBatchFileResponse={handleBatchFileResponse}
12881319
onFollowUpUnmount={handleFollowUpUnmount}
1320+
isFollowUpAnswered={followUpAnswered.has(messageOrGroup.ts)}
12891321
/>
12901322
)
12911323
},
@@ -1299,6 +1331,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
12991331
handleSuggestionClickInRow,
13001332
handleBatchFileResponse,
13011333
handleFollowUpUnmount,
1334+
followUpAnswered,
13021335
],
13031336
)
13041337

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

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,16 @@ interface FollowUpSuggestProps {
1515
onSuggestionClick?: (suggestion: SuggestionItem, event?: React.MouseEvent) => void
1616
ts: number
1717
onUnmount?: () => 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+
onUnmount,
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)
@@ -64,6 +78,7 @@ export const FollowUpSuggest = ({ suggestions = [], onSuggestionClick, ts = 1, o
6478
followupAutoApproveTimeoutMs,
6579
suggestionSelected,
6680
onUnmount,
81+
isAnswered,
6782
])
6883
const handleSuggestionClick = useCallback(
6984
(suggestion: SuggestionItem, event: React.MouseEvent) => {
@@ -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 })}>
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import { render, screen } from "@testing-library/react"
2+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
3+
import { FollowUpSuggest } from "../FollowUpSuggest"
4+
import { ExtensionStateContext, ExtensionStateContextType } from "@src/context/ExtensionStateContext"
5+
import { TooltipProvider } from "@radix-ui/react-tooltip"
6+
7+
// Mock the translation hook
8+
vi.mock("@src/i18n/TranslationContext", () => ({
9+
TranslationProvider: ({ children }: { children: React.ReactNode }) => children,
10+
useAppTranslation: () => ({
11+
t: (key: string, options?: any) => {
12+
if (key === "chat:followUpSuggest.countdownDisplay" && options?.count !== undefined) {
13+
return `${options.count}s`
14+
}
15+
if (key === "chat:followUpSuggest.autoSelectCountdown" && options?.count !== undefined) {
16+
return `Auto-selecting in ${options.count} seconds`
17+
}
18+
if (key === "chat:followUpSuggest.copyToInput") {
19+
return "Copy to input"
20+
}
21+
return key
22+
},
23+
}),
24+
}))
25+
26+
// Mock the extension state
27+
const createMockExtensionState = (overrides?: Partial<ExtensionStateContextType>): ExtensionStateContextType =>
28+
({
29+
version: "1.0.0",
30+
clineMessages: [],
31+
taskHistory: [],
32+
shouldShowAnnouncement: false,
33+
allowedCommands: [],
34+
soundEnabled: false,
35+
soundVolume: 0.5,
36+
ttsEnabled: false,
37+
ttsSpeed: 1.0,
38+
diffEnabled: false,
39+
enableCheckpoints: true,
40+
fuzzyMatchThreshold: 1.0,
41+
language: "en",
42+
writeDelayMs: 1000,
43+
browserViewportSize: "900x600",
44+
screenshotQuality: 75,
45+
terminalOutputLineLimit: 500,
46+
terminalShellIntegrationTimeout: 4000,
47+
mcpEnabled: true,
48+
enableMcpServerCreation: false,
49+
alwaysApproveResubmit: false,
50+
requestDelaySeconds: 5,
51+
currentApiConfigName: "default",
52+
listApiConfigMeta: [],
53+
mode: "code",
54+
customModePrompts: {},
55+
customSupportPrompts: {},
56+
experiments: {},
57+
enhancementApiConfigId: "",
58+
condensingApiConfigId: "",
59+
customCondensingPrompt: "",
60+
hasOpenedModeSelector: false,
61+
autoApprovalEnabled: true,
62+
alwaysAllowFollowupQuestions: true,
63+
followupAutoApproveTimeoutMs: 3000, // 3 seconds for testing
64+
customModes: [],
65+
maxOpenTabsContext: 20,
66+
maxWorkspaceFiles: 200,
67+
cwd: "",
68+
browserToolEnabled: true,
69+
telemetrySetting: "unset",
70+
showRooIgnoredFiles: true,
71+
renderContext: "sidebar",
72+
maxReadFileLine: -1,
73+
pinnedApiConfigs: {},
74+
didHydrateState: true,
75+
showWelcome: false,
76+
theme: {},
77+
mcpServers: [],
78+
filePaths: [],
79+
openedTabs: [],
80+
organizationAllowList: { type: "all" },
81+
cloudIsAuthenticated: false,
82+
sharingEnabled: false,
83+
mdmCompliant: true,
84+
autoCondenseContext: false,
85+
autoCondenseContextPercent: 50,
86+
setHasOpenedModeSelector: vi.fn(),
87+
setAlwaysAllowFollowupQuestions: vi.fn(),
88+
setFollowupAutoApproveTimeoutMs: vi.fn(),
89+
setCondensingApiConfigId: vi.fn(),
90+
setCustomCondensingPrompt: vi.fn(),
91+
setPinnedApiConfigs: vi.fn(),
92+
togglePinnedApiConfig: vi.fn(),
93+
setTerminalCompressProgressBar: vi.fn(),
94+
setHistoryPreviewCollapsed: vi.fn(),
95+
setAutoCondenseContext: vi.fn(),
96+
setAutoCondenseContextPercent: vi.fn(),
97+
...overrides,
98+
}) as ExtensionStateContextType
99+
100+
const renderWithProviders = (component: React.ReactElement, stateOverrides?: Partial<ExtensionStateContextType>) => {
101+
const mockState = createMockExtensionState(stateOverrides)
102+
103+
return render(
104+
<ExtensionStateContext.Provider value={mockState}>
105+
<TooltipProvider>{component}</TooltipProvider>
106+
</ExtensionStateContext.Provider>,
107+
)
108+
}
109+
110+
describe("FollowUpSuggest", () => {
111+
const mockSuggestions = [{ answer: "First suggestion" }, { answer: "Second suggestion" }]
112+
113+
const mockOnSuggestionClick = vi.fn()
114+
const mockOnUnmount = vi.fn()
115+
116+
beforeEach(() => {
117+
vi.clearAllMocks()
118+
vi.useFakeTimers()
119+
})
120+
121+
afterEach(() => {
122+
vi.useRealTimers()
123+
})
124+
125+
it("should display countdown timer when auto-approval is enabled", () => {
126+
renderWithProviders(
127+
<FollowUpSuggest
128+
suggestions={mockSuggestions}
129+
onSuggestionClick={mockOnSuggestionClick}
130+
ts={123}
131+
onUnmount={mockOnUnmount}
132+
/>,
133+
)
134+
135+
// Should show initial countdown (3 seconds)
136+
expect(screen.getByText(/3s/)).toBeInTheDocument()
137+
})
138+
139+
it("should not display countdown timer when isAnswered is true", () => {
140+
renderWithProviders(
141+
<FollowUpSuggest
142+
suggestions={mockSuggestions}
143+
onSuggestionClick={mockOnSuggestionClick}
144+
ts={123}
145+
onUnmount={mockOnUnmount}
146+
isAnswered={true}
147+
/>,
148+
)
149+
150+
// Should not show countdown
151+
expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument()
152+
})
153+
154+
it("should clear interval and call onUnmount when component unmounts", () => {
155+
const { unmount } = renderWithProviders(
156+
<FollowUpSuggest
157+
suggestions={mockSuggestions}
158+
onSuggestionClick={mockOnSuggestionClick}
159+
ts={123}
160+
onUnmount={mockOnUnmount}
161+
/>,
162+
)
163+
164+
// Unmount the component
165+
unmount()
166+
167+
// onUnmount should have been called
168+
expect(mockOnUnmount).toHaveBeenCalled()
169+
})
170+
171+
it("should not show countdown when auto-approval is disabled", () => {
172+
renderWithProviders(
173+
<FollowUpSuggest
174+
suggestions={mockSuggestions}
175+
onSuggestionClick={mockOnSuggestionClick}
176+
ts={123}
177+
onUnmount={mockOnUnmount}
178+
/>,
179+
{ autoApprovalEnabled: false },
180+
)
181+
182+
// Should not show countdown
183+
expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument()
184+
})
185+
186+
it("should not show countdown when alwaysAllowFollowupQuestions is false", () => {
187+
renderWithProviders(
188+
<FollowUpSuggest
189+
suggestions={mockSuggestions}
190+
onSuggestionClick={mockOnSuggestionClick}
191+
ts={123}
192+
onUnmount={mockOnUnmount}
193+
/>,
194+
{ alwaysAllowFollowupQuestions: false },
195+
)
196+
197+
// Should not show countdown
198+
expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument()
199+
})
200+
})

0 commit comments

Comments
 (0)