From 94700e8574488eae2f1e183980a7366f5f8bf611 Mon Sep 17 00:00:00 2001 From: Melvyn Claes Date: Wed, 28 Oct 2026 09:31:22 +0100 Subject: [PATCH 1/2] fix(web): scroll-to-end pill no longer jitters after scrolling up The timeline's list ref callback got a new identity on every render, so React detached and re-attached the list each time. Detaching reports "no overflow", which pulled the composer out of its resting layout; the next measurement put it back, and the pill, positioned from the composer's height, jumped with every flip. The ref callback is now stable, so the list attaches once per instance. A layout effect keeps the per-render content-size check the re-attach used to run, so overflow and thread settling still re-measure. Fixes #26338 Co-Authored-By: Claude Opus 5.5 --- .../chat/MessagesTimeline.browser.test.tsx | 23 +++++++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 12 ++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 2c7aa332756cb..dccb8136164e1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -345,6 +345,29 @@ describe("MessagesTimeline messages", () => { expect(turns).toEqual(turns.toSorted((a, b) => a - b)); }); + it("keeps reporting overflow while a parent re-renders it", async () => { + setup(); + const props = baseProps(); + const onContentOverflowChange = vi.fn(); + const entries = Array.from({ length: 40 }, (_, index) => + message(`Turn ${index}:\n\n${"x ".repeat(200)}`, {}, `turn-${index}`), + ); + const element = () => ( + {}} + /> + ); + await using screen = await renderElement(element()); + await vi.waitFor(() => expect(onContentOverflowChange).toHaveBeenLastCalledWith(true)); + onContentOverflowChange.mockClear(); + + await screen.rerender(element()); + expect(onContentOverflowChange).not.toHaveBeenCalledWith(false); + }); + it("preserves XML-like tags and comparisons in user messages as text", async () => { const source = [ 'Without reading a file, do you have ', diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index c7a38de8fbed6..fdc108cf24f75 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1144,7 +1144,9 @@ const ConversationTimeline = memo(function ConversationTimeline({ const canvas = useChatCanvas(); const registerTimeline = canvas?.registerTimeline; - const setTimelineList = (list: LegendListRef | null) => { + const scheduleContentSizeChangedRef = useRef<(() => void) | null>(null); + // A new ref callback identity detaches the list, and detaching reports no overflow. + const setTimelineList = useStableCallback((list: LegendListRef | null) => { listRef.current = list; const scrollNode: HTMLElement | null = list?.getScrollableNode() ?? null; registerTimeline?.(scrollNode); @@ -1171,8 +1173,10 @@ const ConversationTimeline = memo(function ConversationTimeline({ const stopListeningToContentSize = list ?.getState() .listen("totalSize", () => scheduleContentSizeChanged()); + scheduleContentSizeChangedRef.current = scheduleContentSizeChanged; scheduleContentSizeChanged(); return () => { + scheduleContentSizeChangedRef.current = null; stopListeningToContentSize?.(); stopRememberingRelease?.(); onContentOverflowChange?.(false); @@ -1190,7 +1194,11 @@ const ConversationTimeline = memo(function ConversationTimeline({ listRef.current = null; registerTimeline?.(null); }; - }; + }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- re-measures overflow and thread settling after renders that change rows or the composer inset without resizing the content + useLayoutEffect(() => { + scheduleContentSizeChangedRef.current?.(); + }); useThreadFindPositionReader({ threadKey: listIdentityKey, From 15b075e1260f107a3587c79fd60476a235f77eef Mon Sep 17 00:00:00 2001 From: Melvyn Claes Date: Wed, 28 Oct 2026 09:44:44 +0100 Subject: [PATCH 2/2] test(web): overflow test waits for the re-measure after a re-render Co-Authored-By: Claude Opus 5.5 --- .../src/components/chat/MessagesTimeline.browser.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index dccb8136164e1..aa32fb0e4ba07 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -365,7 +365,10 @@ describe("MessagesTimeline messages", () => { onContentOverflowChange.mockClear(); await screen.rerender(element()); - expect(onContentOverflowChange).not.toHaveBeenCalledWith(false); + await vi.waitFor(() => { + expect(onContentOverflowChange).toHaveBeenLastCalledWith(true); + expect(onContentOverflowChange).not.toHaveBeenCalledWith(false); + }); }); it("preserves XML-like tags and comparisons in user messages as text", async () => {