diff --git a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx index f8411e752c..29a302d3dd 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx @@ -1380,6 +1380,133 @@ export const StickyUserMessagePinsOnScroll: Story = { }, }; +// Tall user messages interleaved with verbose assistant replies. The height +// gives the sticky clip room to shrink as the transcript grows, and the +// volume overflows the 600px scroll decorator. +const buildTallStickyConversation = (count: number): TypesGen.ChatMessage[] => { + const messages: TypesGen.ChatMessage[] = []; + for (let i = 1; i <= count; i++) { + const role: TypesGen.ChatMessageRole = i % 2 === 1 ? "user" : "assistant"; + const text = + role === "user" + ? Array.from( + { length: 6 }, + (_, line) => + `Question ${Math.ceil(i / 2)} paragraph ${line + 1}: keep this user message tall enough to clip.`, + ).join("\n\n") + : `Detailed answer ${Math.floor(i / 2)}. `.repeat(12); + messages.push(buildMessage(i, role, text)); + } + return messages; +}; + +const stickyClipUpdateStore = buildStoreWithMessages( + buildTallStickyConversation(30), +); + +/** + * Regression guard: the sticky truncation must stay in sync as the + * transcript grows while the user is pinned to the bottom. + * + * The clip height is recomputed by a scroll handler, a window-resize + * handler, and a ResizeObserver on the transcript. The observer used to + * watch `scroller.firstElementChild`, which is the aria-hidden flex spacer + * that pins content to the bottom. That spacer collapses to 0px once the + * transcript overflows and then stops emitting resize callbacks, so several + * messages arriving while pinned left the clip stale until the next manual + * scroll and the bubble overflowed. The fix observes the real content + * wrapper tagged with `data-chat-scroll-content`. + * + * This story grows the transcript while pinned and asserts the clip tracks + * the new geometry without any scroll event. + */ +export const StickyUserMessageClipUpdatesWhilePinned: Story = { + parameters: { chromatic: { disableSnapshot: true } }, + decorators: scrollStoryDecorators, + render: () => , + play: async ({ canvasElement }) => { + stickyClipUpdateStore.replaceMessages(buildTallStickyConversation(30)); + stickyClipUpdateStore.setChatStatus("completed"); + const canvas = within(canvasElement); + const scrollContainer = canvas.getByTestId("scroll-container"); + + await waitForScrollOverflow(scrollContainer); + + // The observed transcript node must be the real content wrapper, not + // the aria-hidden flex spacer that collapses to 0px on overflow. + const contentMarker = scrollContainer.querySelector( + "[data-chat-scroll-content]", + ); + expect(contentMarker).not.toBeNull(); + const spacer = scrollContainer.firstElementChild; + expect(spacer).not.toBe(contentMarker); + expect(spacer?.getAttribute("aria-hidden")).toBe("true"); + + // Every sticky sentinel lives inside the observed content node, so a + // resize of that node reflects transcript growth. + const sentinels = scrollContainer.querySelectorAll("[data-user-sentinel]"); + expect(sentinels.length).toBeGreaterThan(0); + for (const sentinel of sentinels) { + expect(contentMarker?.contains(sentinel)).toBe(true); + } + + // At scrollTop 0 the newest message is pinned to the bottom. The most + // recent user message whose sentinel sits just above the top edge is + // the bubble pinned at the top and actively clipped. + const scrollerRect = scrollContainer.getBoundingClientRect(); + 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; + + const MIN_CLIP_HEIGHT = 72; + const readClip = () => + Number.parseFloat(pinnedContainer.style.getPropertyValue("--clip-h")) || + 0; + const measureScrolledPast = () => + scrollContainer.getBoundingClientRect().top - + pinnedSentinel.getBoundingClientRect().top; + const expectedClip = () => + Math.max( + pinnedContainer.offsetHeight - measureScrolledPast(), + MIN_CLIP_HEIGHT, + ); + + const scrolledPastBefore = measureScrolledPast(); + expect(scrolledPastBefore).toBeGreaterThan(4); + // Stay in the clipping regime (not a near-full-height bubble). + expect(pinnedContainer.offsetHeight).toBeLessThanOrEqual( + scrollContainer.clientHeight * 0.75, + ); + expect(scrollContainer.scrollTop).toBe(0); + + // Grow the transcript at the newest end. While pinned, scrollTop stays + // at 0 so no scroll event fires; only the content ResizeObserver can + // drive the recompute. + stickyClipUpdateStore.replaceMessages([ + ...getStoreMessages(stickyClipUpdateStore), + buildMessage(31, "assistant", "Freshly streamed reply. ".repeat(80)), + buildMessage(32, "assistant", "More freshly streamed reply. ".repeat(80)), + ]); + + // The pinned bubble is now further above the top edge. Its clip must + // follow the new geometry. Before the fix it stayed stale (matching + // the pre-growth scrolledPast) until a manual scroll. + await waitFor(() => { + expect(scrollContainer.scrollTop).toBe(0); + expect(measureScrolledPast()).toBeGreaterThan(scrolledPastBefore + 10); + expect(Math.abs(readClip() - expectedClip())).toBeLessThanOrEqual(2); + }); + }, +}; + /** * 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/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index 3013ff0d24..bcfa3d4a7a 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -894,7 +894,7 @@ export const AgentChatPageView: FC = ({ onFetchMoreMessages={onFetchMoreMessages} messageCount={messageCount} > -
+
{ + // Read the scroller geometry on each tick. Caching it goes + // stale when the scroller moves or resizes without a window + // resize (for example the composer growing), which skews the + // clip height and push-up math. + const scrollerTop = scroller.getBoundingClientRect().top; + const scrollerHeight = scroller.clientHeight; const fullHeight = container.offsetHeight; // Skip sticky behavior for messages that take up @@ -904,12 +907,6 @@ const StickyUserMessage = memo<{ }; updateFnRef.current = update; - const onResize = () => { - scrollerTop = scroller.getBoundingClientRect().top; - scrollerHeight = scroller.clientHeight; - update(); - }; - // Throttle to one update per animation frame so we don't // do redundant work on high-refresh-rate displays. let rafId: number | null = null; @@ -921,12 +918,21 @@ const StickyUserMessage = memo<{ }); }; - // Re-run the visual update when the scrollable content height - // changes (e.g. streaming responses growing the transcript). - // In flex-col-reverse, scrollTop stays at 0 when pinned to - // bottom so no scroll event fires — but the content wrapper - // resizes and this observer catches that. - const contentEl = scroller.firstElementChild as HTMLElement | null; + // Re-run the visual update when the transcript height changes, + // for example a streaming response or several messages arriving + // at once. In flex-col-reverse the scrollTop stays at 0 while + // pinned to the bottom, so no scroll event fires; observing the + // content wrapper catches that growth instead. + // + // The scroller's firstElementChild is the flex spacer that pins + // content to the bottom. It collapses to 0px once the transcript + // overflows and then stops emitting resize callbacks, which is + // exactly when truncation is active, so observe the real content + // node (an ancestor of the sentinel) and fall back to the spacer + // only when the marker is absent. + const contentEl = + sentinel.closest("[data-chat-scroll-content]") ?? + (scroller.firstElementChild as HTMLElement | null); let contentRafId: number | null = null; const contentObserver = contentEl ? new ResizeObserver(() => { @@ -940,7 +946,7 @@ const StickyUserMessage = memo<{ contentObserver?.observe(contentEl!); scroller.addEventListener("scroll", onScroll, { passive: true }); - window.addEventListener("resize", onResize); + window.addEventListener("resize", update); update(); // Set immediately — both --clip-h and --overlay-ready are // applied before the browser paints since we're in a @@ -948,7 +954,7 @@ const StickyUserMessage = memo<{ container.style.setProperty("--overlay-ready", "1"); return () => { scroller.removeEventListener("scroll", onScroll); - window.removeEventListener("resize", onResize); + window.removeEventListener("resize", update); contentObserver?.disconnect(); container.style.removeProperty("--overlay-ready"); if (rafId !== null) cancelAnimationFrame(rafId);