mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-02 12:22:22 +00:00
🪗 feat: Dock-Style Fisheye Nav Rail With Instant Hover Preview (#14021)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
* ✨ feat: Dock-Fisheye Message Nav Rail with Instant Hover Preview * 🎚️ refactor: Uniform resting ribs + clickable cursor for message nav * 🧹 fix: One rib per message in nav rail (dedupe nested message-render) * 🎯 fix: Accurate fisheye focus + click-anywhere-to-jump in message nav - Measure rib centers relative to the column (getBoundingClientRect) instead of offsetTop, which was relative to the positioned <nav> and shifted the pointer->rib mapping by the chevron height (hovered line wasn't the peak, preview showed an earlier message). - Column-level click jumps to the focused rib, so clicking anywhere the preview is showing works even when the pointer is off the thin line. - Restore @librechat/client jest stub to keep the unit isolated. * 💡 fix: Highlight only the hovered rib white in message nav * 🫥 style: Transparent message nav (drop pill background) * ♿ feat: Keyboard focus mirrors hover (magnify + highlight + preview) in message nav Tabbing to / Shift+Alt+M focusing a rib now drives the same fisheye pipeline as pointer hover via onFocus/onBlur on the column: the focused rib magnifies, highlights white, and shows the shared preview. Also addresses Codex finding on keyboard-focus previews. * 🩹 fix: Live tooltip preview + legacy media-query fallback in message nav - Derive the shared preview text from entryById at render time instead of snapshotting it into tip state, so a streaming/updating message refreshes the open tooltip without leaving and re-entering the rail. - Feature-detect MediaQueryList.addEventListener and fall back to addListener/removeListener so the reduced-motion watcher no longer throws (and breaks the nav) on Safari/iOS < 14. Addresses both Codex findings on review 4601236141.
This commit is contained in:
parent
e5d5018d7f
commit
dd8a4558f1
2 changed files with 656 additions and 126 deletions
|
|
@ -1,7 +1,7 @@
|
|||
import { memo, useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import { HoverCard, HoverCardTrigger, HoverCardPortal, HoverCardContent } from '@librechat/client';
|
||||
import type { TMessage, TMessageContentParts } from 'librechat-data-provider';
|
||||
import { useMessagesConversation, useMessagesSubmission } from '~/Providers';
|
||||
import { useGetMessagesByConvoId } from '~/data-provider';
|
||||
|
|
@ -17,7 +17,7 @@ type MessageEntry = {
|
|||
|
||||
const MESSAGES_END_ID = 'messages-end';
|
||||
|
||||
function extractPreviewFromContent(content?: TMessageContentParts[]): string {
|
||||
export function extractPreviewFromContent(content?: TMessageContentParts[]): string {
|
||||
if (!content) {
|
||||
return '';
|
||||
}
|
||||
|
|
@ -36,7 +36,7 @@ function extractPreviewFromContent(content?: TMessageContentParts[]): string {
|
|||
return '';
|
||||
}
|
||||
|
||||
function buildEntry(id: string, msg: TMessage): MessageEntry {
|
||||
export function buildEntry(id: string, msg: TMessage): MessageEntry {
|
||||
const raw = msg.text?.trim() ? msg.text : extractPreviewFromContent(msg.content);
|
||||
const trimmed = raw.trim();
|
||||
return {
|
||||
|
|
@ -48,7 +48,7 @@ function buildEntry(id: string, msg: TMessage): MessageEntry {
|
|||
|
||||
const USER_TURN_SELECTOR = '.user-turn';
|
||||
|
||||
function buildFallbackEntry(node: HTMLElement, id: string): MessageEntry {
|
||||
export function buildFallbackEntry(node: HTMLElement, id: string): MessageEntry {
|
||||
const isUser = node.querySelector(USER_TURN_SELECTOR) != null;
|
||||
const trimmed = (node.textContent ?? '').trim();
|
||||
return {
|
||||
|
|
@ -61,12 +61,14 @@ function buildFallbackEntry(node: HTMLElement, id: string): MessageEntry {
|
|||
function getMessageEntries(root: ParentNode, messagesById: Map<string, TMessage>): MessageEntry[] {
|
||||
const nodes = root.querySelectorAll<HTMLElement>('.message-render');
|
||||
const entries: MessageEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i];
|
||||
const id = node.id;
|
||||
if (!id) {
|
||||
if (!id || seen.has(id)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(id);
|
||||
const msg = messagesById.get(id);
|
||||
entries.push(msg ? buildEntry(id, msg) : buildFallbackEntry(node, id));
|
||||
}
|
||||
|
|
@ -105,64 +107,67 @@ function computeTargetScroll(
|
|||
return Math.max(0, Math.min(target, max));
|
||||
}
|
||||
|
||||
type RibDims = { baseW: number; baseH: number; peakW: number; peakH: number };
|
||||
|
||||
const RIB_END: RibDims = { baseW: 4, baseH: 4, peakW: 6, peakH: 6 };
|
||||
const RIB_MESSAGE: RibDims = { baseW: 16, baseH: 3, peakW: 52, peakH: 6 };
|
||||
|
||||
/** Vertical falloff radius (content-space px) over which neighbouring ribs magnify. */
|
||||
const MAG_INFLUENCE = 50;
|
||||
/** Delay before the shared preview first opens; subsequent moves reposition instantly. */
|
||||
const TOOLTIP_OPEN_DELAY = 60;
|
||||
|
||||
export function ribDimsFor(entry: MessageEntry): RibDims {
|
||||
return entry.isEnd ? RIB_END : RIB_MESSAGE;
|
||||
}
|
||||
|
||||
/** Cosine bell: 1 at the pointer, easing to 0 at the influence radius. */
|
||||
export function magnifyFalloff(distance: number, influence: number): number {
|
||||
if (distance >= influence) {
|
||||
return 0;
|
||||
}
|
||||
return 0.5 * (1 + Math.cos((Math.PI * distance) / influence));
|
||||
}
|
||||
|
||||
const indicatorButtonClasses = cn(
|
||||
'flex h-[5px] items-center justify-center rounded-sm',
|
||||
'flex h-1.5 w-full items-center justify-end rounded-sm',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy',
|
||||
);
|
||||
|
||||
const MessageIndicator = memo(function MessageIndicator({
|
||||
entry,
|
||||
isActive,
|
||||
isHighlighted,
|
||||
isCurrent,
|
||||
label,
|
||||
hoverText,
|
||||
onSelect,
|
||||
}: {
|
||||
entry: MessageEntry;
|
||||
isActive: boolean;
|
||||
isHighlighted: boolean;
|
||||
isCurrent: boolean;
|
||||
label: string;
|
||||
hoverText: string;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const baseSize = entry.isEnd ? 'h-1 w-1' : 'h-[3px] w-4';
|
||||
return (
|
||||
<HoverCard openDelay={150}>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(entry.id)}
|
||||
className={cn(indicatorButtonClasses, entry.isEnd || !entry.isUser ? 'w-6' : 'w-4')}
|
||||
aria-label={label}
|
||||
aria-current={isCurrent ? 'true' : undefined}
|
||||
data-msg-id={entry.id}
|
||||
>
|
||||
{entry.isEnd ? (
|
||||
<span
|
||||
className={cn(
|
||||
'block rounded-full transition-all duration-200',
|
||||
isActive
|
||||
? 'h-1.5 w-1.5 bg-gray-800 dark:bg-gray-100'
|
||||
: 'h-1 w-1 bg-gray-400 dark:bg-gray-500',
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<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">{hoverText}</p>
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal>
|
||||
</HoverCard>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(entry.id);
|
||||
}}
|
||||
className={indicatorButtonClasses}
|
||||
aria-label={label}
|
||||
aria-current={isCurrent ? 'true' : undefined}
|
||||
data-msg-id={entry.id}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'block rounded-full',
|
||||
baseSize,
|
||||
isHighlighted ? 'bg-gray-800 dark:bg-gray-100' : 'bg-gray-400 dark:bg-gray-500',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -215,6 +220,30 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
|
|||
const navRef = useRef<HTMLElement>(null);
|
||||
const dragCleanupRef = useRef<(() => void) | null>(null);
|
||||
const suppressClickRef = useRef(false);
|
||||
const isDraggingRef = useRef(false);
|
||||
|
||||
const ribLayoutRef = useRef<
|
||||
Array<{ id: string; line: HTMLElement; center: number; dims: RibDims }>
|
||||
>([]);
|
||||
const pointerYRef = useRef<number | null>(null);
|
||||
const magRafRef = useRef<number | null>(null);
|
||||
const reducedMotionRef = useRef(false);
|
||||
const focusedIdRef = useRef<string | null>(null);
|
||||
const tipShownRef = useRef(false);
|
||||
const tipTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const tipElRef = useRef<HTMLDivElement | null>(null);
|
||||
const tipPosRef = useRef({ top: 0, right: 0 });
|
||||
|
||||
const [tip, setTip] = useState<{ id: string; top: number; right: number } | null>(null);
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||
|
||||
const entryById = useMemo(() => {
|
||||
const map = new Map<string, MessageEntry>();
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
map.set(entries[i].id, entries[i]);
|
||||
}
|
||||
return map;
|
||||
}, [entries]);
|
||||
|
||||
const getCurrentVisibleId = useCallback((): string | null => {
|
||||
let nextId: string | null = null;
|
||||
|
|
@ -346,6 +375,13 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
|
|||
[scrollToStart, focusMessage],
|
||||
);
|
||||
|
||||
const handleColumnClick = useCallback(() => {
|
||||
const id = focusedIdRef.current;
|
||||
if (id) {
|
||||
handleSelect(id);
|
||||
}
|
||||
}, [handleSelect]);
|
||||
|
||||
const focusNav = useCallback((): boolean => {
|
||||
const nav = navRef.current;
|
||||
if (!nav) {
|
||||
|
|
@ -398,6 +434,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
|
|||
document.removeEventListener('pointercancel', onUp);
|
||||
window.removeEventListener('blur', onBlur);
|
||||
dragCleanupRef.current = null;
|
||||
isDraggingRef.current = false;
|
||||
if (wasDragging) {
|
||||
suppressClickRef.current = true;
|
||||
window.setTimeout(() => {
|
||||
|
|
@ -419,6 +456,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
|
|||
return;
|
||||
}
|
||||
state.dragging = true;
|
||||
isDraggingRef.current = true;
|
||||
}
|
||||
scrubTo(ev.clientY);
|
||||
}
|
||||
|
|
@ -445,6 +483,236 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
|
|||
|
||||
useEffect(() => () => dragCleanupRef.current?.(), []);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
reducedMotionRef.current = mq.matches;
|
||||
const onChange = () => {
|
||||
reducedMotionRef.current = mq.matches;
|
||||
};
|
||||
if (typeof mq.addEventListener === 'function') {
|
||||
mq.addEventListener('change', onChange);
|
||||
return () => mq.removeEventListener('change', onChange);
|
||||
}
|
||||
mq.addListener(onChange);
|
||||
return () => mq.removeListener(onChange);
|
||||
}, []);
|
||||
|
||||
const measureRibs = useCallback(() => {
|
||||
const col = columnRef.current;
|
||||
if (!col) {
|
||||
return;
|
||||
}
|
||||
const colRect = col.getBoundingClientRect();
|
||||
const scrollTop = col.scrollTop;
|
||||
const layout: Array<{ id: string; line: HTMLElement; center: number; dims: RibDims }> = [];
|
||||
const kids = col.children;
|
||||
for (let i = 0; i < kids.length; i++) {
|
||||
const button = kids[i] as HTMLElement;
|
||||
const id = button.getAttribute('data-msg-id');
|
||||
const line = button.firstElementChild as HTMLElement | null;
|
||||
const entry = id ? entryById.get(id) : undefined;
|
||||
if (!id || !line || !entry) {
|
||||
continue;
|
||||
}
|
||||
const rect = button.getBoundingClientRect();
|
||||
layout.push({
|
||||
id,
|
||||
line,
|
||||
center: rect.top - colRect.top + scrollTop + rect.height / 2,
|
||||
dims: ribDimsFor(entry),
|
||||
});
|
||||
}
|
||||
ribLayoutRef.current = layout;
|
||||
}, [entryById]);
|
||||
|
||||
useEffect(() => {
|
||||
const raf = requestAnimationFrame(measureRibs);
|
||||
const col = columnRef.current;
|
||||
const resize = col ? new ResizeObserver(measureRibs) : null;
|
||||
if (col && resize) {
|
||||
resize.observe(col);
|
||||
}
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
resize?.disconnect();
|
||||
};
|
||||
}, [entries, measureRibs]);
|
||||
|
||||
const positionTip = useCallback((top: number, right: number) => {
|
||||
tipPosRef.current = { top, right };
|
||||
const el = tipElRef.current;
|
||||
if (el) {
|
||||
el.style.top = `${top}px`;
|
||||
el.style.right = `${right}px`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const revealTip = useCallback(
|
||||
(id: string | null) => {
|
||||
if (!id || !entryById.has(id)) {
|
||||
setTip(null);
|
||||
return;
|
||||
}
|
||||
setTip({ id, top: tipPosRef.current.top, right: tipPosRef.current.right });
|
||||
},
|
||||
[entryById],
|
||||
);
|
||||
|
||||
const clearTooltip = useCallback(() => {
|
||||
if (tipTimerRef.current) {
|
||||
clearTimeout(tipTimerRef.current);
|
||||
tipTimerRef.current = null;
|
||||
}
|
||||
if (focusedIdRef.current !== null) {
|
||||
focusedIdRef.current = null;
|
||||
setHoveredId(null);
|
||||
}
|
||||
if (tipShownRef.current) {
|
||||
tipShownRef.current = false;
|
||||
setTip(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const focusTooltip = useCallback(
|
||||
(id: string, top: number, right: number) => {
|
||||
positionTip(top, right);
|
||||
if (focusedIdRef.current === id) {
|
||||
return;
|
||||
}
|
||||
focusedIdRef.current = id;
|
||||
setHoveredId(id);
|
||||
if (tipShownRef.current) {
|
||||
revealTip(id);
|
||||
return;
|
||||
}
|
||||
if (tipTimerRef.current) {
|
||||
return;
|
||||
}
|
||||
tipTimerRef.current = setTimeout(() => {
|
||||
tipTimerRef.current = null;
|
||||
tipShownRef.current = true;
|
||||
revealTip(focusedIdRef.current);
|
||||
}, TOOLTIP_OPEN_DELAY);
|
||||
},
|
||||
[positionTip, revealTip],
|
||||
);
|
||||
|
||||
const applyMagnify = useCallback(() => {
|
||||
magRafRef.current = null;
|
||||
const col = columnRef.current;
|
||||
const layout = ribLayoutRef.current;
|
||||
const py = pointerYRef.current;
|
||||
if (!col || py == null || layout.length === 0) {
|
||||
return;
|
||||
}
|
||||
const reduce = reducedMotionRef.current;
|
||||
const colRect = col.getBoundingClientRect();
|
||||
const scrollTop = col.scrollTop;
|
||||
let nearestId: string | null = null;
|
||||
let nearestD = Number.POSITIVE_INFINITY;
|
||||
let nearestCenter = 0;
|
||||
for (let i = 0; i < layout.length; i++) {
|
||||
const rib = layout[i];
|
||||
const d = Math.abs(py - rib.center);
|
||||
if (d < nearestD) {
|
||||
nearestD = d;
|
||||
nearestId = rib.id;
|
||||
nearestCenter = rib.center;
|
||||
}
|
||||
if (reduce) {
|
||||
continue;
|
||||
}
|
||||
const t = magnifyFalloff(d, MAG_INFLUENCE);
|
||||
const dims = rib.dims;
|
||||
rib.line.style.transition = 'none';
|
||||
rib.line.style.width = `${(dims.baseW + (dims.peakW - dims.baseW) * t).toFixed(2)}px`;
|
||||
rib.line.style.height = `${(dims.baseH + (dims.peakH - dims.baseH) * t).toFixed(2)}px`;
|
||||
}
|
||||
if (nearestId != null && nearestD <= MAG_INFLUENCE && !isDraggingRef.current) {
|
||||
const top = colRect.top - scrollTop + nearestCenter;
|
||||
const right = window.innerWidth - colRect.left + 8;
|
||||
focusTooltip(nearestId, top, right);
|
||||
} else {
|
||||
clearTooltip();
|
||||
}
|
||||
}, [focusTooltip, clearTooltip]);
|
||||
|
||||
const resetMagnify = useCallback(() => {
|
||||
const layout = ribLayoutRef.current;
|
||||
for (let i = 0; i < layout.length; i++) {
|
||||
const line = layout[i].line;
|
||||
line.style.transition = 'width 140ms ease-out, height 140ms ease-out';
|
||||
line.style.width = '';
|
||||
line.style.height = '';
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const col = columnRef.current;
|
||||
if (!col) {
|
||||
return;
|
||||
}
|
||||
const rect = col.getBoundingClientRect();
|
||||
pointerYRef.current = e.clientY - rect.top + col.scrollTop;
|
||||
if (magRafRef.current == null) {
|
||||
magRafRef.current = requestAnimationFrame(applyMagnify);
|
||||
}
|
||||
},
|
||||
[applyMagnify],
|
||||
);
|
||||
|
||||
const handlePointerLeave = useCallback(() => {
|
||||
pointerYRef.current = null;
|
||||
if (magRafRef.current != null) {
|
||||
cancelAnimationFrame(magRafRef.current);
|
||||
magRafRef.current = null;
|
||||
}
|
||||
resetMagnify();
|
||||
clearTooltip();
|
||||
}, [resetMagnify, clearTooltip]);
|
||||
|
||||
const handleColumnFocus = useCallback(
|
||||
(e: React.FocusEvent<HTMLDivElement>) => {
|
||||
const col = columnRef.current;
|
||||
const target = e.target as HTMLElement;
|
||||
if (!col || !target.getAttribute?.('data-msg-id')) {
|
||||
return;
|
||||
}
|
||||
const colRect = col.getBoundingClientRect();
|
||||
const rect = target.getBoundingClientRect();
|
||||
pointerYRef.current = rect.top - colRect.top + col.scrollTop + rect.height / 2;
|
||||
if (magRafRef.current == null) {
|
||||
magRafRef.current = requestAnimationFrame(applyMagnify);
|
||||
}
|
||||
},
|
||||
[applyMagnify],
|
||||
);
|
||||
|
||||
const handleColumnBlur = useCallback(
|
||||
(e: React.FocusEvent<HTMLDivElement>) => {
|
||||
const col = columnRef.current;
|
||||
const next = e.relatedTarget as Node | null;
|
||||
if (col && next && col.contains(next)) {
|
||||
return;
|
||||
}
|
||||
handlePointerLeave();
|
||||
},
|
||||
[handlePointerLeave],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (magRafRef.current != null) {
|
||||
cancelAnimationFrame(magRafRef.current);
|
||||
}
|
||||
if (tipTimerRef.current) {
|
||||
clearTimeout(tipTimerRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
refreshEntries();
|
||||
|
||||
|
|
@ -831,6 +1099,12 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
|
|||
return null;
|
||||
}
|
||||
|
||||
const tipEntry = tip ? entryById.get(tip.id) : undefined;
|
||||
let tipText = '';
|
||||
if (tipEntry) {
|
||||
tipText = tipEntry.isEnd ? localize('com_ui_scroll_to_bottom') : tipEntry.preview;
|
||||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
ref={navRef}
|
||||
|
|
@ -838,10 +1112,9 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
|
|||
aria-keyshortcuts="Shift+Alt+M"
|
||||
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',
|
||||
'-translate-y-1/2 flex-col items-end gap-1.5 px-1.5 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',
|
||||
'focus-within:opacity-100 hover:opacity-100',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
|
|
@ -857,7 +1130,12 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
|
|||
<div
|
||||
ref={columnRef}
|
||||
onPointerDown={handlePointerDown}
|
||||
className="flex min-h-0 cursor-grab touch-none select-none flex-col items-center gap-1.5 overflow-y-auto active:cursor-grabbing [&::-webkit-scrollbar]:hidden"
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
onFocus={handleColumnFocus}
|
||||
onBlur={handleColumnBlur}
|
||||
onClick={handleColumnClick}
|
||||
className="flex min-h-0 w-14 cursor-pointer touch-none select-none flex-col items-stretch gap-1.5 overflow-y-auto [&::-webkit-scrollbar]:hidden"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{entries.map((entry) => {
|
||||
|
|
@ -869,15 +1147,16 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
|
|||
: 'com_ui_message_nav_go_to_assistant',
|
||||
{ 0: entry.preview.slice(0, 30) },
|
||||
);
|
||||
const isHighlighted =
|
||||
hoveredId != null ? hoveredId === entry.id : visibleIds.has(entry.id);
|
||||
return (
|
||||
<MessageIndicator
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
isActive={visibleIds.has(entry.id)}
|
||||
isHighlighted={isHighlighted}
|
||||
isCurrent={currentId === entry.id}
|
||||
onSelect={handleSelect}
|
||||
label={label}
|
||||
hoverText={entry.isEnd ? label : entry.preview}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
@ -892,6 +1171,24 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
|
|||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
{tip &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={tipElRef}
|
||||
role="tooltip"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: tip.top,
|
||||
right: tip.right,
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 999,
|
||||
}}
|
||||
className="pointer-events-none max-w-[280px] rounded-md border border-border-medium bg-surface-secondary px-3 py-2 text-text-secondary shadow-lg"
|
||||
>
|
||||
<p className="line-clamp-3 text-xs">{tipText}</p>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,18 +38,31 @@ jest.mock('~/hooks', () => ({
|
|||
opts ? `${key}|${JSON.stringify(opts)}` : key,
|
||||
}));
|
||||
|
||||
// The nav no longer renders HoverCard, but `~/utils` transitively imports the
|
||||
// dual CJS/ESM @librechat/client whose dist pulls ESM-only @ariakit subpaths
|
||||
// that jest cannot resolve. Stub the module so the unit under test stays isolated.
|
||||
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>
|
||||
),
|
||||
HoverCardContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
if (typeof window.matchMedia !== 'function') {
|
||||
window.matchMedia = (query: string) =>
|
||||
({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}) as unknown as MediaQueryList;
|
||||
}
|
||||
|
||||
type IOEntry = Pick<IntersectionObserverEntry, 'target' | 'isIntersecting'>;
|
||||
|
||||
class MockIntersectionObserver {
|
||||
|
|
@ -110,7 +123,13 @@ if (typeof (global as { PointerEvent?: unknown }).PointerEvent === 'undefined')
|
|||
PointerEventPolyfill;
|
||||
}
|
||||
|
||||
import MessageNav from '../MessageNav';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import MessageNav, {
|
||||
buildEntry,
|
||||
buildFallbackEntry,
|
||||
magnifyFalloff,
|
||||
ribDimsFor,
|
||||
} from '../MessageNav';
|
||||
|
||||
function buildMessage(overrides: Partial<TestMessage> = {}): TestMessage {
|
||||
return {
|
||||
|
|
@ -122,6 +141,8 @@ function buildMessage(overrides: Partial<TestMessage> = {}): TestMessage {
|
|||
};
|
||||
}
|
||||
|
||||
const asTMessage = (m: TestMessage): TMessage => m as unknown as TMessage;
|
||||
|
||||
function buildDom(messages: TestMessage[]): {
|
||||
scrollable: HTMLDivElement;
|
||||
content: HTMLDivElement;
|
||||
|
|
@ -229,51 +250,27 @@ describe('MessageNav', () => {
|
|||
'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', () => {
|
||||
it('renders one rib per message even when a message nests duplicate .message-render nodes', () => {
|
||||
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()) {
|
||||
for (const [i, id] of ['a', 'b', 'c'].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 });
|
||||
div.textContent = `msg-${id}`;
|
||||
if (id === 'b') {
|
||||
const part = document.createElement('div');
|
||||
part.id = id;
|
||||
part.className = 'message-render';
|
||||
part.textContent = 'msg-b-part';
|
||||
div.appendChild(part);
|
||||
}
|
||||
Object.defineProperty(div, 'offsetTop', { value: i * 200, configurable: true });
|
||||
Object.defineProperty(div, 'offsetHeight', { value: 150, configurable: true });
|
||||
content.appendChild(div);
|
||||
}
|
||||
document.body.appendChild(scrollable);
|
||||
|
|
@ -283,39 +280,107 @@ describe('MessageNav', () => {
|
|||
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');
|
||||
const indicators = container.querySelectorAll('[data-msg-id]');
|
||||
expect(Array.from(indicators).map((el) => el.getAttribute('data-msg-id'))).toEqual([
|
||||
'a',
|
||||
'b',
|
||||
'c',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('indicator styling', () => {
|
||||
it('gives every message rib the same short resting width regardless of role', () => {
|
||||
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]');
|
||||
const userLine = userInd.querySelector('span');
|
||||
const assistantLine = assistantInd.querySelector('span');
|
||||
expect(userLine?.className).toContain('w-4');
|
||||
expect(assistantLine?.className).toContain('w-4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('preview text', () => {
|
||||
it('uses message text from React Query data when available', () => {
|
||||
const entry = buildEntry(
|
||||
'a',
|
||||
asTMessage(buildMessage({ messageId: 'a', text: 'alpha-preview' })),
|
||||
);
|
||||
expect(entry.preview).toBe('alpha-preview');
|
||||
});
|
||||
|
||||
it('falls back to DOM text content for messages without React Query data', () => {
|
||||
const node = document.createElement('div');
|
||||
node.className = 'message-render';
|
||||
node.textContent = 'dom-text-x';
|
||||
const entry = buildFallbackEntry(node, 'x');
|
||||
expect(entry.preview).toBe('dom-text-x');
|
||||
expect(entry.isUser).toBe(false);
|
||||
});
|
||||
|
||||
it('marks fallback entries containing a user turn as user messages', () => {
|
||||
const node = document.createElement('div');
|
||||
node.className = 'message-render';
|
||||
const turn = document.createElement('div');
|
||||
turn.className = 'user-turn';
|
||||
turn.textContent = 'hi';
|
||||
node.appendChild(turn);
|
||||
expect(buildFallbackEntry(node, 'u').isUser).toBe(true);
|
||||
});
|
||||
|
||||
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);
|
||||
const entry = buildEntry(
|
||||
'a',
|
||||
asTMessage(buildMessage({ messageId: 'a', text: 'a'.repeat(120) })),
|
||||
);
|
||||
expect(entry.preview.endsWith('...')).toBe(true);
|
||||
expect(entry.preview.length).toBe(83);
|
||||
});
|
||||
|
||||
it('skips sparse content entries when deriving preview text', () => {
|
||||
const messages = [
|
||||
buildMessage({
|
||||
messageId: 'a',
|
||||
text: '',
|
||||
isCreatedByUser: true,
|
||||
content: [undefined, { type: 'text', text: 'content-preview' }],
|
||||
}),
|
||||
buildMessage({ messageId: 'b', text: 'bravo' }),
|
||||
buildMessage({ messageId: 'c', text: 'charlie', isCreatedByUser: true }),
|
||||
];
|
||||
const { container } = renderNav(messages);
|
||||
const preview = container.querySelectorAll('[data-testid="hover-card-content"] p')[0];
|
||||
expect(preview).toHaveTextContent('content-preview');
|
||||
const entry = buildEntry(
|
||||
'a',
|
||||
asTMessage(
|
||||
buildMessage({
|
||||
messageId: 'a',
|
||||
text: '',
|
||||
content: [undefined, { type: 'text', text: 'content-preview' }],
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(entry.preview).toBe('content-preview');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rib magnification', () => {
|
||||
it('peaks at the pointer and decays to zero at the influence radius', () => {
|
||||
expect(magnifyFalloff(0, 50)).toBeCloseTo(1);
|
||||
expect(magnifyFalloff(50, 50)).toBe(0);
|
||||
expect(magnifyFalloff(100, 50)).toBe(0);
|
||||
});
|
||||
|
||||
it('decreases monotonically with distance within the radius', () => {
|
||||
const near = magnifyFalloff(10, 50);
|
||||
const mid = magnifyFalloff(25, 50);
|
||||
const far = magnifyFalloff(40, 50);
|
||||
expect(near).toBeGreaterThan(mid);
|
||||
expect(mid).toBeGreaterThan(far);
|
||||
});
|
||||
|
||||
it('magnifies every message rib uniformly and keeps the end marker square', () => {
|
||||
const user = ribDimsFor({ id: 'u', isUser: true, preview: '' });
|
||||
const assistant = ribDimsFor({ id: 'a', isUser: false, preview: '' });
|
||||
const end = ribDimsFor({ id: 'e', isUser: false, preview: '', isEnd: true });
|
||||
expect(assistant.baseW).toBe(user.baseW);
|
||||
expect(assistant.peakW).toBe(user.peakW);
|
||||
expect(user.peakW).toBeGreaterThan(user.baseW);
|
||||
expect(end.baseW).toBe(end.baseH);
|
||||
expect(end.peakW).toBe(end.peakH);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -395,10 +460,8 @@ describe('MessageNav', () => {
|
|||
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]');
|
||||
}
|
||||
const activeLine = container.querySelector('[aria-current="true"] span');
|
||||
expect(activeLine?.className).toContain('bg-gray-800');
|
||||
});
|
||||
|
||||
it('chevron buttons expose a disabled state when there is nothing to navigate to', () => {
|
||||
|
|
@ -759,6 +822,176 @@ describe('MessageNav', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('click to jump', () => {
|
||||
it('jumps to the hovered (focused) message when the column is clicked off a rib line', () => {
|
||||
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);
|
||||
const column = container.querySelector('nav > div') as HTMLDivElement;
|
||||
column.getBoundingClientRect = () => ({ top: 0, bottom: 50, height: 50 }) as DOMRect;
|
||||
|
||||
const writes: number[] = [];
|
||||
Object.defineProperty(scrollable, 'scrollTop', {
|
||||
get: () => 0,
|
||||
set: (v: number) => writes.push(v),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fireEvent.pointerMove(column, { pointerId: 1, clientY: 5 });
|
||||
jest.advanceTimersByTime(120);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(column);
|
||||
jest.advanceTimersByTime(32);
|
||||
});
|
||||
|
||||
expect(writes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('highlights only the hovered rib white, dimming the rest', () => {
|
||||
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 column = container.querySelector('nav > div') as HTMLDivElement;
|
||||
column.getBoundingClientRect = () => ({ top: 0, bottom: 50, height: 50 }) as DOMRect;
|
||||
|
||||
act(() => {
|
||||
fireEvent.pointerMove(column, { pointerId: 1, clientY: 5 });
|
||||
jest.advanceTimersByTime(20);
|
||||
});
|
||||
|
||||
const ribs = Array.from(container.querySelectorAll('[data-msg-id]'));
|
||||
const white = ribs.filter((r) => r.querySelector('span')?.className.includes('bg-gray-800'));
|
||||
expect(white).toHaveLength(1);
|
||||
expect(white[0]).toHaveAttribute('data-msg-id', 'a');
|
||||
});
|
||||
});
|
||||
|
||||
describe('keyboard accessibility', () => {
|
||||
function setupFocusableNav() {
|
||||
const messages = [
|
||||
buildMessage({ messageId: 'a', text: 'alpha', isCreatedByUser: true }),
|
||||
buildMessage({ messageId: 'b', text: 'bravo' }),
|
||||
buildMessage({ messageId: 'c', text: 'charlie', isCreatedByUser: true }),
|
||||
];
|
||||
const result = renderNav(messages);
|
||||
const column = result.container.querySelector('nav > div') as HTMLDivElement;
|
||||
column.getBoundingClientRect = () => ({ top: 0, bottom: 50, height: 50 }) as DOMRect;
|
||||
return { ...result, column };
|
||||
}
|
||||
|
||||
it('highlights and previews a rib when it receives keyboard focus, like hover', () => {
|
||||
const { container, column } = setupFocusableNav();
|
||||
const ribA = container.querySelector('[data-msg-id="a"]') as HTMLElement;
|
||||
|
||||
act(() => {
|
||||
ribA.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
||||
jest.advanceTimersByTime(80);
|
||||
});
|
||||
|
||||
expect(ribA.querySelector('span')?.className).toContain('bg-gray-800');
|
||||
const tip = document.body.querySelector('[role="tooltip"]');
|
||||
expect(tip).not.toBeNull();
|
||||
expect(tip).toHaveTextContent('alpha');
|
||||
expect(column).toBeDefined();
|
||||
});
|
||||
|
||||
it('clears the highlight and preview when focus leaves the rail', () => {
|
||||
const { container } = setupFocusableNav();
|
||||
const ribA = container.querySelector('[data-msg-id="a"]') as HTMLElement;
|
||||
|
||||
act(() => {
|
||||
ribA.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
||||
jest.advanceTimersByTime(80);
|
||||
});
|
||||
act(() => {
|
||||
ribA.dispatchEvent(
|
||||
new FocusEvent('focusout', { bubbles: true, relatedTarget: document.body }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(document.body.querySelector('[role="tooltip"]')).toBeNull();
|
||||
const white = Array.from(container.querySelectorAll('[data-msg-id] span')).filter((s) =>
|
||||
s.className.includes('bg-gray-800'),
|
||||
);
|
||||
expect(white).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('preview live sync', () => {
|
||||
it('refreshes the open tooltip text when the hovered message updates in place', () => {
|
||||
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 column = container.querySelector('nav > div') as HTMLDivElement;
|
||||
column.getBoundingClientRect = () => ({ top: 0, bottom: 50, height: 50 }) as DOMRect;
|
||||
|
||||
act(() => {
|
||||
fireEvent.pointerMove(column, { pointerId: 1, clientY: 5 });
|
||||
jest.advanceTimersByTime(80);
|
||||
});
|
||||
expect(document.body.querySelector('[role="tooltip"]')).toHaveTextContent('alpha');
|
||||
|
||||
// Message text updates in place (e.g. streaming). A re-render happens without
|
||||
// the pointer leaving the rail; the tooltip should reflect the new preview.
|
||||
mockUseGetMessagesByConvoId.mockReturnValue({
|
||||
data: [
|
||||
buildMessage({ messageId: 'a', text: 'alpha streamed more', isCreatedByUser: true }),
|
||||
buildMessage({ messageId: 'b', text: 'bravo' }),
|
||||
buildMessage({ messageId: 'c', text: 'charlie', isCreatedByUser: true }),
|
||||
],
|
||||
});
|
||||
const io = MockIntersectionObserver.last();
|
||||
act(() => {
|
||||
io!.trigger([{ target: document.getElementById('a')!, isIntersecting: true }]);
|
||||
jest.advanceTimersByTime(20);
|
||||
});
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(260);
|
||||
});
|
||||
|
||||
expect(document.body.querySelector('[role="tooltip"]')).toHaveTextContent(
|
||||
'alpha streamed more',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('browser compatibility', () => {
|
||||
it('uses legacy MediaQueryList listeners when addEventListener is unavailable', () => {
|
||||
const original = window.matchMedia;
|
||||
const addListener = jest.fn();
|
||||
const removeListener = jest.fn();
|
||||
window.matchMedia = (() => ({ matches: false, addListener, removeListener })) as never;
|
||||
try {
|
||||
const messages = [
|
||||
buildMessage({ messageId: 'a', text: 'alpha', isCreatedByUser: true }),
|
||||
buildMessage({ messageId: 'b', text: 'bravo' }),
|
||||
buildMessage({ messageId: 'c', text: 'charlie', isCreatedByUser: true }),
|
||||
];
|
||||
let result: ReturnType<typeof renderNav> | undefined;
|
||||
expect(() => {
|
||||
result = renderNav(messages);
|
||||
}).not.toThrow();
|
||||
expect(addListener).toHaveBeenCalledTimes(1);
|
||||
result?.unmount();
|
||||
expect(removeListener).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
window.matchMedia = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('drag to scroll', () => {
|
||||
function setupDraggableNav() {
|
||||
const messages = Array.from({ length: 5 }, (_, i) =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue