1import {
2 ANTIGRAVITY_DEFAULT_MODEL,
3 type ProviderInstanceId,
4 type EnvironmentId,
5 type ProviderDriverKind,
6 type ResolvedKeybindingsConfig,
7} from "@t3tools/contracts";
8import { memo, useEffect, useMemo, useState } from "react";
9import { Badge } from "../ui/badge";
10import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
11import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
12import { cn } from "~/lib/utils";
13import { ModelPickerContent, resolveModelPickerSelectedModel } from "./ModelPickerContent";
14import { ChatGptSharingControl } from "./ChatGptSharingControl";
15import { ProviderInstanceIcon } from "./ProviderInstanceIcon";
16import {
17 ModelEsque,
18 getTriggerDisplayModelLabel,
19 getTriggerDisplayModelName,
20} from "./providerIconUtils";
21import { shouldShowInstanceBadge, type ProviderInstanceEntry } from "../../providerInstances";
22import {
23 ComposerControl,
24 ComposerControlChevron,
25 type ComposerControlSize,
26} from "./ComposerControl";
27import { useComposerMenuProps } from "./composerEventScope";
28import { shortcutLabelForCommand } from "../../keybindings";
29
30export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
31 environmentId: EnvironmentId | null;
32 /**
33 * The instance currently selected in the composer. Drives the trigger
34 * icon, label and the default-highlighted combobox row.
35 */
36 activeInstanceId: ProviderInstanceId;
37 model: string;
38 selectedModels?: ReadonlyArray<{ instanceId: ProviderInstanceId; model: string }>;
39 onToggleModel?: (instanceId: ProviderInstanceId, model: string) => void;
40 lockedProvider: ProviderDriverKind | null;
41 lockedContinuationGroupKey?: string | null;
42 /** Instance entries rendered in the sidebar + used to resolve display name. */
43 instanceEntries: ReadonlyArray<ProviderInstanceEntry>;
44 keybindings?: ResolvedKeybindingsConfig;
45 modelOptionsByInstance: ReadonlyMap<ProviderInstanceId, ReadonlyArray<ModelEsque>>;
46 activeProviderIconClassName?: string;
47 instanceIndicatorBackground?: string;
48 size?: ComposerControlSize;
49 compact?: boolean;
50 isComposerOwned?: boolean;
51 disabled?: boolean;
52 terminalOpen?: boolean;
53 open?: boolean;
54 triggerClassName?: string;
55 /** Aggregate settings can show a neutral value without claiming one provider is selected. */
56 triggerLabel?: string;
57 triggerAriaLabel?: string;
58 onOpenChange?: (open: boolean) => void;
59 onOpenProviderSetup?: (instanceId: ProviderInstanceId) => void;
60 getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null;
61 onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void;
62}) {
63 const composerFloatingLayerProps = useComposerMenuProps();
64 const [uncontrolledIsMenuOpen, setUncontrolledIsMenuOpen] = useState(false);
65 const isMenuOpen = props.open ?? uncontrolledIsMenuOpen;
66 const size = props.size ?? "sm";
67
68 // Resolve the active instance entry by exact routing key. The composer
69 // resolves fallbacks before rendering this component; if the selected
70 // instance disappears, do not infer a replacement from its driver kind.
71 const activeEntry = useMemo(() => {
72 return (
73 props.instanceEntries.find((entry) => entry.instanceId === props.activeInstanceId) ?? null
74 );
75 }, [props.activeInstanceId, props.instanceEntries]);
76
77 const activeInstanceId = props.activeInstanceId;
78 const selectedInstanceOptions = props.modelOptionsByInstance.get(activeInstanceId) ?? [];
79 // Account-specific catalogs must keep the selected model label while unavailable.
80 const selectedModel =
81 resolveModelPickerSelectedModel({
82 driverKind: activeEntry?.driverKind,
83 model: props.model,
84 options: selectedInstanceOptions,
85 }) ??
86 (activeEntry?.driverKind === "opencode" || activeEntry?.driverKind === "antigravity"
87 ? undefined
88 : selectedInstanceOptions[0]);
89 const triggerTitle = selectedModel
90 ? getTriggerDisplayModelName(selectedModel)
91 : props.model === ANTIGRAVITY_DEFAULT_MODEL
92 ? "Choose model"
93 : props.model || "Choose model";
94 const triggerLabel = selectedModel
95 ? `${getTriggerDisplayModelLabel(selectedModel)}${selectedModel.isUnavailable ? " (Unavailable)" : ""}`
96 : triggerTitle;
97 const showInstanceBadge =
98 activeEntry !== null && shouldShowInstanceBadge(activeEntry, props.instanceEntries);
99
100 const setIsMenuOpen = (open: boolean) => {
101 props.onOpenChange?.(open);
102 if (props.open === undefined) {
103 setUncontrolledIsMenuOpen(open);
104 }
105 };
106
107 useEffect(() => {
108 if (!isMenuOpen) {
109 return;
110 }
111
112 const { documentElement, body } = document;
113 const previousDocumentOverscrollBehavior = documentElement.style.overscrollBehavior;
114 const previousBodyOverflow = body.style.overflow;
115 const previousBodyPaddingRight = body.style.paddingRight;
116 const scrollbarWidth = window.innerWidth - documentElement.clientWidth;
117
118 documentElement.style.overscrollBehavior = "contain";
119 body.style.overflow = "hidden";
120 if (scrollbarWidth > 0) {
121 body.style.paddingRight = `${scrollbarWidth}px`;
122 }
123
124 const shouldAllowOverlayScroll = (target: EventTarget | null) => {
125 return target instanceof Element && target.closest("[data-model-picker-content]");
126 };
127 const preventBackgroundWheel = (event: WheelEvent) => {
128 if (shouldAllowOverlayScroll(event.target)) {
129 return;
130 }
131 event.preventDefault();
132 };
133 const preventBackgroundTouchMove = (event: TouchEvent) => {
134 if (shouldAllowOverlayScroll(event.target)) {
135 return;
136 }
137 event.preventDefault();
138 };
139
140 document.addEventListener("wheel", preventBackgroundWheel, { capture: true, passive: false });
141 document.addEventListener("touchmove", preventBackgroundTouchMove, {
142 capture: true,
143 passive: false,
144 });
145
146 return () => {
147 document.removeEventListener("wheel", preventBackgroundWheel, { capture: true });
148 document.removeEventListener("touchmove", preventBackgroundTouchMove, { capture: true });
149 documentElement.style.overscrollBehavior = previousDocumentOverscrollBehavior;
150 body.style.overflow = previousBodyOverflow;
151 body.style.paddingRight = previousBodyPaddingRight;
152 };
153 }, [isMenuOpen]);
154
155 const handleInstanceModelChange = (instanceId: ProviderInstanceId, model: string) => {
156 if (props.disabled) return;
157 props.onInstanceModelChange(instanceId, model);
158 setIsMenuOpen(false);
159 };
160
161 const shortcutLabel = props.keybindings
162 ? shortcutLabelForCommand(props.keybindings, "modelPicker.toggle")
163 : null;
164 const selectedEntries = props.selectedModels?.map((selection) => {
165 const entry = props.instanceEntries.find(
166 (candidate) => candidate.instanceId === selection.instanceId,
167 );
168 const model = resolveModelPickerSelectedModel({
169 driverKind: entry?.driverKind,
170 model: selection.model,
171 options: props.modelOptionsByInstance.get(selection.instanceId) ?? [],
172 });
173 return {
174 ...selection,
175 entry,
176 label: model
177 ? `${getTriggerDisplayModelName(model)}${model.isUnavailable ? " (Unavailable)" : ""}`
178 : selection.model,
179 };
180 });
181 const multipleLabel = selectedEntries
182 ? selectedEntries.length === 0
183 ? "Choose models"
184 : `${selectedEntries
185 .slice(0, 2)
186 .map((selection) => selection.label)
187 .join(", ")}${selectedEntries.length > 2 ? `, ${selectedEntries.length - 2} more` : ""}`
188 : undefined;
189 const allModelNames = selectedEntries
190 ? selectedEntries.map((selection) => selection.label).join(", ") || "Choose models"
191 : undefined;
192 const triggerTooltipContent = shortcutLabel
193 ? `${props.triggerLabel ?? allModelNames ?? triggerLabel} · ${shortcutLabel}`
194 : (props.triggerLabel ?? allModelNames ?? triggerLabel);
195
196 return (
197 <Popover
198 open={isMenuOpen}
199 onOpenChange={(open) => {
200 if (props.disabled) {
201 setIsMenuOpen(false);
202 return;
203 }
204 setIsMenuOpen(open);
205 }}
206 >
207 <PopoverTrigger
208 render={
209 <ComposerControl
210 aria-label={props.triggerAriaLabel ?? allModelNames}
211 size={size}
212 data-chat-provider-model-picker="true"
213 className={cn(
214 "min-w-0 shrink justify-between whitespace-nowrap",
215 props.compact
216 ? "max-w-42 shrink-0"
217 : !props.isComposerOwned && "max-w-48 sm:max-w-56",
218 props.triggerClassName,
219 )}
220 disabled={props.disabled}
221 />
222 }
223 >
224 <span
225 className={cn("flex min-w-0 flex-1 items-center", size === "xs" ? "gap-1" : "gap-1.5")}
226 >
227 {selectedEntries && props.triggerLabel === undefined ? (
228 <span className="flex shrink-0 items-center -space-x-1" aria-hidden="true">
229 {selectedEntries
230 .slice(0, 3)
231 .map((selection) =>
232 selection.entry ? (
233 <ProviderInstanceIcon
234 key={`${selection.instanceId}:${selection.model}`}
235 driverKind={selection.entry.driverKind}
236 displayName={selection.entry.displayName}
237 accentColor={selection.entry.accentColor}
238 className="size-4 rounded-full bg-(--chat-composer-glass-surface,var(--background)) ring-2 ring-(--chat-composer-glass-surface,var(--background))"
239 iconClassName="size-4"
240 />
241 ) : null,
242 )}
243 {selectedEntries.length > 3 ? (
244 <span className="relative z-30 flex size-4 items-center justify-center rounded-full bg-(--chat-composer-glass-surface,var(--background)) text-3xs ring-2 ring-(--chat-composer-glass-surface,var(--background))">
245 +{selectedEntries.length - 3}
246 </span>
247 ) : null}
248 </span>
249 ) : activeEntry && props.triggerLabel === undefined ? (
250 <ProviderInstanceIcon
251 driverKind={activeEntry.driverKind}
252 displayName={activeEntry.displayName}
253 accentColor={activeEntry.accentColor}
254 acpRegistryAgentId={activeEntry.acpRegistryAgentId}
255 acpRegistryIconUrl={activeEntry.acpRegistryIconUrl}
256 showBadge={showInstanceBadge}
257 className="size-4"
258 iconClassName={cn("size-4", props.activeProviderIconClassName)}
259 indicatorBackground={props.instanceIndicatorBackground ?? "var(--contrast-input)"}
260 badgeClassName={cn(
261 "right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3 px-0.5 text-5xs",
262 size === "xs" && "shadow-none",
263 )}
264 />
265 ) : null}
266 <Tooltip>
267 <TooltipTrigger
268 render={
269 <span
270 className="min-w-0 flex-1 overflow-hidden truncate"
271 data-chat-provider-model-picker-label="true"
272 />
273 }
274 >
275 {props.triggerLabel ?? multipleLabel ?? triggerTitle}
276 </TooltipTrigger>
277 <TooltipPopup side="top">{triggerTooltipContent}</TooltipPopup>
278 </Tooltip>
279 {selectedModel?.isUnavailable && !selectedEntries && props.triggerLabel === undefined ? (
280 <Badge variant="outline" size="sm">
281 Unavailable
282 </Badge>
283 ) : null}
284 </span>
285 <span aria-hidden="true" className="flex items-center">
286 <ComposerControlChevron size={size} />
287 </span>
288 </PopoverTrigger>
289 <PopoverPopup
290 {...(props.isComposerOwned ? composerFloatingLayerProps : {})}
291 align="start"
292 className="before:hidden"
293 padding="none"
294 >
295 <ModelPickerContent
296 environmentId={props.environmentId}
297 activeInstanceId={activeInstanceId}
298 model={props.model}
299 {...(props.selectedModels !== undefined ? { selectedModels: props.selectedModels } : {})}
300 {...(props.onToggleModel
301 ? {
302 onToggleModel: (instanceId: ProviderInstanceId, model: string) => {
303 if (!props.disabled) props.onToggleModel?.(instanceId, model);
304 },
305 }
306 : {})}
307 lockedProvider={props.lockedProvider}
308 lockedContinuationGroupKey={props.lockedContinuationGroupKey ?? null}
309 instanceEntries={props.instanceEntries}
310 {...(props.keybindings ? { keybindings: props.keybindings } : {})}
311 modelOptionsByInstance={props.modelOptionsByInstance}
312 terminalOpen={props.terminalOpen ?? false}
313 onRequestClose={() => setIsMenuOpen(false)}
314 {...(props.onOpenProviderSetup ? { onOpenProviderSetup: props.onOpenProviderSetup } : {})}
315 {...(props.getModelDisabledReason
316 ? { getModelDisabledReason: props.getModelDisabledReason }
317 : {})}
318 onInstanceModelChange={handleInstanceModelChange}
319 />
320 {props.selectedModels === undefined ? (
321 <ChatGptSharingControl provider={activeEntry?.snapshot ?? null} />
322 ) : null}
323 </PopoverPopup>
324 </Popover>
325 );
326});