🪗 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

*  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:
Danny Avila 2026-06-30 14:21:22 -04:00 committed by GitHub
parent e5d5018d7f
commit dd8a4558f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 656 additions and 126 deletions

View file

@ -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>
);
}

View file

@ -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) =>