Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
30 changes: 27 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,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()
Expand All @@ -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.
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