mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 12:13:30 +00:00
⬇️ fix: Keep Scroll-to-Bottom Clear of the In-Flight Steer Stack (#14844)
Both elements claim the same corner. `ScrollToBottom` is `bottom-5`, right-aligned, anchored to the message scroll region. `InFlightSteers` is `bottom-full`, right-aligned, stacking upward from the composer's top edge. They overlap at every breakpoint, and because they sit in different stacking contexts, which one paints on top depends on ancestor DOM order rather than intent. The reservation mechanism already exists: `InFlightSteers` measures itself into `steerOverlayHeightFamily` and `MessagesView` reads it to pad the thread so the newest message clears the overlay. The scroll button was never included. Thread that same height through and offset the button by it — no new state, no second measurement. Also gives the button a mobile gutter. Its width was `md:max-w-3xl` with no base value, so on a phone it escaped the message column and pinned to the viewport edge while the steer bubbles inset by 8px. `px-4 md:px-0` aligns it with the message content and leaves desktop untouched. The overlay is capped at `max-h-[35vh]`, so the button can rise at most a third of the screen.
This commit is contained in:
parent
b0ed8524d4
commit
f44ce0bb5d
3 changed files with 85 additions and 21 deletions
|
|
@ -28,11 +28,13 @@ const ScrollButton = memo(function ScrollButton({
|
|||
messagesEndRef,
|
||||
scrollHandler,
|
||||
onNearBottomChange,
|
||||
overlayHeight,
|
||||
}: {
|
||||
scrollableRef: React.RefObject<HTMLDivElement | null>;
|
||||
messagesEndRef: React.RefObject<HTMLDivElement | null>;
|
||||
scrollHandler: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
onNearBottomChange: (isNearBottom: boolean) => void;
|
||||
overlayHeight: number;
|
||||
}) {
|
||||
const scrollButtonPreference = useRecoilValue(store.showScrollButton);
|
||||
const [showScrollButton, setShowScrollButton] = useState(false);
|
||||
|
|
@ -75,7 +77,11 @@ const ScrollButton = memo(function ScrollButton({
|
|||
appear={true}
|
||||
nodeRef={scrollToBottomRef}
|
||||
>
|
||||
<ScrollToBottom ref={scrollToBottomRef} scrollHandler={scrollHandler} />
|
||||
<ScrollToBottom
|
||||
ref={scrollToBottomRef}
|
||||
scrollHandler={scrollHandler}
|
||||
overlayHeight={overlayHeight}
|
||||
/>
|
||||
</CSSTransition>
|
||||
);
|
||||
});
|
||||
|
|
@ -166,6 +172,7 @@ function MessagesViewContent({
|
|||
messagesEndRef={messagesEndRef}
|
||||
scrollHandler={handleSmoothToRef}
|
||||
onNearBottomChange={handleNearBottomChange}
|
||||
overlayHeight={steerOverlayHeight}
|
||||
/>
|
||||
|
||||
<MessageNav scrollableRef={scrollableRef} />
|
||||
|
|
|
|||
|
|
@ -7,30 +7,38 @@ import store from '~/store';
|
|||
|
||||
type Props = {
|
||||
scrollHandler: React.MouseEventHandler<HTMLButtonElement>;
|
||||
/**
|
||||
* Height of the in-flight steer overlay, which stacks upward from the
|
||||
* composer into this same corner. Lifts the button clear of it.
|
||||
*/
|
||||
overlayHeight?: number;
|
||||
};
|
||||
|
||||
const ScrollToBottom = forwardRef<HTMLDivElement, Props>(({ scrollHandler }, ref) => {
|
||||
const localize = useLocalize();
|
||||
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
|
||||
const ScrollToBottom = forwardRef<HTMLDivElement, Props>(
|
||||
({ scrollHandler, overlayHeight = 0 }, ref) => {
|
||||
const localize = useLocalize();
|
||||
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'pointer-events-none absolute bottom-5 left-0 right-0 mx-auto flex justify-end',
|
||||
maximizeChatSpace ? 'max-w-full' : 'md:max-w-3xl xl:max-w-4xl',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={scrollHandler}
|
||||
className="premium-scroll-button pointer-events-auto cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy"
|
||||
aria-label={localize('com_ui_scroll_to_bottom')}
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'pointer-events-none absolute left-0 right-0 mx-auto flex justify-end px-4 md:px-0',
|
||||
maximizeChatSpace ? 'max-w-full' : 'md:max-w-3xl xl:max-w-4xl',
|
||||
)}
|
||||
style={{ bottom: `calc(1.25rem + ${overlayHeight}px)` }}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4 text-text-secondary" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
<button
|
||||
onClick={scrollHandler}
|
||||
className="premium-scroll-button pointer-events-auto cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy"
|
||||
aria-label={localize('com_ui_scroll_to_bottom')}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4 text-text-secondary" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ScrollToBottom.displayName = 'ScrollToBottom';
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
jest.mock('recoil', () => ({
|
||||
useRecoilValue: () => false,
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
jest.mock('~/store', () => ({
|
||||
__esModule: true,
|
||||
default: { maximizeChatSpace: 'maximizeChatSpace' },
|
||||
}));
|
||||
|
||||
import ScrollToBottom from '../ScrollToBottom';
|
||||
|
||||
const renderButton = (overlayHeight?: number) =>
|
||||
render(<ScrollToBottom scrollHandler={jest.fn()} overlayHeight={overlayHeight} />);
|
||||
|
||||
const container = () => screen.getByLabelText('com_ui_scroll_to_bottom').parentElement;
|
||||
|
||||
describe('ScrollToBottom', () => {
|
||||
it('rests just above the composer when nothing is queued', () => {
|
||||
renderButton();
|
||||
|
||||
expect(container()).toHaveStyle({ bottom: 'calc(1.25rem + 0px)' });
|
||||
});
|
||||
|
||||
it('lifts clear of the in-flight steer overlay', () => {
|
||||
renderButton(96);
|
||||
|
||||
expect(container()).toHaveStyle({ bottom: 'calc(1.25rem + 96px)' });
|
||||
});
|
||||
|
||||
it('stays inside the message column on mobile', () => {
|
||||
renderButton();
|
||||
|
||||
/** Without a mobile gutter the button escapes to the viewport edge while
|
||||
* the steer bubbles inset, so the two visibly disagree. */
|
||||
expect(container()).toHaveClass('px-4', 'md:px-0');
|
||||
});
|
||||
|
||||
it('keeps the desktop width constraint', () => {
|
||||
renderButton();
|
||||
|
||||
expect(container()).toHaveClass('md:max-w-3xl', 'xl:max-w-4xl');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue