Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
10 changes: 10 additions & 0 deletions webview-ui/src/components/chat/ChatTextArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import Thumbnails from "../common/Thumbnails"
import { ModeSelector } from "./ModeSelector"
import { ApiConfigSelector } from "./ApiConfigSelector"
import { ModelSelector } from "./ModelSelector"
import { AutoApproveDropdown } from "./AutoApproveDropdown"
import { MAX_IMAGES_PER_MESSAGE } from "./constants"
import ContextMenu from "./ContextMenu"
Expand Down Expand Up @@ -87,6 +88,7 @@
const {
filePaths,
openedTabs,
apiConfiguration,
currentApiConfigName,
listApiConfigMeta,
customModes,
Expand All @@ -99,6 +101,7 @@
commands,
enterBehavior,
lockApiConfigAcrossModes,
organizationAllowList,
} = useExtensionState()

// Find the ID and display text for the currently selected API configuration.
Expand Down Expand Up @@ -1311,6 +1314,13 @@
lockApiConfigAcrossModes={!!lockApiConfigAcrossModes}
onToggleLockApiConfig={handleToggleLockApiConfig}
/>
<ModelSelector
apiConfiguration={apiConfiguration}
currentApiConfigName={currentApiConfigName}
title={t("chat:selectModel")}

Check failure on line 1320 in webview-ui/src/components/chat/ChatTextArea.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test gap

Survived StringLiteral mutant (replacement: ""). See the job summary for the complete list and resolution guidance.
triggerClassName="min-w-[28px] text-ellipsis overflow-hidden flex-shrink min-[310px]:overflow-visible min-[310px]:flex-shrink-0"
organizationAllowList={organizationAllowList}
/>
<AutoApproveDropdown triggerClassName="min-w-[28px] text-ellipsis overflow-hidden flex-shrink min-[310px]:overflow-visible min-[310px]:flex-shrink-0" />
</div>
<div className={cn("flex flex-shrink-0 items-center gap-0.5 h-5 leading-none pr-2")}>
Expand Down
262 changes: 262 additions & 0 deletions webview-ui/src/components/chat/ModelSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
import { useState, useMemo, useCallback } from "react"
import { Fzf } from "fzf"

import {
type ModelInfo,
type ModelRecord,
type OrganizationAllowList,
type ProviderSettings,
isDynamicProvider,
isRetiredProvider,
providerIdentifiers,
} from "@roo-code/types"

import { cn } from "@/lib/utils"
import { enabledSelectorTriggerClassName, selectorTriggerClassName } from "@/components/ui/selectorTriggerStyles"
import { useRooPortal } from "@/components/ui/hooks/useRooPortal"
import { useRouterModels } from "@/components/ui/hooks/useRouterModels"
import { useSelectedModel } from "@/components/ui/hooks/useSelectedModel"
import { Popover, PopoverContent, PopoverTrigger, StandardTooltip } from "@/components/ui"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { vscode } from "@/utils/vscode"

import { filterModels } from "../settings/utils/organizationFilters"
import {
getProviderModelConfig,
getStaticModelsForProvider,
isStaticModelProvider,
} from "../settings/utils/providerModelConfig"

const SEARCH_THRESHOLD = 6

interface ModelSelectorProps {
apiConfiguration: ProviderSettings
currentApiConfigName?: string
disabled?: boolean
title: string
triggerClassName?: string
organizationAllowList?: OrganizationAllowList
}

