📌 fix: Pin Scroll-to-Bottom Rib in Message Nav (#14397)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run

* 📌 fix: Pin Scroll-to-Bottom Rib in Message Nav

Render the terminus rib outside the scrolling rail, between the column
and the down chevron, so the scroll-to-bottom affordance stays in view
no matter how far the rail has scrolled.

- scrubTo enumerates ribs from the nav so drag-to-bottom still lands on
  the terminus
- the pinned rib drives the shared preview itself on hover and focus,
  since it is no longer covered by the column's pointer magnification

* 🖱️ fix: Keep Drag-Scrub Startable From the Pinned Terminus

Pointer-down on the pinned rib no longer reaches the column's handler now
that it renders outside the scrollport, so wire the same drag-start to the
wrapper. Dragging up from the bottom dot scrubs the thread again.
This commit is contained in:
Danny Avila 2026-07-22 22:13:29 -04:00 committed by GitHub
parent 00c5a747e9
commit 30ae414911
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 187 additions and 14 deletions

View file

@ -313,6 +313,16 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
return map;
}, [entries]);
/** The terminus rib is pinned beside the down chevron rather than living in
* the scrolling column, so it stays reachable however far the rail scrolls. */
const { messageEntries, endEntry } = useMemo(() => {
const last = entries[entries.length - 1];
if (last?.isEnd === true) {
return { messageEntries: entries.slice(0, -1), endEntry: last };
}
return { messageEntries: entries, endEntry: null };
}, [entries]);
const getCurrentVisibleId = useCallback((): string | null => {
const container = scrollableRef.current;
if (!container) {
@ -518,10 +528,11 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
const scrubTo = useCallback(
(clientY: number) => {
const col = columnRef.current;
if (!col) {
const nav = navRef.current;
if (!col || !nav) {
return;
}
const ribs = col.querySelectorAll<HTMLElement>('[data-msg-id]');
const ribs = nav.querySelectorAll<HTMLElement>('[data-msg-id]');
const count = ribs.length;
if (count === 0) {
return;
@ -715,6 +726,38 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
[positionTip, revealTip],
);
/** The pinned terminus lives outside the column, so it drives the shared
* preview itself instead of through the rail's pointer magnification. */
const showEndTip = useCallback(
(el: HTMLElement) => {
const rect = el.getBoundingClientRect();
const left = columnRef.current?.getBoundingClientRect().left ?? rect.left;
focusTooltip(MESSAGES_END_ID, rect.top + rect.height / 2, window.innerWidth - left + 8);
},
[focusTooltip],
);
const handleEndPointerEnter = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => showEndTip(e.currentTarget),
[showEndTip],
);
const handleEndFocus = useCallback(
(e: React.FocusEvent<HTMLDivElement>) => showEndTip(e.currentTarget),
[showEndTip],
);
const handleEndBlur = useCallback(
(e: React.FocusEvent<HTMLDivElement>) => {
const next = e.relatedTarget as Node | null;
if (next && e.currentTarget.contains(next)) {
return;
}
clearTooltip();
},
[clearTooltip],
);
const applyMagnify = useCallback(() => {
magRafRef.current = null;
const col = columnRef.current;
@ -1209,9 +1252,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
return () => document.removeEventListener('keydown', onKeyDown);
}, [focusNav]);
const hasEnd = entries.length > 0 && entries[entries.length - 1].isEnd === true;
const messageCount = hasEnd ? entries.length - 1 : entries.length;
if (messageCount < 3) {
if (messageEntries.length < 3) {
return null;
}
@ -1252,15 +1293,11 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
className="flex min-h-0 w-14 cursor-pointer touch-none select-none flex-col items-stretch gap-1.5 overflow-y-auto [&::-webkit-scrollbar]:hidden"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{entries.map((entry) => {
const label = entry.isEnd
? localize('com_ui_scroll_to_bottom')
: localize(
entry.isUser
? 'com_ui_message_nav_go_to_user'
: 'com_ui_message_nav_go_to_assistant',
{ 0: entry.preview.slice(0, 30) },
);
{messageEntries.map((entry) => {
const label = localize(
entry.isUser ? 'com_ui_message_nav_go_to_user' : 'com_ui_message_nav_go_to_assistant',
{ 0: entry.preview.slice(0, 30) },
);
const isHighlighted =
hoveredId != null ? hoveredId === entry.id : visibleIds.has(entry.id);
return (
@ -1276,6 +1313,27 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
})}
</div>
{endEntry && (
<div
className="flex w-14 cursor-pointer touch-none select-none flex-col items-stretch"
onPointerDown={handlePointerDown}
onPointerEnter={handleEndPointerEnter}
onPointerLeave={clearTooltip}
onFocus={handleEndFocus}
onBlur={handleEndBlur}
>
<MessageIndicator
entry={endEntry}
isHighlighted={
hoveredId != null ? hoveredId === endEntry.id : visibleIds.has(endEntry.id)
}
isCurrent={currentId === endEntry.id}
onSelect={handleSelect}
label={localize('com_ui_scroll_to_bottom')}
/>
</div>
)}
<button
type="button"
onClick={jumpToNext}

View file

@ -1590,6 +1590,121 @@ describe('MessageNav', () => {
expect(ribs[3].getAttribute('aria-label')).toBe('com_ui_scroll_to_bottom');
});
it('pins the terminus outside the scrolling column, between it and the next chevron', () => {
const { container } = renderNavWithEnd(threeMessages());
const nav = container.querySelector('nav') as HTMLElement;
const column = container.querySelector('nav > div') as HTMLDivElement;
expect(column.querySelector('[data-msg-id="messages-end"]')).toBeNull();
expect(nav.querySelector('[data-msg-id="messages-end"]')).not.toBeNull();
const kids = Array.from(nav.children);
const endIndex = kids.findIndex((k) => k.querySelector('[data-msg-id="messages-end"]'));
const nextIndex = kids.findIndex(
(k) => k.getAttribute('aria-label') === 'com_ui_message_nav_next',
);
expect(endIndex).toBe(kids.indexOf(column) + 1);
expect(nextIndex).toBe(endIndex + 1);
});
it('keeps column children aligned with message entries once the terminus is pinned', () => {
const messages = Array.from({ length: 5 }, (_, i) =>
buildMessage({ messageId: `m-${i}`, text: `message ${i}`, isCreatedByUser: i % 2 === 0 }),
);
const { container } = renderNavWithEnd(messages);
const column = container.querySelector('nav > div') as HTMLDivElement;
expect(column.children).toHaveLength(messages.length);
for (let i = 0; i < messages.length; i++) {
expect(column.children[i].getAttribute('data-msg-id')).toBe(`m-${i}`);
}
});
it('centers the column on the visible window using the pinned-out child indices', () => {
const messages = Array.from({ length: 10 }, (_, i) =>
buildMessage({ messageId: `m-${i}`, text: `message ${i}`, isCreatedByUser: i % 2 === 0 }),
);
const { container, scrollable } = renderNavWithEnd(messages);
const column = container.querySelector('nav > div') as HTMLDivElement;
Object.defineProperty(column, 'clientHeight', { value: 30, configurable: true });
Object.defineProperty(column, 'scrollHeight', { value: 200, configurable: true });
Object.defineProperty(column, 'scrollTop', { value: 0, writable: true, configurable: true });
for (let i = 0; i < column.children.length; i++) {
Object.defineProperty(column.children[i], 'offsetTop', { value: i * 10 });
Object.defineProperty(column.children[i], 'offsetHeight', { value: 6 });
}
(scrollable as HTMLElement).scrollTop = 400;
act(() => {
fireEvent.scroll(scrollable);
jest.advanceTimersByTime(32);
});
/** Visible rows m-1..m-4 → mid of ribs 1 and 4, minus half the column height. */
expect(column.scrollTop).toBe(13);
});
it('starts a scrub drag from the pinned terminus', () => {
const messages = Array.from({ length: 5 }, (_, i) =>
buildMessage({ messageId: `m-${i}`, text: `message ${i}`, isCreatedByUser: i % 2 === 0 }),
);
const { container, scrollable } = renderNavWithEnd(messages);
const column = container.querySelector('nav > div') as HTMLDivElement;
column.getBoundingClientRect = () => ({ top: 0, bottom: 50, height: 50 }) as DOMRect;
const wrapper = container.querySelector('[data-msg-id="messages-end"]')!
.parentElement as HTMLElement;
const getById = jest.spyOn(document, 'getElementById');
act(() => {
fireEvent.pointerDown(wrapper, { pointerId: 1, button: 0, buttons: 1, clientY: 60 });
fireEvent.pointerMove(document, { pointerId: 1, buttons: 1, clientY: 0 });
});
expect(getById.mock.calls.map((c) => c[0])).toContain('m-0');
getById.mockRestore();
expect(scrollable).toBeDefined();
});
it('previews the terminus on hover even though it sits outside the column', () => {
const { container } = renderNavWithEnd(threeMessages());
const wrapper = container.querySelector('[data-msg-id="messages-end"]')!
.parentElement as HTMLElement;
act(() => {
fireEvent.pointerEnter(wrapper, { pointerId: 1, clientY: 5 });
jest.advanceTimersByTime(80);
});
expect(document.body.querySelector('[role="tooltip"]')).toHaveTextContent(
'com_ui_scroll_to_bottom',
);
act(() => {
fireEvent.pointerLeave(wrapper, { pointerId: 1 });
});
expect(document.body.querySelector('[role="tooltip"]')).toBeNull();
});
it('previews the terminus when it takes keyboard focus', () => {
const { container } = renderNavWithEnd(threeMessages());
const endRib = container.querySelector('[data-msg-id="messages-end"]') as HTMLElement;
act(() => {
endRib.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
jest.advanceTimersByTime(80);
});
expect(document.body.querySelector('[role="tooltip"]')).toHaveTextContent(
'com_ui_scroll_to_bottom',
);
act(() => {
endRib.dispatchEvent(
new FocusEvent('focusout', { bubbles: true, relatedTarget: document.body }),
);
});
expect(document.body.querySelector('[role="tooltip"]')).toBeNull();
});
it('omits the terminus indicator and the nav when there are fewer than 3 messages', () => {
const messages = [
buildMessage({ messageId: 'a', text: 'alpha', isCreatedByUser: true }),