Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 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
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.
12 changes: 12 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 { StandardTooltip } from "@src/components/ui"
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 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const {
filePaths,
openedTabs,
apiConfiguration,
currentApiConfigName,
listApiConfigMeta,
customModes,
Expand All @@ -104,6 +106,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
commands,
enterBehavior,
lockApiConfigAcrossModes,
organizationAllowList,
} = useExtensionState()

// Find the ID and display text for the currently selected API configuration.
Expand All @@ -114,6 +117,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
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 @@ -1319,6 +1323,14 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
lockApiConfigAcrossModes={!!lockApiConfigAcrossModes}
onToggleLockApiConfig={handleToggleLockApiConfig}
/>
<ModelSelector
apiConfiguration={apiConfiguration}
currentApiConfigName={currentApiConfigName}
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
282 changes: 282 additions & 0 deletions webview-ui/src/components/chat/ModelSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
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"

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 = "",
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 76 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:76: 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 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,
}
;(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
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={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 || 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