Просмотр исходного кода

fix: Handle mode selector empty state on workspace switch (#9674)

* fix: handle mode selector empty state on workspace switch

When switching between VS Code workspaces, if the current mode from
workspace A is not available in workspace B, the mode selector would
show an empty string. This fix adds fallback logic to automatically
switch to the default "code" mode when the current mode is not found
in the available modes list.

Changes:
- Import defaultModeSlug from @roo/modes
- Add fallback logic in selectedMode useMemo to detect when current
  mode is not available and automatically switch to default mode
- Add tests to verify the fallback behavior works correctly
- Export defaultModeSlug in test mock for consistent behavior

* fix: prevent infinite loop by moving fallback notification to useEffect

* fix: prevent infinite loop by using ref to track notified invalid mode

* refactor: clean up comments in ModeSelector fallback logic

---------

Co-authored-by: Roo Code <[email protected]>
Co-authored-by: daniel-lxs <[email protected]>
roomote[bot] 3 недель назад
Родитель
Сommit
5bd26eb1d9

+ 27 - 3
webview-ui/src/components/chat/ModeSelector.tsx

@@ -4,7 +4,7 @@ import { Check, X } from "lucide-react"
 
 import { type ModeConfig, type CustomModePrompts, TelemetryEventName } from "@roo-code/types"
 
-import { type Mode, getAllModes } from "@roo/modes"
+import { type Mode, getAllModes, defaultModeSlug } from "@roo/modes"
 
 import { vscode } from "@/utils/vscode"
 import { telemetryClient } from "@/utils/TelemetryClient"
@@ -46,6 +46,7 @@ export const ModeSelector = ({
 	const searchInputRef = React.useRef<HTMLInputElement>(null)
 	const selectedItemRef = React.useRef<HTMLDivElement>(null)
 	const scrollContainerRef = React.useRef<HTMLDivElement>(null)
+	const lastNotifiedInvalidModeRef = React.useRef<string | null>(null)
 	const portalContainer = useRooPortal("roo-portal")
 	const { hasOpenedModeSelector, setHasOpenedModeSelector } = useExtensionState()
 	const { t } = useAppTranslation()
@@ -71,8 +72,31 @@ export const ModeSelector = ({
 		}))
 	}, [customModes, customModePrompts])
 
-	// Find the selected mode.
-	const selectedMode = React.useMemo(() => modes.find((mode) => mode.slug === value), [modes, value])
+	// Find the selected mode, falling back to default if current mode doesn't exist (e.g., after workspace switch)
+	const selectedMode = React.useMemo(() => {
+		return modes.find((mode) => mode.slug === value) ?? modes.find((mode) => mode.slug === defaultModeSlug)
+	}, [modes, value])
+
+	// Notify parent when current mode is invalid so it can update its state
+	React.useEffect(() => {
+		const isValidMode = modes.some((mode) => mode.slug === value)
+
+		if (isValidMode) {
+			lastNotifiedInvalidModeRef.current = null
+			return
+		}
+
+		if (lastNotifiedInvalidModeRef.current === value) {
+			return
+		}
+
+		const fallbackMode = modes.find((mode) => mode.slug === defaultModeSlug)
+		if (fallbackMode) {
+			lastNotifiedInvalidModeRef.current = value
+			onChange(fallbackMode.slug as Mode)
+		}
+		// eslint-disable-next-line react-hooks/exhaustive-deps -- onChange omitted to prevent loops when parent doesn't memoize
+	}, [modes, value])
 
 	// Memoize searchable items for fuzzy search with separate name and
 	// description search.

+ 71 - 0
webview-ui/src/components/chat/__tests__/ModeSelector.spec.tsx

@@ -43,6 +43,7 @@ vi.mock("@roo/modes", async () => {
 	return {
 		...actual,
 		getAllModes: () => mockModes,
+		defaultModeSlug: "code", // Export the default mode slug for tests
 	}
 })
 
@@ -226,4 +227,74 @@ describe("ModeSelector", () => {
 		const infoIcon = document.querySelector(".codicon-info")
 		expect(infoIcon).toBeInTheDocument()
 	})
+
+	test("falls back to default mode when current mode is not available", async () => {
+		// Set up modes including "code" as the default mode (which getAllModes returns first)
+		mockModes = [
+			{
+				slug: "code",
+				name: "Code",
+				description: "Code mode",
+				roleDefinition: "Role definition",
+				groups: ["read", "edit"],
+			},
+			{
+				slug: "other",
+				name: "Other",
+				description: "Other mode",
+				roleDefinition: "Role definition",
+				groups: ["read"],
+			},
+		]
+
+		const onChange = vi.fn()
+
+		render(
+			<ModeSelector
+				title="Mode Selector"
+				value={"non-existent-mode" as Mode}
+				onChange={onChange}
+				modeShortcutText="Ctrl+M"
+			/>,
+		)
+
+		// The component should automatically call onChange with the fallback mode (code)
+		// via useEffect after render
+		await vi.waitFor(() => {
+			expect(onChange).toHaveBeenCalledWith("code")
+		})
+	})
+
+	test("shows default mode name when current mode is not available", () => {
+		// Set up modes where "code" is available (the default mode)
+		mockModes = [
+			{
+				slug: "code",
+				name: "Code",
+				description: "Code mode",
+				roleDefinition: "Role definition",
+				groups: ["read", "edit"],
+			},
+			{
+				slug: "other",
+				name: "Other",
+				description: "Other mode",
+				roleDefinition: "Role definition",
+				groups: ["read"],
+			},
+		]
+
+		render(
+			<ModeSelector
+				title="Mode Selector"
+				value={"non-existent-mode" as Mode}
+				onChange={vi.fn()}
+				modeShortcutText="Ctrl+M"
+			/>,
+		)
+
+		// Should show the default mode name instead of empty string
+		const trigger = screen.getByTestId("mode-selector-trigger")
+		expect(trigger).toHaveTextContent("Code")
+	})
 })