Skip to content

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

Open
berry-13 wants to merge 17 commits intodevfrom
feat/message-nav
Open

🧭 feat: Add Message Navigation Strip & Redesign Scroll-to-Bottom#12657
berry-13 wants to merge 17 commits intodevfrom
feat/message-nav

Conversation

@berry-13
Copy link
Copy Markdown
Collaborator

@berry-13 berry-13 commented Apr 14, 2026

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 from useGetMessagesByConvoId with a DOM-based fallback for messages not yet in cache. Updates are throttled with requestAnimationFrame, and the MutationObserver only fires on .message-render adds/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 (#ffffff light, #2a2a2e dark) instead of semi-transparency. Slide-up/fade-in on entrance, slide-down/fade-out on exit, and a scale(0.95) press state with no idle float or rotation. The button is now aligned to the right edge of the chat form (same max-w constraint) and uses the lucide chevron icon instead of a custom SVG.

Test Plan

  • Open a conversation with 3+ messages, nav strip appears on the right edge
  • Hover the nav strip, background, buttons, and indicators become fully visible
  • Hover an indicator, HoverCard shows message preview with animation
  • Click an indicator, smooth scrolls to that message
  • Press up/down arrows, navigates between messages, scrolling to message start
  • Press up on a long message scrolled mid-way, scrolls to the top of current message first
  • Scroll to top, scroll-to-bottom button appears at the right edge of the chat form
  • Click scroll-to-bottom, smooth scrolls to bottom, button exits with animation
  • Test with "Maximize chat space" setting on, button stays aligned
  • Test in dark mode, proper colors for all elements
  • Test on mobile viewport, nav strip is hidden
  • Test with long messages containing many code blocks, no disabled buttons during fast scroll

Checklist

  • My code adheres to this project's style guidelines
  • I have performed a self-review of my own code
  • I have commented in any complex areas of my code
  • My changes do not introduce new warnings
  • I have written tests demonstrating that my changes are effective or that my feature works
  • Local unit tests pass with my changes
  • Any changes dependent on mine have been merged and published in downstream modules.

…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.
Copilot AI review requested due to automatic review settings April 14, 2026 08:54
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 MessageNav component that builds per-message indicators and supports previous/next navigation via IntersectionObserver.
  • Mounted MessageNav alongside the existing ScrollToBottom control in MessagesView.
  • Redesigned the scroll-to-bottom button (icon, positioning, and CSS enter/exit animations) and added scroll-margin-top for .message-render elements.

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.

Comment on lines +131 to +137
const next = getMessageEntries();
setEntries((prev) => {
if (prev.length === next.length && prev.every((e, i) => e.id === next[i].id)) {
return prev;
}
return next;
});
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.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +12 to +13
function getMessageEntries(): MessageEntry[] {
const nodes = document.querySelectorAll<HTMLElement>('.message-render');
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.

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.

Suggested change
function getMessageEntries(): MessageEntry[] {
const nodes = document.querySelectorAll<HTMLElement>('.message-render');
function getMessageEntries(rootEl: ParentNode = document): MessageEntry[] {
const nodes = rootEl.querySelectorAll<HTMLElement>('.message-render');

Copilot uses AI. Check for mistakes.
Comment thread client/src/style.css
Comment on lines 729 to 732
.premium-scroll-button:active {
animation: none;
transform: scale(0.95);
transition: transform 100ms ease;
}
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.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +54 to +61
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.
@berry-13 berry-13 marked this pull request as draft April 14, 2026 09:06
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.
@github-actions
Copy link
Copy Markdown
Contributor

GitNexus: 🚀 deployed

The LibreChat-pr-12657 index is now live on the MCP server.
Deploy run

- 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
@github-actions
Copy link
Copy Markdown
Contributor

GitNexus: 🚀 deployed

The LibreChat-pr-12657 index is now live on the MCP server.
Deploy run

@danny-avila
Copy link
Copy Markdown
Owner

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 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)}`}
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 👍 / 👎.

Comment on lines +58 to +61
const distanceFromTop = el.getBoundingClientRect().top - container.getBoundingClientRect().top;
return (
distanceFromTop >= -AT_TOP_THRESHOLD && distanceFromTop <= SCROLL_MARGIN + AT_TOP_THRESHOLD
);
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 👍 / 👎.

- 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.
@berry-13 berry-13 marked this pull request as ready for review April 19, 2026 23:00
@danny-avila
Copy link
Copy Markdown
Owner

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 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 });
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 👍 / 👎.

Comment on lines +268 to +271
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;
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 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.
@berry-13 berry-13 requested a review from danny-avila April 24, 2026 14:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants