Skip to content

Commit 8625b45

Browse files
feat: Add search functionality to mode selector popup and reorganize layout (#6140)
Co-authored-by: Roo Code Translate Mode Co-authored-by: Daniel Riccio <[email protected]>
1 parent 5d4e381 commit 8625b45

File tree

20 files changed

+399
-95
lines changed

20 files changed

+399
-95
lines changed

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

Lines changed: 200 additions & 76 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,10 @@ 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"
15+
16+
// Minimum number of modes required to show search functionality
17+
const SEARCH_THRESHOLD = 6
1418

1519
interface ModeSelectorProps {
1620
value: Mode
@@ -21,6 +25,7 @@ interface ModeSelectorProps {
2125
modeShortcutText: string
2226
customModes?: ModeConfig[]
2327
customModePrompts?: CustomModePrompts
28+
disableSearch?: boolean
2429
}
2530

2631
export const ModeSelector = ({
@@ -32,13 +37,16 @@ export const ModeSelector = ({
3237
modeShortcutText,
3338
customModes,
3439
customModePrompts,
40+
disableSearch = false,
3541
}: ModeSelectorProps) => {
3642
const [open, setOpen] = React.useState(false)
43+
const [searchValue, setSearchValue] = React.useState("")
44+
const searchInputRef = React.useRef<HTMLInputElement>(null)
3745
const portalContainer = useRooPortal("roo-portal")
3846
const { hasOpenedModeSelector, setHasOpenedModeSelector } = useExtensionState()
3947
const { t } = useAppTranslation()
4048

41-
const trackModeSelectorOpened = () => {
49+
const trackModeSelectorOpened = React.useCallback(() => {
4250
// Track telemetry every time the mode selector is opened
4351
telemetryClient.capture(TelemetryEventName.MODE_SELECTOR_OPENED)
4452

@@ -47,7 +55,7 @@ export const ModeSelector = ({
4755
setHasOpenedModeSelector(true)
4856
vscode.postMessage({ type: "hasOpenedModeSelector", bool: true })
4957
}
50-
}
58+
}, [hasOpenedModeSelector, setHasOpenedModeSelector])
5159

5260
// Get all modes including custom modes and merge custom prompt descriptions
5361
const modes = React.useMemo(() => {
@@ -61,6 +69,96 @@ export const ModeSelector = ({
6169
// Find the selected mode
6270
const selectedMode = React.useMemo(() => modes.find((mode) => mode.slug === value), [modes, value])
6371

72+
// Memoize searchable items for fuzzy search with separate name and description search
73+
const nameSearchItems = React.useMemo(() => {
74+
return modes.map((mode) => ({
75+
original: mode,
76+
searchStr: [mode.name, mode.slug].filter(Boolean).join(" "),
77+
}))
78+
}, [modes])
79+
80+
const descriptionSearchItems = React.useMemo(() => {
81+
return modes.map((mode) => ({
82+
original: mode,
83+
searchStr: mode.description || "",
84+
}))
85+
}, [modes])
86+
87+
// Create memoized Fzf instances for name and description searches
88+
const nameFzfInstance = React.useMemo(() => {
89+
return new Fzf(nameSearchItems, {
90+
selector: (item) => item.searchStr,
91+
})
92+
}, [nameSearchItems])
93+
94+
const descriptionFzfInstance = React.useMemo(() => {
95+
return new Fzf(descriptionSearchItems, {
96+
selector: (item) => item.searchStr,
97+
})
98+
}, [descriptionSearchItems])
99+
100+
// Filter modes based on search value using fuzzy search with priority
101+
const filteredModes = React.useMemo(() => {
102+
if (!searchValue) return modes
103+
104+
// First search in names/slugs
105+
const nameMatches = nameFzfInstance.find(searchValue)
106+
const nameMatchedModes = new Set(nameMatches.map((result) => result.item.original.slug))
107+
108+
// Then search in descriptions
109+
const descriptionMatches = descriptionFzfInstance.find(searchValue)
110+
111+
// Combine results: name matches first, then description matches
112+
const combinedResults = [
113+
...nameMatches.map((result) => result.item.original),
114+
...descriptionMatches
115+
.filter((result) => !nameMatchedModes.has(result.item.original.slug))
116+
.map((result) => result.item.original),
117+
]
118+
119+
return combinedResults
120+
}, [modes, searchValue, nameFzfInstance, descriptionFzfInstance])
121+
122+
const onClearSearch = React.useCallback(() => {
123+
setSearchValue("")
124+
searchInputRef.current?.focus()
125+
}, [])
126+
127+
const handleSelect = React.useCallback(
128+
(modeSlug: string) => {
129+
onChange(modeSlug as Mode)
130+
setOpen(false)
131+
// Clear search after selection
132+
setSearchValue("")
133+
},
134+
[onChange],
135+
)
136+
137+
const onOpenChange = React.useCallback(
138+
(isOpen: boolean) => {
139+
if (isOpen) trackModeSelectorOpened()
140+
setOpen(isOpen)
141+
// Clear search when closing
142+
if (!isOpen) {
143+
setSearchValue("")
144+
}
145+
},
146+
[trackModeSelectorOpened],
147+
)
148+
149+
// Auto-focus search input when popover opens
150+
React.useEffect(() => {
151+
if (open && searchInputRef.current) {
152+
searchInputRef.current.focus()
153+
}
154+
}, [open])
155+
156+
// Determine if search should be shown
157+
const showSearch = !disableSearch && modes.length > SEARCH_THRESHOLD
158+
159+
// Combine instruction text for tooltip
160+
const instructionText = `${t("chat:modeSelector.description")} ${modeShortcutText}`
161+
64162
const trigger = (
65163
<PopoverTrigger
66164
disabled={disabled}
@@ -83,13 +181,7 @@ export const ModeSelector = ({
83181
)
84182

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

95187
<PopoverContent
@@ -98,78 +190,110 @@ export const ModeSelector = ({
98190
container={portalContainer}
99191
className="p-0 overflow-hidden min-w-80 max-w-9/10">
100192
<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-
}}
131-
/>
132-
</div>
193+
{/* Show search bar only when there are more than SEARCH_THRESHOLD items, otherwise show info blurb */}
194+
{showSearch ? (
195+
<div className="relative p-2 border-b border-vscode-dropdown-border">
196+
<input
197+
aria-label="Search modes"
198+
ref={searchInputRef}
199+
value={searchValue}
200+
onChange={(e) => setSearchValue(e.target.value)}
201+
placeholder={t("chat:modeSelector.searchPlaceholder")}
202+
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"
203+
data-testid="mode-search-input"
204+
/>
205+
{searchValue.length > 0 && (
206+
<div className="absolute right-4 top-0 bottom-0 flex items-center justify-center">
207+
<X
208+
className="text-vscode-input-foreground opacity-50 hover:opacity-100 size-4 p-0.5 cursor-pointer"
209+
onClick={onClearSearch}
210+
/>
211+
</div>
212+
)}
133213
</div>
134-
<p className="my-0 pr-4 text-sm w-full">
135-
{t("chat:modeSelector.description")}
136-
<br />
137-
{modeShortcutText}
138-
</p>
139-
</div>
214+
) : (
215+
<div className="p-3 border-b border-vscode-dropdown-border">
216+
<p className="m-0 text-xs text-vscode-descriptionForeground">{instructionText}</p>
217+
</div>
218+
)}
140219

141220
{/* 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}
221+
<div className="max-h-[300px] overflow-y-auto">
222+
{filteredModes.length === 0 && searchValue ? (
223+
<div className="py-2 px-3 text-sm text-vscode-foreground/70">
224+
{t("chat:modeSelector.noResults")}
225+
</div>
226+
) : (
227+
<div className="py-1">
228+
{filteredModes.map((mode) => (
229+
<div
230+
key={mode.slug}
231+
onClick={() => handleSelect(mode.slug)}
232+
className={cn(
233+
"px-3 py-1.5 text-sm cursor-pointer flex items-center",
234+
"hover:bg-vscode-list-hoverBackground",
235+
mode.slug === value
236+
? "bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground"
237+
: "",
238+
)}
239+
data-testid="mode-selector-item">
240+
<div className="flex-1 min-w-0">
241+
<div className="font-bold truncate">{mode.name}</div>
242+
{mode.description && (
243+
<div className="text-xs text-vscode-descriptionForeground truncate">
244+
{mode.description}
245+
</div>
246+
)}
247+
</div>
248+
{mode.slug === value && <Check className="ml-auto size-4 p-0.5" />}
249+
</div>
250+
))}
251+
</div>
252+
)}
253+
</div>
254+
255+
{/* Bottom bar with buttons on left and title on right */}
256+
<div className="flex flex-row items-center justify-between px-2 py-2 border-t border-vscode-dropdown-border">
257+
<div className="flex flex-row gap-1">
258+
<IconButton
259+
iconClass="codicon-extensions"
260+
title={t("chat:modeSelector.marketplace")}
153261
onClick={() => {
154-
onChange(mode.slug as Mode)
262+
window.postMessage(
263+
{
264+
type: "action",
265+
action: "marketplaceButtonClicked",
266+
values: { marketplaceTab: "mode" },
267+
},
268+
"*",
269+
)
155270
setOpen(false)
156271
}}
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-
))}
272+
/>
273+
<IconButton
274+
iconClass="codicon-settings-gear"
275+
title={t("chat:modeSelector.settings")}
276+
onClick={() => {
277+
vscode.postMessage({
278+
type: "switchTab",
279+
tab: "modes",
280+
})
281+
setOpen(false)
282+
}}
283+
/>
284+
</div>
285+
286+
{/* Info icon and title on the right - only show info icon when search bar is visible */}
287+
<div className="flex items-center gap-1 pr-1">
288+
{showSearch && (
289+
<StandardTooltip content={instructionText}>
290+
<span className="codicon codicon-info text-xs text-vscode-descriptionForeground opacity-70 hover:opacity-100 cursor-help" />
291+
</StandardTooltip>
292+
)}
293+
<h4 className="m-0 font-medium text-sm text-vscode-descriptionForeground">
294+
{t("chat:modeSelector.title")}
295+
</h4>
296+
</div>
173297
</div>
174298
</div>
175299
</PopoverContent>

0 commit comments

Comments
 (0)