🧭 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.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 342958e66a
ℹ️ 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".
| if (entry.isIntersecting) { | ||
| visibleSet.add(id); | ||
| } else { |
There was a problem hiding this comment.
Track only one current message in nav state
This callback adds every intersecting message ID into visibleSet, which means multiple indicators can simultaneously render with aria-current="true" in a normal viewport where several messages are visible at once. aria-current is intended to identify a single current item in a related set, so this produces ambiguous accessibility state and inconsistent "current message" behavior. Reduce this to one chosen message (for example, the topmost visible message) before updating activeIds.
Useful? React with 👍 / 👎.
The IO sync effect was observing new ids before unobserving old ones.
During the SSE lifecycle of a fresh chat, a single .message-render node
cycles through three ids (client UUID -> handler id -> server id). When
the id mutated on the same element, the effect would call observe(el)
then unobserve(el) on that element in the same pass — leaving it
permanently unobserved. The active-message highlight never updated for
the new id until a hard refresh rebuilt everything from scratch.
Switched to element-identity tracking. Build an element -> newId map
from entries, then for each currently observed [oldId, el]:
- if the element no longer appears in entries, unobserve and drop it
- if the element appears under a new id, migrate observed and
visibleSet keys in place — the IntersectionObserver keeps watching
the same DOM node uninterrupted
Genuinely new elements get observed afterward as before. Rename doesn't
fire an IO callback, so flush activeIds manually when at least one
migration happened. Existing convos already had this working because
their ids never mutate after load — only fresh chats hit the SSE id
cycle, which matches the reproduction.
|
@codex review |
|
Codex Review: Didn't find any major issues. Breezy! ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
…ny-avila#12657) * feat(ui): add message navigation strip and redesign scroll-to-bottom 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. * fix(ui): prevent message nav layout shift on scroll Use a fixed-height container for each indicator so the nav strip maintains consistent dimensions when indicators transition between active and inactive states. * fix(ui): debounce message nav refresh and persist visibility state 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. * fix(ui): prevent nav buttons from disabling during fast scroll - 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) * fix(ui): scroll to message start when using nav arrow buttons 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. * fix(ui): account for header offset when scrolling to messages Use manual scrollTo with a 56px offset to prevent the fixed header from covering the top of the target message when using arrow buttons. * fix(ui): improve message nav scrolling and visual subtlety - 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 * fix(ui): use native scroll-margin-top for reliable message navigation 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. * fix(ui): use firstActiveIndex for both nav directions 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. * fix(ui): address PR review feedback - 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 * fix(ui): make message nav scroll precise and chevrons reliable - 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. * perf(ui): skip off-screen message layout and fix resulting scroll drift 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. * revert(ui): drop content-visibility on .message-render 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. * fix(ui): address PR review — a11y, tests, and MessageNav correctness - 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). * fix(ui): catch in-place message id mutations and react to layout shifts 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). * fix(ui): address deep-review follow-ups on MessageNav - 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. * fix(ui): address re-review — clean lockfile + ScrollToBottom ref target - 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. * fix(ui): preserve IntersectionObserver across in-place id mutations The IO sync effect was observing new ids before unobserving old ones. During the SSE lifecycle of a fresh chat, a single .message-render node cycles through three ids (client UUID -> handler id -> server id). When the id mutated on the same element, the effect would call observe(el) then unobserve(el) on that element in the same pass — leaving it permanently unobserved. The active-message highlight never updated for the new id until a hard refresh rebuilt everything from scratch. Switched to element-identity tracking. Build an element -> newId map from entries, then for each currently observed [oldId, el]: - if the element no longer appears in entries, unobserve and drop it - if the element appears under a new id, migrate observed and visibleSet keys in place — the IntersectionObserver keeps watching the same DOM node uninterrupted Genuinely new elements get observed afterward as before. Rename doesn't fire an IO callback, so flush activeIds manually when at least one migration happened. Existing convos already had this working because their ids never mutate after load — only fresh chats hit the SSE id cycle, which matches the reproduction. * fix(ui): keep message nav current and pinned at bottom
…ny-avila#12657) * feat(ui): add message navigation strip and redesign scroll-to-bottom 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. * fix(ui): prevent message nav layout shift on scroll Use a fixed-height container for each indicator so the nav strip maintains consistent dimensions when indicators transition between active and inactive states. * fix(ui): debounce message nav refresh and persist visibility state 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. * fix(ui): prevent nav buttons from disabling during fast scroll - 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) * fix(ui): scroll to message start when using nav arrow buttons 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. * fix(ui): account for header offset when scrolling to messages Use manual scrollTo with a 56px offset to prevent the fixed header from covering the top of the target message when using arrow buttons. * fix(ui): improve message nav scrolling and visual subtlety - 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 * fix(ui): use native scroll-margin-top for reliable message navigation 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. * fix(ui): use firstActiveIndex for both nav directions 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. * fix(ui): address PR review feedback - 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 * fix(ui): make message nav scroll precise and chevrons reliable - 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. * perf(ui): skip off-screen message layout and fix resulting scroll drift 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. * revert(ui): drop content-visibility on .message-render 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. * fix(ui): address PR review — a11y, tests, and MessageNav correctness - 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). * fix(ui): catch in-place message id mutations and react to layout shifts 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). * fix(ui): address deep-review follow-ups on MessageNav - 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. * fix(ui): address re-review — clean lockfile + ScrollToBottom ref target - 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. * fix(ui): preserve IntersectionObserver across in-place id mutations The IO sync effect was observing new ids before unobserving old ones. During the SSE lifecycle of a fresh chat, a single .message-render node cycles through three ids (client UUID -> handler id -> server id). When the id mutated on the same element, the effect would call observe(el) then unobserve(el) on that element in the same pass — leaving it permanently unobserved. The active-message highlight never updated for the new id until a hard refresh rebuilt everything from scratch. Switched to element-identity tracking. Build an element -> newId map from entries, then for each currently observed [oldId, el]: - if the element no longer appears in entries, unobserve and drop it - if the element appears under a new id, migrate observed and visibleSet keys in place — the IntersectionObserver keeps watching the same DOM node uninterrupted Genuinely new elements get observed afterward as before. Rename doesn't fire an IO callback, so flush activeIds manually when at least one migration happened. Existing convos already had this working because their ids never mutate after load — only fresh chats hit the SSE id cycle, which matches the reproduction. * fix(ui): keep message nav current and pinned at bottom
* 🛰️ fix: Honor Anthropic Vertex Configuration (#12972)
* fix: honor Anthropic Vertex config
* chore: format Anthropic Vertex config fix
* 🌍 i18n: Update translation.json with latest translations (#12964)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* 🧮 feat: Add GPT-5.5 Token Definitions (#12973)
* fix: add gpt-5.5 token definitions
* fix: align gpt-5.5 context limit
* 🌐 fix: Preserve Unicode Filenames (#12977)
* fix: Preserve unicode filenames
* fix: Cap unicode filenames by bytes
* fix: Preserve clean artifact directories
* fix: Disambiguate normalized artifact names
* 🧭 feat: Add Message Navigation Strip & Redesign Scroll-to-Bottom (#12657)
* feat(ui): add message navigation strip and redesign scroll-to-bottom 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.
* fix(ui): prevent message nav layout shift on scroll
Use a fixed-height container for each indicator so the nav strip
maintains consistent dimensions when indicators transition between
active and inactive states.
* fix(ui): debounce message nav refresh and persist visibility state
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.
* fix(ui): prevent nav buttons from disabling during fast scroll
- 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)
* fix(ui): scroll to message start when using nav arrow buttons
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.
* fix(ui): account for header offset when scrolling to messages
Use manual scrollTo with a 56px offset to prevent the fixed header
from covering the top of the target message when using arrow buttons.
* fix(ui): improve message nav scrolling and visual subtlety
- 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
* fix(ui): use native scroll-margin-top for reliable message navigation
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.
* fix(ui): use firstActiveIndex for both nav directions
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.
* fix(ui): address PR review feedback
- 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
* fix(ui): make message nav scroll precise and chevrons reliable
- 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.
* perf(ui): skip off-screen message layout and fix resulting scroll drift
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.
* revert(ui): drop content-visibility on .message-render
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.
* fix(ui): address PR review — a11y, tests, and MessageNav correctness
- 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).
* fix(ui): catch in-place message id mutations and react to layout shifts
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).
* fix(ui): address deep-review follow-ups on MessageNav
- 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.
* fix(ui): address re-review — clean lockfile + ScrollToBottom ref target
- 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.
* fix(ui): preserve IntersectionObserver across in-place id mutations
The IO sync effect was observing new ids before unobserving old ones.
During the SSE lifecycle of a fresh chat, a single .message-render node
cycles through three ids (client UUID -> handler id -> server id). When
the id mutated on the same element, the effect would call observe(el)
then unobserve(el) on that element in the same pass — leaving it
permanently unobserved. The active-message highlight never updated for
the new id until a hard refresh rebuilt everything from scratch.
Switched to element-identity tracking. Build an element -> newId map
from entries, then for each currently observed [oldId, el]:
- if the element no longer appears in entries, unobserve and drop it
- if the element appears under a new id, migrate observed and
visibleSet keys in place — the IntersectionObserver keeps watching
the same DOM node uninterrupted
Genuinely new elements get observed afterward as before. Rename doesn't
fire an IO callback, so flush activeIds manually when at least one
migration happened. Existing convos already had this working because
their ids never mutate after load — only fresh chats hit the SSE id
cycle, which matches the reproduction.
* fix(ui): keep message nav current and pinned at bottom
* 🔐 feat: Add Signed CloudFront File Downloads (#12970)
* feat: add signed CloudFront downloads
* fix: preserve local IdP avatar paths
* fix: address signed download review findings
* fix: harden CloudFront cookie scope validation
* fix: preserve URL save API compatibility
* fix: store CDN SSO avatars under shared prefix
* fix: Harden CloudFront tenant file access
* fix: Preserve CloudFront download compatibility
* fix: Address CloudFront review follow-ups
* fix: Preserve file URL fallback user paths
* fix: Address download review hardening
* fix: Use file owner for S3 RAG cleanup
* fix: Address final download review nits
* fix: Clear stale avatar CloudFront cookies
* fix: Align download filename helpers with dev
* fix: Address final CloudFront review follow-ups
* fix: Stream S3 URL uploads
* fix: Set S3 stream upload length
* fix: Preserve download metadata filepath
* fix: Avoid remote content length for stream uploads
* fix: Use bounded multipart URL uploads
* fix: Harden S3 filename boundaries
* ⏳ fix: Preserve Temporary Chat Retention Config (#12985)
* 🛂 fix: Harden Agent File Preview Access (#12981)
* fix: harden agent file access
* style: format agent file query
* fix: prune agent file refs on alternate writes
* test: fix agent pruning specs
* 🌐 fix: Percent-encode X-File-Metadata header for Unicode filenames (#12983)
* 🌐 fix: Percent-encode X-File-Metadata header for Unicode filenames
After #12977 preserved Unicode in filenames, the download route
crashes with ERR_INVALID_CHAR because JSON.stringify(file) now
contains non-ASCII characters that Node.js rejects in HTTP headers
per RFC 7230.
Wrap the header value in encodeURIComponent on the server and
decodeURIComponent on the client before JSON.parse.
* fix: Update file route tests after dev merge
---------
Co-authored-by: Danny Avila <danny@librechat.ai>
* ⏱️ fix: Align Auto-Refill Next Date (#12980)
* fix: Align auto-refill next date
* style: Fix auto-refill lint formatting
* refactor: Share auto-refill eligibility date
* refactor: Consolidate refill interval units
* fix: Guard malformed refill interval units
* fix: Preserve refill unit fallback label
* 🪟 refactor: Improve Subagent Dialog Prompt Rendering (#12982)
* fix: Improve subagent dialog prompt rendering
* fix: Preserve cancelled subagent traces
* chore: Reuse generic prompt toggle labels
* fix: Scope new-chat subagent cleanup exemption
* fix: Use valid subagent prompt min-height
* fix: Flatten subagent dialog conditionals
* fix: Place subagent prompt in dialog scroll
* 🌥️ feat: Add Optional Region-aware S3/CloudFront Storage Keys (#12987)
* feat(files): add optional region-aware storage keys
* test(files): fix region storage CI fixtures
* feat(files): finalize inline CloudFront asset namespaces
* fix(files): allow wildcard region CloudFront cookies
* fix(files): preserve legacy storage key compatibility
* fix(files): align CloudFront clear cookie cleanup
* fix(files): clear legacy CloudFront cookie scopes
* chore(files): clean up storage review nits
* fix(files): keep inline namespaces CloudFront-only
* 📦 chore: npm audit fixes and Mongoose 8.23 TypeScript follow-ups (#12996)
* chore: Update axios dependency to version 1.16.0 across multiple package files
* chore: Update express-rate-limit and ip-address dependencies to versions 8.5.1 and 10.2.0 in package-lock.json and package.json
* chore: Update mongoose and hono dependencies to versions 8.23.1 and 4.12.18 across multiple package files
* fix: Add type parameters to mongoose lean queries in accessRole and aclEntry methods
* fix: Add type parameters to mongoose lean queries in action, agent, and agentCategory methods
* chore: Update moduleResolution to 'bundler' in tsconfig.json for api and data-schemas packages
* fix: Update mongoose lean queries to include type parameters across various methods for improved type safety
* 🧭 fix: Navigate Signed CDN Downloads (#12998)
* fix(files): navigate signed CDN downloads
* fix(files): avoid popup target for signed downloads
* test(files): restore download URL mock
* 🪪 fix: Preserve OIDC Logout ID Token Hint (#12999)
* 📦 chore: Update `@librechat/agents` to v3.1.79 (#13000)
* 🛡️ refactor: Scope `allowedAddresses` By Port (#13022)
* fix: Scope allowedAddresses by port
* test: Fix SSRF agent spec typing
* 🧱 refactor: typed CodeEnvRef + kind discriminator + principal-aware sandbox cache (#12960)
* 🧱 refactor: typed CodeEnvRef + kind discriminator + tenant-aware sandbox cache
Final cutover for the LibreChat ↔ codeapi sandbox file identity. Replaces
the magic string `${session_id}/${file_id}?entity_id=...` with a typed,
discriminated `CodeEnvRef`. Pre-release lockstep deploy with codeapi
#1455 and agents #148; no legacy aliases retained.
## Final shape
```ts
type CodeEnvRef =
| { kind: 'skill'; id: string; storage_session_id: string; file_id: string; version: number }
| { kind: 'agent'; id: string; storage_session_id: string; file_id: string }
| { kind: 'user'; id: string; storage_session_id: string; file_id: string };
```
`kind` drives codeapi's sessionKey: `<tenant>:<kind>:<id>[:v:<version>]`
for shared kinds, `<tenant>:user:<userId>` for user-private (auth context
provides `userId`). `version` is statically required for `kind: 'skill'`
and forbidden otherwise via discriminated union — constraint holds at
compile time on every consumer, not just codeapi's runtime validator.
`id` is sessionKey-meaningful for `'skill'` / `'agent'`; informational
only for `'user'` (codeapi resolves user identity from auth context).
## What changed
- `packages/data-provider/src/codeEnvRef.ts` — discriminated union +
`CODE_ENV_KINDS` const-tuple keeps the runtime list and TS union
locked together.
- Schemas: `metadata.codeEnvRef` and `SkillFile.codeEnvRef` enums
tightened to `['skill', 'agent', 'user']`.
- `primeSkillFiles` writes `kind: 'skill'`, `id: skill._id`,
`version: skill.version`. Cache-hit path reads `codeEnvRef`
directly. Bumping `skill.version` on edit naturally invalidates
the prior cache entry under the new sessionKey.
- `processCodeOutput` writes `kind: 'user'`, `id: req.user.id`. Output
bucket is always user-scoped, regardless of which skill the
execution invoked. New regression test pins the asymmetry.
- `primeFiles` reupload preserves `kind`/`id`/`version?` from the
existing ref so a skill-cache-miss reupload doesn't silently demote
to user bucket.
- `crud.js` upload functions (`uploadCodeEnvFile` /
`batchUploadCodeEnvFiles`) thread `kind`/`id`/`version?` to the
multipart form (codeapi #1455 option α). Without these on the wire,
codeapi falls back to user bucketing and skill-cache invalidation
never fires. Client-side validation mirrors codeapi's validator.
- `Files/process.js` — chat attachments use `kind: 'user'`; agent
setup files use `kind: 'agent'`.
- Drops `entity_id` everywhere (struct, schema sub-docs, write paths,
upload form fields). Drops `'system'` from the kind enum (no emitter
ever existed).
## Test plan
- [x] `cd packages/data-provider && npx jest src/codeEnvRef.spec` — 4 / 4
- [x] `cd packages/data-schemas && npx jest` — 1447 / 1447
- [x] `cd packages/api && npx jest src/agents` — 81 / 81 in skillFiles +
handlers + resources
- [x] `cd api && npx jest server/services/Files server/controllers/agents` —
436 / 436
- [x] `cd api && npx jest server/services/Files/Code` — 98 / 98 (incl.
new "outputs are user-scoped regardless of which skill the execution
invoked" regression and "reupload forwards kind/id/version from
existing ref")
- [x] `npx tsc --noEmit -p packages/data-{provider,schemas}/tsconfig.json
&& npx tsc --noEmit -p packages/api/tsconfig.json` — clean (only
pre-existing unrelated dev errors in storage/balance, untouched here)
## Deploy notes
- **24h cache-miss burst** on first deploy. Inputs (skill caches re-prime
under new sessionKey shape) and outputs (any pre-Phase C skill-output
cached files become unreadable). Bounded by codeapi's 24h TTL.
- **Lockstep with codeapi #1455 and agents #148.** Either repo can land
first since no aliases to drain, but the three deploys must overlap
within the same maintenance window.
- **`@librechat/agents` bump to `3.1.79-dev.0`** required after agents
#148 lands and is published.
## What this enables
Auth bridge work (JWT-based tenant/user identity between LC and codeapi)
— codeapi now derives sessionKey purely from `req.codeApiAuthContext.{
tenantId, userId}`, so the next chapter is replacing the header-asserted
user identity with a verified-claim path.
* 🩹 fix: persist execute_code uploads under codeEnvRef metadata key
Codex review P1 (chatgpt-codex-connector). `Files/process.js` was
storing the upload result under `metadata.fileIdentifier` even though:
- `uploadCodeEnvFile` now returns `{ storage_session_id, file_id }`,
not the legacy magic string.
- The post-cutover schema (`File.metadata.codeEnvRef`) only declares
`codeEnvRef` — mongoose strict mode silently strips unknown keys.
- All readers (`primeFiles`, `getCodeFilesByIds`,
`categorizeFileForToolResources`, controller filtering) check
`metadata.codeEnvRef`.
Net effect of the bug: chat-attached and agent-setup execute_code files
would lose their sandbox reference on save, and primeFiles would skip
them on subsequent code-execution turns — the file blob would still be
available locally but never re-mounted in the sandbox.
Fix: construct the full `CodeEnvRef` (`{ kind, id, storage_session_id,
file_id }`) at the write site and persist under `metadata.codeEnvRef`.
`BaseClient`'s "is this a code-env file" presence check accepts the new
shape alongside the legacy `fileIdentifier` for back-compat with any
pre-cutover records still in the database. Mirrors the same change in
`processAttachments.spec.ts` (which re-implements the BaseClient logic
for testability).
New regression tests in `process.spec.js` cover three cases:
- chat attachments (`messageAttachment=true`) → `kind: 'user'`
- agent setup (`messageAttachment=false`) → `kind: 'agent'`
- legacy `fileIdentifier` key is NOT persisted (would be schema-stripped)
* 🩹 fix: read storage_session_id on primed file refs (Codex P1)
Codex review (chatgpt-codex-connector). After Phase B's per-file
`session_id` → `storage_session_id` rename, `primeFiles` emits the
new field — but `seedCodeFilesIntoSessions` was still reading
`files[0].session_id` for the representative session and `f.session_id`
for the dedupe key. In runs with only primed attachments (no skill
seed), `representativeSessionId` was `undefined`, the function
returned the unchanged map, and `seedCodeFilesIntoSessions` silently
dropped the entire batch. The first `execute_code` call then started
without `_injected_files` and the agent couldn't see prior-turn
artifacts.
Fix:
- `codeFilesSession.ts`: read `f.storage_session_id` for both the
dedupe key and the representative session id. JSDoc updated to
match the new field name.
- `callbacks.js`: the two output-file persistence paths read
`file.session_id` to pass to `processCodeOutput` — switch to
`file.storage_session_id`. The original comment explicitly says
this should be the STORAGE session, which is exactly the field
Phase B renamed.
- `codeFilesSession.spec.ts`: fixture builder uses `storage_session_id`
and `kind: 'user'` to match the post-cutover `CodeEnvFile` shape.
Lockstep coordination: this matches the post-bump shape of
`@librechat/agents` 3.1.79+. CI tsc errors against the currently-pinned
3.1.78 are expected and resolve when the dep bumps in this PR before
merge.
* 📦 chore: Bump `@librechat/agents` to version 3.1.80-dev.0 in package-lock and package.json files
* 🪪 fix: thread kind/id/version through codeapi /download URLs (Phase C α)
Symmetric fix for the upload-side wire change in 537725a. Codeapi's
`sessionAuth` middleware now requires `kind`/`id`/`version?` on every
download/freshness URL — without them it 400s with "kind must be one
of: skill, agent, user" before serving the file.
Three sites construct codeapi-side URLs that go through `sessionAuth`:
- `processCodeOutput` (`Files/Code/process.js`): `/download/<sess>/<id>`
for freshly-generated sandbox outputs. Always `kind: 'user'` +
`id: req.user.id` — code-output files are always user-private,
regardless of which skill the run invoked.
- `getSessionInfo` (`Files/Code/process.js`): `/sessions/<sess>/objects/<id>`
for the 23h freshness check. Pulls kind/id/version straight off the
`codeEnvRef` already in scope — skill files stay skill-bucketed,
user files stay user-bucketed.
- `/code/download/:session_id/:fileId` LC route (`routes/files/files.js`):
proxies to codeapi for manual downloads. Code-output files only on
this route, so `kind: 'user'` + `id: req.user.id`.
The `getCodeOutputDownloadStream` helper in `crud.js` now takes an
`identity` param, validated by a `buildCodeEnvDownloadQuery` helper
that mirrors `appendCodeEnvFileIdentity`'s shape rules: kind required
from the closed `{skill, agent, user}` set, version required for
'skill' and forbidden otherwise. Bad callers fail fast on the client
instead of round-tripping a 400.
Also cleans up two log-noise sources reported alongside the 400:
- `logAxiosError` in `packages/api/src/utils/axios.ts` was dumping
`error.response.data` raw. With `responseType: 'arraybuffer'` that's
a `Buffer` (~4 chars per byte after JSON-serialization); with
`responseType: 'stream'` it's a `Readable` whose internal state
serializes the entire ring buffer + socket. New `renderResponseData`
decodes small buffers as UTF-8 (truncated past 2KB) and stubs streams
as `'[stream]'`. Diagnostics stay useful, log lines stop being
megabytes.
- `/code/download` route's catch was bare `logger.error('...', error)`,
bypassing the redactor. Switched to `logAxiosError` so it benefits
from the same buffer/stream handling.
Tests updated to match the new contract:
- crud.spec: `getCodeOutputDownloadStream` fixtures pass `userIdentity`;
new cases cover skill identity (with version), bad kind rejection,
skill-without-version rejection.
- process.spec: `getSessionInfo` test passes a full `codeEnvRef` object.
* ♻️ refactor: extract codeEnv identity helpers into packages/api
Per the project convention that new backend code lives in TypeScript
under `packages/api`, moves `appendCodeEnvFileIdentity` and
`buildCodeEnvDownloadQuery` from `api/server/services/Files/Code/crud.js`
into a new `packages/api/src/files/code/identity.ts` module.
Both helpers are pure validators that mirror codeapi's
`parseUploadSessionKeyInput` server-side rules (closed kind set,
`version` required for `'skill'` and forbidden otherwise) — they
deserve TS support and a dedicated spec rather than living as
JSDoc-typed helpers in the legacy `/api` workspace. The new module:
- Exports a `CodeEnvIdentity` interface using the
`librechat-data-provider` `CodeEnvKind` discriminated union.
- Adds 13 unit tests in `identity.spec.ts` covering the validation
matrix (skill+version, agent, user, and every rejection path) plus
URL encoding for the download query.
- Re-exported from `packages/api/src/files/code/index.ts` alongside
`classify`, `extract`, and `form`.
Consumer updates:
- `api/server/services/Files/Code/crud.js`: drops the local helpers
and imports them from `@librechat/api`. Net -64 lines.
- `api/server/services/Files/Code/process.js`: same.
- Test mocks for `@librechat/api` in three spec files now stub the
helpers' validation behavior locally rather than pulling them
through `requireActual` (which would drag in provider-config
init-time side effects). The package's `exports` field only
surfaces the root barrel, so leaf imports aren't reachable from
legacy `/api` test setup.
No runtime behavior change. Identity validation rules and emitted
form/query shapes are byte-for-byte identical pre/post.
* 🪪 fix: emit resource_id alongside id on _injected_files (skill 403 fix)
Companion to codeapi #1455 fix and agents 3.1.80-dev.1 — the wire
shape for shared-kind files now requires `resource_id` distinct from
the storage `id`. Without this LC change, codeapi's sessionKey
re-derivation on every shared-kind /exec rejects with 403
session_key_mismatch:
cached: legacy:skill:69dcf561...:v:59 (signed at upload, skill _id)
derived: legacy:skill:ysPwEURuPk-...:v:59 (storage nanoid)
Emit sites updated:
- `primeInvokedSkills` cache-hit path: `resource_id: ref.id` (the
persisted skill `_id` from `codeEnvRef.id`); `id: ref.file_id`
unchanged (storage uuid).
- `primeInvokedSkills` fresh-upload path: `resource_id: skill._id.toString()`
on every primed file (the `allPrimedFiles` builder type now carries
the field).
- `processCodeOutput`'s `pushFile` (Code/process.js): `resource_id: ref.id`
— for `kind: 'user'` this is informational (codeapi derives
sessionKey from auth context) but emitted for shape uniformity
with shared kinds.
Bumps `@librechat/agents` to `^3.1.80-dev.1` (the version that
ships the matching `CodeEnvFile.resource_id` field).
## Test plan
- [x] `cd packages/api && npx jest src/agents` — 67 / 67 pass
(skillFiles fixtures updated to assert `resource_id` on the
emitted CodeSessionContext.files).
- [x] `cd api && npx jest server/services/Files server/controllers/agents` —
445 / 445 pass (process.spec fixtures updated for the reupload
+ cache-hit emission).
- [x] `npx tsc --noEmit -p packages/api/tsconfig.json` — clean.
* fix(skill-tool-call): carry resource_id through primeSkillFiles → artifact
Codeapi was 400ing every /exec following a `handle_skill` tool call
with `resource_id is invalid` (`type: 'undefined'`). Both code paths
in `primeSkillFiles` (cache-hit + fresh-upload) returned files
without `resource_id`/`kind`/`version`, and the artifact in
`handlers.ts` forwarded the stripped shape into
`tc.codeSessionContext.files` → `_injected_files`.
`primeInvokedSkills` (the NL-detected loader) had already been fixed
end-to-end; this commit aligns the tool-invoked path with the same
contract: `resource_id` = `skill._id.toString()`, `kind: 'skill'`,
`version` = the skill's monotonic counter.
Tests added to `skillFiles.spec.ts` lock the contract on
`primeSkillFiles` directly so future refactors can't silently drop
the resource identity again.
* fix(handlers.spec): align session_id → storage_session_id rename + kind discriminator
Pre-existing TS errors against the post-rename `CodeEnvFile` shape:
the test file still used `session_id` on per-file objects (renamed to
`storage_session_id` in agents Phase B/C) and was missing the `kind`
discriminator the discriminated union requires. Both inputs and the
matching `expect.toEqual(...)` mirrors updated together so the
runtime equality check still holds.
Lines 723-732 stay as-is — they sit behind `as unknown as
ToolCallRequest` and TS already skipped them.
* chore: fix `@librechat/agents`, correct version to 3.1.80-dev.0 in package.json files
* chore: bump `@librechat/agents` to version 3.1.80-dev.1 in package.json and package-lock.json
* chore: bump `@librechat/agents` to version 3.1.80-dev.2
* feat(observability): trace file priming chain from primeCodeFiles to _injected_files
Diagnosing the user-upload "files=[] on first /exec" bug requires
seeing where in the LC chain a file ref disappears. Prior to this
patch the chain (primeCodeFiles → primedCodeFiles → initialSessions
→ CodeSessionContext → _injected_files) was opaque end-to-end:
- primeCodeFiles silently dropped files without `metadata.codeEnvRef`
- reuploadFile catches all errors and continues with no signal
- the handlers.ts handoff to codeapi never logged what it was sending
After this patch, a single grep on `[primeCodeFiles]` plus
`[code-env:inject]` shows the full per-file path:
[primeCodeFiles] in: file_ids=N resourceFiles=M
[primeCodeFiles] file=<id> path=skip reason=no-codeenvref filename=...
[primeCodeFiles] file=<id> path=cache-hit-by-session storage_session_id=...
[primeCodeFiles] file=<id> path=reupload reason=no-uploadtime ...
[primeCodeFiles] file=<id> path=reupload reason=stale ...
[primeCodeFiles] file=<id> path=reupload-success oldSession=... newSession=... newFileId=...
[primeCodeFiles] file=<id> path=reupload-failed session=...
[primeCodeFiles] file=<id> path=fresh-active storage_session_id=...
[primeCodeFiles] out: returned=N skippedNoRef=M reuploadFailures=K
[code-env:inject] tool=<name> files=N missingResourceId=K (debug)
[code-env:inject] M/N files missing resource_id ... (warn)
[code-env:inject] tool=<name> _injected_files=0 ... (warn)
The boundary log warns when LC sends zero injected files on a
code-execution tool call — that's the user's actual symptom showing
up at the LC side instead of having to correlate against codeapi's
`Request received { files: [] }`.
Tag chosen as `[code-env:inject]` rather than `[handoff:exec]` to
avoid collision with the app-level "handoff" semantic (subagent
handoff workflow).
Structural cleanup in primeFiles: replaced the `if (ref) { ... }`
nesting with an early `if (!ref) continue` so the per-path
instrumentation hooks land at top-level scope instead of indented
inside a conditional. Behavior unchanged; pushFile / reuploadFile
identical.
Spec fixtures (handlers.spec.ts, codeFilesSession.spec.ts) updated
to include `resource_id` on `CodeEnvFile` literals — required by
the post-3.1.80-dev.2 type now installed.
## Test plan
- [x] `cd packages/api && npx jest src/agents/handlers.spec.ts src/agents/codeFilesSession.spec.ts src/agents/skillFiles.spec.ts` — 69/69 pass
- [x] `cd api && npx jest server/services/Files/Code/process.spec.js` — 84/84 pass
- [x] `npx tsc --noEmit -p packages/api` — clean
- [x] `npx eslint` on all four touched files — clean
* chore: add CONSOLE_JSON_STRING_LENGTH to .env.example for JSON log string length configuration
* fix(files): align codeapi upload filename with LC's sanitized DB filename
User-attached files for code execution were uploading to codeapi
under `file.originalname` (raw upload filename, may contain spaces /
special chars) while LC's DB record stored the sanitized form
(`sanitizeFilename(file.originalname)`, underscores). Codeapi
preserves whatever filename the upload sent, so the sandbox saw
`/mnt/data/<originalname>` while LC's `primeFiles` toolContext text
+ `_injected_files.name` referenced `file.filename` (sanitized).
Visible failure: agent gets system prompt saying
/mnt/data/librechat_code_api_-_active_customer_-_2025-11-05.xlsx
…tries that path, hits `FileNotFoundError`, then notices the
sandbox's actual `Available files` line says
/mnt/data/librechat code api - active customer - 2025-11-05.xlsx
…retries with spaces, succeeds. Wastes a tool call per upload and
leaks raw filenames into model context.
Fix: sanitize once and use the sanitized form in both the codeapi
upload AND the LC DB record. Sandbox path = LC toolContext text =
in-memory ref name. No drift.
Reupload path (`Code/process.js` line 867 `filename: file.filename`)
already uses the sanitized DB name, so it stays consistent with the
fresh-upload path after this change.
## Test plan
- [x] `cd api && npx jest server/services/Files/process` — 32/32 pass
- [x] `npx eslint` on the touched file — clean
* chore: bump `@librechat/agents` to version 3.1.80-dev.3 in package.json and package-lock.json
* 🧠 fix: charge Gemini reasoning tokens in agent usage accounting (#13014)
* 🧠 fix: charge Gemini reasoning tokens in agent usage accounting
Resolves #13006.
`usage.ts` previously billed `usage.output_tokens` directly. For Vertex
AI Gemini thinking models, `@langchain/google-common`'s streaming path
emits `output_tokens = candidatesTokenCount` only, dropping
`thoughtsTokenCount`. Reasoning was billed at zero and the
`total_tokens === input_tokens + output_tokens` invariant was broken.
The fix lives in agents (danny-avila/agents#157) — but this is also a
defense-in-depth backstop in case agents misses a path or another
provider exhibits the same shape. `resolveCompletionTokens(usage)` adds
`output_token_details.reasoning` back when (and only when) the gap is
present (`total - input > output`), so providers that already include
reasoning in `output_tokens` (OpenAI o-series, Anthropic, the
Google-API wrapper) are no-ops — no double-counting.
- `SplitUsage` gains a `completion` field; all four billing call sites
in `processUsageGroup` use it instead of `usage.output_tokens`.
- `total_output_tokens` in the result also reflects the corrected
count.
- `UsageMetadata` interface in `IJobStore.ts` adds the
`output_token_details` field for type safety.
- 4 new tests in `usage.spec.ts` cover: Vertex undercount fix, OpenAI
no-double-count, structured spend path with cache + reasoning, no-op
when no details present.
* 🩹 fix: simplify reasoning correction to invariant-based gap check
Initial fix gated the correction on `output_token_details.reasoning > 0`,
which doesn't help in the live failure case: when google-common's stream
emits the buggy fallback usage_metadata, output_token_details is empty
({}) and the gate exits early.
Live debugging showed the reliable signal is the documented invariant
itself: `total_tokens === input_tokens + output_tokens`. When buggy
streams undercount output, total exceeds input + output by exactly the
unbilled reasoning. Use `total - input` as the corrected output.
This is provider-agnostic and stays a no-op for compliant providers
(OpenAI/Anthropic/Google-via-CustomChatGoogleGenerativeAI), where the
gap is zero.
Live verified end-to-end against gemini-3-flash-preview:
- With agents fix in place: output_tokens=437 → billed 437 (no-op)
- Backstop only (no agents fix, buggy input): raw 135, billed 297
(= total 309 - input 12, matches actual API charge)
Updated tests to cover both scenarios.
* 🚦 fix: Preserve URL Auto-Submit Startup Config (#13017)
* fix: Preserve URL auto-submit startup config
* test: Cover URL auto-submit interface defaults
* 🐛 refactor: anchor code-generated file lookup on threadFileIds for branched conversations (#13004)
* 🐛 fix: anchor getCodeGeneratedFiles on threadFileIds, not threadMessageIds
In a branched conversation (regenerations producing the same code-output
filename), `getCodeGeneratedFiles` would silently exclude files whose
File-record `messageId` lived on a sibling branch. The user-visible
symptom: "the previous file isn't persisted" — the LLM tries
`load_workbook("output.xlsx")` on turn 2 and gets `FileNotFoundError`
because LC sent `_injected_files: []` to codeapi instead of priming
the prior turn's output.
`claimCodeFile` is keyed by `(filename, conversationId, context)` —
not by messageId. When sibling A first creates `output.csv`, the File
record persists with `messageId = A`. When sibling N (a regeneration
of A's parent) recreates `output.csv`, the claim finds A's record and
`processCodeOutput` deliberately preserves `messageId = A` to keep
file→original-creator provenance intact (correct behavior for the
linear case where the original creator is in-thread).
Turn N+1's `parentMessageId = N`. `getThreadData` walks back from N:
the thread is `[N, root]` — sibling A is NOT in it. The pre-fix query
filtered by `messageId IN [N, root]`, so the file was excluded.
`getCodeGeneratedFiles` already lives next to `getUserCodeFiles`,
which has always filtered by `file_id IN threadFileIds` (the file_ids
referenced by `messages.files[]` arrays during the thread walk). The
asymmetry — user-uploaded files anchored on the message's reference,
code-generated files anchored on the File's own creator — was the
bug. Anchoring both functions on `threadFileIds` reaches the right
files regardless of which sibling first generated them.
`File.messageId` stays informational ("who first generated this") for
provenance and `processCodeOutput`'s "preserve original messageId on
update" logic stays as-is — only the lookup key for thread-scoped
fetches changes.
- `packages/data-schemas/src/methods/file.ts`: signature + filter
change. JSDoc spells out the branched-conversation rationale.
- `packages/api/src/agents/initialize.ts`: pass `threadFileIds` instead
of `threadMessageIds`. The local `threadMessageIds` declaration is
removed since the only consumer is gone.
- `packages/data-schemas/src/methods/file.spec.ts`: 5 new cases:
- basic happy-path (file referenced by current thread)
- **the regression**: file's creator messageId is on a sibling
branch but file_id is in threadFileIds → finds it
- empty/missing threadFileIds returns []
- cross-conversation isolation
- non-execute_code context filter still applies (a chat attachment
won't be returned even if its file_id is in threadFileIds —
that's `getUserCodeFiles`'s job)
Applies cleanly on top of dev. When LC #12960 (the typed CodeEnvRef
cutover) lands, the only conflict is the legacy `metadata.fileIdentifier`
metadata key flipping to `metadata.codeEnvRef` — same line, trivial
resolve.
- [x] `cd packages/data-schemas && npx jest src/methods/file.spec` —
42/42 pass (including the 5 new regression cases)
- [x] `cd packages/api && npx jest src/agents` — 722/722 pass
(modulo 2 pre-existing summarization e2e failures unrelated)
- [x] `cd api && npx jest server/services/Files server/controllers/agents` —
432/432 pass
- [x] `npx tsc --noEmit -p packages/api/tsconfig.json` — clean
- [ ] Manual: branched conversation reproducer — generate a file in
turn 1, regenerate the parent (sibling), then in turn N+1 ask the
agent to read the file. Pre-fix: `FileNotFoundError`. Post-fix:
the file is primed and load_workbook succeeds.
* 🧪 test: lock initialize.ts → getCodeGeneratedFiles call shape
Integration-level regression test asserting initializeAgent passes
`threadFileIds` (not `threadMessageIds`) to getCodeGeneratedFiles
in branched-conversation scenarios. Locks in the API shape from the
previous commit, sitting one layer above the data-schemas unit test —
so a future refactor to the priming chain can't silently revert to
the messageId-based filter without surfacing a test failure here.
Two cases:
- The full call shape: agent.tools=['execute_code'], resendFiles=true,
threadData mock returns distinct messageIds and fileIds. Asserts the
call uses fileIds, and that getUserCodeFiles uses the same array
(the symmetric design that closes the sibling-branch hole).
- Empty threadFileIds: getCodeGeneratedFiles is still called with []
(its own internal early-return handles the empty case); getUserCodeFiles
is gated at the call site and stays unscheduled.
* 📦 chore: bump `@librechat/agents` to v3.1.80 (#13021)
* 🧭 fix: Preserve File Search Upload Target (#13019)
* 🖼️ refactor: Tool Image Outputs outside of Tool Group Auto-Collapses (#12949)
* refactor(attachments): add variant prop to AttachmentGroup
* feat(tool-call): add hideImageAttachments prop to ToolCall
* fix(tool-call): keep MCP image outputs visible when tool group auto-collapses
* test(tool-call): verify MCP images hoist out of collapsed tool group
* fix(tool-call): hoist all grouped attachments and prevent ExecuteCode double-render
- rename hideImageAttachments -> hideAttachments and hide every attachment
in the inner tool when a group auto-collapses, then hoist them via
ToolCallGroup with default variant 'all' so non-image attachments survive
the collapse alongside images
- thread hideAttachments to ExecuteCode so it skips its inline AttachmentGroup
when grouped, preventing double-render when the group is expanded
- memoize sequentialParts and groupedParts in ContentParts (with
groupAttachments rolled into each tool-group entry) so we don't re-flatMap
on every render
* test(tool-call): cover hideAttachments contract and grouping integration
- ToolCall: assert AttachmentGroup is skipped when hideAttachments=true and
rendered when explicitly false, locking the prop's contract
- ToolCallGroup: update variant assertion to 'all' (now hoists images and
files together) and add a non-image-only hoist case
- ContentParts.integration: new test exercising the full
ContentParts -> Part -> ToolCall -> AttachmentGroup chain with realistic
MCP-shaped data (groups 2+ contiguous tool calls and hoists, single calls
render inline, mixed image+file hoists, empty attachments are a no-op)
* fix(tool-call): extend hideAttachments to bash/read_file/skill/subagent
When the post-rebase dev branch added BashCall, ReadFileCall, SkillCall,
and SubagentCall as dedicated tool renderers, each rendered its own
inline AttachmentGroup. Once the parent tool group hoists every
attachment, those inline groups would double-render, so they now honor
the same hideAttachments contract as ToolCall and ExecuteCode.
Also seed the new ToolCallGroup mocks (Users icon, getToolDisplayLabel)
so the existing hoist test suite keeps passing on dev.
* fix(image-gen): suppress inline image when attachments are hoisted
OpenAIImageGen renders the generated image directly via <Image>. When
its tool_call lands inside a grouped tool call, the parent now hoists
those attachments into ToolCallGroup's AttachmentGroup, and the inline
<Image> would render the same file a second time. Thread hideAttachments
through Part -> ImageGen (agent-style branch) so the agent-style image
slot stays out of the way once the parent has hoisted.
* refactor(tool-call): drop dead variant prop and flatten render-part hooks
- AttachmentGroup's variant prop ('images' / 'non-images') had no callers
after the final hoisting design landed, so remove the prop and the
filtering branches; everything passes the default 'all' behavior.
- Replace the makeRenderPart factory + dual useMemo with two plain
useCallbacks (renderPart, renderGroupedPart) sharing the same dep set.
- Tighten test mocks: drop 'any' in the new integration test, hoist the
MCP delimiter constant above its consumer, and remove the now-stale
data-variant attribute assertion.
* refactor(tool-call): extract getToolCallId helper and tidy imports
- Pull the (part?.[TOOL_CALL] as Agents.ToolCall)?.id chain into a single
getToolCallId helper in ContentParts so the three call sites stop
repeating the cast verbatim.
- Re-sort ToolCallGroup local imports longest-to-shortest per the project
convention.
- Add a Users mock to the integration test's lucide-react stub so future
subagent-group tests don't trip over an undefined glyph.
* refactor(tool-call): unnest ternaries in subagent and group labels
* 🧵 fix: Include Code Outputs in Thread File Lookup (#13023)
Code-execution outputs land on `messages.attachments` (set by
`processCodeOutput`), while user uploads land on `messages.files`.
The threadFileIds switch (#13004) walked only `files`, so on a
single linear thread:
Turn 1: assistant produces sample.xlsx → attachment with codeEnvRef
Turn 2: user says "add 2 rows"
→ primeCodeFiles: file_ids=0 resourceFiles=0
→ /exec sent files=[]
→ sandbox: FileNotFoundError: 'sample.xlsx'
The `getThreadData` walk found zero file_ids because the assistant's
codeEnvRef was on `attachments`, not `files`. Compounded by the
DB select string `'messageId parentMessageId files'` which didn't
pull `attachments` into memory in the first place — so even fixing
the walk in isolation wouldn't have surfaced them.
Both layers fixed:
- `ThreadMessage` type adds `attachments?: Array<{ file_id?: string }>`
- `getThreadData` walks both arrays, dedups via the same Set
- `initialize.ts` selects `'messageId parentMessageId files attachments'`
## Test plan
`packages/api/src/utils/message.spec.ts` (+6 cases):
- collects file_ids from `attachments`
- walks both `files` and `attachments` on the same message
- regression: linear thread with code-output attachments across
user→assistant→user→assistant produces the right file_ids
- dedupes shared ids that appear in both arrays
- skips attachments without file_id (mirrors `files` behavior)
- empty `attachments` array
`packages/api/src/agents/__tests__/initialize.test.ts` (+1 case):
- locks the DB select string includes `attachments` alongside
`files` / `messageId` / `parentMessageId`
- [x] `npx jest src/utils/message.spec.ts` — 39/39 pass
- [x] `npx jest src/agents/__tests__/initialize.test.ts` — 33/33 pass
- [x] lint clean on all four touched files
* 📦 chore: Bump `@librechat/agents` to v3.1.81 & npm audit fix (#13027)
* 📦 chore: Bump `@librechat/agents` to v3.1.81
* chore: npm audit fix
* 🧭 fix: Preserve Resend Files for Subagents (#13030)
* 🔄 feat: Cross-Origin Admin OAuth Refresh (#13007)
* feat(admin-panel): add /api/admin/oauth/refresh endpoint for cross-origin BFF refresh
The cookie-based /api/auth/refresh controller can't be reached cross-origin
from a separately-hosted admin panel because the refresh-token cookie isn't
sent on cross-origin fetches. Add a dedicated POST /api/admin/oauth/refresh
endpoint that accepts the refresh token in the request body, exchanges it
at the IdP via openid-client refreshTokenGrant, and returns the same
response shape as /api/admin/oauth/exchange.
Implementation lives in packages/api/src/auth/refresh.ts as the
applyAdminRefresh helper. It validates the refreshed tokenset, looks up the
admin user by openidId (with optional user_id disambiguation when multiple
user docs share an openidId), mints the bearer via an injected mintToken
hook, and runs an optional onRefreshSuccess hook for downstream forks that
need to update server-side session state.
The default mintToken passed by the OSS route signs an HS256 LibreChat JWT
via generateToken so admin panel callers continue to use the existing local
JWT strategy. Forks that prefer to hand back an IdP-signed token (e.g. for
deployments where the JWT auth gate is JWKS-only) override mintToken
without changing the helper or the route.
Also threads expiresAt through AdminExchangeData and AdminExchangeResponse
so admin panel clients can drive proactive refresh before the bearer
expires. Defaults the OSS exchange flow to Date.now() + sessionExpiry.
* fix(admin-panel): address review feedback on /api/admin/oauth/refresh
mintToken now returns {token, expiresAt} so the minter is authoritative
for the bearer's lifetime instead of deriving it from the IdP `exp` claim.
The refresh response would otherwise lie to the admin panel and trigger
premature or late refresh cycles.
The helper now falls back to the inbound refresh_token when the IdP omits
one on rotation (Auth0 with rotation off, Microsoft personal accounts).
Without this the admin panel loses its refresh capability after one cycle.
Other hardening:
resolveAdminUser validates user_id with Types.ObjectId.isValid before
hitting Mongoose, avoiding a CastError that would surface as a generic
500 with no useful information for the client.
If user_id resolves to a user whose openidId does not match the refreshed
sub, throw USER_ID_MISMATCH (401) instead of silently swapping in a
different user matching the sub.
Wrap tokenset.claims() in readClaims so an IdP that returns a tokenset
without a usable id_token gets mapped to CLAIMS_INCOMPLETE (502) rather
than bubbling a raw exception.
findUsers now uses the same SAFE_USER_PROJECTION as getUserById so the
fallback path no longer pulls password/totpSecret/backupCodes into memory.
Removed dead fields (email on AdminRefreshClaims, id_token on
RefreshTokenset) and fixed import ordering per AGENTS.md.
Adds packages/api/src/auth/refresh.spec.ts: 18 tests covering the happy
path, userId disambiguation (match, invalid ObjectId, null, mismatch),
all error branches (IDP_INCOMPLETE, CLAIMS_INCOMPLETE for both throw and
missing sub, USER_NOT_FOUND, mintToken/onRefreshSuccess propagation), and
refresh-token preservation under rotation/no-rotation.
* chore(admin-panel): polish per re-review on /api/admin/oauth/refresh
readClaims now logs the original error name/message at warn before mapping
to CLAIMS_INCOMPLETE so a programming bug doesn't get silently rebadged
as an IdP problem in production logs.
The route handler's JSDoc now enumerates every error response (status +
error_code) so admin-panel implementors can plan for each branch without
reading the source.
Tightens the helper's surface: removed the now-dead `exp` field from
`AdminRefreshClaims` (only `sub` is read since the v2 mintToken refactor),
and tightened `AdminRefreshDeps.findUsers`'s projection parameter from
`string | null` to `string` so the contract matches actual usage.
Test polish: the userId-resolves-to-null fallthrough test now asserts the
exact `findUsers` and `getUserById` call arguments so a regression in the
fallthrough query shape is caught. The "skips onRefreshSuccess" test now
asserts a populated response shape rather than just `toBeDefined`.
Declined per prior triage and re-confirmed: a role guard inside
`applyAdminRefresh` (downstream `/api/admin/*` already enforces
ACCESS_ADMIN via requireCapability) and moving the IdP grant call out of
the JS route into TypeScript (matches existing oauth.js / openidStrategy
pattern; package-boundary refactor belongs in a separate PR).
* fix(admin-panel): reject /api/admin/oauth/refresh tokensets from foreign issuers
When the route handler can resolve the configured OpenID issuer, it now
threads it into applyAdminRefresh as expectedIssuer. The helper compares
that against the tokenset claims iss (after normalizeOpenIdIssuer on
both sides to absorb trailing-slash differences) and throws
ISSUER_MISMATCH (401) on mismatch.
The check is skipped when either side is unset so behavior is unchanged
for IdPs that don't return iss on a refresh-grant id_token, and for
older deployments where the OpenID config doesn't expose serverMetadata.
This is a defense-in-depth measure for the refresh path only. The
deeper OIDC posture fix (binding IUser lookup to (sub, iss) as a pair)
is pre-existing debt across openidStrategy.js and the regular exchange
flow as well, and belongs in a separate PR with the schema change and
backfill migration.
* fix(admin-panel): bind refresh user lookup to (sub, iss) and handle getOpenIdConfig throw
Two fixes raised on the PR thread that I previously misdescribed:
The user lookup in resolveAdminUser was keyed on openidId alone, so a
tokenset from a different issuer that happened to share the same sub
could resolve to a local user from a different IdP. Now exports
getIssuerBoundConditions and isUserIssuerAllowed from openid.ts (the
helpers findOpenIDUser already uses) and reuses them. The findUsers
filter becomes ($or of getIssuerBoundConditions for openidId) when an
expectedIssuer is provided, with the same legacy backward-compat
clause for users whose openidIssuer field was never populated. The
direct user_id path now also checks isUserIssuerAllowed and throws
USER_ID_MISMATCH if the stored openidIssuer disagrees with the
configured issuer.
The route's getOpenIdConfig() call was previously documented as
returning null when uninitialized; the actual implementation throws.
That made the if (!openIdConfig) guard unreachable, and an unconfigured
server would surface as 500 INTERNAL_ERROR rather than 503
OPENID_NOT_CONFIGURED. Wraps the call in try/catch so the documented
503 response is what callers actually receive.
Adds 4 tests covering the new lookup binding behavior.
* fix(admin-panel): re-check ACCESS_ADMIN on /api/admin/oauth/refresh
The IdP refresh token can outlive a capability/role change, so the
initial requireAdminAccess on the OAuth callback isn't sufficient.
Inject canAccessAdmin via the existing capability model
(hasCapability with SystemCapabilities.ACCESS_ADMIN, matching
requireAdminAccess so custom roles and user grants are honored)
and gate token minting on it. Capability backend errors are
warn-and-denied to keep the bearer-mint path fail-closed.
* fix(admin-panel): scope /api/admin/oauth/refresh to the request tenant
The same (openidId, openidIssuer) pair is allowed across tenants by
the user schema's unique index. The refresh helper was wrapping both
the direct getUserById and the fallback findUsers in runAsSystem,
bypassing tenant isolation, so an IdP identity that exists in two
tenants could resolve to the wrong tenant's user and mint a JWT
bound to that tenant.
Drop the runAsSystem wrappers, add a trusted tenantId option to
applyAdminRefresh, AND it into the fallback findUsers filter, and
assert it against the direct getUserById result. Mount
preAuthTenantMiddleware on the refresh route so the deployment's
X-Tenant-Id header drives the trusted tenant via ALS. Single-tenant
deploys (no header) keep the existing openidId-only behaviour.
Adds TENANT_MISMATCH (401) and a regression covering duplicate
(sub, iss) across tenants plus the direct-userId tenant assertion.
* fix(admin-panel): gate /api/admin/oauth/refresh on OPENID_REUSE_TOKENS
The OSS refreshController only refreshes OpenID tokensets when
OPENID_REUSE_TOKENS is enabled. The body-based admin variant was
unconditionally calling refreshTokenGrant, which made the flag
ineffective for the admin OAuth flow and let admin sessions keep
renewing in deployments that explicitly turned token reuse off.
Add the same isEnabled(process.env.OPENID_REUSE_TOKENS) check up
front and return 403 TOKEN_REUSE_DISABLED so the admin panel BFF
can surface the configuration mismatch instead of silently churning
through retries.
* ⛴️ fix: Use Bitnami Legacy MongoDB Image in Helm Chart (#13032)
Bitnami moved versioned image tags from docker.io/bitnami to
docker.io/bitnamilegacy on 2025-08-28, which causes ImagePullBackOff
on a fresh Helm install of the LibreChat chart. Override the MongoDB
subchart image repository to bitnamilegacy/mongodb so installs
succeed out of the box.
Fixes #13031
* 🛟 fix: persist Vertex Gemini 3 `thoughtSignatures` across DB round-trips (#13026)
When a tool round-trip is interrupted between the tool result and the
model's text reply (user aborted, network drop, pod restart, ...) and
LibreChat persists the partial assistant message, the next conversation
turn reconstructs an `AIMessage` from `formatAgentMessages` that has
`tool_calls` populated but no `additional_kwargs.signatures`. Vertex
Gemini 3 rejects the resumed request with 400 because the most recent
historical functionCall has no `thought_signature`.
## Storage shape
Capture as `Record<tool_call_id, signature>` rather than a flat array.
This addresses the codex P1 review:
> When an assistant turn contains multiple sequential tool-call batches,
> this restoration path writes all persisted thoughtSignatures onto only
> the last tool-bearing AIMessage. Vertex/Gemini validates signatures
> for each step in the current tool-calling turn, so earlier
> functionCall steps reconstructed without their signature can still
> fail with 400.
A single agent run can fire multiple `chat_model_end` events when the
loop cycles the LLM with intervening tool results — each cycle owns a
distinct `tool_call_id`. Per-id storage maps each signature back onto
the right reconstructed `AIMessage`, not just the last one.
## Mapping
`additional_kwargs.signatures` is a flat array indexed by *response part*
(text + functionCall interleaved). `tool_calls` is just the function
calls in their original order. Non-empty signatures correspond 1:1 with
tool_calls in order — see `partsToSignatures` in
`@langchain/google-common`. Single-pass walk maps `signatures[i]` (when
non-empty) onto the i-th `tool_call.id`.
## Pipeline
| Stage | File | Change |
|---|---|---|
| Capture | callbacks.js | `ModelEndHandler` accepts `Record<string,string>` map; walks signatures + tool_calls in tandem to record per-id. Gated on the map being provided — non-Vertex flows are no-op (and also no-op even when provided, since they don't emit signatures). |
| Plumbing | initialize.js | Allocate `collectedThoughtSignatures = {}`, share with handler + client. Always allocated; the JSDoc explicitly documents that it stays empty for non-Vertex providers. |
| Surface | client.js | `sendCompletion` returns `metadata.thoughtSignatures` when the map has entries; falls through unchanged when empty. |
| Persist | (existing BaseClient.handleRespCompletion) | Writes `metadata` from `sendCompletion` onto `responseMessage.metadata`. Mongoose `Mixed` — no migration. |
| Restore | formatMessages.js | Track every tool-bearing AIMessage produced from a TMessage. For each, build a position-aligned `additional_kwargs.signatures` array (empty placeholders for tool_calls without a stored sig). Agents' `fixThoughtSignatures` dispatches non-empty entries to functionCall parts in order. |
## Live verification
- **Single-step:** real Vertex `gemini-3.1-flash-lite-preview` resume-after-tool case. With fix ✅ / without ❌ 400.
- **Multi-step (codex case):** real two-step agent loop (list /tmp → echo done). Each step's signature attaches to its own reconstructed AIMessage. With fix ✅ / without ❌ 400.
- **Cross-provider:** Anthropic Claude haiku-4.5 + OpenAI gpt-5-mini accept the persisted/restored shape unchanged.
## Tests
`modelEndHandler.spec.js` (new) — 6 tests:
- maps non-empty signatures onto tool_call_ids in order
- accumulates per-id across multiple `model_end` events (multi-step)
- no-op when `collectedThoughtSignatures` is null
- no-op when `signatures` field missing (non-Vertex)
- no-op when `tool_calls` missing
- preserves existing `collectedUsage` array contract
`formatAgentMessages.spec.js` — 6 new tests:
- restores onto the AIMessage that owns the tool_call
- per-step attachment for multi-step turns (codex review case)
- preserves tool_call ordering when signatures are partial
- no-op when metadata.thoughtSignatures absent
- no-op when assistant has no tool_calls
- no-op when stored ids don't match any current tool_call
37 passing across 3 suites; 15 existing formatAgentMessages tests unchanged.
## Compatibility
- Backward-compatible — restore gated on `metadata.thoughtSignatures` being a populated object; capture gated on the map being provided.
- No schema migration — uses `Message.metadata: Mixed` already in place.
- Cross-provider safe — non-Vertex providers tolerate the field (verified live against Anthropic + OpenAI converters).
- Pairs with [agents#159](https://github.com/danny-avila/agents/pull/159) for full coverage on histories that mix plain-text and toolcall AIMessages.
* 🛟 fix: Summarization Provider misses `vertexai` + case-mismatched custom endpoints (#13025)
`resolveSummarizationProvider` calls `getProviderConfig` to tra…
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