mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-06 02:47:56 +00:00
Co-authored-by: Daniel Riccio <ricciodaniel98@gmail.com>
This commit is contained in:
parent
8625b451f4
commit
7a6e852248
21 changed files with 705 additions and 138 deletions
242
webview-ui/src/components/chat/ApiConfigSelector.tsx
Normal file
242
webview-ui/src/components/chat/ApiConfigSelector.tsx
Normal file
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
3
webview-ui/src/i18n/locales/ca/common.json
generated
3
webview-ui/src/i18n/locales/ca/common.json
generated
|
|
@ -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...",
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/de/common.json
generated
3
webview-ui/src/i18n/locales/de/common.json
generated
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@
|
|||
"billion_suffix": "b"
|
||||
},
|
||||
"ui": {
|
||||
"search_placeholder": "Search..."
|
||||
"search_placeholder": "Search...",
|
||||
"no_results": "No results found"
|
||||
},
|
||||
"mermaid": {
|
||||
"loading": "Generating mermaid diagram...",
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/es/common.json
generated
3
webview-ui/src/i18n/locales/es/common.json
generated
|
|
@ -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...",
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/fr/common.json
generated
3
webview-ui/src/i18n/locales/fr/common.json
generated
|
|
@ -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...",
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/hi/common.json
generated
3
webview-ui/src/i18n/locales/hi/common.json
generated
|
|
@ -20,7 +20,8 @@
|
|||
"billion_suffix": "b"
|
||||
},
|
||||
"ui": {
|
||||
"search_placeholder": "खोजें..."
|
||||
"search_placeholder": "खोजें...",
|
||||
"no_results": "कोई परिणाम नहीं मिला"
|
||||
},
|
||||
"mermaid": {
|
||||
"loading": "मरमेड डायग्राम जनरेट हो रहा है...",
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/id/common.json
generated
3
webview-ui/src/i18n/locales/id/common.json
generated
|
|
@ -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...",
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/it/common.json
generated
3
webview-ui/src/i18n/locales/it/common.json
generated
|
|
@ -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...",
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/ja/common.json
generated
3
webview-ui/src/i18n/locales/ja/common.json
generated
|
|
@ -20,7 +20,8 @@
|
|||
"billion_suffix": "b"
|
||||
},
|
||||
"ui": {
|
||||
"search_placeholder": "検索..."
|
||||
"search_placeholder": "検索...",
|
||||
"no_results": "結果が見つかりません"
|
||||
},
|
||||
"mermaid": {
|
||||
"loading": "Mermaidダイアグラムを生成中...",
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/ko/common.json
generated
3
webview-ui/src/i18n/locales/ko/common.json
generated
|
|
@ -20,7 +20,8 @@
|
|||
"billion_suffix": "b"
|
||||
},
|
||||
"ui": {
|
||||
"search_placeholder": "검색..."
|
||||
"search_placeholder": "검색...",
|
||||
"no_results": "결과를 찾을 수 없습니다"
|
||||
},
|
||||
"mermaid": {
|
||||
"loading": "머메이드 다이어그램 생성 중...",
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/nl/common.json
generated
3
webview-ui/src/i18n/locales/nl/common.json
generated
|
|
@ -20,7 +20,8 @@
|
|||
"billion_suffix": "mrd"
|
||||
},
|
||||
"ui": {
|
||||
"search_placeholder": "Zoeken..."
|
||||
"search_placeholder": "Zoeken...",
|
||||
"no_results": "Geen resultaten gevonden"
|
||||
},
|
||||
"mermaid": {
|
||||
"loading": "Mermaid-diagram genereren...",
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/pl/common.json
generated
3
webview-ui/src/i18n/locales/pl/common.json
generated
|
|
@ -20,7 +20,8 @@
|
|||
"keep": "Zachowaj"
|
||||
},
|
||||
"ui": {
|
||||
"search_placeholder": "Szukaj..."
|
||||
"search_placeholder": "Szukaj...",
|
||||
"no_results": "Nie znaleziono wyników"
|
||||
},
|
||||
"mermaid": {
|
||||
"loading": "Generowanie diagramu mermaid...",
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/pt-BR/common.json
generated
3
webview-ui/src/i18n/locales/pt-BR/common.json
generated
|
|
@ -20,7 +20,8 @@
|
|||
"keep": "Manter"
|
||||
},
|
||||
"ui": {
|
||||
"search_placeholder": "Pesquisar..."
|
||||
"search_placeholder": "Pesquisar...",
|
||||
"no_results": "Nenhum resultado encontrado"
|
||||
},
|
||||
"mermaid": {
|
||||
"loading": "Gerando diagrama mermaid...",
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/ru/common.json
generated
3
webview-ui/src/i18n/locales/ru/common.json
generated
|
|
@ -20,7 +20,8 @@
|
|||
"keep": "Оставить"
|
||||
},
|
||||
"ui": {
|
||||
"search_placeholder": "Поиск..."
|
||||
"search_placeholder": "Поиск...",
|
||||
"no_results": "Результатов не найдено"
|
||||
},
|
||||
"mermaid": {
|
||||
"loading": "Создание диаграммы mermaid...",
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/tr/common.json
generated
3
webview-ui/src/i18n/locales/tr/common.json
generated
|
|
@ -20,7 +20,8 @@
|
|||
"billion_suffix": "b"
|
||||
},
|
||||
"ui": {
|
||||
"search_placeholder": "Ara..."
|
||||
"search_placeholder": "Ara...",
|
||||
"no_results": "Sonuç bulunamadı"
|
||||
},
|
||||
"mermaid": {
|
||||
"loading": "Mermaid diyagramı oluşturuluyor...",
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/vi/common.json
generated
3
webview-ui/src/i18n/locales/vi/common.json
generated
|
|
@ -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...",
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/zh-CN/common.json
generated
3
webview-ui/src/i18n/locales/zh-CN/common.json
generated
|
|
@ -20,7 +20,8 @@
|
|||
"billion_suffix": "b"
|
||||
},
|
||||
"ui": {
|
||||
"search_placeholder": "搜索..."
|
||||
"search_placeholder": "搜索...",
|
||||
"no_results": "未找到结果"
|
||||
},
|
||||
"mermaid": {
|
||||
"loading": "生成 Mermaid 图表中...",
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/zh-TW/common.json
generated
3
webview-ui/src/i18n/locales/zh-TW/common.json
generated
|
|
@ -20,7 +20,8 @@
|
|||
"billion_suffix": "b"
|
||||
},
|
||||
"ui": {
|
||||
"search_placeholder": "搜尋..."
|
||||
"search_placeholder": "搜尋...",
|
||||
"no_results": "找不到結果"
|
||||
},
|
||||
"mermaid": {
|
||||
"loading": "產生 Mermaid 圖表中...",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue