diff --git a/client/src/components/Chat/Messages/MessageNav.tsx b/client/src/components/Chat/Messages/MessageNav.tsx index fd96ab9d5d..a6545420a0 100644 --- a/client/src/components/Chat/Messages/MessageNav.tsx +++ b/client/src/components/Chat/Messages/MessageNav.tsx @@ -1,7 +1,7 @@ import { memo, useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import { createPortal } from 'react-dom'; import { ChevronUp, ChevronDown } from 'lucide-react'; import { ContentTypes } from 'librechat-data-provider'; -import { HoverCard, HoverCardTrigger, HoverCardPortal, HoverCardContent } from '@librechat/client'; import type { TMessage, TMessageContentParts } from 'librechat-data-provider'; import { useMessagesConversation, useMessagesSubmission } from '~/Providers'; import { useGetMessagesByConvoId } from '~/data-provider'; @@ -17,7 +17,7 @@ type MessageEntry = { const MESSAGES_END_ID = 'messages-end'; -function extractPreviewFromContent(content?: TMessageContentParts[]): string { +export function extractPreviewFromContent(content?: TMessageContentParts[]): string { if (!content) { return ''; } @@ -36,7 +36,7 @@ function extractPreviewFromContent(content?: TMessageContentParts[]): string { return ''; } -function buildEntry(id: string, msg: TMessage): MessageEntry { +export function buildEntry(id: string, msg: TMessage): MessageEntry { const raw = msg.text?.trim() ? msg.text : extractPreviewFromContent(msg.content); const trimmed = raw.trim(); return { @@ -48,7 +48,7 @@ function buildEntry(id: string, msg: TMessage): MessageEntry { const USER_TURN_SELECTOR = '.user-turn'; -function buildFallbackEntry(node: HTMLElement, id: string): MessageEntry { +export function buildFallbackEntry(node: HTMLElement, id: string): MessageEntry { const isUser = node.querySelector(USER_TURN_SELECTOR) != null; const trimmed = (node.textContent ?? '').trim(); return { @@ -61,12 +61,14 @@ function buildFallbackEntry(node: HTMLElement, id: string): MessageEntry { function getMessageEntries(root: ParentNode, messagesById: Map): MessageEntry[] { const nodes = root.querySelectorAll('.message-render'); const entries: MessageEntry[] = []; + const seen = new Set(); for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; const id = node.id; - if (!id) { + if (!id || seen.has(id)) { continue; } + seen.add(id); const msg = messagesById.get(id); entries.push(msg ? buildEntry(id, msg) : buildFallbackEntry(node, id)); } @@ -105,64 +107,67 @@ function computeTargetScroll( return Math.max(0, Math.min(target, max)); } +type RibDims = { baseW: number; baseH: number; peakW: number; peakH: number }; + +const RIB_END: RibDims = { baseW: 4, baseH: 4, peakW: 6, peakH: 6 }; +const RIB_MESSAGE: RibDims = { baseW: 16, baseH: 3, peakW: 52, peakH: 6 }; + +/** Vertical falloff radius (content-space px) over which neighbouring ribs magnify. */ +const MAG_INFLUENCE = 50; +/** Delay before the shared preview first opens; subsequent moves reposition instantly. */ +const TOOLTIP_OPEN_DELAY = 60; + +export function ribDimsFor(entry: MessageEntry): RibDims { + return entry.isEnd ? RIB_END : RIB_MESSAGE; +} + +/** Cosine bell: 1 at the pointer, easing to 0 at the influence radius. */ +export function magnifyFalloff(distance: number, influence: number): number { + if (distance >= influence) { + return 0; + } + return 0.5 * (1 + Math.cos((Math.PI * distance) / influence)); +} + const indicatorButtonClasses = cn( - 'flex h-[5px] items-center justify-center rounded-sm', + 'flex h-1.5 w-full items-center justify-end rounded-sm', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy', ); const MessageIndicator = memo(function MessageIndicator({ entry, - isActive, + isHighlighted, isCurrent, label, - hoverText, onSelect, }: { entry: MessageEntry; - isActive: boolean; + isHighlighted: boolean; isCurrent: boolean; label: string; - hoverText: string; onSelect: (id: string) => void; }) { + const baseSize = entry.isEnd ? 'h-1 w-1' : 'h-[3px] w-4'; return ( - - - - - - -

{hoverText}

-
-
-
+ ); }); @@ -215,6 +220,30 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject(null); const dragCleanupRef = useRef<(() => void) | null>(null); const suppressClickRef = useRef(false); + const isDraggingRef = useRef(false); + + const ribLayoutRef = useRef< + Array<{ id: string; line: HTMLElement; center: number; dims: RibDims }> + >([]); + const pointerYRef = useRef(null); + const magRafRef = useRef(null); + const reducedMotionRef = useRef(false); + const focusedIdRef = useRef(null); + const tipShownRef = useRef(false); + const tipTimerRef = useRef | null>(null); + const tipElRef = useRef(null); + const tipPosRef = useRef({ top: 0, right: 0 }); + + const [tip, setTip] = useState<{ id: string; top: number; right: number } | null>(null); + const [hoveredId, setHoveredId] = useState(null); + + const entryById = useMemo(() => { + const map = new Map(); + for (let i = 0; i < entries.length; i++) { + map.set(entries[i].id, entries[i]); + } + return map; + }, [entries]); const getCurrentVisibleId = useCallback((): string | null => { let nextId: string | null = null; @@ -346,6 +375,13 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject { + const id = focusedIdRef.current; + if (id) { + handleSelect(id); + } + }, [handleSelect]); + const focusNav = useCallback((): boolean => { const nav = navRef.current; if (!nav) { @@ -398,6 +434,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject { @@ -419,6 +456,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject () => dragCleanupRef.current?.(), []); + useEffect(() => { + const mq = window.matchMedia('(prefers-reduced-motion: reduce)'); + reducedMotionRef.current = mq.matches; + const onChange = () => { + reducedMotionRef.current = mq.matches; + }; + if (typeof mq.addEventListener === 'function') { + mq.addEventListener('change', onChange); + return () => mq.removeEventListener('change', onChange); + } + mq.addListener(onChange); + return () => mq.removeListener(onChange); + }, []); + + const measureRibs = useCallback(() => { + const col = columnRef.current; + if (!col) { + return; + } + const colRect = col.getBoundingClientRect(); + const scrollTop = col.scrollTop; + const layout: Array<{ id: string; line: HTMLElement; center: number; dims: RibDims }> = []; + const kids = col.children; + for (let i = 0; i < kids.length; i++) { + const button = kids[i] as HTMLElement; + const id = button.getAttribute('data-msg-id'); + const line = button.firstElementChild as HTMLElement | null; + const entry = id ? entryById.get(id) : undefined; + if (!id || !line || !entry) { + continue; + } + const rect = button.getBoundingClientRect(); + layout.push({ + id, + line, + center: rect.top - colRect.top + scrollTop + rect.height / 2, + dims: ribDimsFor(entry), + }); + } + ribLayoutRef.current = layout; + }, [entryById]); + + useEffect(() => { + const raf = requestAnimationFrame(measureRibs); + const col = columnRef.current; + const resize = col ? new ResizeObserver(measureRibs) : null; + if (col && resize) { + resize.observe(col); + } + return () => { + cancelAnimationFrame(raf); + resize?.disconnect(); + }; + }, [entries, measureRibs]); + + const positionTip = useCallback((top: number, right: number) => { + tipPosRef.current = { top, right }; + const el = tipElRef.current; + if (el) { + el.style.top = `${top}px`; + el.style.right = `${right}px`; + } + }, []); + + const revealTip = useCallback( + (id: string | null) => { + if (!id || !entryById.has(id)) { + setTip(null); + return; + } + setTip({ id, top: tipPosRef.current.top, right: tipPosRef.current.right }); + }, + [entryById], + ); + + const clearTooltip = useCallback(() => { + if (tipTimerRef.current) { + clearTimeout(tipTimerRef.current); + tipTimerRef.current = null; + } + if (focusedIdRef.current !== null) { + focusedIdRef.current = null; + setHoveredId(null); + } + if (tipShownRef.current) { + tipShownRef.current = false; + setTip(null); + } + }, []); + + const focusTooltip = useCallback( + (id: string, top: number, right: number) => { + positionTip(top, right); + if (focusedIdRef.current === id) { + return; + } + focusedIdRef.current = id; + setHoveredId(id); + if (tipShownRef.current) { + revealTip(id); + return; + } + if (tipTimerRef.current) { + return; + } + tipTimerRef.current = setTimeout(() => { + tipTimerRef.current = null; + tipShownRef.current = true; + revealTip(focusedIdRef.current); + }, TOOLTIP_OPEN_DELAY); + }, + [positionTip, revealTip], + ); + + const applyMagnify = useCallback(() => { + magRafRef.current = null; + const col = columnRef.current; + const layout = ribLayoutRef.current; + const py = pointerYRef.current; + if (!col || py == null || layout.length === 0) { + return; + } + const reduce = reducedMotionRef.current; + const colRect = col.getBoundingClientRect(); + const scrollTop = col.scrollTop; + let nearestId: string | null = null; + let nearestD = Number.POSITIVE_INFINITY; + let nearestCenter = 0; + for (let i = 0; i < layout.length; i++) { + const rib = layout[i]; + const d = Math.abs(py - rib.center); + if (d < nearestD) { + nearestD = d; + nearestId = rib.id; + nearestCenter = rib.center; + } + if (reduce) { + continue; + } + const t = magnifyFalloff(d, MAG_INFLUENCE); + const dims = rib.dims; + rib.line.style.transition = 'none'; + rib.line.style.width = `${(dims.baseW + (dims.peakW - dims.baseW) * t).toFixed(2)}px`; + rib.line.style.height = `${(dims.baseH + (dims.peakH - dims.baseH) * t).toFixed(2)}px`; + } + if (nearestId != null && nearestD <= MAG_INFLUENCE && !isDraggingRef.current) { + const top = colRect.top - scrollTop + nearestCenter; + const right = window.innerWidth - colRect.left + 8; + focusTooltip(nearestId, top, right); + } else { + clearTooltip(); + } + }, [focusTooltip, clearTooltip]); + + const resetMagnify = useCallback(() => { + const layout = ribLayoutRef.current; + for (let i = 0; i < layout.length; i++) { + const line = layout[i].line; + line.style.transition = 'width 140ms ease-out, height 140ms ease-out'; + line.style.width = ''; + line.style.height = ''; + } + }, []); + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + const col = columnRef.current; + if (!col) { + return; + } + const rect = col.getBoundingClientRect(); + pointerYRef.current = e.clientY - rect.top + col.scrollTop; + if (magRafRef.current == null) { + magRafRef.current = requestAnimationFrame(applyMagnify); + } + }, + [applyMagnify], + ); + + const handlePointerLeave = useCallback(() => { + pointerYRef.current = null; + if (magRafRef.current != null) { + cancelAnimationFrame(magRafRef.current); + magRafRef.current = null; + } + resetMagnify(); + clearTooltip(); + }, [resetMagnify, clearTooltip]); + + const handleColumnFocus = useCallback( + (e: React.FocusEvent) => { + const col = columnRef.current; + const target = e.target as HTMLElement; + if (!col || !target.getAttribute?.('data-msg-id')) { + return; + } + const colRect = col.getBoundingClientRect(); + const rect = target.getBoundingClientRect(); + pointerYRef.current = rect.top - colRect.top + col.scrollTop + rect.height / 2; + if (magRafRef.current == null) { + magRafRef.current = requestAnimationFrame(applyMagnify); + } + }, + [applyMagnify], + ); + + const handleColumnBlur = useCallback( + (e: React.FocusEvent) => { + const col = columnRef.current; + const next = e.relatedTarget as Node | null; + if (col && next && col.contains(next)) { + return; + } + handlePointerLeave(); + }, + [handlePointerLeave], + ); + + useEffect( + () => () => { + if (magRafRef.current != null) { + cancelAnimationFrame(magRafRef.current); + } + if (tipTimerRef.current) { + clearTimeout(tipTimerRef.current); + } + }, + [], + ); + useEffect(() => { refreshEntries(); @@ -831,6 +1099,12 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject