⬇️ 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:
Danny Avila 2026-08-14 16:16:01 -04:00 committed by GitHub
parent b0ed8524d4
commit f44ce0bb5d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 85 additions and 21 deletions

View file

@ -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} />

View file

@ -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';

View file

@ -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');
});
});