From c881e0fa66c8484c1ba3a6fd2e287a38ea92eb1a Mon Sep 17 00:00:00 2001 From: Roo Code Date: Fri, 28 Nov 2025 15:59:37 +0000 Subject: [PATCH 1/4] 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 --- .../src/components/chat/ModeSelector.tsx | 28 +++++++- .../chat/__tests__/ModeSelector.spec.tsx | 71 +++++++++++++++++++ 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/webview-ui/src/components/chat/ModeSelector.tsx b/webview-ui/src/components/chat/ModeSelector.tsx index 3f843344a22..f2c4b597306 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" @@ -71,8 +71,30 @@ 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, with fallback logic if the current mode isn't available + const selectedMode = React.useMemo(() => { + const currentMode = modes.find((mode) => mode.slug === value) + + // If the current mode exists in the available modes, use it + if (currentMode) { + return currentMode + } + + // If the current mode doesn't exist (e.g., after workspace switch), + // fall back to the default "code" mode + const fallbackMode = modes.find((mode) => mode.slug === defaultModeSlug) + + // If we found a fallback mode and it's different from the current value, + // notify the parent to update the mode + if (fallbackMode && fallbackMode.slug !== value) { + // Use setTimeout to avoid updating state during render + setTimeout(() => { + onChange(fallbackMode.slug as Mode) + }, 0) + } + + return fallbackMode + }, [modes, value, onChange]) // 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..56167d0ab43 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) + // We need to wait for the setTimeout to execute + 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") + }) }) From 619576d28db158639dea424ad821ddece8b3dc5e Mon Sep 17 00:00:00 2001 From: Roo Code Date: Fri, 28 Nov 2025 16:11:28 +0000 Subject: [PATCH 2/4] fix: prevent infinite loop by moving fallback notification to useEffect --- .../src/components/chat/ModeSelector.tsx | 23 +++++++++++-------- .../chat/__tests__/ModeSelector.spec.tsx | 2 +- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/webview-ui/src/components/chat/ModeSelector.tsx b/webview-ui/src/components/chat/ModeSelector.tsx index f2c4b597306..f5ab894c2f5 100644 --- a/webview-ui/src/components/chat/ModeSelector.tsx +++ b/webview-ui/src/components/chat/ModeSelector.tsx @@ -71,7 +71,7 @@ export const ModeSelector = ({ })) }, [customModes, customModePrompts]) - // Find the selected mode, with fallback logic if the current mode isn't available + // Find the selected mode (pure computation, no side effects) const selectedMode = React.useMemo(() => { const currentMode = modes.find((mode) => mode.slug === value) @@ -82,18 +82,21 @@ export const ModeSelector = ({ // If the current mode doesn't exist (e.g., after workspace switch), // fall back to the default "code" mode - const fallbackMode = modes.find((mode) => mode.slug === defaultModeSlug) + return modes.find((mode) => mode.slug === defaultModeSlug) + }, [modes, value]) - // If we found a fallback mode and it's different from the current value, - // notify the parent to update the mode - if (fallbackMode && fallbackMode.slug !== value) { - // Use setTimeout to avoid updating state during render - setTimeout(() => { + // Handle fallback notification separately in useEffect to avoid infinite loops + // when parent doesn't memoize onChange callback + React.useEffect(() => { + const currentMode = modes.find((mode) => mode.slug === value) + + // If the current mode doesn't exist, notify parent to switch to default mode + if (!currentMode) { + const fallbackMode = modes.find((mode) => mode.slug === defaultModeSlug) + if (fallbackMode && fallbackMode.slug !== value) { onChange(fallbackMode.slug as Mode) - }, 0) + } } - - return fallbackMode }, [modes, value, onChange]) // Memoize searchable items for fuzzy search with separate name and diff --git a/webview-ui/src/components/chat/__tests__/ModeSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/ModeSelector.spec.tsx index 56167d0ab43..6393021e626 100644 --- a/webview-ui/src/components/chat/__tests__/ModeSelector.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ModeSelector.spec.tsx @@ -259,7 +259,7 @@ describe("ModeSelector", () => { ) // The component should automatically call onChange with the fallback mode (code) - // We need to wait for the setTimeout to execute + // via useEffect after render await vi.waitFor(() => { expect(onChange).toHaveBeenCalledWith("code") }) From ab28868157477b0586af3df52cf52344962e3661 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Fri, 28 Nov 2025 16:27:04 +0000 Subject: [PATCH 3/4] fix: prevent infinite loop by using ref to track notified invalid mode --- .../src/components/chat/ModeSelector.tsx | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/webview-ui/src/components/chat/ModeSelector.tsx b/webview-ui/src/components/chat/ModeSelector.tsx index f5ab894c2f5..03483aedd39 100644 --- a/webview-ui/src/components/chat/ModeSelector.tsx +++ b/webview-ui/src/components/chat/ModeSelector.tsx @@ -46,6 +46,8 @@ export const ModeSelector = ({ const searchInputRef = React.useRef(null) const selectedItemRef = React.useRef(null) const scrollContainerRef = React.useRef(null) + // Track the last invalid mode value we've notified about to prevent infinite loops + const lastNotifiedInvalidModeRef = React.useRef(null) const portalContainer = useRooPortal("roo-portal") const { hasOpenedModeSelector, setHasOpenedModeSelector } = useExtensionState() const { t } = useAppTranslation() @@ -85,19 +87,29 @@ export const ModeSelector = ({ return modes.find((mode) => mode.slug === defaultModeSlug) }, [modes, value]) - // Handle fallback notification separately in useEffect to avoid infinite loops - // when parent doesn't memoize onChange callback + // Handle fallback notification separately in useEffect to avoid infinite loops. + // We intentionally omit `onChange` from dependencies because: + // 1. If the parent doesn't memoize onChange, it would cause infinite re-renders + // 2. We use a ref to track if we've already notified about this specific invalid mode + // 3. The effect should only trigger when modes or value changes, not when onChange reference changes React.useEffect(() => { const currentMode = modes.find((mode) => mode.slug === value) - // If the current mode doesn't exist, notify parent to switch to default mode - if (!currentMode) { - const fallbackMode = modes.find((mode) => mode.slug === defaultModeSlug) - if (fallbackMode && fallbackMode.slug !== value) { - onChange(fallbackMode.slug as Mode) + if (currentMode) { + // Mode is valid, reset the notification tracker + lastNotifiedInvalidModeRef.current = null + } else { + // Mode is invalid - only notify if we haven't already notified about this specific value + if (lastNotifiedInvalidModeRef.current !== value) { + const fallbackMode = modes.find((mode) => mode.slug === defaultModeSlug) + if (fallbackMode) { + lastNotifiedInvalidModeRef.current = value + onChange(fallbackMode.slug as Mode) + } } } - }, [modes, value, onChange]) + // eslint-disable-next-line react-hooks/exhaustive-deps -- onChange is intentionally omitted to prevent infinite loops when parent doesn't memoize the callback + }, [modes, value]) // Memoize searchable items for fuzzy search with separate name and // description search. From d10e3bc2eef6a824f6de4c00d2321f63515ea036 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Wed, 21 Jan 2026 17:49:16 -0500 Subject: [PATCH 4/4] refactor: clean up comments in ModeSelector fallback logic --- .../src/components/chat/ModeSelector.tsx | 47 +++++++------------ 1 file changed, 17 insertions(+), 30 deletions(-) diff --git a/webview-ui/src/components/chat/ModeSelector.tsx b/webview-ui/src/components/chat/ModeSelector.tsx index 03483aedd39..b913659e947 100644 --- a/webview-ui/src/components/chat/ModeSelector.tsx +++ b/webview-ui/src/components/chat/ModeSelector.tsx @@ -46,7 +46,6 @@ export const ModeSelector = ({ const searchInputRef = React.useRef(null) const selectedItemRef = React.useRef(null) const scrollContainerRef = React.useRef(null) - // Track the last invalid mode value we've notified about to prevent infinite loops const lastNotifiedInvalidModeRef = React.useRef(null) const portalContainer = useRooPortal("roo-portal") const { hasOpenedModeSelector, setHasOpenedModeSelector } = useExtensionState() @@ -73,42 +72,30 @@ export const ModeSelector = ({ })) }, [customModes, customModePrompts]) - // Find the selected mode (pure computation, no side effects) + // Find the selected mode, falling back to default if current mode doesn't exist (e.g., after workspace switch) const selectedMode = React.useMemo(() => { - const currentMode = modes.find((mode) => mode.slug === value) - - // If the current mode exists in the available modes, use it - if (currentMode) { - return currentMode - } - - // If the current mode doesn't exist (e.g., after workspace switch), - // fall back to the default "code" mode - return modes.find((mode) => mode.slug === defaultModeSlug) + return modes.find((mode) => mode.slug === value) ?? modes.find((mode) => mode.slug === defaultModeSlug) }, [modes, value]) - // Handle fallback notification separately in useEffect to avoid infinite loops. - // We intentionally omit `onChange` from dependencies because: - // 1. If the parent doesn't memoize onChange, it would cause infinite re-renders - // 2. We use a ref to track if we've already notified about this specific invalid mode - // 3. The effect should only trigger when modes or value changes, not when onChange reference changes + // Notify parent when current mode is invalid so it can update its state React.useEffect(() => { - const currentMode = modes.find((mode) => mode.slug === value) + const isValidMode = modes.some((mode) => mode.slug === value) - if (currentMode) { - // Mode is valid, reset the notification tracker + if (isValidMode) { lastNotifiedInvalidModeRef.current = null - } else { - // Mode is invalid - only notify if we haven't already notified about this specific value - if (lastNotifiedInvalidModeRef.current !== value) { - const fallbackMode = modes.find((mode) => mode.slug === defaultModeSlug) - if (fallbackMode) { - lastNotifiedInvalidModeRef.current = value - onChange(fallbackMode.slug as Mode) - } - } + 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 is intentionally omitted to prevent infinite loops when parent doesn't memoize the callback + // 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