fix: prevent race condition when switching modes rapidly with ctrl+.

- Added debouncing (150ms) to mode switching functions to prevent rapid consecutive switches
- Added isModeSwitching state flag to prevent concurrent mode switches in both UI and backend
- Updated tests to account for debounce delay
- Fixes issue where rapidly pressing ctrl+. could assign wrong model to mode

Fixes #6764
This commit is contained in:
Roo Code 2025-08-06 19:03:11 +00:00
parent 2b647ed9a1
commit 6884871ffc
3 changed files with 73 additions and 26 deletions

View file

@ -112,6 +112,7 @@ export class ClineProvider
protected mcpHub?: McpHub // Change from private to protected
private marketplaceManager: MarketplaceManager
private mdmService?: MdmService
private isModeSwitching = false
public isViewLaunched = false
public settingsImportedAt?: number
@ -956,6 +957,23 @@ export class ClineProvider
* @param newMode The mode to switch to
*/
public async handleModeSwitch(newMode: Mode) {
// Prevent concurrent mode switches
if (this.isModeSwitching) {
this.log(`Mode switch already in progress, ignoring switch to ${newMode}`)
return
}
this.isModeSwitching = true
try {
await this.performModeSwitch(newMode)
} finally {
// Always reset the flag, even if an error occurs
this.isModeSwitching = false
}
}
private async performModeSwitch(newMode: Mode) {
const cline = this.getCurrentCline()
if (cline) {

View file

@ -179,6 +179,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
const [showCheckpointWarning, setShowCheckpointWarning] = useState<boolean>(false)
const [isCondensing, setIsCondensing] = useState<boolean>(false)
const [showAnnouncementModal, setShowAnnouncementModal] = useState(false)
const [isModeSwitching, setIsModeSwitching] = useState<boolean>(false)
const everVisibleMessagesTsRef = useRef<LRUCache<number, boolean>>(
new LRUCache({
max: 100,
@ -1456,6 +1457,14 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
// Function to switch to a specific mode
const switchToMode = useCallback(
(modeSlug: string): void => {
// Prevent concurrent mode switches
if (isModeSwitching) {
return
}
// Set flag to prevent concurrent switches
setIsModeSwitching(true)
// Update local state and notify extension to sync mode change
setMode(modeSlug)
@ -1464,8 +1473,13 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
type: "mode",
text: modeSlug,
})
// Reset the flag after a short delay to allow the mode switch to complete
setTimeout(() => {
setIsModeSwitching(false)
}, 300)
},
[setMode],
[setMode, isModeSwitching],
)
const handleSuggestionClickInRow = useCallback(
@ -1714,23 +1728,31 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
tSettings,
])
// Function to handle mode switching
const switchToNextMode = useCallback(() => {
const allModes = getAllModes(customModes)
const currentModeIndex = allModes.findIndex((m) => m.slug === mode)
const nextModeIndex = (currentModeIndex + 1) % allModes.length
// Update local state and notify extension to sync mode change
switchToMode(allModes[nextModeIndex].slug)
}, [mode, customModes, switchToMode])
// Function to handle mode switching with debouncing
const switchToNextMode = useMemo(
() =>
debounce(() => {
const allModes = getAllModes(customModes)
const currentModeIndex = allModes.findIndex((m) => m.slug === mode)
const nextModeIndex = (currentModeIndex + 1) % allModes.length
// Update local state and notify extension to sync mode change
switchToMode(allModes[nextModeIndex].slug)
}, 150),
[mode, customModes, switchToMode],
)
// Function to handle switching to previous mode
const switchToPreviousMode = useCallback(() => {
const allModes = getAllModes(customModes)
const currentModeIndex = allModes.findIndex((m) => m.slug === mode)
const previousModeIndex = (currentModeIndex - 1 + allModes.length) % allModes.length
// Update local state and notify extension to sync mode change
switchToMode(allModes[previousModeIndex].slug)
}, [mode, customModes, switchToMode])
// Function to handle switching to previous mode with debouncing
const switchToPreviousMode = useMemo(
() =>
debounce(() => {
const allModes = getAllModes(customModes)
const currentModeIndex = allModes.findIndex((m) => m.slug === mode)
const previousModeIndex = (currentModeIndex - 1 + allModes.length) % allModes.length
// Update local state and notify extension to sync mode change
switchToMode(allModes[previousModeIndex].slug)
}, 150),
[mode, customModes, switchToMode],
)
// Add keyboard event handler
const handleKeyDown = useCallback(
@ -1752,13 +1774,20 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
[switchToNextMode, switchToPreviousMode],
)
// Add event listener
// Add event listener and cleanup debounced functions
useEffect(() => {
window.addEventListener("keydown", handleKeyDown)
return () => {
window.removeEventListener("keydown", handleKeyDown)
// Cancel any pending debounced calls when unmounting
if (typeof (switchToNextMode as any).cancel === "function") {
;(switchToNextMode as any).cancel()
}
if (typeof (switchToPreviousMode as any).cancel === "function") {
;(switchToPreviousMode as any).cancel()
}
}
}, [handleKeyDown])
}, [handleKeyDown, switchToNextMode, switchToPreviousMode])
useImperativeHandle(ref, () => ({
acceptInput: () => {

View file

@ -162,8 +162,8 @@ describe("ChatView - Keyboard Shortcut Fix for Dvorak", () => {
shiftKey: false,
})
// Wait for event to be processed
await new Promise((resolve) => setTimeout(resolve, 50))
// Wait for event to be processed and debounce delay (150ms)
await new Promise((resolve) => setTimeout(resolve, 200))
// Check if mode switch was triggered
const callsAfterPeriod = (vscode.postMessage as any).mock.calls
@ -183,7 +183,7 @@ describe("ChatView - Keyboard Shortcut Fix for Dvorak", () => {
})
// Wait for event to be processed
await new Promise((resolve) => setTimeout(resolve, 50))
await new Promise((resolve) => setTimeout(resolve, 200))
// Check that NO mode switch was triggered
const callsAfterV = (vscode.postMessage as any).mock.calls
@ -244,8 +244,8 @@ describe("ChatView - Keyboard Shortcut Fix for Dvorak", () => {
shiftKey: false,
})
// Wait for event to be processed
await new Promise((resolve) => setTimeout(resolve, 50))
// Wait for event to be processed and debounce delay (150ms)
await new Promise((resolve) => setTimeout(resolve, 200))
// Check if mode switch was triggered
const calls = (vscode.postMessage as any).mock.calls
@ -277,8 +277,8 @@ describe("ChatView - Keyboard Shortcut Fix for Dvorak", () => {
shiftKey: true, // Should go to previous mode
})
// Wait for event to be processed
await new Promise((resolve) => setTimeout(resolve, 50))
// Wait for event to be processed and debounce delay (150ms)
await new Promise((resolve) => setTimeout(resolve, 200))
// Check if mode switch was triggered
const calls = (vscode.postMessage as any).mock.calls