diff --git a/webview-ui/src/components/chat/ModeSelector.tsx b/webview-ui/src/components/chat/ModeSelector.tsx index 3f843344a22..b913659e947 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 e2fd310b219..6393021e626 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") + }) })