Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
f1d3c09
feat(ui): add message navigation strip and redesign scroll-to-bottom …
berry-13 Apr 13, 2026
f285f60
fix(ui): prevent message nav layout shift on scroll
berry-13 Apr 13, 2026
4b47dc7
fix(ui): debounce message nav refresh and persist visibility state
berry-13 Apr 13, 2026
41d3245
fix(ui): prevent nav buttons from disabling during fast scroll
berry-13 Apr 13, 2026
3e085c5
fix(ui): scroll to message start when using nav arrow buttons
berry-13 Apr 13, 2026
0b5504b
fix(ui): account for header offset when scrolling to messages
berry-13 Apr 13, 2026
719dbbe
fix(ui): improve message nav scrolling and visual subtlety
berry-13 Apr 13, 2026
2349771
fix(ui): use native scroll-margin-top for reliable message navigation
berry-13 Apr 14, 2026
4c36a47
fix(ui): use firstActiveIndex for both nav directions
berry-13 Apr 14, 2026
a8fbb5e
fix(ui): address PR review feedback
berry-13 Apr 14, 2026
9a24d38
fix(ui): make message nav scroll precise and chevrons reliable
berry-13 Apr 19, 2026
7b38e41
perf(ui): skip off-screen message layout and fix resulting scroll drift
berry-13 Apr 19, 2026
7f5e368
revert(ui): drop content-visibility on .message-render
berry-13 Apr 19, 2026
022cf58
fix(ui): address PR review — a11y, tests, and MessageNav correctness
berry-13 Apr 20, 2026
a38d7c6
fix(ui): catch in-place message id mutations and react to layout shifts
berry-13 Apr 20, 2026
b81600b
fix(ui): address deep-review follow-ups on MessageNav
berry-13 Apr 20, 2026
342958e
fix(ui): address re-review — clean lockfile + ScrollToBottom ref target
berry-13 Apr 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
266 changes: 266 additions & 0 deletions client/src/components/Chat/Messages/MessageNav.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { ChevronUp, ChevronDown } from 'lucide-react';
import { HoverCard, HoverCardTrigger, HoverCardPortal, HoverCardContent } from '@librechat/client';
import { cn } from '~/utils';

type MessageEntry = {
id: string;
isUser: boolean;
preview: string;
};

function getMessageEntries(root: ParentNode = document): 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 isUser = node.querySelector('.user-turn') != null;
const turnEl = isUser ? node.querySelector('.user-turn') : node.querySelector('.agent-turn');
const contentEl = isUser
? (turnEl?.querySelector('.flex.flex-col.gap-1') ?? turnEl)
: (node.querySelector('.markdown') ?? turnEl);

const rawText = contentEl?.textContent ?? '';
const preview = rawText.trim().slice(0, 80) + (rawText.trim().length > 80 ? '...' : '');

entries.push({ id, isUser, preview });
}

return entries;
}

const SCROLL_MARGIN = 16;
const AT_TOP_THRESHOLD = 20;

function scrollToMessageStart(id: string) {
const el = document.getElementById(id);
if (!el) {
return;
}
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}

function isMessageAtTop(id: string): boolean {
const el = document.getElementById(id);
if (!el) {
return true;
}
const container = el.closest('.scrollbar-gutter-stable');
if (!container) {
return true;
}
const distanceFromTop = el.getBoundingClientRect().top - container.getBoundingClientRect().top;
return (
distanceFromTop >= -AT_TOP_THRESHOLD && distanceFromTop <= SCROLL_MARGIN + AT_TOP_THRESHOLD
);
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isMessageAtTop() compares the message’s getBoundingClientRect().top against the scroll container’s top with a hardcoded SCROLL_MARGIN (16px). In MessagesView the scroll container’s first child adds pt-14 (56px) padding, so the first message can never get within ~36px of the container top (can’t scroll past scrollTop=0). This keeps canGoUp enabled at the very top and makes the “up” behavior for the first message inconsistent. Consider basing the check on root.scrollTop (e.g., root.scrollTop <= threshold) or factoring in the container/content padding when computing the top distance.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Account for top padding when detecting top-of-thread

isMessageAtTop treats a message as “at top” only when its offset is within SCROLL_MARGIN + AT_TOP_THRESHOLD (36px) of the scroll container top, but the chat list has a fixed pt-14 (56px) top padding. At scrollTop = 0, the first message is still below that threshold, so the up button remains enabled at the true top and pressing it can scroll downward to re-align message 1. This check should include the container’s top padding/actual resting offset so top-of-thread state is detected correctly.

Useful? React with 👍 / 👎.

}

