Procházet zdrojové kódy

feat: sync API config selector style with mode selector from PR #6140 (#6148)

Co-authored-by: Daniel Riccio <[email protected]>
Hannes Rudolph před 5 měsíci
rodič
revize
7a6e852248

+ 242 - 0
webview-ui/src/components/chat/ApiConfigSelector.tsx

@@ -0,0 +1,242 @@
+import React, { useState, useMemo, useCallback } from "react"
+import { cn } from "@/lib/utils"
+import { useRooPortal } from "@/components/ui/hooks/useRooPortal"
+import { Popover, PopoverContent, PopoverTrigger, StandardTooltip } from "@/components/ui"
+import { IconButton } from "./IconButton"
+import { useAppTranslation } from "@/i18n/TranslationContext"
+import { vscode } from "@/utils/vscode"
+import { Fzf } from "fzf"
+import { Button } from "@/components/ui"
+
+interface ApiConfigSelectorProps {
+	value: string
+	displayName: string
+	disabled?: boolean
+	title?: string
+	onChange: (value: string) => void
+	triggerClassName?: string
+	listApiConfigMeta: Array<{ id: string; name: string }>
+	pinnedApiConfigs?: Record<string, boolean>
+	togglePinnedApiConfig: (id: string) => void
+}
+
+export const ApiConfigSelector = ({
+	value,
+	displayName,
+	disabled = false,
+	title = "",
+	onChange,
+	triggerClassName = "",
+	listApiConfigMeta,
+	pinnedApiConfigs,
+	togglePinnedApiConfig,
+}: ApiConfigSelectorProps) => {
+	const { t } = useAppTranslation()
+	const [open, setOpen] = useState(false)
+	const [searchValue, setSearchValue] = useState("")
+	const portalContainer = useRooPortal("roo-portal")
+
+	// Create searchable items for fuzzy search
+	const searchableItems = useMemo(() => {
+		return listApiConfigMeta.map((config) => ({
+			original: config,
+			searchStr: config.name,
+		}))
+	}, [listApiConfigMeta])
+
+	// Create Fzf instance
+	const fzfInstance = useMemo(() => {
+		return new Fzf(searchableItems, {
+			selector: (item) => item.searchStr,
+		})
+	}, [searchableItems])
+
+	// Filter configs based on search
+	const filteredConfigs = useMemo(() => {
+		if (!searchValue) return listApiConfigMeta
+
+		const matchingItems = fzfInstance.find(searchValue).map((result) => result.item.original)
+		return matchingItems
+	}, [listApiConfigMeta, searchValue, fzfInstance])
+
+	// Separate pinned and unpinned configs
+	const { pinnedConfigs, unpinnedConfigs } = useMemo(() => {
+		const pinned = filteredConfigs.filter((config) => pinnedApiConfigs?.[config.id])
+		const unpinned = filteredConfigs.filter((config) => !pinnedApiConfigs?.[config.id])
+		return { pinnedConfigs: pinned, unpinnedConfigs: unpinned }
+	}, [filteredConfigs, pinnedApiConfigs])
+
+	const handleSelect = useCallback(
+		(configId: string) => {
+			onChange(configId)
+			setOpen(false)
+			setSearchValue("")
+		},
+		[onChange],
+	)
+
+	const handleEditClick = useCallback(() => {
+		vscode.postMessage({
+			type: "switchTab",
+			tab: "settings",
+		})
+		setOpen(false)
+	}, [])
+
+	const renderConfigItem = useCallback(
+		(config: { id: string; name: string }, isPinned: boolean) => {
+			const isCurrentConfig = config.id === value
+
+			return (
+				<div
+					key={config.id}
+					onClick={() => handleSelect(config.id)}
+					className={cn(
+						"px-3 py-1.5 text-sm cursor-pointer flex items-center group",
+						"hover:bg-vscode-list-hoverBackground",
+						isCurrentConfig &&
+							"bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground",
+					)}>
+					<span className="flex-1 truncate">{config.name}</span>
+					<div className="flex items-center gap-1">
+						{isCurrentConfig && (
+							<div className="size-5 p-1 flex items-center justify-center">
+								<span className="codicon codicon-check text-xs" />
+							</div>
+						)}
+						<StandardTooltip content={isPinned ? t("chat:unpin") : t("chat:pin")}>
+							<Button
+								variant="ghost"
+								size="icon"
+								tabIndex={-1}
+								onClick={(e) => {
+									e.stopPropagation()
+									togglePinnedApiConfig(config.id)
+									vscode.postMessage({
+										type: "toggleApiConfigPin",
+										text: config.id,
+									})
+								}}
+								className={cn("size-5 flex items-center justify-center", {
+									"opacity-0 group-hover:opacity-100": !isPinned && !isCurrentConfig,
+									"bg-accent opacity-100": isPinned,
+								})}>
+								<span className="codicon codicon-pin text-xs opacity-50" />
+							</Button>
+						</StandardTooltip>
+					</div>
+				</div>
+			)
+		},
+		[value, handleSelect, t, togglePinnedApiConfig],
+	)
+
+	const triggerContent = (
+		<PopoverTrigger
+			disabled={disabled}
+			data-testid="dropdown-trigger"
+			className={cn(
+				"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",
+				"bg-transparent border border-[rgba(255,255,255,0.08)] rounded-md text-vscode-foreground",
+				"transition-all duration-150 focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder focus-visible:ring-inset",
+				disabled
+					? "opacity-50 cursor-not-allowed"
+					: "opacity-90 hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)] cursor-pointer",
+				triggerClassName,
+			)}>
+			<span
+				className={cn(
+					"codicon codicon-chevron-up pointer-events-none opacity-80 flex-shrink-0 text-xs transition-transform duration-200",
+					open && "rotate-180",
+				)}
+			/>
+			<span className="truncate">{displayName}</span>
+		</PopoverTrigger>
+	)
+
+	return (
+		<Popover open={open} onOpenChange={setOpen}>
+			{title ? <StandardTooltip content={title}>{triggerContent}</StandardTooltip> : triggerContent}
+			<PopoverContent
+				align="start"
+				sideOffset={4}
+				container={portalContainer}
+				className="p-0 overflow-hidden w-[300px]">
+				<div className="flex flex-col w-full">
+					{/* Search input or info blurb */}
+					{listApiConfigMeta.length > 6 ? (
+						<div className="relative p-2 border-b border-vscode-dropdown-border">
+							<input
+								aria-label={t("common:ui.search_placeholder")}
+								value={searchValue}
+								onChange={(e) => setSearchValue(e.target.value)}
+								placeholder={t("common:ui.search_placeholder")}
+								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"
+								autoFocus
+							/>
+							{searchValue.length > 0 && (
+								<div className="absolute right-4 top-0 bottom-0 flex items-center justify-center">
+									<span
+										className="codicon codicon-close text-vscode-input-foreground opacity-50 hover:opacity-100 text-xs cursor-pointer"
+										onClick={() => setSearchValue("")}
+									/>
+								</div>
+							)}
+						</div>
+					) : (
+						<div className="p-3 border-b border-vscode-dropdown-border">
+							<p className="text-xs text-vscode-descriptionForeground m-0">
+								{t("prompts:apiConfiguration.select")}
+							</p>
+						</div>
+					)}
+
+					{/* Config list */}
+					<div className="max-h-[300px] overflow-y-auto">
+						{filteredConfigs.length === 0 && searchValue ? (
+							<div className="py-2 px-3 text-sm text-vscode-foreground/70">
+								{t("common:ui.no_results")}
+							</div>
+						) : (
+							<div className="py-1">
+								{/* Pinned configs */}
+								{pinnedConfigs.map((config) => renderConfigItem(config, true))}
+
+								{/* Separator between pinned and unpinned */}
+								{pinnedConfigs.length > 0 && unpinnedConfigs.length > 0 && (
+									<div className="mx-1 my-1 h-px bg-vscode-dropdown-foreground/10" />
+								)}
+
+								{/* Unpinned configs */}
+								{unpinnedConfigs.map((config) => renderConfigItem(config, false))}
+							</div>
+						)}
+					</div>
+
+					{/* Bottom bar with buttons on left and title on right */}
+					<div className="flex flex-row items-center justify-between p-2 border-t border-vscode-dropdown-border">
+						<div className="flex flex-row gap-1">
+							<IconButton
+								iconClass="codicon-settings-gear"
+								title={t("chat:edit")}
+								onClick={handleEditClick}
+							/>
+						</div>
+
+						{/* Info icon and title on the right with matching spacing */}
+						<div className="flex items-center gap-1 pr-1">
+							{listApiConfigMeta.length > 6 && (
+								<StandardTooltip content={t("prompts:apiConfiguration.select")}>
+									<span className="codicon codicon-info text-xs text-vscode-descriptionForeground opacity-70 hover:opacity-100 cursor-help" />
+								</StandardTooltip>
+							)}
+							<h4 className="m-0 font-medium text-sm text-vscode-descriptionForeground">
+								{t("prompts:apiConfiguration.title")}
+							</h4>
+						</div>
+					</div>
+				</div>
+			</PopoverContent>
+		</Popover>
+	)
+}

