Skip to content

Commit 7a6e852

Browse files
feat: sync API config selector style with mode selector from PR #6140 (#6148)
Co-authored-by: Daniel Riccio <[email protected]>
1 parent 8625b45 commit 7a6e852

File tree

21 files changed

+705
-138
lines changed

21 files changed

+705
-138
lines changed
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import React, { useState, useMemo, useCallback } from "react"
2+
import { cn } from "@/lib/utils"
3+
import { useRooPortal } from "@/components/ui/hooks/useRooPortal"
4+
import { Popover, PopoverContent, PopoverTrigger, StandardTooltip } from "@/components/ui"
5+
import { IconButton } from "./IconButton"
6+
import { useAppTranslation } from "@/i18n/TranslationContext"
7+
import { vscode } from "@/utils/vscode"
8+
import { Fzf } from "fzf"
9+
import { Button } from "@/components/ui"
10+
11+
interface ApiConfigSelectorProps {
12+
value: string
13+
displayName: string
14+
disabled?: boolean
15+
title?: string
16+
onChange: (value: string) => void
17+
triggerClassName?: string
18+
listApiConfigMeta: Array<{ id: string; name: string }>
19+
pinnedApiConfigs?: Record<string, boolean>
20+
togglePinnedApiConfig: (id: string) => void
21+
}
22+
23+
export const ApiConfigSelector = ({
24+
value,
25+
displayName,
26+
disabled = false,
27+
title = "",
28+
onChange,
29+
triggerClassName = "",
30+
listApiConfigMeta,
31+
pinnedApiConfigs,
32+
togglePinnedApiConfig,
33+
}: ApiConfigSelectorProps) => {
34+
const { t } = useAppTranslation()
35+
const [open, setOpen] = useState(false)
36+
const [searchValue, setSearchValue] = useState("")
37+
const portalContainer = useRooPortal("roo-portal")
38+
39+
// Create searchable items for fuzzy search
40+
const searchableItems = useMemo(() => {
41+
return listApiConfigMeta.map((config) => ({
42+
original: config,
43+
searchStr: config.name,
44+
}))
45+
}, [listApiConfigMeta])
46+
47+
// Create Fzf instance
48+
const fzfInstance = useMemo(() => {
49+
return new Fzf(searchableItems, {
50+
selector: (item) => item.searchStr,
51+
})
52+
}, [searchableItems])
53+
54+
// Filter configs based on search
55+
const filteredConfigs = useMemo(() => {
56+
if (!searchValue) return listApiConfigMeta
57+
58+
const matchingItems = fzfInstance.find(searchValue).map((result) => result.item.original)
59+
return matchingItems
60+
}, [listApiConfigMeta, searchValue, fzfInstance])
61+
62+
// Separate pinned and unpinned configs
63+
const { pinnedConfigs, unpinnedConfigs } = useMemo(() => {
64+
const pinned = filteredConfigs.filter((config) => pinnedApiConfigs?.[config.id])
65+
const unpinned = filteredConfigs.filter((config) => !pinnedApiConfigs?.[config.id])
66+
return { pinnedConfigs: pinned, unpinnedConfigs: unpinned }
67+
}, [filteredConfigs, pinnedApiConfigs])
68+
69+
const handleSelect = useCallback(
70+
(configId: string) => {
71+
onChange(configId)
72+
setOpen(false)
73+
setSearchValue("")
74+
},
75+
[onChange],
76+
)
77+
78+
const handleEditClick = useCallback(() => {
79+
vscode.postMessage({
80+
type: "switchTab",
81+
tab: "settings",
82+
})
83+
setOpen(false)
84+
}, [])
85+
86+
const renderConfigItem = useCallback(
87+
(config: { id: string; name: string }, isPinned: boolean) => {
88+
const isCurrentConfig = config.id === value
89+
90+
return (
91+
<div
92+
key={config.id}
93+
onClick={() => handleSelect(config.id)}
94+
className={cn(
95+
"px-3 py-1.5 text-sm cursor-pointer flex items-center group",
96+
"hover:bg-vscode-list-hoverBackground",
97+
isCurrentConfig &&
98+
"bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground",
99+
)}>
100+
<span className="flex-1 truncate">{config.name}</span>
101+
<div className="flex items-center gap-1">
102+
{isCurrentConfig && (
103+
<div className="size-5 p-1 flex items-center justify-center">
104+
<span className="codicon codicon-check text-xs" />
105+
</div>
106+
)}
107+
<StandardTooltip content={isPinned ? t("chat:unpin") : t("chat:pin")}>
108+
<Button
109+
variant="ghost"
110+
size="icon"
111+
tabIndex={-1}
112+
onClick={(e) => {
113+
e.stopPropagation()
114+
togglePinnedApiConfig(config.id)
115+
vscode.postMessage({
116+
type: "toggleApiConfigPin",
117+
text: config.id,
118+
})
119+
}}
120+
className={cn("size-5 flex items-center justify-center", {
121+
"opacity-0 group-hover:opacity-100": !isPinned && !isCurrentConfig,
122+
"bg-accent opacity-100": isPinned,
123+
})}>
124+
<span className="codicon codicon-pin text-xs opacity-50" />
125+
</Button>
126+
</StandardTooltip>
127+
</div>
128+
</div>
129+
)
130+
},
131+
[value, handleSelect, t, togglePinnedApiConfig],
132+
)
133+
134+
const triggerContent = (
135+
<PopoverTrigger
136+
disabled={disabled}
137+
data-testid="dropdown-trigger"
138+
className={cn(
139+
"w-full min-w-0 max-w-full inline-flex items-center gap-1.5 relative whitespace-nowrap px-1.5 py-1 text-xs",
140+
"bg-transparent border border-[rgba(255,255,255,0.08)] rounded-md text-vscode-foreground",
141+
"transition-all duration-150 focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder focus-visible:ring-inset",
142+
disabled
143+
? "opacity-50 cursor-not-allowed"
144+
: "opacity-90 hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)] cursor-pointer",
145+
triggerClassName,
146+
)}>
147+
<span
148+
className={cn(
149+
"codicon codicon-chevron-up pointer-events-none opacity-80 flex-shrink-0 text-xs transition-transform duration-200",
150+
open && "rotate-180",
151+
)}
152+
/>
153+
<span className="truncate">{displayName}</span>
154+
</PopoverTrigger>
155+
)
156+
157+
return (
158+
<Popover open={open} onOpenChange={setOpen}>
159+
{title ? <StandardTooltip content={title}>{triggerContent}</StandardTooltip> : triggerContent}
160+
<PopoverContent
161+
align="start"
162+
sideOffset={4}
163+
container={portalContainer}
164+
className="p-0 overflow-hidden w-[300px]">
165+
<div className="flex flex-col w-full">
166+
{/* Search input or info blurb */}
167+
{listApiConfigMeta.length > 6 ? (
168+
<div className="relative p-2 border-b border-vscode-dropdown-border">
169+
<input
170+
aria-label={t("common:ui.search_placeholder")}
171+
value={searchValue}
172+
onChange={(e) => setSearchValue(e.target.value)}
173+
placeholder={t("common:ui.search_placeholder")}
174+
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"
175+
autoFocus
176+
/>
177+
{searchValue.length > 0 && (
178+
<div className="absolute right-4 top-0 bottom-0 flex items-center justify-center">
179+
<span
180+
className="codicon codicon-close text-vscode-input-foreground opacity-50 hover:opacity-100 text-xs cursor-pointer"
181+
onClick={() => setSearchValue("")}
182+
/>
183+
</div>
184+
)}
185+
</div>
186+
) : (
187+
<div className="p-3 border-b border-vscode-dropdown-border">
188+
<p className="text-xs text-vscode-descriptionForeground m-0">
189+
{t("prompts:apiConfiguration.select")}
190+
</p>
191+
</div>
192+
)}
193+
194+
{/* Config list */}
195+
<div className="max-h-[300px] overflow-y-auto">
196+
{filteredConfigs.length === 0 && searchValue ? (
197+
<div className="py-2 px-3 text-sm text-vscode-foreground/70">
198+
{t("common:ui.no_results")}
199+
</div>
200+
) : (
201+
<div className="py-1">
202+
{/* Pinned configs */}
203+
{pinnedConfigs.map((config) => renderConfigItem(config, true))}
204+
205+
{/* Separator between pinned and unpinned */}
206+
{pinnedConfigs.length > 0 && unpinnedConfigs.length > 0 && (
207+
<div className="mx-1 my-1 h-px bg-vscode-dropdown-foreground/10" />
208+
)}
209+
210+
{/* Unpinned configs */}
211+
{unpinnedConfigs.map((config) => renderConfigItem(config, false))}
212+
</div>
213+
)}
214+
</div>
215+
216+
{/* Bottom bar with buttons on left and title on right */}
217+
<div className="flex flex-row items-center justify-between p-2 border-t border-vscode-dropdown-border">
218+
<div className="flex flex-row gap-1">
219+
<IconButton
220+
iconClass="codicon-settings-gear"
221+
title={t("chat:edit")}
222+
onClick={handleEditClick}
223+
/>
224+
</div>
225+
226+
{/* Info icon and title on the right with matching spacing */}
227+
<div className="flex items-center gap-1 pr-1">
228+
{listApiConfigMeta.length > 6 && (
229+
<StandardTooltip content={t("prompts:apiConfiguration.select")}>
230+
<span className="codicon codicon-info text-xs text-vscode-descriptionForeground opacity-70 hover:opacity-100 cursor-help" />
231+
</StandardTooltip>
232+
)}
233+
<h4 className="m-0 font-medium text-sm text-vscode-descriptionForeground">
234+
{t("prompts:apiConfiguration.title")}
235+
</h4>
236+
</div>
237+
</div>
238+
</div>
239+
</PopoverContent>
240+
</Popover>
241+
)
242+
}

0 commit comments

Comments
 (0)