Port all Dropdown components to Select in ApiOptions (#1780)

* Port all `Dropdown` components to `Select` in `ApiOptions`

* Fix tests
This commit is contained in:
Chris Estreich 2025-03-18 10:15:36 -07:00 committed by GitHub
parent 516f635595
commit 923d4a56f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 189 additions and 177 deletions

View file

@ -2,12 +2,12 @@ import React, { memo, useCallback, useEffect, useMemo, useState } from "react"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { Trans } from "react-i18next"
import { useDebounce, useEvent } from "react-use"
import { Checkbox, Dropdown, type DropdownOption } from "vscrui"
import { LanguageModelChatSelector } from "vscode"
import { Checkbox } from "vscrui"
import { VSCodeLink, VSCodeRadio, VSCodeRadioGroup, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import * as vscodemodels from "vscode"
import { ExternalLinkIcon } from "@radix-ui/react-icons"
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, Button } from "@/components/ui"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SelectSeparator, Button } from "@/components/ui"
import {
ApiConfiguration,
@ -36,6 +36,7 @@ import {
unboundDefaultModelInfo,
requestyDefaultModelId,
requestyDefaultModelInfo,
ApiProvider,
} from "../../../../src/shared/api"
import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage"
@ -45,6 +46,7 @@ import {
OPENROUTER_DEFAULT_PROVIDER_NAME,
} from "@/components/ui/hooks/useOpenRouterModelProviders"
import { MODELS_BY_PROVIDER, PROVIDERS, AWS_REGIONS, VERTEX_REGIONS } from "./constants"
import { VSCodeButtonLink } from "../common/VSCodeButtonLink"
import { ModelInfoView } from "./ModelInfoView"
import { ModelPicker } from "./ModelPicker"
@ -53,35 +55,6 @@ import { validateApiConfiguration, validateModelId, validateBedrockArn } from "@
import { ApiErrorMessage } from "./ApiErrorMessage"
import { ThinkingBudget } from "./ThinkingBudget"
const modelsByProvider: Record<string, Record<string, ModelInfo>> = {
anthropic: anthropicModels,
bedrock: bedrockModels,
vertex: vertexModels,
gemini: geminiModels,
"openai-native": openAiNativeModels,
deepseek: deepSeekModels,
mistral: mistralModels,
}
const providers = [
{ value: "openrouter", label: "OpenRouter" },
{ value: "anthropic", label: "Anthropic" },
{ value: "gemini", label: "Google Gemini" },
{ value: "deepseek", label: "DeepSeek" },
{ value: "openai-native", label: "OpenAI" },
{ value: "openai", label: "OpenAI Compatible" },
{ value: "vertex", label: "GCP Vertex AI" },
{ value: "bedrock", label: "AWS Bedrock" },
{ value: "glama", label: "Glama" },
{ value: "vscode-lm", label: "VS Code LM API" },
{ value: "mistral", label: "Mistral" },
{ value: "lmstudio", label: "LM Studio" },
{ value: "ollama", label: "Ollama" },
{ value: "unbound", label: "Unbound" },
{ value: "requesty", label: "Requesty" },
{ value: "human-relay", label: "Human Relay" },
]
interface ApiOptionsProps {
uriScheme: string | undefined
apiConfiguration: ApiConfiguration
@ -103,7 +76,7 @@ const ApiOptions = ({
const [ollamaModels, setOllamaModels] = useState<string[]>([])
const [lmStudioModels, setLmStudioModels] = useState<string[]>([])
const [vsCodeLmModels, setVsCodeLmModels] = useState<vscodemodels.LanguageModelChatSelector[]>([])
const [vsCodeLmModels, setVsCodeLmModels] = useState<LanguageModelChatSelector[]>([])
const [openRouterModels, setOpenRouterModels] = useState<Record<string, ModelInfo>>({
[openRouterDefaultModelId]: openRouterDefaultModelInfo,
@ -132,9 +105,8 @@ const ApiOptions = ({
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
const noTransform = <T,>(value: T) => value
const inputEventTransform = <E,>(event: E) => (event as { target: HTMLInputElement })?.target?.value as any
const dropdownEventTransform = <T,>(event: DropdownOption | string | undefined) =>
(typeof event == "string" ? event : event?.value) as T
const handleInputChange = useCallback(
<K extends keyof ApiConfiguration, E>(
@ -258,40 +230,35 @@ const ApiOptions = ({
useEvent("message", onMessage)
const selectedProviderModelOptions: DropdownOption[] = useMemo(
const selectedProviderModelOptions = useMemo(
() =>
modelsByProvider[selectedProvider]
? [
{ value: "", label: "Select a model..." },
...Object.keys(modelsByProvider[selectedProvider]).map((modelId) => ({
value: modelId,
label: modelId,
})),
]
MODELS_BY_PROVIDER[selectedProvider]
? Object.keys(MODELS_BY_PROVIDER[selectedProvider]).map((modelId) => ({
value: modelId,
label: modelId,
}))
: [],
[selectedProvider],
)
return (
<div className="flex flex-col gap-3">
<div className="dropdown-container">
<label htmlFor="api-provider" className="font-medium">
{t("settings:providers.apiProvider")}
</label>
<div>
<label className="block font-medium mb-1">{t("settings:providers.apiProvider")}</label>
<Select
value={selectedProvider}
onValueChange={handleInputChange("apiProvider", dropdownEventTransform)}>
onValueChange={(value) => setApiConfigurationField("apiProvider", value as ApiProvider)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{providers.map(({ value, label }) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectGroup>
<SelectItem value="openrouter">OpenRouter</SelectItem>
<SelectSeparator />
{PROVIDERS.map(({ value, label }) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
@ -544,34 +511,22 @@ const ApiOptions = ({
</VSCodeTextField>
</>
)}
<div className="dropdown-container">
<label htmlFor="aws-region-dropdown" className="font-medium">
{t("settings:providers.awsRegion")}
</label>
<Dropdown
id="aws-region-dropdown"
<div>
<label className="block font-medium mb-1">{t("settings:providers.awsRegion")}</label>
<Select
value={apiConfiguration?.awsRegion || ""}
onChange={handleInputChange("awsRegion", dropdownEventTransform)}
options={[
{ value: "", label: "Select a region..." },
{ value: "us-east-1", label: "us-east-1" },
{ value: "us-east-2", label: "us-east-2" },
{ value: "us-west-2", label: "us-west-2" },
{ value: "ap-south-1", label: "ap-south-1" },
{ value: "ap-northeast-1", label: "ap-northeast-1" },
{ value: "ap-northeast-2", label: "ap-northeast-2" },
{ value: "ap-southeast-1", label: "ap-southeast-1" },
{ value: "ap-southeast-2", label: "ap-southeast-2" },
{ value: "ca-central-1", label: "ca-central-1" },
{ value: "eu-central-1", label: "eu-central-1" },
{ value: "eu-west-1", label: "eu-west-1" },
{ value: "eu-west-2", label: "eu-west-2" },
{ value: "eu-west-3", label: "eu-west-3" },
{ value: "sa-east-1", label: "sa-east-1" },
{ value: "us-gov-west-1", label: "us-gov-west-1" },
]}
className="w-full"
/>
onValueChange={(value) => setApiConfigurationField("awsRegion", value)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
{AWS_REGIONS.map(({ value, label }) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Checkbox
checked={apiConfiguration?.awsUseCrossRegionInference || false}
@ -628,24 +583,22 @@ const ApiOptions = ({
className="w-full">
<span className="font-medium">{t("settings:providers.googleCloudProjectId")}</span>
</VSCodeTextField>
<div className="dropdown-container">
<label htmlFor="vertex-region-dropdown" className="font-medium">
{t("settings:providers.googleCloudRegion")}
</label>
<Dropdown
id="vertex-region-dropdown"
<div>
<label className="block font-medium mb-1">{t("settings:providers.googleCloudRegion")}</label>
<Select
value={apiConfiguration?.vertexRegion || ""}
onChange={handleInputChange("vertexRegion", dropdownEventTransform)}
options={[
{ value: "", label: "Select a region..." },
{ value: "us-east5", label: "us-east5" },
{ value: "us-central1", label: "us-central1" },
{ value: "europe-west1", label: "europe-west1" },
{ value: "europe-west4", label: "europe-west4" },
{ value: "asia-southeast1", label: "asia-southeast1" },
]}
className="w-full"
/>
onValueChange={(value) => setApiConfigurationField("vertexRegion", value)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
{VERTEX_REGIONS.map(({ value, label }) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
@ -1247,10 +1200,8 @@ const ApiOptions = ({
{selectedProvider === "vscode-lm" && (
<>
<div className="dropdown-container">
<label htmlFor="vscode-lm-model" className="font-medium">
{t("settings:providers.vscodeLmModel")}
</label>
<div>
<label className="block font-medium mb-1">{t("settings:providers.vscodeLmModel")}</label>
{vsCodeLmModels.length > 0 ? (
<Select
value={
@ -1258,23 +1209,21 @@ const ApiOptions = ({
? `${apiConfiguration.vsCodeLmModelSelector.vendor ?? ""}/${apiConfiguration.vsCodeLmModelSelector.family ?? ""}`
: ""
}
onValueChange={handleInputChange("vsCodeLmModelSelector", (valueStr) => {
const [vendor, family] = valueStr.split("/")
onValueChange={handleInputChange("vsCodeLmModelSelector", (value) => {
const [vendor, family] = value.split("/")
return { vendor, family }
})}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a model..." />
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{vsCodeLmModels.map((model) => (
<SelectItem
key={`${model.vendor}/${model.family}`}
value={`${model.vendor}/${model.family}`}>
{`${model.vendor} - ${model.family}`}
</SelectItem>
))}
</SelectGroup>
{vsCodeLmModels.map((model) => (
<SelectItem
key={`${model.vendor}/${model.family}`}
value={`${model.vendor}/${model.family}`}>
{`${model.vendor} - ${model.family}`}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
@ -1379,51 +1328,52 @@ const ApiOptions = ({
/>
)}
{openRouterModelProviders && (
<>
<div className="dropdown-container" style={{ marginTop: 3 }}>
{selectedProvider === "openrouter" &&
openRouterModelProviders &&
Object.keys(openRouterModelProviders).length > 0 && (
<div>
<div className="flex items-center gap-1">
<label htmlFor="provider-routing" className="font-medium">
<label className="block font-medium mb-1">
{t("settings:providers.openRouter.providerRouting.title")}
</label>
<a href={`https://openrouter.ai/${selectedModelId}/providers`}>
<ExternalLinkIcon className="w-4 h-4" />
</a>
</div>
<Dropdown
id="provider-routing"
value={apiConfiguration?.openRouterSpecificProvider || ""}
onChange={(event) => {
const provider = typeof event == "string" ? event : event?.value
const providerModelInfo = provider ? openRouterModelProviders[provider] : undefined
if (providerModelInfo) {
<Select
value={apiConfiguration?.openRouterSpecificProvider || OPENROUTER_DEFAULT_PROVIDER_NAME}
onValueChange={(value) => {
if (openRouterModelProviders[value]) {
setApiConfigurationField("openRouterModelInfo", {
...apiConfiguration.openRouterModelInfo,
...providerModelInfo,
...openRouterModelProviders[value],
})
}
setApiConfigurationField("openRouterSpecificProvider", provider)
}}
options={[
{ value: OPENROUTER_DEFAULT_PROVIDER_NAME, label: OPENROUTER_DEFAULT_PROVIDER_NAME },
...Object.entries(openRouterModelProviders).map(([value, { label }]) => ({
value,
label,
})),
]}
className="w-full"
/>
setApiConfigurationField("openRouterSpecificProvider", value)
}}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
<SelectItem value={OPENROUTER_DEFAULT_PROVIDER_NAME}>
{OPENROUTER_DEFAULT_PROVIDER_NAME}
</SelectItem>
{Object.entries(openRouterModelProviders).map(([value, { label }]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="text-sm text-vscode-descriptionForeground">
{t("settings:providers.openRouter.providerRouting.description")}{" "}
<a href="https://openrouter.ai/docs/features/provider-routing">
{t("settings:providers.openRouter.providerRouting.learnMore")}.
</a>
</div>
</div>
<div className="text-sm text-vscode-descriptionForeground">
{t("settings:providers.openRouter.providerRouting.description")}{" "}
<a href="https://openrouter.ai/docs/features/provider-routing">
{t("settings:providers.openRouter.providerRouting.learnMore")}.
</a>
</div>
</>
)}
)}
{selectedProvider === "glama" && (
<ModelPicker
@ -1469,30 +1419,33 @@ const ApiOptions = ({
{selectedProviderModelOptions.length > 0 && (
<>
<div className="dropdown-container">
<label htmlFor="model-id" className="font-medium">
{t("settings:providers.model")}
</label>
<Dropdown
id="model-id"
value={selectedModelId === "custom-arn" ? "custom-arn" : selectedModelId}
onChange={(value) => {
const modelValue = typeof value == "string" ? value : value?.value
setApiConfigurationField("apiModelId", modelValue)
<div>
<label className="block font-medium mb-1">{t("settings:providers.model")}</label>
// Clear custom ARN if not using custom ARN option
if (modelValue !== "custom-arn" && selectedProvider === "bedrock") {
<Select
value={selectedModelId === "custom-arn" ? "custom-arn" : selectedModelId}
onValueChange={(value) => {
setApiConfigurationField("apiModelId", value)
// Clear custom ARN if not using custom ARN option.
if (value !== "custom-arn" && selectedProvider === "bedrock") {
setApiConfigurationField("awsCustomArn", "")
}
}}
options={[
...selectedProviderModelOptions,
...(selectedProvider === "bedrock"
? [{ value: "custom-arn", label: "Use custom ARN..." }]
: []),
]}
className="w-full"
/>
}}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
{selectedProviderModelOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
{selectedProvider === "bedrock" && (
<SelectItem value="custom-arn">Use custom ARN...</SelectItem>
)}
</SelectContent>
</Select>
</div>
{selectedProvider === "bedrock" && selectedModelId === "custom-arn" && (

View file

@ -23,18 +23,12 @@ jest.mock("@vscode/webview-ui-toolkit/react", () => ({
// Mock other components
jest.mock("vscrui", () => ({
Dropdown: ({ children, value, onChange }: any) => (
<select value={value} onChange={onChange}>
{children}
</select>
),
Checkbox: ({ children, checked, onChange }: any) => (
<label>
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
{children}
</label>
),
Pane: ({ children }: any) => <div>{children}</div>,
}))
// Mock @shadcn/ui components
@ -49,12 +43,12 @@ jest.mock("@/components/ui", () => ({
SelectTrigger: ({ children }: any) => <div className="select-trigger-mock">{children}</div>,
SelectValue: ({ children }: any) => <div className="select-value-mock">{children}</div>,
SelectContent: ({ children }: any) => <div className="select-content-mock">{children}</div>,
SelectGroup: ({ children }: any) => <div className="select-group-mock">{children}</div>,
SelectItem: ({ children, value }: any) => (
<option value={value} className="select-item-mock">
{children}
</option>
),
SelectSeparator: ({ children }: any) => <div className="select-separator-mock">{children}</div>,
Button: ({ children, onClick }: any) => (
<button onClick={onClick} className="button-mock">
{children}

View file

@ -0,0 +1,65 @@
import {
ApiProvider,
ModelInfo,
anthropicModels,
bedrockModels,
deepSeekModels,
geminiModels,
mistralModels,
openAiNativeModels,
vertexModels,
} from "../../../../src/shared/api"
export const MODELS_BY_PROVIDER: Partial<Record<ApiProvider, Record<string, ModelInfo>>> = {
anthropic: anthropicModels,
bedrock: bedrockModels,
deepseek: deepSeekModels,
gemini: geminiModels,
mistral: mistralModels,
"openai-native": openAiNativeModels,
vertex: vertexModels,
}
export const PROVIDERS = [
{ value: "anthropic", label: "Anthropic" },
{ value: "gemini", label: "Google Gemini" },
{ value: "deepseek", label: "DeepSeek" },
{ value: "openai-native", label: "OpenAI" },
{ value: "openai", label: "OpenAI Compatible" },
{ value: "vertex", label: "GCP Vertex AI" },
{ value: "bedrock", label: "AWS Bedrock" },
{ value: "glama", label: "Glama" },
{ value: "vscode-lm", label: "VS Code LM API" },
{ value: "mistral", label: "Mistral" },
{ value: "lmstudio", label: "LM Studio" },
{ value: "ollama", label: "Ollama" },
{ value: "unbound", label: "Unbound" },
{ value: "requesty", label: "Requesty" },
{ value: "human-relay", label: "Human Relay" },
].sort((a, b) => a.label.localeCompare(b.label))
export const AWS_REGIONS = [
{ value: "us-east-1", label: "us-east-1" },
{ value: "us-east-2", label: "us-east-2" },
{ value: "us-west-2", label: "us-west-2" },
{ value: "ap-south-1", label: "ap-south-1" },
{ value: "ap-northeast-1", label: "ap-northeast-1" },
{ value: "ap-northeast-2", label: "ap-northeast-2" },
{ value: "ap-southeast-1", label: "ap-southeast-1" },
{ value: "ap-southeast-2", label: "ap-southeast-2" },
{ value: "ca-central-1", label: "ca-central-1" },
{ value: "eu-central-1", label: "eu-central-1" },
{ value: "eu-west-1", label: "eu-west-1" },
{ value: "eu-west-2", label: "eu-west-2" },
{ value: "eu-west-3", label: "eu-west-3" },
{ value: "sa-east-1", label: "sa-east-1" },
{ value: "us-gov-west-1", label: "us-gov-west-1" },
]
export const VERTEX_REGIONS = [
{ value: "us-east5", label: "us-east5" },
{ value: "us-central1", label: "us-central1" },
{ value: "europe-west1", label: "europe-west1" },
{ value: "europe-west4", label: "europe-west4" },
{ value: "asia-southeast1", label: "asia-southeast1" },
]

View file

@ -83,7 +83,7 @@ function SelectItem({ className, children, ...props }: React.ComponentProps<type
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-vscode-list-activeSelectionBackground focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-xs py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 cursor-pointer",
"focus:bg-vscode-list-activeSelectionBackground focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full items-center gap-2 rounded-xs py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 cursor-pointer",
className,
)}
{...props}>