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}