feat: Allow scheduling model switches during execution

- Modified ApiConfigSelector to remain interactive when disabled
- Added scheduledConfigId and onScheduleChange props
- Implemented visual indicators (clock icons) for scheduled switches
- Added state management in ChatTextArea for tracking scheduled config
- Automatically applies scheduled switch when request completes
- Added comprehensive unit tests for the new functionality
- Added translation keys for new UI elements

Fixes #8334
This commit is contained in:
Roo Code 2025-09-26 18:29:19 +00:00
parent 5e218febdb
commit e68076ebc5
7 changed files with 245 additions and 393 deletions

1
.review/pr-8274 Submodule

@ -0,0 +1 @@
Subproject commit e46929b8d8add0cd3c412d69f8ac882c405a4ba9

1
tmp/pr-8287-Roo-Code Submodule

@ -0,0 +1 @@
Subproject commit 88a473b017af37091c85ce3056e444e856f80d6e

View file

@ -20,6 +20,8 @@ interface ApiConfigSelectorProps {
listApiConfigMeta: Array<{ id: string; name: string; modelId?: string }>
pinnedApiConfigs?: Record<string, boolean>
togglePinnedApiConfig: (id: string) => void
scheduledConfigId?: string
onScheduleChange?: (configId: string | undefined) => void
}
export const ApiConfigSelector = ({
@ -32,6 +34,8 @@ export const ApiConfigSelector = ({
listApiConfigMeta,
pinnedApiConfigs,
togglePinnedApiConfig,
scheduledConfigId,
onScheduleChange,
}: ApiConfigSelectorProps) => {
const { t } = useAppTranslation()
const [open, setOpen] = useState(false)
@ -73,11 +77,23 @@ export const ApiConfigSelector = ({
const handleSelect = useCallback(
(configId: string) => {
onChange(configId)
if (disabled && onScheduleChange) {
// When disabled, schedule the change instead of applying immediately
if (scheduledConfigId === configId) {
// If clicking the same config, cancel the scheduled change
onScheduleChange(undefined)
} else {
// Schedule the new config
onScheduleChange(configId)
}
} else {
// Apply immediately when not disabled
onChange(configId)
}
setOpen(false)
setSearchValue("")
},
[onChange],
[disabled, onChange, onScheduleChange, scheduledConfigId],
)
const handleEditClick = useCallback(() => {
@ -88,6 +104,7 @@ export const ApiConfigSelector = ({
const renderConfigItem = useCallback(
(config: { id: string; name: string; modelId?: string }, isPinned: boolean) => {
const isCurrentConfig = config.id === value
const isScheduledConfig = config.id === scheduledConfigId
return (
<div
@ -98,6 +115,7 @@ export const ApiConfigSelector = ({
"hover:bg-vscode-list-hoverBackground",
isCurrentConfig &&
"bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground",
isScheduledConfig && !isCurrentConfig && "border-l-2 border-vscode-focusBorder",
)}>
<div className="flex-1 min-w-0 flex items-center gap-1 overflow-hidden">
<span className="flex-shrink-0">{config.name}</span>
@ -112,11 +130,18 @@ export const ApiConfigSelector = ({
)}
</div>
<div className="flex items-center gap-1">
{isCurrentConfig && (
{isCurrentConfig && !isScheduledConfig && (
<div className="size-5 p-1 flex items-center justify-center">
<span className="codicon codicon-check text-xs" />
</div>
)}
{isScheduledConfig && (
<StandardTooltip content={disabled ? t("chat:scheduledSwitch") : t("chat:nextModel")}>
<div className="size-5 p-1 flex items-center justify-center">
<span className="codicon codicon-clock text-xs text-vscode-focusBorder" />
</div>
</StandardTooltip>
)}
<StandardTooltip content={isPinned ? t("chat:unpin") : t("chat:pin")}>
<Button
variant="ghost"
@ -138,25 +163,40 @@ export const ApiConfigSelector = ({
</div>
)
},
[value, handleSelect, t, togglePinnedApiConfig],
[value, scheduledConfigId, handleSelect, t, togglePinnedApiConfig, disabled],
)
// Get the scheduled config's display name
const scheduledConfigName = useMemo(() => {
if (!scheduledConfigId) return null
const config = listApiConfigMeta.find((c) => c.id === scheduledConfigId)
return config?.name || null
}, [scheduledConfigId, listApiConfigMeta])
return (
<Popover open={open} onOpenChange={setOpen} data-testid="api-config-selector-root">
<StandardTooltip content={title}>
<StandardTooltip
content={scheduledConfigName && disabled ? `${title} (Scheduled: ${scheduledConfigName})` : title}>
<PopoverTrigger
disabled={disabled}
disabled={false} // Always allow opening the dropdown
data-testid="dropdown-trigger"
className={cn(
"min-w-0 inline-flex items-center 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",
"bg-transparent border 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",
? scheduledConfigId
? "border-vscode-focusBorder opacity-70 cursor-pointer hover:opacity-90"
: "border-[rgba(255,255,255,0.08)] opacity-50 cursor-pointer hover:opacity-70"
: "border-[rgba(255,255,255,0.08)] 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="truncate">{displayName}</span>
<span className="truncate flex items-center gap-1">
{displayName}
{scheduledConfigId && disabled && (
<span className="codicon codicon-clock text-xs text-vscode-focusBorder" />
)}
</span>
</PopoverTrigger>
</StandardTooltip>
<PopoverContent
@ -188,7 +228,11 @@ export const ApiConfigSelector = ({
) : (
<div className="p-3 border-b border-vscode-dropdown-border">
<p className="text-xs text-vscode-descriptionForeground m-0">
{t("prompts:apiConfiguration.select")}
{disabled && scheduledConfigId
? t("prompts:apiConfiguration.selectToCancel")
: disabled
? t("prompts:apiConfiguration.selectToSchedule")
: t("prompts:apiConfiguration.select")}
</p>
</div>
)}

View file

@ -58,6 +58,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
{
inputValue,
setInputValue,
sendingDisabled,
selectApiConfigDisabled,
placeholderText,
selectedImages,
@ -91,6 +92,9 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
cloudUserInfo,
} = useExtensionState()
// State for scheduled model switch
const [scheduledConfigId, setScheduledConfigId] = useState<string | undefined>(undefined)
// Find the ID and display text for the currently selected API configuration.
const { currentConfigId, displayName } = useMemo(() => {
const currentConfig = listApiConfigMeta?.find((config) => config.name === currentApiConfigName)
@ -907,6 +911,21 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
vscode.postMessage({ type: "loadApiConfigurationById", text: value })
}, [])
// Handle scheduled config change
const handleScheduledConfigChange = useCallback((configId: string | undefined) => {
setScheduledConfigId(configId)
// We'll apply the change when sendingDisabled becomes false
}, [])
// Effect to apply scheduled config change when sending is re-enabled
useEffect(() => {
if (!sendingDisabled && scheduledConfigId) {
// Apply the scheduled change
handleApiConfigChange(scheduledConfigId)
setScheduledConfigId(undefined)
}
}, [sendingDisabled, scheduledConfigId, handleApiConfigChange])
return (
<div
className={cn(
@ -1231,6 +1250,8 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
listApiConfigMeta={listApiConfigMeta || []}
pinnedApiConfigs={pinnedApiConfigs}
togglePinnedApiConfig={togglePinnedApiConfig}
scheduledConfigId={scheduledConfigId}
onScheduleChange={handleScheduledConfigChange}
/>
<AutoApproveDropdown triggerClassName="min-w-[28px] text-ellipsis overflow-hidden flex-shrink" />
</div>

View file

@ -1,438 +1,219 @@
import { render, screen, fireEvent, waitFor } from "@/utils/test-utils"
import { vscode } from "@/utils/vscode"
import React from "react"
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
import { vi } from "vitest"
import { ApiConfigSelector } from "../ApiConfigSelector"
import { TooltipProvider } from "../../ui/tooltip"
// Mock the dependencies
// Mock the vscode module
vi.mock("@/utils/vscode", () => ({
vscode: {
postMessage: vi.fn(),
},
}))
// Mock the translation hook
vi.mock("@/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock the portal hook
vi.mock("@/components/ui/hooks/useRooPortal", () => ({
useRooPortal: () => document.body,
}))
// Mock the ExtensionStateContext
vi.mock("@/context/ExtensionStateContext", () => ({
useExtensionState: () => ({
apiConfiguration: {
apiProvider: "anthropic",
apiModelId: "claude-3-opus-20240229",
},
}),
}))
// Mock the getModelId function from @roo-code/types
vi.mock("@roo-code/types", () => ({
getModelId: (config: any) => config?.apiModelId || undefined,
}))
// 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",
title: "API Config",
onChange: mockOnChange,
disabled: false,
title: "Select API Config",
onChange: vi.fn(),
listApiConfigMeta: [
{ id: "config1", name: "Config 1", modelId: "claude-3-opus-20240229" },
{ id: "config2", name: "Config 2", modelId: "gpt-4" },
{ id: "config3", name: "Config 3", modelId: "claude-3-sonnet-20240229" },
{ id: "config1", name: "Config 1", modelId: "model1" },
{ id: "config2", name: "Config 2", modelId: "model2" },
{ id: "config3", name: "Config 3", modelId: "model3" },
],
pinnedApiConfigs: { config1: true },
togglePinnedApiConfig: mockTogglePinnedApiConfig,
pinnedApiConfigs: {},
togglePinnedApiConfig: vi.fn(),
}
// Helper function to render with TooltipProvider
const renderWithTooltip = (ui: React.ReactElement) => {
return render(<TooltipProvider>{ui}</TooltipProvider>)
}
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")
it("renders the selector with current config", () => {
renderWithTooltip(<ApiConfigSelector {...defaultProps} />)
expect(screen.getByText("Config 1")).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} />)
it("opens dropdown when clicked", async () => {
renderWithTooltip(<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", modelId: "claude-3-opus-20240229" },
{ id: "config2", name: "Config 2", modelId: "gpt-4" },
{ id: "config3", name: "Config 3", modelId: "claude-3-sonnet-20240229" },
{ id: "config4", name: "Config 4", modelId: "gpt-3.5-turbo" },
{ id: "config5", name: "Config 5", modelId: "claude-3-haiku-20240307" },
{ id: "config6", name: "Config 6", modelId: "gpt-4-turbo" },
{ id: "config7", name: "Config 7", modelId: "claude-2.1" },
],
}
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", modelId: "claude-3-opus-20240229" },
{ id: "config2", name: "Config 2", modelId: "gpt-4" },
{ id: "config3", name: "Config 3", modelId: "claude-3-sonnet-20240229" },
{ id: "config4", name: "Config 4", modelId: "gpt-3.5-turbo" },
{ id: "config5", name: "Config 5", modelId: "claude-3-haiku-20240307" },
{ id: "config6", name: "Config 6", modelId: "gpt-4-turbo" },
{ id: "config7", name: "Config 7", modelId: "claude-2.1" },
],
}
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()
expect(screen.getByText("Config 3")).toBeInTheDocument()
})
})
test("shows no results message when search has no matches", async () => {
const props = {
...defaultProps,
listApiConfigMeta: [
{ id: "config1", name: "Config 1", modelId: "claude-3-opus-20240229" },
{ id: "config2", name: "Config 2", modelId: "gpt-4" },
{ id: "config3", name: "Config 3", modelId: "claude-3-sonnet-20240229" },
{ id: "config4", name: "Config 4", modelId: "gpt-3.5-turbo" },
{ id: "config5", name: "Config 5", modelId: "claude-3-haiku-20240307" },
{ id: "config6", name: "Config 6", modelId: "gpt-4-turbo" },
{ id: "config7", name: "Config 7", modelId: "claude-2.1" },
],
}
render(<ApiConfigSelector {...props} />)
it("calls onChange when a config is selected while enabled", async () => {
renderWithTooltip(<ApiConfigSelector {...defaultProps} />)
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()
const config2 = screen.getByText("Config 2")
fireEvent.click(config2)
})
expect(defaultProps.onChange).toHaveBeenCalledWith("config2")
})
describe("Scheduled Model Switch", () => {
it("allows scheduling a model switch when disabled", async () => {
const onScheduleChange = vi.fn()
renderWithTooltip(
<ApiConfigSelector {...defaultProps} disabled={true} onScheduleChange={onScheduleChange} />,
)
// Should still be able to open dropdown when disabled
const trigger = screen.getByTestId("dropdown-trigger")
fireEvent.click(trigger)
await waitFor(() => {
const config2 = screen.getByText("Config 2")
fireEvent.click(config2)
})
// Should call onScheduleChange instead of onChange
expect(onScheduleChange).toHaveBeenCalledWith("config2")
expect(defaultProps.onChange).not.toHaveBeenCalled()
})
it("shows scheduled config indicator", () => {
renderWithTooltip(
<ApiConfigSelector
{...defaultProps}
disabled={true}
scheduledConfigId="config2"
onScheduleChange={vi.fn()}
/>,
)
// Should show clock icon when a config is scheduled
const clockIcon = document.querySelector(".codicon-clock")
expect(clockIcon).toBeInTheDocument()
})
it("cancels scheduled switch when clicking the same config", async () => {
const onScheduleChange = vi.fn()
renderWithTooltip(
<ApiConfigSelector
{...defaultProps}
disabled={true}
scheduledConfigId="config2"
onScheduleChange={onScheduleChange}
/>,
)
const trigger = screen.getByTestId("dropdown-trigger")
fireEvent.click(trigger)
await waitFor(() => {
const config2 = screen.getByText("Config 2")
fireEvent.click(config2)
})
// Should cancel the scheduled change
expect(onScheduleChange).toHaveBeenCalledWith(undefined)
})
it("shows scheduled config in dropdown with indicator", async () => {
renderWithTooltip(
<ApiConfigSelector
{...defaultProps}
disabled={true}
scheduledConfigId="config2"
onScheduleChange={vi.fn()}
/>,
)
const trigger = screen.getByTestId("dropdown-trigger")
fireEvent.click(trigger)
await waitFor(() => {
// Find the config2 item's container - look for the parent that contains both text and icon
const config2Items = screen.getAllByText("Config 2")
// Find the one that's in the dropdown (not the trigger)
const dropdownConfig2 = config2Items.find((item) => item.closest('[role="dialog"]'))
expect(dropdownConfig2).toBeTruthy()
// Check if the parent container has the scheduled border style
const configContainer = dropdownConfig2?.closest(".border-l-2")
expect(configContainer).toBeInTheDocument()
})
})
it("updates tooltip when config is scheduled", () => {
renderWithTooltip(
<ApiConfigSelector
{...defaultProps}
disabled={true}
scheduledConfigId="config2"
onScheduleChange={vi.fn()}
/>,
)
// When scheduled, the trigger should show a clock icon
const trigger = screen.getByTestId("dropdown-trigger")
const clockIcon = trigger.querySelector(".codicon-clock")
expect(clockIcon).toBeInTheDocument()
})
})
test("clears search when X button is clicked", async () => {
const props = {
...defaultProps,
listApiConfigMeta: [
{ id: "config1", name: "Config 1", modelId: "claude-3-opus-20240229" },
{ id: "config2", name: "Config 2", modelId: "gpt-4" },
{ id: "config3", name: "Config 3", modelId: "claude-3-sonnet-20240229" },
{ id: "config4", name: "Config 4", modelId: "gpt-3.5-turbo" },
{ id: "config5", name: "Config 5", modelId: "claude-3-haiku-20240307" },
{ id: "config6", name: "Config 6", modelId: "gpt-4-turbo" },
{ id: "config7", name: "Config 7", modelId: "claude-2.1" },
],
}
render(<ApiConfigSelector {...props} />)
describe("Pinned Configs", () => {
it("shows pinned configs at the top", async () => {
renderWithTooltip(<ApiConfigSelector {...defaultProps} pinnedApiConfigs={{ config2: true }} />)
const trigger = screen.getByTestId("dropdown-trigger")
fireEvent.click(trigger)
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" } })
await waitFor(() => {
// Find all config items in the dropdown
const dropdownContent = screen.getByRole("dialog")
const configItems = dropdownContent.querySelectorAll(".px-3.py-1\\.5")
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"]'))
// Navigate up to find the parent row that contains both the text and the check icon
const selectedConfigRow = configInDropdown?.closest(".group")
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")
// Get all config items by looking for the group class
const configRows = content.querySelectorAll(".group")
// Extract the config names from each row
const configNames: string[] = []
configRows.forEach((row) => {
// Find the first span that's flex-shrink-0 (the profile name)
const nameElement = row.querySelector(".flex-1 span.flex-shrink-0")
if (nameElement?.textContent) {
configNames.push(nameElement.textContent)
}
// First item should be Config 2 (pinned)
expect(configItems[0]).toHaveTextContent("Config 2")
// Check that it has the pinned button visible
const pinnedButton = configItems[0].querySelector(".bg-accent")
expect(pinnedButton).toBeInTheDocument()
})
})
// Pinned configs should appear first
expect(configNames[0]).toBe("Config 1")
expect(configNames[1]).toBe("Config 3")
// Unpinned config should appear after separator
expect(configNames[2]).toBe("Config 2")
})
it("toggles pin status when pin button is clicked", async () => {
const togglePin = vi.fn()
renderWithTooltip(<ApiConfigSelector {...defaultProps} togglePinnedApiConfig={togglePin} />)
test("toggles pin status when pin button is clicked", () => {
render(<ApiConfigSelector {...defaultProps} />)
const trigger = screen.getByTestId("dropdown-trigger")
fireEvent.click(trigger)
const trigger = screen.getByTestId("dropdown-trigger")
fireEvent.click(trigger)
// Find the pin button for Config 2 (unpinned)
const config2Row = screen.getByText("Config 2").closest(".group")
// Find the button with the pin icon (it's the second button, first is the row itself)
const buttons = config2Row?.querySelectorAll("button")
const pinButton = Array.from(buttons || []).find((btn) => btn.querySelector(".codicon-pin"))
if (pinButton) {
fireEvent.click(pinButton)
}
expect(mockTogglePinnedApiConfig).toHaveBeenCalledWith("config2")
expect(vi.mocked(vscode.postMessage)).toHaveBeenCalledWith({
type: "toggleApiConfigPin",
text: "config2",
await waitFor(() => {
// Find a pin button and click it
const pinButtons = screen.getAllByRole("button").filter((btn) => btn.querySelector(".codicon-pin"))
if (pinButtons.length > 0) {
fireEvent.click(pinButtons[0])
expect(togglePin).toHaveBeenCalled()
}
})
})
})
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", modelId: "claude-3-opus-20240229" },
{ id: "config2", name: "Config 2", modelId: "gpt-4" },
{ id: "config3", name: "Config 3", modelId: "claude-3-sonnet-20240229" },
{ id: "config4", name: "Config 4", modelId: "gpt-3.5-turbo" },
{ id: "config5", name: "Config 5", modelId: "claude-3-haiku-20240307" },
{ id: "config6", name: "Config 6", modelId: "gpt-4-turbo" },
{ id: "config7", name: "Config 7", modelId: "claude-2.1" },
],
}
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", modelId: "claude-3-opus-20240229" },
{ id: "config2", name: "Config 2", modelId: "gpt-4" },
{ id: "config3", name: "Config 3", modelId: "claude-3-sonnet-20240229" },
{ id: "config4", name: "Config 4", modelId: "gpt-3.5-turbo" },
{ id: "config5", name: "Config 5", modelId: "claude-3-haiku-20240307" },
{ id: "config6", name: "Config 6", modelId: "gpt-4-turbo" },
{ id: "config7", name: "Config 7", modelId: "claude-2.1" },
],
}
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")
})
})

View file

@ -118,6 +118,8 @@
},
"selectMode": "Select mode for interaction",
"selectApiConfig": "Select API configuration",
"scheduledSwitch": "Scheduled for next request",
"nextModel": "Next model",
"enhancePrompt": "Enhance prompt with additional context",
"modeSelector": {
"title": "Modes",

View file

@ -14,7 +14,9 @@
},
"apiConfiguration": {
"title": "API Configuration",
"select": "Select which API configuration to use for this mode"
"select": "Select which API configuration to use for this mode",
"selectToSchedule": "Select a model to schedule for the next request",
"selectToCancel": "Select the same model again to cancel the scheduled switch"
},
"tools": {
"title": "Available Tools",