Files
openchamber/packages/ui/src/hooks/useChatScrollManager.ts
T

568 lines
21 KiB
TypeScript
Raw Normal View History

2025-12-07 19:32:53 +02:00
import React from 'react';
2026-01-03 13:39:59 +02:00
import type { Part } from '@opencode-ai/sdk/v2';
2025-12-07 19:32:53 +02:00
import { MessageFreshnessDetector } from '@/lib/messageFreshness';
import { createScrollSpy } from '@/components/chat/lib/scroll/scrollSpy';
import {
isNearBottom,
normalizeWheelDelta,
shouldPauseAutoScrollOnWheel,
} from '@/components/chat/lib/scroll/scrollIntent';
2025-12-07 19:32:53 +02:00
import { useScrollEngine } from './useScrollEngine';
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect;
export type ContentChangeReason = 'text' | 'structural' | 'permission';
interface ChatMessageRecord {
info: Record<string, unknown>;
parts: Part[];
}
interface SessionMemoryState {
viewportAnchor: number;
isStreaming: boolean;
lastAccessedAt: number;
backgroundMessageCount: number;
totalAvailableMessages?: number;
hasMoreAbove?: boolean;
streamStartTime?: number;
isZombie?: boolean;
}
interface UseChatScrollManagerOptions {
currentSessionId: string | null;
sessionMessages: ChatMessageRecord[];
sessionPermissions: unknown[];
streamingMessageId: string | null;
sessionMemoryState: Map<string, SessionMemoryState>;
updateViewportAnchor: (sessionId: string, anchor: number) => void;
isSyncing: boolean;
isMobile: boolean;
chatRenderMode?: 'sorted' | 'live';
2025-12-07 19:32:53 +02:00
messageStreamStates: Map<string, unknown>;
onActiveTurnChange?: (turnId: string | null) => void;
2025-12-07 19:32:53 +02:00
}
export interface AnimationHandlers {
onChunk: () => void;
onComplete: () => void;
onStreamingCandidate?: () => void;
onAnimationStart?: () => void;
onReservationCancelled?: () => void;
onReasoningBlock?: () => void;
onAnimatedHeightChange?: (height: number) => void;
}
interface UseChatScrollManagerResult {
scrollRef: React.RefObject<HTMLDivElement | null>;
handleMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
showScrollButton: boolean;
2026-01-20 03:23:39 +02:00
scrollToBottom: (options?: { instant?: boolean; force?: boolean }) => void;
scrollToPosition: (position: number, options?: { instant?: boolean }) => void;
releasePinnedScroll: () => void;
2026-01-20 03:23:39 +02:00
isPinned: boolean;
isOverflowing: boolean;
isProgrammaticFollowActive: boolean;
2025-12-07 19:32:53 +02:00
}
2026-01-19 02:48:56 +02:00
const PROGRAMMATIC_SCROLL_SUPPRESS_MS = 200;
const DIRECT_SCROLL_INTENT_WINDOW_MS = 250;
2026-01-20 03:23:39 +02:00
// Threshold for re-pinning: 10% of container height (matches bottom spacer)
const PIN_THRESHOLD_RATIO = 0.10;
const REPIN_BLOCK_AFTER_RELEASE_MS = 4000;
const STRICT_REPIN_DISTANCE_PX = 160;
const SORTED_PIN_THRESHOLD_PX = 24;
2026-01-19 02:48:56 +02:00
2025-12-07 19:32:53 +02:00
export const useChatScrollManager = ({
currentSessionId,
sessionMessages,
streamingMessageId,
2025-12-07 19:32:53 +02:00
updateViewportAnchor,
isSyncing,
isMobile,
chatRenderMode = 'live',
onActiveTurnChange,
2025-12-07 19:32:53 +02:00
}: UseChatScrollManagerOptions): UseChatScrollManagerResult => {
const scrollRef = React.useRef<HTMLDivElement | null>(null);
const scrollEngine = useScrollEngine({ containerRef: scrollRef, isMobile });
2026-01-20 03:23:39 +02:00
const getPinThreshold = React.useCallback(() => {
const container = scrollRef.current;
if (!container || container.clientHeight <= 0) {
return 0;
}
const raw = container.clientHeight * PIN_THRESHOLD_RATIO;
return Math.max(24, Math.min(200, raw));
}, []);
const getAutoFollowThreshold = React.useCallback(() => {
if (chatRenderMode === 'sorted') {
return SORTED_PIN_THRESHOLD_PX;
}
return getPinThreshold();
}, [chatRenderMode, getPinThreshold]);
2025-12-07 19:32:53 +02:00
const [showScrollButton, setShowScrollButton] = React.useState(false);
2026-01-20 03:23:39 +02:00
const [isPinned, setIsPinned] = React.useState(true);
const [isOverflowing, setIsOverflowing] = React.useState(false);
2025-12-07 19:32:53 +02:00
const lastSessionIdRef = React.useRef<string | null>(null);
2026-01-19 02:48:56 +02:00
const suppressUserScrollUntilRef = React.useRef<number>(0);
const lastDirectScrollIntentAtRef = React.useRef<number>(0);
2026-01-20 03:23:39 +02:00
const isPinnedRef = React.useRef(true);
const repinBlockedUntilRef = React.useRef<number>(0);
2026-01-20 03:23:39 +02:00
const lastScrollTopRef = React.useRef<number>(0);
const touchLastYRef = React.useRef<number | null>(null);
2025-12-07 19:32:53 +02:00
2026-01-20 03:23:39 +02:00
const markProgrammaticScroll = React.useCallback(() => {
suppressUserScrollUntilRef.current = Date.now() + PROGRAMMATIC_SCROLL_SUPPRESS_MS;
2025-12-07 19:32:53 +02:00
}, []);
2026-01-20 03:23:39 +02:00
const getDistanceFromBottom = React.useCallback(() => {
const container = scrollRef.current;
if (!container) return 0;
return container.scrollHeight - container.scrollTop - container.clientHeight;
}, []);
const isStrictlyAtBottom = React.useCallback((distanceFromBottom: number) => {
return distanceFromBottom <= STRICT_REPIN_DISTANCE_PX;
}, []);
2026-01-20 03:23:39 +02:00
const updatePinnedState = React.useCallback((newPinned: boolean) => {
if (isPinnedRef.current !== newPinned) {
isPinnedRef.current = newPinned;
setIsPinned(newPinned);
}
}, []);
2026-01-20 03:23:39 +02:00
const scrollToBottomInternal = React.useCallback((options?: { instant?: boolean; followBottom?: boolean }) => {
2025-12-07 19:32:53 +02:00
const container = scrollRef.current;
2026-01-20 03:23:39 +02:00
if (!container) return;
2026-01-19 02:48:56 +02:00
2026-01-20 03:23:39 +02:00
const bottom = container.scrollHeight - container.clientHeight;
markProgrammaticScroll();
scrollEngine.scrollToPosition(Math.max(0, bottom), options);
}, [markProgrammaticScroll, scrollEngine]);
const scrollPinnedToBottom = React.useCallback(() => {
if (Date.now() < repinBlockedUntilRef.current) {
return;
}
if (streamingMessageId) {
scrollToBottomInternal({ followBottom: true });
return;
}
scrollToBottomInternal({ instant: true });
}, [scrollToBottomInternal, streamingMessageId]);
2025-12-07 19:32:53 +02:00
const updateScrollButtonVisibility = React.useCallback(() => {
const container = scrollRef.current;
if (!container) {
setShowScrollButton(false);
setIsOverflowing(false);
2025-12-07 19:32:53 +02:00
return;
}
const hasScrollableContent = container.scrollHeight > container.clientHeight;
setIsOverflowing(hasScrollableContent);
2025-12-07 19:32:53 +02:00
if (!hasScrollableContent) {
setShowScrollButton(false);
return;
}
2026-01-20 03:23:39 +02:00
// Show scroll button when scrolled above the 10vh threshold
const distanceFromBottom = getDistanceFromBottom();
setShowScrollButton(!isNearBottom(distanceFromBottom, getPinThreshold()));
2026-01-20 03:23:39 +02:00
}, [getDistanceFromBottom, getPinThreshold]);
2025-12-07 19:32:53 +02:00
2026-01-20 03:23:39 +02:00
const scrollToPosition = React.useCallback((position: number, options?: { instant?: boolean }) => {
2025-12-07 19:32:53 +02:00
const container = scrollRef.current;
if (!container) return;
2026-01-19 02:48:56 +02:00
markProgrammaticScroll();
2026-01-20 03:23:39 +02:00
scrollEngine.scrollToPosition(Math.max(0, position), options);
}, [markProgrammaticScroll, scrollEngine]);
2025-12-07 19:32:53 +02:00
2026-01-20 03:23:39 +02:00
const scrollToBottom = React.useCallback((options?: { instant?: boolean; force?: boolean }) => {
const container = scrollRef.current;
if (!container) return;
2026-01-19 02:48:56 +02:00
2026-01-20 03:23:39 +02:00
// Re-pin when explicitly scrolling to bottom
repinBlockedUntilRef.current = 0;
2026-01-20 03:23:39 +02:00
updatePinnedState(true);
2026-01-19 02:48:56 +02:00
2026-01-20 03:23:39 +02:00
scrollToBottomInternal(options);
setShowScrollButton(false);
}, [scrollToBottomInternal, updatePinnedState]);
2025-12-07 19:32:53 +02:00
const releasePinnedScroll = React.useCallback(() => {
scrollEngine.cancelFollow();
repinBlockedUntilRef.current = Date.now() + REPIN_BLOCK_AFTER_RELEASE_MS;
updatePinnedState(false);
updateScrollButtonVisibility();
}, [scrollEngine, updatePinnedState, updateScrollButtonVisibility]);
2025-12-13 16:34:17 +02:00
const handleScrollEvent = React.useCallback((event?: Event) => {
2025-12-07 19:32:53 +02:00
const container = scrollRef.current;
if (!container || !currentSessionId) {
return;
}
const now = Date.now();
2026-01-20 03:23:39 +02:00
const isProgrammatic = now < suppressUserScrollUntilRef.current;
const hasDirectIntent = now - lastDirectScrollIntentAtRef.current <= DIRECT_SCROLL_INTENT_WINDOW_MS;
2026-01-19 02:48:56 +02:00
2025-12-07 19:32:53 +02:00
scrollEngine.handleScroll();
updateScrollButtonVisibility();
2026-01-20 03:23:39 +02:00
// Handle pin/unpin logic
const currentScrollTop = container.scrollTop;
const distanceFromBottom = getDistanceFromBottom();
2026-01-20 03:23:39 +02:00
const scrollingUp = currentScrollTop < lastScrollTopRef.current;
// Unpin whenever we move away from bottom.
// Also handle programmatic jumps to older content (timeline navigation)
// so we don't snap back to bottom on the next content update.
if (isPinnedRef.current) {
const nearBottom = isNearBottom(distanceFromBottom, getPinThreshold());
const scrollingUpByUserIntent = Boolean(!isProgrammatic && event?.isTrusted && hasDirectIntent && scrollingUp);
const programmaticJumpAwayFromBottom = Boolean(!event?.isTrusted && scrollingUp && !nearBottom);
if (scrollingUpByUserIntent || programmaticJumpAwayFromBottom) {
2026-01-20 03:23:39 +02:00
updatePinnedState(false);
}
}
// Re-pin only when returning to bottom, not while still scrolling up.
if (!isPinnedRef.current && now >= repinBlockedUntilRef.current) {
if (event?.isTrusted && !scrollingUp && isStrictlyAtBottom(distanceFromBottom)) {
2026-01-20 03:23:39 +02:00
updatePinnedState(true);
}
2025-12-07 19:32:53 +02:00
}
2026-01-20 03:23:39 +02:00
lastScrollTopRef.current = currentScrollTop;
2025-12-07 19:32:53 +02:00
const { scrollTop, scrollHeight, clientHeight } = container;
const position = (scrollTop + clientHeight / 2) / Math.max(scrollHeight, 1);
const estimatedIndex = Math.floor(position * sessionMessages.length);
updateViewportAnchor(currentSessionId, estimatedIndex);
}, [
currentSessionId,
2026-01-20 03:23:39 +02:00
getDistanceFromBottom,
getPinThreshold,
isStrictlyAtBottom,
2025-12-07 19:32:53 +02:00
scrollEngine,
sessionMessages.length,
2026-01-20 03:23:39 +02:00
updatePinnedState,
2025-12-07 19:32:53 +02:00
updateScrollButtonVisibility,
updateViewportAnchor,
]);
const handleWheelIntent = React.useCallback((event: WheelEvent) => {
const container = scrollRef.current;
if (!container) {
return;
}
const delta = normalizeWheelDelta({
deltaY: event.deltaY,
deltaMode: event.deltaMode,
rootHeight: container.clientHeight,
});
// Scrolling up while pinned → unpin and kill follow loop immediately
if (isPinnedRef.current && shouldPauseAutoScrollOnWheel({
root: container,
target: event.target,
delta,
})) {
scrollEngine.cancelFollow();
updatePinnedState(false);
return;
}
}, [scrollEngine, updatePinnedState]);
2025-12-07 19:32:53 +02:00
React.useEffect(() => {
const container = scrollRef.current;
if (!container) return;
const markDirectIntent = () => {
lastDirectScrollIntentAtRef.current = Date.now();
};
const handleTouchStartIntent = (event: TouchEvent) => {
markDirectIntent();
const touch = event.touches.item(0);
touchLastYRef.current = touch ? touch.clientY : null;
};
const handleTouchMoveIntent = (event: TouchEvent) => {
markDirectIntent();
const touch = event.touches.item(0);
if (!touch) {
touchLastYRef.current = null;
return;
}
const previousY = touchLastYRef.current;
touchLastYRef.current = touch.clientY;
if (previousY === null || !isPinnedRef.current) {
return;
}
const fingerDelta = touch.clientY - previousY;
if (Math.abs(fingerDelta) < 2) {
return;
}
const syntheticWheelDelta = -fingerDelta;
if (syntheticWheelDelta >= 0) {
return;
}
if (shouldPauseAutoScrollOnWheel({
root: container,
target: event.target,
delta: syntheticWheelDelta,
})) {
scrollEngine.cancelFollow();
updatePinnedState(false);
}
};
const handleTouchEndIntent = () => {
touchLastYRef.current = null;
};
2025-12-13 16:34:17 +02:00
container.addEventListener('scroll', handleScrollEvent as EventListener, { passive: true });
container.addEventListener('touchstart', handleTouchStartIntent as EventListener, { passive: true });
container.addEventListener('touchmove', handleTouchMoveIntent as EventListener, { passive: true });
container.addEventListener('touchend', handleTouchEndIntent as EventListener, { passive: true });
container.addEventListener('touchcancel', handleTouchEndIntent as EventListener, { passive: true });
container.addEventListener('wheel', handleWheelIntent as EventListener, { passive: true });
container.addEventListener('wheel', markDirectIntent as EventListener, { passive: true });
2025-12-07 19:32:53 +02:00
return () => {
2025-12-13 16:34:17 +02:00
container.removeEventListener('scroll', handleScrollEvent as EventListener);
container.removeEventListener('touchstart', handleTouchStartIntent as EventListener);
container.removeEventListener('touchmove', handleTouchMoveIntent as EventListener);
container.removeEventListener('touchend', handleTouchEndIntent as EventListener);
container.removeEventListener('touchcancel', handleTouchEndIntent as EventListener);
container.removeEventListener('wheel', handleWheelIntent as EventListener);
container.removeEventListener('wheel', markDirectIntent as EventListener);
2025-12-07 19:32:53 +02:00
};
}, [handleScrollEvent, handleWheelIntent, scrollEngine, updatePinnedState]);
2025-12-07 19:32:53 +02:00
2026-01-20 03:23:39 +02:00
// Session switch - always start pinned at bottom
2025-12-07 19:32:53 +02:00
useIsomorphicLayoutEffect(() => {
2026-01-19 02:48:56 +02:00
if (!currentSessionId || currentSessionId === lastSessionIdRef.current) {
return;
}
2025-12-07 19:32:53 +02:00
2026-01-19 02:48:56 +02:00
lastSessionIdRef.current = currentSessionId;
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
2025-12-07 19:32:53 +02:00
2026-01-20 03:23:39 +02:00
// Always start pinned at bottom on session switch
updatePinnedState(true);
2026-01-19 02:48:56 +02:00
setShowScrollButton(false);
const container = scrollRef.current;
2026-01-20 03:23:39 +02:00
if (container) {
markProgrammaticScroll();
scrollToBottomInternal({ instant: true });
2025-12-07 19:32:53 +02:00
}
2026-01-20 03:23:39 +02:00
}, [currentSessionId, markProgrammaticScroll, scrollToBottomInternal, updatePinnedState]);
2025-12-07 19:32:53 +02:00
2026-01-20 03:23:39 +02:00
// Maintain pin-to-bottom when content changes
React.useEffect(() => {
if (!isPinnedRef.current) return;
if (Date.now() < repinBlockedUntilRef.current) return;
2026-01-20 03:23:39 +02:00
if (isSyncing) return;
2025-12-07 19:32:53 +02:00
2026-01-20 03:23:39 +02:00
const container = scrollRef.current;
if (!container) return;
2025-12-07 19:32:53 +02:00
// When pinned and content grows, follow bottom with fast smooth scroll
2026-01-20 03:23:39 +02:00
const distanceFromBottom = getDistanceFromBottom();
if (distanceFromBottom > getAutoFollowThreshold()) {
scrollPinnedToBottom();
2025-12-07 19:32:53 +02:00
}
}, [getAutoFollowThreshold, getDistanceFromBottom, isSyncing, scrollPinnedToBottom, sessionMessages]);
2025-12-07 19:32:53 +02:00
2026-01-20 03:23:39 +02:00
// Use ResizeObserver to detect content changes and maintain pin
2025-12-07 19:32:53 +02:00
React.useEffect(() => {
const container = scrollRef.current;
if (!container || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => {
updateScrollButtonVisibility();
2026-01-20 03:23:39 +02:00
// Maintain pin when content grows - fast smooth follow
if (isPinnedRef.current && Date.now() >= repinBlockedUntilRef.current) {
2026-01-20 03:23:39 +02:00
const distanceFromBottom = getDistanceFromBottom();
if (distanceFromBottom > getAutoFollowThreshold()) {
scrollPinnedToBottom();
2026-01-20 03:23:39 +02:00
}
}
2025-12-07 19:32:53 +02:00
});
observer.observe(container);
2026-01-20 03:23:39 +02:00
// Also observe children for content changes
const childObserver = new MutationObserver(() => {
if (isPinnedRef.current && Date.now() >= repinBlockedUntilRef.current) {
2026-01-20 03:23:39 +02:00
const distanceFromBottom = getDistanceFromBottom();
if (distanceFromBottom > getAutoFollowThreshold()) {
scrollPinnedToBottom();
2026-01-20 03:23:39 +02:00
}
}
});
childObserver.observe(container, { childList: true, subtree: true });
2025-12-07 19:32:53 +02:00
return () => {
observer.disconnect();
2026-01-20 03:23:39 +02:00
childObserver.disconnect();
2025-12-07 19:32:53 +02:00
};
}, [getAutoFollowThreshold, getDistanceFromBottom, scrollPinnedToBottom, updateScrollButtonVisibility]);
2025-12-07 19:32:53 +02:00
React.useEffect(() => {
if (typeof window === 'undefined') {
updateScrollButtonVisibility();
return;
}
const rafId = window.requestAnimationFrame(() => {
updateScrollButtonVisibility();
});
return () => {
window.cancelAnimationFrame(rafId);
};
}, [currentSessionId, sessionMessages.length, updateScrollButtonVisibility]);
2025-12-07 19:32:53 +02:00
const animationHandlersRef = React.useRef<Map<string, AnimationHandlers>>(new Map());
const handleMessageContentChange = React.useCallback(() => {
updateScrollButtonVisibility();
// Maintain pin when content changes - fast smooth follow
if (isPinnedRef.current && Date.now() >= repinBlockedUntilRef.current) {
2026-01-20 03:23:39 +02:00
const distanceFromBottom = getDistanceFromBottom();
if (distanceFromBottom > getAutoFollowThreshold()) {
scrollPinnedToBottom();
2026-01-20 03:23:39 +02:00
}
}
}, [getAutoFollowThreshold, getDistanceFromBottom, scrollPinnedToBottom, updateScrollButtonVisibility]);
const getAnimationHandlers = React.useCallback((messageId: string): AnimationHandlers => {
2025-12-07 19:32:53 +02:00
const existing = animationHandlersRef.current.get(messageId);
if (existing) {
return existing;
}
const handlers: AnimationHandlers = {
onChunk: () => {
2026-01-19 02:48:56 +02:00
updateScrollButtonVisibility();
if (isPinnedRef.current && Date.now() >= repinBlockedUntilRef.current) {
2026-01-20 03:23:39 +02:00
const distanceFromBottom = getDistanceFromBottom();
if (distanceFromBottom > getAutoFollowThreshold()) {
scrollPinnedToBottom();
2026-01-20 03:23:39 +02:00
}
}
2025-12-07 19:32:53 +02:00
},
onComplete: () => {
2026-01-19 02:48:56 +02:00
updateScrollButtonVisibility();
2025-12-07 19:32:53 +02:00
},
2026-01-20 03:23:39 +02:00
onStreamingCandidate: () => {},
onAnimationStart: () => {},
2025-12-07 19:32:53 +02:00
onAnimatedHeightChange: () => {
2026-01-19 02:48:56 +02:00
updateScrollButtonVisibility();
if (isPinnedRef.current && Date.now() >= repinBlockedUntilRef.current) {
2026-01-20 03:23:39 +02:00
const distanceFromBottom = getDistanceFromBottom();
if (distanceFromBottom > getAutoFollowThreshold()) {
scrollPinnedToBottom();
2026-01-20 03:23:39 +02:00
}
}
2025-12-07 19:32:53 +02:00
},
2026-01-20 03:23:39 +02:00
onReservationCancelled: () => {},
onReasoningBlock: () => {},
2025-12-07 19:32:53 +02:00
};
animationHandlersRef.current.set(messageId, handlers);
return handlers;
}, [getAutoFollowThreshold, getDistanceFromBottom, scrollPinnedToBottom, updateScrollButtonVisibility]);
React.useEffect(() => {
if (!onActiveTurnChange) {
return;
}
const container = scrollRef.current;
if (!container) {
onActiveTurnChange(null);
return;
}
const spy = createScrollSpy({
onActive: (turnId) => {
onActiveTurnChange(turnId);
},
});
spy.setContainer(container);
const registerTurns = () => {
spy.clear();
const turnNodes = container.querySelectorAll<HTMLElement>('[data-turn-id]');
turnNodes.forEach((node) => {
const turnId = node.dataset.turnId;
if (!turnId) {
return;
}
spy.register(node, turnId);
});
spy.markDirty();
};
registerTurns();
const mutationObserver = new MutationObserver(() => {
registerTurns();
});
mutationObserver.observe(container, { subtree: true, childList: true });
const handleScroll = () => {
spy.onScroll();
};
container.addEventListener('scroll', handleScroll, { passive: true });
return () => {
container.removeEventListener('scroll', handleScroll);
mutationObserver.disconnect();
spy.destroy();
onActiveTurnChange(null);
};
}, [currentSessionId, onActiveTurnChange, scrollRef, sessionMessages.length]);
2025-12-07 19:32:53 +02:00
return {
scrollRef,
handleMessageContentChange,
getAnimationHandlers,
showScrollButton,
scrollToBottom,
2026-01-20 03:23:39 +02:00
scrollToPosition,
releasePinnedScroll,
2026-01-20 03:23:39 +02:00
isPinned,
isOverflowing,
isProgrammaticFollowActive: scrollEngine.isFollowingBottom,
2025-12-07 19:32:53 +02:00
};
};