Skip to content

Commit d401963

Browse files
committed
feat: Add search functionality to mode selector popup and reorganize layout
- Add fuzzy search functionality with Fzf library for better mode discovery - Move marketplace/settings buttons to bottom of popup for cleaner layout - Convert instruction text to tooltip with info icon for space efficiency - Align search input styling with API configuration selector pattern - Improve spacing consistency throughout the component - Add 'Search modes...' placeholder text for better UX - Fix React Hook dependency arrays and useCallback optimization Resolves #6128
1 parent c47de36 commit d401963

File tree

1 file changed

+148
-74
lines changed

1 file changed

+148
-74
lines changed

webview-ui/src/components/chat/ModeSelector.tsx

Lines changed: 148 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from "react"
2-
import { ChevronUp, Check } from "lucide-react"
2+
import { ChevronUp, Check, X } from "lucide-react"
33
import { cn } from "@/lib/utils"
44
import { useRooPortal } from "@/components/ui/hooks/useRooPortal"
55
import { Popover, PopoverContent, PopoverTrigger, StandardTooltip } from "@/components/ui"
@@ -11,6 +11,7 @@ import { Mode, getAllModes } from "@roo/modes"
1111
import { ModeConfig, CustomModePrompts } from "@roo-code/types"
1212
import { telemetryClient } from "@/utils/TelemetryClient"
1313
import { TelemetryEventName } from "@roo-code/types"
14+
import { Fzf } from "fzf"
1415

