Skip to content

Commit 962a201

Browse files
author
seeones
committed
feat(chat): add chat model selector in input bar (#2)
1 parent 09e7326 commit 962a201

33 files changed

Lines changed: 1555 additions & 8 deletions
-295 Bytes
Loading
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import { useMemo, useState, useCallback, useRef, type KeyboardEvent } from "react"
2+
3+
import { cn } from "@/lib/utils"
4+
import { useRooPortal } from "@/components/ui/hooks/useRooPortal"
5+
import { Popover, PopoverContent, PopoverTrigger, StandardTooltip } from "@/components/ui"
6+
import { useAppTranslation } from "@/i18n/TranslationContext"
7+
import { vscode } from "@/utils/vscode"
8+
import { useExtensionState } from "@/context/ExtensionStateContext"
9+
import { useSelectedModel } from "@/components/ui/hooks/useSelectedModel"
10+
import { filterModels } from "@/components/settings/utils/organizationFilters"
11+
import { useChatModelSelector } from "./hooks/useChatModelSelector"
12+
13+
interface ChatModelSelectorProps {
14+
disabled?: boolean
15+
title: string
16+
triggerClassName?: string
17+
}
18+
19+
export const ChatModelSelector = ({ disabled = false, title, triggerClassName = "" }: ChatModelSelectorProps) => {
20+
const { t } = useAppTranslation()
21+
const { apiConfiguration, organizationAllowList, currentApiConfigName } = useExtensionState()
22+
const { id: selectedModelId } = useSelectedModel(apiConfiguration)
23+
const { provider, models, modelIdKey, defaultModelId, isLoading, valueTransform, displayTransform } =
24+
useChatModelSelector()
25+
26+
const [open, setOpen] = useState(false)
27+
const [searchValue, setSearchValue] = useState("")
28+
const searchInputRef = useRef<HTMLInputElement>(null)
29+
const portalContainer = useRooPortal("roo-portal")
30+
31+
// Filter deprecated models but always keep the currently selected one visible.
32+
const modelIds = useMemo(() => {
33+
const filteredModels = filterModels(models, provider, organizationAllowList)
34+
const available = Object.entries(filteredModels ?? {})
35+
.filter(([modelId, modelInfo]) => {
36+
if (modelId === selectedModelId) return true
37+
return !modelInfo.deprecated
38+
})
39+
.map(([modelId]) => modelId)
40+
.sort((a, b) => a.localeCompare(b))
41+
return available
42+
}, [models, provider, organizationAllowList, selectedModelId])
43+
44+
// Resolve the display value (custom transform for compound config values like VSCode LM).
45+
const displayValue = useMemo(() => {
46+
if (displayTransform && modelIdKey) {
47+
const storedValue = apiConfiguration?.[modelIdKey]
48+
return storedValue ? displayTransform(storedValue) : undefined
49+
}
50+
return selectedModelId || (searchValue ? searchValue : undefined)
51+
}, [displayTransform, modelIdKey, apiConfiguration, selectedModelId, searchValue])
52+
53+
const filteredModelIds = useMemo(() => {
54+
if (!searchValue) return modelIds
55+
const q = searchValue.toLowerCase()
56+
return modelIds.filter((id) => id.toLowerCase().includes(q))
57+
}, [modelIds, searchValue])
58+
59+
const onSelect = useCallback(
60+
(modelId: string) => {
61+
if (!modelId || !modelIdKey || !apiConfiguration) {
62+
return
63+
}
64+
65+
setOpen(false)
66+
setSearchValue("")
67+
68+
// Transform the model id for storage if needed (e.g. VSCode LM selector object).
69+
const valueToStore = valueTransform ? valueTransform(modelId) : modelId
70+
71+
// Persist the change to the current API configuration profile. The
72+
// backend will save the profile, activate it and broadcast the
73+
// updated apiConfiguration back to the webview.
74+
vscode.postMessage({
75+
type: "upsertApiConfiguration",
76+
text: currentApiConfigName,
77+
apiConfiguration: {
78+
...apiConfiguration,
79+
[modelIdKey]: valueToStore,
80+
},
81+
})
82+
},
83+
[modelIdKey, apiConfiguration, valueTransform, currentApiConfigName],
84+
)
85+
86+
const onClearSearch = useCallback(() => {
87+
setSearchValue("")
88+
searchInputRef.current?.focus()
89+
}, [])
90+
91+
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
92+
if (event.nativeEvent.isComposing) return
93+
94+
const options = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>("[data-model-option]"))
95+
const currentIndex = options.findIndex((option) => option === event.target)
96+
const isSearchInput = event.target === searchInputRef.current
97+
if (!isSearchInput && currentIndex === -1) return
98+
99+
if (isSearchInput && event.key === "Enter") {
100+
event.preventDefault()
101+
options[0]?.click()
102+
return
103+
}
104+
105+
let nextIndex: number
106+
switch (event.key) {
107+
case "ArrowDown":
108+
nextIndex = currentIndex + 1
109+
break
110+
case "ArrowUp":
111+
nextIndex = currentIndex === -1 ? options.length - 1 : currentIndex - 1
112+
break
113+
case "Home":
114+
case "End":
115+
if (isSearchInput) return
116+
nextIndex = event.key === "Home" ? 0 : options.length - 1
117+
break
118+
default:
119+
return
120+
}
121+
event.preventDefault()
122+
if (nextIndex < 0 || nextIndex >= options.length) {
123+
searchInputRef.current?.focus()
124+
} else {
125+
options[nextIndex]?.focus()
126+
}
127+
}
128+
129+
return (
130+
<Popover open={open} onOpenChange={setOpen} data-testid="chat-model-selector-root">
131+
<StandardTooltip content={title}>
132+
<PopoverTrigger
133+
disabled={disabled || !modelIdKey}
134+
data-testid="chat-model-selector-trigger"
135+
className={cn(
136+
"min-w-0 inline-flex items-center relative whitespace-nowrap px-1.5 py-1 text-xs",
137+
"bg-transparent border border-[rgba(255,255,255,0.08)] rounded-md text-vscode-foreground",
138+
"transition-all duration-150 focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder focus-visible:ring-inset",
139+
disabled || !modelIdKey
140+
? "opacity-50 cursor-not-allowed"
141+
: "opacity-90 hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)] cursor-pointer",
142+
triggerClassName,
143+
)}>
144+
<span className="truncate max-w-[140px]">
145+
{isLoading ? "…" : displayValue || defaultModelId || t("chat:selectModel")}
146+
</span>
147+
</PopoverTrigger>
148+
</StandardTooltip>
149+
<PopoverContent
150+
align="start"
151+
sideOffset={4}
152+
container={portalContainer}
153+
className="p-0 overflow-hidden w-[300px]">
154+
<div className="flex flex-col w-full" onKeyDown={onKeyDown}>
155+
<div className="relative p-2 border-b border-vscode-dropdown-border">
156+
<input
157+
ref={searchInputRef}
158+
aria-label={t("chat:searchModel")}
159+
value={searchValue}
160+
onChange={(e) => setSearchValue(e.target.value)}
161+
placeholder={t("chat:searchModel")}
162+
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"
163+
autoFocus
164+
/>
165+
{searchValue.length > 0 && (
166+
<div className="absolute right-4 top-0 bottom-0 flex items-center justify-center">
167+
<button
168+
type="button"
169+
aria-label={t("chat:clearSearch")}
170+
className="codicon codicon-close text-vscode-input-foreground opacity-50 hover:opacity-100 text-xs cursor-pointer"
171+
onClick={onClearSearch}
172+
/>
173+
</div>
174+
)}
175+
</div>
176+
177+
{modelIds.length === 0 && !isLoading && (
178+
<div className="py-2 px-3 text-sm text-vscode-foreground/70">{t("chat:modelListEmpty")}</div>
179+
)}
180+
181+
{modelIds.length > 0 && (
182+
<div className="max-h-[300px] overflow-y-auto">
183+
{filteredModelIds.map((modelId) => (
184+
<button
185+
type="button"
186+
data-model-option
187+
key={modelId}
188+
onClick={() => onSelect(modelId)}
189+
data-testid={`chat-model-option-${modelId}`}
190+
className={cn(
191+
"w-full text-left px-3 py-1.5 text-sm cursor-pointer flex items-center gap-2",
192+
"hover:bg-vscode-list-hoverBackground focus-visible:outline focus-visible:outline-1 focus-visible:outline-vscode-focusBorder focus-visible:-outline-offset-1",
193+
modelId === displayValue &&
194+
"bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground",
195+
)}>
196+
<span className="flex-1 min-w-0 truncate" title={modelId}>
197+
{modelId}
198+
</span>
199+
{modelId === displayValue && <span className="codicon codicon-check text-xs" />}
200+
</button>
201+
))}
202+
</div>
203+
)}
204+
205+
{searchValue && !modelIds.includes(searchValue) && (
206+
<button
207+
type="button"
208+
data-model-option
209+
data-testid="chat-model-use-custom"
210+
onClick={() => onSelect(searchValue)}
211+
className="w-full text-left px-3 py-1.5 text-sm cursor-pointer hover:bg-vscode-list-hoverBackground border-t border-vscode-input-border focus-visible:outline focus-visible:outline-1 focus-visible:outline-vscode-focusBorder focus-visible:-outline-offset-1">
212+
{t("chat:useCustomModel", { modelId: searchValue })}
213+
</button>
214+
)}
215+
</div>
216+
</PopoverContent>
217+
</Popover>
218+
)
219+
}

