fix(chat): stop Thinking stream from fighting chat scroll
Render a streaming Thinking block inline instead of inside a capped, independently-scrollable max-height box (the cap now applies only to finished thinking, for compact review). The nested scroll box was capturing the wheel and auto-pinning to its own bottom, so the chat could not be scrolled while thinking streamed. With it gone the chat's own auto-follow owns the scroll. Two auto-follow refinements make that solid: - Direction-aware bottom-zone re-engage: scrolling UP into the bottom spacer zone no longer re-arms follow (which the next growth would yank back). Follow resumes only when the user arrives at the bottom by scrolling down, is already following, or is at the true bottom. Kills the dead-zone fight near the bottom. - Animation guard: while a Thinking block COLLAPSE animation runs, transient geometry / trailing async scroll events are treated as our own and never trigger a false release. Genuine user gestures still release instantly.
This commit is contained in:
@@ -118,10 +118,14 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
||||
: expansion.expanded;
|
||||
const [shouldRenderExpandedContent, setShouldRenderExpandedContent] = React.useState(defaultExpanded === true || canAutoExpand);
|
||||
const contentId = React.useId();
|
||||
const scrollRef = React.useRef<HTMLElement>(null);
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
const contentAnimationRef = React.useRef<AnimationPlaybackControls | null>(null);
|
||||
const contentMountedRef = React.useRef(false);
|
||||
// Stable handle to onContentChange so the height-animation layout effect can
|
||||
// signal auto-follow without taking onContentChange as a dependency (which
|
||||
// would risk re-running — and thus restarting — the animation on re-render).
|
||||
const onContentChangeRef = React.useRef(onContentChange);
|
||||
onContentChangeRef.current = onContentChange;
|
||||
|
||||
const summary = React.useMemo(() => getReasoningSummary(text), [text]);
|
||||
const toggleAriaLabel = isExpanded
|
||||
@@ -160,12 +164,6 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
||||
onContentChange?.('structural');
|
||||
}, [onContentChange, text]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isStreaming && isExpanded && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [text, isStreaming, isExpanded]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isExpanded || isStreaming) {
|
||||
setShouldRenderExpandedContent(true);
|
||||
@@ -239,6 +237,11 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
||||
element.style.height = '0px';
|
||||
} else {
|
||||
element.style.height = `${element.scrollHeight}px`;
|
||||
// Only the COLLAPSE animation needs the guard: it shrinks the
|
||||
// timeline and the trailing async scroll events can be misread as a
|
||||
// user scroll-away. Expansion grows the timeline and re-pins cleanly,
|
||||
// and guarding it caused a faint scroll fight while thinking streams.
|
||||
onContentChangeRef.current?.('animation');
|
||||
}
|
||||
|
||||
const animation = animate(
|
||||
@@ -280,6 +283,27 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const reasoningBody = (
|
||||
<>
|
||||
<div data-message-text-export-source="true">
|
||||
<MarkdownRenderer
|
||||
content={text}
|
||||
messageId={blockId}
|
||||
isAnimated={false}
|
||||
isStreaming={isStreaming}
|
||||
variant="reasoning"
|
||||
/>
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="mt-2 mb-1 flex items-center justify-start gap-1.5" data-message-actions="true">
|
||||
<div className="flex items-center gap-1.5" data-message-action-group="true">
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-reasoning-block-id={blockId} data-message-text-export-root="true">
|
||||
<div
|
||||
@@ -379,32 +403,28 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
||||
className="pointer-events-none absolute left-0 top-0 bottom-0 w-px"
|
||||
style={{ backgroundColor: 'var(--tools-border)' }}
|
||||
/>
|
||||
<ScrollableOverlay
|
||||
ref={scrollRef}
|
||||
as="div"
|
||||
outerClassName="max-h-80"
|
||||
className="p-0"
|
||||
useScrollShadow
|
||||
scrollShadowSize={36}
|
||||
userIntentOnly
|
||||
>
|
||||
<div data-message-text-export-source="true">
|
||||
<MarkdownRenderer
|
||||
content={text}
|
||||
messageId={blockId}
|
||||
isAnimated={false}
|
||||
isStreaming={isStreaming}
|
||||
variant="reasoning"
|
||||
/>
|
||||
{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>
|
||||
{actions ? (
|
||||
<div className="mt-2 mb-1 flex items-center justify-start gap-1.5" data-message-actions="true">
|
||||
<div className="flex items-center gap-1.5" data-message-action-group="true">
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</ScrollableOverlay>
|
||||
) : (
|
||||
<ScrollableOverlay
|
||||
as="div"
|
||||
outerClassName="max-h-80"
|
||||
className="p-0"
|
||||
useScrollShadow
|
||||
scrollShadowSize={36}
|
||||
userIntentOnly
|
||||
>
|
||||
{reasoningBody}
|
||||
</ScrollableOverlay>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user