1516
interface ModeSelectorProps {
1617
value: Mode
@@ -34,11 +35,13 @@ export const ModeSelector = ({
3435
customModePrompts,
3536
}: ModeSelectorProps) => {
3637
const [open, setOpen] = React.useState(false)
38+
const [searchValue, setSearchValue] = React.useState("")
39+
const searchInputRef = React.useRef<HTMLInputElement>(null)
3740
const portalContainer = useRooPortal("roo-portal")
3841
const { hasOpenedModeSelector, setHasOpenedModeSelector } = useExtensionState()
3942
const { t } = useAppTranslation()
4043

41-
const trackModeSelectorOpened = () => {
44+
const trackModeSelectorOpened = React.useCallback(() => {
4245
// Track telemetry every time the mode selector is opened
4346
telemetryClient.capture(TelemetryEventName.MODE_SELECTOR_OPENED)
4447

@@ -47,7 +50,7 @@ export const ModeSelector = ({
4750
setHasOpenedModeSelector(true)
4851
vscode.postMessage({ type: "hasOpenedModeSelector", bool: true })
4952
}
50-
}
53+
}, [hasOpenedModeSelector, setHasOpenedModeSelector])
5154

5255
// Get all modes including custom modes and merge custom prompt descriptions
5356
const modes = React.useMemo(() => {
@@ -61,6 +64,59 @@ export const ModeSelector = ({
6164
// Find the selected mode
6265
const selectedMode = React.useMemo(() => modes.find((mode) => mode.slug === value), [modes, value])
6366

67+
// Memoize searchable items for fuzzy search
68+
const searchableItems = React.useMemo(() => {
69+
return modes.map((mode) => ({
70+
original: mode,
71+
searchStr: [mode.name, mode.slug, mode.description].filter(Boolean).join(" "),
72+
}))
73+
}, [modes])
74+
75+
// Create a memoized Fzf instance
76+
const fzfInstance = React.useMemo(() => {
77+
return new Fzf(searchableItems, {
78+
selector: (item) => item.searchStr,
79+
})
80+
}, [searchableItems])
81+
82+
// Filter modes based on search value using fuzzy search
83+
const filteredModes = React.useMemo(() => {
84+
if (!searchValue) return modes
85+
86+
const matchingItems = fzfInstance.find(searchValue).map((result) => result.item.original)
87+
return matchingItems
88+
}, [modes, searchValue, fzfInstance])
89+
90+
const onClearSearch = React.useCallback(() => {
91+
setSearchValue("")
92+
searchInputRef.current?.focus()
93+
}, [])
94+
95+
const handleSelect = React.useCallback(
96+
(modeSlug: string) => {
97+
onChange(modeSlug as Mode)
98+
setOpen(false)
99+
// Clear search after selection
100+
requestAnimationFrame(() => setSearchValue(""))
101+
},
102+
[onChange],
103+
)
104+
105+
const onOpenChange = React.useCallback(
106+
(isOpen: boolean) => {
107+
if (isOpen) trackModeSelectorOpened()
108+
setOpen(isOpen)
109+
// Clear search when closing
110+
if (!isOpen) {
111+
requestAnimationFrame(() => setSearchValue(""))
112+
}
113+
},
114+
[trackModeSelectorOpened],
115+
)
116+
117+
// Combine instruction text for tooltip
118+
const instructionText = `${t("chat:modeSelector.description")} ${modeShortcutText}`
119+
64120
const trigger = (
65121
<PopoverTrigger
66122
disabled={disabled}
@@ -83,13 +139,7 @@ export const ModeSelector = ({
83139
)
84140

85141
return (
86-
<Popover
87-
open={open}
88-
onOpenChange={(isOpen) => {
89-
if (isOpen) trackModeSelectorOpened()
90-
setOpen(isOpen)
91-
}}
92-
data-testid="mode-selector-root">
142+
<Popover open={open} onOpenChange={onOpenChange} data-testid="mode-selector-root">
93143
{title ? <StandardTooltip content={title}>{trigger}</StandardTooltip> : trigger}
94144

95145
<PopoverContent
@@ -98,78 +148,102 @@ export const ModeSelector = ({
98148
container={portalContainer}
99149
className="p-0 overflow-hidden min-w-80 max-w-9/10">
100150
<div className="flex flex-col w-full">
101-
<div className="p-3 border-b border-vscode-dropdown-border cursor-default">
102-
<div className="flex flex-row items-center gap-1 p-0 mt-0 mb-1 w-full">
103-
<h4 className="m-0 pb-2 flex-1">{t("chat:modeSelector.title")}</h4>
104-
<div className="flex flex-row gap-1 ml-auto mb-1">
105-
<IconButton
106-
iconClass="codicon-extensions"
107-
title={t("chat:modeSelector.marketplace")}
108-
onClick={() => {
109-
window.postMessage(
110-
{
111-
type: "action",
112-
action: "marketplaceButtonClicked",
113-
values: { marketplaceTab: "mode" },
114-
},
115-
"*",
116-
)
117-
118-
setOpen(false)
119-
}}
120-
/>
121-
<IconButton
122-
iconClass="codicon-settings-gear"
123-
title={t("chat:modeSelector.settings")}
124-
onClick={() => {
125-
vscode.postMessage({
126-
type: "switchTab",
127-
tab: "modes",
128-
})
129-
setOpen(false)
130-
}}
151+
{/* Search input only */}
152+
<div className="relative p-2 border-b border-vscode-dropdown-border">
153+
<input
154+
aria-label="Search modes"
155+
ref={searchInputRef}
156+
value={searchValue}
157+
onChange={(e) => setSearchValue(e.target.value)}
158+
placeholder="Search modes..."
159+
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"
160+
data-testid="mode-search-input"
161+
/>
162+
{searchValue.length > 0 && (
163+
<div className="absolute right-4 top-0 bottom-0 flex items-center justify-center">
164+
<X
165+
className="text-vscode-input-foreground opacity-50 hover:opacity-100 size-4 p-0.5 cursor-pointer"
166+
onClick={onClearSearch}
131167
/>
132168
</div>
133-
</div>
134-
<p className="my-0 pr-4 text-sm w-full">
135-
{t("chat:modeSelector.description")}
136-
<br />
137-
{modeShortcutText}
138-
</p>
169+
)}
139170
</div>
140171

141172
{/* Mode List */}
142-
<div className="max-h-[400px] overflow-y-auto py-0">
143-
{modes.map((mode) => (
144-
<div
145-
className={cn(
146-
"p-2 text-sm cursor-pointer flex flex-row gap-4 items-center",
147-
"hover:bg-vscode-list-hoverBackground",
148-
mode.slug === value
149-
? "bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground"
150-
: "",
151-
)}
152-
key={mode.slug}
173+
<div className="max-h-[300px] overflow-y-auto">
174+
{filteredModes.length === 0 && searchValue ? (
175+
<div className="py-2 px-3 text-sm text-vscode-foreground/70">
176+
{t("settings:modelPicker.noMatchFound")}
177+
</div>
178+
) : (
179+
<div className="py-1">
180+
{filteredModes.map((mode) => (
181+
<div
182+
key={mode.slug}
183+
onClick={() => handleSelect(mode.slug)}
184+
className={cn(
185+
"px-3 py-1.5 text-sm cursor-pointer flex items-center",
186+
"hover:bg-vscode-list-hoverBackground",
187+
mode.slug === value
188+
? "bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground"
189+
: "",
190+
)}
191+
data-testid="mode-selector-item">
192+
<div className="flex-1 min-w-0">
193+
<div className="font-bold truncate">{mode.name}</div>
194+
{mode.description && (
195+
<div className="text-xs text-vscode-descriptionForeground truncate">
196+
{mode.description}
197+
</div>
198+
)}
199+
</div>
200+
{mode.slug === value && <Check className="ml-auto size-4 p-0.5" />}
201+
</div>
202+
))}
203+
</div>
204+
)}
205+
</div>
206+
207+
{/* Bottom bar with buttons on left and title on right */}
208+
<div className="flex flex-row items-center justify-between px-2 py-2 border-t border-vscode-dropdown-border">
209+
<div className="flex flex-row gap-1">
210+
<IconButton
211+
iconClass="codicon-extensions"
212+
title={t("chat:modeSelector.marketplace")}
153213
onClick={() => {
154-
onChange(mode.slug as Mode)
214+
window.postMessage(
215+
{
216+
type: "action",
217+
action: "marketplaceButtonClicked",
218+
values: { marketplaceTab: "mode" },
219+
},
220+
"*",
221+
)
155222
setOpen(false)
156223
}}
157-
data-testid="mode-selector-item">
158-
<div className="flex-grow">
159-
<p className="m-0 mb-0 font-bold">{mode.name}</p>
160-
{mode.description && (
161-
<p className="m-0 py-0 pl-4 h-4 flex-1 text-xs overflow-hidden">
162-
{mode.description}
163-
</p>
164-
)}
165-
</div>
166-
{mode.slug === value ? (
167-
<Check className="m-0 size-4 p-0.5" />
168-
) : (
169-
<div className="size-4" />
170-
)}
171-
</div>
172-
))}
224+
/>
225+
<IconButton
226+
iconClass="codicon-settings-gear"
227+
title={t("chat:modeSelector.settings")}
228+
onClick={() => {
229+
vscode.postMessage({
230+
type: "switchTab",
231+
tab: "modes",
232+
})
233+
setOpen(false)
234+
}}
235+
/>
236+
</div>
237+
238+
{/* Info icon and title on the right with matching spacing */}
239+
<div className="flex items-center gap-1">
240+
<StandardTooltip content={instructionText}>
241+
<span className="codicon codicon-info text-xs text-vscode-descriptionForeground opacity-70 hover:opacity-100 cursor-help" />
242+
</StandardTooltip>
243+
<h4 className="m-0 font-medium text-sm text-vscode-descriptionForeground">
244+
{t("chat:modeSelector.title")}
245+
</h4>
246+
</div>
173247
</div>
174248
</div>
175249
</PopoverContent>

0 commit comments

Comments
 (0)