import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type PointerEvent, } from "react"; import { Check, CircleHelp, Sparkles } from "lucide-react"; import { useTranslation } from "react-i18next"; import { floatingItemClassName, floatingItemFocusClassName, } from "@/components/ui/floating-surface"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { useLogoFallback } from "@/hooks/useLogoFallback"; import { inferProviderFromModelName, providerBrand } from "@/lib/provider-brand"; import { cn } from "@/lib/utils"; const pickerWidthClassName = "w-[min(18rem,calc(100vw-2rem))]"; const LONG_PRESS_MS = 400; const PRESS_SLOP_PX = 8; const PILL_GAP_PX = 4; const PILL_OFFSETS = [-2, -1, 0, 1, 2] as const; const HANDOFF_THRESHOLD = 0.56; const DOCK_MAX_SCALE = 1.08; const DOCK_RADIUS = 1.5; const SETTLE_MS = 200; interface PresetGesture { active: boolean; baseIndex: number; latestY: number; pointerId: number; startY: number; step: number; target: HTMLElement; timer: ReturnType | null; } interface PresetMotion { index: number; remainder: number; settling: boolean; } function wrapIndex(index: number, length: number): number { return ((index % length) + length) % length; } function dockScale(distanceFromFocus: number): number { const distance = Math.abs(distanceFromFocus); if (distance >= DOCK_RADIUS) return 1; const influence = (1 + Math.cos(Math.PI * distance / DOCK_RADIUS)) / 2; return 1 + (DOCK_MAX_SCALE - 1) * influence; } function stepWithHysteresis(raw: number, current: number): number { let next = current; while (raw > next + HANDOFF_THRESHOLD) next += 1; while (raw < next - HANDOFF_THRESHOLD) next -= 1; return next; } function preventTouchScroll(event: TouchEvent) { if (event.cancelable) event.preventDefault(); } function compactModelName(model?: string | null): string | null { const value = model?.trim(); if (!value) return null; return value.split("/").at(-1) || value; } export interface ModelPresetOption { name: string; model?: string | null; provider?: string | null; } interface ModelPresetBadgeProps { label: string; modelDetail?: string | null; modelPreset?: string | null; modelPresets?: ModelPresetOption[]; onPresetChange?: (name: string) => void; onRequestComposerFocus?: () => void; provider?: string | null; providerLabel?: string | null; needsSetup?: boolean; fallbackModelName?: string | null; isHero: boolean; onClick?: () => void; } export function ModelPresetBadge({ label, modelDetail, modelPreset, modelPresets = [], onPresetChange, onRequestComposerFocus, provider, providerLabel, needsSetup = false, fallbackModelName, isHero, onClick, }: ModelPresetBadgeProps) { const { t } = useTranslation(); const [open, setOpen] = useState(false); const [motion, setMotion] = useState(null); const [motionWidth, setMotionWidth] = useState(null); const gestureRef = useRef(null); const suppressClickRef = useRef(false); const activeName = modelPreset?.trim() || ""; const listedIndex = modelPresets.findIndex((preset) => preset.name === activeName); const activePreset: ModelPresetOption = { ...(listedIndex >= 0 ? modelPresets[listedIndex] : undefined), name: activeName, model: modelDetail ?? modelPresets[listedIndex]?.model, provider: provider || modelPresets[listedIndex]?.provider, }; const fallbackPreset = fallbackModelName ? modelPresets.find((preset) => preset.model?.trim() === fallbackModelName.trim()) : undefined; const fallbackDisplayLabel = fallbackPreset?.name || fallbackModelName?.trim().split(/[/:]/).pop() || null; const displayLabel = fallbackDisplayLabel || label; const displayModelDetail = fallbackPreset ? fallbackPreset.model : fallbackModelName ? null : modelDetail; const displayProvider = fallbackPreset?.provider || (fallbackModelName ? inferProviderFromModelName(fallbackModelName) : provider); const presets = !activeName ? modelPresets : listedIndex < 0 ? [activePreset, ...modelPresets] : modelPresets.map((preset, index) => index === listedIndex ? activePreset : preset); const opensSetup = Boolean(onClick); const canSwitch = !opensSetup && Boolean(onPresetChange) && activeName !== "" && presets.length > 1; const currentIndex = Math.max(0, presets.findIndex((preset) => preset.name === activeName)); const pillHeight = isHero ? 32 : 36; const pillStride = pillHeight + PILL_GAP_PX; const switchModelLabel = t("thread.composer.switchModel", { defaultValue: "Switch model for this chat", }); const selectPreset = (name: string) => { setOpen(false); if (name !== activeName) onPresetChange?.(name); requestAnimationFrame(() => onRequestComposerFocus?.()); }; const clearGesture = () => { const gesture = gestureRef.current; if (gesture?.timer) clearTimeout(gesture.timer); if (gesture?.active) gesture.target.removeEventListener("touchmove", preventTouchScroll); gestureRef.current = null; }; const clearMotion = () => { setMotion(null); setMotionWidth(null); }; useEffect(() => { if (!canSwitch) { clearGesture(); clearMotion(); } return clearGesture; }, [canSwitch]); useEffect(() => { if (!motion?.settling) return; const timer = setTimeout(clearMotion, SETTLE_MS + 80); return () => clearTimeout(timer); }, [motion?.settling]); const updateMotion = (gesture: PresetGesture, clientY: number) => { const raw = -(clientY - gesture.startY) / pillStride; gesture.step = stepWithHysteresis(raw, gesture.step); setMotion({ index: gesture.baseIndex + gesture.step, remainder: raw - gesture.step, settling: false, }); }; const handlePointerDown = (event: PointerEvent) => { if (!canSwitch || gestureRef.current || motion) return; if (event.pointerType === "mouse" && event.button !== 0) return; const gesture: PresetGesture = { active: false, baseIndex: currentIndex, latestY: event.clientY, pointerId: event.pointerId, startY: event.clientY, step: 0, target: event.currentTarget, timer: null, }; gesture.timer = setTimeout(() => { if (gestureRef.current !== gesture) return; gesture.active = true; setMotionWidth(Math.round(gesture.target.getBoundingClientRect().width) || null); updateMotion(gesture, gesture.latestY); gesture.target.addEventListener("touchmove", preventTouchScroll, { passive: false }); try { gesture.target.setPointerCapture(gesture.pointerId); } catch { // The pointer may already have ended. } }, LONG_PRESS_MS); gestureRef.current = gesture; }; const handlePointerMove = (event: PointerEvent) => { const gesture = gestureRef.current; if (!gesture || gesture.pointerId !== event.pointerId) return; gesture.latestY = event.clientY; if (!gesture.active) { if (Math.abs(event.clientY - gesture.startY) > PRESS_SLOP_PX) clearGesture(); return; } event.preventDefault(); updateMotion(gesture, event.clientY); }; const finishGesture = (event: PointerEvent, commit: boolean) => { const gesture = gestureRef.current; if (!gesture || gesture.pointerId !== event.pointerId) return; clearGesture(); if (event.currentTarget.hasPointerCapture?.(gesture.pointerId)) { event.currentTarget.releasePointerCapture?.(gesture.pointerId); } if (!commit || !gesture.active) { clearMotion(); return; } suppressClickRef.current = true; const selected = presets[wrapIndex(gesture.baseIndex + gesture.step, presets.length)]; setMotion((current) => current && { ...current, remainder: 0, settling: true }); if (selected && selected.name !== activeName) selectPreset(selected.name); }; const handleKeyDown = (event: KeyboardEvent) => { const targetByKey: Record = { ArrowUp: currentIndex - 1, ArrowDown: currentIndex + 1, Home: 0, End: presets.length - 1, }; const target = targetByKey[event.key]; if (target === undefined) return; event.preventDefault(); const next = presets[wrapIndex(target, presets.length)]; if (next?.name !== activeName) selectPreset(next.name); }; const pill = ( ); if (!canSwitch) { const Container = opensSetup ? "button" : "span"; return ( {pill} ); } return ( { setOpen(nextOpen); if (!nextOpen) requestAnimationFrame(() => onRequestComposerFocus?.()); }} > { event.preventDefault(); const content = event.currentTarget; if (!(content instanceof HTMLElement)) return; const selected = content.querySelector( '[role="option"][aria-selected="true"]', ); selected?.focus(); }} className={cn( pickerWidthClassName, "origin-[var(--radix-popover-content-transform-origin)] p-1.5 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-bottom-1 data-[state=open]:slide-in-from-bottom-1 duration-200 ease-out motion-reduce:animate-none", )} >
{presets.map((preset) => ( ))}
); } function PresetOption({ preset, selected, onSelect, }: { preset: ModelPresetOption; selected: boolean; onSelect: (name: string) => void; }) { const detail = compactModelName(preset.model); return ( ); } function PresetPill({ label, modelDetail, provider, providerLabel, needsSetup = false, fallbackModelName, fallbackFromLabel, isHero, offset, scale, }: { label: string; modelDetail?: string | null; provider?: string | null; providerLabel?: string | null; needsSetup?: boolean; fallbackModelName?: string | null; fallbackFromLabel?: string | null; isHero: boolean; offset?: number; scale?: number; }) { const labelRef = useRef(null); const [labelOverflows, setLabelOverflows] = useState(false); const inferredProvider = needsSetup ? null : provider || inferProviderFromModelName(modelDetail || label); const title = [...new Set([label, modelDetail, providerLabel].filter(Boolean))].join(" · "); const fallbackTitle = fallbackModelName ? `${fallbackFromLabel || label} · using ${fallbackModelName}` : title; useLayoutEffect(() => { const node = labelRef.current; if (!node) return; const update = () => setLabelOverflows(node.scrollWidth > node.clientWidth + 1); update(); const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(update); observer?.observe(node); return () => observer?.disconnect(); }, [label]); return ( {label} ); } function PresetProviderIcon({ label, modelDetail, provider, needsSetup = false, testId, isHero, }: { label: string; modelDetail?: string | null; provider?: string | null; needsSetup?: boolean; testId?: string; isHero: boolean; }) { const inferredProvider = needsSetup ? null : provider || inferProviderFromModelName(modelDetail || label); const brand = providerBrand(inferredProvider); const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls); return ( {needsSetup ? ( ) : logoUrl ? ( ) : brand ? ( {brand.initials.slice(0, 2)} ) : ( )} ); }