mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
📌 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
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:
parent
00c5a747e9
commit
30ae414911
2 changed files with 187 additions and 14 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue