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.
This commit is contained in:
@@ -239,6 +239,16 @@ describe('ReasoningPart streaming gating (issue #2020)', () => {
|
|||||||
expect(markup).toContain('aria-expanded="true"');
|
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', () => {
|
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
|
// 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
|
// reveal commits a first line. The header must read as busy and must not
|
||||||
|
|||||||
@@ -122,6 +122,59 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
|||||||
const contentAnimationRef = React.useRef<AnimationPlaybackControls | null>(null);
|
const contentAnimationRef = React.useRef<AnimationPlaybackControls | null>(null);
|
||||||
const contentMountedRef = React.useRef(false);
|
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<HTMLElement | null>(null);
|
||||||
|
const followBoxEndRef = React.useRef(true);
|
||||||
|
const touchStartYRef = React.useRef<number | null>(null);
|
||||||
|
const releaseBoxFollow = React.useCallback(() => {
|
||||||
|
followBoxEndRef.current = false;
|
||||||
|
}, []);
|
||||||
|
const handleBoxWheel = React.useCallback((event: React.WheelEvent<HTMLElement>) => {
|
||||||
|
if (event.deltaY < 0) releaseBoxFollow();
|
||||||
|
}, [releaseBoxFollow]);
|
||||||
|
const handleBoxTouchStart = React.useCallback((event: React.TouchEvent<HTMLElement>) => {
|
||||||
|
touchStartYRef.current = event.touches[0]?.clientY ?? null;
|
||||||
|
}, []);
|
||||||
|
const handleBoxTouchMove = React.useCallback((event: React.TouchEvent<HTMLElement>) => {
|
||||||
|
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<HTMLElement>) => {
|
||||||
|
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 summary = React.useMemo(() => getReasoningSummary(text), [text]);
|
||||||
const toggleAriaLabel = isExpanded
|
const toggleAriaLabel = isExpanded
|
||||||
? t('chat.reasoningTrace.collapseAria')
|
? t('chat.reasoningTrace.collapseAria')
|
||||||
@@ -389,28 +442,22 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
|||||||
className="pointer-events-none absolute left-0 top-0 bottom-0 w-px"
|
className="pointer-events-none absolute left-0 top-0 bottom-0 w-px"
|
||||||
style={{ backgroundColor: 'var(--tools-border)' }}
|
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).
|
|
||||||
<div className="p-0">
|
|
||||||
{reasoningBody}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ScrollableOverlay
|
<ScrollableOverlay
|
||||||
|
ref={scrollBoxRef}
|
||||||
as="div"
|
as="div"
|
||||||
outerClassName="max-h-80"
|
outerClassName="max-h-80"
|
||||||
className="p-0"
|
className="p-0"
|
||||||
useScrollShadow
|
useScrollShadow
|
||||||
scrollShadowSize={36}
|
scrollShadowSize={36}
|
||||||
userIntentOnly
|
userIntentOnly
|
||||||
|
data-scrollable="true"
|
||||||
|
onWheel={handleBoxWheel}
|
||||||
|
onTouchStart={handleBoxTouchStart}
|
||||||
|
onTouchMove={handleBoxTouchMove}
|
||||||
|
onScroll={handleBoxScroll}
|
||||||
>
|
>
|
||||||
{reasoningBody}
|
<div>{reasoningBody}</div>
|
||||||
</ScrollableOverlay>
|
</ScrollableOverlay>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
Reference in New Issue
Block a user