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

Commit af81589

Browse files
committed
Make chat footer model selector interactive
1 parent 6e0af18 commit af81589

2 files changed

Lines changed: 318 additions & 26 deletions

File tree

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

Lines changed: 280 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
11
import React, { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
22
import { useEvent } from "react-use"
33
import DynamicTextArea from "react-textarea-autosize"
4-
import { VolumeX, Image, WandSparkles, SendHorizontal, X, ListEnd, Square } from "lucide-react"
4+
import { VolumeX, Image, WandSparkles, SendHorizontal, X, ListEnd, Square, Check, ChevronsUpDown } from "lucide-react"
55

6-
import type { ExtensionMessage } from "@roo-code/types"
6+
import {
7+
isDynamicProvider,
8+
isRetiredProvider,
9+
modelIdKeysByProvider,
10+
openAiModelInfoSaneDefaults,
11+
type ExtensionMessage,
12+
type ModelIdKey,
13+
type ModelRecord,
14+
type OrganizationAllowList,
15+
type ProviderName,
16+
type ProviderSettings,
17+
} from "@roo-code/types"
718

819
import { mentionRegex, mentionRegexGlobal, commandRegexGlobal, unescapeSpaces } from "@roo/context-mentions"
920
import { WebviewMessage } from "@roo/WebviewMessage"
@@ -22,9 +33,25 @@ import {
2233
} from "@src/utils/context-mentions"
2334
import { cn } from "@src/lib/utils"
2435
import { convertToMentionPath } from "@src/utils/path-mentions"
25-
import { StandardTooltip } from "@src/components/ui"
36+
import {
37+
Command,
38+
CommandEmpty,
39+
CommandGroup,
40+
CommandInput,
41+
CommandItem,
42+
CommandList,
43+
Popover,
44+
PopoverContent,
45+
PopoverTrigger,
46+
StandardTooltip,
47+
} from "@src/components/ui"
48+
import { useRouterModels } from "@src/components/ui/hooks/useRouterModels"
49+
import { useLmStudioModels } from "@src/components/ui/hooks/useLmStudioModels"
50+
import { useOllamaModels } from "@src/components/ui/hooks/useOllamaModels"
2651

2752
import Thumbnails from "../common/Thumbnails"
53+
import { MODELS_BY_PROVIDER as STATIC_MODELS_BY_PROVIDER } from "../settings/constants"
54+
import { filterModels } from "../settings/utils/organizationFilters"
2855
import { ModeSelector } from "./ModeSelector"
2956
import { ApiConfigSelector } from "./ApiConfigSelector"
3057
import { AutoApproveDropdown } from "./AutoApproveDropdown"
@@ -34,6 +61,243 @@ import { IndexingStatusBadge } from "./IndexingStatusBadge"
3461
import { usePromptHistory } from "./hooks/usePromptHistory"
3562
import { CloudAccountSwitcher } from "../cloud/CloudAccountSwitcher"
3663

64+
const QUICK_MODEL_ID_KEYS: Partial<Record<ProviderName, ModelIdKey>> = {
65+
openai: "openAiModelId",
66+
openrouter: "openRouterModelId",
67+
requesty: "requestyModelId",
68+
unbound: "unboundModelId",
69+
litellm: "litellmModelId",
70+
"vercel-ai-gateway": "vercelAiGatewayModelId",
71+
ollama: "ollamaModelId",
72+
lmstudio: "lmStudioModelId",
73+
"openai-native": "apiModelId",
74+
}
75+
76+
interface CurrentModelSelectorProps {
77+
apiConfiguration?: ProviderSettings
78+
currentApiConfigName?: string
79+
currentModelId?: string
80+
currentModelDisplayName?: string
81+
disabled?: boolean
82+
organizationAllowList?: OrganizationAllowList
83+
setApiConfiguration: (config: ProviderSettings) => void
84+
}
85+
86+
const CurrentModelSelector = ({
87+
apiConfiguration,
88+
currentApiConfigName,
89+
currentModelId,
90+
currentModelDisplayName,
91+
disabled,
92+
organizationAllowList,
93+
setApiConfiguration,
94+
}: CurrentModelSelectorProps) => {
95+
const { t } = useAppTranslation()
96+
const [open, setOpen] = useState(false)
97+
const [searchValue, setSearchValue] = useState("")
98+
const [openAiModels, setOpenAiModels] = useState<ModelRecord | null>(null)
99+
100+
const provider = apiConfiguration?.apiProvider
101+
const activeProvider: ProviderName | undefined =
102+
provider && !isRetiredProvider(provider) ? (provider as ProviderName) : undefined
103+
const dynamicProvider = activeProvider && isDynamicProvider(activeProvider) ? activeProvider : undefined
104+
105+
const routerModels = useRouterModels({ provider: dynamicProvider, enabled: !!dynamicProvider })
106+
const lmStudioModels = useLmStudioModels(
107+
activeProvider === "lmstudio" ? apiConfiguration?.lmStudioModelId : undefined,
108+
)
109+
const ollamaModels = useOllamaModels(activeProvider === "ollama" ? apiConfiguration?.ollamaModelId : undefined)
110+
111+
const onMessage = useCallback((event: MessageEvent) => {
112+
const message: ExtensionMessage = event.data
113+
114+
if (message.type === "openAiModels") {
115+
setOpenAiModels(
116+
Object.fromEntries(
117+
(message.openAiModels ?? []).map((modelId) => [modelId, openAiModelInfoSaneDefaults]),
118+
),
119+
)
120+
}
121+
}, [])
122+
123+
useEvent("message", onMessage)
124+
125+
useEffect(() => {
126+
if (open && activeProvider === "openai") {
127+
vscode.postMessage({
128+
type: "requestOpenAiModels",
129+
values: {
130+
baseUrl: apiConfiguration?.openAiBaseUrl,
131+
apiKey: apiConfiguration?.openAiApiKey,
132+
customHeaders: apiConfiguration?.openAiHeaders ?? {},
133+
},
134+
})
135+
}
136+
}, [
137+
activeProvider,
138+
apiConfiguration?.openAiApiKey,
139+
apiConfiguration?.openAiBaseUrl,
140+
apiConfiguration?.openAiHeaders,
141+
open,
142+
])
143+
144+
const models = useMemo<ModelRecord | null>(() => {
145+
if (!activeProvider) {
146+
return null
147+
}
148+
149+
if (activeProvider === "openai") {
150+
return openAiModels
151+
}
152+
153+
if (activeProvider === "lmstudio") {
154+
return lmStudioModels.data ?? null
155+
}
156+
157+
if (activeProvider === "ollama") {
158+
return ollamaModels.data ?? null
159+
}
160+
161+
if (dynamicProvider) {
162+
return routerModels.data?.[dynamicProvider] ?? null
163+
}
164+
165+
return STATIC_MODELS_BY_PROVIDER[activeProvider] ?? null
166+
}, [activeProvider, dynamicProvider, lmStudioModels.data, ollamaModels.data, openAiModels, routerModels.data])
167+
168+
const modelIds = useMemo(() => {
169+
const filteredModels = filterModels(models, activeProvider, organizationAllowList)
170+
171+
return Object.entries(filteredModels ?? {})
172+
.filter(([modelId, modelInfo]) => modelId === currentModelId || !modelInfo.deprecated)
173+
.map(([modelId]) => modelId)
174+
.sort((a, b) => a.localeCompare(b))
175+
}, [activeProvider, currentModelId, models, organizationAllowList])
176+
177+
const modelIdKey = useMemo<ModelIdKey | undefined>(() => {
178+
if (!activeProvider) {
179+
return undefined
180+
}
181+
182+
return (
183+
QUICK_MODEL_ID_KEYS[activeProvider] ??
184+
modelIdKeysByProvider[activeProvider as keyof typeof modelIdKeysByProvider]
185+
)
186+
}, [activeProvider])
187+
188+
const handleModelSelect = useCallback(
189+
(modelId: string) => {
190+
if (!apiConfiguration || !currentApiConfigName || !modelIdKey) {
191+
return
192+
}
193+
194+
const updatedConfiguration = {
195+
...apiConfiguration,
196+
[modelIdKey]: modelId,
197+
} as ProviderSettings
198+
199+
setOpen(false)
200+
setSearchValue("")
201+
setApiConfiguration(updatedConfiguration)
202+
vscode.postMessage({
203+
type: "upsertApiConfiguration",
204+
text: currentApiConfigName,
205+
apiConfiguration: updatedConfiguration,
206+
})
207+
},
208+
[apiConfiguration, currentApiConfigName, modelIdKey, setApiConfiguration],
209+
)
210+
211+
if (!currentModelDisplayName || !currentModelId) {
212+
return null
213+
}
214+
215+
const isLoading =
216+
(activeProvider === "openai" && openAiModels === null) ||
217+
(!!dynamicProvider && routerModels.isLoading) ||
218+
(activeProvider === "lmstudio" && lmStudioModels.isLoading) ||
219+
(activeProvider === "ollama" && ollamaModels.isLoading)
220+
221+
const canSelectModel = !disabled && !!modelIdKey && (modelIds.length > 0 || activeProvider === "openai")
222+
223+
return (
224+
<Popover open={open} onOpenChange={setOpen}>
225+
<PopoverTrigger asChild>
226+
<button
227+
type="button"
228+
disabled={!canSelectModel}
229+
aria-label={t("settings:modelPicker.label")}
230+
className={cn(
231+
"flex h-5 max-w-[180px] min-w-0 flex-shrink items-center gap-1 rounded-sm border border-vscode-input-border/60 px-1.5 text-vscode-descriptionForeground",
232+
"bg-transparent text-xs leading-none",
233+
canSelectModel &&
234+
"cursor-pointer hover:bg-[rgba(255,255,255,0.03)] hover:text-vscode-foreground focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
235+
!canSelectModel && "cursor-default opacity-80",
236+
)}
237+
title={currentModelId}
238+
data-testid="current-model-indicator">
239+
<span className="truncate">{currentModelDisplayName}</span>
240+
{canSelectModel ? <ChevronsUpDown className="size-3 flex-shrink-0 opacity-70" /> : null}
241+
</button>
242+
</PopoverTrigger>
243+
<PopoverContent align="start" sideOffset={4} className="w-[280px] p-0">
244+
<Command>
245+
<CommandInput
246+
value={searchValue}
247+
onValueChange={setSearchValue}
248+
placeholder={t("settings:modelPicker.searchPlaceholder")}
249+
className="h-8"
250+
/>
251+
<CommandList className="max-h-[260px]">
252+
<CommandEmpty>
253+
<div className="py-2 px-1 text-sm">
254+
{isLoading ? "Loading..." : t("settings:modelPicker.noMatchFound")}
255+
</div>
256+
</CommandEmpty>
257+
<CommandGroup>
258+
{modelIds.map((modelId) => (
259+
<CommandItem
260+
key={modelId}
261+
value={modelId}
262+
onSelect={handleModelSelect}
263+
data-testid={`quick-model-option-${modelId}`}>
264+
<span className="truncate" title={modelId}>
265+
{formatModelDisplayName(modelId)}
266+
</span>
267+
<Check
268+
className={cn(
269+
"size-4 p-0.5 ml-auto flex-shrink-0",
270+
modelId === currentModelId ? "opacity-100" : "opacity-0",
271+
)}
272+
/>
273+
</CommandItem>
274+
))}
275+
</CommandGroup>
276+
</CommandList>
277+
</Command>
278+
</PopoverContent>
279+
</Popover>
280+
)
281+
}
282+
283+
const formatModelDisplayName = (modelId: string) => {
284+
return modelId
285+
.split(/[-_\s]+/)
286+
.filter(Boolean)
287+
.map((part) => {
288+
if (part.toLowerCase() === "deepseek") {
289+
return "DeepSeek"
290+
}
291+
292+
if (/^v\d+$/i.test(part)) {
293+
return part.toUpperCase()
294+
}
295+
296+
return part.charAt(0).toUpperCase() + part.slice(1)
297+
})
298+
.join(" ")
299+
}
300+
37301
interface ChatTextAreaProps {
38302
inputValue: string
39303
setInputValue: (value: string) => void
@@ -99,7 +363,10 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
99363
cloudUserInfo,
100364
enterBehavior,
101365
lockApiConfigAcrossModes,
366+
apiConfiguration,
102367
currentModelId,
368+
organizationAllowList,
369+
setApiConfiguration,
103370
} = useExtensionState()
104371

105372
// Find the ID and display text for the currently selected API configuration.
@@ -116,21 +383,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
116383
return undefined
117384
}
118385

