Skip to content
Closed
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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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
267 changes: 267 additions & 0 deletions webview-ui/src/components/chat/ModelSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
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

Survived 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. The loading gate
// checks both the selected-model resolution (useSelectedModel) and the
// router-models query (useRouterModels) so a dynamic provider whose model
// list hasn't resolved yet never flashes the unsupported shortcut.
if (!isSupported && !isLoading && !routerModels.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 || routerModels.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 @@ -17,7 +17,7 @@ for (const theme of visualThemes) {
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