‎webview-ui/src/components/chat/ChatTextArea.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { StandardTooltip } from "@src/components/ui"
2727
import Thumbnails from "../common/Thumbnails"
2828
import { ModeSelector } from "./ModeSelector"
2929
import { ApiConfigSelector } from "./ApiConfigSelector"
30+
import { ChatModelSelector } from "./ChatModelSelector"
3031
import { AutoApproveDropdown } from "./AutoApproveDropdown"
3132
import { MAX_IMAGES_PER_MESSAGE } from "./constants"
3233
import ContextMenu from "./ContextMenu"
@@ -1319,6 +1320,11 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
13191320
lockApiConfigAcrossModes={!!lockApiConfigAcrossModes}
13201321
onToggleLockApiConfig={handleToggleLockApiConfig}
13211322
/>
1323+
<ChatModelSelector
1324+
disabled={selectApiConfigDisabled}
1325+
title={t("chat:selectModel")}
1326+
triggerClassName="min-w-[28px] text-ellipsis overflow-hidden flex-shrink min-[310px]:min-w-fit min-[310px]:overflow-visible min-[310px]:flex-shrink-0"
1327+
/>
13221328
<AutoApproveDropdown triggerClassName="min-w-[28px] text-ellipsis overflow-hidden flex-shrink min-[310px]:overflow-visible min-[310px]:flex-shrink-0" />
13231329
</div>
13241330
<div className={cn("flex flex-shrink-0 items-center gap-0.5 h-5 leading-none pr-2")}>

0 commit comments

Comments
 (0)