🧭 feat: Add Message Navigation Strip & Redesign Scroll-to-Bottom (#12657)

* 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 <div> instead of
  the inner <button>. CSSTransition's nodeRef + unmountOnExit can now
  add transition classes to the actual root element, so the layout
  wrapper is what mounts/unmounts, not just the button. Updated
  scrollToBottomRef type in MessagesView to HTMLDivElement.
- jumpToPrevious / jumpToNext skip the document.getElementById fallback
  lookup when scrollMarginRef is already populated, which is the normal
  case after the first scroll-tick effect run.

* fix(ui): preserve IntersectionObserver across in-place id mutations

The IO sync effect was observing new ids before unobserving old ones.
During the SSE lifecycle of a fresh chat, a single .message-render node
cycles through three ids (client UUID -> handler id -> server id). When
the id mutated on the same element, the effect would call observe(el)
then unobserve(el) on that element in the same pass — leaving it
permanently unobserved. The active-message highlight never updated for
the new id until a hard refresh rebuilt everything from scratch.

Switched to element-identity tracking. Build an element -> newId map
from entries, then for each currently observed [oldId, el]:

  - if the element no longer appears in entries, unobserve and drop it
  - if the element appears under a new id, migrate observed and
    visibleSet keys in place — the IntersectionObserver keeps watching
    the same DOM node uninterrupted

Genuinely new elements get observed afterward as before. Rename doesn't
fire an IO callback, so flush activeIds manually when at least one
migration happened. Existing convos already had this working because
their ids never mutate after load — only fresh chats hit the SSE id
cycle, which matches the reproduction.

* fix(ui): keep message nav current and pinned at bottom
This commit is contained in:
Marco Beretta 2026-05-06 21:53:06 +02:00 committed by GitHub
parent f2de3a219c
commit 4bd5630651
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1911 additions and 414 deletions

View file

@ -122,7 +122,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",

View file

@ -0,0 +1,684 @@
import { memo, useState, useEffect, useCallback, useRef, useMemo } from 'react';
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 { useGetMessagesByConvoId } from '~/data-provider';
import { useMessagesConversation } from '~/Providers';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
type MessageEntry = {
id: string;
isUser: boolean;
preview: string;
};
function extractPreviewFromContent(content?: TMessageContentParts[]): string {
if (!content) {
return '';
}
for (const part of content) {
if (part.type !== ContentTypes.TEXT) {
continue;
}
const textField = part.text;
if (typeof textField === 'string' && textField.trim()) {
return textField;
}
if (textField && typeof textField === 'object' && textField.value?.trim()) {
return textField.value;
}
}
return '';
}
function buildEntry(id: string, msg: TMessage): MessageEntry {
const raw = msg.text?.trim() ? msg.text : extractPreviewFromContent(msg.content);
const trimmed = raw.trim();
return {
id,
isUser: !!msg.isCreatedByUser,
preview: trimmed.slice(0, 80) + (trimmed.length > 80 ? '...' : ''),
};
}
const USER_TURN_SELECTOR = '.user-turn';
function buildFallbackEntry(node: HTMLElement, id: string): MessageEntry {
const isUser = node.querySelector(USER_TURN_SELECTOR) != null;
const trimmed = (node.textContent ?? '').trim();
return {
id,
isUser,
preview: trimmed.slice(0, 80) + (trimmed.length > 80 ? '...' : ''),
};
}
function getMessageEntries(root: ParentNode, messagesById: Map<string, TMessage>): MessageEntry[] {
const nodes = root.querySelectorAll<HTMLElement>('.message-render');
const entries: MessageEntry[] = [];
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
const id = node.id;
if (!id) {
continue;
}
const msg = messagesById.get(id);
entries.push(msg ? buildEntry(id, msg) : buildFallbackEntry(node, id));
}
return entries;
}
const JUMP_EPS = 4;
const SCROLL_DURATION = 400;
const BOTTOM_SNAP_RETRIES = 2;
function easeOutCubic(t: number): number {
return 1 - Math.pow(1 - t, 3);
}
function readScrollMargin(el: HTMLElement | null): number {
if (!el) {
return 0;
}
const value = parseFloat(getComputedStyle(el).scrollMarginTop);
return Number.isFinite(value) ? value : 0;
}
const indicatorButtonClasses = cn(
'flex h-[5px] items-center justify-center rounded-sm',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy',
);
const MessageIndicator = memo(function MessageIndicator({
entry,
isActive,
isCurrent,
label,
onSelect,
}: {
entry: MessageEntry;
isActive: boolean;
isCurrent: boolean;
label: string;
onSelect: (id: string) => void;
}) {
return (
<HoverCard openDelay={150}>
<HoverCardTrigger asChild>
<button
type="button"
onClick={() => onSelect(entry.id)}
className={cn(indicatorButtonClasses, entry.isUser ? 'w-4' : 'w-6')}
aria-label={label}
aria-current={isCurrent ? 'true' : undefined}
data-msg-id={entry.id}
>
<span
className={cn(
'block w-full rounded-full transition-all duration-200',
isActive
? 'h-[5px] bg-gray-800 dark:bg-gray-100'
: 'h-[3px] bg-gray-400 dark:bg-gray-500',
)}
/>
</button>
</HoverCardTrigger>
<HoverCardPortal>
<HoverCardContent side="left" sideOffset={12} className="z-[999] max-w-[280px] px-3 py-2">
<p className="line-clamp-3 text-xs text-text-secondary">{entry.preview}</p>
</HoverCardContent>
</HoverCardPortal>
</HoverCard>
);
});
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<HTMLDivElement> }) {
const localize = useLocalize();
const { conversationId } = useMessagesConversation();
const { data: messages } = useGetMessagesByConvoId(conversationId ?? '', {
enabled: !!conversationId,
});
const messagesById = useMemo(() => {
const map = new Map<string, TMessage>();
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<MessageEntry[]>([]);
const [visibleIds, setVisibleIds] = useState<Set<string>>(new Set());
const [currentId, setCurrentId] = useState<string | null>(null);
const [canGoUp, setCanGoUp] = useState(false);
const [canGoDown, setCanGoDown] = useState(false);
const observerRef = useRef<IntersectionObserver | null>(null);
const observedRef = useRef(new Map<string, HTMLElement>());
const columnRef = useRef<HTMLDivElement>(null);
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const visibleSetRef = useRef(new Set<string>());
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<HTMLElement>('.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<HTMLElement, string>();
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 (
<nav
aria-label={localize('com_ui_message_nav')}
className={cn(
'group/nav absolute right-2 top-1/2 z-40 hidden max-h-[min(24rem,calc(100%-2rem))]',
'-translate-y-1/2 flex-col items-center gap-1.5 rounded-full px-1 py-2 md:flex',
'opacity-30 transition-opacity duration-300',
'hover:bg-black/5 hover:opacity-100 dark:hover:bg-white/5',
'focus-within:bg-black/5 focus-within:opacity-100 dark:focus-within:bg-white/5',
)}
>
<button
type="button"
onClick={jumpToPrevious}
disabled={!canGoUp}
className={chevronButtonClasses}
aria-label={localize('com_ui_message_nav_previous')}
>
<ChevronUp className="h-4 w-4" />
</button>
<div
ref={columnRef}
className="flex min-h-0 flex-col items-center gap-1.5 overflow-y-auto [&::-webkit-scrollbar]:hidden"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{entries.map((entry) => (
<MessageIndicator
key={entry.id}
entry={entry}
isActive={visibleIds.has(entry.id)}
isCurrent={currentId === entry.id}
onSelect={scrollToStart}
label={localize(
entry.isUser ? 'com_ui_message_nav_go_to_user' : 'com_ui_message_nav_go_to_assistant',
{ 0: entry.preview.slice(0, 30) },
)}
/>
))}
</div>
<button
type="button"
onClick={jumpToNext}
disabled={!canGoDown}
className={chevronButtonClasses}
aria-label={localize('com_ui_message_nav_next')}
>
<ChevronDown className="h-4 w-4" />
</button>
</nav>
);
}
export default memo(MessageNav);

View file

@ -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<number | string | null>(-1);
const scrollToBottomRef = useRef<HTMLButtonElement>(null);
const scrollToBottomRef = useRef<HTMLDivElement>(null);
const {
conversation,
@ -81,8 +82,8 @@ function MessagesViewContent({
<CSSTransition
in={showScrollButton && scrollButtonPreference}
timeout={{
enter: 550,
exit: 700,
enter: 300,
exit: 250,
}}
classNames="scroll-animation"
unmountOnExit={true}
@ -91,6 +92,8 @@ function MessagesViewContent({
>
<ScrollToBottom ref={scrollToBottomRef} scrollHandler={handleSmoothToRef} />
</CSSTransition>
<MessageNav scrollableRef={scrollableRef} />
</div>
</div>
</>

View file

@ -0,0 +1,755 @@
import React from 'react';
import { render, act, fireEvent } from '@testing-library/react';
type ReactNode = React.ReactNode;
type RefObject<T> = React.RefObject<T>;
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, string | number>): 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 : <div>{children}</div>,
HoverCardPortal: ({ children }: { children: ReactNode }) => <>{children}</>,
HoverCardContent: ({ children, className }: { children: ReactNode; className?: string }) => (
<div data-testid="hover-card-content" className={className}>
{children}
</div>
),
}));
type IOEntry = Pick<IntersectionObserverEntry, 'target' | 'isIntersecting'>;
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<Element>();
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> = {}): 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<HTMLDivElement>;
const result = render(<MessageNav scrollableRef={scrollableRef} />);
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<HTMLDivElement>;
const { container } = render(<MessageNav scrollableRef={scrollableRef} />);
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(
<MessageNav scrollableRef={{ current: scrollable } as RefObject<HTMLDivElement>} />,
);
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(
<MessageNav scrollableRef={{ current: domA.scrollable } as RefObject<HTMLDivElement>} />,
);
act(() => {
jest.advanceTimersByTime(250);
});
mockUseGetMessagesByConvoId.mockReturnValue({ data: messagesB });
const domB = buildDom(messagesB);
const { container: navB } = render(
<MessageNav scrollableRef={{ current: domB.scrollable } as RefObject<HTMLDivElement>} />,
);
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();
});
});
});

View file

@ -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<HTMLButtonElement>;
};
const ScrollToBottom = forwardRef<HTMLButtonElement, Props>(({ scrollHandler }, ref) => {
const ScrollToBottom = forwardRef<HTMLDivElement, Props>(({ scrollHandler }, ref) => {
const localize = useLocalize();
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
return (
<button
<div
ref={ref}
onClick={scrollHandler}
className="premium-scroll-button absolute bottom-5 right-1/2 cursor-pointer border border-border-light bg-surface-secondary"
aria-label="Scroll to bottom"
className={cn(
'pointer-events-none absolute bottom-5 left-0 right-0 mx-auto flex justify-end',
maximizeChatSpace ? 'max-w-full' : 'md:max-w-3xl xl:max-w-4xl',
)}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" className="text-text-secondary">
<path
d="M17 13L12 18L7 13M12 6L12 17"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
></path>
</svg>
</button>
<button
onClick={scrollHandler}
className="premium-scroll-button pointer-events-auto cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy"
aria-label={localize('com_ui_scroll_to_bottom')}
>
<ChevronDown className="h-4 w-4 text-text-secondary" />
</button>
</div>
);
});

View file

@ -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",

View file

@ -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 {

708
package-lock.json generated
View file

@ -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",