From 4bd5630651e77fc30b2aeb256c4fdd2357f30f8e Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Wed, 6 May 2026 21:53:06 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=AD=20feat:=20Add=20Message=20Navigati?= =?UTF-8?q?on=20Strip=20&=20Redesign=20Scroll-to-Bottom=20(#12657)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): add message navigation strip and redesign scroll-to-bottom button Add a floating vertical navigation strip on the right edge of the chat area that lets users jump between messages quickly. Each message gets an indicator line (wider for assistant, narrower for user) with HoverCard previews showing truncated message text. IntersectionObserver tracks which messages are currently visible and highlights their indicators. Redesign the scroll-to-bottom button: solid backgrounds instead of semi-transparent, clean enter/exit animations without twist/rotate, no hover float animation, positioned at the right edge of the chat form instead of center. * fix(ui): prevent message nav layout shift on scroll Use a fixed-height container for each indicator so the nav strip maintains consistent dimensions when indicators transition between active and inactive states. * fix(ui): debounce message nav refresh and persist visibility state Debounce entry refresh (200ms) to avoid thrashing from rapid DOM mutations during code block rendering. Persist the visible message set across IntersectionObserver reconnections to prevent momentary empty state that disabled navigation buttons. * fix(ui): prevent nav buttons from disabling during fast scroll - Fall back to last known active index when IntersectionObserver reports no visible messages during rapid scrolling - Lower intersection threshold from 10% to 1% for long messages - Fix preview text to skip the message header (Prompt N: username) * fix(ui): scroll to message start when using nav arrow buttons Arrow buttons now use block: 'start' to always scroll to the top of the target message. Indicator dots keep block: 'nearest' for minimal repositioning on direct clicks. * fix(ui): account for header offset when scrolling to messages Use manual scrollTo with a 56px offset to prevent the fixed header from covering the top of the target message when using arrow buttons. * fix(ui): improve message nav scrolling and visual subtlety - Up button scrolls to current message top first before jumping to previous, preventing skipped messages on long content - Down button consistently scrolls to the start of the next message - Nav strip is faded (opacity 30%) by default, fully visible on hover - Background, buttons, and indicators all appear on hover of the nav area using group hover coordination * fix(ui): use native scroll-margin-top for reliable message navigation Replace manual scrollTo calculations with scrollIntoView + CSS scroll-margin-top on .message-render elements. The browser handles scroll offset natively, eliminating positioning errors during smooth scroll animations. * fix(ui): use firstActiveIndex for both nav directions Use firstActiveIndex (topmost visible message) for both up and down navigation. Down now advances one message at a time from what the user is currently reading instead of jumping past all visible messages. Remove unused lastActiveIndex. * fix(ui): address PR review feedback - Scope getMessageEntries query to scroll container instead of document - Include preview text in entries equality check to catch content updates during streaming/edits - Move scroll button transition to base state so release animates smoothly instead of snapping back * fix(ui): make message nav scroll precise and chevrons reliable - Bump .message-render scroll-margin-top from 1rem to 4rem so messages land below the 52px absolute gradient header instead of behind it. - Drive chevron jumps from live scrollTop + offsetTop comparison rather than the IntersectionObserver-derived firstActiveIndex, which lagged behind rapid clicks and treated any 1px-visible message as "current". - Track canGoUp / canGoDown from the same scroll-position comparison so the disabled state matches what the buttons will actually do. - Auto-center the indicator column on the visible message range and smooth-scroll it via rAF so 500+ indicators stay at 60fps. - Pull entry data from useGetMessagesByConvoId (with a DOM fallback) so previews are state-backed instead of scraped from rendered markup. - Memoize MessageIndicator and filter MutationObserver to .message-render add/remove only. - Add 5 i18n keys (com_ui_message_nav*) for nav and indicator labels. * perf(ui): skip off-screen message layout and fix resulting scroll drift Large conversations used to freeze the main thread during sidebar toggles because every animated frame had to relayout every message. With ~3000 message elements on this branch: avg frame 650ms, max 1701ms (~1.5fps) during the 300ms transition. Adding `content-visibility: auto` with `contain-intrinsic-size: auto 200px` on .message-render lets the browser skip layout/paint for messages outside the viewport, dropping avg frame to 33ms and max to 74ms (~30fps, feels responsive). content-visibility comes with a trade-off though: off-screen messages use the 200px intrinsic-size estimate until they're measured. That broke indicator-click scrolling on long conversations, landing 1-2 messages off the target because scrollIntoView computed its target scrollTop once with stale estimates, and intermediate messages shrunk/grew as they rendered during the smooth scroll. Replaced scrollIntoView with a manual rAF scroll that re-reads the target's getBoundingClientRect every frame and eases toward the *current* target. Verified drift=0 across fake-0, fake-50, fake-250, fake-450 (messages near the bottom naturally land higher than scroll-margin when the container is already at max scroll — expected). Also two small MessageNav.tsx hot-path cleanups: - Use col.children[i] instead of col.querySelector by data-msg-id for the indicator-column centering lookup (entries map 1:1 to column children since HoverCardTrigger asChild forwards to the button). - Compare visibility set contents before setActiveIds, so an IntersectionObserver flush with unchanged membership doesn't force a re-render and 500x memo comparisons. * revert(ui): drop content-visibility on .message-render Didn't deliver the expected sidebar-toggle perf win in real-world usage, and its intrinsic-size estimation introduced the exact kind of scroll drift we then had to work around. The rAF scroll in MessageNav is orthogonal to this and stays — it works fine with or without content-visibility. * fix(ui): address PR review — a11y, tests, and MessageNav correctness - ScrollToBottom aria-label now runs through useLocalize instead of being hardcoded English. Added com_ui_scroll_to_bottom translation key. - MessageNav nav expands on keyboard focus-within, not just pointer hover. - Indicator buttons expose aria-current="true" for the active message and get a visible focus-visible ring. Chevron buttons get the same ring so keyboard users can see focus. - Cancel in-flight rAF scrolls when a new navigation starts, so clicking a second indicator mid-animation doesn't race the first loop on container.scrollTop. - Invalidate the cached offsetsTop/offsetsBottom arrays via a ResizeObserver on the scroll content. Previously heights that changed after mount (code blocks rendering, images loading) left canGoUp / canGoDown and the indicator-column centering reading stale positions. - Observe IntersectionObserver entries incrementally. The observer is now created once per scroll container and entries add/remove on change instead of the whole observer being torn down and rebuilt for every new message. - memo() the default export so parent re-renders don't cascade through MessageNav when entries/activeIds haven't changed. - Add 18-test suite covering rendering threshold, user/assistant indicator styling, preview sourcing (React Query vs DOM fallback vs truncation), accessibility (aria-label, aria-current, chevron disabled state), click-driven rAF scroll + cancellation, and observer lifecycle (observe on mount, incremental sync, unobserve on removal, disconnect on unmount). * fix(ui): catch in-place message id mutations and react to layout shifts Follow-ups from deep review: - MutationObserver on .message-render now also watches the id attribute. During the SSE lifecycle a single DOM node's id cycles through three values (client UUID -> createdHandler id -> server id, see the comment in MultiMessage.tsx), which meant the previous childList-only observer never refreshed entries after a streaming response. Nav clicks on the most recent message were silently failing because getElementById returned null for the stale id. - ResizeObserver now calls scheduleTick() instead of only flipping a flag. The flag was only consumed inside the scroll handler's tick, so heights that changed while the user wasn't scrolling (assistant message streaming in, code blocks highlighting) left offsetsTop/offsetsBottom stale and canGoUp / canGoDown wrong. Both handlers now route through scheduleTick so a resize and a scroll share the same rAF slot. - Unify scroll and resize callbacks on scheduleTick. Removes a duplicate rAF path and makes the effect cleaner. - Single-pass build of newIds during incremental IO sync (previously entries.map().new Set() did two passes for no reason). - CSSTransition timeouts drop from 550/700 to 300/250 to match the new scroll-to-bottom animations. Old values left the button in the DOM for up to 450ms after the exit animation finished. - ScrollToBottom.tsx imports reordered to longest-first per project convention. - style.css: collapse split `border: 1px solid` + `border-color` into one shorthand; dark variant still overrides border-color cleanly. - Tests: add SSE-lifecycle test that mutates a .message-render id in place and asserts the nav now shows an indicator for the new id and none for the old one. HoverCard mock no longer spreads unknown props to the DOM div (drops a React warning). * fix(ui): address deep-review follow-ups on MessageNav - Move activeScrollToken from module scope to a per-instance useRef (scrollTokenRef). When LibreChat eventually mounts more than one MessageNav side-by-side (multi-panel / added-convo view) a click in one panel will no longer cancel an in-flight smooth scroll in another. scrollToMessageStart is now an instance useCallback and the button click path goes through an onSelect prop on MessageIndicator, keeping the memoized indicator stable. - messagesById goes through a ref (messagesByIdRef) so refreshEntries is no longer recreated on every streaming token. Previously messagesById landed in both the useMemo and the refreshEntries dep array, so each streaming response rebuilt the MutationObserver effect dozens of times per second. A separate small effect still calls refreshEntries when messagesById changes, so previews stay fresh. - Extract USER_TURN_SELECTOR constant and tighten the text-preview type narrowing so we no longer need the `as { value?: string }` cast (TS narrows string | TextData correctly through the `typeof object` + property access guard). - Cache the computed scroll margin (4rem = 64px) in scrollMarginRef so the nav callbacks don't call getComputedStyle on every click. - Tests: add a two-instance isolation test that verifies scroll tokens don't cross between mounted MessageNavs. Drop the unused `import React from 'react'` pattern in favor of local type aliases. - client/package.json: bump @babel/preset-typescript to ^7.28.5. The old ^7.22.15 constraint was resolving to 7.23.3 via hoisting, which can't parse modern `import type` syntax on a clean install and was breaking the test suite. * fix(ui): address re-review — clean lockfile + ScrollToBottom ref target - package-lock.json: the preset-typescript bump last commit pulled in transitive Babel packages resolved through a local internal registry (npm.internal.berry13.com). Rewrote those 31 entries back to the public npmjs.org registry so CI and contributors can install cleanly. Integrity hashes unchanged — content-addressed. - ScrollToBottom now forwards its ref to the wrapping
instead of the inner + + + +

{entry.preview}

+
+
+ + ); +}); + +const chevronButtonClasses = cn( + 'rounded-md p-0.5 text-text-tertiary transition-colors', + 'group-hover/nav:text-text-secondary group-focus-within/nav:text-text-secondary', + 'group-hover/nav:hover:text-text-primary', + 'group-hover/nav:disabled:opacity-30 group-focus-within/nav:disabled:opacity-30', + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy', +); + +function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject }) { + const localize = useLocalize(); + const { conversationId } = useMessagesConversation(); + const { data: messages } = useGetMessagesByConvoId(conversationId ?? '', { + enabled: !!conversationId, + }); + const messagesById = useMemo(() => { + const map = new Map(); + if (messages) { + for (let i = 0; i < messages.length; i++) { + const m = messages[i]; + if (m.messageId) { + map.set(m.messageId, m); + } + } + } + return map; + }, [messages]); + + const [entries, setEntries] = useState([]); + const [visibleIds, setVisibleIds] = useState>(new Set()); + const [currentId, setCurrentId] = useState(null); + const [canGoUp, setCanGoUp] = useState(false); + const [canGoDown, setCanGoDown] = useState(false); + + const observerRef = useRef(null); + const observedRef = useRef(new Map()); + const columnRef = useRef(null); + const refreshTimerRef = useRef | null>(null); + const visibleSetRef = useRef(new Set()); + const messagesByIdRef = useRef(messagesById); + const scrollTokenRef = useRef(0); + const scrollMarginRef = useRef(0); + + const getCurrentVisibleId = useCallback((): string | null => { + let nextId: string | null = null; + let nextTop = Number.POSITIVE_INFINITY; + for (const id of visibleSetRef.current) { + const el = observedRef.current.get(id); + if (!el || el.offsetTop >= nextTop) { + continue; + } + nextId = id; + nextTop = el.offsetTop; + } + return nextId; + }, []); + + useEffect(() => { + messagesByIdRef.current = messagesById; + }, [messagesById]); + + const refreshEntries = useCallback(() => { + if (refreshTimerRef.current) { + clearTimeout(refreshTimerRef.current); + } + refreshTimerRef.current = setTimeout(() => { + const root = scrollableRef.current ?? document; + const next = getMessageEntries(root, messagesByIdRef.current); + setEntries((prev) => { + if ( + prev.length === next.length && + prev.every((e, i) => e.id === next[i].id && e.preview === next[i].preview) + ) { + return prev; + } + return next; + }); + }, 200); + }, [scrollableRef]); + + useEffect(() => { + refreshEntries(); + }, [messagesById, refreshEntries]); + + const scrollToStart = useCallback((id: string) => { + const el = document.getElementById(id); + if (!el) { + return; + } + const container = el.closest('.scrollbar-gutter-stable'); + if (!container) { + el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + return; + } + const token = ++scrollTokenRef.current; + const scrollMargin = scrollMarginRef.current || readScrollMargin(el); + const startScroll = container.scrollTop; + const start = performance.now(); + + const step = (now: number) => { + if (token !== scrollTokenRef.current) { + return; + } + const progress = Math.min(1, (now - start) / SCROLL_DURATION); + const current = document.getElementById(id); + if (!current) { + return; + } + const cRect = container.getBoundingClientRect(); + const elRect = current.getBoundingClientRect(); + const targetScroll = container.scrollTop + (elRect.top - cRect.top) - scrollMargin; + const max = container.scrollHeight - container.clientHeight; + const clamped = Math.max(0, Math.min(targetScroll, max)); + container.scrollTop = startScroll + (clamped - startScroll) * easeOutCubic(progress); + if (progress < 1) { + requestAnimationFrame(step); + } + }; + + requestAnimationFrame(step); + }, []); + + useEffect(() => { + refreshEntries(); + + const container = scrollableRef.current; + if (!container) { + return; + } + + const mutationObserver = new MutationObserver((mutations) => { + for (let i = 0; i < mutations.length; i++) { + const m = mutations[i]; + if (m.type === 'attributes') { + const target = m.target as HTMLElement; + if (target.nodeType === 1 && target.classList?.contains('message-render')) { + refreshEntries(); + return; + } + continue; + } + if (m.addedNodes.length || m.removedNodes.length) { + for (let j = 0; j < m.addedNodes.length; j++) { + const n = m.addedNodes[j] as HTMLElement; + if ( + n.nodeType === 1 && + (n.classList?.contains('message-render') || n.querySelector?.('.message-render')) + ) { + refreshEntries(); + return; + } + } + for (let j = 0; j < m.removedNodes.length; j++) { + const n = m.removedNodes[j] as HTMLElement; + if ( + n.nodeType === 1 && + (n.classList?.contains('message-render') || n.querySelector?.('.message-render')) + ) { + refreshEntries(); + return; + } + } + } + } + }); + + mutationObserver.observe(container, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['id'], + }); + + return () => { + mutationObserver.disconnect(); + if (refreshTimerRef.current) { + clearTimeout(refreshTimerRef.current); + } + }; + }, [scrollableRef, refreshEntries]); + + useEffect(() => { + const container = scrollableRef.current; + if (!container || entries.length === 0) { + setCanGoUp(false); + setCanGoDown(false); + return; + } + + const offsetsTop: number[] = new Array(entries.length); + const offsetsBottom: number[] = new Array(entries.length); + const recomputeOffsets = () => { + for (let i = 0; i < entries.length; i++) { + const el = document.getElementById(entries[i].id); + offsetsTop[i] = el ? el.offsetTop : Number.POSITIVE_INFINITY; + offsetsBottom[i] = el ? el.offsetTop + el.offsetHeight : Number.POSITIVE_INFINITY; + } + }; + recomputeOffsets(); + + const firstEl = document.getElementById(entries[0].id); + const scrollMargin = readScrollMargin(firstEl); + scrollMarginRef.current = scrollMargin; + + let needsRecompute = false; + let frameId: number | null = null; + let bottomFrameId: number | null = null; + let bottomSnapToken = 0; + + const scrollColumnToBottom = () => { + const col = columnRef.current; + if (!col) { + return; + } + col.scrollTop = Math.max(0, col.scrollHeight - col.clientHeight); + }; + + const cancelColumnBottomScroll = () => { + bottomSnapToken++; + if (bottomFrameId != null) { + cancelAnimationFrame(bottomFrameId); + bottomFrameId = null; + } + }; + + const scheduleColumnBottomScroll = () => { + const token = ++bottomSnapToken; + const run = (remaining: number) => { + if (token !== bottomSnapToken) { + return; + } + scrollColumnToBottom(); + if (remaining <= 0) { + bottomFrameId = null; + return; + } + bottomFrameId = requestAnimationFrame(() => run(remaining - 1)); + }; + run(BOTTOM_SNAP_RETRIES); + }; + + const tick = () => { + frameId = null; + if (needsRecompute) { + recomputeOffsets(); + needsRecompute = false; + } + + const scrollTop = container.scrollTop; + let nextCanUp = false; + let nextCanDown = false; + for (let i = 0; i < offsetsTop.length; i++) { + const snap = offsetsTop[i] - scrollMargin; + if (snap < scrollTop - JUMP_EPS) { + nextCanUp = true; + } else if (snap > scrollTop + JUMP_EPS) { + nextCanDown = true; + break; + } + } + setCanGoUp((prev) => (prev === nextCanUp ? prev : nextCanUp)); + setCanGoDown((prev) => (prev === nextCanDown ? prev : nextCanDown)); + + const col = columnRef.current; + if (!col) { + return; + } + const containerMaxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight); + if (containerMaxScrollTop > 0 && scrollTop >= containerMaxScrollTop - JUMP_EPS) { + scheduleColumnBottomScroll(); + return; + } + const viewBottom = scrollTop + container.clientHeight; + let first = -1; + let last = -1; + for (let i = 0; i < offsetsTop.length; i++) { + if (offsetsBottom[i] <= scrollTop) { + continue; + } + if (offsetsTop[i] >= viewBottom) { + break; + } + if (first === -1) { + first = i; + } + last = i; + } + if (first === -1) { + return; + } + if (last === entries.length - 1) { + scheduleColumnBottomScroll(); + return; + } + cancelColumnBottomScroll(); + const firstInd = col.children[first] as HTMLElement | undefined; + const lastInd = col.children[last] as HTMLElement | undefined; + if (!firstInd || !lastInd) { + return; + } + const mid = (firstInd.offsetTop + lastInd.offsetTop + lastInd.offsetHeight) / 2; + const target = mid - col.clientHeight / 2; + const columnMaxScrollTop = Math.max(0, col.scrollHeight - col.clientHeight); + col.scrollTop = Math.max(0, Math.min(target, columnMaxScrollTop)); + }; + + const scheduleTick = () => { + if (frameId == null) { + frameId = requestAnimationFrame(tick); + } + }; + + const content = container.firstElementChild as HTMLElement | null; + const resizeObserver = new ResizeObserver(() => { + needsRecompute = true; + scheduleTick(); + }); + if (content) { + resizeObserver.observe(content); + } + + tick(); + container.addEventListener('scroll', scheduleTick, { passive: true }); + + return () => { + container.removeEventListener('scroll', scheduleTick); + if (frameId != null) { + cancelAnimationFrame(frameId); + } + cancelColumnBottomScroll(); + resizeObserver.disconnect(); + }; + }, [entries, scrollableRef]); + + useEffect(() => { + const root = scrollableRef.current; + if (!root) { + return; + } + + const visibleSet = visibleSetRef.current; + const observed = observedRef.current; + let pendingFrame: number | null = null; + + const flush = () => { + pendingFrame = null; + const nextCurrentId = getCurrentVisibleId(); + setCurrentId((prev) => (prev === nextCurrentId ? prev : nextCurrentId)); + setVisibleIds((prev) => { + if (prev.size === visibleSet.size) { + let same = true; + for (const id of visibleSet) { + if (!prev.has(id)) { + same = false; + break; + } + } + if (same) { + return prev; + } + } + return new Set(visibleSet); + }); + }; + + const observer = new IntersectionObserver( + (intersections) => { + for (const entry of intersections) { + const id = entry.target.id; + if (entry.isIntersecting) { + visibleSet.add(id); + } else { + visibleSet.delete(id); + } + } + if (pendingFrame == null) { + pendingFrame = requestAnimationFrame(flush); + } + }, + { root, threshold: 0 }, + ); + observerRef.current = observer; + + return () => { + observer.disconnect(); + observerRef.current = null; + observed.clear(); + visibleSet.clear(); + if (pendingFrame != null) { + cancelAnimationFrame(pendingFrame); + } + }; + }, [getCurrentVisibleId, scrollableRef]); + + useEffect(() => { + const observer = observerRef.current; + if (!observer) { + return; + } + const observed = observedRef.current; + const visibleSet = visibleSetRef.current; + + const elementByNewId = new Map(); + for (let i = 0; i < entries.length; i++) { + const id = entries[i].id; + const el = document.getElementById(id); + if (el) { + elementByNewId.set(el, id); + } + } + + let visibilityChanged = false; + for (const [oldId, el] of [...observed]) { + const newId = elementByNewId.get(el); + if (newId === undefined) { + observer.unobserve(el); + observed.delete(oldId); + if (visibleSet.delete(oldId)) { + visibilityChanged = true; + } + continue; + } + if (newId !== oldId) { + observed.delete(oldId); + observed.set(newId, el); + if (visibleSet.delete(oldId)) { + visibleSet.add(newId); + visibilityChanged = true; + } + } + } + + for (const [el, id] of elementByNewId) { + if (!observed.has(id)) { + observer.observe(el); + observed.set(id, el); + } + } + + if (visibilityChanged) { + setCurrentId(getCurrentVisibleId()); + setVisibleIds(new Set(visibleSet)); + } + }, [entries, getCurrentVisibleId]); + + const jumpToPrevious = useCallback(() => { + const container = scrollableRef.current; + if (!container || entries.length === 0) { + return; + } + const scrollTop = container.scrollTop; + const scrollMargin = + scrollMarginRef.current !== 0 + ? scrollMarginRef.current + : readScrollMargin(document.getElementById(entries[0].id)); + for (let i = entries.length - 1; i >= 0; i--) { + const el = document.getElementById(entries[i].id); + if (!el) { + continue; + } + if (el.offsetTop - scrollMargin < scrollTop - JUMP_EPS) { + scrollToStart(entries[i].id); + return; + } + } + container.scrollTo({ top: 0, behavior: 'smooth' }); + }, [entries, scrollableRef, scrollToStart]); + + const jumpToNext = useCallback(() => { + const container = scrollableRef.current; + if (!container || entries.length === 0) { + return; + } + const scrollTop = container.scrollTop; + const scrollMargin = + scrollMarginRef.current !== 0 + ? scrollMarginRef.current + : readScrollMargin(document.getElementById(entries[0].id)); + for (let i = 0; i < entries.length; i++) { + const el = document.getElementById(entries[i].id); + if (!el) { + continue; + } + if (el.offsetTop - scrollMargin > scrollTop + JUMP_EPS) { + scrollToStart(entries[i].id); + return; + } + } + }, [entries, scrollableRef, scrollToStart]); + + if (entries.length < 3) { + return null; + } + + return ( + + ); +} + +export default memo(MessageNav); diff --git a/client/src/components/Chat/Messages/MessagesView.tsx b/client/src/components/Chat/Messages/MessagesView.tsx index b4f555d189..9c59cab292 100644 --- a/client/src/components/Chat/Messages/MessagesView.tsx +++ b/client/src/components/Chat/Messages/MessagesView.tsx @@ -8,6 +8,7 @@ import ScrollToBottom from '~/components/Messages/ScrollToBottom'; import { MessagesViewProvider } from '~/Providers'; import { fontSizeAtom } from '~/store/fontSize'; import MultiMessage from './MultiMessage'; +import MessageNav from './MessageNav'; import { cn } from '~/utils'; import store from '~/store'; @@ -21,7 +22,7 @@ function MessagesViewContent({ const { screenshotTargetRef } = useScreenshot(); const scrollButtonPreference = useRecoilValue(store.showScrollButton); const [currentEditId, setCurrentEditId] = useState(-1); - const scrollToBottomRef = useRef(null); + const scrollToBottomRef = useRef(null); const { conversation, @@ -81,8 +82,8 @@ function MessagesViewContent({ + +
diff --git a/client/src/components/Chat/Messages/__tests__/MessageNav.spec.tsx b/client/src/components/Chat/Messages/__tests__/MessageNav.spec.tsx new file mode 100644 index 0000000000..d9c02baa43 --- /dev/null +++ b/client/src/components/Chat/Messages/__tests__/MessageNav.spec.tsx @@ -0,0 +1,755 @@ +import React from 'react'; +import { render, act, fireEvent } from '@testing-library/react'; + +type ReactNode = React.ReactNode; +type RefObject = React.RefObject; + +type TestMessage = { + messageId: string; + conversationId?: string; + text?: string; + isCreatedByUser?: boolean; +}; + +const mockUseGetMessagesByConvoId = jest.fn(); +const mockUseMessagesConversation = jest.fn(); + +jest.mock('~/data-provider', () => ({ + useGetMessagesByConvoId: (...args: unknown[]) => mockUseGetMessagesByConvoId(...args), +})); + +jest.mock('~/Providers', () => ({ + useMessagesConversation: (...args: unknown[]) => mockUseMessagesConversation(...args), +})); + +jest.mock('~/hooks', () => ({ + useLocalize: + () => + (key: string, opts?: Record): string => + opts ? `${key}|${JSON.stringify(opts)}` : key, +})); + +jest.mock('@librechat/client', () => ({ + HoverCard: ({ children }: { children: ReactNode }) => <>{children}, + HoverCardTrigger: ({ children, asChild }: { children: ReactNode; asChild?: boolean }) => + asChild ? children :
{children}
, + HoverCardPortal: ({ children }: { children: ReactNode }) => <>{children}, + HoverCardContent: ({ children, className }: { children: ReactNode; className?: string }) => ( +
+ {children} +
+ ), +})); + +type IOEntry = Pick; + +class MockIntersectionObserver { + static instances: MockIntersectionObserver[] = []; + static last(): MockIntersectionObserver | undefined { + return MockIntersectionObserver.instances[MockIntersectionObserver.instances.length - 1]; + } + + static reset() { + MockIntersectionObserver.instances = []; + } + + root: Element | Document | null = null; + rootMargin = '0px'; + thresholds: number[] = [0]; + callback: IntersectionObserverCallback; + observed = new Set(); + observe = jest.fn((el: Element) => { + this.observed.add(el); + }); + + unobserve = jest.fn((el: Element) => { + this.observed.delete(el); + }); + + disconnect = jest.fn(() => { + this.observed.clear(); + }); + + takeRecords = jest.fn(() => []); + constructor(cb: IntersectionObserverCallback, opts?: IntersectionObserverInit) { + this.callback = cb; + if (opts?.root instanceof Element || opts?.root instanceof Document) { + this.root = opts.root; + } + MockIntersectionObserver.instances.push(this); + } + + trigger(entries: IOEntry[]) { + this.callback(entries as IntersectionObserverEntry[], this as unknown as IntersectionObserver); + } +} + +const originalIO = global.IntersectionObserver; + +import MessageNav from '../MessageNav'; + +function buildMessage(overrides: Partial = {}): TestMessage { + return { + messageId: 'm', + conversationId: 'test-convo', + text: 'hello', + isCreatedByUser: false, + ...overrides, + }; +} + +function buildDom(messages: TestMessage[]): { + scrollable: HTMLDivElement; + content: HTMLDivElement; +} { + const scrollable = document.createElement('div'); + scrollable.className = 'scrollbar-gutter-stable'; + Object.defineProperty(scrollable, 'clientHeight', { value: 600, configurable: true }); + Object.defineProperty(scrollable, 'scrollHeight', { value: 3000, configurable: true }); + Object.defineProperty(scrollable, 'scrollTop', { value: 0, writable: true, configurable: true }); + + const content = document.createElement('div'); + content.className = 'flex flex-col'; + scrollable.appendChild(content); + + for (let i = 0; i < messages.length; i++) { + const m = messages[i]; + const div = document.createElement('div'); + div.id = m.messageId; + div.className = 'message-render'; + if (m.isCreatedByUser) { + const turn = document.createElement('div'); + turn.className = 'user-turn'; + turn.textContent = m.text ?? ''; + div.appendChild(turn); + } else { + div.textContent = m.text ?? ''; + } + Object.defineProperty(div, 'offsetTop', { value: 100 + i * 200, configurable: true }); + Object.defineProperty(div, 'offsetHeight', { value: 150, configurable: true }); + content.appendChild(div); + } + + document.body.appendChild(scrollable); + return { scrollable, content }; +} + +function renderNav(messages: TestMessage[]) { + mockUseGetMessagesByConvoId.mockReturnValue({ data: messages }); + const { scrollable, content } = buildDom(messages); + const scrollableRef = { current: scrollable } as RefObject; + const result = render(); + act(() => { + jest.advanceTimersByTime(250); + }); + return { ...result, scrollable, content, scrollableRef }; +} + +function clearDom() { + while (document.body.firstChild) { + document.body.removeChild(document.body.firstChild); + } +} + +beforeEach(() => { + MockIntersectionObserver.reset(); + ( + global as unknown as { IntersectionObserver: typeof MockIntersectionObserver } + ).IntersectionObserver = MockIntersectionObserver; + jest.useFakeTimers(); + mockUseMessagesConversation.mockReturnValue({ conversationId: 'test-convo' }); + mockUseGetMessagesByConvoId.mockReturnValue({ data: [] }); +}); + +afterEach(() => { + jest.useRealTimers(); + ( + global as unknown as { IntersectionObserver: typeof IntersectionObserver } + ).IntersectionObserver = originalIO; + clearDom(); +}); + +describe('MessageNav', () => { + describe('rendering threshold', () => { + it('renders nothing when there are 0 messages', () => { + const { container } = renderNav([]); + expect(container.querySelector('nav')).toBeNull(); + }); + + it('renders nothing with fewer than 3 messages', () => { + const messages = [ + buildMessage({ messageId: 'a', text: 'hi', isCreatedByUser: true }), + buildMessage({ messageId: 'b', text: 'hey' }), + ]; + const { container } = renderNav(messages); + expect(container.querySelector('nav')).toBeNull(); + }); + + it('renders the nav with 3+ messages and an indicator for each', () => { + const messages = [ + buildMessage({ messageId: 'a', text: 'alpha', isCreatedByUser: true }), + buildMessage({ messageId: 'b', text: 'bravo' }), + buildMessage({ messageId: 'c', text: 'charlie', isCreatedByUser: true }), + ]; + const { container } = renderNav(messages); + const nav = container.querySelector('nav'); + expect(nav).not.toBeNull(); + expect(nav).toHaveAttribute('aria-label', 'com_ui_message_nav'); + + const indicators = container.querySelectorAll('[data-msg-id]'); + expect(indicators).toHaveLength(3); + expect(Array.from(indicators).map((el) => el.getAttribute('data-msg-id'))).toEqual([ + 'a', + 'b', + 'c', + ]); + }); + }); + + describe('indicator styling', () => { + it('uses narrower width for user turns and wider for assistant turns', () => { + const messages = [ + buildMessage({ messageId: 'u', text: 'user msg', isCreatedByUser: true }), + buildMessage({ messageId: 'a', text: 'assistant msg' }), + buildMessage({ messageId: 'u2', text: 'more user', isCreatedByUser: true }), + ]; + const { container } = renderNav(messages); + const [userInd, assistantInd] = container.querySelectorAll('[data-msg-id]'); + expect(userInd.className).toContain('w-4'); + expect(userInd.className).not.toContain('w-6'); + expect(assistantInd.className).toContain('w-6'); + expect(assistantInd.className).not.toContain('w-4'); + }); + }); + + describe('preview text', () => { + it('uses message text from React Query data when available', () => { + const messages = [ + buildMessage({ messageId: 'a', text: 'alpha-preview', isCreatedByUser: true }), + buildMessage({ messageId: 'b', text: 'bravo-preview' }), + buildMessage({ messageId: 'c', text: 'charlie-preview', isCreatedByUser: true }), + ]; + const { container } = renderNav(messages); + const previews = container.querySelectorAll('[data-testid="hover-card-content"] p'); + expect(previews).toHaveLength(3); + expect(previews[0]).toHaveTextContent('alpha-preview'); + expect(previews[1]).toHaveTextContent('bravo-preview'); + }); + + it('falls back to DOM text when a message is not in React Query data', () => { + mockUseGetMessagesByConvoId.mockReturnValue({ data: [] }); + const scrollable = document.createElement('div'); + scrollable.className = 'scrollbar-gutter-stable'; + const content = document.createElement('div'); + scrollable.appendChild(content); + for (const [i, id] of ['x', 'y', 'z'].entries()) { + const div = document.createElement('div'); + div.id = id; + div.className = 'message-render'; + div.textContent = `dom-text-${id}`; + Object.defineProperty(div, 'offsetTop', { value: i * 200 }); + Object.defineProperty(div, 'offsetHeight', { value: 150 }); + content.appendChild(div); + } + document.body.appendChild(scrollable); + const scrollableRef = { current: scrollable } as RefObject; + const { container } = render(); + act(() => { + jest.advanceTimersByTime(250); + }); + + const previews = container.querySelectorAll('[data-testid="hover-card-content"] p'); + expect(previews[0]).toHaveTextContent('dom-text-x'); + expect(previews[2]).toHaveTextContent('dom-text-z'); + }); + + it('truncates previews longer than 80 chars with an ellipsis', () => { + const long = 'a'.repeat(120); + const messages = [ + buildMessage({ messageId: 'a', text: long, isCreatedByUser: true }), + buildMessage({ messageId: 'b', text: 'short' }), + buildMessage({ messageId: 'c', text: 'also short', isCreatedByUser: true }), + ]; + const { container } = renderNav(messages); + const preview = container.querySelectorAll('[data-testid="hover-card-content"] p')[0]; + const text = preview?.textContent ?? ''; + expect(text.endsWith('...')).toBe(true); + expect(text.length).toBe(83); + }); + }); + + describe('accessibility', () => { + it('labels the nav and chevron buttons via useLocalize', () => { + const messages = [ + buildMessage({ messageId: 'a', text: 'one', isCreatedByUser: true }), + buildMessage({ messageId: 'b', text: 'two' }), + buildMessage({ messageId: 'c', text: 'three', isCreatedByUser: true }), + ]; + const { container } = renderNav(messages); + + expect(container.querySelector('nav')).toHaveAttribute('aria-label', 'com_ui_message_nav'); + + const prevBtn = container.querySelector('button[aria-label="com_ui_message_nav_previous"]'); + const nextBtn = container.querySelector('button[aria-label="com_ui_message_nav_next"]'); + expect(prevBtn).not.toBeNull(); + expect(nextBtn).not.toBeNull(); + }); + + it('labels each indicator with its role-specific key and localized preview', () => { + const messages = [ + buildMessage({ messageId: 'u', text: 'hi there', isCreatedByUser: true }), + buildMessage({ messageId: 'a', text: 'assistant reply' }), + buildMessage({ messageId: 'u2', text: 'follow-up', isCreatedByUser: true }), + ]; + const { container } = renderNav(messages); + const [userInd, assistantInd] = container.querySelectorAll('[data-msg-id]'); + expect(userInd.getAttribute('aria-label')).toMatch(/^com_ui_message_nav_go_to_user\|/); + expect(userInd.getAttribute('aria-label')).toContain('hi there'); + expect(assistantInd.getAttribute('aria-label')).toMatch( + /^com_ui_message_nav_go_to_assistant\|/, + ); + }); + + it('sets aria-current on the active indicator after IntersectionObserver fires', () => { + const messages = [ + buildMessage({ messageId: 'a', text: 'alpha', isCreatedByUser: true }), + buildMessage({ messageId: 'b', text: 'bravo' }), + buildMessage({ messageId: 'c', text: 'charlie', isCreatedByUser: true }), + ]; + const { container } = renderNav(messages); + const io = MockIntersectionObserver.last(); + expect(io).toBeDefined(); + + const target = document.getElementById('b'); + act(() => { + io!.trigger([{ target: target!, isIntersecting: true }]); + jest.advanceTimersByTime(32); + }); + + const active = container.querySelector('[aria-current="true"]'); + expect(active).not.toBeNull(); + expect(active).toHaveAttribute('data-msg-id', 'b'); + }); + + it('sets aria-current only on the topmost visible indicator', () => { + const messages = [ + buildMessage({ messageId: 'a', text: 'alpha', isCreatedByUser: true }), + buildMessage({ messageId: 'b', text: 'bravo' }), + buildMessage({ messageId: 'c', text: 'charlie', isCreatedByUser: true }), + ]; + const { container } = renderNav(messages); + const io = MockIntersectionObserver.last(); + expect(io).toBeDefined(); + + act(() => { + io!.trigger([ + { target: document.getElementById('c')!, isIntersecting: true }, + { target: document.getElementById('b')!, isIntersecting: true }, + { target: document.getElementById('a')!, isIntersecting: true }, + ]); + jest.advanceTimersByTime(32); + }); + + const current = container.querySelectorAll('[aria-current="true"]'); + expect(current).toHaveLength(1); + expect(current[0]).toHaveAttribute('data-msg-id', 'a'); + + for (const id of ['a', 'b', 'c']) { + const indicator = container.querySelector(`[data-msg-id="${id}"] span`); + expect(indicator?.className).toContain('h-[5px]'); + } + }); + + it('chevron buttons expose a disabled state when there is nothing to navigate to', () => { + const messages = [ + buildMessage({ messageId: 'a', text: 'one', isCreatedByUser: true }), + buildMessage({ messageId: 'b', text: 'two' }), + buildMessage({ messageId: 'c', text: 'three', isCreatedByUser: true }), + ]; + const { container, scrollable } = renderNav(messages); + + Object.defineProperty(scrollable, 'scrollTop', { + value: 0, + configurable: true, + writable: true, + }); + act(() => { + fireEvent.scroll(scrollable); + jest.advanceTimersByTime(32); + }); + + const prev = container.querySelector( + 'button[aria-label="com_ui_message_nav_previous"]', + ) as HTMLButtonElement; + const next = container.querySelector( + 'button[aria-label="com_ui_message_nav_next"]', + ) as HTMLButtonElement; + + expect(prev.disabled).toBe(true); + expect(next.disabled).toBe(false); + }); + }); + + describe('scroll behavior', () => { + it('schedules a rAF when an indicator is clicked', () => { + const messages = [ + buildMessage({ messageId: 'a', text: 'alpha', isCreatedByUser: true }), + buildMessage({ messageId: 'b', text: 'bravo' }), + buildMessage({ messageId: 'c', text: 'charlie', isCreatedByUser: true }), + ]; + const { container } = renderNav(messages); + const rafSpy = jest.spyOn(window, 'requestAnimationFrame'); + + const target = container.querySelectorAll('[data-msg-id]')[2] as HTMLButtonElement; + act(() => { + fireEvent.click(target); + }); + expect(rafSpy).toHaveBeenCalled(); + rafSpy.mockRestore(); + }); + + it('cancels any prior rAF scroll when a new one starts', () => { + const messages = [ + buildMessage({ messageId: 'a', text: 'alpha', isCreatedByUser: true }), + buildMessage({ messageId: 'b', text: 'bravo' }), + buildMessage({ messageId: 'c', text: 'charlie', isCreatedByUser: true }), + ]; + const { container } = renderNav(messages); + const indicators = container.querySelectorAll('[data-msg-id]'); + + const steps: Array<(ts: number) => void> = []; + const rafSpy = jest + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((cb: FrameRequestCallback) => { + steps.push(cb); + return steps.length; + }); + + act(() => { + fireEvent.click(indicators[0] as HTMLElement); + }); + act(() => { + fireEvent.click(indicators[2] as HTMLElement); + }); + + expect(steps.length).toBeGreaterThanOrEqual(2); + expect(() => { + steps[0](performance.now()); + steps[steps.length - 1](performance.now()); + }).not.toThrow(); + + rafSpy.mockRestore(); + }); + + it('scrolls the indicator column fully down when the final message is visible', () => { + const messages = Array.from({ length: 15 }, (_, i) => + buildMessage({ + messageId: `m-${i}`, + text: `message ${i}`, + isCreatedByUser: i % 2 === 0, + }), + ); + const { container, scrollable } = renderNav(messages); + const column = container.querySelector('nav > div') as HTMLDivElement; + let scrollHeightReads = 0; + + Object.defineProperty(column, 'clientHeight', { value: 30, configurable: true }); + Object.defineProperty(column, 'scrollHeight', { + get: () => { + scrollHeightReads++; + return scrollHeightReads === 1 ? 170 : 180; + }, + configurable: true, + }); + Object.defineProperty(column, 'scrollTop', { value: 0, writable: true, configurable: true }); + Object.defineProperty(scrollable, 'scrollHeight', { value: 3600, configurable: true }); + Object.defineProperty(scrollable, 'scrollTop', { + value: 2500, + writable: true, + configurable: true, + }); + + act(() => { + fireEvent.scroll(scrollable); + jest.advanceTimersByTime(32); + }); + + expect(column.scrollTop).toBe(150); + }); + + it('scrolls the indicator column fully down when the chat reaches bottom with stale offsets', () => { + const messages = Array.from({ length: 15 }, (_, i) => + buildMessage({ + messageId: `bottom-${i}`, + text: `message ${i}`, + isCreatedByUser: i % 2 === 0, + }), + ); + mockUseGetMessagesByConvoId.mockReturnValue({ data: messages }); + + const scrollable = document.createElement('div'); + scrollable.className = 'scrollbar-gutter-stable'; + Object.defineProperty(scrollable, 'clientHeight', { value: 600, configurable: true }); + Object.defineProperty(scrollable, 'scrollHeight', { value: 3000, configurable: true }); + Object.defineProperty(scrollable, 'scrollTop', { + value: 0, + writable: true, + configurable: true, + }); + + const content = document.createElement('div'); + scrollable.appendChild(content); + for (let i = 0; i < messages.length; i++) { + const div = document.createElement('div'); + div.id = messages[i].messageId; + div.className = 'message-render'; + div.textContent = messages[i].text ?? ''; + Object.defineProperty(div, 'offsetTop', { + value: i === messages.length - 1 ? 5000 : 100 + i * 100, + configurable: true, + }); + Object.defineProperty(div, 'offsetHeight', { value: 80, configurable: true }); + content.appendChild(div); + } + document.body.appendChild(scrollable); + + const { container } = render( + } />, + ); + act(() => { + jest.advanceTimersByTime(250); + }); + + const column = container.querySelector('nav > div') as HTMLDivElement; + Object.defineProperty(column, 'clientHeight', { value: 30, configurable: true }); + Object.defineProperty(column, 'scrollHeight', { value: 180, configurable: true }); + Object.defineProperty(column, 'scrollTop', { value: 0, writable: true, configurable: true }); + Object.defineProperty(scrollable, 'scrollTop', { + value: 2400, + writable: true, + configurable: true, + }); + + act(() => { + fireEvent.scroll(scrollable); + jest.advanceTimersByTime(32); + }); + + expect(column.scrollTop).toBe(150); + }); + + it('keeps per-instance scroll tokens isolated across mounted MessageNav instances', () => { + const messagesA = [ + buildMessage({ messageId: 'a1', text: 'one', isCreatedByUser: true }), + buildMessage({ messageId: 'a2', text: 'two' }), + buildMessage({ messageId: 'a3', text: 'three', isCreatedByUser: true }), + ]; + const messagesB = [ + buildMessage({ messageId: 'b1', text: 'alpha', isCreatedByUser: true }), + buildMessage({ messageId: 'b2', text: 'beta' }), + buildMessage({ messageId: 'b3', text: 'gamma', isCreatedByUser: true }), + ]; + + mockUseGetMessagesByConvoId.mockReturnValue({ data: messagesA }); + const domA = buildDom(messagesA); + const { container: navA } = render( + } />, + ); + act(() => { + jest.advanceTimersByTime(250); + }); + + mockUseGetMessagesByConvoId.mockReturnValue({ data: messagesB }); + const domB = buildDom(messagesB); + const { container: navB } = render( + } />, + ); + act(() => { + jest.advanceTimersByTime(250); + }); + + const steps: Array<(ts: number) => void> = []; + const rafSpy = jest + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((cb: FrameRequestCallback) => { + steps.push(cb); + return steps.length; + }); + + const indA = navA.querySelectorAll('[data-msg-id]')[2] as HTMLButtonElement; + const indB = navB.querySelectorAll('[data-msg-id]')[2] as HTMLButtonElement; + + act(() => { + fireEvent.click(indA); + }); + act(() => { + fireEvent.click(indB); + }); + + expect(steps.length).toBeGreaterThanOrEqual(2); + + let aScrollTouched = false; + Object.defineProperty(domA.scrollable, 'scrollTop', { + get: () => 0, + set: () => { + aScrollTouched = true; + }, + configurable: true, + }); + act(() => { + steps[0](performance.now()); + }); + expect(aScrollTouched).toBe(true); + + rafSpy.mockRestore(); + }); + }); + + describe('observers', () => { + it('observes each message on mount', () => { + const messages = [ + buildMessage({ messageId: 'a', text: 'alpha', isCreatedByUser: true }), + buildMessage({ messageId: 'b', text: 'bravo' }), + buildMessage({ messageId: 'c', text: 'charlie', isCreatedByUser: true }), + ]; + renderNav(messages); + const io = MockIntersectionObserver.last(); + expect(io).toBeDefined(); + expect(io!.observed.size).toBe(3); + expect(io!.observe).toHaveBeenCalledTimes(3); + }); + + it('reuses the same IntersectionObserver when entries change', async () => { + const messages = [ + buildMessage({ messageId: 'a', text: 'alpha', isCreatedByUser: true }), + buildMessage({ messageId: 'b', text: 'bravo' }), + buildMessage({ messageId: 'c', text: 'charlie', isCreatedByUser: true }), + ]; + const { scrollable } = renderNav(messages); + + const instanceCountAfterMount = MockIntersectionObserver.instances.length; + const io = MockIntersectionObserver.last()!; + const initialObserveCalls = io.observe.mock.calls.length; + const initialUnobserveCalls = io.unobserve.mock.calls.length; + + const newMsg = document.createElement('div'); + newMsg.id = 'd'; + newMsg.className = 'message-render'; + newMsg.textContent = 'delta'; + Object.defineProperty(newMsg, 'offsetTop', { value: 800 }); + Object.defineProperty(newMsg, 'offsetHeight', { value: 150 }); + + await act(async () => { + scrollable.firstElementChild!.appendChild(newMsg); + await Promise.resolve(); + }); + mockUseGetMessagesByConvoId.mockReturnValue({ + data: [...messages, buildMessage({ messageId: 'd', text: 'delta' })], + }); + await act(async () => { + jest.advanceTimersByTime(250); + await Promise.resolve(); + }); + + expect(MockIntersectionObserver.instances.length).toBe(instanceCountAfterMount); + expect(io.observe.mock.calls.length).toBe(initialObserveCalls + 1); + expect(io.unobserve.mock.calls.length).toBe(initialUnobserveCalls); + }); + + it('unobserves messages that are removed', async () => { + const messages = [ + buildMessage({ messageId: 'a', text: 'alpha', isCreatedByUser: true }), + buildMessage({ messageId: 'b', text: 'bravo' }), + buildMessage({ messageId: 'c', text: 'charlie', isCreatedByUser: true }), + buildMessage({ messageId: 'd', text: 'delta' }), + ]; + const { scrollable } = renderNav(messages); + const io = MockIntersectionObserver.last()!; + const initialUnobserve = io.unobserve.mock.calls.length; + + const removed = document.getElementById('d'); + await act(async () => { + removed?.remove(); + await Promise.resolve(); + }); + mockUseGetMessagesByConvoId.mockReturnValue({ data: messages.slice(0, 3) }); + await act(async () => { + jest.advanceTimersByTime(250); + await Promise.resolve(); + }); + + expect(io.unobserve.mock.calls.length).toBeGreaterThan(initialUnobserve); + expect(scrollable.contains(removed as Node)).toBe(false); + }); + + it('disconnects the IntersectionObserver on unmount', () => { + const messages = [ + buildMessage({ messageId: 'a', text: 'alpha', isCreatedByUser: true }), + buildMessage({ messageId: 'b', text: 'bravo' }), + buildMessage({ messageId: 'c', text: 'charlie', isCreatedByUser: true }), + ]; + const { unmount } = renderNav(messages); + const io = MockIntersectionObserver.last()!; + expect(io.disconnect).not.toHaveBeenCalled(); + unmount(); + expect(io.disconnect).toHaveBeenCalled(); + }); + }); + + describe('dom-driven refresh', () => { + it('refreshes entries when a .message-render id attribute changes (SSE lifecycle)', async () => { + const messages = [ + buildMessage({ messageId: 'client-uuid', text: 'streaming', isCreatedByUser: false }), + buildMessage({ messageId: 'b', text: 'bravo', isCreatedByUser: true }), + buildMessage({ messageId: 'c', text: 'charlie', isCreatedByUser: true }), + ]; + const { container } = renderNav(messages); + expect(container.querySelector('[data-msg-id="client-uuid"]')).not.toBeNull(); + + const streamingNode = document.getElementById('client-uuid') as HTMLElement; + await act(async () => { + streamingNode.id = 'server-id'; + await Promise.resolve(); + }); + mockUseGetMessagesByConvoId.mockReturnValue({ + data: [buildMessage({ messageId: 'server-id', text: 'streaming' }), ...messages.slice(1)], + }); + await act(async () => { + jest.advanceTimersByTime(250); + await Promise.resolve(); + }); + + expect(container.querySelector('[data-msg-id="client-uuid"]')).toBeNull(); + expect(container.querySelector('[data-msg-id="server-id"]')).not.toBeNull(); + }); + + it('refreshes entries when a .message-render is added via MutationObserver', async () => { + const messages = [ + buildMessage({ messageId: 'a', text: 'alpha', isCreatedByUser: true }), + buildMessage({ messageId: 'b', text: 'bravo' }), + buildMessage({ messageId: 'c', text: 'charlie', isCreatedByUser: true }), + ]; + const { container, scrollable } = renderNav(messages); + expect(container.querySelectorAll('[data-msg-id]')).toHaveLength(3); + + const newMsg = document.createElement('div'); + newMsg.id = 'd'; + newMsg.className = 'message-render'; + newMsg.textContent = 'delta'; + Object.defineProperty(newMsg, 'offsetTop', { value: 800 }); + Object.defineProperty(newMsg, 'offsetHeight', { value: 150 }); + + await act(async () => { + scrollable.firstElementChild!.appendChild(newMsg); + await Promise.resolve(); + }); + await act(async () => { + jest.advanceTimersByTime(250); + await Promise.resolve(); + }); + + expect(container.querySelectorAll('[data-msg-id]')).toHaveLength(4); + expect(container.querySelector('[data-msg-id="d"]')).not.toBeNull(); + }); + }); +}); diff --git a/client/src/components/Messages/ScrollToBottom.tsx b/client/src/components/Messages/ScrollToBottom.tsx index 0b99df0a61..b6537a1493 100644 --- a/client/src/components/Messages/ScrollToBottom.tsx +++ b/client/src/components/Messages/ScrollToBottom.tsx @@ -1,27 +1,34 @@ import { forwardRef } from 'react'; +import { useRecoilValue } from 'recoil'; +import { ChevronDown } from 'lucide-react'; +import { useLocalize } from '~/hooks'; +import { cn } from '~/utils'; +import store from '~/store'; type Props = { scrollHandler: React.MouseEventHandler; }; -const ScrollToBottom = forwardRef(({ scrollHandler }, ref) => { +const ScrollToBottom = forwardRef(({ scrollHandler }, ref) => { + const localize = useLocalize(); + const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace); + return ( - + + ); }); diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 7bf4ad96ff..a6b77c40ac 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1220,6 +1220,11 @@ "com_ui_mermaid_failed": "Failed to render diagram:", "com_ui_mermaid_source": "Source code:", "com_ui_message_input": "Message input", + "com_ui_message_nav": "Message navigation", + "com_ui_message_nav_go_to_assistant": "Go to assistant message: {{0}}", + "com_ui_message_nav_go_to_user": "Go to user message: {{0}}", + "com_ui_message_nav_next": "Navigate to next message", + "com_ui_message_nav_previous": "Navigate to previous message", "com_ui_microphone_unavailable": "Microphone is not available", "com_ui_min_tags": "Cannot remove more values, a minimum of {{0}} are required.", "com_ui_minimal": "Minimal", @@ -1419,6 +1424,7 @@ "com_ui_saving": "Saving...", "com_ui_schema": "Schema", "com_ui_scope": "Scope", + "com_ui_scroll_to_bottom": "Scroll to bottom", "com_ui_search": "Search", "com_ui_search_above_to_add": "Search above to add users or groups", "com_ui_search_above_to_add_all": "Search above to add users, groups, or roles", diff --git a/client/src/style.css b/client/src/style.css index e934d241c1..902860ef52 100644 --- a/client/src/style.css +++ b/client/src/style.css @@ -2,6 +2,10 @@ @tailwind components; @tailwind utilities; +.message-render { + scroll-margin-top: 4rem; +} + /* Custom Variables */ :root { --white: #fff; @@ -647,127 +651,83 @@ pre { margin: 0; } +/* Scroll-to-bottom button — enter */ .scroll-animation-enter { opacity: 0; - transform: translateY(20px) scale(0.7) rotate(-5deg); + transform: translateY(12px) scale(0.9); pointer-events: none; } +.scroll-animation-enter-active { + animation: scroll-btn-enter 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + .scroll-animation-enter-done { opacity: 1; transform: translateY(0) scale(1); } +@keyframes scroll-btn-enter { + 0% { + opacity: 0; + transform: translateY(12px) scale(0.9); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +/* Scroll-to-bottom button — exit */ +.scroll-animation-exit-active { + animation: scroll-btn-exit 0.25s cubic-bezier(0.4, 0, 1, 1) forwards; + pointer-events: none; +} + .scroll-animation-exit-done { display: none; } -@keyframes twist-entrance { +@keyframes scroll-btn-exit { 0% { - transform: translateY(20px) scale(0.7) rotate(-5deg); - opacity: 0; - } - 60% { - transform: translateY(2px) scale(0.95) rotate(2deg); - opacity: 0.9; + opacity: 1; + transform: translateY(0) scale(1); } 100% { - transform: translateY(0) scale(1) rotate(0deg); - opacity: 1; - } -} - -.scroll-animation-enter-active { - animation: twist-entrance 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards; - transition-delay: 50ms; -} - -@keyframes twist-exit { - 0% { - transform: translateY(0) scale(1) rotate(0deg); - opacity: 1; - } - 40% { - transform: translateY(5px) scale(0.95) rotate(2deg); - opacity: 0.7; - } - 100% { - transform: translateY(20px) scale(0.7) rotate(-5deg); opacity: 0; + transform: translateY(8px) scale(0.95); } } -.scroll-animation-exit-active { - animation: twist-exit 0.4s cubic-bezier(0.55, 0.085, 0.68, 0.53) forwards; - pointer-events: none; -} - +/* Scroll-to-bottom button */ .premium-scroll-button { display: flex; align-items: center; justify-content: center; - width: 34px; - height: 34px; + width: 32px; + height: 32px; padding: 0; border-radius: 50%; - box-shadow: - 0 2px 8px rgba(0, 0, 0, 0.05), - 0 4px 12px rgba(0, 0, 0, 0.08), - 0 0 0 1px rgba(255, 255, 255, 0.08); - background-color: rgba(255, 255, 255, 0.9); + border: 1px solid rgba(0, 0, 0, 0.08); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + background-color: #ffffff; z-index: 10; - transition: - transform 500ms cubic-bezier(0.25, 0.1, 0.25, 1), - box-shadow 500ms cubic-bezier(0.25, 0.1, 0.25, 1); overflow: hidden; + transition: transform 100ms ease; } .dark .premium-scroll-button { - box-shadow: - 0 2px 8px rgba(0, 0, 0, 0.2), - 0 4px 12px rgba(0, 0, 0, 0.25), - 0 0 0 1px rgba(255, 255, 255, 0.06); - background-color: rgba(35, 35, 40, 0.9); + border-color: rgba(255, 255, 255, 0.1); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + background-color: #2a2a2e; } .scroll-animation-enter-active .premium-scroll-button { pointer-events: none !important; } -.premium-scroll-button:hover:not(:active) { - transform: translateY(-1.5px) scale(1.02); - box-shadow: - 0 5px 10px rgba(0, 0, 0, 0.07), - 0 7px 14px rgba(0, 0, 0, 0.1), - 0 0 0 1px rgba(255, 255, 255, 0.1); -} - .premium-scroll-button:active { - transform: translateY(1px) scale(0.98); - transition: all 150ms cubic-bezier(0.2, 0, 0.2, 1); - box-shadow: - 0 1px 4px rgba(0, 0, 0, 0.1), - 0 2px 8px rgba(0, 0, 0, 0.08), - 0 0 0 1px rgba(255, 255, 255, 0.08); -} - -@keyframes float { - 0%, - 100% { - transform: translateY(0); - } - 50% { - transform: translateY(-1px); - } -} - -.scroll-animation-enter-done .premium-scroll-button { - animation: float 2s ease-in-out infinite; -} - -.premium-scroll-button:hover, -.premium-scroll-button:active { - animation: none; + transform: scale(0.95); } .blink { diff --git a/package-lock.json b/package-lock.json index 2a0f598ada..47f48370bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -491,7 +491,7 @@ "@babel/plugin-transform-runtime": "^7.22.15", "@babel/preset-env": "^7.22.15", "@babel/preset-react": "^7.22.15", - "@babel/preset-typescript": "^7.22.15", + "@babel/preset-typescript": "^7.28.5", "@happy-dom/jest-environment": "^20.8.9", "@tanstack/react-query-devtools": "^4.29.0", "@testing-library/dom": "^9.3.0", @@ -609,28 +609,6 @@ "node": ">=6.9.0" } }, - "client/node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.26.9.tgz", - "integrity": "sha512-ubbUqCofvxPRurw5L8WTsCLSkQiVpov4Qx0WMA+jUN+nXBK8ADPlJO1grkFw5CWKC5+sZSOfuGMdX1aI1iT9Sg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.25.9", - "@babel/helper-member-expression-to-functions": "^7.25.9", - "@babel/helper-optimise-call-expression": "^7.25.9", - "@babel/helper-replace-supers": "^7.26.5", - "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", - "@babel/traverse": "^7.26.9", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, "client/node_modules/@babel/helper-define-polyfill-provider": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.3.tgz", @@ -648,20 +626,6 @@ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, - "client/node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz", - "integrity": "sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - } - }, "client/node_modules/@babel/helper-module-imports": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", @@ -694,19 +658,6 @@ "@babel/core": "^7.0.0" } }, - "client/node_modules/@babel/helper-optimise-call-expression": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.9.tgz", - "integrity": "sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - } - }, "client/node_modules/@babel/helper-remap-async-to-generator": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.9.tgz", @@ -725,38 +676,6 @@ "@babel/core": "^7.0.0" } }, - "client/node_modules/@babel/helper-replace-supers": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.26.5.tgz", - "integrity": "sha512-bJ6iIVdYX1YooY2X7w1q6VITt+LnUILtNk7zT78ykuwStx8BauCzxvFqFaHjOpW1bVnSUM1PN1f0p5P21wHxvg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.25.9", - "@babel/helper-optimise-call-expression": "^7.25.9", - "@babel/traverse": "^7.26.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "client/node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.25.9.tgz", - "integrity": "sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - } - }, "client/node_modules/@babel/helper-wrap-function": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.25.9.tgz", @@ -1222,23 +1141,6 @@ "@babel/core": "^7.0.0-0" } }, - "client/node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.26.3.tgz", - "integrity": "sha512-MgR55l4q9KddUDITEzEFYn5ZsGDXMSsU9E+kh7fjRXTIC3RHqfCo8RPRbyReYJh44HQ/yomFkqbOFohXvDCiIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-transforms": "^7.26.0", - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "client/node_modules/@babel/plugin-transform-modules-systemjs": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.25.9.tgz", @@ -5074,13 +4976,13 @@ } }, "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz", - "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==", + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.25.9" + "@babel/types": "^7.27.3" }, "engines": { "node": ">=6.9.0" @@ -5123,19 +5025,18 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.23.10", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.23.10.tgz", - "integrity": "sha512-2XpP2XhkXzgxecPNEEK8Vz8Asj9aRxt08oKOqtiZoqV2UGZ5T+EkyP9sXQ9nwMxBIG34a7jmasVqoMop7VdPUw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", + "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-member-expression-to-functions": "^7.23.0", - "@babel/helper-optimise-call-expression": "^7.22.5", - "@babel/helper-replace-supers": "^7.22.20", - "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.6", "semver": "^6.3.1" }, "engines": { @@ -5145,6 +5046,102 @@ "@babel/core": "^7.0.0" } }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -5240,12 +5237,14 @@ } }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.23.0.tgz", - "integrity": "sha512-6gfrPwh7OuT6gZyJZvd6WbTfrqAo7vm4xCzAXOusKqq/vWdKXphTpj5klHKNmRUU6/QRGlBsyU9mAIPaWHlqJA==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/types": "^7.23.0" + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" }, "engines": { "node": ">=6.9.0" @@ -5282,21 +5281,22 @@ } }, "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.22.5.tgz", - "integrity": "sha512-HBwaojN0xFRx4yIvpwGqxiV2tUfl7401jlok564NgB9EHS1y6QT17FmKWm4ztqjeVdXLuC4fSvHc5ePpQjoTbw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/types": "^7.22.5" + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -5320,14 +5320,15 @@ } }, "node_modules/@babel/helper-replace-supers": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.22.20.tgz", - "integrity": "sha512-qsW0In3dbwQUbK8kejJ4R7IHVGwHJlV6lpG6UA7a9hSa2YEiAib+N1T2kr6PEeUT+Fl7najmSOS6SmAwCHK6Tw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", + "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-member-expression-to-functions": "^7.22.15", - "@babel/helper-optimise-call-expression": "^7.22.5" + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -5336,26 +5337,111 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-simple-access": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.24.7.tgz", - "integrity": "sha512-zBAIvbCMh5Ts+b86r/CjU+4XGYIs+R1j951gxI3KmmxBMhCg4oQMsv6ZXQ64XOm/cvzfU1FmoCyt6+owc5QMYg==", + "node_modules/@babel/helper-replace-supers/node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/traverse": "^7.24.7", - "@babel/types": "^7.24.7" + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-replace-supers/node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-replace-supers/node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/helper-replace-supers/node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-replace-supers/node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-replace-supers/node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.22.5.tgz", - "integrity": "sha512-tK14r66JZKiC43p8Ki33yLBVJKlQDFoA8GYN67lWCDCqoL6EMMSuM9b+Iff2jHaM/RRFYl7K+iiru7hbRqNx8Q==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/types": "^7.22.5" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -6217,14 +6303,14 @@ } }, "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.23.3.tgz", - "integrity": "sha512-aVS0F65LKsdNOtcz6FRCpE4OgsP2OFnW46qNxNIX9h3wuzaNcSQsJysuMwqSibC98HPrf2vCgtxKNwS0DAlgcA==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", + "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.23.3", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-simple-access": "^7.22.5" + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -6233,6 +6319,134 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-modules-commonjs/node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs/node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs/node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs/node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs/node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs/node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs/node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs/node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/plugin-transform-modules-systemjs": { "version": "7.23.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.23.9.tgz", @@ -6711,15 +6925,33 @@ } }, "node_modules/@babel/plugin-transform-typescript": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.23.6.tgz", - "integrity": "sha512-6cBG5mBvUu4VUD04OHKnYzbuHNP8huDsD3EDqqpIpsswTDoqHCjLoHb6+QgsV1WsT2nipRqCPgxD3LXnEO7XfA==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", + "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-create-class-features-plugin": "^7.23.6", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-typescript": "^7.23.3" + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript/node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -6929,16 +7161,17 @@ } }, "node_modules/@babel/preset-typescript": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.23.3.tgz", - "integrity": "sha512-17oIGVlqz6CchO9RFYn5U6ZpWRZIngayYCtrPRSgANSwC2V1Jb+iP74nVxzzXJte8b8BYxrL1yY96xfhTBrNNQ==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", + "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-validator-option": "^7.22.15", - "@babel/plugin-syntax-jsx": "^7.23.3", - "@babel/plugin-transform-modules-commonjs": "^7.23.3", - "@babel/plugin-transform-typescript": "^7.23.3" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-typescript": "^7.28.5" }, "engines": { "node": ">=6.9.0" @@ -44614,41 +44847,6 @@ "dev": true, "license": "MIT" }, - "packages/client/node_modules/@babel/helper-annotate-as-pure": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", - "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.3" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "packages/client/node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.5.tgz", - "integrity": "sha512-q3WC4JfdODypvxArsJQROfupPBq9+lMwjKq7C33GhbFYJsufD0yd/ziwD+hJucLeWsnFPWZjsU2DNFqBPE7jwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.28.5", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, "packages/client/node_modules/@babel/helper-create-regexp-features-plugin": { "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", @@ -44684,33 +44882,6 @@ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, - "packages/client/node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", - "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "packages/client/node_modules/@babel/helper-optimise-call-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", - "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "packages/client/node_modules/@babel/helper-remap-async-to-generator": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", @@ -44729,38 +44900,6 @@ "@babel/core": "^7.0.0" } }, - "packages/client/node_modules/@babel/helper-replace-supers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz", - "integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.27.1", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "packages/client/node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", - "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "packages/client/node_modules/@babel/helper-wrap-function": { "version": "7.28.3", "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.3.tgz", @@ -45246,23 +45385,6 @@ "@babel/core": "^7.0.0-0" } }, - "packages/client/node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.27.1.tgz", - "integrity": "sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "packages/client/node_modules/@babel/plugin-transform-modules-systemjs": { "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.28.5.tgz", @@ -45700,26 +45822,6 @@ "@babel/core": "^7.0.0-0" } }, - "packages/client/node_modules/@babel/plugin-transform-typescript": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.5.tgz", - "integrity": "sha512-x2Qa+v/CuEoX7Dr31iAfr0IhInrVOWZU/2vJMJ00FOR/2nM0BcBEclpaf9sWCDc+v5e9dMrhSH8/atq/kX7+bA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-create-class-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-syntax-typescript": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "packages/client/node_modules/@babel/plugin-transform-unicode-escapes": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", @@ -45893,26 +45995,6 @@ "@babel/core": "^7.0.0-0" } }, - "packages/client/node_modules/@babel/preset-typescript": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", - "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-syntax-jsx": "^7.27.1", - "@babel/plugin-transform-modules-commonjs": "^7.27.1", - "@babel/plugin-transform-typescript": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "packages/client/node_modules/@tanstack/react-table": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",