import React from 'react'; import { MessageFreshnessDetector } from '@/lib/messageFreshness'; import { createScrollSpy } from '@/components/chat/lib/scroll/scrollSpy'; import { useViewportStore } from '@/sync/viewport-store'; type AutoFollowState = 'following' | 'released'; export type ContentChangeReason = 'text' | 'structural' | 'permission' | 'animation'; export interface AnimationHandlers { onChunk: () => void; onComplete: () => void; onStreamingCandidate?: () => void; onAnimationStart?: () => void; onReservationCancelled?: () => void; onReasoningBlock?: () => void; onAnimatedHeightChange?: (height: number) => void; } interface UseChatAutoFollowOptions { currentSessionId: string | null; currentSessionKey: string | null; sessionMessageCount: number; sessionIsWorking: boolean; isMobile: boolean; onActiveTurnChange?: (turnId: string | null) => void; } export interface UseChatAutoFollowResult { scrollRef: React.RefObject; state: AutoFollowState; isPinned: boolean; isOverflowing: boolean; isFollowingProgrammatically: boolean; showScrollButton: boolean; notifyContentChange: (reason?: ContentChangeReason) => void; getAnimationHandlers: (messageId: string) => AnimationHandlers; goToBottom: (mode?: 'instant' | 'smooth') => void; scrollToBottomOnSend: () => void; releaseAutoFollow: () => void; saveSnapshotNow: () => void; restoreSnapshot: () => Promise; } // ────────────────────────────────────────────────────────────────────────── // Chat auto-follow. The model is deliberately simple, which is what makes it // flicker-free: // // • Auto-follow is on unless the user scrolled up (`released`), AND passive // following only acts while the session is active (working, plus a short // settle window). When idle, content-size changes are layout churn // (virtualizer re-measurement, async tool/code rendering) rather than live // growth, so the hook leaves scroll alone — re-pinning then would fight the // virtualizer and twitch the viewport. // • 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 SAVE_DEBOUNCE_MS = 150; const TOUCH_FINGER_DOWN_THRESHOLD = 2; // 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. 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; // Entry-stick window. On the FIRST open of a session, late async data (most // visibly a task/subagent tool whose nested rows are fetched from the child // session after entry — see useEnsureSessionMessages in ToolPart.tsx) grows the // timeline a beat or two AFTER we have already pinned to the bottom, leaving the // viewport stranded mid-history. The steady-state idle gate deliberately ignores // that growth (it can't tell entry from a user reading idle history). So instead // of weakening the gate, we open a short, gesture-cancellable window on entry // during which we FORCE the bottom on every growth. It ends QUIESCENCE_MS after // growth stops (capped by MAX_MS), or instantly on any real user scroll gesture. const ENTRY_STICK_QUIESCENCE_MS = 600; const ENTRY_STICK_MAX_MS = 8000; 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 // looking at "empty" space. We use that same value as the threshold for both // re-pinning auto-follow and showing the scroll-to-bottom button. const computeBottomZoneThreshold = (isMobile: boolean, container?: HTMLElement | null): number => { if (isMobile) return BOTTOM_SPACER_MOBILE_PX; const height = container?.clientHeight ?? 0; if (height <= 0) return 96; return Math.max(48, height * BOTTOM_SPACER_DESKTOP_VH); }; 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); }; const isReleaseKey = (event: KeyboardEvent): boolean => { if (event.altKey || event.ctrlKey || event.metaKey) { return false; } switch (event.key) { case 'ArrowUp': case 'PageUp': case 'Home': return true; default: return false; } }; const nestedScrollableTarget = (root: HTMLElement, target: EventTarget | null): HTMLElement | null => { if (!(target instanceof Element)) return null; const nested = target.closest('[data-scrollable]'); if (!nested || nested === root || !(nested instanceof HTMLElement)) return null; return nested; }; const nestedScrollableCanConsumeUp = (root: HTMLElement, target: EventTarget | null): boolean => { const nested = nestedScrollableTarget(root, target); if (!nested) return false; return nested.scrollTop > 0; }; export const useChatAutoFollow = ({ currentSessionId, currentSessionKey, sessionMessageCount, sessionIsWorking, isMobile, onActiveTurnChange, }: UseChatAutoFollowOptions): UseChatAutoFollowResult => { const scrollRef = React.useRef(null); const [containerEl, setContainerEl] = React.useState(null); const lastSeenContainerRef = React.useRef(null); const [state, setState] = React.useState('following'); const [isOverflowing, setIsOverflowing] = React.useState(false); 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` means the user scrolled // up and away from the bottom. const stateRef = React.useRef('following'); const isMobileRef = React.useRef(isMobile); isMobileRef.current = isMobile; const sessionIsWorkingRef = React.useRef(sessionIsWorking); sessionIsWorkingRef.current = sessionIsWorking; // `settling` keeps passive follow alive for a short window after work stops // so the final content can land at the bottom. const settlingRef = React.useRef(false); const settleTimerRef = React.useRef | null>(null); const sessionMessageCountRef = React.useRef(sessionMessageCount); sessionMessageCountRef.current = sessionMessageCount; const currentSessionIdRef = React.useRef(currentSessionId); currentSessionIdRef.current = currentSessionId; const currentSessionKeyRef = React.useRef(currentSessionKey); currentSessionKeyRef.current = currentSessionKey; const lastSessionKeyRef = React.useRef(null); // Programmatic-scroll marker: 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 | 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); // True while the native (Capacitor iOS) keyboard slide choreography is in // flight (between 'oc:keyboard-anim' and 'oc:keyboard-settled' from // useNativeMobileChrome). During that window the pinned content is moved by a // transform on the inner wrapper, so the ResizeObserver chase must stand down. const keyboardAnimRef = React.useRef(false); // 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 | null>(null); const entryStickCapTimerRef = React.useRef | null>(null); const entryStickLastHeightRef = React.useRef(0); const saveTimerRef = React.useRef | null>(null); const pendingSaveRef = React.useRef<{ sessionId: string; anchor: number } | null>(null); // 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. const pendingInitialRestoreRef = React.useRef(null); const updateViewportAnchor = useViewportStore((s) => s.updateViewportAnchor); // Detect when the scroll container DOM element changes (mount, unmount, remount). // Without this, listener-attach effects would only ever bind to the element that // existed at the hook's first render, missing later mounts (e.g. after first send // promotes a draft session to a real chat with messages). // eslint-disable-next-line react-hooks/exhaustive-deps React.useLayoutEffect(() => { if (scrollRef.current !== lastSeenContainerRef.current) { lastSeenContainerRef.current = scrollRef.current; setContainerEl(scrollRef.current); } }); // `active` is `working || settling`. Passive auto-follow // (the ResizeObserver re-pin and any non-forced scrollToBottom) only runs // while active. When the session is idle, content-size changes are layout // churn — virtualizer re-measurement, async tool/code rendering — NOT live // growth, so we must NOT yank the user to the bottom. Forcing this gate is // what stops the twitch when tall items (expanded tools) re-measure as the // user scrolls. const isActive = React.useCallback((): boolean => { return sessionIsWorkingRef.current || settlingRef.current; }, []); const setStateValue = React.useCallback((next: AutoFollowState) => { if (stateRef.current === next) return; stateRef.current = next; setState(next); }, []); // ── 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 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 isAnimationGuardActive = React.useCallback((): boolean => { return now() < animationGuardUntilRef.current; }, []); // ── entry-stick window ─────────────────────────────────────────────────── const endEntryStick = React.useCallback(() => { entryStickRef.current = false; if (entryStickQuietTimerRef.current) { clearTimeout(entryStickQuietTimerRef.current); entryStickQuietTimerRef.current = null; } if (entryStickCapTimerRef.current) { clearTimeout(entryStickCapTimerRef.current); entryStickCapTimerRef.current = null; } }, []); // (Re)arm the quiescence timer: the window closes this long after the last // growth. Called once on begin and again on every growth-driven re-pin. const armEntryStickQuiet = React.useCallback(() => { if (entryStickQuietTimerRef.current) { clearTimeout(entryStickQuietTimerRef.current); } entryStickQuietTimerRef.current = setTimeout(() => { entryStickQuietTimerRef.current = null; endEntryStick(); }, ENTRY_STICK_QUIESCENCE_MS); }, [endEntryStick]); const beginEntryStick = React.useCallback(() => { const el = scrollRef.current; if (!el) return; entryStickRef.current = true; entryStickLastHeightRef.current = el.scrollHeight; armEntryStickQuiet(); // Reset the absolute cap fresh on every entry (e.g. session switch) so a // stale cap from a previous open can't cut this window short. if (entryStickCapTimerRef.current) { clearTimeout(entryStickCapTimerRef.current); } entryStickCapTimerRef.current = setTimeout(() => { entryStickCapTimerRef.current = null; endEntryStick(); }, ENTRY_STICK_MAX_MS); }, [armEntryStickQuiet, endEntryStick]); // ── overflow / scroll-to-bottom button ────────────────────────────────── const updateOverflowAndButton = React.useCallback(() => { const container = scrollRef.current; if (!container) { setIsOverflowing(false); setShowScrollButton(false); return; } const overflowing = canScroll(container); setIsOverflowing(overflowing); if (!overflowing) { setShowScrollButton(false); return; } const showButton = stateRef.current === 'released' && !isNearBottom(container, isMobileRef.current); setShowScrollButton(showButton); }, []); // ── core scroll primitives ─────────────────────────────────────────────── const scrollToBottomNow = React.useCallback((behavior: ScrollBehavior) => { const el = scrollRef.current; if (!el) return; markAuto(el); // `scrollHeight` is rounded to an integer while the real content height // is fractional (prose line-heights), so `scrollTop = scrollHeight` // leaves a 0–1px remainder that oscillates per streamed token and makes // bottom-anchored rows jitter vertically. An over-large target clamps to // the exact fractional maximum instead, pinning content to the bottom. const overshootTarget = el.scrollHeight + 4096; if (behavior === 'smooth') { el.scrollTo({ top: overshootTarget, behavior }); return; } // Direct `scrollTop` assignment bypasses any CSS `scroll-behavior: smooth` // and lands in the same frame — no visible catch-up animation. el.scrollTop = overshootTarget; }, [markAuto]); // `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; // Passive follow only while active (working/settling). Forced jumps // (send, go-to-bottom, session restore) always proceed. if (!force && !isActive()) return; if (force && stateRef.current !== 'following') { setStateValue('following'); } if (!el) return; if (!force && stateRef.current !== 'following') return; // Always re-pin, even when already within tolerance of the bottom. // Sub-tolerance growth (fractional line-height remainders) would // otherwise leave the bottom drifting by up to ±AUTO_MATCH_TOLERANCE_PX // between full re-pins, which reads as 1px vertical jitter on // bottom-anchored rows during streaming. The write happens pre-paint // (ResizeObserver) and is a no-op when the position is unchanged. scrollToBottomNow(force ? behavior : 'auto'); }, [isActive, scrollToBottomNow, setStateValue]); // 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'); updateOverflowAndButton(); }, [setStateValue, updateOverflowAndButton]); // ── public scroll API (mapped onto the primitives) ─────────────────────── const goToBottom = React.useCallback((mode: 'instant' | 'smooth' = 'instant') => { scrollToBottom(true, mode === 'smooth' ? 'smooth' : 'auto'); }, [scrollToBottom]); const scrollToBottomOnSend = React.useCallback(() => { // 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(() => { // A genuine user gesture (wheel/touch/key/scrollbar) cancels the entry // window immediately so we never fight the user's read position. endEntryStick(); stop(); }, [endEntryStick, stop]); // ── per-session snapshot persistence (kept; restore still goes to bottom) ─ const flushSave = React.useCallback(() => { if (saveTimerRef.current !== null) { clearTimeout(saveTimerRef.current); saveTimerRef.current = null; } const pending = pendingSaveRef.current; if (!pending) return; const container = scrollRef.current; if (!container) { pendingSaveRef.current = null; return; } updateViewportAnchor(pending.sessionId, pending.anchor, { scrollTop: container.scrollTop, scrollHeight: container.scrollHeight, clientHeight: container.clientHeight, }); pendingSaveRef.current = null; }, [updateViewportAnchor]); const queueSave = React.useCallback(() => { const sessionId = currentSessionIdRef.current; if (!sessionId) return; const container = scrollRef.current; if (!container) return; const { scrollTop, scrollHeight, clientHeight } = container; const anchorRatio = scrollHeight > 0 ? (scrollTop + clientHeight / 2) / scrollHeight : 0; const anchor = Math.floor(anchorRatio * sessionMessageCountRef.current); pendingSaveRef.current = { sessionId, anchor }; if (saveTimerRef.current !== null) return; saveTimerRef.current = setTimeout(() => { saveTimerRef.current = null; flushSave(); }, SAVE_DEBOUNCE_MS); }, [flushSave]); const saveSnapshotNow = React.useCallback(() => { flushSave(); }, [flushSave]); const restoreSnapshot = React.useCallback(async (): Promise => { const sessionKey = currentSessionKeyRef.current; if (!sessionKey) return false; const container = scrollRef.current; if (!container) { // ChatViewport not mounted yet (e.g., session still hydrating). // Record the request so the container-attach effect can replay it. pendingInitialRestoreRef.current = sessionKey; setStateValue('following'); return false; } pendingInitialRestoreRef.current = null; // Always return to the bottom on session switch. The content // ResizeObserver re-pins instantly as late // history measures in, so there is no smooth scroll-from-mid artifact. setStateValue('following'); scrollToBottom(true); // Hold the bottom across late async growth (e.g. task/subagent child // session data landing a beat after entry) until content quiesces or the // user scrolls. beginEntryStick(); updateOverflowAndButton(); return false; }, [beginEntryStick, scrollToBottom, setStateValue, updateOverflowAndButton]); // ── session change ─────────────────────────────────────────────────────── React.useEffect(() => { if (!currentSessionId || !currentSessionKey || currentSessionKey === lastSessionKeyRef.current) { return; } lastSessionKeyRef.current = currentSessionKey; MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId); flushSave(); autoRef.current = null; // Drop any pending restore request inherited from a different session. if (pendingInitialRestoreRef.current && pendingInitialRestoreRef.current !== currentSessionKey) { pendingInitialRestoreRef.current = null; } }, [currentSessionId, currentSessionKey, flushSave]); // When work begins and we are still // following, pin to the bottom. When work stops, keep following alive for a // short settle window so the final content lands at the bottom, then go // idle (after which passive follow is disabled — see `isActive`). React.useEffect(() => { settlingRef.current = false; if (settleTimerRef.current) { clearTimeout(settleTimerRef.current); settleTimerRef.current = null; } if (sessionIsWorking) { if (stateRef.current === 'following') { scrollToBottom(true); } return; } settlingRef.current = true; settleTimerRef.current = setTimeout(() => { settlingRef.current = false; settleTimerRef.current = null; }, SETTLE_MS); }, [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, // preventing a visible flash of content at the wrong scroll position. React.useLayoutEffect(() => { if (!containerEl) return; if (pendingInitialRestoreRef.current && pendingInitialRestoreRef.current === currentSessionKey) { void restoreSnapshot(); } }, [containerEl, currentSessionKey, restoreSnapshot]); // ── scroll event handling ──────────────────────────────────────────────── const handleScrollEvent = React.useCallback(() => { 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)) { setStateValue('following'); return; } // Within the bottom zone → (re-)pin to following. This is how scrolling // 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)) { const atTrueBottom = distanceFromBottom(el) <= AUTO_MATCH_TOLERANCE_PX; if (scrollingDown || stateRef.current === 'following' || atTrueBottom) { setStateValue('following'); } queueSave(); return; } // 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; } // Genuine user scroll away from the bottom. stop(); queueSave(); }, [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; releaseFromUserIntent(); }; let touchLastY: number | null = null; const handleTouchStart = (event: TouchEvent) => { const touch = event.touches.item(0); touchLastY = touch ? touch.clientY : null; }; const handleTouchMove = (event: TouchEvent) => { const touch = event.touches.item(0); if (!touch) { touchLastY = null; return; } const previousY = touchLastY; touchLastY = touch.clientY; if (previousY === null) return; const fingerDelta = touch.clientY - previousY; if (fingerDelta <= TOUCH_FINGER_DOWN_THRESHOLD) return; if (nestedScrollableCanConsumeUp(container, event.target)) return; releaseFromUserIntent(); }; const handleTouchEnd = () => { touchLastY = null; }; const handleKeyDown = (event: KeyboardEvent) => { if (!isReleaseKey(event)) return; releaseFromUserIntent(); }; const handlePointerDownIntent = (event: PointerEvent) => { const target = event.target; if (!(target instanceof Element)) return; if (!target.closest('[data-overlay-scrollbar-thumb]')) return; releaseFromUserIntent(); }; container.addEventListener('scroll', handleScrollEvent, { passive: true }); container.addEventListener('wheel', handleWheel, { passive: true }); container.addEventListener('touchstart', handleTouchStart, { passive: true }); container.addEventListener('touchmove', handleTouchMove, { passive: true }); container.addEventListener('touchend', handleTouchEnd, { passive: true }); container.addEventListener('touchcancel', handleTouchEnd, { passive: true }); container.addEventListener('keydown', handleKeyDown); if (typeof window !== 'undefined') { window.addEventListener('pointerdown', handlePointerDownIntent, true); } return () => { container.removeEventListener('scroll', handleScrollEvent); container.removeEventListener('wheel', handleWheel); container.removeEventListener('touchstart', handleTouchStart); container.removeEventListener('touchmove', handleTouchMove); container.removeEventListener('touchend', handleTouchEnd); container.removeEventListener('touchcancel', handleTouchEnd); container.removeEventListener('keydown', handleKeyDown); if (typeof window !== 'undefined') { window.removeEventListener('pointerdown', handlePointerDownIntent, true); } }; }, [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(() => { // Keyboard slide in flight: the container/composer resizes it reports // are part of the transform choreography — the settle handler does the // single deterministic re-pin, so chasing here would just fight it. if (keyboardAnimRef.current) { updateOverflowAndButton(); return; } const el = scrollRef.current; if (el && !canScroll(el)) { setStateValue('following'); updateOverflowAndButton(); return; } updateOverflowAndButton(); // Entry-stick window: on first session open, FORCE the bottom on // every growth so late async data (task/subagent child rows, code // highlight, mermaid) can't strand the viewport mid-history. Force // overrides any false `released` from the growth itself; only a real // user gesture clears the window (releaseFromUserIntent). if (entryStickRef.current && el) { const grew = el.scrollHeight > entryStickLastHeightRef.current + 1; entryStickLastHeightRef.current = el.scrollHeight; scrollToBottom(true); if (grew) armEntryStickQuiet(); return; } // Idle resize = layout churn (virtualizer re-measurement, async // tool/code rendering), NOT live growth. Never re-pin when idle, or // tall items re-measuring as the user scrolls cause an endless // scroll-to-bottom/re-measure twitch. if (!isActive()) return; if (stateRef.current !== 'following') return; scrollToBottom(false); }); observer.observe(container); const inner = container.firstElementChild; if (inner instanceof Element) { observer.observe(inner); } return () => observer.disconnect(); }, [armEntryStickQuiet, containerEl, isActive, scrollToBottom, setStateValue, updateOverflowAndButton]); // ── native keyboard transitions (Capacitor choreography) ──────────────── // The chat scroller gets NO transforms during the keyboard transition: // transforming the scroll container (or its content) forces WebKit to // rebuild the composited scrolling layers, which stalls for seconds on // long chats. Instead the chat repositions with instant snaps that hide // behind the keyboard itself: // show: content stays put while the keyboard/composer slide over it; the // settled event (shell layout snap) does ONE instant re-pin. // hide: the shell layout is restored up-front — the scrollTop clamp // happens while the keyboard still covers that region — and the // settled event re-pins once at the end. // During the window we only guard the scroll heuristics and the observer // chase. These events never fire outside the Capacitor app. React.useEffect(() => { if (typeof window === 'undefined') return; const handleKeyboardAnim = (event: Event) => { const detail = (event as CustomEvent<{ phase: 'show' | 'hide'; slide: number; durationMs: number; easing: string }>).detail; if (!detail) return; keyboardAnimRef.current = true; // The clamp/resize during the choreography can dispatch scroll events // that land away from the auto marker — never read those as a user // scroll-away. animationGuardUntilRef.current = now() + detail.durationMs + ANIMATION_GUARD_MS; }; const handleKeyboardSettled = () => { keyboardAnimRef.current = false; const el = scrollRef.current; if (!el) { updateOverflowAndButton(); return; } // Single deterministic re-pin, same task as the layout swap → lands // before paint. (scrollToBottomNow, not scrollToBottom: this must not // be gated on working/settling — the keyboard resize is a viewport // change, not content growth.) if (stateRef.current === 'following' && canScroll(el)) { scrollToBottomNow('auto'); } updateOverflowAndButton(); }; window.addEventListener('oc:keyboard-anim', handleKeyboardAnim); window.addEventListener('oc:keyboard-settled', handleKeyboardSettled); return () => { window.removeEventListener('oc:keyboard-anim', handleKeyboardAnim); window.removeEventListener('oc:keyboard-settled', handleKeyboardSettled); keyboardAnimRef.current = false; }; }, [scrollToBottomNow, updateOverflowAndButton]); React.useEffect(() => { updateOverflowAndButton(); }, [sessionMessageCount, updateOverflowAndButton]); 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' // here) must keep us pinned and refresh the quiescence timer, even though // the session is idle. if (entryStickRef.current) { scrollToBottom(true); armEntryStickQuiet(); return; } if (stateRef.current === 'following') { scrollToBottom(false); } }, [armEntryStickQuiet, scrollToBottom, updateOverflowAndButton]); const animationHandlersRef = React.useRef>(new Map()); const getAnimationHandlers = React.useCallback((messageId: string): AnimationHandlers => { const cached = animationHandlersRef.current.get(messageId); if (cached) return cached; const kick = () => { if (stateRef.current === 'following') { scrollToBottom(false); } }; const handlers: AnimationHandlers = { onChunk: kick, onComplete: () => { updateOverflowAndButton(); }, onStreamingCandidate: () => {}, onAnimationStart: () => {}, onAnimatedHeightChange: kick, onReservationCancelled: () => {}, onReasoningBlock: () => {}, }; animationHandlersRef.current.set(messageId, handlers); return handlers; }, [scrollToBottom, updateOverflowAndButton]); React.useEffect(() => { return () => { if (autoTimerRef.current) { clearTimeout(autoTimerRef.current); autoTimerRef.current = null; } if (settleTimerRef.current) { clearTimeout(settleTimerRef.current); settleTimerRef.current = null; } endEntryStick(); flushSave(); if (saveTimerRef.current !== null) { clearTimeout(saveTimerRef.current); saveTimerRef.current = null; } }; }, [endEntryStick, flushSave]); React.useEffect(() => { if (!onActiveTurnChange) return; const container = containerEl; if (!container) return; let lastActiveTurnId: string | null = null; const spy = createScrollSpy({ onActive: (turnId) => { if (turnId === lastActiveTurnId) return; lastActiveTurnId = turnId; onActiveTurnChange(turnId); }, }); spy.setContainer(container); const elementByTurnId = new Map(); const registerTurnNode = (node: HTMLElement) => { const turnId = node.dataset.turnId; if (!turnId) return false; elementByTurnId.set(turnId, node); spy.register(node, turnId); return true; }; const unregisterTurnNode = (node: HTMLElement) => { const turnId = node.dataset.turnId; if (!turnId) return false; if (elementByTurnId.get(turnId) !== node) return false; elementByTurnId.delete(turnId); spy.unregister(turnId); return true; }; const collectTurnNodes = (node: Node): HTMLElement[] => { if (!(node instanceof HTMLElement)) return []; const collected: HTMLElement[] = []; if (node.matches('[data-turn-id]')) collected.push(node); node.querySelectorAll('[data-turn-id]').forEach((el) => collected.push(el)); return collected; }; container.querySelectorAll('[data-turn-id]').forEach(registerTurnNode); spy.markDirty(); const mutationObserver = new MutationObserver((records) => { let changed = false; records.forEach((record) => { record.removedNodes.forEach((node) => { collectTurnNodes(node).forEach((turnNode) => { if (unregisterTurnNode(turnNode)) changed = true; }); }); record.addedNodes.forEach((node) => { collectTurnNodes(node).forEach((turnNode) => { if (registerTurnNode(turnNode)) changed = true; }); }); }); if (changed) spy.markDirty(); }); mutationObserver.observe(container, { subtree: true, childList: true }); const onScroll = () => spy.onScroll(); container.addEventListener('scroll', onScroll, { passive: true }); return () => { container.removeEventListener('scroll', onScroll); mutationObserver.disconnect(); spy.destroy(); }; }, [containerEl, onActiveTurnChange]); return { scrollRef, state, isPinned: state === 'following', isOverflowing, isFollowingProgrammatically, showScrollButton, notifyContentChange, getAnimationHandlers, goToBottom, scrollToBottomOnSend, releaseAutoFollow, saveSnapshotNow, restoreSnapshot, }; };