+ 9 - 120
webview-ui/src/components/chat/ChatTextArea.tsx

@@ -19,13 +19,14 @@ import {
 	SearchResult,
 } from "@src/utils/context-mentions"
 import { convertToMentionPath } from "@/utils/path-mentions"
-import { SelectDropdown, DropdownOptionType, Button, StandardTooltip } from "@/components/ui"
+import { StandardTooltip } from "@/components/ui"
 
 import Thumbnails from "../common/Thumbnails"
 import ModeSelector from "./ModeSelector"
+import { ApiConfigSelector } from "./ApiConfigSelector"
 import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
 import ContextMenu from "./ContextMenu"
-import { VolumeX, Pin, Check, Image, WandSparkles, SendHorizontal } from "lucide-react"
+import { VolumeX, Image, WandSparkles, SendHorizontal } from "lucide-react"
 import { IndexingStatusBadge } from "./IndexingStatusBadge"
 import { cn } from "@/lib/utils"
 import { usePromptHistory } from "./hooks/usePromptHistory"
@@ -856,122 +857,11 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 			/>
 		)
 
-		// Helper function to get API config dropdown options
-		const getApiConfigOptions = useMemo(() => {
-			const pinnedConfigs = (listApiConfigMeta || [])
-				.filter((config) => pinnedApiConfigs && pinnedApiConfigs[config.id])
-				.map((config) => ({
-					value: config.id,
-					label: config.name,
-					name: config.name,
-					type: DropdownOptionType.ITEM,
-					pinned: true,
-				}))
-				.sort((a, b) => a.label.localeCompare(b.label))
-
-			const unpinnedConfigs = (listApiConfigMeta || [])
-				.filter((config) => !pinnedApiConfigs || !pinnedApiConfigs[config.id])
-				.map((config) => ({
-					value: config.id,
-					label: config.name,
-					name: config.name,
-					type: DropdownOptionType.ITEM,
-					pinned: false,
-				}))
-				.sort((a, b) => a.label.localeCompare(b.label))
-
-			const hasPinnedAndUnpinned = pinnedConfigs.length > 0 && unpinnedConfigs.length > 0
-
-			return [
-				...pinnedConfigs,
-				...(hasPinnedAndUnpinned
-					? [
-							{
-								value: "sep-pinned",
-								label: t("chat:separator"),
-								type: DropdownOptionType.SEPARATOR,
-							},
-						]
-					: []),
-				...unpinnedConfigs,
-				{
-					value: "sep-2",
-					label: t("chat:separator"),
-					type: DropdownOptionType.SEPARATOR,
-				},
-				{
-					value: "settingsButtonClicked",
-					label: t("chat:edit"),
-					type: DropdownOptionType.ACTION,
-				},
-			]
-		}, [listApiConfigMeta, pinnedApiConfigs, t])
-
 		// Helper function to handle API config change
 		const handleApiConfigChange = useCallback((value: string) => {
-			if (value === "settingsButtonClicked") {
-				vscode.postMessage({
-					type: "loadApiConfiguration",
-					text: value,
-					values: { section: "providers" },
-				})
-			} else {
-				vscode.postMessage({ type: "loadApiConfigurationById", text: value })
-			}
+			vscode.postMessage({ type: "loadApiConfigurationById", text: value })
 		}, [])
 
-		// Helper function to render API config item
-		const renderApiConfigItem = useCallback(
-			({ type, value, label, pinned }: any) => {
-				if (type !== DropdownOptionType.ITEM) {
-					return label
-				}
-
-				const config = listApiConfigMeta?.find((c) => c.id === value)
-				const isCurrentConfig = config?.name === currentApiConfigName
-
-				return (
-					<div className="flex justify-between gap-2 w-full h-5">
-						<div
-							className={cn("truncate min-w-0 overflow-hidden", {
-								"font-medium": isCurrentConfig,
-							})}>
-							{label}
-						</div>
-						<div className="flex justify-end w-10 flex-shrink-0">
-							<div
-								className={cn("size-5 p-1", {
-									"block group-hover:hidden": !pinned,
-									hidden: !isCurrentConfig,
-								})}>
-								<Check className="size-3" />
-							</div>
-							<StandardTooltip content={pinned ? t("chat:unpin") : t("chat:pin")}>
-								<Button
-									variant="ghost"
-									size="icon"
-									onClick={(e) => {
-										e.stopPropagation()
-										togglePinnedApiConfig(value)
-										vscode.postMessage({
-											type: "toggleApiConfigPin",
-											text: value,
-										})
-									}}
-									className={cn("size-5", {
-										"hidden group-hover:flex": !pinned,
-										"bg-accent": pinned,
-									})}>
-									<Pin className="size-3 p-0.5 opacity-50" />
-								</Button>
-							</StandardTooltip>
-						</div>
-					</div>
-				)
-			},
-			[listApiConfigMeta, currentApiConfigName, t, togglePinnedApiConfig],
-		)
-
 		// Helper function to render non-edit mode controls
 		const renderNonEditModeControls = () => (
 			<div className={cn("flex", "justify-between", "items-center", "mt-auto")}>
@@ -979,17 +869,16 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 					<div className="shrink-0">{renderModeSelector()}</div>
 
 					<div className={cn("flex-1", "min-w-0", "overflow-hidden")}>
-						<SelectDropdown
+						<ApiConfigSelector
 							value={currentConfigId}
+							displayName={displayName}
 							disabled={selectApiConfigDisabled}
 							title={t("chat:selectApiConfig")}
-							disableSearch={false}
-							placeholder={displayName}
-							options={getApiConfigOptions}
 							onChange={handleApiConfigChange}
 							triggerClassName="w-full text-ellipsis overflow-hidden"
-							itemClassName="group"
-							renderItem={renderApiConfigItem}
+							listApiConfigMeta={listApiConfigMeta || []}
+							pinnedApiConfigs={pinnedApiConfigs}
+							togglePinnedApiConfig={togglePinnedApiConfig}
 						/>
 					</div>
 				</div>

+ 418 - 0
webview-ui/src/components/chat/__tests__/ApiConfigSelector.spec.tsx

@@ -0,0 +1,418 @@
+import React from "react"
+import { render, screen, fireEvent, waitFor } from "@/utils/test-utils"
+import { describe, test, expect, vi, beforeEach } from "vitest"
+import { ApiConfigSelector } from "../ApiConfigSelector"
+import { vscode } from "@/utils/vscode"
+
+// Mock the dependencies
+vi.mock("@/utils/vscode", () => ({
+	vscode: {
+		postMessage: vi.fn(),
+	},
+}))
+
+vi.mock("@/i18n/TranslationContext", () => ({
+	useAppTranslation: () => ({
+		t: (key: string) => key,
+	}),
+}))
+
+vi.mock("@/components/ui/hooks/useRooPortal", () => ({
+	useRooPortal: () => document.body,
+}))
+
+// Mock Popover components to be testable
+vi.mock("@/components/ui", () => ({
+	Popover: ({ children, open }: any) => (
+		<div data-testid="popover-root" data-open={open}>
+			{children}
+		</div>
+	),
+	PopoverTrigger: ({ children, disabled, ...props }: any) => (
+		<button data-testid="dropdown-trigger" disabled={disabled} onClick={() => props.onClick?.()} {...props}>
+			{children}
+		</button>
+	),
+	PopoverContent: ({ children }: any) => <div data-testid="popover-content">{children}</div>,
+	StandardTooltip: ({ children }: any) => <>{children}</>,
+	Button: ({ children, onClick, ...props }: any) => (
+		<button onClick={onClick} {...props}>
+			{children}
+		</button>
+	),
+}))
+
+describe("ApiConfigSelector", () => {
+	const mockOnChange = vi.fn()
+	const mockTogglePinnedApiConfig = vi.fn()
+
+	const defaultProps = {
+		value: "config1",
+		displayName: "Config 1",
+		onChange: mockOnChange,
+		listApiConfigMeta: [
+			{ id: "config1", name: "Config 1" },
+			{ id: "config2", name: "Config 2" },
+			{ id: "config3", name: "Config 3" },
+		],
+		pinnedApiConfigs: { config1: true },
+		togglePinnedApiConfig: mockTogglePinnedApiConfig,
+	}
+
+	beforeEach(() => {
+		vi.clearAllMocks()
+	})
+
+	test("renders correctly with default props", () => {
+		render(<ApiConfigSelector {...defaultProps} />)
+
+		const trigger = screen.getByTestId("dropdown-trigger")
+		expect(trigger).toBeInTheDocument()
+		expect(trigger).toHaveTextContent("Config 1")
+	})
+
+	test("renders with ChevronUp icon", () => {
+		render(<ApiConfigSelector {...defaultProps} />)
+
+		const trigger = screen.getByTestId("dropdown-trigger")
+		// Check for the icon by looking for the codicon span element
+		const icon = trigger.querySelector(".codicon-chevron-up")
+		expect(icon).toBeInTheDocument()
+	})
+
+	test("handles disabled state correctly", () => {
+		render(<ApiConfigSelector {...defaultProps} disabled={true} />)
+
+		const trigger = screen.getByTestId("dropdown-trigger")
+		expect(trigger).toBeDisabled()
+	})
+
+	test("renders with custom title tooltip", () => {
+		const customTitle = "Custom tooltip text"
+		render(<ApiConfigSelector {...defaultProps} title={customTitle} />)
+
+		// The component should render with the tooltip wrapper
+		const trigger = screen.getByTestId("dropdown-trigger")
+		expect(trigger).toBeInTheDocument()
+	})
+
+	test("applies custom trigger className", () => {
+		const customClass = "custom-trigger-class"
+		render(<ApiConfigSelector {...defaultProps} triggerClassName={customClass} />)
+
+		const trigger = screen.getByTestId("dropdown-trigger")
+		expect(trigger.className).toContain(customClass)
+	})
+
+	test("opens popover when trigger is clicked", () => {
+		render(<ApiConfigSelector {...defaultProps} />)
+
+		const trigger = screen.getByTestId("dropdown-trigger")
+		fireEvent.click(trigger)
+
+		// Check if popover content is rendered
+		const popoverContent = screen.getByTestId("popover-content")
+		expect(popoverContent).toBeInTheDocument()
+	})
+
+	test("renders search input when popover is open and more than 6 configs", () => {
+		const props = {
+			...defaultProps,
+			listApiConfigMeta: [
+				{ id: "config1", name: "Config 1" },
+				{ id: "config2", name: "Config 2" },
+				{ id: "config3", name: "Config 3" },
+				{ id: "config4", name: "Config 4" },
+				{ id: "config5", name: "Config 5" },
+				{ id: "config6", name: "Config 6" },
+				{ id: "config7", name: "Config 7" },
+			],
+		}
+		render(<ApiConfigSelector {...props} />)
+
+		const trigger = screen.getByTestId("dropdown-trigger")
+		fireEvent.click(trigger)
+
+		const searchInput = screen.getByPlaceholderText("common:ui.search_placeholder")
+		expect(searchInput).toBeInTheDocument()
+	})
+
+	test("renders info blurb instead of search when 6 or fewer configs", () => {
+		render(<ApiConfigSelector {...defaultProps} />)
+
+		const trigger = screen.getByTestId("dropdown-trigger")
+		fireEvent.click(trigger)
+
+		// Should not have search input
+		expect(screen.queryByPlaceholderText("common:ui.search_placeholder")).not.toBeInTheDocument()
+		// Should have info blurb
+		expect(screen.getByText("prompts:apiConfiguration.select")).toBeInTheDocument()
+	})
+
+	test("filters configs based on search input", async () => {
+		const props = {
+			...defaultProps,
+			listApiConfigMeta: [
+				{ id: "config1", name: "Config 1" },
+				{ id: "config2", name: "Config 2" },
+				{ id: "config3", name: "Config 3" },
+				{ id: "config4", name: "Config 4" },
+				{ id: "config5", name: "Config 5" },
+				{ id: "config6", name: "Config 6" },
+				{ id: "config7", name: "Config 7" },
+			],
+		}
+		render(<ApiConfigSelector {...props} />)
+
+		const trigger = screen.getByTestId("dropdown-trigger")
+		fireEvent.click(trigger)
+
+		const searchInput = screen.getByPlaceholderText("common:ui.search_placeholder")
+		fireEvent.change(searchInput, { target: { value: "Config 2" } })
+
+		// Wait for the filtering to take effect
+		await waitFor(() => {
+			// Config 2 should be visible
+			expect(screen.getByText("Config 2")).toBeInTheDocument()
+			// Config 3 should not be visible (assuming exact match filtering)
+			expect(screen.queryByText("Config 3")).not.toBeInTheDocument()
+		})
+	})
+
+	test("shows no results message when search has no matches", async () => {
+		const props = {
+			...defaultProps,
+			listApiConfigMeta: [
+				{ id: "config1", name: "Config 1" },
+				{ id: "config2", name: "Config 2" },
+				{ id: "config3", name: "Config 3" },
+				{ id: "config4", name: "Config 4" },
+				{ id: "config5", name: "Config 5" },
+				{ id: "config6", name: "Config 6" },
+				{ id: "config7", name: "Config 7" },
+			],
+		}
+		render(<ApiConfigSelector {...props} />)
+
+		const trigger = screen.getByTestId("dropdown-trigger")
+		fireEvent.click(trigger)
+
+		const searchInput = screen.getByPlaceholderText("common:ui.search_placeholder")
+		fireEvent.change(searchInput, { target: { value: "NonExistentConfig" } })
+
+		await waitFor(() => {
+			expect(screen.getByText("common:ui.no_results")).toBeInTheDocument()
+		})
+	})
+
+	test("clears search when X button is clicked", async () => {
+		const props = {
+			...defaultProps,
+			listApiConfigMeta: [
+				{ id: "config1", name: "Config 1" },
+				{ id: "config2", name: "Config 2" },
+				{ id: "config3", name: "Config 3" },
+				{ id: "config4", name: "Config 4" },
+				{ id: "config5", name: "Config 5" },
+				{ id: "config6", name: "Config 6" },
+				{ id: "config7", name: "Config 7" },
+			],
+		}
+		render(<ApiConfigSelector {...props} />)
+
+		const trigger = screen.getByTestId("dropdown-trigger")
+		fireEvent.click(trigger)
+
+		const searchInput = screen.getByPlaceholderText("common:ui.search_placeholder") as HTMLInputElement
+		fireEvent.change(searchInput, { target: { value: "test" } })
+
+		expect(searchInput.value).toBe("test")
+
+		// Find and click the X button
+		const clearButton = screen.getByTestId("popover-content").querySelector(".cursor-pointer")
+		if (clearButton) {
+			fireEvent.click(clearButton)
+		}
+
+		await waitFor(() => {
+			expect(searchInput.value).toBe("")
+		})
+	})
+
+	test("calls onChange when a config is selected", () => {
+		render(<ApiConfigSelector {...defaultProps} />)
+
+		const trigger = screen.getByTestId("dropdown-trigger")
+		fireEvent.click(trigger)
+
+		const config2 = screen.getByText("Config 2")
+		fireEvent.click(config2)
+
+		expect(mockOnChange).toHaveBeenCalledWith("config2")
+	})
+
+	test("shows check mark for selected config", () => {
+		render(<ApiConfigSelector {...defaultProps} />)
+
+		const trigger = screen.getByTestId("dropdown-trigger")
+		fireEvent.click(trigger)
+
+		// The selected config (config1) should have a check mark
+		// Use getAllByText since there might be multiple elements with "Config 1"
+		const config1Elements = screen.getAllByText("Config 1")
+		// Find the one that's in the dropdown content (not the trigger)
+		const configInDropdown = config1Elements.find((el) => el.closest('[data-testid="popover-content"]'))
+		const selectedConfigRow = configInDropdown?.closest("div")
+		const checkIcon = selectedConfigRow?.querySelector(".codicon-check")
+		expect(checkIcon).toBeInTheDocument()
+	})
+
+	test("separates pinned and unpinned configs", () => {
+		const props = {
+			...defaultProps,
+			pinnedApiConfigs: { config1: true, config3: true },
+		}
+
+		render(<ApiConfigSelector {...props} />)
+
+		const trigger = screen.getByTestId("dropdown-trigger")
+		fireEvent.click(trigger)
+
+		const content = screen.getByTestId("popover-content")
+		const configTexts = content.querySelectorAll(".truncate")
+
+		// Pinned configs should appear first
+		expect(configTexts[0]).toHaveTextContent("Config 1")
+		expect(configTexts[1]).toHaveTextContent("Config 3")
+		// Unpinned config should appear after separator
+		expect(configTexts[2]).toHaveTextContent("Config 2")
+	})
+
+	test("toggles pin status when pin button is clicked", () => {
+		render(<ApiConfigSelector {...defaultProps} />)
+
+		const trigger = screen.getByTestId("dropdown-trigger")
+		fireEvent.click(trigger)
+
+		// Find the pin button for Config 2 (unpinned)
+		const config2Row = screen.getByText("Config 2").closest("div")
+		const pinButton = config2Row?.querySelector("button")
+
+		if (pinButton) {
+			fireEvent.click(pinButton)
+		}
+
+		expect(mockTogglePinnedApiConfig).toHaveBeenCalledWith("config2")
+		expect(vi.mocked(vscode.postMessage)).toHaveBeenCalledWith({
+			type: "toggleApiConfigPin",
+			text: "config2",
+		})
+	})
+
+	test("opens settings when edit button is clicked", () => {
+		render(<ApiConfigSelector {...defaultProps} />)
+
+		const trigger = screen.getByTestId("dropdown-trigger")
+		fireEvent.click(trigger)
+
+		// Find the settings button by its icon class within the popover content
+		const popoverContent = screen.getByTestId("popover-content")
+		const settingsButton = popoverContent.querySelector('[aria-label="chat:edit"]') as HTMLElement
+		expect(settingsButton).toBeInTheDocument()
+		fireEvent.click(settingsButton)
+
+		expect(vi.mocked(vscode.postMessage)).toHaveBeenCalledWith({
+			type: "switchTab",
+			tab: "settings",
+		})
+	})
+
+	test("renders bottom bar with title and info icon when more than 6 configs", () => {
+		const props = {
+			...defaultProps,
+			listApiConfigMeta: [
+				{ id: "config1", name: "Config 1" },
+				{ id: "config2", name: "Config 2" },
+				{ id: "config3", name: "Config 3" },
+				{ id: "config4", name: "Config 4" },
+				{ id: "config5", name: "Config 5" },
+				{ id: "config6", name: "Config 6" },
+				{ id: "config7", name: "Config 7" },
+			],
+		}
+		render(<ApiConfigSelector {...props} />)
+
+		const trigger = screen.getByTestId("dropdown-trigger")
+		fireEvent.click(trigger)
+
+		// Check for the title
+		expect(screen.getByText("prompts:apiConfiguration.title")).toBeInTheDocument()
+
+		// Check for the info icon
+		const infoIcon = screen.getByTestId("popover-content").querySelector(".codicon-info")
+		expect(infoIcon).toBeInTheDocument()
+	})
+
+	test("renders bottom bar with title but no info icon when 6 or fewer configs", () => {
+		render(<ApiConfigSelector {...defaultProps} />)
+
+		const trigger = screen.getByTestId("dropdown-trigger")
+		fireEvent.click(trigger)
+
+		// Check for the title
+		expect(screen.getByText("prompts:apiConfiguration.title")).toBeInTheDocument()
+
+		// Check that info icon is not present
+		const infoIcon = screen.getByTestId("popover-content").querySelector(".codicon-info")
+		expect(infoIcon).not.toBeInTheDocument()
+	})
+
+	test("handles empty config list gracefully", () => {
+		const props = {
+			...defaultProps,
+			listApiConfigMeta: [],
+		}
+
+		render(<ApiConfigSelector {...props} />)
+
+		const trigger = screen.getByTestId("dropdown-trigger")
+		fireEvent.click(trigger)
+
+		// Should render info blurb instead of search for empty list
+		expect(screen.queryByPlaceholderText("common:ui.search_placeholder")).not.toBeInTheDocument()
+		expect(screen.getByText("prompts:apiConfiguration.select")).toBeInTheDocument()
+		expect(screen.getByText("prompts:apiConfiguration.title")).toBeInTheDocument()
+	})
+
+	test("maintains search value when pinning/unpinning", async () => {
+		const props = {
+			...defaultProps,
+			listApiConfigMeta: [
+				{ id: "config1", name: "Config 1" },
+				{ id: "config2", name: "Config 2" },
+				{ id: "config3", name: "Config 3" },
+				{ id: "config4", name: "Config 4" },
+				{ id: "config5", name: "Config 5" },
+				{ id: "config6", name: "Config 6" },
+				{ id: "config7", name: "Config 7" },
+			],
+		}
+		render(<ApiConfigSelector {...props} />)
+
+		const trigger = screen.getByTestId("dropdown-trigger")
+		fireEvent.click(trigger)
+
+		const searchInput = screen.getByPlaceholderText("common:ui.search_placeholder") as HTMLInputElement
+		fireEvent.change(searchInput, { target: { value: "Config" } })
+
+		// Pin a config
+		const config2Row = screen.getByText("Config 2").closest("div")
+		const pinButton = config2Row?.querySelector("button")
+		if (pinButton) {
+			fireEvent.click(pinButton)
+		}
+
+		// Search value should be maintained
+		expect(searchInput.value).toBe("Config")
+	})
+})

+ 2 - 1
webview-ui/src/i18n/locales/ca/common.json

@@ -20,7 +20,8 @@
 		"billion_suffix": "b"
 	},
 	"ui": {
-		"search_placeholder": "Cerca..."
+		"search_placeholder": "Cerca...",
+		"no_results": "No s'han trobat resultats"
 	},
 	"mermaid": {
 		"loading": "Generant diagrama mermaid...",

+ 2 - 1
webview-ui/src/i18n/locales/de/common.json

@@ -20,7 +20,8 @@
 		"billion_suffix": "b"
 	},
 	"ui": {
-		"search_placeholder": "Suchen..."
+		"search_placeholder": "Suchen...",
+		"no_results": "Keine Ergebnisse gefunden"
 	},
 	"mermaid": {
 		"loading": "Mermaid-Diagramm wird generiert...",

+ 2 - 1
webview-ui/src/i18n/locales/en/common.json

@@ -20,7 +20,8 @@
 		"billion_suffix": "b"
 	},
 	"ui": {
-		"search_placeholder": "Search..."
+		"search_placeholder": "Search...",
+		"no_results": "No results found"
 	},
 	"mermaid": {
 		"loading": "Generating mermaid diagram...",

+ 2 - 1
webview-ui/src/i18n/locales/es/common.json

@@ -20,7 +20,8 @@
 		"billion_suffix": "b"
 	},
 	"ui": {
-		"search_placeholder": "Buscar..."
+		"search_placeholder": "Buscar...",
+		"no_results": "No se encontraron resultados"
 	},
 	"mermaid": {
 		"loading": "Generando diagrama mermaid...",

+ 2 - 1
webview-ui/src/i18n/locales/fr/common.json

@@ -20,7 +20,8 @@
 		"billion_suffix": "b"
 	},
 	"ui": {
-		"search_placeholder": "Rechercher..."
+		"search_placeholder": "Rechercher...",
+		"no_results": "Aucun résultat trouvé"
 	},
 	"mermaid": {
 		"loading": "Génération du diagramme mermaid...",

+ 2 - 1
webview-ui/src/i18n/locales/hi/common.json

@@ -20,7 +20,8 @@
 		"billion_suffix": "b"
 	},
 	"ui": {
-		"search_placeholder": "खोजें..."
+		"search_placeholder": "खोजें...",
+		"no_results": "कोई परिणाम नहीं मिला"
 	},
 	"mermaid": {
 		"loading": "मरमेड डायग्राम जनरेट हो रहा है...",

+ 2 - 1
webview-ui/src/i18n/locales/id/common.json

@@ -20,7 +20,8 @@
 		"billion_suffix": "m"
 	},
 	"ui": {
-		"search_placeholder": "Cari..."
+		"search_placeholder": "Cari...",
+		"no_results": "Tidak ada hasil yang ditemukan"
 	},
 	"mermaid": {
 		"loading": "Membuat diagram mermaid...",

+ 2 - 1
webview-ui/src/i18n/locales/it/common.json

@@ -20,7 +20,8 @@
 		"billion_suffix": "b"
 	},
 	"ui": {
-		"search_placeholder": "Cerca..."
+		"search_placeholder": "Cerca...",
+		"no_results": "Nessun risultato trovato"
 	},
 	"mermaid": {
 		"loading": "Generazione del diagramma mermaid...",

+ 2 - 1
webview-ui/src/i18n/locales/ja/common.json

@@ -20,7 +20,8 @@
 		"billion_suffix": "b"
 	},
 	"ui": {
-		"search_placeholder": "検索..."
+		"search_placeholder": "検索...",
+		"no_results": "結果が見つかりません"
 	},
 	"mermaid": {
 		"loading": "Mermaidダイアグラムを生成中...",

+ 2 - 1
webview-ui/src/i18n/locales/ko/common.json

@@ -20,7 +20,8 @@
 		"billion_suffix": "b"
 	},
 	"ui": {
-		"search_placeholder": "검색..."
+		"search_placeholder": "검색...",
+		"no_results": "결과를 찾을 수 없습니다"
 	},
 	"mermaid": {
 		"loading": "머메이드 다이어그램 생성 중...",

+ 2 - 1
webview-ui/src/i18n/locales/nl/common.json

@@ -20,7 +20,8 @@
 		"billion_suffix": "mrd"
 	},
 	"ui": {
-		"search_placeholder": "Zoeken..."
+		"search_placeholder": "Zoeken...",
+		"no_results": "Geen resultaten gevonden"
 	},
 	"mermaid": {
 		"loading": "Mermaid-diagram genereren...",

+ 2 - 1
webview-ui/src/i18n/locales/pl/common.json

@@ -20,7 +20,8 @@
 		"keep": "Zachowaj"
 	},
 	"ui": {
-		"search_placeholder": "Szukaj..."
+		"search_placeholder": "Szukaj...",
+		"no_results": "Nie znaleziono wyników"
 	},
 	"mermaid": {
 		"loading": "Generowanie diagramu mermaid...",

+ 2 - 1
webview-ui/src/i18n/locales/pt-BR/common.json

@@ -20,7 +20,8 @@
 		"keep": "Manter"
 	},
 	"ui": {
-		"search_placeholder": "Pesquisar..."
+		"search_placeholder": "Pesquisar...",
+		"no_results": "Nenhum resultado encontrado"
 	},
 	"mermaid": {
 		"loading": "Gerando diagrama mermaid...",

+ 2 - 1
webview-ui/src/i18n/locales/ru/common.json

@@ -20,7 +20,8 @@
 		"keep": "Оставить"
 	},
 	"ui": {
-		"search_placeholder": "Поиск..."
+		"search_placeholder": "Поиск...",
+		"no_results": "Результатов не найдено"
 	},
 	"mermaid": {
 		"loading": "Создание диаграммы mermaid...",

+ 2 - 1
webview-ui/src/i18n/locales/tr/common.json

@@ -20,7 +20,8 @@
 		"billion_suffix": "b"
 	},
 	"ui": {
-		"search_placeholder": "Ara..."
+		"search_placeholder": "Ara...",
+		"no_results": "Sonuç bulunamadı"
 	},
 	"mermaid": {
 		"loading": "Mermaid diyagramı oluşturuluyor...",

+ 2 - 1
webview-ui/src/i18n/locales/vi/common.json

@@ -20,7 +20,8 @@
 		"billion_suffix": "b"
 	},
 	"ui": {
-		"search_placeholder": "Tìm kiếm..."
+		"search_placeholder": "Tìm kiếm...",
+		"no_results": "Không tìm thấy kết quả nào"
 	},
 	"mermaid": {
 		"loading": "Đang tạo biểu đồ mermaid...",

+ 2 - 1
webview-ui/src/i18n/locales/zh-CN/common.json

@@ -20,7 +20,8 @@
 		"billion_suffix": "b"
 	},
 	"ui": {
-		"search_placeholder": "搜索..."
+		"search_placeholder": "搜索...",
+		"no_results": "未找到结果"
 	},
 	"mermaid": {
 		"loading": "生成 Mermaid 图表中...",

+ 2 - 1
webview-ui/src/i18n/locales/zh-TW/common.json

@@ -20,7 +20,8 @@
 		"billion_suffix": "b"
 	},
 	"ui": {
-		"search_placeholder": "搜尋..."
+		"search_placeholder": "搜尋...",
+		"no_results": "找不到結果"
 	},
 	"mermaid": {
 		"loading": "產生 Mermaid 圖表中...",