From 1cfd1c1a4489b96defb1cea79fc8561cf5dc2a24 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 10 Oct 2026 17:59:23 -0700 Subject: [PATCH] fix(web): toggling tools and thinking at the bottom keeps you at the bottom (#17954) Co-authored-by: Claude Opus 5.5 --- .../components/chat/MessagesTimeline.test.tsx | 71 ++++++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 74 +++++++++++++------ 2 files changed, 124 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 199cdfcbdf62dc43130c56c71fee7c242863378a..1ceb55060608e2c1ec4bd4d8e5e908063da9bf77 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -670,6 +670,77 @@ describe("MessagesTimeline", () => { }, ); + it.each([true, false])( + "keeps the end in view when tool output toggles only at the end: %s", + async (isAtEnd) => { + const frames = new Map(); + let nextFrame = 0; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + frames.set(++nextFrame, callback); + return nextFrame; + }); + vi.stubGlobal("cancelAnimationFrame", (frame: number) => frames.delete(frame)); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const flushFrame = () => + act(() => { + const callbacks = [...frames.values()]; + frames.clear(); + callbacks.forEach((callback) => callback(0)); + }); + const viewport = { scrollTop: 400, scrollHeight: 1000 }; + const props = buildProps(); + props.listRef.current = { + getState: () => ({ isAtEnd }), + getScrollableNode: () => viewport, + } as unknown as LegendListRef; + let renderer: ReactTestRenderer | undefined; + try { + await act(() => { + renderer = create( + , + ); + }); + const toggle = renderer!.root.findByProps({ "aria-expanded": false }); + const list = renderer!.root.find( + (node) => node.props.ListFooterComponent !== undefined && node.props.onItemSizeChanged, + ); + for (const scrollHeight of [1300, 900]) { + await act(() => toggle.props.onClick()); + // The rows grow or shrink once the list measures them. + viewport.scrollHeight = scrollHeight; + await flushFrame(); + expect(viewport.scrollTop).toBe(isAtEnd ? scrollHeight : 400); + // Tool output that renders late is pinned before the next frame. + viewport.scrollHeight = scrollHeight + 200; + await act(() => list.props.onItemSizeChanged()); + expect(viewport.scrollTop).toBe(isAtEnd ? scrollHeight + 200 : 400); + await flushFrame(); + await flushFrame(); + viewport.scrollTop = 400; + } + } finally { + await act(() => renderer?.unmount()); + } + }, + ); + it("preserves arbitrary XML-like tags and comparisons in rendered user messages", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 165dbdaea146c1d91fe0a6da1cf0efc0e7e43362..ad52b716aa212ea4504a93667e077d706fdb0238 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -718,34 +718,59 @@ const ConversationTimeline = memo(function ConversationTimeline({ }; }, [settlingListIdentity]); + // A toggle made at the end keeps the end in view while rows re-measure. + // Anywhere else, the toggled row holds its place. + const disclosurePinsEndRef = useRef(false); + const disclosureCollapsedRef = useRef(false); + const pinDisclosureToEnd = useCallback(() => { + if (!disclosurePinsEndRef.current) return; + const element = listRef.current?.getScrollableNode(); + if (element) element.scrollTop = element.scrollHeight; + }, [listRef]); + + // Settles two frames after the last toggle, or after the last row resize + // while pinned to the end, since tool output can render a few frames late. + const scheduleDisclosureSettle = useCallback(() => { + if (disclosureSettleFrameRef.current !== null) { + cancelAnimationFrame(disclosureSettleFrameRef.current); + } + if (disclosureSettleSecondFrameRef.current !== null) { + cancelAnimationFrame(disclosureSettleSecondFrameRef.current); + } + disclosureSettleFrameRef.current = requestAnimationFrame(() => { + pinDisclosureToEnd(); + disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => { + pinDisclosureToEnd(); + disclosurePinsEndRef.current = false; + disclosureAnchorKeyRef.current = null; + setDisclosureToggleSettling(false); + disclosureSettleFrameRef.current = null; + disclosureSettleSecondFrameRef.current = null; + // Wait for row measurement and the disclosure click's blur check. + // Closing output can reveal the end without a scroll event. + if ( + disclosureCollapsedRef.current && + resolveTimelineIsAtEnd(listRef.current?.getState()) === true + ) { + onToolOutputCollapsedAtEnd?.(); + } + }); + }); + }, [listRef, onToolOutputCollapsedAtEnd, pinDisclosureToEnd]); + const suspendEndScrollMaintenanceForDisclosure = useCallback( (anchorKey: string, collapsed = false) => { disclosureAnchorKeyRef.current = anchorKey; + disclosureCollapsedRef.current = collapsed; + disclosurePinsEndRef.current = resolveTimelineIsAtEnd(listRef.current?.getState()) === true; setDisclosureToggleSettling(true); - if (disclosureSettleFrameRef.current !== null) { - cancelAnimationFrame(disclosureSettleFrameRef.current); - } - if (disclosureSettleSecondFrameRef.current !== null) { - cancelAnimationFrame(disclosureSettleSecondFrameRef.current); - } - disclosureSettleFrameRef.current = requestAnimationFrame(() => { - disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => { - disclosureAnchorKeyRef.current = null; - setDisclosureToggleSettling(false); - disclosureSettleFrameRef.current = null; - disclosureSettleSecondFrameRef.current = null; - // Wait for row measurement and the disclosure click's blur check. - // Closing output can reveal the end without a scroll event. - if (collapsed && resolveTimelineIsAtEnd(listRef.current?.getState()) === true) { - onToolOutputCollapsedAtEnd?.(); - } - }); - }); + scheduleDisclosureSettle(); }, - [listRef, onToolOutputCollapsedAtEnd], + [listRef, scheduleDisclosureSettle], ); const shouldRestoreVisibleContentPosition = useCallback((row: MessagesTimelineRow) => { + if (disclosurePinsEndRef.current) return false; const disclosureAnchorKey = disclosureAnchorKeyRef.current; return disclosureAnchorKey === null || row.id === disclosureAnchorKey; }, []); @@ -1165,6 +1190,13 @@ const ConversationTimeline = memo(function ConversationTimeline({ onContentOverflowChange(measureContentOverflow()); }); }, [measureContentOverflow, onContentOverflowChange]); + const handleItemSizeChanged = useCallback(() => { + if (disclosurePinsEndRef.current) { + queueMicrotask(pinDisclosureToEnd); + scheduleDisclosureSettle(); + } + reportContentOverflow(); + }, [pinDisclosureToEnd, reportContentOverflow, scheduleDisclosureSettle]); useEffect(() => cancelContentOverflowFrame, [cancelContentOverflowFrame]); // The list's own layout effects have already run here, so estimated row // positions are in place. Reporting before the first paint lets a thread @@ -1615,7 +1647,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ } maintainScrollAtEndThreshold={1} onScroll={handleScroll} - onItemSizeChanged={reportContentOverflow} + onItemSizeChanged={handleItemSizeChanged} className={cn( "messages-timeline-scroll scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain [overflow-anchor:none]", topFadeEnabled && "topbar-scroll-fade", -- 2.54.0