Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 3 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
43 changes: 40 additions & 3 deletions webview-ui/src/components/chat/ModeSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -46,6 +46,8 @@ export const ModeSelector = ({
const searchInputRef = React.useRef<HTMLInputElement>(null)
const selectedItemRef = React.useRef<HTMLDivElement>(null)
const scrollContainerRef = React.useRef<HTMLDivElement>(null)
// Track the last invalid mode value we've notified about to prevent infinite loops
const lastNotifiedInvalidModeRef = React.useRef<string | null>(null)
const portalContainer = useRooPortal("roo-portal")
const { hasOpenedModeSelector, setHasOpenedModeSelector } = useExtensionState()
const { t } = useAppTranslation()
Expand All @@ -71,8 +73,43 @@ 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 (pure computation, no side effects)
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)
}, [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
React.useEffect(() => {
const currentMode = modes.find((mode) => mode.slug === value)

if (currentMode) {
// Mode is valid, reset the notification tracker
lastNotifiedInvalidModeRef.current = null
Comment thread
daniel-lxs marked this conversation as resolved.
} 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)
}
}
}
// 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.
Expand Down
71 changes: 71 additions & 0 deletions webview-ui/src/components/chat/__tests__/ModeSelector.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ vi.mock("@roo/modes", async () => {
return {
...actual,
getAllModes: () => mockModes,
defaultModeSlug: "code", // Export the default mode slug for tests
}
})

Expand Down Expand Up @@ -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")
})
})
Loading