export const ModelSelector = ({
apiConfiguration,
currentApiConfigName,
disabled = false,
title,
triggerClassName = "",

Check failure on line 46 in webview-ui/src/components/chat/ModelSelector.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test gap

Survived StringLiteral mutant (replacement: "Stryker was here!"). See the job summary for the complete list and resolution guidance.
organizationAllowList,
}: ModelSelectorProps) => {
const { t } = useAppTranslation()
const [open, setOpen] = useState(false)
const [searchValue, setSearchValue] = useState("")
const portalContainer = useRooPortal("roo-portal")

Check failure on line 52 in webview-ui/src/components/chat/ModelSelector.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test gap

Survived StringLiteral mutant (replacement: ""). See the job summary for the complete list and resolution guidance.

const rawProvider = apiConfiguration?.apiProvider || providerIdentifiers.openrouter
const retired = isRetiredProvider(rawProvider)
const provider = retired ? providerIdentifiers.openrouter : rawProvider
const dynamicProvider = !retired && isDynamicProvider(provider) ? provider : undefined
const modelConfig = retired ? undefined : getProviderModelConfig(provider, apiConfiguration)

const routerModels = useRouterModels({ provider: dynamicProvider, enabled: !!dynamicProvider })

Check failure on line 60 in webview-ui/src/components/chat/ModelSelector.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test gap

Survived ObjectLiteral mutant (replacement: {}). See the job summary for the complete list and resolution guidance.
const { id: selectedModelId, info: selectedModelInfo, isLoading } = useSelectedModel(apiConfiguration)

const models: ModelRecord = useMemo(() => {
if (!modelConfig) {

Check failure on line 64 in webview-ui/src/components/chat/ModelSelector.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test gap

NoCoverage BlockStatement mutant (replacement: {}). See the job summary for the complete list and resolution guidance.
return {}
}

let resolved: ModelRecord

if (dynamicProvider) {
resolved = routerModels.data?.[dynamicProvider] ?? {}
} else if (isStaticModelProvider(provider)) {

Check failure on line 72 in webview-ui/src/components/chat/ModelSelector.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test gap

Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
const staticModels = getStaticModelsForProvider(provider, undefined, apiConfiguration)
const { "custom-arn": _customArn, ...rest } = staticModels
resolved = rest
} else {

Check failure on line 76 in webview-ui/src/components/chat/ModelSelector.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test gap

Survived BlockStatement mutant (replacement: {}). See the job summary for the complete list and resolution guidance.
resolved = {}
}

// Apply the organization allowlist so the inline selector never exposes
// or activates models the organization has not approved. Mirrors the
// filtering performed by ModelPicker in the settings view.
return filterModels(resolved, provider, organizationAllowList) ?? {}
}, [modelConfig, dynamicProvider, routerModels.data, provider, apiConfiguration, organizationAllowList])

Check failure on line 84 in webview-ui/src/components/chat/ModelSelector.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test gap

Survived ArrayDeclaration mutant (replacement: []). See the job summary for the complete list and resolution guidance.

const modelIds = useMemo(() => Object.keys(models), [models])

const isSupported = !!modelConfig && modelIds.length > 0
const isDisabled = disabled || !isSupported

// Label shown for a model — prefers `ModelInfo.displayName` when present, falling back to
// the raw model id (mirrors ModelPicker.tsx's trigger/list label logic).
const getModelLabel = useCallback((modelId: string, info?: ModelInfo) => info?.displayName ?? modelId, [])

const selectedModelLabel = getModelLabel(selectedModelId, selectedModelInfo)

// Create searchable items for fuzzy search.
const searchableItems = useMemo(
() =>
modelIds.map((id) => {
const label = getModelLabel(id, models[id])
return { original: id, searchStr: label === id ? id : `${label} ${id}` }
}),
[modelIds, models, getModelLabel],
)

const fzfInstance = useMemo(
() => new Fzf(searchableItems, { selector: (item) => item.searchStr }),
[searchableItems],
)

const filteredModelIds = useMemo(() => {
if (!searchValue) {
return modelIds
}

return fzfInstance.find(searchValue).map((result) => result.item.original)
}, [modelIds, searchValue, fzfInstance])

const handleEditClick = useCallback(() => {
vscode.postMessage({ type: "switchTab", tab: "settings" })
setOpen(false)
}, [])

const handleSelect = useCallback(
(modelId: string) => {
if (!modelConfig) {
return
}

const updated: ProviderSettings = {
...apiConfiguration,
reasoningEffort: undefined,
modelMaxTokens: undefined,
modelMaxThinkingTokens: undefined,
}
;(updated as Record<string, unknown>)[modelConfig.field] = modelId

vscode.postMessage({
type: "upsertApiConfiguration",
text: currentApiConfigName,
apiConfiguration: updated,
})

setOpen(false)
setSearchValue("")
},
[apiConfiguration, modelConfig, currentApiConfigName],
)

const renderModelItem = useCallback(
(modelId: string) => {
const isCurrentModel = modelId === selectedModelId
const label = getModelLabel(modelId, models[modelId])

return (
<button
key={modelId}
type="button"
role="option"
aria-selected={isCurrentModel}
onClick={() => handleSelect(modelId)}
className={cn(
"w-full text-left px-3 py-1.5 text-sm cursor-pointer flex items-center group",
"hover:bg-vscode-list-hoverBackground focus-visible:outline-0 focus-visible:bg-vscode-list-hoverBackground",
isCurrentModel &&
"bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground",
)}>
<span className="flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">{label}</span>
{isCurrentModel && (
<span className="size-5 p-1 flex items-center justify-center">
<span className="codicon codicon-check text-xs" />
</span>
)}
</button>
)
},
[selectedModelId, models, getModelLabel, handleSelect],
)

// While a dynamic provider's model list is still loading, keep the trigger
// visible (showing the loading label) instead of falling back to the
// unsupported-provider shortcut. Only show the unsupported fallback once we
// know the provider genuinely has no selectable models.
if (!isSupported && !isLoading) {
return (
<StandardTooltip content={t("chat:selectModelUnsupported")}>
<button
data-testid="model-selector-disabled"
className={cn(
"min-w-0 inline-flex items-center relative whitespace-nowrap px-1.5 py-1 text-xs",
selectorTriggerClassName,
"opacity-50",
triggerClassName,
)}
onClick={handleEditClick}>
<span className="truncate">{selectedModelLabel || provider}</span>
</button>
</StandardTooltip>
)
}

return (
<Popover open={open} onOpenChange={setOpen} data-testid="model-selector-root">
<StandardTooltip content={title}>
<PopoverTrigger
disabled={isDisabled}
data-testid="model-selector-trigger"
className={cn(
"min-w-0 inline-flex items-center relative whitespace-nowrap px-1.5 py-1 text-xs",
selectorTriggerClassName,
isDisabled ? "opacity-50 cursor-not-allowed" : enabledSelectorTriggerClassName,
triggerClassName,
)}>
<span className="truncate">{isLoading ? t("common:ui.loading") : selectedModelLabel}</span>
</PopoverTrigger>
</StandardTooltip>
<PopoverContent
align="start"
sideOffset={4}
container={portalContainer}
className="p-0 overflow-hidden w-[300px]">
<div className="flex flex-col w-full">
{modelIds.length > SEARCH_THRESHOLD && (
<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>
)}

{filteredModelIds.length === 0 ? (
<div className="py-2 px-3 text-sm text-vscode-foreground/70">{t("common:ui.no_results")}</div>
) : (
<div className="max-h-[300px] overflow-y-auto py-1">
{filteredModelIds.map(renderModelItem)}
</div>
)}

<div className="flex flex-row items-center justify-between px-2 py-2 border-t border-vscode-dropdown-border">
<h4 className="m-0 font-medium text-sm text-vscode-descriptionForeground">
{t("chat:selectModel")}
</h4>
</div>
</div>
</PopoverContent>
</Popover>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@
const story = component.getByTestId("chat-text-area-story")
const editor = story.getByRole("textbox")
await expect(editor).toBeVisible()
await expect(story).toHaveScreenshot(`chat-composer-resting-${theme.name}.png`)

Check failure on line 15 in webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx

View workflow job for this annotation

GitHub Actions / webview-visual

[chromium] › src/components/chat/__tests__/ChatTextArea.visual.tsx:7:2 › renders the production chat composer in the VS Code high-contrast-light theme

4) [chromium] › src/components/chat/__tests__/ChatTextArea.visual.tsx:7:2 › renders the production chat composer in the VS Code high-contrast-light theme Error: expect(locator).toHaveScreenshot(expected) failed Locator: locator('#root').locator(':scope > [data-playwright-mounted]').getByTestId('chat-text-area-story') Expected an image 488px by 186px, received 488px by 153px. 3526 pixels (ratio 0.04 of all image pixels) are different. Snapshot: chat-composer-resting-high-contrast-light.png Call log: - Expect "toHaveScreenshot(chat-composer-resting-high-contrast-light.png)" with timeout 5000ms - verifying given screenshot expectation - waiting for locator('#root').locator(':scope > [data-playwright-mounted]').getByTestId('chat-text-area-story') - locator resolved to <div data-testid="chat-text-area-story" class="w-[488px] max-w-full rounded-lg border border-vscode-panel-border bg-vscode-editor-background p-3">…</div> - taking element screenshot - disabled all CSS animations - waiting for fonts to load... - fonts loaded - attempting scroll into view action - waiting for element to be stable - Expected an image 488px by 186px, received 488px by 153px. 3526 pixels (ratio 0.04 of all image pixels) are different. - waiting 100ms before taking screenshot - waiting for locator('#root').locator(':scope > [data-playwright-mounted]').getByTestId('chat-text-area-story') - locator resolved to <div data-testid="chat-text-area-story" class="w-[488px] max-w-full rounded-lg border border-vscode-panel-border bg-vscode-editor-background p-3">…</div> - taking element screenshot - disabled all CSS animations - waiting for fonts to load... - fonts loaded - attempting scroll into view action - waiting for element to be stable - captured a stable screenshot - Expected an image 488px by 186px, received 488px by 153px. 3526 pixels (ratio 0.04 of all image pixels) are different. 13 | const editor = story.getByRole("textbox") 14 | await expect(editor).toBeVisible() > 15 | await expect(story).toHaveScreenshot(`chat-composer-resting-${theme.name}.png`) | ^ 16 | 17 | await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()) 18 | for ( at /__w/Zoo-Code/Zoo-Code/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx:15:23

Check failure on line 15 in webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx

View workflow job for this annotation

GitHub Actions / webview-visual

[chromium] › src/components/chat/__tests__/ChatTextArea.visual.tsx:7:2 › renders the production chat composer in the VS Code high-contrast theme

3) [chromium] › src/components/chat/__tests__/ChatTextArea.visual.tsx:7:2 › renders the production chat composer in the VS Code high-contrast theme Error: expect(locator).toHaveScreenshot(expected) failed Locator: locator('#root').locator(':scope > [data-playwright-mounted]').getByTestId('chat-text-area-story') Expected an image 488px by 186px, received 488px by 153px. 18219 pixels (ratio 0.21 of all image pixels) are different. Snapshot: chat-composer-resting-high-contrast.png Call log: - Expect "toHaveScreenshot(chat-composer-resting-high-contrast.png)" with timeout 5000ms - verifying given screenshot expectation - waiting for locator('#root').locator(':scope > [data-playwright-mounted]').getByTestId('chat-text-area-story') - locator resolved to <div data-testid="chat-text-area-story" class="w-[488px] max-w-full rounded-lg border border-vscode-panel-border bg-vscode-editor-background p-3">…</div> - taking element screenshot - disabled all CSS animations - waiting for fonts to load... - fonts loaded - attempting scroll into view action - waiting for element to be stable - Expected an image 488px by 186px, received 488px by 153px. 18219 pixels (ratio 0.21 of all image pixels) are different. - waiting 100ms before taking screenshot - waiting for locator('#root').locator(':scope > [data-playwright-mounted]').getByTestId('chat-text-area-story') - locator resolved to <div data-testid="chat-text-area-story" class="w-[488px] max-w-full rounded-lg border border-vscode-panel-border bg-vscode-editor-background p-3">…</div> - taking element screenshot - disabled all CSS animations - waiting for fonts to load... - fonts loaded - attempting scroll into view action - waiting for element to be stable - captured a stable screenshot - Expected an image 488px by 186px, received 488px by 153px. 18219 pixels (ratio 0.21 of all image pixels) are different. 13 | const editor = story.getByRole("textbox") 14 | await expect(editor).toBeVisible() > 15 | await expect(story).toHaveScreenshot(`chat-composer-resting-${theme.name}.png`) | ^ 16 | 17 | await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()) 18 | for ( at /__w/Zoo-Code/Zoo-Code/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx:15:23

Check failure on line 15 in webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx

View workflow job for this annotation

GitHub Actions / webview-visual

[chromium] › src/components/chat/__tests__/ChatTextArea.visual.tsx:7:2 › renders the production chat composer in the VS Code light theme

2) [chromium] › src/components/chat/__tests__/ChatTextArea.visual.tsx:7:2 › renders the production chat composer in the VS Code light theme Error: expect(locator).toHaveScreenshot(expected) failed Locator: locator('#root').locator(':scope > [data-playwright-mounted]').getByTestId('chat-text-area-story') Expected an image 488px by 186px, received 488px by 153px. 1823 pixels (ratio 0.03 of all image pixels) are different. Snapshot: chat-composer-resting-light.png Call log: - Expect "toHaveScreenshot(chat-composer-resting-light.png)" with timeout 5000ms - verifying given screenshot expectation - waiting for locator('#root').locator(':scope > [data-playwright-mounted]').getByTestId('chat-text-area-story') - locator resolved to <div data-testid="chat-text-area-story" class="w-[488px] max-w-full rounded-lg border border-vscode-panel-border bg-vscode-editor-background p-3">…</div> - taking element screenshot - disabled all CSS animations - waiting for fonts to load... - fonts loaded - attempting scroll into view action - waiting for element to be stable - Expected an image 488px by 186px, received 488px by 153px. 1823 pixels (ratio 0.03 of all image pixels) are different. - waiting 100ms before taking screenshot - waiting for locator('#root').locator(':scope > [data-playwright-mounted]').getByTestId('chat-text-area-story') - locator resolved to <div data-testid="chat-text-area-story" class="w-[488px] max-w-full rounded-lg border border-vscode-panel-border bg-vscode-editor-background p-3">…</div> - taking element screenshot - disabled all CSS animations - waiting for fonts to load... - fonts loaded - attempting scroll into view action - waiting for element to be stable - captured a stable screenshot - Expected an image 488px by 186px, received 488px by 153px. 1823 pixels (ratio 0.03 of all image pixels) are different. 13 | const editor = story.getByRole("textbox") 14 | await expect(editor).toBeVisible() > 15 | await expect(story).toHaveScreenshot(`chat-composer-resting-${theme.name}.png`) | ^ 16 | 17 | await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()) 18 | for ( at /__w/Zoo-Code/Zoo-Code/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx:15:23

Check failure on line 15 in webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx

View workflow job for this annotation

GitHub Actions / webview-visual

[chromium] › src/components/chat/__tests__/ChatTextArea.visual.tsx:7:2 › renders the production chat composer in the VS Code dark theme

1) [chromium] › src/components/chat/__tests__/ChatTextArea.visual.tsx:7:2 › renders the production chat composer in the VS Code dark theme Error: expect(locator).toHaveScreenshot(expected) failed Locator: locator('#root').locator(':scope > [data-playwright-mounted]').getByTestId('chat-text-area-story') Expected an image 488px by 186px, received 488px by 153px. 17454 pixels (ratio 0.20 of all image pixels) are different. Snapshot: chat-composer-resting-dark.png Call log: - Expect "toHaveScreenshot(chat-composer-resting-dark.png)" with timeout 5000ms - verifying given screenshot expectation - waiting for locator('#root').locator(':scope > [data-playwright-mounted]').getByTestId('chat-text-area-story') - locator resolved to <div data-testid="chat-text-area-story" class="w-[488px] max-w-full rounded-lg border border-vscode-panel-border bg-vscode-editor-background p-3">…</div> - taking element screenshot - disabled all CSS animations - waiting for fonts to load... - fonts loaded - attempting scroll into view action - waiting for element to be stable - Expected an image 488px by 186px, received 488px by 153px. 17454 pixels (ratio 0.20 of all image pixels) are different. - waiting 100ms before taking screenshot - waiting for locator('#root').locator(':scope > [data-playwright-mounted]').getByTestId('chat-text-area-story') - locator resolved to <div data-testid="chat-text-area-story" class="w-[488px] max-w-full rounded-lg border border-vscode-panel-border bg-vscode-editor-background p-3">…</div> - taking element screenshot - disabled all CSS animations - waiting for fonts to load... - fonts loaded - attempting scroll into view action - waiting for element to be stable - captured a stable screenshot - Expected an image 488px by 186px, received 488px by 153px. 17454 pixels (ratio 0.20 of all image pixels) are different. 13 | const editor = story.getByRole("textbox") 14 | await expect(editor).toBeVisible() > 15 | await expect(story).toHaveScreenshot(`chat-composer-resting-${theme.name}.png`) | ^ 16 | 17 | await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()) 18 | for ( at /__w/Zoo-Code/Zoo-Code/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx:15:23

await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
for (
let index = 0;
index < 10 && !(await editor.evaluate((element) => element === document.activeElement));
index < 15 && !(await editor.evaluate((element) => element === document.activeElement));
index++
) {
await page.keyboard.press("Tab")
Expand Down
Loading
Loading