Skip to content
Closed
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
55 changes: 55 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ export class ClineProvider
protected mcpHub?: McpHub // Change from private to protected
private marketplaceManager: MarketplaceManager
private mdmService?: MdmService
private isModeSwitching = false
private pendingModeSwitch: Mode | null = null

// Constants for mode switching delays
private static readonly MODE_SWITCH_TIMEOUT_MS = 150

public isViewLaunched = false
public settingsImportedAt?: number
Expand Down Expand Up @@ -956,6 +961,56 @@ export class ClineProvider
* @param newMode The mode to switch to
*/
public async handleModeSwitch(newMode: Mode) {
// If a mode switch is in progress, update the pending mode
// The most recent request wins
if (this.isModeSwitching) {
this.log(`Mode switch in progress, updating pending switch to ${newMode}`)
this.pendingModeSwitch = newMode
// Return a promise that resolves when the pending switch completes
return new Promise<void>((resolve, reject) => {
const checkInterval = setInterval(() => {
// Check if our pending mode has been processed
if (!this.isModeSwitching && this.pendingModeSwitch !== newMode) {
clearInterval(checkInterval)
resolve()
}
}, 50)

// Timeout after 1 second to prevent hanging
setTimeout(() => {
clearInterval(checkInterval)
resolve()
}, 1000)
})
}

this.isModeSwitching = true

try {
await this.performModeSwitch(newMode)
} catch (error) {
// Log the error and re-throw to maintain existing behavior
this.log(`Error during mode switch: ${error instanceof Error ? error.message : String(error)}`)
throw error
} finally {
// Use configurable timeout for testing
const timeoutMs = (globalThis as any).__TEST_MODE_SWITCH_TIMEOUT_MS ?? ClineProvider.MODE_SWITCH_TIMEOUT_MS

// Reset the flag after a delay to ensure synchronization with frontend
setTimeout(async () => {
this.isModeSwitching = false

// Process any pending mode switch
if (this.pendingModeSwitch) {
const pendingMode = this.pendingModeSwitch
this.pendingModeSwitch = null
await this.handleModeSwitch(pendingMode)
}
}, timeoutMs)
}
}

private async performModeSwitch(newMode: Mode) {
const cline = this.getCurrentCline()

if (cline) {
Expand Down
19 changes: 13 additions & 6 deletions src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,9 @@ describe("ClineProvider - Sticky Mode", () => {
beforeEach(() => {
vi.clearAllMocks()

// Set mode switch timeout to 0 for testing to avoid delays
;(globalThis as any).__TEST_MODE_SWITCH_TIMEOUT_MS = 0

if (!TelemetryService.hasInstance()) {
TelemetryService.createInstance([])
}
Expand Down Expand Up @@ -257,6 +260,11 @@ describe("ClineProvider - Sticky Mode", () => {
})
})

afterEach(() => {
// Clean up test override
delete (globalThis as any).__TEST_MODE_SWITCH_TIMEOUT_MS
})

describe("handleModeSwitch", () => {
beforeEach(async () => {
await provider.resolveWebviewView(mockWebviewView)
Expand Down Expand Up @@ -1091,17 +1099,16 @@ describe("ClineProvider - Sticky Mode", () => {
// Mock getCurrentCline to return different tasks
const getCurrentClineSpy = vi.spyOn(provider, "getCurrentCline")

// Simulate simultaneous mode switches for different tasks
// Simulate mode switches for different tasks
// Need to do them sequentially since they all use the same provider
getCurrentClineSpy.mockReturnValue(task1 as any)
const switch1 = provider.handleModeSwitch("architect")
await provider.handleModeSwitch("architect")

getCurrentClineSpy.mockReturnValue(task2 as any)
const switch2 = provider.handleModeSwitch("debug")
await provider.handleModeSwitch("debug")

getCurrentClineSpy.mockReturnValue(task3 as any)
const switch3 = provider.handleModeSwitch("code")

await Promise.all([switch1, switch2, switch3])
await provider.handleModeSwitch("code")

// Verify each task was updated with its new mode
expect(task1._taskMode).toBe("architect")
Expand Down
95 changes: 68 additions & 27 deletions webview-ui/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"
import { useDeepCompareEffect, useEvent, useMount } from "react-use"
import debounce from "debounce"

// Constants for mode switching delays
const MODE_SWITCH_DEBOUNCE_MS = 150
const MODE_SWITCH_RESET_DELAY_MS = 150 // Aligned with debounce for consistency
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"
import removeMd from "remove-markdown"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
Expand Down Expand Up @@ -179,6 +183,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,
Expand Down Expand Up @@ -1456,16 +1461,37 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
// Function to switch to a specific mode
const switchToMode = useCallback(
(modeSlug: string): void => {
// Update local state and notify extension to sync mode change
setMode(modeSlug)
// Prevent concurrent mode switches
if (isModeSwitching) {
return
}

// Send the mode switch message
vscode.postMessage({
type: "mode",
text: modeSlug,
})
try {
// Set flag to prevent concurrent switches
setIsModeSwitching(true)

// Update local state and notify extension to sync mode change
setMode(modeSlug)

// Send the mode switch message
vscode.postMessage({
type: "mode",
text: modeSlug,
})

// Reset the flag after a short delay to allow the mode switch to complete
setTimeout(() => {
setIsModeSwitching(false)
}, MODE_SWITCH_RESET_DELAY_MS)
} catch (error) {
// Reset the flag on error to allow retry
setIsModeSwitching(false)
console.error("Failed to switch mode:", error)
// Optionally show user-friendly error message
// You could add a toast notification here if available
}
},
[setMode],
[setMode, isModeSwitching],
)

const handleSuggestionClickInRow = useCallback(
Expand Down Expand Up @@ -1714,23 +1740,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 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 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)
}, MODE_SWITCH_DEBOUNCE_MS),
[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)
}, MODE_SWITCH_DEBOUNCE_MS),
[mode, customModes, switchToMode],
)

// Add keyboard event handler
const handleKeyDown = useCallback(
Expand All @@ -1752,13 +1786,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: () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,9 @@ 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)
// Adding extra buffer to ensure debounce completes
await new Promise((resolve) => setTimeout(resolve, 200))

// Check if mode switch was triggered
const callsAfterPeriod = (vscode.postMessage as any).mock.calls
Expand All @@ -183,7 +184,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
Expand Down Expand Up @@ -244,8 +245,9 @@ 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)
// Adding extra buffer to ensure debounce completes
await new Promise((resolve) => setTimeout(resolve, 200))

// Check if mode switch was triggered
const calls = (vscode.postMessage as any).mock.calls
Expand Down Expand Up @@ -277,8 +279,9 @@ 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)
// Adding extra buffer to ensure debounce completes
await new Promise((resolve) => setTimeout(resolve, 200))

// Check if mode switch was triggered
const calls = (vscode.postMessage as any).mock.calls
Expand Down
Loading