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:
Bohdan Triapitsyn
2026-06-30 03:05:33 +03:00
parent ea34ca4b92
commit 088a70fe5a
2 changed files with 106 additions and 41 deletions
@@ -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}