Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 14f10d5

Browse files
committed
Improve API config picker layout
1 parent ad25634 commit 14f10d5

3 files changed

Lines changed: 234 additions & 102 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"roo-cline": patch
3+
---
4+
5+
Improve the chat API configuration picker with a two-column provider and model layout.

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

Lines changed: 174 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useMemo, useCallback } from "react"
1+
import { useState, useMemo, useCallback, useEffect } from "react"
22
import { Fzf } from "fzf"
33

44
import { cn } from "@/lib/utils"
@@ -10,14 +10,68 @@ import { Button } from "@/components/ui"
1010

1111
import { IconButton } from "./IconButton"
1212

13+
type ApiConfigMeta = {
14+
id: string
15+
name: string
16+
apiProvider?: string
17+
modelId?: string
18+
}
19+
20+
type ProviderGroup = {
21+
key: string
22+
label: string
23+
configs: ApiConfigMeta[]
24+
}
25+
26+
const PROVIDER_LABELS: Record<string, string> = {
27+
anthropic: "Anthropic",
28+
bedrock: "Amazon Bedrock",
29+
deepseek: "DeepSeek",
30+
gemini: "Google Gemini",
31+
"gemini-cli": "Gemini CLI",
32+
vertex: "Vertex AI",
33+
openai: "OpenAI Compatible",
34+
"openai-native": "OpenAI",
35+
"openai-codex": "OpenAI Codex",
36+
openrouter: "OpenRouter",
37+
ollama: "Ollama",
38+
lmstudio: "LM Studio",
39+
mistral: "Mistral",
40+
moonshot: "Moonshot",
41+
minimax: "MiniMax",
42+
requesty: "Requesty",
43+
unbound: "Unbound",
44+
poe: "Poe",
45+
xai: "xAI",
46+
baseten: "Baseten",
47+
litellm: "LiteLLM",
48+
sambanova: "SambaNova",
49+
zai: "Z.ai",
50+
fireworks: "Fireworks AI",
51+
"qwen-code": "Qwen Code",
52+
roo: "Roo",
53+
"vscode-lm": "VS Code LM",
54+
"vercel-ai-gateway": "Vercel AI Gateway",
55+
}
56+
57+
const getProviderKey = (config?: ApiConfigMeta) => config?.apiProvider ?? "unknown"
58+
59+
const getProviderLabel = (apiProvider?: string) => {
60+
if (!apiProvider) {
61+
return "Other"
62+
}
63+
64+
return PROVIDER_LABELS[apiProvider] ?? apiProvider
65+
}
66+
1367
interface ApiConfigSelectorProps {
1468
value: string
1569
displayName: string
1670
disabled?: boolean
1771
title: string
1872
onChange: (value: string) => void
1973
triggerClassName?: string
20-
listApiConfigMeta: Array<{ id: string; name: string; modelId?: string }>
74+
listApiConfigMeta: ApiConfigMeta[]
2175
pinnedApiConfigs?: Record<string, boolean>
2276
togglePinnedApiConfig: (id: string) => void
2377
lockApiConfigAcrossModes: boolean
@@ -40,14 +94,17 @@ export const ApiConfigSelector = ({
4094
const { t } = useAppTranslation()
4195
const [open, setOpen] = useState(false)
4296
const [searchValue, setSearchValue] = useState("")
97+
const [activeProviderKey, setActiveProviderKey] = useState<string>("")
4398
const portalContainer = useRooPortal("roo-portal")
4499

45100
// Create searchable items for fuzzy search.
46101
const searchableItems = useMemo(
47102
() =>
48103
listApiConfigMeta.map((config) => ({
49104
original: config,
50-
searchStr: config.name,
105+
searchStr: [config.name, config.modelId, config.apiProvider, getProviderLabel(config.apiProvider)]
106+
.filter(Boolean)
107+
.join(" "),
51108
})),
52109
[listApiConfigMeta],
53110
)
@@ -68,54 +125,129 @@ export const ApiConfigSelector = ({
68125
return matchingItems
69126
}, [listApiConfigMeta, searchValue, fzfInstance])
70127

71-
// Separate pinned and unpinned configs.
72-
const { pinnedConfigs, unpinnedConfigs } = useMemo(() => {
73-
const pinned = filteredConfigs.filter((config) => pinnedApiConfigs?.[config.id])
74-
const unpinned = filteredConfigs.filter((config) => !pinnedApiConfigs?.[config.id])
75-
return { pinnedConfigs: pinned, unpinnedConfigs: unpinned }
128+
const providerGroups = useMemo<ProviderGroup[]>(() => {
129+
const groups = new Map<string, ProviderGroup>()
130+
131+
for (const config of filteredConfigs) {
132+
const key = getProviderKey(config)
133+
134+
if (!groups.has(key)) {
135+
groups.set(key, {
136+
key,
137+
label: getProviderLabel(config.apiProvider),
138+
configs: [],
139+
})
140+
}
141+
142+
groups.get(key)!.configs.push(config)
143+
}
144+
145+
return Array.from(groups.values()).map((group) => ({
146+
...group,
147+
configs: [...group.configs].sort((a, b) => {
148+
const pinnedDelta = Number(!!pinnedApiConfigs?.[b.id]) - Number(!!pinnedApiConfigs?.[a.id])
149+
150+
return pinnedDelta
151+
}),
152+
}))
76153
}, [filteredConfigs, pinnedApiConfigs])
77154

155+
const currentConfig = useMemo(
156+
() => listApiConfigMeta.find((config) => config.id === value),
157+
[listApiConfigMeta, value],
158+
)
159+
160+
const currentProviderKey = getProviderKey(currentConfig)
161+
const preferredProviderKey = providerGroups.some((group) => group.key === currentProviderKey)
162+
? currentProviderKey
163+
: (providerGroups[0]?.key ?? "")
164+
165+
useEffect(() => {
166+
if (!providerGroups.length) {
167+
setActiveProviderKey("")
168+
return
169+
}
170+
171+
if (!providerGroups.some((group) => group.key === activeProviderKey)) {
172+
setActiveProviderKey(preferredProviderKey)
173+
}
174+
}, [activeProviderKey, preferredProviderKey, providerGroups])
175+
176+
const activeProviderGroup = providerGroups.find((group) => group.key === activeProviderKey) ?? providerGroups[0]
177+
78178
const handleSelect = useCallback(
79179
(configId: string) => {
180+
const selectedConfig = listApiConfigMeta.find((config) => config.id === configId)
181+
182+
if (selectedConfig) {
183+
setActiveProviderKey(getProviderKey(selectedConfig))
184+
}
185+
80186
onChange(configId)
81187
setOpen(false)
82188
setSearchValue("")
83189
},
84-
[onChange],
190+
[listApiConfigMeta, onChange],
85191
)
86192

87193
const handleEditClick = useCallback(() => {
88194
vscode.postMessage({ type: "switchTab", tab: "settings" })
89195
setOpen(false)
90196
}, [])
91197

92-
const renderConfigItem = useCallback(
93-
(config: { id: string; name: string; modelId?: string }, isPinned: boolean) => {
198+
const renderProviderItem = useCallback(
199+
(group: ProviderGroup) => {
200+
const isActive = group.key === activeProviderGroup?.key
201+
const currentCount = group.configs.filter((config) => config.id === value).length
202+
const pinnedCount = group.configs.filter((config) => pinnedApiConfigs?.[config.id]).length
203+
204+
return (
205+
<button
206+
key={group.key}
207+
type="button"
208+
onClick={() => setActiveProviderKey(group.key)}
209+
className={cn(
210+
"w-full px-2 py-1.5 text-left text-sm cursor-pointer flex items-center gap-2",
211+
"hover:bg-vscode-list-hoverBackground",
212+
isActive &&
213+
"bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground",
214+
)}>
215+
<span className="truncate flex-1 min-w-0">{group.label}</span>
216+
<div className="flex items-center gap-1 flex-shrink-0">
217+
{pinnedCount > 0 && <span className="codicon codicon-pin text-[10px] opacity-60" />}
218+
{currentCount > 0 && <span className="codicon codicon-check text-xs" />}
219+
<span className="text-[10px] opacity-70">{group.configs.length}</span>
220+
</div>
221+
</button>
222+
)
223+
},
224+
[activeProviderGroup?.key, pinnedApiConfigs, value],
225+
)
226+
227+
const renderModelItem = useCallback(
228+
(config: ApiConfigMeta) => {
94229
const isCurrentConfig = config.id === value
230+
const isPinned = !!pinnedApiConfigs?.[config.id]
95231

96232
return (
97233
<div
98234
key={config.id}
99235
onClick={() => handleSelect(config.id)}
100236
className={cn(
101-
"px-3 py-1.5 text-sm cursor-pointer flex items-center group",
237+
"px-3 py-1.5 text-sm cursor-pointer flex items-center group gap-2",
102238
"hover:bg-vscode-list-hoverBackground",
103239
isCurrentConfig &&
104240
"bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground",
105241
)}>
106-
<div className="flex-1 min-w-0 flex items-center gap-1 overflow-hidden">
107-
<span className="flex-shrink-0">{config.name}</span>
242+
<div className="flex-1 min-w-0 flex flex-col overflow-hidden leading-tight">
243+
<span className="truncate">{config.modelId || config.name}</span>
108244
{config.modelId && (
109-
<>
110-
<span
111-
className="text-vscode-descriptionForeground opacity-70 min-w-0 overflow-hidden"
112-
style={{ direction: "rtl", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
113-
{config.modelId}
114-
</span>
115-
</>
245+
<span className="text-xs text-vscode-descriptionForeground opacity-80 truncate">
246+
{config.name}
247+
</span>
116248
)}
117249
</div>
118-
<div className="flex items-center gap-1">
250+
<div className="flex items-center gap-1 flex-shrink-0">
119251
{isCurrentConfig && (
120252
<div className="size-5 p-1 flex items-center justify-center">
121253
<span className="codicon codicon-check text-xs" />
@@ -142,7 +274,7 @@ export const ApiConfigSelector = ({
142274
</div>
143275
)
144276
},
145-
[value, handleSelect, t, togglePinnedApiConfig],
277+
[value, pinnedApiConfigs, handleSelect, t, togglePinnedApiConfig],
146278
)
147279

148280
return (
@@ -167,7 +299,7 @@ export const ApiConfigSelector = ({
167299
align="start"
168300
sideOffset={4}
169301
container={portalContainer}
170-
className="p-0 overflow-hidden w-[300px]">
302+
className="p-0 overflow-hidden w-[520px] max-w-[calc(100vw-24px)]">
171303
<div className="flex flex-col w-full">
172304
{/* Search input or info blurb */}
173305
{listApiConfigMeta.length > 6 ? (
@@ -197,29 +329,29 @@ export const ApiConfigSelector = ({
197329
</div>
198330
)}
199331

200-
{/* Config list - single scroll container */}
332+
{/* Provider/model picker */}
201333
{filteredConfigs.length === 0 && searchValue ? (
202334
<div className="py-2 px-3 text-sm text-vscode-foreground/70">{t("common:ui.no_results")}</div>
203335
) : (
204-
<div className="max-h-[300px] overflow-y-auto">
205-
{/* Pinned configs - sticky header */}
206-
{pinnedConfigs.length > 0 && (
207-
<div
208-
className={cn(
209-
"sticky top-0 z-10 bg-vscode-dropdown-background py-1",
210-
unpinnedConfigs.length > 0 && "border-b border-vscode-dropdown-foreground/10",
211-
)}
212-
aria-label="Pinned configurations">
213-
{pinnedConfigs.map((config) => renderConfigItem(config, true))}
336+
<div className="grid grid-cols-[170px_minmax(0,1fr)] max-h-[320px] overflow-hidden">
337+
<div
338+
className="border-r border-vscode-dropdown-border overflow-y-auto"
339+
data-testid="api-provider-column"
340+
aria-label={t("settings:providers.apiProvider")}>
341+
<div className="sticky top-0 z-10 bg-vscode-dropdown-background px-2 py-1 text-[10px] uppercase tracking-wide text-vscode-descriptionForeground border-b border-vscode-dropdown-border">
342+
{t("settings:providers.apiProvider")}
214343
</div>
215-
)}
216-
217-
{/* Unpinned configs */}
218-
{unpinnedConfigs.length > 0 && (
219-
<div className="py-1" aria-label="All configurations">
220-
{unpinnedConfigs.map((config) => renderConfigItem(config, false))}
344+
<div className="py-1">{providerGroups.map(renderProviderItem)}</div>
345+
</div>
346+
<div
347+
className="overflow-y-auto"
348+
data-testid="api-model-column"
349+
aria-label={t("settings:providers.model")}>
350+
<div className="sticky top-0 z-10 bg-vscode-dropdown-background px-3 py-1 text-[10px] uppercase tracking-wide text-vscode-descriptionForeground border-b border-vscode-dropdown-border">
351+
{activeProviderGroup?.label ?? t("settings:providers.model")}
221352
</div>
222-
)}
353+
<div className="py-1">{activeProviderGroup?.configs.map(renderModelItem)}</div>
354+
</div>
223355
</div>
224356
)}
225357

0 commit comments

Comments
 (0)