Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e1c15b3
feat: add model selector UI to chat
daewoongoh Sep 4, 2026
d0cc90f
test: strengthen ModelSelector mutation test coverage
daewoongoh Sep 4, 2026
4986118
test: cover remaining ModelSelector mutation gaps
daewoongoh Sep 4, 2026
ca27164
fix: place Stryker disable directive so it actually attaches
daewoongoh Sep 4, 2026
dd48924
test: update chat composer visual baselines for the model selector
daewoongoh Sep 4, 2026
c47ea9c
test: update electron chat sidebar baseline for the model selector
daewoongoh Sep 4, 2026
9d8936f
test(electron): refresh chat-dark sidebar baseline from CI runner
daewoongoh Sep 4, 2026
983216b
Merge branch 'main' into feat/model-selector-ui-chat
daewoongoh Sep 8, 2026
48b2ab5
fix(webview): close describe block properly in ChatTextArea spec
daewoongoh Sep 8, 2026
1c761ef
feat(webview): filter chat model selector by organization allow list
daewoongoh Sep 8, 2026
bd5fb57
fix(webview): disable model selector when selectApiConfigDisabled is …
daewoongoh Sep 8, 2026
660c074
fix: address model selector review feedback
daewoongoh Sep 11, 2026
b407a1b
test: exercise model selector popover interactions
daewoongoh Sep 11, 2026
4e493a8
Merge branch 'Zoo-Code-Org:main' into feat/model-selector-ui-chat
daewoongoh Sep 14, 2026
6d755f0
fix: address CodeRabbit review on model selector PR
daewoongoh Sep 14, 2026
ea3315b
fix(webview): preserve static router provider models
daewoongoh Sep 14, 2026
74187e6
fix(webview): disable unsaved task model selection
daewoongoh Sep 14, 2026
54155ee
fix(webview): address edelauna review on ModelSelector PR
daewoongoh Sep 17, 2026
0be38e8
fix(webview): reset model search when popover closes without a selection
daewoongoh Sep 17, 2026
19cdede
Merge branch 'main' into feat/model-selector-ui-chat
daewoongoh Sep 17, 2026
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.
26 changes: 25 additions & 1 deletion webview-ui/src/components/chat/ChatTextArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import DynamicTextArea from "react-textarea-autosize"
import { VolumeX, Image, WandSparkles, SendHorizontal, X, ListEnd, Square } from "lucide-react"

import type { ExtensionMessage } from "@roo-code/types"
import type { ExtensionMessage, ProviderSettings } from "@roo-code/types"

import { mentionRegex, mentionRegexGlobal, commandRegexGlobal, unescapeSpaces } from "@roo/context-mentions"
import { WebviewMessage } from "@roo/WebviewMessage"
Expand All @@ -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 @@ -92,6 +93,7 @@
const {
filePaths,
openedTabs,
apiConfiguration,
currentApiConfigName,
listApiConfigMeta,
customModes,
Expand All @@ -104,6 +106,7 @@
commands,
enterBehavior,
lockApiConfigAcrossModes,
organizationAllowList,
} = useExtensionState()

// Find the ID and display text for the currently selected API configuration.
Expand All @@ -114,6 +117,7 @@
displayName: currentApiConfigName || "", // Use the name directly for display.
}
}, [listApiConfigMeta, currentApiConfigName])
const hasPersistedApiConfiguration = !!currentApiConfigName

const [gitCommits, setGitCommits] = useState<any[]>([])
const [showDropdown, setShowDropdown] = useState(false)
Expand Down Expand Up @@ -944,6 +948,18 @@
[setMode],
)

// Common model selector handler
const handleModelChange = useCallback(
(value: ProviderSettings) => {
vscode.postMessage({
type: "upsertApiConfiguration",
text: currentApiConfigName,
apiConfiguration: value,
})
},
[currentApiConfigName],

Check warning on line 960 in webview-ui/src/components/chat/ChatTextArea.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/components/chat/ChatTextArea.tsx:960: Survived ArrayDeclaration mutant (replacement: []). See the job summary for the complete list and resolution guidance.
)

