🎯 fix: Correct Off-by-One Rail Scrub After Pinning the Terminus (#14409)
Some checks failed
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Has been cancelled
GitNexus Index / index (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Helm Chart Tags / Ignore non-main push (push) Has been cancelled
Sync Helm Chart Tags / Sync chart tags (push) Has been cancelled
GitNexus Index / post-index (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled

Pinning the scroll-to-bottom rib moved it out of the column, but scrubTo
kept enumerating ribs from the nav (messages + terminus) while measuring
the fraction against the column, which now spans the messages alone. Every
drag position mapped one rib late: pointing at the middle of the rail
scrolled to the message below the rib under the cursor.

Enumerate the column's own ribs for the proportional mapping and reach the
terminus by dragging past the column's bottom edge, where it now sits.
This commit is contained in:
Danny Avila 2026-07-23 11:48:45 -04:00 committed by GitHub
parent 142973e7e8
commit 21dc4a2ef4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 72 additions and 5 deletions

View file

@ -528,16 +528,22 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
const scrubTo = useCallback(
(clientY: number) => {
const col = columnRef.current;
const nav = navRef.current;
if (!col || !nav) {
if (!col) {
return;
}
const ribs = nav.querySelectorAll<HTMLElement>('[data-msg-id]');
const rect = col.getBoundingClientRect();
/** The terminus is pinned below the column, so the pointer reaches it by
* travelling past the bottom edge the proportional mapping covers only
* the ribs the column actually spans, or every position lands one late. */
if (endEntry && clientY >= rect.bottom) {
scrollToImmediate(MESSAGES_END_ID);
return;
}
const ribs = col.querySelectorAll<HTMLElement>('[data-msg-id]');
const count = ribs.length;
if (count === 0) {
return;
}
const rect = col.getBoundingClientRect();
const fraction = rect.height > 0 ? (clientY - rect.top) / rect.height : 0;
const index = Math.max(0, Math.min(count - 1, Math.round(fraction * (count - 1))));
const id = ribs[index].getAttribute('data-msg-id');
@ -545,7 +551,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
scrollToImmediate(id);
}
},
[scrollToImmediate],
[scrollToImmediate, endEntry],
);
const handlePointerDown = useCallback(

View file

@ -1645,6 +1645,67 @@ describe('MessageNav', () => {
expect(column.scrollTop).toBe(13);
});
it('scrubs to the rib under the pointer, not one past it, with the terminus 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;
column.getBoundingClientRect = () => ({ top: 0, bottom: 50, height: 50 }) as DOMRect;
const getById = jest.spyOn(document, 'getElementById');
act(() => {
fireEvent.pointerDown(column, { pointerId: 1, button: 0, buttons: 1, clientY: 0 });
fireEvent.pointerMove(document, { pointerId: 1, buttons: 1, clientY: 25 });
});
const scrubbed = getById.mock.calls.map((c) => c[0]);
expect(scrubbed).toContain('m-2');
expect(scrubbed).not.toContain('m-3');
getById.mockRestore();
});
it('peaks the fisheye and preview on the rib under the pointer', () => {
const messages = Array.from({ length: 6 }, (_, i) =>
buildMessage({ messageId: `m-${i}`, text: `message ${i}` }),
);
const asRect = (top: number, height: number): DOMRect =>
({
top,
bottom: top + height,
height,
left: 200,
right: 214,
width: 14,
x: 200,
y: top,
toJSON: () => ({}),
}) as DOMRect;
/** Rib i occupies [i*12, i*12+6] — a 6px rib on a 6px gap. */
const rectSpy = jest
.spyOn(Element.prototype, 'getBoundingClientRect')
.mockImplementation(function (this: Element) {
const id = this.getAttribute?.('data-msg-id');
const index = id != null ? messages.findIndex((m) => m.messageId === id) : -1;
return index >= 0 ? asRect(index * 12, 6) : asRect(0, messages.length * 12);
});
const { container } = renderNavWithEnd(messages);
const column = container.querySelector('nav > div') as HTMLDivElement;
act(() => {
fireEvent.pointerMove(column, { pointerId: 1, clientY: 3 * 12 + 3 });
jest.advanceTimersByTime(80);
});
expect(document.body.querySelector('[role="tooltip"]')).toHaveTextContent('message 3');
const highlighted = Array.from(container.querySelectorAll('[data-msg-id]')).filter((r) =>
r.querySelector('span')?.className.includes('bg-gray-800'),
);
expect(highlighted.map((r) => r.getAttribute('data-msg-id'))).toEqual(['m-3']);
rectSpy.mockRestore();
});
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 }),