119-
return currentModelId
120-
.split(/[-_\s]+/)
121-
.filter(Boolean)
122-
.map((part) => {
123-
if (part.toLowerCase() === "deepseek") {
124-
return "DeepSeek"
125-
}
126-
127-
if (/^v\d+$/i.test(part)) {
128-
return part.toUpperCase()
129-
}
130-
131-
return part.charAt(0).toUpperCase() + part.slice(1)
132-
})
133-
.join(" ")
386+
return formatModelDisplayName(currentModelId)
134387
}, [currentModelId])
135388

136389
const [gitCommits, setGitCommits] = useState<any[]>([])
@@ -1343,14 +1596,15 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
13431596
lockApiConfigAcrossModes={!!lockApiConfigAcrossModes}
13441597
onToggleLockApiConfig={handleToggleLockApiConfig}
13451598
/>
1346-
{currentModelDisplayName ? (
1347-
<div
1348-
className="flex h-5 max-w-[160px] min-w-0 flex-shrink items-center rounded-sm border border-vscode-input-border/60 px-1.5 text-vscode-descriptionForeground"
1349-
title={currentModelId}
1350-
data-testid="current-model-indicator">
1351-
<span className="truncate text-xs leading-none">{currentModelDisplayName}</span>
1352-
</div>
1353-
) : null}
1599+
<CurrentModelSelector
1600+
apiConfiguration={apiConfiguration}
1601+
currentApiConfigName={currentApiConfigName}
1602+
currentModelId={currentModelId}
1603+
currentModelDisplayName={currentModelDisplayName}
1604+
disabled={selectApiConfigDisabled}
1605+
organizationAllowList={organizationAllowList}
1606+
setApiConfiguration={setApiConfiguration}
1607+
/>
13541608
<AutoApproveDropdown triggerClassName="min-w-[28px] text-ellipsis overflow-hidden flex-shrink" />
13551609
</div>
13561610
<div

0 commit comments

Comments
 (0)