From ec089b34e82130be910d8ce6a55d9127b653a13d Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 7 Sep 2026 17:57:56 +0300 Subject: [PATCH] fix(chat): keep streaming thinking inside its capped scroll box A streaming Thinking block grew without a height cap and only moved into the capped, scrollable box once it finished, so a long thought pushed the whole timeline down while it streamed. The cap was dropped in June because the box then pinned to its own end on every tick and captured the wheel, so the chat could not be scrolled while thinking streamed. The box is capped in every state now and follows its own end only while streaming and only until the reader wheels or drags upward inside it; returning to its end re-arms the follow. It is marked as a nested scroller, so an upward wheel scrolls the box first and reaches the chat once the box sits at its top. Growth is observed on the content box because markdown commits asynchronously. Testing: reasoning block tests extended for the capped, nested box while streaming; ui type-check and lint; web build. Live reasoning stream not exercised in a browser. --- .../chat/message/parts/ReasoningPart.test.tsx | 10 ++ .../chat/message/parts/ReasoningPart.tsx | 91 ++++++++++++++----- 2 files changed, 79 insertions(+), 22 deletions(-) diff --git a/packages/ui/src/components/chat/message/parts/ReasoningPart.test.tsx b/packages/ui/src/components/chat/message/parts/ReasoningPart.test.tsx index 32903b9f..590a879d 100644 --- a/packages/ui/src/components/chat/message/parts/ReasoningPart.test.tsx +++ b/packages/ui/src/components/chat/message/parts/ReasoningPart.test.tsx @@ -239,6 +239,16 @@ describe('ReasoningPart streaming gating (issue #2020)', () => { expect(markup).toContain('aria-expanded="true"'); }); + test('streaming reasoning stays inside the capped nested scroll box', () => { + // The box is capped while streaming too, so a long thought scrolls inside + // its own box instead of growing the timeline; it is marked as a nested + // scroller so an upward wheel over it scrolls the box before the chat. + const markup = renderPart(makeReasoningPart({ start: 1_000 }), 'streaming'); + + expect(markup).toContain('max-h-80'); + expect(markup).toContain('data-scrollable="true"'); + }); + test('a live part with no committed text yet shows the busy header and no empty summary', () => { // The streaming early-return keeps the block mounted before the block-level // reveal commits a first line. The header must read as busy and must not diff --git a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx index 169c0eb7..d080be23 100644 --- a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx @@ -122,6 +122,59 @@ export const ReasoningTimelineBlock: React.FC = ({ const contentAnimationRef = React.useRef(null); const contentMountedRef = React.useRef(false); + // The thinking body lives in a capped scroll box in every state. While it + // streams, the box follows its own end so the newest thought stays in + // view without growing the timeline; a wheel or drag upward inside the + // box hands the box to the reader, and returning to its end re-arms the + // follow. The chat's own end-follow is unaffected: the box keeps a fixed + // height once capped, so the timeline stops growing underneath it, and an + // upward wheel over the box scrolls the box first (it is a nested + // scroller) and only reaches the chat once the box sits at its top. + const scrollBoxRef = React.useRef(null); + const followBoxEndRef = React.useRef(true); + const touchStartYRef = React.useRef(null); + const releaseBoxFollow = React.useCallback(() => { + followBoxEndRef.current = false; + }, []); + const handleBoxWheel = React.useCallback((event: React.WheelEvent) => { + if (event.deltaY < 0) releaseBoxFollow(); + }, [releaseBoxFollow]); + const handleBoxTouchStart = React.useCallback((event: React.TouchEvent) => { + touchStartYRef.current = event.touches[0]?.clientY ?? null; + }, []); + const handleBoxTouchMove = React.useCallback((event: React.TouchEvent) => { + const startY = touchStartYRef.current; + const touch = event.touches[0]; + if (startY === null || !touch) return; + // A downward finger drags the content up: the reader wants history. + if (touch.clientY > startY + 4) releaseBoxFollow(); + }, [releaseBoxFollow]); + const handleBoxScroll = React.useCallback((event: React.UIEvent) => { + const node = event.currentTarget; + const distanceToEnd = node.scrollHeight - node.clientHeight - node.scrollTop; + if (distanceToEnd <= 2) followBoxEndRef.current = true; + }, []); + + React.useEffect(() => { + if (!isStreaming) return; + followBoxEndRef.current = true; + const node = scrollBoxRef.current; + if (!node || !globalThis.ResizeObserver) return; + const content = node.firstElementChild; + if (!content) return; + const follow = () => { + if (!followBoxEndRef.current) return; + const end = node.scrollHeight - node.clientHeight; + if (end - node.scrollTop > 1) node.scrollTop = end; + }; + // Growth lands asynchronously (markdown commits off the render pass), + // so the content box is observed rather than the text prop. + const observer = new ResizeObserver(follow); + observer.observe(content); + follow(); + return () => observer.disconnect(); + }, [isStreaming, shouldRenderExpandedContent]); + const summary = React.useMemo(() => getReasoningSummary(text), [text]); const toggleAriaLabel = isExpanded ? t('chat.reasoningTrace.collapseAria') @@ -389,28 +442,22 @@ export const ReasoningTimelineBlock: React.FC = ({ className="pointer-events-none absolute left-0 top-0 bottom-0 w-px" style={{ backgroundColor: 'var(--tools-border)' }} /> - {isStreaming ? ( - // While streaming, let the thinking grow inline — no - // capped, independently-scrollable box. The chat's own - // auto-follow then handles following / releasing, so the - // box never captures the wheel or fights the user's - // scroll. The max-height scroll box is applied only once - // the thinking has finished (the branch below). -
- {reasoningBody} -
- ) : ( - - {reasoningBody} - - )} + +
{reasoningBody}
+
) : null}