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}
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user