Skip to content
Open
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.
8 changes: 8 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 @@ -87,6 +88,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const {
filePaths,
openedTabs,
apiConfiguration,
currentApiConfigName,
listApiConfigMeta,
customModes,
Expand Down Expand Up @@ -1311,6 +1313,12 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
lockApiConfigAcrossModes={!!lockApiConfigAcrossModes}
onToggleLockApiConfig={handleToggleLockApiConfig}
/>
<ModelSelector
apiConfiguration={apiConfiguration}
currentApiConfigName={currentApiConfigName}
title={t("chat:selectModel")}
triggerClassName="min-w-[28px] text-ellipsis overflow-hidden flex-shrink min-[310px]:overflow-visible min-[310px]:flex-shrink-0"
/>
<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
266 changes: 266 additions & 0 deletions webview-ui/src/components/chat/ModelSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
import { useState, useMemo, useCallback } from "react"
import { Fzf } from "fzf"

import {
type ModelInfo,
type ModelRecord,
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"

const SEARCH_THRESHOLD = 6

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

export const ModelSelector = ({
apiConfiguration,
currentApiConfigName,
disabled = false,
title,
triggerClassName = "",
}: 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 {}
}

if (dynamicProvider) {
return routerModels.data?.[dynamicProvider] ?? {}
}

// Stryker disable next-line ConditionalExpression: getStaticModelsForProvider already
// falls back to `{}` for a provider missing from MODELS_BY_PROVIDER, so forcing this
// branch to run unconditionally yields the same result as the `false` case below.
if (isStaticModelProvider(provider)) {
const staticModels = getStaticModelsForProvider(provider, undefined, apiConfiguration)
const { "custom-arn": _customArn, ...rest } = staticModels
return rest
}

return {}
}, [modelConfig, dynamicProvider, routerModels.data, provider, apiConfiguration])

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

const isSupported = !!modelConfig && modelIds.length > 0
Comment thread
daewoongoh marked this conversation as resolved.
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).
// 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 (
<div
key={modelId}
onClick={() => handleSelect(modelId)}
className={cn(
"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",
)}>
<div className="flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">{label}</div>
{isCurrentModel && (
<div className="size-5 p-1 flex items-center justify-center">
<span className="codicon codicon-check text-xs" />
</div>
)}
</div>
Comment thread
daewoongoh marked this conversation as resolved.
)
},
[selectedModelId, models, getModelLabel, handleSelect],
)

if (!isSupported) {
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>
)
}
29 changes: 29 additions & 0 deletions webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { ReactNode } from "react"

import { providerIdentifiers } from "@roo-code/types"
import { defaultModeSlug } from "@roo/modes"

Expand Down Expand Up @@ -34,6 +36,16 @@ const mockConvertToMentionPath = pathMentions.convertToMentionPath as ReturnType
// Mock ExtensionStateContext
vi.mock("@src/context/ExtensionStateContext")

vi.mock("@src/components/ui", async (importOriginal) => {
const actual = await importOriginal<typeof import("@src/components/ui")>()
return {
...actual,
StandardTooltip: ({ children, content }: { children: ReactNode; content?: ReactNode }) => (
<div data-tooltip-content={content}>{children}</div>
),
}
})

// Custom query function to get the enhance prompt button
const getEnhancePromptButton = () => {
return screen.getByRole("button", {
Expand Down Expand Up @@ -1206,4 +1218,21 @@ describe("ChatTextArea", () => {
expect(sendButton).toHaveClass("pointer-events-auto")
})
})

describe("model selector", () => {
it("passes the responsive trigger class name to the model selector", () => {
render(<ChatTextArea {...defaultProps} />)

expect(screen.getByTestId("model-selector-trigger")).toHaveClass("min-w-[28px]")
})

it("passes the selectModel translation as the trigger tooltip", () => {
render(<ChatTextArea {...defaultProps} />)

expect(screen.getByTestId("model-selector-trigger").closest("[data-tooltip-content]")).toHaveAttribute(
"data-tooltip-content",
"chat:selectModel",
)
})
})
})
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