Skip to content

Commit 8318d3e

Browse files
committed
feat: sync API config selector style with mode selector from PR #6140
- Add search functionality at the top with fuzzy search - Move settings button to bottom left - Add title and info icon on bottom right - Match exact layout and spacing from ModeSelector - Create new ApiConfigSelector component - Update ChatTextArea to use new component - Remove unused imports to fix linting
1 parent d48be23 commit 8318d3e

File tree

2 files changed

+234
-120
lines changed

2 files changed

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

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

Lines changed: 9 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,14 @@ import {
1919
SearchResult,
2020
} from "@src/utils/context-mentions"
2121
import { convertToMentionPath } from "@/utils/path-mentions"
22-
import { SelectDropdown, DropdownOptionType, Button, StandardTooltip } from "@/components/ui"
22+
import { StandardTooltip } from "@/components/ui"
2323

2424
import Thumbnails from "../common/Thumbnails"
2525
import ModeSelector from "./ModeSelector"
26+
import { ApiConfigSelector } from "./ApiConfigSelector"
2627
import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
2728
import ContextMenu from "./ContextMenu"
28-
import { VolumeX, Pin, Check, Image, WandSparkles, SendHorizontal } from "lucide-react"
29+
import { VolumeX, Image, WandSparkles, SendHorizontal } from "lucide-react"
2930
import { IndexingStatusBadge } from "./IndexingStatusBadge"
3031
import { cn } from "@/lib/utils"
3132
import { usePromptHistory } from "./hooks/usePromptHistory"
@@ -824,140 +825,28 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
824825
/>
825826
)
826827

827-
// Helper function to get API config dropdown options
828-
const getApiConfigOptions = useMemo(() => {
829-
const pinnedConfigs = (listApiConfigMeta || [])
830-
.filter((config) => pinnedApiConfigs && pinnedApiConfigs[config.id])
831-
.map((config) => ({
832-
value: config.id,
833-
label: config.name,
834-
name: config.name,
835-
type: DropdownOptionType.ITEM,
836-
pinned: true,
837-
}))
838-
.sort((a, b) => a.label.localeCompare(b.label))
839-
840-
const unpinnedConfigs = (listApiConfigMeta || [])
841-
.filter((config) => !pinnedApiConfigs || !pinnedApiConfigs[config.id])
842-
.map((config) => ({
843-
value: config.id,
844-
label: config.name,
845-
name: config.name,
846-
type: DropdownOptionType.ITEM,
847-
pinned: false,
848-
}))
849-
.sort((a, b) => a.label.localeCompare(b.label))
850-
851-
const hasPinnedAndUnpinned = pinnedConfigs.length > 0 && unpinnedConfigs.length > 0
852-
853-
return [
854-
...pinnedConfigs,
855-
...(hasPinnedAndUnpinned
856-
? [
857-
{
858-
value: "sep-pinned",
859-
label: t("chat:separator"),
860-
type: DropdownOptionType.SEPARATOR,
861-
},
862-
]
863-
: []),
864-
...unpinnedConfigs,
865-
{
866-
value: "sep-2",
867-
label: t("chat:separator"),
868-
type: DropdownOptionType.SEPARATOR,
869-
},
870-
{
871-
value: "settingsButtonClicked",
872-
label: t("chat:edit"),
873-
type: DropdownOptionType.ACTION,
874-
},
875-
]
876-
}, [listApiConfigMeta, pinnedApiConfigs, t])
877-
878828
// Helper function to handle API config change
879829
const handleApiConfigChange = useCallback((value: string) => {
880-
if (value === "settingsButtonClicked") {
881-
vscode.postMessage({
882-
type: "loadApiConfiguration",
883-
text: value,
884-
values: { section: "providers" },
885-
})
886-
} else {
887-
vscode.postMessage({ type: "loadApiConfigurationById", text: value })
888-
}
830+
vscode.postMessage({ type: "loadApiConfigurationById", text: value })
889831
}, [])
890832

891-
// Helper function to render API config item
892-
const renderApiConfigItem = useCallback(
893-
({ type, value, label, pinned }: any) => {
894-
if (type !== DropdownOptionType.ITEM) {
895-
return label
896-
}
897-
898-
const config = listApiConfigMeta?.find((c) => c.id === value)
899-
const isCurrentConfig = config?.name === currentApiConfigName
900-
901-
return (
902-
<div className="flex justify-between gap-2 w-full h-5">
903-
<div
904-
className={cn("truncate min-w-0 overflow-hidden", {
905-
"font-medium": isCurrentConfig,
906-
})}>
907-
{label}
908-
</div>
909-
<div className="flex justify-end w-10 flex-shrink-0">
910-
<div
911-
className={cn("size-5 p-1", {
912-
"block group-hover:hidden": !pinned,
913-
hidden: !isCurrentConfig,
914-
})}>
915-
<Check className="size-3" />
916-
</div>
917-
<StandardTooltip content={pinned ? t("chat:unpin") : t("chat:pin")}>
918-
<Button
919-
variant="ghost"
920-
size="icon"
921-
onClick={(e) => {
922-
e.stopPropagation()
923-
togglePinnedApiConfig(value)
924-
vscode.postMessage({
925-
type: "toggleApiConfigPin",
926-
text: value,
927-
})
928-
}}
929-
className={cn("size-5", {
930-
"hidden group-hover:flex": !pinned,
931-
"bg-accent": pinned,
932-
})}>
933-
<Pin className="size-3 p-0.5 opacity-50" />
934-
</Button>
935-
</StandardTooltip>
936-
</div>
937-
</div>
938-
)
939-
},
940-
[listApiConfigMeta, currentApiConfigName, t, togglePinnedApiConfig],
941-
)
942-
943833
// Helper function to render non-edit mode controls
944834
const renderNonEditModeControls = () => (
945835
<div className={cn("flex", "justify-between", "items-center", "mt-auto")}>
946836
<div className={cn("flex", "items-center", "gap-1", "min-w-0")}>
947837
<div className="shrink-0">{renderModeSelector()}</div>
948838

949839
<div className={cn("flex-1", "min-w-0", "overflow-hidden")}>
950-
<SelectDropdown
840+
<ApiConfigSelector
951841
value={currentConfigId}
842+
displayName={displayName}
952843
disabled={selectApiConfigDisabled}
953844
title={t("chat:selectApiConfig")}
954-
disableSearch={false}
955-
placeholder={displayName}
956-
options={getApiConfigOptions}
957845
onChange={handleApiConfigChange}
958846
triggerClassName="w-full text-ellipsis overflow-hidden"
959-
itemClassName="group"
960-
renderItem={renderApiConfigItem}
847+
listApiConfigMeta={listApiConfigMeta || []}
848+
pinnedApiConfigs={pinnedApiConfigs}
849+
togglePinnedApiConfig={togglePinnedApiConfig}
961850
/>
962851
</div>
963852
</div>

0 commit comments

Comments
 (0)