import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type PointerEvent, } from "react"; import { CircleHelp, Sparkles } from "lucide-react"; import { useLogoFallback } from "@/hooks/useLogoFallback"; import { inferProviderFromModelName, providerBrand } from "@/lib/provider-brand"; import { cn } from "@/lib/utils"; export interface ModelPresetOption { name: string; label: string; model?: string | null; provider?: string | null; } interface ModelPresetBadgeProps { label: string; modelDetail?: string | null; modelPreset?: string | null; modelPresets?: ModelPresetOption[]; onPresetChange?: (name: string) => void; provider?: string | null; providerLabel?: string | null; needsSetup?: boolean; fallbackModelName?: string | null; isHero: boolean; onClick?: () => void; } 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; } 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 = 180; 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(); } export function ModelPresetBadge({ label, modelDetail, modelPreset, modelPresets = [], onPresetChange, provider, providerLabel, needsSetup = false, fallbackModelName, isHero, onClick, }: ModelPresetBadgeProps) { const activeName = modelPreset?.trim() || ""; const listedIndex = modelPresets.findIndex((preset) => preset.name === activeName); const activePreset: ModelPresetOption = { ...(listedIndex >= 0 ? modelPresets[listedIndex] : undefined), name: activeName, label: label || modelPresets[listedIndex]?.label || activeName, model: modelDetail ?? modelPresets[listedIndex]?.model, provider: provider || modelPresets[listedIndex]?.provider, }; const presets = !activeName ? modelPresets : listedIndex < 0 ? [activePreset, ...modelPresets] : modelPresets.map((preset, index) => index === listedIndex ? activePreset : preset); const interactive = Boolean(onClick); const canSwitch = !interactive && 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 [motion, setMotion] = useState(null); const gestureRef = useRef(null); function clearGesture() { const gesture = gestureRef.current; if (gesture?.timer) clearTimeout(gesture.timer); if (gesture?.active) gesture.target.removeEventListener("touchmove", preventTouchScroll); gestureRef.current = null; } useEffect(() => { if (!canSwitch) { clearGesture(); setMotion(null); } return clearGesture; }, [canSwitch]); useEffect(() => { if (!motion?.settling) return; const timer = setTimeout(() => setMotion(null), SETTLE_MS + 80); return () => clearTimeout(timer); }, [motion?.settling]); function 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 }); } function handlePointerDown(event: PointerEvent) { if (!canSwitch || gestureRef.current || motion || event.isPrimary === false) 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; 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; } function 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); } function 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) { setMotion(null); return; } const selected = presets[wrapIndex(gesture.baseIndex + gesture.step, presets.length)]; setMotion((current) => current && { ...current, remainder: 0, settling: true }); if (selected && selected.name !== activeName) onPresetChange?.(selected.name); } function handleKeyDown(event: KeyboardEvent) { if (!canSwitch) return; 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) onPresetChange?.(next.name); } const previewIndex = wrapIndex(motion?.index ?? currentIndex, presets.length); const previewPreset = presets[previewIndex]; const Container = interactive || canSwitch ? "button" : "span"; const trackOffset = motion ? -pillStride * (2 + motion.remainder) : 0; return ( { const gesture = gestureRef.current; if (gesture && gesture.pointerId === event.pointerId && !gesture.active) clearGesture(); }} onPointerUp={(event) => finishGesture(event, true)} onPointerCancel={(event) => finishGesture(event, false)} onLostPointerCapture={(event) => finishGesture(event, false)} onContextMenu={(event) => { if (gestureRef.current?.active) event.preventDefault(); }} onDragStart={(event) => event.preventDefault()} style={{ touchAction: canSwitch ? "manipulation" : undefined }} className={cn( "thread-composer-model-badge group/model-badge relative inline-flex w-fit min-w-0 max-w-[min(18rem,44vw)] justify-end appearance-none border-0 bg-transparent p-0 shadow-none", interactive && "cursor-pointer", canSwitch && "cursor-grab select-none focus-visible:outline-none", motion && "z-10 cursor-grabbing", isHero ? "h-8" : "h-9", )} > {motion ? ( { if (motion.settling && event.currentTarget === event.target) setMotion(null); }} style={{ paddingTop: isHero ? "10px" : "12px", transform: `translate3d(0, ${trackOffset}px, 0)`, }} > {PILL_OFFSETS.map((offset) => { const virtualIndex = motion.index + offset; const preset = presets[wrapIndex(virtualIndex, presets.length)]; const scale = motion.settling ? 1 : dockScale(offset - motion.remainder); return ( ); })} ) : null} ); } function PresetPill({ className, label, modelDetail, provider, providerLabel, needsSetup = false, fallbackModelName, isHero, offset, scale, }: { className?: string | false | null; label: string; modelDetail?: string | null; provider?: string | null; providerLabel?: string | null; needsSetup?: boolean; fallbackModelName?: 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 brand = providerBrand(inferredProvider); const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls); const title = [...new Set([label, modelDetail, providerLabel].filter(Boolean))].join(" ยท "); const logoTestId = offset !== undefined ? undefined : needsSetup ? "composer-model-setup-icon" : `composer-model-logo${inferredProvider ? `-${inferredProvider}` : ""}`; 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 ( {needsSetup ? ( ) : logoUrl ? ( ) : brand ? ( {brand.initials.slice(0, 2)} ) : ( )} {label} ); }