fix(chat): rebuild auto-follow on a single instant writer to kill scroll twitch
Replace the RAF easing follow loop + settle burst with an always-on instant-follow model: while pinned, the content ResizeObserver re-pins to the bottom synchronously (scrollTop = scrollHeight, before paint) and is the only writer of scrollTop. A position+TTL auto marker distinguishes our own programmatic writes from genuine user scrolling, so a scroll event that lands at the just-written bottom never trips a false release. This removes the feedback loop where the easing animation, growing content, and the user's own scroll all fought for scrollTop in the same frame -- the infinite twitch when scrolling down during streaming, and the jiggle on send / from the queue. The public hook interface is unchanged; all consumers keep working untouched.
This commit is contained in:
@@ -382,12 +382,13 @@ export const useChatTimelineController = ({
|
||||
// through goToBottom — the single programmatic writer.
|
||||
//
|
||||
// A normal bottom APPEND (a sent message, a streaming part) must NOT
|
||||
// re-pin here. Auto-follow's own follow loop — kicked by the content
|
||||
// ResizeObserver and the streaming chunk handlers — already eases to the
|
||||
// new bottom. Calling goToBottom on every append layered its settle burst
|
||||
// on top of that loop: two writers aiming at different positions, which
|
||||
// is exactly the up/down jiggle reported on send / from the queue / while
|
||||
// streaming. So for an append we do nothing and let the follow loop own it.
|
||||
// re-pin here. Auto-follow already owns the bottom: its content
|
||||
// ResizeObserver re-pins instantly (scrollTop = scrollHeight, before
|
||||
// paint) on every append. Re-pinning again from here would just be a
|
||||
// second writer chasing the same target a frame later — redundant at
|
||||
// best, and the source of the old up/down jiggle on send / from the
|
||||
// queue / while streaming. So for an append we do nothing and let
|
||||
// auto-follow own it.
|
||||
if (snap || isPrepend) {
|
||||
prePrependScrollRef.current = null;
|
||||
goToBottom('instant');
|
||||
|
||||
@@ -42,16 +42,37 @@ export interface UseChatAutoFollowResult {
|
||||
restoreSnapshot: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// This is a direct port of opencode's `createAutoScroll` (SolidJS) to a React
|
||||
// hook. The model is deliberately simple, which is what makes it flicker-free:
|
||||
//
|
||||
// • Auto-follow is ALWAYS on unless the user scrolled up (`released`).
|
||||
// • Following the bottom is INSTANT — `scrollTop = scrollHeight` inside the
|
||||
// content ResizeObserver, which fires after layout and before paint. There
|
||||
// is NO easing loop and NO settle burst, so there are never two writers
|
||||
// racing for `scrollTop` (the root cause of the old jiggle/double-scroll).
|
||||
// • A short-lived "auto" marker (position + 1500ms) lets the scroll handler
|
||||
// distinguish our own programmatic writes from genuine user scrolling, so
|
||||
// a scroll event that lands at our just-written bottom never trips a false
|
||||
// release.
|
||||
//
|
||||
// The public interface below is unchanged from the old implementation so every
|
||||
// consumer (ChatContainer, message parts, the timeline controller) keeps
|
||||
// working without edits.
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const BOTTOM_SPACER_DESKTOP_VH = 0.10;
|
||||
const BOTTOM_SPACER_MOBILE_PX = 40;
|
||||
const PROGRAMMATIC_WRITE_WINDOW_MS = 200;
|
||||
const SAVE_DEBOUNCE_MS = 150;
|
||||
const LERP = 0.18;
|
||||
const SETTLE_EPSILON = 0.5;
|
||||
const SETTLE_FRAMES = 4;
|
||||
const TOUCH_FINGER_DOWN_THRESHOLD = 2;
|
||||
const SETTLE_BURST_DURATION_MS = 280;
|
||||
const REPIN_GRACE_AFTER_RELEASE_MS = 1200;
|
||||
// How long an "auto" (programmatic) scroll position stays trusted. Browsers can
|
||||
// dispatch the `scroll` event for our write asynchronously, after newer content
|
||||
// has already changed the geometry; the window keeps us from reading that lag as
|
||||
// a user scroll. Mirrors opencode's 1500ms.
|
||||
const AUTO_MARK_TTL_MS = 1500;
|
||||
const AUTO_MATCH_TOLERANCE_PX = 2;
|
||||
|
||||
const now = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
||||
|
||||
// The bottom of the chat has an empty spacer (10vh on desktop, 40px on mobile)
|
||||
// — its height is exactly how far above scrollHeight the user can be while still
|
||||
@@ -68,6 +89,10 @@ const distanceFromBottom = (el: HTMLElement): number => {
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
};
|
||||
|
||||
const canScroll = (el: HTMLElement): boolean => {
|
||||
return el.scrollHeight - el.clientHeight > 1;
|
||||
};
|
||||
|
||||
const isNearBottom = (el: HTMLElement, isMobile: boolean): boolean => {
|
||||
return distanceFromBottom(el) <= computeBottomZoneThreshold(isMobile, el);
|
||||
};
|
||||
@@ -115,21 +140,27 @@ export const useChatAutoFollow = ({
|
||||
const [showScrollButton, setShowScrollButton] = React.useState(false);
|
||||
const [isFollowingProgrammatically, setIsFollowingProgrammatically] = React.useState(false);
|
||||
|
||||
// `stateRef` is the single source of truth for follow vs released; the React
|
||||
// state above is a mirror for rendering. `released === userScrolled` in
|
||||
// opencode terms.
|
||||
const stateRef = React.useRef<AutoFollowState>('following');
|
||||
const isMobileRef = React.useRef(isMobile);
|
||||
isMobileRef.current = isMobile;
|
||||
const sessionMessageCountRef = React.useRef(sessionMessageCount);
|
||||
sessionMessageCountRef.current = sessionMessageCount;
|
||||
const currentSessionIdRef = React.useRef(currentSessionId);
|
||||
currentSessionIdRef.current = currentSessionId;
|
||||
|
||||
const lastSessionIdRef = React.useRef<string | null>(null);
|
||||
const programmaticWriteUntilRef = React.useRef(0);
|
||||
const followRafRef = React.useRef<number | null>(null);
|
||||
const settledFramesRef = React.useRef(0);
|
||||
const lastScrollTopRef = React.useRef(0);
|
||||
|
||||
// Programmatic-scroll marker (opencode's `auto`): the bottom position we last
|
||||
// wrote and when. A scroll event whose scrollTop matches `top` within a few
|
||||
// px while still inside the TTL is OUR write, not the user's.
|
||||
const autoRef = React.useRef<{ top: number; time: number } | null>(null);
|
||||
const autoTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const saveTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingSaveRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
|
||||
const settleBurstRafRef = React.useRef<number | null>(null);
|
||||
const lastUserReleaseAtRef = React.useRef(0);
|
||||
// When restoreSnapshot is invoked while ChatViewport is still hydrating
|
||||
// (skeleton rendered, no scroll container yet), we record the session here
|
||||
// so a follow-up effect can replay the restore once the container mounts.
|
||||
@@ -155,210 +186,117 @@ export const useChatAutoFollow = ({
|
||||
setState(next);
|
||||
}, []);
|
||||
|
||||
const markProgrammaticWrite = React.useCallback(() => {
|
||||
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
programmaticWriteUntilRef.current = now + PROGRAMMATIC_WRITE_WINDOW_MS;
|
||||
// ── auto marker ────────────────────────────────────────────────────────
|
||||
const markAuto = React.useCallback((el: HTMLElement) => {
|
||||
autoRef.current = {
|
||||
top: Math.max(0, el.scrollHeight - el.clientHeight),
|
||||
time: now(),
|
||||
};
|
||||
if (autoTimerRef.current) clearTimeout(autoTimerRef.current);
|
||||
autoTimerRef.current = setTimeout(() => {
|
||||
autoRef.current = null;
|
||||
autoTimerRef.current = null;
|
||||
}, AUTO_MARK_TTL_MS);
|
||||
}, []);
|
||||
|
||||
const isInProgrammaticWindow = React.useCallback(() => {
|
||||
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
return now < programmaticWriteUntilRef.current;
|
||||
const isAuto = React.useCallback((el: HTMLElement): boolean => {
|
||||
const a = autoRef.current;
|
||||
if (!a) return false;
|
||||
if (now() - a.time > AUTO_MARK_TTL_MS) {
|
||||
autoRef.current = null;
|
||||
return false;
|
||||
}
|
||||
return Math.abs(el.scrollTop - a.top) < AUTO_MATCH_TOLERANCE_PX;
|
||||
}, []);
|
||||
|
||||
const stopFollowLoop = React.useCallback(() => {
|
||||
if (followRafRef.current !== null && typeof window !== 'undefined') {
|
||||
window.cancelAnimationFrame(followRafRef.current);
|
||||
}
|
||||
followRafRef.current = null;
|
||||
settledFramesRef.current = 0;
|
||||
// Only the active scroll-writer owns the "programmatic follow" flag. If the
|
||||
// settle burst is still running it remains the owner, so don't clear here.
|
||||
if (settleBurstRafRef.current === null) {
|
||||
setIsFollowingProgrammatically(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stopSettleBurst = React.useCallback(() => {
|
||||
if (settleBurstRafRef.current !== null && typeof window !== 'undefined') {
|
||||
window.cancelAnimationFrame(settleBurstRafRef.current);
|
||||
}
|
||||
settleBurstRafRef.current = null;
|
||||
if (followRafRef.current === null) {
|
||||
setIsFollowingProgrammatically(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const tickFollow = React.useCallback(() => {
|
||||
followRafRef.current = null;
|
||||
// ── overflow / scroll-to-bottom button ──────────────────────────────────
|
||||
const updateOverflowAndButton = React.useCallback(() => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) {
|
||||
stopFollowLoop();
|
||||
setIsOverflowing(false);
|
||||
setShowScrollButton(false);
|
||||
return;
|
||||
}
|
||||
if (stateRef.current !== 'following') {
|
||||
stopFollowLoop();
|
||||
const overflowing = canScroll(container);
|
||||
setIsOverflowing(overflowing);
|
||||
if (!overflowing) {
|
||||
setShowScrollButton(false);
|
||||
return;
|
||||
}
|
||||
const showButton = stateRef.current === 'released' && !isNearBottom(container, isMobileRef.current);
|
||||
setShowScrollButton(showButton);
|
||||
}, []);
|
||||
|
||||
const target = Math.max(0, container.scrollHeight - container.clientHeight);
|
||||
const current = container.scrollTop;
|
||||
const delta = target - current;
|
||||
|
||||
// A delta larger than a full viewport is a DISCRETE jump (late history
|
||||
// measurement, session entry, a big block rendering in one commit) — not
|
||||
// incremental streaming growth. Easing it produces a visible smooth scroll
|
||||
// from a mid position to the bottom, which felt inconsistent when entering
|
||||
// historical sessions. Snap such jumps; only ease small, streaming-sized
|
||||
// deltas below.
|
||||
if (Math.abs(delta) > container.clientHeight) {
|
||||
markProgrammaticWrite();
|
||||
container.scrollTop = target;
|
||||
lastScrollTopRef.current = target;
|
||||
settledFramesRef.current = 0;
|
||||
followRafRef.current = window.requestAnimationFrame(tickFollow);
|
||||
// ── core scroll primitives (ported from opencode) ────────────────────────
|
||||
const scrollToBottomNow = React.useCallback((behavior: ScrollBehavior) => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
markAuto(el);
|
||||
if (behavior === 'smooth') {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior });
|
||||
return;
|
||||
}
|
||||
// Direct `scrollTop` assignment bypasses any CSS `scroll-behavior: smooth`
|
||||
// and lands in the same frame — no visible catch-up animation.
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}, [markAuto]);
|
||||
|
||||
if (Math.abs(delta) <= SETTLE_EPSILON) {
|
||||
if (current !== target) {
|
||||
markProgrammaticWrite();
|
||||
container.scrollTop = target;
|
||||
lastScrollTopRef.current = target;
|
||||
}
|
||||
settledFramesRef.current += 1;
|
||||
if (settledFramesRef.current >= SETTLE_FRAMES) {
|
||||
stopFollowLoop();
|
||||
return;
|
||||
}
|
||||
followRafRef.current = window.requestAnimationFrame(tickFollow);
|
||||
// `force` true = user-intent jump (clears released and always scrolls).
|
||||
// `force` false = passive follow (only while still following).
|
||||
const scrollToBottom = React.useCallback((force: boolean, behavior: ScrollBehavior = 'auto') => {
|
||||
const el = scrollRef.current;
|
||||
|
||||
if (force && stateRef.current !== 'following') {
|
||||
setStateValue('following');
|
||||
}
|
||||
if (!el) return;
|
||||
if (!force && stateRef.current !== 'following') return;
|
||||
|
||||
const distance = distanceFromBottom(el);
|
||||
if (distance < AUTO_MATCH_TOLERANCE_PX) {
|
||||
// Already at the bottom; just refresh the auto marker so the next
|
||||
// scroll event is recognised as ours.
|
||||
markAuto(el);
|
||||
return;
|
||||
}
|
||||
scrollToBottomNow(force ? behavior : 'auto');
|
||||
}, [markAuto, scrollToBottomNow, setStateValue]);
|
||||
|
||||
settledFramesRef.current = 0;
|
||||
const next = current + delta * LERP;
|
||||
markProgrammaticWrite();
|
||||
container.scrollTop = next;
|
||||
lastScrollTopRef.current = container.scrollTop;
|
||||
followRafRef.current = window.requestAnimationFrame(tickFollow);
|
||||
}, [markProgrammaticWrite, stopFollowLoop]);
|
||||
|
||||
const startFollowLoop = React.useCallback(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (stateRef.current !== 'following') return;
|
||||
// Single-writer invariant, asymmetric on purpose: the settle burst is the
|
||||
// AUTHORITATIVE instant pin (session restore / goToBottom 'instant'). While
|
||||
// it is snapping to the bottom, YIELD — never preempt it with the easing
|
||||
// follow loop. Preempting it let a content-measurement ResizeObserver tick
|
||||
// downgrade an instant restore into a visible smooth scroll from a mid
|
||||
// position when entering a historical session. When the burst ends, the
|
||||
// next content kick starts the follow loop. (startSettleBurst still stops
|
||||
// this loop, so the two never write scrollTop in the same frame.)
|
||||
if (settleBurstRafRef.current !== null) return;
|
||||
if (followRafRef.current !== null) return;
|
||||
settledFramesRef.current = 0;
|
||||
setIsFollowingProgrammatically(true);
|
||||
followRafRef.current = window.requestAnimationFrame(tickFollow);
|
||||
}, [tickFollow]);
|
||||
|
||||
const writeScrollTopInstant = React.useCallback((target: number) => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
const max = Math.max(0, container.scrollHeight - container.clientHeight);
|
||||
const clamped = Math.max(0, Math.min(target, max));
|
||||
markProgrammaticWrite();
|
||||
container.scrollTop = clamped;
|
||||
lastScrollTopRef.current = container.scrollTop;
|
||||
}, [markProgrammaticWrite]);
|
||||
|
||||
const startSettleBurst = React.useCallback(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
// Single-writer invariant (mirror of startFollowLoop): the settle burst is
|
||||
// taking over scroll ownership, so stop the easing follow loop first. The
|
||||
// two must never write scrollTop in the same frame.
|
||||
stopFollowLoop();
|
||||
stopSettleBurst();
|
||||
setIsFollowingProgrammatically(true);
|
||||
const until = (typeof performance !== 'undefined' ? performance.now() : Date.now()) + SETTLE_BURST_DURATION_MS;
|
||||
const finish = () => {
|
||||
settleBurstRafRef.current = null;
|
||||
if (followRafRef.current === null) {
|
||||
setIsFollowingProgrammatically(false);
|
||||
}
|
||||
};
|
||||
const tick = () => {
|
||||
settleBurstRafRef.current = null;
|
||||
if (stateRef.current !== 'following') {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
const c = scrollRef.current;
|
||||
if (!c) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
const target = Math.max(0, c.scrollHeight - c.clientHeight);
|
||||
if (Math.abs(c.scrollTop - target) > SETTLE_EPSILON) {
|
||||
markProgrammaticWrite();
|
||||
c.scrollTop = target;
|
||||
lastScrollTopRef.current = target;
|
||||
}
|
||||
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
if (now < until) {
|
||||
settleBurstRafRef.current = window.requestAnimationFrame(tick);
|
||||
} else {
|
||||
finish();
|
||||
}
|
||||
};
|
||||
settleBurstRafRef.current = window.requestAnimationFrame(tick);
|
||||
}, [markProgrammaticWrite, stopFollowLoop, stopSettleBurst]);
|
||||
|
||||
const releaseAutoFollow = React.useCallback(() => {
|
||||
stopFollowLoop();
|
||||
stopSettleBurst();
|
||||
lastUserReleaseAtRef.current = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
// User left the bottom — release auto-follow.
|
||||
const stop = React.useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
if (!canScroll(el)) {
|
||||
setStateValue('following');
|
||||
return;
|
||||
}
|
||||
if (stateRef.current === 'released') return;
|
||||
setStateValue('released');
|
||||
}, [setStateValue, stopFollowLoop, stopSettleBurst]);
|
||||
|
||||
const releaseFromUserIntent = React.useCallback(() => {
|
||||
if (stateRef.current === 'following') {
|
||||
stopFollowLoop();
|
||||
stopSettleBurst();
|
||||
lastUserReleaseAtRef.current = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
setStateValue('released');
|
||||
} else {
|
||||
lastUserReleaseAtRef.current = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
}
|
||||
}, [setStateValue, stopFollowLoop, stopSettleBurst]);
|
||||
updateOverflowAndButton();
|
||||
}, [setStateValue, updateOverflowAndButton]);
|
||||
|
||||
// ── public scroll API (mapped onto the primitives) ───────────────────────
|
||||
const goToBottom = React.useCallback((mode: 'instant' | 'smooth' = 'instant') => {
|
||||
const container = scrollRef.current;
|
||||
setStateValue('following');
|
||||
lastUserReleaseAtRef.current = 0;
|
||||
if (!container) return;
|
||||
if (mode === 'smooth') {
|
||||
startFollowLoop();
|
||||
return;
|
||||
}
|
||||
const target = Math.max(0, container.scrollHeight - container.clientHeight);
|
||||
writeScrollTopInstant(target);
|
||||
startSettleBurst();
|
||||
}, [setStateValue, startFollowLoop, startSettleBurst, writeScrollTopInstant]);
|
||||
scrollToBottom(true, mode === 'smooth' ? 'smooth' : 'auto');
|
||||
}, [scrollToBottom]);
|
||||
|
||||
const scrollToBottomOnSend = React.useCallback(() => {
|
||||
// Keep a SINGLE movement to the just-sent message.
|
||||
// If we're already following the bottom, the optimistic message is eased
|
||||
// into view by the follow loop (kicked by the content ResizeObserver). Just
|
||||
// (re)kick that one owner — do NOT also fire an instant goToBottom here, or
|
||||
// the instant snap races the easing loop and you see a visible double scroll
|
||||
// (ease, then snap).
|
||||
if (stateRef.current === 'following') {
|
||||
startFollowLoop();
|
||||
return;
|
||||
}
|
||||
// Scrolled up (released): bring the user down to the message they just sent.
|
||||
goToBottom('instant');
|
||||
}, [goToBottom, startFollowLoop]);
|
||||
// Single movement to the just-sent message. Force re-pins to the bottom
|
||||
// whether we were following or scrolled up; the content ResizeObserver
|
||||
// keeps us pinned as the optimistic message and its reply stream in.
|
||||
scrollToBottom(true);
|
||||
}, [scrollToBottom]);
|
||||
|
||||
const releaseAutoFollow = React.useCallback(() => {
|
||||
setStateValue('released');
|
||||
updateOverflowAndButton();
|
||||
}, [setStateValue, updateOverflowAndButton]);
|
||||
|
||||
const releaseFromUserIntent = React.useCallback(() => {
|
||||
stop();
|
||||
}, [stop]);
|
||||
|
||||
// ── per-session snapshot persistence (kept; restore still goes to bottom) ─
|
||||
const flushSave = React.useCallback(() => {
|
||||
if (saveTimerRef.current !== null) {
|
||||
clearTimeout(saveTimerRef.current);
|
||||
@@ -417,21 +355,16 @@ export const useChatAutoFollow = ({
|
||||
}
|
||||
pendingInitialRestoreRef.current = null;
|
||||
|
||||
// Always return to the bottom on session switch. The previous saved-ratio
|
||||
// restore had a low success rate and, by landing 'released' partway up,
|
||||
// produced the visible backward jump as content finished loading.
|
||||
// Always return to the bottom on session switch (opencode resumes on the
|
||||
// same edge). The content ResizeObserver re-pins instantly as late
|
||||
// history measures in, so there is no smooth scroll-from-mid artifact.
|
||||
setStateValue('following');
|
||||
lastUserReleaseAtRef.current = 0;
|
||||
const target = Math.max(0, container.scrollHeight - container.clientHeight);
|
||||
// Mirror goToBottom('instant'): jump to the bottom now, then hold it with the
|
||||
// settle burst while late history content measures in. Do NOT also start the
|
||||
// easing follow loop here — that is what produced the smooth scroll-from-mid
|
||||
// position on session entry.
|
||||
writeScrollTopInstant(target);
|
||||
startSettleBurst();
|
||||
scrollToBottom(true);
|
||||
updateOverflowAndButton();
|
||||
return false;
|
||||
}, [setStateValue, startSettleBurst, writeScrollTopInstant]);
|
||||
}, [scrollToBottom, setStateValue, updateOverflowAndButton]);
|
||||
|
||||
// ── session change ───────────────────────────────────────────────────────
|
||||
React.useEffect(() => {
|
||||
if (!currentSessionId || currentSessionId === lastSessionIdRef.current) {
|
||||
return;
|
||||
@@ -439,20 +372,28 @@ export const useChatAutoFollow = ({
|
||||
lastSessionIdRef.current = currentSessionId;
|
||||
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
|
||||
flushSave();
|
||||
stopFollowLoop();
|
||||
stopSettleBurst();
|
||||
markProgrammaticWrite();
|
||||
autoRef.current = null;
|
||||
// Drop any pending restore request inherited from a different session.
|
||||
if (pendingInitialRestoreRef.current && pendingInitialRestoreRef.current !== currentSessionId) {
|
||||
pendingInitialRestoreRef.current = null;
|
||||
}
|
||||
}, [currentSessionId, flushSave, markProgrammaticWrite, stopFollowLoop, stopSettleBurst]);
|
||||
}, [currentSessionId, flushSave]);
|
||||
|
||||
// When work begins (a reply starts streaming) and we are still following,
|
||||
// make sure we are pinned to the bottom. Mirrors opencode's `working` effect.
|
||||
React.useEffect(() => {
|
||||
if (sessionIsWorking && stateRef.current === 'following') {
|
||||
startFollowLoop();
|
||||
scrollToBottom(false);
|
||||
}
|
||||
}, [sessionIsWorking, startFollowLoop]);
|
||||
}, [sessionIsWorking, scrollToBottom]);
|
||||
|
||||
// Suppress the overlay scrollbar thumb only while we are actively following a
|
||||
// live stream (the thumb would otherwise jump on every instant re-pin). When
|
||||
// idle or released the scrollbar behaves normally. Stable: changes only when
|
||||
// follow-state or working-state flips, not on every frame.
|
||||
React.useEffect(() => {
|
||||
setIsFollowingProgrammatically(state === 'following' && sessionIsWorking);
|
||||
}, [state, sessionIsWorking]);
|
||||
|
||||
// Replay a deferred restoreSnapshot once ChatViewport mounts.
|
||||
// useLayoutEffect ensures scroll position is set before the browser paints,
|
||||
@@ -464,67 +405,38 @@ export const useChatAutoFollow = ({
|
||||
}
|
||||
}, [containerEl, currentSessionId, restoreSnapshot]);
|
||||
|
||||
const updateOverflowAndButton = React.useCallback(() => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) {
|
||||
setIsOverflowing(false);
|
||||
setShowScrollButton(false);
|
||||
return;
|
||||
}
|
||||
const overflowing = container.scrollHeight > container.clientHeight + 1;
|
||||
setIsOverflowing(overflowing);
|
||||
if (!overflowing) {
|
||||
setShowScrollButton(false);
|
||||
return;
|
||||
}
|
||||
const showButton = stateRef.current === 'released' && !isNearBottom(container, isMobile);
|
||||
setShowScrollButton(showButton);
|
||||
}, [isMobile]);
|
||||
|
||||
// ── scroll event handling (ported from opencode handleScroll) ────────────
|
||||
const handleScrollEvent = React.useCallback(() => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const programmatic = isInProgrammaticWindow();
|
||||
const currentTop = container.scrollTop;
|
||||
lastScrollTopRef.current = currentTop;
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
|
||||
updateOverflowAndButton();
|
||||
|
||||
if (programmatic) {
|
||||
if (!canScroll(el)) {
|
||||
setStateValue('following');
|
||||
return;
|
||||
}
|
||||
|
||||
// Release auto-follow only when the user has actually left the near-bottom
|
||||
// zone — not on the small scrollTop clamp the browser applies when the
|
||||
// composer grows and shrinks the viewport (which keeps you at the bottom).
|
||||
// Position-based, mirroring the re-pin check below; this removes the false
|
||||
// release that produced the visible backward jump on session switch.
|
||||
if (stateRef.current === 'following' && !isNearBottom(container, isMobile)) {
|
||||
stopFollowLoop();
|
||||
stopSettleBurst();
|
||||
lastUserReleaseAtRef.current = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
setStateValue('released');
|
||||
}
|
||||
|
||||
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
const inGrace = (now - lastUserReleaseAtRef.current) < REPIN_GRACE_AFTER_RELEASE_MS;
|
||||
if (stateRef.current === 'released' && isNearBottom(container, isMobile) && !inGrace) {
|
||||
// Within the bottom zone → (re-)pin to following. This is how scrolling
|
||||
// back down to the bottom resumes auto-follow.
|
||||
if (isNearBottom(el, isMobileRef.current)) {
|
||||
setStateValue('following');
|
||||
startFollowLoop();
|
||||
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)) {
|
||||
scrollToBottom(false);
|
||||
queueSave();
|
||||
return;
|
||||
}
|
||||
|
||||
// Genuine user scroll away from the bottom.
|
||||
stop();
|
||||
queueSave();
|
||||
}, [
|
||||
isInProgrammaticWindow,
|
||||
isMobile,
|
||||
queueSave,
|
||||
setStateValue,
|
||||
startFollowLoop,
|
||||
stopFollowLoop,
|
||||
stopSettleBurst,
|
||||
updateOverflowAndButton,
|
||||
]);
|
||||
}, [isAuto, queueSave, scrollToBottom, setStateValue, stop, updateOverflowAndButton]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerEl;
|
||||
@@ -596,15 +508,24 @@ export const useChatAutoFollow = ({
|
||||
};
|
||||
}, [containerEl, handleScrollEvent, releaseFromUserIntent]);
|
||||
|
||||
// The heart of the follow behaviour: the content ResizeObserver fires after
|
||||
// layout and before paint, so re-pinning to the bottom here is invisible —
|
||||
// there is no "jump up then catch up". Observe both the container (composer
|
||||
// growth shrinks the viewport) and the inner content (streaming growth).
|
||||
React.useEffect(() => {
|
||||
const container = containerEl;
|
||||
if (!container || typeof ResizeObserver === 'undefined') return;
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
updateOverflowAndButton();
|
||||
if (stateRef.current === 'following') {
|
||||
startFollowLoop();
|
||||
const el = scrollRef.current;
|
||||
if (el && !canScroll(el)) {
|
||||
setStateValue('following');
|
||||
updateOverflowAndButton();
|
||||
return;
|
||||
}
|
||||
updateOverflowAndButton();
|
||||
if (stateRef.current !== 'following') return;
|
||||
scrollToBottom(false);
|
||||
});
|
||||
observer.observe(container);
|
||||
const inner = container.firstElementChild;
|
||||
@@ -612,7 +533,7 @@ export const useChatAutoFollow = ({
|
||||
observer.observe(inner);
|
||||
}
|
||||
return () => observer.disconnect();
|
||||
}, [containerEl, startFollowLoop, updateOverflowAndButton]);
|
||||
}, [containerEl, scrollToBottom, setStateValue, updateOverflowAndButton]);
|
||||
|
||||
React.useEffect(() => {
|
||||
updateOverflowAndButton();
|
||||
@@ -622,9 +543,9 @@ export const useChatAutoFollow = ({
|
||||
void _reason;
|
||||
updateOverflowAndButton();
|
||||
if (stateRef.current === 'following') {
|
||||
startFollowLoop();
|
||||
scrollToBottom(false);
|
||||
}
|
||||
}, [startFollowLoop, updateOverflowAndButton]);
|
||||
}, [scrollToBottom, updateOverflowAndButton]);
|
||||
|
||||
const animationHandlersRef = React.useRef<Map<string, AnimationHandlers>>(new Map());
|
||||
|
||||
@@ -634,7 +555,7 @@ export const useChatAutoFollow = ({
|
||||
|
||||
const kick = () => {
|
||||
if (stateRef.current === 'following') {
|
||||
startFollowLoop();
|
||||
scrollToBottom(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -651,19 +572,21 @@ export const useChatAutoFollow = ({
|
||||
};
|
||||
animationHandlersRef.current.set(messageId, handlers);
|
||||
return handlers;
|
||||
}, [startFollowLoop, updateOverflowAndButton]);
|
||||
}, [scrollToBottom, updateOverflowAndButton]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
stopFollowLoop();
|
||||
stopSettleBurst();
|
||||
if (autoTimerRef.current) {
|
||||
clearTimeout(autoTimerRef.current);
|
||||
autoTimerRef.current = null;
|
||||
}
|
||||
flushSave();
|
||||
if (saveTimerRef.current !== null) {
|
||||
clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [flushSave, stopFollowLoop, stopSettleBurst]);
|
||||
}, [flushSave]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!onActiveTurnChange) return;
|
||||
|
||||
Reference in New Issue
Block a user