Browse Source

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

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

* Fix tests
Chris Estreich 9 months ago
parent
commit
923d4a56f9

+ 121 - 168
webview-ui/src/components/settings/ApiOptions.tsx

@@ -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"
-						/>
-					</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>
+								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>
-				</>
-			)}
+				)}
 
 			{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"
+					<div>
+						<label className="block font-medium mb-1">{t("settings:providers.model")}</label>
+
+						<Select
 							value={selectedModelId === "custom-arn" ? "custom-arn" : selectedModelId}
-							onChange={(value) => {
-								const modelValue = typeof value == "string" ? value : value?.value
-								setApiConfigurationField("apiModelId", modelValue)
+							onValueChange={(value) => {
+								setApiConfigurationField("apiModelId", value)
 
-								// Clear custom ARN if not using custom ARN option
-								if (modelValue !== "custom-arn" && selectedProvider === "bedrock") {
+								// 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" && (

+ 1 - 7
webview-ui/src/components/settings/__tests__/ApiOptions.test.tsx

@@ -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}

+ 65 - 0
webview-ui/src/components/settings/constants.ts

@@ -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" },
+]

+ 1 - 1
webview-ui/src/components/ui/select.tsx

@@ -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}>