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}
+54 -9
View File
@@ -6,7 +6,7 @@ import { useViewportStore } from '@/sync/viewport-store';
type AutoFollowState = 'following' | 'released';
export type ContentChangeReason = 'text' | 'structural' | 'permission';
export type ContentChangeReason = 'text' | 'structural' | 'permission' | 'animation';
export interface AnimationHandlers {
onChunk: () => void;
@@ -76,6 +76,17 @@ const TOUCH_FINGER_DOWN_THRESHOLD = 2;
// a user scroll.
const AUTO_MARK_TTL_MS = 1500;
const AUTO_MATCH_TOLERANCE_PX = 2;
// While a tracked height animation runs (e.g. a Thinking block auto-collapsing
// mid-stream), the timeline shrinks/grows over a couple hundred ms and the
// virtualizer re-measures, producing transient geometry. Browsers dispatch the
// resulting `scroll` events asynchronously, so a stale event can land after we
// have already re-pinned — its position matching neither the bottom zone nor the
// freshly-moved auto marker — and be misread as a user scroll-away. During this
// guard window we treat any `following`-state scroll event as our own and never
// release via the heuristic. GENUINE user gestures still release instantly
// through releaseFromUserIntent, so this is not glue. Sized to the reasoning
// animation (200ms) plus headroom for trailing async scroll events.
const ANIMATION_GUARD_MS = 350;
// After streaming stops, keep following the bottom for a short window so the
// final content can settle into place.
const SETTLE_MS = 300;
@@ -184,6 +195,15 @@ export const useChatAutoFollow = ({
const autoRef = React.useRef<{ top: number; time: number } | null>(null);
const autoTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
// Timestamp until which a tracked height animation is in flight (see
// ANIMATION_GUARD_MS). 0 = no animation guard active.
const animationGuardUntilRef = React.useRef(0);
// Last observed scrollTop, used to derive scroll DIRECTION in the scroll
// handler so the bottom-zone re-engage only fires when arriving at the bottom
// by scrolling down — never when a user scrolling UP merely lands in the zone.
const lastScrollTopRef = React.useRef(0);
// Entry-stick window state (see ENTRY_STICK_* above).
const entryStickRef = React.useRef(false);
const entryStickQuietTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -251,6 +271,10 @@ export const useChatAutoFollow = ({
return Math.abs(el.scrollTop - a.top) < AUTO_MATCH_TOLERANCE_PX;
}, []);
const isAnimationGuardActive = React.useCallback((): boolean => {
return now() < animationGuardUntilRef.current;
}, []);
// ── entry-stick window ───────────────────────────────────────────────────
const endEntryStick = React.useCallback(() => {
entryStickRef.current = false;
@@ -522,6 +546,10 @@ export const useChatAutoFollow = ({
const el = scrollRef.current;
if (!el) return;
const previousTop = lastScrollTopRef.current;
lastScrollTopRef.current = el.scrollTop;
const scrollingDown = el.scrollTop > previousTop + 0.5;
updateOverflowAndButton();
if (!canScroll(el)) {
@@ -530,16 +558,25 @@ export const useChatAutoFollow = ({
}
// Within the bottom zone → (re-)pin to following. This is how scrolling
// back down to the bottom resumes auto-follow.
// back DOWN to the bottom resumes auto-follow. Crucially, re-engage only
// when the user arrives by scrolling down (or is already following, or is
// essentially at the true bottom). A user scrolling UP that merely lands
// in the bottom spacer zone must NOT be yanked back into follow — that is
// the dead-zone fight that made small upward scrolls impossible while
// content streams.
if (isNearBottom(el, isMobileRef.current)) {
setStateValue('following');
const atTrueBottom = distanceFromBottom(el) <= AUTO_MATCH_TOLERANCE_PX;
if (scrollingDown || stateRef.current === 'following' || atTrueBottom) {
setStateValue('following');
}
queueSave();
return;
}
// Our own programmatic write that landed at the bottom but where content
// grew between the write and this event — keep following, don't release.
if (stateRef.current === 'following' && isAuto(el)) {
// Our own geometry change (a programmatic write that landed at the bottom
// but where content grew between the write and this event, OR a tracked
// height animation in flight) — keep following, don't release.
if (stateRef.current === 'following' && (isAuto(el) || isAnimationGuardActive())) {
scrollToBottom(false);
queueSave();
return;
@@ -548,12 +585,14 @@ export const useChatAutoFollow = ({
// Genuine user scroll away from the bottom.
stop();
queueSave();
}, [isAuto, queueSave, scrollToBottom, setStateValue, stop, updateOverflowAndButton]);
}, [isAnimationGuardActive, isAuto, queueSave, scrollToBottom, setStateValue, stop, updateOverflowAndButton]);
React.useEffect(() => {
const container = containerEl;
if (!container) return;
lastScrollTopRef.current = container.scrollTop;
const handleWheel = (event: WheelEvent) => {
if (event.deltaY >= 0) return;
if (nestedScrollableCanConsumeUp(container, event.target)) return;
@@ -668,8 +707,14 @@ export const useChatAutoFollow = ({
updateOverflowAndButton();
}, [sessionMessageCount, updateOverflowAndButton]);
const notifyContentChange = React.useCallback((_reason?: ContentChangeReason) => {
void _reason;
const notifyContentChange = React.useCallback((reason?: ContentChangeReason) => {
// A tracked height animation (e.g. Thinking auto-collapse) opens a guard
// window so its transient geometry / async scroll events are not misread
// as a user scroll-away. Real gestures still release through
// releaseFromUserIntent, so the user can always scroll up freely.
if (reason === 'animation') {
animationGuardUntilRef.current = now() + ANIMATION_GUARD_MS;
}
updateOverflowAndButton();
// Entry-stick window: late structural growth (notably the task/subagent
// summary landing from the child session — ToolPart emits 'structural'