diff --git a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx index 8fcbe2944c..a3b7a2795d 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx @@ -954,6 +954,87 @@ export const MessageOrderIsStillCorrect: Story = { }, }; +const stickyPinningStore = buildStoreWithMessages(buildLongConversation(40)); + +/** + * Regression guard for the StickyUserMessage push-up logic. + * + * `react-infinite-scroll-component` renders two wrapper divs between the + * scroll container and the message tree. The library applies `overflow: + * auto` to its inner wrapper, which used to make `position: sticky` on a + * user message resolve against that wrapper instead of the actual scroller. + * The fix forces both wrappers to `display: contents` so the sticky + * container's nearest scrolling ancestor is once again the + * `.overflow-y-auto` element. + * + * This story scrolls past the most recent user message and asserts the + * message is pinned within a few pixels of the scroll container's top. + */ +export const StickyUserMessagePinsOnScroll: Story = { + parameters: { chromatic: { disableSnapshot: true } }, + decorators: scrollStoryDecorators, + render: () => , + play: async ({ canvasElement }) => { + resetScrollStoryStore(stickyPinningStore, 40); + const canvas = within(canvasElement); + const scrollContainer = canvas.getByTestId("scroll-container"); + + await waitForScrollOverflow(scrollContainer); + + // Each sticky user message is the element immediately following its + // `data-user-sentinel` marker. The push-up logic depends on the + // sticky container resolving against the real scroll container, + // which is the regression this story guards against. + const sentinels = scrollContainer.querySelectorAll("[data-user-sentinel]"); + expect(sentinels.length).toBeGreaterThan(0); + for (const sentinel of sentinels) { + expect(sentinel.closest("[data-testid='scroll-container']")).toBe( + scrollContainer, + ); + const container = sentinel.nextElementSibling; + expect(container).not.toBeNull(); + expect(window.getComputedStyle(container as Element).position).toBe( + "sticky", + ); + } + + // At the default `scrollTop = 0`, the inverse layout shows the + // newest messages at the bottom of the viewport. Older user + // messages whose sentinels have already scrolled above the + // scroller's top edge should be pinned by `position: sticky`. Pick + // a sentinel that is comfortably above the top edge so a tiny + // scroll offset cannot flip it on or off the boundary. + const scrollerRect = scrollContainer.getBoundingClientRect(); + // Walk the sentinels in reverse DOM order so we land on the + // most recent user message whose sentinel has scrolled above + // the scroll container's top edge. That is the message the + // push-up logic actively pins at the top; earlier pinned + // messages will have been pushed out of view by it. + const pinnedSentinel = Array.from(sentinels) + .reverse() + .find( + (sentinel) => + sentinel.getBoundingClientRect().top < scrollerRect.top - 4, + ) as HTMLElement | undefined; + expect(pinnedSentinel).toBeDefined(); + if (!pinnedSentinel) { + return; + } + const pinnedContainer = pinnedSentinel.nextElementSibling as HTMLElement; + + // `position: sticky` should pin the user message container near + // the scroll container's top edge while the assistant response + // below it is on screen. Before the fix, the sticky container + // resolved against the InfiniteScroll wrapper rather than the + // real scroll container, so it scrolled out with its sentinel + // and ended up far above the viewport. + const pinnedRect = pinnedContainer.getBoundingClientRect(); + expect(window.getComputedStyle(pinnedContainer).position).toBe("sticky"); + expect(pinnedRect.top - scrollerRect.top).toBeGreaterThanOrEqual(-1); + expect(pinnedRect.top - scrollerRect.top).toBeLessThan(40); + }, +}; + /** * Selecting the Terminal tab in the sidebar must move keyboard focus into * the terminal so typing goes there, not the chat input. diff --git a/site/src/pages/AgentsPage/components/ChatScrollContainer.tsx b/site/src/pages/AgentsPage/components/ChatScrollContainer.tsx index 03062cfe34..b3c8c39dc4 100644 --- a/site/src/pages/AgentsPage/components/ChatScrollContainer.tsx +++ b/site/src/pages/AgentsPage/components/ChatScrollContainer.tsx @@ -74,7 +74,9 @@ const ScrollToBottomButton: FC<{ }; return ( -
+ // Floating overlay above the scroll container. The button has its own + // fixed-size box so the wrapper does not need overflow handling. +