Skip to content

Commit 6884871

Browse files
committed
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
1 parent 2b647ed commit 6884871

File tree

3 files changed

+74
-27
lines changed

3 files changed

+74
-27
lines changed

src/core/webview/ClineProvider.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ export class ClineProvider
112112
protected mcpHub?: McpHub // Change from private to protected
113113
private marketplaceManager: MarketplaceManager
114114
private mdmService?: MdmService
115+
private isModeSwitching = false
115116

116117
public isViewLaunched = false
117118
public settingsImportedAt?: number
@@ -956,6 +957,23 @@ export class ClineProvider
956957
* @param newMode The mode to switch to
957958
*/
958959
public async handleModeSwitch(newMode: Mode) {
960+
// Prevent concurrent mode switches
961+
if (this.isModeSwitching) {
962+
this.log(`Mode switch already in progress, ignoring switch to ${newMode}`)
963+
return
964+
}
965+
966+
this.isModeSwitching = true
967+
968+
try {
969+
await this.performModeSwitch(newMode)
970+
} finally {
971+
// Always reset the flag, even if an error occurs
972+
this.isModeSwitching = false
973+
}
974+
}
975+
976+
private async performModeSwitch(newMode: Mode) {
959977
const cline = this.getCurrentCline()
960978

961979
if (cline) {

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

Lines changed: 49 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
179179
const [showCheckpointWarning, setShowCheckpointWarning] = useState<boolean>(false)
180180
const [isCondensing, setIsCondensing] = useState<boolean>(false)
181181
const [showAnnouncementModal, setShowAnnouncementModal] = useState(false)
182+
const [isModeSwitching, setIsModeSwitching] = useState<boolean>(false)
182183
const everVisibleMessagesTsRef = useRef<LRUCache<number, boolean>>(
183184
new LRUCache({
184185
max: 100,
@@ -1456,6 +1457,14 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
14561457
// Function to switch to a specific mode
14571458
const switchToMode = useCallback(
14581459
(modeSlug: string): void => {
1460+
// Prevent concurrent mode switches
1461+
if (isModeSwitching) {
1462+
return
1463+
}
1464+
1465+
// Set flag to prevent concurrent switches
1466+
setIsModeSwitching(true)
1467+
14591468
// Update local state and notify extension to sync mode change
14601469
setMode(modeSlug)
14611470

@@ -1464,8 +1473,13 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
14641473
type: "mode",
14651474
text: modeSlug,
14661475
})
1476+
1477+
// Reset the flag after a short delay to allow the mode switch to complete
1478+
setTimeout(() => {
1479+
setIsModeSwitching(false)
1480+
}, 300)
14671481
},
1468-
[setMode],
1482+
[setMode, isModeSwitching],
14691483
)
14701484

14711485
const handleSuggestionClickInRow = useCallback(
@@ -1714,23 +1728,31 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
17141728
tSettings,
17151729
])
17161730

1717-
// Function to handle mode switching
1718-
const switchToNextMode = useCallback(() => {
1719-
const allModes = getAllModes(customModes)
1720-
const currentModeIndex = allModes.findIndex((m) => m.slug === mode)
1721-
const nextModeIndex = (currentModeIndex + 1) % allModes.length
1722-
// Update local state and notify extension to sync mode change
1723-
switchToMode(allModes[nextModeIndex].slug)
1724-
}, [mode, customModes, switchToMode])
1725-
1726-
// Function to handle switching to previous mode
1727-
const switchToPreviousMode = useCallback(() => {
1728-
const allModes = getAllModes(customModes)
1729-
const currentModeIndex = allModes.findIndex((m) => m.slug === mode)
1730-
const previousModeIndex = (currentModeIndex - 1 + allModes.length) % allModes.length
1731-
// Update local state and notify extension to sync mode change
1732-
switchToMode(allModes[previousModeIndex].slug)
1733-
}, [mode, customModes, switchToMode])
1731+
// Function to handle mode switching with debouncing
1732+
const switchToNextMode = useMemo(
1733+
() =>
1734+
debounce(() => {
1735+
const allModes = getAllModes(customModes)
1736+
const currentModeIndex = allModes.findIndex((m) => m.slug === mode)
1737+
const nextModeIndex = (currentModeIndex + 1) % allModes.length
1738+
// Update local state and notify extension to sync mode change
1739+
switchToMode(allModes[nextModeIndex].slug)
1740+
}, 150),
1741+
[mode, customModes, switchToMode],
1742+
)
1743+
1744+
// Function to handle switching to previous mode with debouncing
1745+
const switchToPreviousMode = useMemo(
1746+
() =>
1747+
debounce(() => {
1748+
const allModes = getAllModes(customModes)
1749+
const currentModeIndex = allModes.findIndex((m) => m.slug === mode)
1750+
const previousModeIndex = (currentModeIndex - 1 + allModes.length) % allModes.length
1751+
// Update local state and notify extension to sync mode change
1752+
switchToMode(allModes[previousModeIndex].slug)
1753+
}, 150),
1754+
[mode, customModes, switchToMode],
1755+
)
17341756

17351757
// Add keyboard event handler
17361758
const handleKeyDown = useCallback(
@@ -1752,13 +1774,20 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
17521774
[switchToNextMode, switchToPreviousMode],
17531775
)
17541776

1755-
// Add event listener
1777+
// Add event listener and cleanup debounced functions
17561778
useEffect(() => {
17571779
window.addEventListener("keydown", handleKeyDown)
17581780
return () => {
17591781
window.removeEventListener("keydown", handleKeyDown)
1782+
// Cancel any pending debounced calls when unmounting
1783+
if (typeof (switchToNextMode as any).cancel === "function") {
1784+
;(switchToNextMode as any).cancel()
1785+
}
1786+
if (typeof (switchToPreviousMode as any).cancel === "function") {
1787+
;(switchToPreviousMode as any).cancel()
1788+
}
17601789
}
1761-
}, [handleKeyDown])
1790+
}, [handleKeyDown, switchToNextMode, switchToPreviousMode])
17621791

17631792
useImperativeHandle(ref, () => ({
17641793
acceptInput: () => {

webview-ui/src/components/chat/__tests__/ChatView.keyboard-fix.spec.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -162,8 +162,8 @@ describe("ChatView - Keyboard Shortcut Fix for Dvorak", () => {
162162
shiftKey: false,
163163
})
164164

165-
// Wait for event to be processed
166-
await new Promise((resolve) => setTimeout(resolve, 50))
165+
// Wait for event to be processed and debounce delay (150ms)
166+
await new Promise((resolve) => setTimeout(resolve, 200))
167167

168168
// Check if mode switch was triggered
169169
const callsAfterPeriod = (vscode.postMessage as any).mock.calls
@@ -183,7 +183,7 @@ describe("ChatView - Keyboard Shortcut Fix for Dvorak", () => {
183183
})
184184

185185
// Wait for event to be processed
186-
await new Promise((resolve) => setTimeout(resolve, 50))
186+
await new Promise((resolve) => setTimeout(resolve, 200))
187187

188188
// Check that NO mode switch was triggered
189189
const callsAfterV = (vscode.postMessage as any).mock.calls
@@ -244,8 +244,8 @@ describe("ChatView - Keyboard Shortcut Fix for Dvorak", () => {
244244
shiftKey: false,
245245
})
246246

247-
// Wait for event to be processed
248-
await new Promise((resolve) => setTimeout(resolve, 50))
247+
// Wait for event to be processed and debounce delay (150ms)
248+
await new Promise((resolve) => setTimeout(resolve, 200))
249249

250250
// Check if mode switch was triggered
251251
const calls = (vscode.postMessage as any).mock.calls
@@ -277,8 +277,8 @@ describe("ChatView - Keyboard Shortcut Fix for Dvorak", () => {
277277
shiftKey: true, // Should go to previous mode
278278
})
279279

280-
// Wait for event to be processed
281-
await new Promise((resolve) => setTimeout(resolve, 50))
280+
// Wait for event to be processed and debounce delay (150ms)
281+
await new Promise((resolve) => setTimeout(resolve, 200))
282282

283283
// Check if mode switch was triggered
284284
const calls = (vscode.postMessage as any).mock.calls

0 commit comments

Comments
 (0)