Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/types/src/provider-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export const providerSettingsEntrySchema = z.object({
id: z.string(),
name: z.string(),
apiProvider: providerNamesSchema.optional(),
modelId: z.string().optional(),
})

export type ProviderSettingsEntry = z.infer<typeof providerSettingsEntrySchema>
Expand Down
16 changes: 16 additions & 0 deletions src/core/config/ProviderSettingsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
isSecretStateKey,
ProviderSettingsEntry,
DEFAULT_CONSECUTIVE_MISTAKE_LIMIT,
getModelId,
} from "@roo-code/types"
import { TelemetryService } from "@roo-code/telemetry"

Expand Down Expand Up @@ -274,6 +275,20 @@ export class ProviderSettingsManager {
}
}

/**
* Clean model ID by removing prefix before "/"
*/
private cleanModelId(modelId: string | undefined): string | undefined {
if (!modelId) return undefined

// Check for "/" and take the part after it
if (modelId.includes("/")) {
return modelId.split("/").pop()
}

return modelId
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this intentional to duplicate the model ID cleaning logic here? Consider moving the cleanModelId() helper to a shared utility location (perhaps in packages/types/src/provider-settings.ts near the existing getModelId() function) since this logic might be useful elsewhere in the codebase.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not duplicated


/**
* List all available configs with metadata.
*/
Expand All @@ -286,6 +301,7 @@ export class ProviderSettingsManager {
name,
id: apiConfig.id || "",
apiProvider: apiConfig.apiProvider,
modelId: this.cleanModelId(getModelId(apiConfig)),
}))
})
} catch (error) {
Expand Down
18 changes: 15 additions & 3 deletions webview-ui/src/components/chat/ApiConfigSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ interface ApiConfigSelectorProps {
title: string
onChange: (value: string) => void
triggerClassName?: string
listApiConfigMeta: Array<{ id: string; name: string }>
listApiConfigMeta: Array<{ id: string; name: string; modelId?: string }>
pinnedApiConfigs?: Record<string, boolean>
togglePinnedApiConfig: (id: string) => void
}
Expand Down Expand Up @@ -87,7 +87,7 @@ export const ApiConfigSelector = ({
}, [])

const renderConfigItem = useCallback(
(config: { id: string; name: string }, isPinned: boolean) => {
(config: { id: string; name: string; modelId?: string }, isPinned: boolean) => {
const isCurrentConfig = config.id === value

return (
Expand All @@ -100,7 +100,19 @@ export const ApiConfigSelector = ({
isCurrentConfig &&
"bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground",
)}>
<span className="flex-1 truncate">{config.name}</span>
<div className="flex-1 min-w-0 flex items-center gap-1 overflow-hidden">
<span className="flex-shrink-0">{config.name}</span>
{config.modelId && (
<>
<span className="text-vscode-descriptionForeground opacity-70 flex-shrink-0">·</span>
<span
className="text-vscode-descriptionForeground opacity-70 min-w-0 overflow-hidden"
style={{ direction: "rtl", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{config.modelId}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice implementation of the smart truncation! The visual hierarchy is clear with the opacity and separator. For future consideration: if this pattern of displaying model IDs with profile names is needed elsewhere, this could be extracted into a small reusable component like .

</span>
</>
)}
</div>
<div className="flex items-center gap-1">
{isCurrentConfig && (
<div className="size-5 p-1 flex items-center justify-center">
Expand Down
133 changes: 81 additions & 52 deletions webview-ui/src/components/chat/__tests__/ApiConfigSelector.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,21 @@ 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) => (
Expand Down Expand Up @@ -52,9 +67,9 @@ describe("ApiConfigSelector", () => {
title: "API Config",
onChange: mockOnChange,
listApiConfigMeta: [
{ id: "config1", name: "Config 1" },
{ id: "config2", name: "Config 2" },
{ id: "config3", name: "Config 3" },
{ 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" },
],
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great test coverage! Consider adding one more test case specifically for profiles without model IDs to ensure the component handles undefined modelId gracefully (though the code already handles this well with optional chaining).

pinnedApiConfigs: { config1: true },
togglePinnedApiConfig: mockTogglePinnedApiConfig,
Expand Down Expand Up @@ -120,13 +135,13 @@ describe("ApiConfigSelector", () => {
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" },
{ 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} />)
Expand Down Expand Up @@ -154,13 +169,13 @@ describe("ApiConfigSelector", () => {
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" },
{ 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} />)
Expand All @@ -184,13 +199,13 @@ describe("ApiConfigSelector", () => {
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" },
{ 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} />)
Expand All @@ -210,13 +225,13 @@ describe("ApiConfigSelector", () => {
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" },
{ 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} />)
Expand Down Expand Up @@ -263,7 +278,8 @@ describe("ApiConfigSelector", () => {
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")
// 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()
})
Expand All @@ -280,13 +296,24 @@ describe("ApiConfigSelector", () => {
fireEvent.click(trigger)

const content = screen.getByTestId("popover-content")
const configTexts = content.querySelectorAll(".truncate")
// 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)
}
})

// Pinned configs should appear first
expect(configTexts[0]).toHaveTextContent("Config 1")
expect(configTexts[1]).toHaveTextContent("Config 3")
expect(configNames[0]).toBe("Config 1")
expect(configNames[1]).toBe("Config 3")
// Unpinned config should appear after separator
expect(configTexts[2]).toHaveTextContent("Config 2")
expect(configNames[2]).toBe("Config 2")
})

test("toggles pin status when pin button is clicked", () => {
Expand All @@ -296,8 +323,10 @@ describe("ApiConfigSelector", () => {
fireEvent.click(trigger)

// Find the pin button for Config 2 (unpinned)
const config2Row = screen.getByText("Config 2").closest("div")
const pinButton = config2Row?.querySelector("button")
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)
Expand Down Expand Up @@ -332,13 +361,13 @@ describe("ApiConfigSelector", () => {
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" },
{ 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} />)
Expand Down Expand Up @@ -389,13 +418,13 @@ describe("ApiConfigSelector", () => {
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" },
{ 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} />)
Expand Down
Loading