Skip to content
Merged
256 changes: 182 additions & 74 deletions webview-ui/src/components/chat/ModeSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from "react"
import { ChevronUp, Check } from "lucide-react"
import { ChevronUp, Check, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { useRooPortal } from "@/components/ui/hooks/useRooPortal"
import { Popover, PopoverContent, PopoverTrigger, StandardTooltip } from "@/components/ui"
Expand All @@ -11,6 +11,7 @@ import { Mode, getAllModes } from "@roo/modes"
import { ModeConfig, CustomModePrompts } from "@roo-code/types"
import { telemetryClient } from "@/utils/TelemetryClient"
import { TelemetryEventName } from "@roo-code/types"
import { Fzf } from "fzf"

interface ModeSelectorProps {
value: Mode
Expand All @@ -34,11 +35,13 @@ export const ModeSelector = ({
customModePrompts,
}: ModeSelectorProps) => {
const [open, setOpen] = React.useState(false)
const [searchValue, setSearchValue] = React.useState("")
const searchInputRef = React.useRef<HTMLInputElement>(null)
const portalContainer = useRooPortal("roo-portal")
const { hasOpenedModeSelector, setHasOpenedModeSelector } = useExtensionState()
const { t } = useAppTranslation()

const trackModeSelectorOpened = () => {
const trackModeSelectorOpened = React.useCallback(() => {
// Track telemetry every time the mode selector is opened
telemetryClient.capture(TelemetryEventName.MODE_SELECTOR_OPENED)

Expand All @@ -47,7 +50,7 @@ export const ModeSelector = ({
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(() => {
Expand All @@ -61,6 +64,93 @@ export const ModeSelector = ({
// Find the selected mode
const selectedMode = React.useMemo(() => modes.find((mode) => mode.slug === value), [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(() => {
return new Fzf(nameSearchItems, {
selector: (item) => item.searchStr,
})
}, [nameSearchItems])

const descriptionFzfInstance = React.useMemo(() => {
return 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 when popover opens
React.useEffect(() => {
if (open && searchInputRef.current) {
searchInputRef.current.focus()
}
}, [open])

// Combine instruction text for tooltip
const instructionText = `${t("chat:modeSelector.description")} ${modeShortcutText}`

const trigger = (
<PopoverTrigger
disabled={disabled}
Expand All @@ -83,13 +173,7 @@ export const ModeSelector = ({
)

return (
<Popover
open={open}
onOpenChange={(isOpen) => {
if (isOpen) trackModeSelectorOpened()
setOpen(isOpen)
}}
data-testid="mode-selector-root">
<Popover open={open} onOpenChange={onOpenChange} data-testid="mode-selector-root">
{title ? <StandardTooltip content={title}>{trigger}</StandardTooltip> : trigger}

<PopoverContent
Expand All @@ -98,78 +182,102 @@ export const ModeSelector = ({
container={portalContainer}
className="p-0 overflow-hidden min-w-80 max-w-9/10">
<div className="flex flex-col w-full">
<div className="p-3 border-b border-vscode-dropdown-border cursor-default">
<div className="flex flex-row items-center gap-1 p-0 mt-0 mb-1 w-full">
<h4 className="m-0 pb-2 flex-1">{t("chat:modeSelector.title")}</h4>
<div className="flex flex-row gap-1 ml-auto mb-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: "modes",
})
setOpen(false)
}}
{/* Search input only */}
<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>
<p className="my-0 pr-4 text-sm w-full">
{t("chat:modeSelector.description")}
<br />
{modeShortcutText}
</p>
)}
</div>

{/* Mode List */}
<div className="max-h-[400px] overflow-y-auto py-0">
{modes.map((mode) => (
<div
className={cn(
"p-2 text-sm cursor-pointer flex flex-row gap-4 items-center",
"hover:bg-vscode-list-hoverBackground",
mode.slug === value
? "bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground"
: "",
)}
key={mode.slug}
<div 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) => (
<div
key={mode.slug}
onClick={() => handleSelect(mode.slug)}
className={cn(
"px-3 py-1.5 text-sm cursor-pointer flex items-center",
"hover:bg-vscode-list-hoverBackground",
mode.slug === value
? "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>
{mode.slug === value && <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={() => {
onChange(mode.slug as Mode)
window.postMessage(
{
type: "action",
action: "marketplaceButtonClicked",
values: { marketplaceTab: "mode" },
},
"*",
)
setOpen(false)
}}
data-testid="mode-selector-item">
<div className="flex-grow">
<p className="m-0 mb-0 font-bold">{mode.name}</p>
{mode.description && (
<p className="m-0 py-0 pl-4 h-4 flex-1 text-xs overflow-hidden">
{mode.description}
</p>
)}
</div>
{mode.slug === value ? (
<Check className="m-0 size-4 p-0.5" />
) : (
<div className="size-4" />
)}
</div>
))}
/>
<IconButton
iconClass="codicon-settings-gear"
title={t("chat:modeSelector.settings")}
onClick={() => {
vscode.postMessage({
type: "switchTab",
tab: "modes",
})
setOpen(false)
}}
/>
</div>

{/* Info icon and title on the right with matching spacing */}
<div className="flex items-center gap-1 pr-1">
<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>
Expand Down
4 changes: 3 additions & 1 deletion webview-ui/src/i18n/locales/ca/chat.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion webview-ui/src/i18n/locales/de/chat.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion webview-ui/src/i18n/locales/en/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,9 @@
"title": "Modes",
"marketplace": "Mode Marketplace",
"settings": "Mode Settings",
"description": "Specialized personas that tailor Roo's behavior."
"description": "Specialized personas that tailor Roo's behavior.",
"searchPlaceholder": "Search modes...",
"noResults": "No results found"
},
"enhancePromptDescription": "The 'Enhance Prompt' button helps improve your prompt by providing additional context, clarification, or rephrasing. Try typing a prompt in here and clicking the button again to see how it works.",
"addImages": "Add images to message",
Expand Down
4 changes: 3 additions & 1 deletion webview-ui/src/i18n/locales/es/chat.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion webview-ui/src/i18n/locales/fr/chat.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion webview-ui/src/i18n/locales/hi/chat.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion webview-ui/src/i18n/locales/id/chat.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading