import { ANTIGRAVITY_DEFAULT_MODEL, type ProviderInstanceId, type EnvironmentId, type ProviderDriverKind, type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { createContext, memo, type ReactNode, useRef, useState } from "react"; import { Badge } from "../ui/badge"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { cn } from "~/lib/utils"; import { ModelPickerContent, resolveModelPickerSelectedModel } from "./ModelPickerContent"; import { ChatGptSharingControl } from "./ChatGptSharingControl"; import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; import { ModelEsque, getDisplayModelName, getTriggerDisplayModelName } from "./providerIconUtils"; import { shouldShowInstanceBadge, type ProviderInstanceEntry } from "../../providerInstances"; import { ComposerControl, type ComposerControlSize } from "./ComposerControl"; import { takeOpenTarget, useComposerMenuProps } from "./composerEventScope"; import { shortcutLabelForCommand } from "../../keybindings"; /** Holds the page still behind the open picker; its lifetime is the popup element's. */ function lockBackgroundScroll(popup: HTMLElement | null) { if (!popup) return; const { documentElement, body } = document; const previousDocumentOverscrollBehavior = documentElement.style.overscrollBehavior; const previousBodyOverflow = body.style.overflow; const previousBodyPaddingRight = body.style.paddingRight; const scrollbarWidth = window.innerWidth - documentElement.clientWidth; documentElement.style.overscrollBehavior = "contain"; body.style.overflow = "hidden"; if (scrollbarWidth > 0) { body.style.paddingRight = `${scrollbarWidth}px`; } const shouldAllowOverlayScroll = (target: EventTarget | null) => { return ( target instanceof Element && target.closest( "[data-model-picker-content], [data-model-picker-effort], [data-model-picker-option-menu]", ) ); }; const preventBackgroundWheel = (event: WheelEvent) => { if (shouldAllowOverlayScroll(event.target)) { return; } event.preventDefault(); }; const preventBackgroundTouchMove = (event: TouchEvent) => { if (shouldAllowOverlayScroll(event.target)) { return; } event.preventDefault(); }; document.addEventListener("wheel", preventBackgroundWheel, { capture: true, passive: false }); document.addEventListener("touchmove", preventBackgroundTouchMove, { capture: true, passive: false, }); return () => { document.removeEventListener("wheel", preventBackgroundWheel, { capture: true }); document.removeEventListener("touchmove", preventBackgroundTouchMove, { capture: true }); documentElement.style.overscrollBehavior = previousDocumentOverscrollBehavior; body.style.overflow = previousBodyOverflow; body.style.paddingRight = previousBodyPaddingRight; }; } /** What a docked effort column needs from its picker: the model it serves. */ export const DockedPickerContext = createContext<{ modelLabel: string }>({ modelLabel: "" }); export const ProviderModelPicker = memo(function ProviderModelPicker(props: { environmentId: EnvironmentId | null; activeInstanceId: ProviderInstanceId; model: string; selectedModels?: ReadonlyArray<{ instanceId: ProviderInstanceId; model: string }>; onToggleModel?: (instanceId: ProviderInstanceId, model: string) => void; lockedProvider: ProviderDriverKind | null; lockedContinuationGroupKey?: string | null; instanceEntries: ReadonlyArray; keybindings?: ResolvedKeybindingsConfig; modelOptionsByInstance: ReadonlyMap>; activeProviderIconClassName?: string; instanceIndicatorBackground?: string; size?: ComposerControlSize; compact?: boolean; isComposerOwned?: boolean; disabled?: boolean; terminalOpen?: boolean; open?: boolean; popupAlign?: "start" | "end"; triggerClassName?: string; triggerLabel?: string; triggerAriaLabel?: string; onOpenChange?: (open: boolean) => void; onOpenProviderSetup?: (instanceId: ProviderInstanceId) => void; getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null; onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void; /** Shown after the model name inside the same trigger, such as the effort level. */ triggerSuffix?: ReactNode; /** Names the suffix for the tooltip and screen readers. */ triggerDetail?: string | null; /** Composer shortcuts this trigger answers besides the picker's own. */ triggerShortcuts?: string; /** * Docks an effort column beside the list and a footer under both. The popup then stays open * until Escape or an outside press, and picking a model moves focus to its levels. */ effortColumn?: ReactNode; footer?: ReactNode; }) { const composerFloatingLayerProps = useComposerMenuProps(); const [uncontrolledIsMenuOpen, setUncontrolledIsMenuOpen] = useState(false); const isMenuOpen = props.open ?? uncontrolledIsMenuOpen; const searchInputRef = useRef(null); const triggerRef = useRef(null); const popupRef = useRef(null); const size = props.size ?? "sm"; const docked = props.effortColumn !== undefined; const activeEntry = props.instanceEntries.find((entry) => entry.instanceId === props.activeInstanceId) ?? null; const activeInstanceId = props.activeInstanceId; const selectedInstanceOptions = props.modelOptionsByInstance.get(activeInstanceId) ?? []; const selectedModel = resolveModelPickerSelectedModel({ driverKind: activeEntry?.driverKind, model: props.model, options: selectedInstanceOptions, }) ?? (activeEntry?.driverKind === "opencode" || activeEntry?.driverKind === "antigravity" ? undefined : selectedInstanceOptions[0]); const triggerTitle = selectedModel ? getTriggerDisplayModelName(selectedModel, activeEntry?.driverKind) : props.model === ANTIGRAVITY_DEFAULT_MODEL ? "Choose model" : props.model || "Choose model"; const triggerLabel = selectedModel ? `${getDisplayModelName(selectedModel)}${selectedModel.isUnavailable ? " (Unavailable)" : ""}` : triggerTitle; const showInstanceBadge = activeEntry !== null && shouldShowInstanceBadge(activeEntry, props.instanceEntries); const setIsMenuOpen = (open: boolean) => { props.onOpenChange?.(open); if (props.open === undefined) { setUncontrolledIsMenuOpen(open); } }; /** * Focuses the selected reasoning level, else the first level. With `rows: "any"` it falls back * to the column's first enabled option. False when nothing took focus. */ const focusEffort = (rows: "levels" | "any") => { const column = popupRef.current?.querySelector("[data-model-picker-effort]"); const level = column?.querySelector( '[data-effort-level][aria-checked="true"]:not(:disabled)', ) ?? column?.querySelector("[data-effort-level]:not(:disabled)"); // No aria-checked here: an On switch is checked but is not where focus belongs. const row = level ?? (rows === "any" ? column?.querySelector("[data-effort-row]:not(:disabled)") : null); row?.focus({ preventScroll: true }); row?.scrollIntoView({ block: "nearest" }); return row != null; }; const openTarget = (target: "effort" | "permissions") => { if (target === "effort") { focusEffort("any"); return; } popupRef.current ?.querySelector("[data-composer-permissions-trigger]") ?.click(); }; const handleInstanceModelChange = (instanceId: ProviderInstanceId, model: string) => { if (props.disabled) return; props.onInstanceModelChange(instanceId, model); if (!docked) { setIsMenuOpen(false); return; } // The column renders the new model's levels on commit. window.requestAnimationFrame(() => focusEffort("levels")); }; const shortcutLabel = props.keybindings ? shortcutLabelForCommand(props.keybindings, "modelPicker.toggle") : null; const selectedEntries = props.selectedModels?.map((selection) => { const entry = props.instanceEntries.find( (candidate) => candidate.instanceId === selection.instanceId, ); const model = resolveModelPickerSelectedModel({ driverKind: entry?.driverKind, model: selection.model, options: props.modelOptionsByInstance.get(selection.instanceId) ?? [], }); const unavailable = model?.isUnavailable ? " (Unavailable)" : ""; return { ...selection, entry, label: model ? `${getTriggerDisplayModelName(model, entry?.driverKind)}${unavailable}` : selection.model, fullLabel: model ? `${getDisplayModelName(model)}${unavailable}` : selection.model, }; }); const multipleLabel = selectedEntries ? selectedEntries.length === 0 ? "Choose models" : `${selectedEntries .slice(0, 2) .map((selection) => selection.label) .join(", ")}${selectedEntries.length > 2 ? `, ${selectedEntries.length - 2} more` : ""}` : undefined; const allModelNames = selectedEntries ? selectedEntries.map((selection) => selection.fullLabel).join(", ") || "Choose models" : undefined; const triggerTooltipContent = [ props.triggerLabel ?? allModelNames ?? triggerLabel, props.triggerDetail, shortcutLabel, ] .filter(Boolean) .join(" ยท "); const triggerText = props.triggerLabel ?? multipleLabel ?? triggerTitle; const triggerTextProps = { className: cn( "min-w-0 flex-1 overflow-hidden truncate", // Long names such as "Fusion (Claude Opus 5.5 Medium + SWE-2 High)" would push the composer. props.isComposerOwned && "max-w-56", ), "data-chat-provider-model-picker-label": "true", } as const; const picker = ( { if (!props.disabled) props.onToggleModel?.(instanceId, model); }, } : {})} lockedProvider={props.lockedProvider} lockedContinuationGroupKey={props.lockedContinuationGroupKey ?? null} instanceEntries={props.instanceEntries} {...(props.keybindings ? { keybindings: props.keybindings } : {})} modelOptionsByInstance={props.modelOptionsByInstance} terminalOpen={props.terminalOpen ?? false} onRequestClose={() => setIsMenuOpen(false)} {...(props.onOpenProviderSetup ? { onOpenProviderSetup: props.onOpenProviderSetup } : {})} getModelDisabledReason={(instanceId, model) => props.disabled ? "Model picker is disabled" : (props.getModelDisabledReason?.(instanceId, model) ?? null) } onInstanceModelChange={handleInstanceModelChange} {...(docked ? { docked, onFocusEffort: () => void focusEffort("any"), // Inside the fixed height, so switching to or from a ChatGPT plan never resizes it. ...(props.selectedModels === undefined ? { listFooter: } : {}), } : {})} /> ); return ( { if (props.disabled) { setIsMenuOpen(false); return; } setIsMenuOpen(open); }} onOpenChangeComplete={(open) => { const target = takeOpenTarget(triggerRef.current); if (open && docked && target) openTarget(target); }} > { if (!isMenuOpen || !docked) return; const target = takeOpenTarget(triggerRef.current); if (!target) return; // A shortcut aimed at the open picker moves within it instead of closing it. ( event as typeof event & { preventBaseUIHandler?: () => void } ).preventBaseUIHandler?.(); openTarget(target); }} className={cn( "min-w-0 shrink justify-between whitespace-nowrap", props.compact ? "max-w-42 shrink-0" : !props.isComposerOwned && "max-w-48 sm:max-w-56", props.triggerClassName, )} disabled={props.disabled} /> } > {selectedEntries && props.triggerLabel === undefined ? ( ) : activeEntry && props.triggerLabel === undefined ? ( // Narrow compacted composers hide the provider icon before truncating the name. ) : null} {props.isComposerOwned ? ( {triggerText} ) : ( }>{triggerText} {triggerTooltipContent} )} {props.triggerSuffix} {selectedModel?.isUnavailable && !selectedEntries && props.triggerLabel === undefined ? ( Unavailable ) : null} { popupRef.current = popup; const unlock = lockBackgroundScroll(popup); return () => { popupRef.current = null; unlock?.(); }; }} initialFocus={searchInputRef} align={props.popupAlign ?? "start"} // Docked, the popup opens above the composer and slides over it when that space is short, // so a short window gets its full height (Floating UI sizes to the viewport) instead of a sliver. {...(docked ? { side: "top" as const, collisionAvoidance: { side: "shift", align: "shift" } as const } : {})} className="before:hidden" padding="none" touchBackdrop > {props.effortColumn ? (
{/* One fixed height, so a model with more options never resizes the popup. Short windows shrink it and keep the footer. */}
{picker}
{ if (event.key !== "ArrowLeft" || event.altKey || event.ctrlKey || event.metaKey) return; event.preventDefault(); event.stopPropagation(); searchInputRef.current?.focus(); }} > {props.effortColumn}
{props.footer}
) : ( picker )} {props.selectedModels === undefined && !docked ? ( ) : null}
); });