| 1 | import { |
| 2 | ANTIGRAVITY_DEFAULT_MODEL, |
| 3 | type ProviderInstanceId, |
| 4 | type EnvironmentId, |
| 5 | type ProviderDriverKind, |
| 6 | type ResolvedKeybindingsConfig, |
| 7 | } from "@t3tools/contracts"; |
| 8 | import { memo, useEffect, useMemo, useState } from "react"; |
| 9 | import { Badge } from "../ui/badge"; |
| 10 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 11 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 12 | import { cn } from "~/lib/utils"; |
| 13 | import { ModelPickerContent, resolveModelPickerSelectedModel } from "./ModelPickerContent"; |
| 14 | import { ChatGptSharingControl } from "./ChatGptSharingControl"; |
| 15 | import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; |
| 16 | import { |
| 17 | ModelEsque, |
| 18 | getTriggerDisplayModelLabel, |
| 19 | getTriggerDisplayModelName, |
| 20 | } from "./providerIconUtils"; |
| 21 | import { shouldShowInstanceBadge, type ProviderInstanceEntry } from "../../providerInstances"; |
| 22 | import { |
| 23 | ComposerControl, |
| 24 | ComposerControlChevron, |
| 25 | type ComposerControlSize, |
| 26 | } from "./ComposerControl"; |
| 27 | import { useComposerMenuProps } from "./composerEventScope"; |
| 28 | import { shortcutLabelForCommand } from "../../keybindings"; |
| 29 | |
| 30 | export 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 | }); |