mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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:
parent
5e218febdb
commit
e68076ebc5
7 changed files with 245 additions and 393 deletions
1
.review/pr-8274
Submodule
1
.review/pr-8274
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit e46929b8d8add0cd3c412d69f8ac882c405a4ba9
|
||||
1
tmp/pr-8287-Roo-Code
Submodule
1
tmp/pr-8287-Roo-Code
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 88a473b017af37091c85ce3056e444e856f80d6e
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue