From 5bd26eb1d9848965cb9f716585f70d4a9c4708a3 Mon Sep 17 00:00:00 2001 From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com> Date: Wed, 21 Jan 2026 20:47:51 -0500 Subject: [PATCH] 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 Co-authored-by: daniel-lxs --- .../src/components/chat/ModeSelector.tsx | 30 +++++++- .../chat/__tests__/ModeSelector.spec.tsx | 71 +++++++++++++++++++ 2 files changed, 98 insertions(+), 3 deletions(-) diff --git a/webview-ui/src/components/chat/ModeSelector.tsx b/webview-ui/src/components/chat/ModeSelector.tsx index 8810850fad..b436d92225 100644 --- a/webview-ui/src/components/chat/ModeSelector.tsx +++ b/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(null) const selectedItemRef = React.useRef(null) const scrollContainerRef = React.useRef(null) + const lastNotifiedInvalidModeRef = React.useRef(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. diff --git a/webview-ui/src/components/chat/__tests__/ModeSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/ModeSelector.spec.tsx index e2fd310b21..6393021e62 100644 --- a/webview-ui/src/components/chat/__tests__/ModeSelector.spec.tsx +++ b/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( + , + ) + + // 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( + , + ) + + // Should show the default mode name instead of empty string + const trigger = screen.getByTestId("mode-selector-trigger") + expect(trigger).toHaveTextContent("Code") + }) })