import React, { memo, useState, useRef, useEffect, useCallback, useMemo, forwardRef, useReducer, } from 'react'; import { useRecoilValue, useRecoilCallback } from 'recoil'; import type { LucideIcon } from 'lucide-react'; import CodeInterpreter from './CodeInterpreter'; import type { BadgeItem } from '~/common'; import { useChatBadges } from '~/hooks'; import { Badge } from '~/components/ui'; import MCPSelect from './MCPSelect'; import WebSearch from './WebSearch'; import store from '~/store'; interface BadgeRowProps { showEphemeralBadges?: boolean; onChange: (badges: Pick[]) => void; onToggle?: (badgeId: string, currentActive: boolean) => void; conversationId?: string | null; isInChat: boolean; } interface BadgeWrapperProps { badge: BadgeItem; isEditing: boolean; isInChat: boolean; onToggle: (badge: BadgeItem) => void; onDelete: (id: string) => void; onMouseDown: (e: React.MouseEvent, badge: BadgeItem, isActive: boolean) => void; badgeRefs: React.MutableRefObject>; } const BadgeWrapper = React.memo( forwardRef( ({ badge, isEditing, isInChat, onToggle, onDelete, onMouseDown, badgeRefs }, ref) => { const atomBadge = useRecoilValue(badge.atom); const isActive = badge.atom ? atomBadge : false; return (
{ if (el) { badgeRefs.current[badge.id] = el; } if (typeof ref === 'function') { ref(el); } else if (ref) { ref.current = el; } }} onMouseDown={(e) => onMouseDown(e, badge, isActive)} className={isEditing ? 'ios-wiggle badge-icon h-full' : 'badge-icon h-full'} > onToggle(badge)} onBadgeAction={() => onDelete(badge.id)} />
); }, ), (prevProps, nextProps) => prevProps.badge.id === nextProps.badge.id && prevProps.isEditing === nextProps.isEditing && prevProps.isInChat === nextProps.isInChat && prevProps.onToggle === nextProps.onToggle && prevProps.onDelete === nextProps.onDelete && prevProps.onMouseDown === nextProps.onMouseDown && prevProps.badgeRefs === nextProps.badgeRefs, ); BadgeWrapper.displayName = 'BadgeWrapper'; interface DragState { draggedBadge: BadgeItem | null; mouseX: number; offsetX: number; insertIndex: number | null; draggedBadgeActive: boolean; } type DragAction = | { type: 'START_DRAG'; badge: BadgeItem; mouseX: number; offsetX: number; insertIndex: number; isActive: boolean; } | { type: 'UPDATE_POSITION'; mouseX: number; insertIndex: number } | { type: 'END_DRAG' }; const dragReducer = (state: DragState, action: DragAction): DragState => { switch (action.type) { case 'START_DRAG': return { draggedBadge: action.badge, mouseX: action.mouseX, offsetX: action.offsetX, insertIndex: action.insertIndex, draggedBadgeActive: action.isActive, }; case 'UPDATE_POSITION': return { ...state, mouseX: action.mouseX, insertIndex: action.insertIndex, }; case 'END_DRAG': return { draggedBadge: null, mouseX: 0, offsetX: 0, insertIndex: null, draggedBadgeActive: false, }; default: return state; } }; function BadgeRow({ showEphemeralBadges, conversationId, onChange, onToggle, isInChat, }: BadgeRowProps) { const [orderedBadges, setOrderedBadges] = useState([]); const [dragState, dispatch] = useReducer(dragReducer, { draggedBadge: null, mouseX: 0, offsetX: 0, insertIndex: null, draggedBadgeActive: false, }); const badgeRefs = useRef>({}); const containerRef = useRef(null); const animationFrame = useRef(null); const containerRectRef = useRef(null); const allBadges = useChatBadges(); const isEditing = useRecoilValue(store.isEditingBadges); const badges = useMemo( () => allBadges.filter((badge) => badge.isAvailable !== false), [allBadges], ); const toggleBadge = useRecoilCallback( ({ snapshot, set }) => async (badgeAtom: any) => { const current = await snapshot.getPromise(badgeAtom); set(badgeAtom, !current); }, [], ); useEffect(() => { setOrderedBadges((prev) => { const currentIds = new Set(prev.map((b) => b.id)); const newBadges = badges.filter((b) => !currentIds.has(b.id)); return newBadges.length > 0 ? [...prev, ...newBadges] : prev; }); }, [badges]); const tempBadges = dragState.draggedBadge ? orderedBadges.filter((b) => b.id !== dragState.draggedBadge?.id) : orderedBadges; const ghostBadge = dragState.draggedBadge || null; const calculateInsertIndex = useCallback( (currentMouseX: number): number => { if (!dragState.draggedBadge || !containerRef.current || !containerRectRef.current) { return 0; } const relativeMouseX = currentMouseX - containerRectRef.current.left; const refs = tempBadges.map((b) => badgeRefs.current[b.id]).filter(Boolean); if (refs.length === 0) { return 0; } let idx = 0; for (let i = 0; i < refs.length; i++) { const rect = refs[i].getBoundingClientRect(); const relativeLeft = rect.left - containerRectRef.current.left; const relativeCenter = relativeLeft + rect.width / 2; if (relativeMouseX < relativeCenter) { break; } idx = i + 1; } return idx; }, [dragState.draggedBadge, tempBadges], ); const handleMouseDown = useCallback( (e: React.MouseEvent, badge: BadgeItem, isActive: boolean) => { if (!isEditing || !containerRef.current) { return; } const el = badgeRefs.current[badge.id]; if (!el) { return; } const rect = el.getBoundingClientRect(); const offsetX = e.clientX - rect.left; const mouseX = e.clientX; const initialIndex = orderedBadges.findIndex((b) => b.id === badge.id); containerRectRef.current = containerRef.current.getBoundingClientRect(); dispatch({ type: 'START_DRAG', badge, mouseX, offsetX, insertIndex: initialIndex, isActive, }); }, [isEditing, orderedBadges], ); const handleMouseMove = useCallback( (e: MouseEvent) => { if (!dragState.draggedBadge) { return; } if (animationFrame.current) { cancelAnimationFrame(animationFrame.current); } animationFrame.current = requestAnimationFrame(() => { const newMouseX = e.clientX; const newInsertIndex = calculateInsertIndex(newMouseX); if (newInsertIndex !== dragState.insertIndex) { dispatch({ type: 'UPDATE_POSITION', mouseX: newMouseX, insertIndex: newInsertIndex }); } else { dispatch({ type: 'UPDATE_POSITION', mouseX: newMouseX, insertIndex: dragState.insertIndex, }); } }); }, [dragState.draggedBadge, dragState.insertIndex, calculateInsertIndex], ); const handleMouseUp = useCallback(() => { if (dragState.draggedBadge && dragState.insertIndex !== null) { const otherBadges = orderedBadges.filter((b) => b.id !== dragState.draggedBadge?.id); const newBadges = [ ...otherBadges.slice(0, dragState.insertIndex), dragState.draggedBadge, ...otherBadges.slice(dragState.insertIndex), ]; setOrderedBadges(newBadges); onChange(newBadges.map((badge) => ({ id: badge.id }))); } dispatch({ type: 'END_DRAG' }); containerRectRef.current = null; }, [dragState.draggedBadge, dragState.insertIndex, orderedBadges, onChange]); const handleDelete = useCallback( (badgeId: string) => { const newBadges = orderedBadges.filter((b) => b.id !== badgeId); setOrderedBadges(newBadges); onChange(newBadges.map((badge) => ({ id: badge.id }))); }, [orderedBadges, onChange], ); const handleBadgeToggle = useCallback( (badge: BadgeItem) => { if (badge.atom) { toggleBadge(badge.atom); } if (onToggle) { onToggle(badge.id, !!badge.atom); } }, [toggleBadge, onToggle], ); useEffect(() => { if (!dragState.draggedBadge) { return; } document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); if (animationFrame.current) { cancelAnimationFrame(animationFrame.current); animationFrame.current = null; } }; }, [dragState.draggedBadge, handleMouseMove, handleMouseUp]); return (
{tempBadges.map((badge, index) => ( {dragState.draggedBadge && dragState.insertIndex === index && ghostBadge && (
)}
))} {dragState.draggedBadge && dragState.insertIndex === tempBadges.length && ghostBadge && (
)} {showEphemeralBadges === true && ( <> )} {ghostBadge && (
)}
); } export default memo(BadgeRow);