1import {
2 ANTIGRAVITY_DEFAULT_MODEL,
3 type ProviderInstanceId,
4 type EnvironmentId,
5 type ProviderDriverKind,
6 type ResolvedKeybindingsConfig,
7} from "@t3tools/contracts";
8import { RegistryContext } from "@effect/atom-react";
9import {
10 createContext,
11 memo,
12 type ReactNode,
13 useContext,
14 useEffect,
15 useEffectEvent,
16 useRef,
17 useState,
18} from "react";
19import { Badge } from "../ui/badge";
20import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
21import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
22import { cn } from "~/lib/utils";
23import { ModelPickerContent, resolveModelPickerSelectedModel } from "./ModelPickerContent";
24import { retainedModelWhileProviderLocked } from "./ProviderModelPicker.logic";
25import { ChatGptSharingControl } from "./ChatGptSharingControl";
26import { ProviderInstanceIcon } from "./ProviderInstanceIcon";
27import { ModelEsque, getDisplayModelName, getTriggerDisplayModelName } from "./providerIconUtils";
28import { shouldShowInstanceBadge, type ProviderInstanceEntry } from "../../providerInstances";
29import { ComposerControl, type ComposerControlSize } from "./ComposerControl";
30import { takeOpenTarget, useComposerMenuProps } from "./composerEventScope";
31import { shortcutLabelForCommand } from "../../keybindings";
32import { serverEnvironment } from "../../state/server";
33import { useAtomCommand } from "../../state/use-atom-command";
34
35/** Holds the page still behind the open picker; its lifetime is the popup element's. */
36function lockBackgroundScroll(popup: HTMLElement | null) {
37 if (!popup) return;
38 const { documentElement, body } = document;
39 const previousDocumentOverscrollBehavior = documentElement.style.overscrollBehavior;
40 const previousBodyOverflow = body.style.overflow;
41 const previousBodyPaddingRight = body.style.paddingRight;
42 const scrollbarWidth = window.innerWidth - documentElement.clientWidth;
43
44 documentElement.style.overscrollBehavior = "contain";
45 body.style.overflow = "hidden";
46 if (scrollbarWidth > 0) {
47 body.style.paddingRight = `${scrollbarWidth}px`;
48 }
49
50 const shouldAllowOverlayScroll = (target: EventTarget | null) => {
51 return (
52 target instanceof Element &&
53 target.closest(
54 "[data-model-picker-content], [data-model-picker-effort], [data-model-picker-option-menu]",
55 )
56 );
57 };
58 const preventBackgroundWheel = (event: WheelEvent) => {
59 if (shouldAllowOverlayScroll(event.target)) {
60 return;
61 }
62 event.preventDefault();
63 };
64 const preventBackgroundTouchMove = (event: TouchEvent) => {
65 if (shouldAllowOverlayScroll(event.target)) {
66 return;
67 }
68 event.preventDefault();
69 };
70
71 document.addEventListener("wheel", preventBackgroundWheel, { capture: true, passive: false });
72 document.addEventListener("touchmove", preventBackgroundTouchMove, {
73 capture: true,
74 passive: false,
75 });
76
77 return () => {
78 document.removeEventListener("wheel", preventBackgroundWheel, { capture: true });
79 document.removeEventListener("touchmove", preventBackgroundTouchMove, { capture: true });
80 documentElement.style.overscrollBehavior = previousDocumentOverscrollBehavior;
81 body.style.overflow = previousBodyOverflow;
82 body.style.paddingRight = previousBodyPaddingRight;
83 };
84}
85
86/** What a docked effort column needs from its picker: the model it serves. */
87export const DockedPickerContext = createContext<{ modelLabel: string }>({ modelLabel: "" });
88
89export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
90 environmentId: EnvironmentId | null;
91 workspaceCwd?: string | null;
92 activeInstanceId: ProviderInstanceId;
93 model: string;
94 selectedModels?: ReadonlyArray<{ instanceId: ProviderInstanceId; model: string }>;
95 onToggleModel?: (instanceId: ProviderInstanceId, model: string) => void;
96 lockedProvider: ProviderDriverKind | null;
97 lockedContinuationGroupKey?: string | null;
98 instanceEntries: ReadonlyArray<ProviderInstanceEntry>;
99 keybindings?: ResolvedKeybindingsConfig;
100 modelOptionsByInstance: ReadonlyMap<ProviderInstanceId, ReadonlyArray<ModelEsque>>;
101 activeProviderIconClassName?: string;
102 instanceIndicatorBackground?: string;
103 size?: ComposerControlSize;
104 compact?: boolean;
105 isComposerOwned?: boolean;
106 disabled?: boolean;
107 terminalOpen?: boolean;
108 open?: boolean;
109 popupAlign?: "start" | "end";
110 triggerClassName?: string;
111 triggerLabel?: string;
112 triggerAriaLabel?: string;
113 onOpenChange?: (open: boolean) => void;
114 onOpenProviderSetup?: (instanceId: ProviderInstanceId) => void;
115 getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null;
116 onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void;
117 /** Shown after the model name inside the same trigger, such as the effort level. */
118 triggerSuffix?: ReactNode;
119 /** Names the suffix for the tooltip and screen readers. */
120 triggerDetail?: string | null;
121 /** Composer shortcuts this trigger answers besides the picker's own. */
122 triggerShortcuts?: string;
123 /**
124 * Docks an effort column beside the list and a footer under both. The popup then stays open
125 * until Escape or an outside press, and picking a model moves focus to its levels.
126 */
127 effortColumn?: ReactNode;
128 footer?: ReactNode;
129}) {
130 const composerFloatingLayerProps = useComposerMenuProps();
131 const [uncontrolledIsMenuOpen, setUncontrolledIsMenuOpen] = useState(false);
132 const isMenuOpen = props.open ?? uncontrolledIsMenuOpen;
133 const registry = useContext(RegistryContext);
134 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
135 reportFailure: false,
136 });
137 const refreshModelCatalog = useEffectEvent(() => {
138 const environmentId = props.environmentId;
139 if (environmentId === null) return;
140 // An older server drops the field and probes every provider.
141 const config = registry.get(serverEnvironment.configValueAtom(environmentId));
142 if (config?.environment.capabilities.modelCatalogRefresh !== true) return;
143 void refreshProviders({ environmentId, input: { modelCatalogOnly: true } });
144 });
145 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- asks the server for newly released models on every open; a parent, a keybinding or the palette can open the picker, so no one handler sees them all
146 useEffect(() => {
147 if (isMenuOpen) refreshModelCatalog();
148 }, [isMenuOpen]);
149 const searchInputRef = useRef<HTMLInputElement>(null);
150 const triggerRef = useRef<HTMLButtonElement>(null);
151 const popupRef = useRef<HTMLDivElement | null>(null);
152 const size = props.size ?? "sm";
153 const docked = props.effortColumn !== undefined;
154
155 const activeEntry =
156 props.instanceEntries.find((entry) => entry.instanceId === props.activeInstanceId) ?? null;
157
158 const activeInstanceId = props.activeInstanceId;
159 const selectedInstanceOptions = props.modelOptionsByInstance.get(activeInstanceId) ?? [];
160 const selectedModel =
161 resolveModelPickerSelectedModel({
162 driverKind: activeEntry?.driverKind,
163 model: props.model,
164 options: selectedInstanceOptions,
165 }) ??
166 retainedModelWhileProviderLocked(activeEntry, props.model) ??
167 (activeEntry?.driverKind === "opencode" || activeEntry?.driverKind === "antigravity"
168 ? undefined
169 : selectedInstanceOptions[0]);
170 const triggerTitle = selectedModel
171 ? getTriggerDisplayModelName(selectedModel, activeEntry?.driverKind)
172 : props.model === ANTIGRAVITY_DEFAULT_MODEL
173 ? "Choose model"
174 : props.model || "Choose model";
175 const triggerLabel = selectedModel
176 ? `${getDisplayModelName(selectedModel)}${selectedModel.isUnavailable ? " (Unavailable)" : ""}`
177 : triggerTitle;
178 const showInstanceBadge =
179 activeEntry !== null && shouldShowInstanceBadge(activeEntry, props.instanceEntries);
180
181 const setIsMenuOpen = (open: boolean) => {
182 props.onOpenChange?.(open);
183 if (props.open === undefined) {
184 setUncontrolledIsMenuOpen(open);
185 }
186 };
187
188 /**
189 * Focuses the selected reasoning level, else the first level. With `rows: "any"` it falls back
190 * to the column's first enabled option. False when nothing took focus.
191 */
192 const focusEffort = (rows: "levels" | "any") => {
193 const column = popupRef.current?.querySelector("[data-model-picker-effort]");
194 const level =
195 column?.querySelector<HTMLElement>(
196 '[data-effort-level][aria-checked="true"]:not(:disabled)',
197 ) ?? column?.querySelector<HTMLElement>("[data-effort-level]:not(:disabled)");
198 // No aria-checked here: an On switch is checked but is not where focus belongs.
199 const row =
200 level ??
201 (rows === "any"
202 ? column?.querySelector<HTMLElement>("[data-effort-row]:not(:disabled)")
203 : null);
204 row?.focus({ preventScroll: true });
205 row?.scrollIntoView({ block: "nearest" });
206 return row != null;
207 };
208 const openTarget = (target: "effort" | "permissions") => {
209 if (target === "effort") {
210 focusEffort("any");
211 return;
212 }
213 popupRef.current
214 ?.querySelector<HTMLButtonElement>("[data-composer-permissions-trigger]")
215 ?.click();
216 };
217
218 const handleInstanceModelChange = (instanceId: ProviderInstanceId, model: string) => {
219 if (props.disabled) return;
220 props.onInstanceModelChange(instanceId, model);
221 if (!docked) {
222 setIsMenuOpen(false);
223 return;
224 }
225 // The column renders the new model's levels on commit.
226 window.requestAnimationFrame(() => focusEffort("levels"));
227 };
228
229 const shortcutLabel = props.keybindings
230 ? shortcutLabelForCommand(props.keybindings, "modelPicker.toggle")
231 : null;
232 const selectedEntries = props.selectedModels?.map((selection) => {
233 const entry = props.instanceEntries.find(
234 (candidate) => candidate.instanceId === selection.instanceId,
235 );
236 const model = resolveModelPickerSelectedModel({
237 driverKind: entry?.driverKind,
238 model: selection.model,
239 options: props.modelOptionsByInstance.get(selection.instanceId) ?? [],
240 });
241 const unavailable = model?.isUnavailable ? " (Unavailable)" : "";
242 return {
243 ...selection,
244 entry,
245 label: model
246 ? `${getTriggerDisplayModelName(model, entry?.driverKind)}${unavailable}`
247 : selection.model,
248 fullLabel: model ? `${getDisplayModelName(model)}${unavailable}` : selection.model,
249 };
250 });
251 const multipleLabel = selectedEntries
252 ? selectedEntries.length === 0
253 ? "Choose models"
254 : `${selectedEntries
255 .slice(0, 2)
256 .map((selection) => selection.label)
257 .join(", ")}${selectedEntries.length > 2 ? `, ${selectedEntries.length - 2} more` : ""}`
258 : undefined;
259 const allModelNames = selectedEntries
260 ? selectedEntries.map((selection) => selection.fullLabel).join(", ") || "Choose models"
261 : undefined;
262 const triggerTooltipContent = [
263 props.triggerLabel ?? allModelNames ?? triggerLabel,
264 props.triggerDetail,
265 shortcutLabel,
266 ]
267 .filter(Boolean)
268 .join(" · ");
269
270 const triggerText = props.triggerLabel ?? multipleLabel ?? triggerTitle;
271 const triggerTextProps = {
272 className: cn(
273 "min-w-0 flex-1 overflow-hidden truncate",
274 // Long names such as "Fusion (Claude Opus 5.5 Medium + SWE-2 High)" would push the composer.
275 props.isComposerOwned && "max-w-56",
276 ),
277 "data-chat-provider-model-picker-label": "true",
278 } as const;
279
280 const picker = (
281 <ModelPickerContent
282 searchInputRef={searchInputRef}
283 environmentId={props.environmentId}
284 workspaceCwd={props.workspaceCwd ?? null}
285 activeInstanceId={activeInstanceId}
286 model={props.model}
287 {...(props.selectedModels !== undefined ? { selectedModels: props.selectedModels } : {})}
288 {...(props.onToggleModel
289 ? {
290 onToggleModel: (instanceId: ProviderInstanceId, model: string) => {
291 if (!props.disabled) props.onToggleModel?.(instanceId, model);
292 },
293 }
294 : {})}
295 lockedProvider={props.lockedProvider}
296 lockedContinuationGroupKey={props.lockedContinuationGroupKey ?? null}
297 instanceEntries={props.instanceEntries}
298 {...(props.keybindings ? { keybindings: props.keybindings } : {})}
299 modelOptionsByInstance={props.modelOptionsByInstance}
300 terminalOpen={props.terminalOpen ?? false}
301 onRequestClose={() => setIsMenuOpen(false)}
302 {...(props.onOpenProviderSetup ? { onOpenProviderSetup: props.onOpenProviderSetup } : {})}
303 getModelDisabledReason={(instanceId, model) =>
304 props.disabled
305 ? "Model picker is disabled"
306 : (props.getModelDisabledReason?.(instanceId, model) ?? null)
307 }
308 onInstanceModelChange={handleInstanceModelChange}
309 {...(docked
310 ? {
311 docked,
312 onFocusEffort: () => void focusEffort("any"),
313 // Inside the fixed height, so switching to or from a ChatGPT plan never resizes it.
314 ...(props.selectedModels === undefined
315 ? { listFooter: <ChatGptSharingControl provider={activeEntry?.snapshot ?? null} /> }
316 : {}),
317 }
318 : {})}
319 />
320 );
321
322 return (
323 <Popover
324 open={isMenuOpen}
325 onOpenChange={(open) => {
326 if (props.disabled) {
327 setIsMenuOpen(false);
328 return;
329 }
330 setIsMenuOpen(open);
331 }}
332 onOpenChangeComplete={(open) => {
333 const target = takeOpenTarget(triggerRef.current);
334 if (open && docked && target) openTarget(target);
335 }}
336 >
337 <PopoverTrigger
338 render={
339 <ComposerControl
340 ref={triggerRef}
341 aria-label={[
342 props.triggerAriaLabel ?? props.triggerLabel ?? allModelNames ?? triggerLabel,
343 props.triggerDetail,
344 ]
345 .filter(Boolean)
346 .join(", ")}
347 size={size}
348 data-chat-provider-model-picker="true"
349 data-composer-shortcut={props.triggerShortcuts}
350 onClick={(event) => {
351 if (!isMenuOpen || !docked) return;
352 const target = takeOpenTarget(triggerRef.current);
353 if (!target) return;
354 // A shortcut aimed at the open picker moves within it instead of closing it.
355 (
356 event as typeof event & { preventBaseUIHandler?: () => void }
357 ).preventBaseUIHandler?.();
358 openTarget(target);
359 }}
360 className={cn(
361 "min-w-0 shrink justify-between whitespace-nowrap",
362 props.compact
363 ? "max-w-42 shrink-0"
364 : !props.isComposerOwned && "max-w-48 sm:max-w-56",
365 props.triggerClassName,
366 )}
367 disabled={props.disabled}
368 />
369 }
370 >
371 <span
372 className={cn("flex min-w-0 flex-1 items-center", size === "xs" ? "gap-1" : "gap-1.5")}
373 >
374 {selectedEntries && props.triggerLabel === undefined ? (
375 <span
376 data-chat-provider-model-picker-icon
377 className="flex shrink-0 items-center -space-x-1"
378 aria-hidden="true"
379 >
380 {selectedEntries
381 .slice(0, 3)
382 .map((selection) =>
383 selection.entry ? (
384 <ProviderInstanceIcon
385 key={`${selection.instanceId}:${selection.model}`}
386 driverKind={selection.entry.driverKind}
387 displayName={selection.entry.displayName}
388 accentColor={selection.entry.accentColor}
389 acpRegistryAgentId={selection.entry.acpRegistryAgentId}
390 acpRegistryIconUrl={selection.entry.acpRegistryIconUrl}
391 className="size-4 rounded-full bg-(--chat-composer-glass-surface,var(--background)) ring-2 ring-(--chat-composer-glass-surface,var(--background))"
392 iconClassName="size-4"
393 />
394 ) : null,
395 )}
396 {selectedEntries.length > 3 ? (
397 <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))">
398 +{selectedEntries.length - 3}
399 </span>
400 ) : null}
401 </span>
402 ) : activeEntry && props.triggerLabel === undefined ? (
403 // Narrow compacted composers hide the provider icon before truncating the name.
404 <span data-chat-provider-model-picker-icon className="contents">
405 <ProviderInstanceIcon
406 driverKind={activeEntry.driverKind}
407 displayName={activeEntry.displayName}
408 accentColor={activeEntry.accentColor}
409 acpRegistryAgentId={activeEntry.acpRegistryAgentId}
410 acpRegistryIconUrl={activeEntry.acpRegistryIconUrl}
411 showBadge={showInstanceBadge}
412 className="size-4"
413 iconClassName={cn("size-4", props.activeProviderIconClassName)}
414 indicatorBackground={props.instanceIndicatorBackground ?? "var(--contrast-input)"}
415 badgeClassName={cn(
416 "right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3 px-0.5 text-5xs",
417 size === "xs" && "shadow-none",
418 )}
419 />
420 </span>
421 ) : null}
422 {props.isComposerOwned ? (
423 <span {...triggerTextProps}>{triggerText}</span>
424 ) : (
425 <Tooltip>
426 <TooltipTrigger render={<span {...triggerTextProps} />}>{triggerText}</TooltipTrigger>
427 <TooltipPopup side="top">{triggerTooltipContent}</TooltipPopup>
428 </Tooltip>
429 )}
430 {props.triggerSuffix}
431 {selectedModel?.isUnavailable && !selectedEntries && props.triggerLabel === undefined ? (
432 <Badge variant="outline" size="sm">
433 Unavailable
434 </Badge>
435 ) : null}
436 </span>
437 </PopoverTrigger>
438 <PopoverPopup
439 {...(props.isComposerOwned ? composerFloatingLayerProps : {})}
440 ref={(popup) => {
441 popupRef.current = popup;
442 const unlock = lockBackgroundScroll(popup);
443 return () => {
444 popupRef.current = null;
445 unlock?.();
446 };
447 }}
448 initialFocus={searchInputRef}
449 align={props.popupAlign ?? "start"}
450 // Docked, the popup opens above the composer and slides over it when that space is short,
451 // so a short window gets its full height (Floating UI sizes to the viewport) instead of a sliver.
452 {...(docked
453 ? { side: "top" as const, collisionAvoidance: { side: "shift", align: "shift" } as const }
454 : {})}
455 className="before:hidden"
456 padding="none"
457 touchBackdrop
458 >
459 {props.effortColumn ? (
460 <DockedPickerContext value={{ modelLabel: triggerTitle }}>
461 <div className="flex max-h-(--available-height) flex-col">
462 {/* One fixed height, so a model with more options never resizes the popup. Short
463 windows shrink it and keep the footer. */}
464 <div className="flex h-86.5 min-h-0 max-sm:h-auto max-sm:flex-col">
465 {picker}
466 <div
467 data-model-picker-effort
468 className="flex min-h-0 w-48 flex-col sm:shrink-0 border-l border-border/70 bg-muted/40 max-sm:h-64 max-sm:w-full max-sm:border-t max-sm:border-l-0"
469 onKeyDown={(event) => {
470 if (event.key !== "ArrowLeft" || event.altKey || event.ctrlKey || event.metaKey)
471 return;
472 event.preventDefault();
473 event.stopPropagation();
474 searchInputRef.current?.focus();
475 }}
476 >
477 {props.effortColumn}
478 </div>
479 </div>
480 {props.footer}
481 </div>
482 </DockedPickerContext>
483 ) : (
484 picker
485 )}
486 {props.selectedModels === undefined && !docked ? (
487 <ChatGptSharingControl provider={activeEntry?.snapshot ?? null} />
488 ) : null}
489 </PopoverPopup>
490 </Popover>
491 );
492});