Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion webview-ui/src/components/chat/ChatRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -104,6 +105,7 @@ export const ChatRowContent = ({
onSuggestionClick,
onFollowUpUnmount,
onBatchFileResponse,
isFollowUpAnswered,
editable,
}: ChatRowContentProps) => {
const { t } = useTranslation()
Expand Down Expand Up @@ -1238,7 +1240,8 @@ export const ChatRowContent = ({
suggestions={followUpData?.suggest}
onSuggestionClick={onSuggestionClick}
ts={message?.ts}
onUnmount={onFollowUpUnmount}
onCancelAutoApproval={onFollowUpUnmount}
isAnswered={isFollowUpAnswered}
/>
</>
)
Expand Down
82 changes: 73 additions & 9 deletions webview-ui/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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 }) => {
Expand All @@ -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(
Expand Down Expand Up @@ -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" &&
Expand Down Expand Up @@ -1322,6 +1369,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
handleSuggestionClickInRow,
handleBatchFileResponse,
handleFollowUpUnmount,
currentFollowUpTs,
alwaysAllowUpdateTodoList,
enableButtons,
primaryButtonText,
Expand All @@ -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
Expand All @@ -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]

Expand All @@ -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)
})
}

Expand Down
31 changes: 23 additions & 8 deletions webview-ui/src/components/chat/FollowUpSuggest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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) => {
Expand All @@ -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.
Expand All @@ -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 })}>
Expand Down
Loading
Loading