Roo-Code/webview-ui/src/components/chat/ModeSelector.tsx
roomote[bot] 5bd26eb1d9
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 <roomote@roocode.com>
Co-authored-by: daniel-lxs <ricciodaniel98@gmail.com>
2026-01-21 20:47:51 -05:00

359 lines
12 KiB
TypeScript

import React from "react"
import { Fzf } from "fzf"
import { Check, X } from "lucide-react"
import { type ModeConfig, type CustomModePrompts, TelemetryEventName } from "@roo-code/types"
import { type Mode, getAllModes, defaultModeSlug } from "@roo/modes"
import { vscode } from "@/utils/vscode"
import { telemetryClient } from "@/utils/TelemetryClient"
import { cn } from "@/lib/utils"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { useRooPortal } from "@/components/ui/hooks/useRooPortal"
import { Popover, PopoverContent, PopoverTrigger, StandardTooltip } from "@/components/ui"
import { IconButton } from "./IconButton"
const SEARCH_THRESHOLD = 6
interface ModeSelectorProps {
value: Mode
onChange: (value: Mode) => void
disabled?: boolean
title: string
triggerClassName?: string
modeShortcutText: string
customModes?: ModeConfig[]
customModePrompts?: CustomModePrompts
disableSearch?: boolean
}
export const ModeSelector = ({
value,
onChange,
disabled = false,
title,
triggerClassName = "",
modeShortcutText,
customModes,
customModePrompts,
disableSearch = false,
}: ModeSelectorProps) => {
const [open, setOpen] = React.useState(false)
const [searchValue, setSearchValue] = React.useState("")
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()
const trackModeSelectorOpened = React.useCallback(() => {
// Track telemetry every time the mode selector is opened.
telemetryClient.capture(TelemetryEventName.MODE_SELECTOR_OPENED)
// Track first-time usage for UI purposes.
if (!hasOpenedModeSelector) {
setHasOpenedModeSelector(true)
vscode.postMessage({ type: "hasOpenedModeSelector", bool: true })
}
}, [hasOpenedModeSelector, setHasOpenedModeSelector])
// Get all modes including custom modes and merge custom prompt descriptions.
const modes = React.useMemo(() => {
const allModes = getAllModes(customModes)
return allModes.map((mode) => ({
...mode,
description: customModePrompts?.[mode.slug]?.description ?? mode.description,
}))
}, [customModes, customModePrompts])
// 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.
const nameSearchItems = React.useMemo(() => {
return modes.map((mode) => ({
original: mode,
searchStr: [mode.name, mode.slug].filter(Boolean).join(" "),
}))
}, [modes])
const descriptionSearchItems = React.useMemo(() => {
return modes.map((mode) => ({
original: mode,
searchStr: mode.description || "",
}))
}, [modes])
// Create memoized Fzf instances for name and description searches.
const nameFzfInstance = React.useMemo(
() => new Fzf(nameSearchItems, { selector: (item) => item.searchStr }),
[nameSearchItems],
)
const descriptionFzfInstance = React.useMemo(
() => new Fzf(descriptionSearchItems, { selector: (item) => item.searchStr }),
[descriptionSearchItems],
)
// Filter modes based on search value using fuzzy search with priority.
const filteredModes = React.useMemo(() => {
if (!searchValue) return modes
// First search in names/slugs.
const nameMatches = nameFzfInstance.find(searchValue)
const nameMatchedModes = new Set(nameMatches.map((result) => result.item.original.slug))
// Then search in descriptions.
const descriptionMatches = descriptionFzfInstance.find(searchValue)
// Combine results: name matches first, then description matches.
const combinedResults = [
...nameMatches.map((result) => result.item.original),
...descriptionMatches
.filter((result) => !nameMatchedModes.has(result.item.original.slug))
.map((result) => result.item.original),
]
return combinedResults
}, [modes, searchValue, nameFzfInstance, descriptionFzfInstance])
const onClearSearch = React.useCallback(() => {
setSearchValue("")
searchInputRef.current?.focus()
}, [])
const handleSelect = React.useCallback(
(modeSlug: string) => {
onChange(modeSlug as Mode)
setOpen(false)
// Clear search after selection.
setSearchValue("")
},
[onChange],
)
const onOpenChange = React.useCallback(
(isOpen: boolean) => {
if (isOpen) trackModeSelectorOpened()
setOpen(isOpen)
// Clear search when closing.
if (!isOpen) {
setSearchValue("")
}
},
[trackModeSelectorOpened],
)
// Auto-focus search input and scroll to selected item when popover opens.
React.useEffect(() => {
if (open) {
// Focus search input
if (searchInputRef.current) {
searchInputRef.current.focus()
}
requestAnimationFrame(() => {
if (selectedItemRef.current && scrollContainerRef.current) {
const container = scrollContainerRef.current
const item = selectedItemRef.current
// Calculate positions
const containerHeight = container.clientHeight
const itemTop = item.offsetTop
const itemHeight = item.offsetHeight
// Center the item in the container
const scrollPosition = itemTop - containerHeight / 2 + itemHeight / 2
// Ensure we don't scroll past boundaries
const maxScroll = container.scrollHeight - containerHeight
const finalScrollPosition = Math.min(Math.max(0, scrollPosition), maxScroll)
container.scrollTo({
top: finalScrollPosition,
behavior: "instant",
})
}
})
}
}, [open])
// Determine if search should be shown.
const showSearch = !disableSearch && modes.length > SEARCH_THRESHOLD
// Combine instruction text for tooltip.
const instructionText = `${t("chat:modeSelector.description")} ${modeShortcutText}`
return (
<Popover open={open} onOpenChange={onOpenChange} data-testid="mode-selector-root">
<StandardTooltip content={title}>
<PopoverTrigger
disabled={disabled}
data-testid="mode-selector-trigger"
className={cn(
"inline-flex items-center relative whitespace-nowrap px-1.5 py-1 text-xs",
"bg-transparent border border-[rgba(255,255,255,0.08)] rounded-md text-vscode-foreground",
"transition-all duration-150 focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder focus-visible:ring-inset",
disabled
? "opacity-50 cursor-not-allowed"
: "opacity-90 hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)] cursor-pointer",
triggerClassName,
!disabled && !hasOpenedModeSelector
? "bg-primary opacity-90 hover:bg-primary-hover text-vscode-button-foreground"
: null,
)}>
<span className="truncate">{selectedMode?.name || ""}</span>
</PopoverTrigger>
</StandardTooltip>
<PopoverContent
align="start"
sideOffset={4}
container={portalContainer}
className="p-0 overflow-hidden min-w-80 max-w-9/10">
<div className="flex flex-col w-full">
{/* Show search bar only when there are more than SEARCH_THRESHOLD items, otherwise show info blurb */}
{showSearch ? (
<div className="relative p-2 border-b border-vscode-dropdown-border">
<input
aria-label="Search modes"
ref={searchInputRef}
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
placeholder={t("chat:modeSelector.searchPlaceholder")}
className="w-full h-8 px-2 py-1 text-xs bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded focus:outline-0"
data-testid="mode-search-input"
/>
{searchValue.length > 0 && (
<div className="absolute right-4 top-0 bottom-0 flex items-center justify-center">
<X
className="text-vscode-input-foreground opacity-50 hover:opacity-100 size-4 p-0.5 cursor-pointer"
onClick={onClearSearch}
/>
</div>
)}
</div>
) : (
<div className="p-3 border-b border-vscode-dropdown-border">
<p className="m-0 text-xs text-vscode-descriptionForeground">{instructionText}</p>
</div>
)}
{/* Mode List */}
<div ref={scrollContainerRef} className="max-h-[300px] overflow-y-auto">
{filteredModes.length === 0 && searchValue ? (
<div className="py-2 px-3 text-sm text-vscode-foreground/70">
{t("chat:modeSelector.noResults")}
</div>
) : (
<div className="py-1">
{filteredModes.map((mode) => {
const isSelected = mode.slug === value
return (
<div
key={mode.slug}
ref={isSelected ? selectedItemRef : null}
onClick={() => handleSelect(mode.slug)}
className={cn(
"px-3 py-1.5 text-sm cursor-pointer flex items-center",
"hover:bg-vscode-list-hoverBackground",
isSelected
? "bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground"
: "",
)}
data-testid="mode-selector-item">
<div className="flex-1 min-w-0">
<div className="font-bold truncate">{mode.name}</div>
{mode.description && (
<div className="text-xs text-vscode-descriptionForeground truncate">
{mode.description}
</div>
)}
</div>
{isSelected && <Check className="ml-auto size-4 p-0.5" />}
</div>
)
})}
</div>
)}
</div>
{/* Bottom bar with buttons on left and title on right */}
<div className="flex flex-row items-center justify-between px-2 py-2 border-t border-vscode-dropdown-border">
<div className="flex flex-row gap-1">
<IconButton
iconClass="codicon-extensions"
title={t("chat:modeSelector.marketplace")}
onClick={() => {
window.postMessage(
{
type: "action",
action: "marketplaceButtonClicked",
values: { marketplaceTab: "mode" },
},
"*",
)
setOpen(false)
}}
/>
<IconButton
iconClass="codicon-settings-gear"
title={t("chat:modeSelector.settings")}
onClick={() => {
vscode.postMessage({
type: "switchTab",
tab: "settings",
values: { section: "modes" },
})
setOpen(false)
}}
/>
</div>
{/* Info icon and title on the right - only show info icon when search bar is visible */}
<div className="flex items-center gap-1 pr-1">
{showSearch && (
<StandardTooltip content={instructionText}>
<span className="codicon codicon-info text-xs text-vscode-descriptionForeground opacity-70 hover:opacity-100 cursor-help" />
</StandardTooltip>
)}
<h4 className="m-0 font-medium text-sm text-vscode-descriptionForeground">
{t("chat:modeSelector.title")}
</h4>
</div>
</div>
</div>
</PopoverContent>
</Popover>
)
}