// Helper function to handle API config change
const handleApiConfigChange = useCallback((value: string) => {
vscode.postMessage({ type: "loadApiConfigurationById", text: value })
Expand Down Expand Up @@ -1319,6 +1335,14 @@
lockApiConfigAcrossModes={!!lockApiConfigAcrossModes}
onToggleLockApiConfig={handleToggleLockApiConfig}
/>
<ModelSelector
apiConfiguration={apiConfiguration}
onChange={handleModelChange}
disabled={selectApiConfigDisabled || !hasPersistedApiConfiguration}
title={hasPersistedApiConfiguration ? t("chat:selectModel") : t("chat:selectApiConfig")}
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
3 changes: 1 addition & 2 deletions webview-ui/src/components/chat/ModeSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ import { useRooPortal } from "@/components/ui/hooks/useRooPortal"
import { Popover, PopoverContent, PopoverTrigger, StandardTooltip } from "@/components/ui"

import { IconButton } from "./IconButton"

const SEARCH_THRESHOLD = 6
import { SEARCH_THRESHOLD } from "./selectorConstants"

interface ModeSelectorProps {
value: Mode
Expand Down
287 changes: 287 additions & 0 deletions webview-ui/src/components/chat/ModelSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
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 {
getProviderModelConfig,
getStaticModelsForProvider,
isStaticModelProvider,
} from "../settings/utils/providerModelConfig"
import { filterModels } from "../settings/utils/organizationFilters"
import { SEARCH_THRESHOLD } from "./selectorConstants"

interface ModelSelectorProps {
apiConfiguration: ProviderSettings
onChange: (apiConfiguration: ProviderSettings) => void
disabled?: boolean
title: string
triggerClassName?: string
organizationAllowList?: OrganizationAllowList
}

export const ModelSelector = ({
apiConfiguration,
onChange,
disabled = false,
title,
triggerClassName = "",
organizationAllowList,
}: ModelSelectorProps) => {
const { t } = useAppTranslation()
const [open, setOpen] = useState(false)
const [searchValue, setSearchValue] = useState("")
const portalContainer = useRooPortal("roo-portal")

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 })
const { id: selectedModelId, info: selectedModelInfo, isLoading } = useSelectedModel(apiConfiguration)

const models: ModelRecord = useMemo(() => {
// Stryker disable next-line ConditionalExpression,BlockStatement: every provider that is
// dynamic or has static models also has an entry in PROVIDER_MODEL_CONFIG, so `modelConfig`
// is only ever undefined for providers that would fall through to `{}` below anyway.
if (!modelConfig) {
return {}
}

const staticModels = isStaticModelProvider(provider)
? getStaticModelsForProvider(provider, undefined, apiConfiguration)
: {}
const { "custom-arn": _customArn, ...modelsWithoutCustomArn } = staticModels

if (dynamicProvider) {

Check warning on line 75 in webview-ui/src/components/chat/ModelSelector.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/components/chat/ModelSelector.tsx:75: Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
return (
filterModels(
{ ...modelsWithoutCustomArn, ...(routerModels.data?.[dynamicProvider] ?? {}) },
provider,
organizationAllowList,
) ?? {}
)
}

return filterModels(modelsWithoutCustomArn, provider, organizationAllowList) ?? {}
}, [modelConfig, dynamicProvider, routerModels.data, provider, apiConfiguration, organizationAllowList])

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

const isModelListLoading = !!dynamicProvider && routerModels.isLoading
const isSupported = !!modelConfig && (isModelListLoading || modelIds.length > 0)
const isDisabled = disabled || modelIds.length === 0

// 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).
// Stryker disable next-line ArrayDeclaration: this callback closes over no props or state, so
// its identity across renders isn't observable — only its (unmutated) body behavior is.
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(() => {
// Stryker disable next-line ConditionalExpression,BlockStatement: fzf's `find("")` already
// returns every item in its original order, so skipping this shortcut is unobservable.
if (!searchValue) {
return modelIds
}

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

const handleOpenChange = useCallback((next: boolean) => {
setOpen(next)
if (!next) {

Check warning on line 129 in webview-ui/src/components/chat/ModelSelector.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/components/chat/ModelSelector.tsx:129: 4 mutation test gaps; example: NoCoverage BlockStatement mutant (replacement: {}). See the job summary for the complete list and resolution guidance.
setSearchValue("")

Check warning on line 130 in webview-ui/src/components/chat/ModelSelector.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/components/chat/ModelSelector.tsx:130: NoCoverage StringLiteral mutant (replacement: "Stryker was here!"). See the job summary for the complete list and resolution guidance.
}
}, [])

Check warning on line 132 in webview-ui/src/components/chat/ModelSelector.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/components/chat/ModelSelector.tsx:132: Survived ArrayDeclaration mutant (replacement: ["Stryker was here"]). See the job summary for the complete list and resolution guidance.

const handleEditClick = useCallback(
() => {
vscode.postMessage({ type: "switchTab", tab: "settings" })
// Stryker disable next-line BooleanLiteral,CallExpression: this button only renders
// while the popover (and its `open` state) doesn't exist, so this call has no
// observable effect either way.
setOpen(false)
},
// Stryker disable next-line ArrayDeclaration: this callback closes over no props or state.
[],
)

const handleSelect = useCallback(
(modelId: string) => {
// Stryker disable next-line ConditionalExpression,BlockStatement: handleSelect is only
// ever invoked from a rendered model-list item, which requires a non-empty `models`
// map, which in turn requires `modelConfig` to be defined — this guard can't be hit.
if (!modelConfig) {
return
}

const updated: ProviderSettings = {
...apiConfiguration,
reasoningEffort: undefined,
modelMaxTokens: undefined,
modelMaxThinkingTokens: undefined,
}
Comment thread
daewoongoh marked this conversation as resolved.
if (provider === providerIdentifiers.bedrock && modelId !== "custom-arn") {

Check warning on line 161 in webview-ui/src/components/chat/ModelSelector.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/components/chat/ModelSelector.tsx:161: 5 mutation test gaps; example: Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
;(updated as Record<string, unknown>)["awsCustomArn"] = undefined
}
;(updated as Record<string, unknown>)[modelConfig.field] = modelId

onChange(updated)

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

Check warning on line 171 in webview-ui/src/components/chat/ModelSelector.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/components/chat/ModelSelector.tsx:171: Survived ArrayDeclaration mutant (replacement: []). See the job summary for the complete list and resolution guidance.
)

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

return (
<button
Comment thread
coderabbitai[bot] marked this conversation as resolved.
type="button"
disabled={isDisabled}
aria-pressed={isCurrentModel}
key={modelId}
onClick={() => handleSelect(modelId)}
className={cn(
"w-full border-0 bg-transparent text-left text-inherit px-3 py-1.5 text-sm cursor-pointer flex items-center group",
"hover: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" aria-hidden="true">
<span className="codicon codicon-check text-xs" />
</span>
)}
</button>
)
},
[selectedModelId, models, getModelLabel, handleSelect, isDisabled],
)

if (!isSupported) {
return (
<StandardTooltip content={t("chat:selectModelUnsupported")}>
<button
type="button"
disabled={disabled}
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 || rawProvider}</span>
</button>
</StandardTooltip>
)
}

return (
<Popover open={open} onOpenChange={handleOpenChange} 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 || isModelListLoading ? 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">
<button
type="button"
aria-label={t("common:ui.clear_search")}
className="border-0 bg-transparent p-0 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>
)
}
Loading
Loading