🧭 feat: Add Message Navigation Strip & Redesign Scroll-to-Bottom#12657
🧭 feat: Add Message Navigation Strip & Redesign Scroll-to-Bottom#12657
Conversation
…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.
Use a fixed-height container for each indicator so the nav strip maintains consistent dimensions when indicators transition between active and inactive states.
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.
- 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)
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.
Use manual scrollTo with a 56px offset to prevent the fixed header from covering the top of the target message when using arrow buttons.
- 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
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.
There was a problem hiding this comment.
Pull request overview
Adds in-chat navigation affordances for long conversations by introducing a right-edge message navigation strip and updating the existing scroll-to-bottom control styling/positioning.
Changes:
- Added a new
MessageNavcomponent that builds per-message indicators and supports previous/next navigation viaIntersectionObserver. - Mounted
MessageNavalongside the existingScrollToBottomcontrol inMessagesView. - Redesigned the scroll-to-bottom button (icon, positioning, and CSS enter/exit animations) and added
scroll-margin-topfor.message-renderelements.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
client/src/components/Chat/Messages/MessageNav.tsx |
New floating nav strip with message discovery, visibility tracking, and jump logic. |
client/src/components/Chat/Messages/MessagesView.tsx |
Renders MessageNav in the messages container alongside ScrollToBottom. |
client/src/components/Messages/ScrollToBottom.tsx |
Updates button layout to right-align within chat width and swaps in lucide chevron icon. |
client/src/style.css |
Adds .message-render scroll margin and replaces scroll-to-bottom enter/exit + button styling. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const next = getMessageEntries(); | ||
| setEntries((prev) => { | ||
| if (prev.length === next.length && prev.every((e, i) => e.id === next[i].id)) { | ||
| return prev; | ||
| } | ||
| return next; | ||
| }); |
There was a problem hiding this comment.
The setEntries equality check only compares ids, so if message content changes without changing ids (e.g., streaming assistant output, edits, post-render code highlighting), next[i].preview updates will be ignored and HoverCard previews can become stale. Consider including preview in the comparison (or removing the optimization / using a cheap hash) so content updates propagate when ids are unchanged.
| function getMessageEntries(): MessageEntry[] { | ||
| const nodes = document.querySelectorAll<HTMLElement>('.message-render'); |
There was a problem hiding this comment.
getMessageEntries() uses document.querySelectorAll('.message-render'), which isn’t scoped to the current scrollableRef container. If other .message-render instances exist in the DOM (e.g., overlays/modals/other panels), they’ll be included in the nav and observer list. Consider passing the scroll container into getMessageEntries(rootEl) and querying within it (e.g., rootEl.querySelectorAll(...)) for correctness and to reduce work.
| function getMessageEntries(): MessageEntry[] { | |
| const nodes = document.querySelectorAll<HTMLElement>('.message-render'); | |
| function getMessageEntries(rootEl: ParentNode = document): MessageEntry[] { | |
| const nodes = rootEl.querySelectorAll<HTMLElement>('.message-render'); |
| .premium-scroll-button:active { | ||
| animation: none; | ||
| transform: scale(0.95); | ||
| transition: transform 100ms ease; | ||
| } |
There was a problem hiding this comment.
The transition is only applied in the :active rule. When the button is released, transition is removed, so the scale snaps back immediately instead of animating smoothly. Move the transition: transform 100ms ease to .premium-scroll-button (base state) and keep only transform: scale(...) in :active so both press and release are animated.
| 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 | ||
| ); |
There was a problem hiding this comment.
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.
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.
GitNexus: 🚀 deployedThe |
- 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
GitNexus: 🚀 deployedThe |
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a8fbb5ed76
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| 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)}`} |
There was a problem hiding this comment.
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 👍 / 👎.
| const distanceFromTop = el.getBoundingClientRect().top - container.getBoundingClientRect().top; | ||
| return ( | ||
| distanceFromTop >= -AT_TOP_THRESHOLD && distanceFromTop <= SCROLL_MARGIN + AT_TOP_THRESHOLD | ||
| ); |
There was a problem hiding this comment.
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 👍 / 👎.
- 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.
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.
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.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7f5e368b77
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| } | ||
| }); | ||
|
|
||
| mutationObserver.observe(container, { childList: true, subtree: true }); |
There was a problem hiding this comment.
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 👍 / 👎.
| 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; |
There was a problem hiding this comment.
Recompute message offsets after layout shifts
The navigation geometry is captured once into offsetsTop/offsetsBottom and then reused on every scroll tick, but these values become stale when message heights/positions change after mount (e.g., streaming content growth, image/code-block render expansion, font-size/maximize layout changes). In those cases canGoUp/canGoDown and indicator auto-centering can be wrong because they operate on outdated offsets. Re-measure offsets when layout changes (or inside tick) so navigation state stays accurate.
Useful? React with 👍 / 👎.
- 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).
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).
- 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.
- 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.
Summary
Adds a floating message navigation strip to the right edge of the chat area for fast jumps between messages in long conversations. Also redesigns the scroll-to-bottom button with a cleaner look.
Closes #12471
Message Navigation Strip
The strip lives on the right edge with up/down chevrons and one indicator per message: user turns are narrower lines, assistant turns are wider ones, and the currently visible message is highlighted. Hovering an indicator shows a Radix HoverCard with a truncated preview of the message text.
Clicking any indicator jumps to that message. The chevrons step through messages one at a time, scrolling each below the gradient header via
scroll-margin-top. Pressing up while you're mid-way through a long message first snaps back to its start, then on the next press jumps to the previous message.Visibility tracking uses an
IntersectionObserver, and entries are pulled fromuseGetMessagesByConvoIdwith a DOM-based fallback for messages not yet in cache. Updates are throttled withrequestAnimationFrame, and the MutationObserver only fires on.message-renderadds/removes. The indicator column auto-scrolls to keep the visible range centered, so the active dot stays roughly in the middle even on conversations with hundreds of messages.By default the strip is faded (30% opacity), and the background plus buttons only appear on hover. It's hidden on mobile and only renders when there are at least 3 messages.
Scroll-to-Bottom Redesign
Solid backgrounds (
#fffffflight,#2a2a2edark) instead of semi-transparency. Slide-up/fade-in on entrance, slide-down/fade-out on exit, and ascale(0.95)press state with no idle float or rotation. The button is now aligned to the right edge of the chat form (samemax-wconstraint) and uses the lucide chevron icon instead of a custom SVG.Test Plan
Checklist