From 1ecdad689b6ffb423c4a1681620d6ea73a084683 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 4 May 2026 13:35:35 -0400 Subject: [PATCH] fix(site/src/pages/AgentsPage): restore sticky user message pinning after react-infinite-scroll-component refactor (#24937) Restores the sticky user message pinning behavior in the Agents chat that regressed after #24687 swapped the chat scroll container for `react-infinite-scroll-component`. ## Root cause `react-infinite-scroll-component` renders two wrapper divs between the `.overflow-y-auto` scroller and the rendered messages, and its inner wrapper hard-codes `overflow: auto` in its inline style. With the new layout, `position: sticky` on a user message resolved against that inner wrapper rather than the real scroller, so the message scrolled out with its sentinel and the existing fade/clip overlay never engaged. ## Fix Force both InfiniteScroll wrappers to `display: contents` so they no longer participate in layout. The user message's nearest scrolling ancestor is once again the `.overflow-y-auto` element, and `position: sticky` anchors to the scroll container as it did before #24687. The outer wrapper is reached via the Tailwind arbitrary selector `[&>[class$=outerdiv]]:contents` because the library only exposes `style` for the inner wrapper. The inverse infinite-scroll behavior is preserved: the scroller itself stays `flex-col-reverse`, so it remains bottom-anchored and the library's load-more sentinel still lands at the visual top of the content stack. Also drops the dead `overflow-y-auto` class on the floating scroll-to-bottom button wrapper noted in the bug report. ## Test coverage Adds `StickyUserMessagePinsOnScroll` to `AgentChatPageView.stories.tsx`. With a 40-message conversation it walks the user-message sentinels in reverse DOM order to find the one currently pinned (the latest sentinel above the scroller's top edge) and asserts the matching sticky container is anchored within a few pixels of that edge. Without the fix the container ends up hundreds of pixels above the scroller because `position: sticky` silently no-ops. The existing structural `StickyUserMessageStructure` story in `ConversationTimeline.stories.tsx` continues to pass unchanged.
Verification ```sh pnpm exec tsc -p . # 0 errors pnpm run lint:check # passes pnpm exec vitest run --project=unit # 2303 passed pnpm exec vitest run --project=storybook \ src/pages/AgentsPage/AgentChatPage.stories.tsx \ src/pages/AgentsPage/AgentChatPageView.stories.tsx \ src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx # 90 passed ``` Confirmed the new story fails on `main` (sticky container at the sentinel's position instead of the scroller top) and passes with the fix applied.
--- Generated by Coder Agents. --- .../AgentsPage/AgentChatPageView.stories.tsx | 81 +++++++++++++++++++ .../components/ChatScrollContainer.tsx | 21 ++++- 2 files changed, 99 insertions(+), 3 deletions(-) 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. +