From f44ce0bb5d06412b3ef0aab3d44593be6ca8fc79 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 14 Aug 2026 16:16:01 -0400 Subject: [PATCH] =?UTF-8?q?=E2=AC=87=EF=B8=8F=20fix:=20Keep=20Scroll-to-Bo?= =?UTF-8?q?ttom=20Clear=20of=20the=20In-Flight=20Steer=20Stack=20(#14844)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../components/Chat/Messages/MessagesView.tsx | 9 +++- .../components/Messages/ScrollToBottom.tsx | 48 ++++++++++-------- .../__tests__/ScrollToBottom.spec.tsx | 49 +++++++++++++++++++ 3 files changed, 85 insertions(+), 21 deletions(-) create mode 100644 client/src/components/Messages/__tests__/ScrollToBottom.spec.tsx diff --git a/client/src/components/Chat/Messages/MessagesView.tsx b/client/src/components/Chat/Messages/MessagesView.tsx index 71601b1da5..ead1879c36 100644 --- a/client/src/components/Chat/Messages/MessagesView.tsx +++ b/client/src/components/Chat/Messages/MessagesView.tsx @@ -28,11 +28,13 @@ const ScrollButton = memo(function ScrollButton({ messagesEndRef, scrollHandler, onNearBottomChange, + overlayHeight, }: { scrollableRef: React.RefObject; messagesEndRef: React.RefObject; scrollHandler: (event: React.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} > - + ); }); @@ -166,6 +172,7 @@ function MessagesViewContent({ messagesEndRef={messagesEndRef} scrollHandler={handleSmoothToRef} onNearBottomChange={handleNearBottomChange} + overlayHeight={steerOverlayHeight} /> diff --git a/client/src/components/Messages/ScrollToBottom.tsx b/client/src/components/Messages/ScrollToBottom.tsx index b6537a1493..d13add1593 100644 --- a/client/src/components/Messages/ScrollToBottom.tsx +++ b/client/src/components/Messages/ScrollToBottom.tsx @@ -7,30 +7,38 @@ import store from '~/store'; type Props = { scrollHandler: React.MouseEventHandler; + /** + * 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(({ scrollHandler }, ref) => { - const localize = useLocalize(); - const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace); +const ScrollToBottom = forwardRef( + ({ scrollHandler, overlayHeight = 0 }, ref) => { + const localize = useLocalize(); + const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace); - return ( -
- -
- ); -}); + + + ); + }, +); ScrollToBottom.displayName = 'ScrollToBottom'; diff --git a/client/src/components/Messages/__tests__/ScrollToBottom.spec.tsx b/client/src/components/Messages/__tests__/ScrollToBottom.spec.tsx new file mode 100644 index 0000000000..9d9e67a618 --- /dev/null +++ b/client/src/components/Messages/__tests__/ScrollToBottom.spec.tsx @@ -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(); + +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'); + }); +});