function MessageIndicator({ entry, isActive }: { entry: MessageEntry; isActive: boolean }) {
return (
<HoverCard openDelay={150}>
<HoverCardTrigger asChild>
<button
type="button"
onClick={() => scrollToMessageStart(entry.id)}
className={cn('flex h-[5px] items-center justify-center', entry.isUser ? 'w-4' : 'w-6')}
aria-label={`Go to ${entry.isUser ? 'user' : 'assistant'} message: ${entry.preview.slice(0, 30)}`}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Localize newly added navigation labels

The new navigation strip introduces hard-coded English user-facing text (aria-label content), which violates the frontend localization rule in CLAUDE.md (“All user-facing text must use useLocalize()). This will leave the new controls untranslated (including screen-reader labels) in non-English deployments; please move these labels to localization keys and resolve them via useLocalize.

Useful? React with 👍 / 👎.

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

export default function MessageNav({
scrollableRef,
}: {
scrollableRef: React.RefObject<HTMLDivElement>;
}) {
const [entries, setEntries] = useState<MessageEntry[]>([]);
const [activeIds, setActiveIds] = useState<Set<string>>(new Set());
const observerRef = useRef<IntersectionObserver | null>(null);
const lastKnownIndexRef = useRef(0);

const firstActiveIndex = useMemo(() => {
for (let i = 0; i < entries.length; i++) {
if (activeIds.has(entries[i].id)) {
lastKnownIndexRef.current = i;
return i;
}
}
if (entries.length > 0) {
return Math.min(lastKnownIndexRef.current, entries.length - 1);
}
return -1;
}, [entries, activeIds]);

const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const visibleSetRef = useRef(new Set<string>());

const refreshEntries = useCallback(() => {
if (refreshTimerRef.current) {
clearTimeout(refreshTimerRef.current);
}
refreshTimerRef.current = setTimeout(() => {
const root = scrollableRef.current ?? document;
const next = getMessageEntries(root);
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();

const container = scrollableRef.current;
if (!container) {
return;
}

const mutationObserver = new MutationObserver(() => {
refreshEntries();
});

mutationObserver.observe(container, { childList: true, subtree: true });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Watch attribute mutations when refreshing nav entries

The mutation observer only listens for childList changes, so it misses in-place id updates on existing .message-render nodes. Message IDs are updated during the SSE lifecycle, and when that happens without node add/remove events, entries keeps stale IDs; subsequent navigation calls (which use document.getElementById) can no-op for those messages and leave arrow state inconsistent until another structural mutation happens. Please include attribute observation for id changes (or otherwise trigger refreshEntries on message ID updates).

Useful? React with 👍 / 👎.


return () => {
mutationObserver.disconnect();
if (refreshTimerRef.current) {
clearTimeout(refreshTimerRef.current);
}
};
}, [scrollableRef, refreshEntries]);

useEffect(() => {
const root = scrollableRef.current;
if (!root || entries.length === 0) {
return;
}

observerRef.current?.disconnect();

const visibleSet = visibleSetRef.current;
const entryIds = new Set(entries.map((e) => e.id));
for (const id of visibleSet) {
if (!entryIds.has(id)) {
visibleSet.delete(id);
}
}

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);
}
}
setActiveIds(new Set(visibleSet));
},
{ root, threshold: 0.01 },
);

observerRef.current = observer;

for (const msg of entries) {
const el = document.getElementById(msg.id);
if (el) {
observer.observe(el);
}
}

return () => observer.disconnect();
}, [entries, scrollableRef]);

const jumpToPrevious = useCallback(() => {
if (firstActiveIndex < 0) {
return;
}
const currentId = entries[firstActiveIndex].id;
if (!isMessageAtTop(currentId) && firstActiveIndex > 0) {
scrollToMessageStart(currentId);
return;
}
if (firstActiveIndex <= 0) {
scrollToMessageStart(entries[0].id);
return;
}
scrollToMessageStart(entries[firstActiveIndex - 1].id);
}, [firstActiveIndex, entries]);

const jumpToNext = useCallback(() => {
if (firstActiveIndex < 0 || firstActiveIndex >= entries.length - 1) {
return;
}
scrollToMessageStart(entries[firstActiveIndex + 1].id);
}, [firstActiveIndex, entries]);

if (entries.length < 3) {
return null;
}

const canGoUp =
firstActiveIndex > 0 || (firstActiveIndex === 0 && !isMessageAtTop(entries[0].id));
const canGoDown = firstActiveIndex >= 0 && firstActiveIndex < entries.length - 1;

return (
<nav
aria-label="Message navigation"
className="group/nav absolute right-2 top-1/2 z-40 hidden -translate-y-1/2 flex-col items-center gap-1.5 rounded-full px-1 py-2 opacity-30 transition-opacity duration-300 hover:bg-black/5 hover:opacity-100 dark:hover:bg-white/5 md:flex"
>
<button
type="button"
onClick={jumpToPrevious}
disabled={!canGoUp}
className="rounded-md p-0.5 text-text-tertiary transition-colors group-hover/nav:text-text-secondary group-hover/nav:hover:text-text-primary group-hover/nav:disabled:opacity-30"
aria-label="Navigate to previous message"
>
<ChevronUp className="h-4 w-4" />
</button>

<div className="flex flex-col items-center gap-1.5">
{entries.map((entry) => (
<MessageIndicator key={entry.id} entry={entry} isActive={activeIds.has(entry.id)} />
))}
</div>

<button
type="button"
onClick={jumpToNext}
disabled={!canGoDown}
className="rounded-md p-0.5 text-text-tertiary transition-colors group-hover/nav:text-text-secondary group-hover/nav:hover:text-text-primary group-hover/nav:disabled:opacity-30"
aria-label="Navigate to next message"
>
<ChevronDown className="h-4 w-4" />
</button>
</nav>
);
}
3 changes: 3 additions & 0 deletions client/src/components/Chat/Messages/MessagesView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -91,6 +92,8 @@ function MessagesViewContent({
>
<ScrollToBottom ref={scrollToBottomRef} scrollHandler={handleSmoothToRef} />
</CSSTransition>

<MessageNav scrollableRef={scrollableRef} />
</div>
</div>
</>
Expand Down
35 changes: 20 additions & 15 deletions client/src/components/Messages/ScrollToBottom.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,32 @@
import { forwardRef } from 'react';
import { useRecoilValue } from 'recoil';
import { ChevronDown } from 'lucide-react';
import store from '~/store';
import { cn } from '~/utils';

type Props = {
scrollHandler: React.MouseEventHandler<HTMLButtonElement>;
};

const ScrollToBottom = forwardRef<HTMLButtonElement, Props>(({ scrollHandler }, ref) => {
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);

return (
<button
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"
<div
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
ref={ref}
onClick={scrollHandler}
className="premium-scroll-button pointer-events-auto cursor-pointer"
aria-label="Scroll to bottom"
>
<ChevronDown className="h-4 w-4 text-text-secondary" />
</button>
</div>
);
});

Expand Down
Loading
Loading