Skip to content

Commit 38fa055

Browse files
committed
feat: enhance mode selector with search and improved UI
- Add search functionality to mode selector popup - Move marketplace and settings buttons to bottom of popup - Replace instruction text with info icon tooltip - Update tests to cover new functionality - Add missing translation keys for search placeholder and no results message
1 parent 714fafd commit 38fa055

File tree

3 files changed

+269
-70
lines changed

3 files changed

+269
-70
lines changed

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

Lines changed: 144 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,19 @@
11
import React from "react"
2-
import { ChevronUp, Check } from "lucide-react"
2+
import { ChevronUp, Check, Info, X } from "lucide-react"
33
import { cn } from "@/lib/utils"
44
import { useRooPortal } from "@/components/ui/hooks/useRooPortal"
5-
import { Popover, PopoverContent, PopoverTrigger, StandardTooltip } from "@/components/ui"
5+
import {
6+
Popover,
7+
PopoverContent,
8+
PopoverTrigger,
9+
StandardTooltip,
10+
Command,
11+
CommandEmpty,
12+
CommandGroup,
13+
CommandInput,
14+
CommandItem,
15+
CommandList,
16+
} from "@/components/ui"
617
import { IconButton } from "./IconButton"
718
import { vscode } from "@/utils/vscode"
819
import { useExtensionState } from "@/context/ExtensionStateContext"
@@ -34,6 +45,8 @@ export const ModeSelector = ({
3445
customModePrompts,
3546
}: ModeSelectorProps) => {
3647
const [open, setOpen] = React.useState(false)
48+
const [searchValue, setSearchValue] = React.useState("")
49+
const searchInputRef = React.useRef<HTMLInputElement>(null)
3750
const portalContainer = useRooPortal("roo-portal")
3851
const { hasOpenedModeSelector, setHasOpenedModeSelector } = useExtensionState()
3952
const { t } = useAppTranslation()
@@ -61,6 +74,30 @@ export const ModeSelector = ({
6174
// Find the selected mode
6275
const selectedMode = React.useMemo(() => modes.find((mode) => mode.slug === value), [modes, value])
6376

77+
// Filter modes based on search
78+
const filteredModes = React.useMemo(() => {
79+
if (!searchValue) return modes
80+
const searchLower = searchValue.toLowerCase()
81+
return modes.filter(
82+
(mode) =>
83+
mode.name.toLowerCase().includes(searchLower) || mode.description?.toLowerCase().includes(searchLower),
84+
)
85+
}, [modes, searchValue])
86+
87+
const onClearSearch = React.useCallback(() => {
88+
setSearchValue("")
89+
searchInputRef.current?.focus()
90+
}, [])
91+
92+
const handleModeSelect = React.useCallback(
93+
(modeSlug: string) => {
94+
onChange(modeSlug as Mode)
95+
setOpen(false)
96+
setSearchValue("")
97+
},
98+
[onChange],
99+
)
100+
64101
const trigger = (
65102
<PopoverTrigger
66103
disabled={disabled}
@@ -86,7 +123,12 @@ export const ModeSelector = ({
86123
<Popover
87124
open={open}
88125
onOpenChange={(isOpen) => {
89-
if (isOpen) trackModeSelectorOpened()
126+
if (isOpen) {
127+
trackModeSelectorOpened()
128+
} else {
129+
// Clear search when closing
130+
setSearchValue("")
131+
}
90132
setOpen(isOpen)
91133
}}
92134
data-testid="mode-selector-root">
@@ -97,81 +139,115 @@ export const ModeSelector = ({
97139
sideOffset={4}
98140
container={portalContainer}
99141
className="p-0 overflow-hidden min-w-80 max-w-9/10">
100-
<div className="flex flex-col w-full">
142+
<Command className="flex flex-col w-full">
143+
{/* Header with title and description */}
101144
<div className="p-3 border-b border-vscode-dropdown-border cursor-default">
102145
<div className="flex flex-row items-center gap-1 p-0 mt-0 mb-1 w-full">
103146
<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-
}}
147+
<StandardTooltip
148+
content={
149+
<div className="max-w-xs">
150+
{t("chat:modeSelector.description")}
151+
<br />
152+
<br />
153+
{modeShortcutText}
154+
</div>
155+
}>
156+
<Info className="size-4 opacity-60 hover:opacity-100 cursor-help mb-2" />
157+
</StandardTooltip>
158+
</div>
159+
</div>
160+
161+
{/* Search Input */}
162+
<div className="relative">
163+
<CommandInput
164+
ref={searchInputRef}
165+
value={searchValue}
166+
onValueChange={setSearchValue}
167+
placeholder={t("chat:modeSelector.searchPlaceholder", { defaultValue: "Search modes..." })}
168+
className="h-9 mr-4"
169+
data-testid="mode-search-input"
170+
/>
171+
{searchValue.length > 0 && (
172+
<div className="absolute right-2 top-0 bottom-0 flex items-center justify-center">
173+
<X
174+
className="text-vscode-input-foreground opacity-50 hover:opacity-100 size-4 p-0.5 cursor-pointer"
175+
onClick={onClearSearch}
131176
/>
132177
</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>
178+
)}
139179
</div>
140180

141181
{/* 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}
153-
onClick={() => {
154-
onChange(mode.slug as Mode)
155-
setOpen(false)
156-
}}
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-
)}
182+
<CommandList className="max-h-[300px]">
183+
<CommandEmpty>
184+
{searchValue && (
185+
<div className="py-2 px-1 text-sm">
186+
{t("chat:modeSelector.noMatchFound", { defaultValue: "No modes found" })}
165187
</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-
))}
188+
)}
189+
</CommandEmpty>
190+
<CommandGroup>
191+
{filteredModes.map((mode) => (
192+
<CommandItem
193+
key={mode.slug}
194+
value={mode.slug}
195+
onSelect={handleModeSelect}
196+
data-testid="mode-selector-item"
197+
className={cn(
198+
"flex flex-row gap-4 items-center",
199+
mode.slug === value
200+
? "bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground"
201+
: "",
202+
)}>
203+
<div className="flex-grow">
204+
<p className="m-0 mb-0 font-bold">{mode.name}</p>
205+
{mode.description && (
206+
<p className="m-0 py-0 pl-4 h-4 flex-1 text-xs overflow-hidden">
207+
{mode.description}
208+
</p>
209+
)}
210+
</div>
211+
{mode.slug === value ? (
212+
<Check className="m-0 size-4 p-0.5" />
213+
) : (
214+
<div className="size-4" />
215+
)}
216+
</CommandItem>
217+
))}
218+
</CommandGroup>
219+
</CommandList>
220+
221+
{/* Bottom section with marketplace and settings buttons */}
222+
<div className="p-2 border-t border-vscode-dropdown-border flex flex-row gap-1 justify-end">
223+
<IconButton
224+
iconClass="codicon-extensions"
225+
title={t("chat:modeSelector.marketplace")}
226+
onClick={() => {
227+
window.postMessage(
228+
{
229+
type: "action",
230+
action: "marketplaceButtonClicked",
231+
values: { marketplaceTab: "mode" },
232+
},
233+
"*",
234+
)
235+
setOpen(false)
236+
}}
237+
/>
238+
<IconButton
239+
iconClass="codicon-settings-gear"
240+
title={t("chat:modeSelector.settings")}
241+
onClick={() => {
242+
vscode.postMessage({
243+
type: "switchTab",
244+
tab: "modes",
245+
})
246+
setOpen(false)
247+
}}
248+
/>
173249
</div>
174-
</div>
250+
</Command>
175251
</PopoverContent>
176252
</Popover>
177253
)

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

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from "react"
2-
import { render, screen } from "@/utils/test-utils"
2+
import { render, screen, fireEvent, waitFor } from "@/utils/test-utils"
33
import { describe, test, expect, vi } from "vitest"
44
import ModeSelector from "../ModeSelector"
55
import { Mode } from "@roo/modes"
@@ -55,4 +55,125 @@ describe("ModeSelector", () => {
5555
// The component should be rendered
5656
expect(screen.getByTestId("mode-selector-trigger")).toBeInTheDocument()
5757
})
58+
59+
test("opens popover and shows search input when clicked", async () => {
60+
render(<ModeSelector value={"code" as Mode} onChange={vi.fn()} modeShortcutText="Ctrl+M" />)
61+
62+
const trigger = screen.getByTestId("mode-selector-trigger")
63+
fireEvent.click(trigger)
64+
65+
await waitFor(() => {
66+
expect(screen.getByTestId("mode-search-input")).toBeInTheDocument()
67+
expect(screen.getByPlaceholderText("chat:modeSelector.searchPlaceholder")).toBeInTheDocument()
68+
})
69+
})
70+
71+
test("filters modes based on search input", async () => {
72+
const onChange = vi.fn()
73+
render(<ModeSelector value={"code" as Mode} onChange={onChange} modeShortcutText="Ctrl+M" />)
74+
75+
// Open the popover
76+
const trigger = screen.getByTestId("mode-selector-trigger")
77+
fireEvent.click(trigger)
78+
79+
// Wait for the popover to open
80+
await waitFor(() => {
81+
expect(screen.getByTestId("mode-search-input")).toBeInTheDocument()
82+
})
83+
84+
// Type in the search input
85+
const searchInput = screen.getByTestId("mode-search-input")
86+
fireEvent.change(searchInput, { target: { value: "code" } })
87+
88+
// Check that modes are filtered (this assumes "code" mode exists)
89+
await waitFor(() => {
90+
const modeItems = screen.getAllByTestId("mode-selector-item")
91+
expect(modeItems.length).toBeGreaterThan(0)
92+
})
93+
})
94+
95+
test("clears search when X button is clicked", async () => {
96+
render(<ModeSelector value={"code" as Mode} onChange={vi.fn()} modeShortcutText="Ctrl+M" />)
97+
98+
// Open the popover
99+
const trigger = screen.getByTestId("mode-selector-trigger")
100+
fireEvent.click(trigger)
101+
102+
// Wait for the popover to open
103+
await waitFor(() => {
104+
expect(screen.getByTestId("mode-search-input")).toBeInTheDocument()
105+
})
106+
107+
// Type in the search input
108+
const searchInput = screen.getByTestId("mode-search-input")
109+
fireEvent.change(searchInput, { target: { value: "test" } })
110+
111+
// Wait for the X button to appear
112+
await waitFor(() => {
113+
// The X button is an svg element with specific classes
114+
const clearButton = document.querySelector(".lucide-x")
115+
expect(clearButton).toBeInTheDocument()
116+
})
117+
118+
// Click the clear button
119+
const clearButton = document.querySelector(".lucide-x") as HTMLElement
120+
fireEvent.click(clearButton)
121+
122+
// Check that search input is cleared
123+
await waitFor(() => {
124+
expect(searchInput).toHaveValue("")
125+
})
126+
})
127+
128+
test("shows marketplace and settings buttons at the bottom", async () => {
129+
render(<ModeSelector value={"code" as Mode} onChange={vi.fn()} modeShortcutText="Ctrl+M" />)
130+
131+
// Open the popover
132+
const trigger = screen.getByTestId("mode-selector-trigger")
133+
fireEvent.click(trigger)
134+
135+
await waitFor(() => {
136+
// Check for marketplace button by aria-label
137+
expect(screen.getByLabelText("chat:modeSelector.marketplace")).toBeInTheDocument()
138+
// Check for settings button by aria-label
139+
expect(screen.getByLabelText("chat:modeSelector.settings")).toBeInTheDocument()
140+
})
141+
})
142+
143+
test("shows info icon with tooltip for description", async () => {
144+
render(<ModeSelector value={"code" as Mode} onChange={vi.fn()} modeShortcutText="Ctrl+M" />)
145+
146+
// Open the popover
147+
const trigger = screen.getByTestId("mode-selector-trigger")
148+
fireEvent.click(trigger)
149+
150+
await waitFor(() => {
151+
// Look for the info icon by its class
152+
const infoIcon = document.querySelector(".lucide-info")
153+
expect(infoIcon).toBeInTheDocument()
154+
})
155+
})
156+
157+
test("selects a mode when clicked", async () => {
158+
const onChange = vi.fn()
159+
render(<ModeSelector value={"code" as Mode} onChange={onChange} modeShortcutText="Ctrl+M" />)
160+
161+
// Open the popover
162+
const trigger = screen.getByTestId("mode-selector-trigger")
163+
fireEvent.click(trigger)
164+
165+
await waitFor(() => {
166+
const modeItems = screen.getAllByTestId("mode-selector-item")
167+
expect(modeItems.length).toBeGreaterThan(0)
168+
})
169+
170+
// Click on a mode item
171+
const modeItems = screen.getAllByTestId("mode-selector-item")
172+
fireEvent.click(modeItems[0])
173+
174+
// Check that onChange was called
175+
await waitFor(() => {
176+
expect(onChange).toHaveBeenCalled()
177+
})
178+
})
58179
})

0 commit comments

Comments
 (0)