diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index d7c2d532..8f40a244 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -15,7 +15,6 @@ import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { useChatScrollManager, type AnimationHandlers, type ContentChangeReason } from '@/hooks/useChatScrollManager'; import { useChatTimelineController } from './hooks/useChatTimelineController'; import { useChatTurnNavigation } from './hooks/useChatTurnNavigation'; -import { useTimelineStaging } from '@/hooks/useTimelineStaging'; import { useDeviceInfo } from '@/lib/device'; import { Button } from '@/components/ui/button'; import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar'; @@ -311,6 +310,7 @@ export const ChatContainer: React.FC = () => { ); const sessionMessageCount = useSessionMessageCount(currentSessionId ?? ''); const [suspendDetachedTailUpdates, setSuspendDetachedTailUpdates] = React.useState(false); + const [forceLiveViewport, setForceLiveViewport] = React.useState(false); // Messages from sync system const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '', undefined, { suspendPartUpdates: suspendDetachedTailUpdates, @@ -366,6 +366,10 @@ export const ChatContainer: React.FC = () => { return false; } + if (streamingMessageId || activeStreamingPhase) { + return true; + } + const statusType = sessionStatusForCurrent.type ?? 'idle'; if (statusType === 'busy' || statusType === 'retry') { return true; @@ -377,7 +381,7 @@ export const ChatContainer: React.FC = () => { && lastMessage.role === 'assistant' && typeof (lastMessage as { time?: { completed?: number } }).time?.completed !== 'number', ); - }, [currentSessionId, sessionMessages, sessionPermissions.length, sessionStatusForCurrent.type]); + }, [activeStreamingPhase, currentSessionId, sessionMessages, sessionPermissions.length, sessionStatusForCurrent.type, streamingMessageId]); const activeRetryStatus = React.useMemo(() => { if (!currentSessionId || sessionStatusForCurrent.type !== 'retry') { return null; @@ -487,6 +491,7 @@ export const ChatContainer: React.FC = () => { scrollRef, handleMessageContentChange, getAnimationHandlers, + prepareForBottomResume, scrollToBottom, isPinned, isOverflowing, @@ -505,9 +510,9 @@ export const ChatContainer: React.FC = () => { }); React.useEffect(() => { - const next = Boolean(currentSessionId && streamingMessageId && !isPinned); + const next = Boolean(currentSessionId && streamingMessageId && !isPinned && !forceLiveViewport); setSuspendDetachedTailUpdates((previous) => (previous === next ? previous : next)); - }, [currentSessionId, isPinned, streamingMessageId]); + }, [currentSessionId, forceLiveViewport, isPinned, streamingMessageId]); const viewportMessagesRef = React.useRef(EMPTY_MESSAGES); const viewportSessionIdRef = React.useRef(null); @@ -522,6 +527,7 @@ export const ChatContainer: React.FC = () => { currentSessionId && streamingMessageId && !isPinned + && !forceLiveViewport && historyMeta?.loading !== true && canFreezeDetachedViewport(viewportMessagesRef.current, sessionMessages, streamingMessageId), ); @@ -532,28 +538,45 @@ export const ChatContainer: React.FC = () => { viewportMessagesRef.current = sessionMessages; return sessionMessages; - }, [currentSessionId, historyMeta?.loading, isPinned, sessionMessages, streamingMessageId]); - - // Deferred timeline staging — renders 1 message on first paint, - // adds 3 per rAF frame to avoid blocking. - const { stagedMessages } = useTimelineStaging({ - sessionKey: currentSessionId ?? '', - messages: viewportMessages, - }); + }, [currentSessionId, forceLiveViewport, historyMeta?.loading, isPinned, sessionMessages, streamingMessageId]); const timelineController = useChatTimelineController({ sessionId: currentSessionId, - messages: stagedMessages, + messages: viewportMessages, historyMeta, scrollRef, messageListRef, loadMoreMessages, + prepareForBottomResume, scrollToBottom, isPinned, isOverflowing, }); const { loadEarlier, resumeToBottomInstant } = timelineController; + const runLatestInstantResume = React.useCallback(async () => { + setForceLiveViewport(true); + try { + if (!currentSessionId) { + scrollToBottom({ instant: true, force: true }); + return; + } + await resumeToBottomInstant(); + } finally { + if (typeof window === 'undefined') { + setForceLiveViewport(false); + } else { + window.requestAnimationFrame(() => { + setForceLiveViewport(false); + }); + } + } + }, [currentSessionId, resumeToBottomInstant, scrollToBottom]); + + const resumeToLatestInstant = React.useCallback(() => { + void runLatestInstantResume(); + }, [runLatestInstantResume]); + React.useEffect(() => { activeTurnChangeRef.current = timelineController.handleActiveTurnChange; }, [timelineController.handleActiveTurnChange]); @@ -575,7 +598,7 @@ export const ChatContainer: React.FC = () => { activeTurnId: timelineController.activeTurnId, scrollToTurn: timelineController.scrollToTurn, scrollToMessage: timelineController.scrollToMessage, - resumeToBottom: timelineController.resumeToBottom, + resumeToBottom: timelineController.resumeToBottomInstant, }); React.useEffect(() => { @@ -585,7 +608,7 @@ export const ChatContainer: React.FC = () => { const customEvent = event as CustomEvent; if (customEvent.detail !== currentSessionId) return; if (isPinned || !isOverflowing || isProgrammaticFollowActive) return; - resumeToBottomInstant(); + void resumeToBottomInstant(); }; window.addEventListener(SESSION_RESELECTED_EVENT, handleSessionReselected as EventListener); @@ -632,11 +655,39 @@ export const ChatContainer: React.FC = () => { const hasHistoryMetadata = Boolean(historyMeta); const lastHydratedSessionRef = React.useRef(null); + const lastScrolledSessionRef = React.useRef(null); const isSessionHydrating = Boolean(currentSessionId) && (!hasSessionMessagesEntry || !hasHistoryMetadata || historyMeta?.loading === true); + React.useEffect(() => { + if (!currentSessionId) { + return; + } + + if (lastScrolledSessionRef.current === currentSessionId) { + return; + } + + const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0; + if (hasHashTarget) { + lastScrolledSessionRef.current = currentSessionId; + return; + } + + lastScrolledSessionRef.current = currentSessionId; + + if (typeof window === 'undefined') { + resumeToLatestInstant(); + return; + } + + window.requestAnimationFrame(() => { + resumeToLatestInstant(); + }); + }, [currentSessionId, resumeToLatestInstant]); + React.useEffect(() => { if (!currentSessionId) return; if (hasSessionMessagesEntry && hasHistoryMetadata) return; @@ -653,10 +704,10 @@ export const ChatContainer: React.FC = () => { if (!shouldSkipScroll) { if (typeof window === 'undefined') { - scrollToBottom({ instant: true }); + resumeToLatestInstant(); } else { window.requestAnimationFrame(() => { - scrollToBottom({ instant: true }); + resumeToLatestInstant(); }); } } @@ -664,7 +715,7 @@ export const ChatContainer: React.FC = () => { }; void load(); - }, [currentSessionId, hasHistoryMetadata, hasSessionMessagesEntry, isPinned, loadMessages, scrollToBottom, sessionMessages.length, sessionStatusForCurrent.type]); + }, [currentSessionId, hasHistoryMetadata, hasSessionMessagesEntry, isPinned, loadMessages, resumeToLatestInstant, sessionMessages.length, sessionStatusForCurrent.type]); if (!currentSessionId && !draftOpen) { return ( @@ -696,7 +747,7 @@ export const ChatContainer: React.FC = () => { : 'bg-background/95 supports-[backdrop-filter]:bg-background/80' )} > - + ); @@ -759,7 +810,7 @@ export const ChatContainer: React.FC = () => { : 'bg-background/95 supports-[backdrop-filter]:bg-background/80' )} > - + ); @@ -795,7 +846,7 @@ export const ChatContainer: React.FC = () => { : 'bg-background/95 supports-[backdrop-filter]:bg-background/80' )} > - + ); @@ -846,7 +897,7 @@ export const ChatContainer: React.FC = () => { onClick={navigation.resumeToLatest} /> )} - + ); diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 34192174..d40b050d 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -1305,9 +1305,6 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo return; } - // Re-pin and scroll to bottom when sending - scrollToBottom?.({ instant: true, force: true }); - if (!currentProviderId || !currentModelId) { console.warn('Cannot send message: provider or model not selected'); return; @@ -1483,7 +1480,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo ...additionalParts.flatMap(p => p.attachments ?? []), ]; - void sendMessage( + const sendPromise = sendMessage( primaryText, currentProviderId, currentModelId, @@ -1493,7 +1490,17 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo additionalParts.length > 0 ? additionalParts : undefined, currentVariant, inputMode - ).then(() => { + ); + + if (typeof window === 'undefined') { + scrollToBottom?.({ instant: true, force: true }); + } else { + window.requestAnimationFrame(() => { + scrollToBottom?.({ instant: true, force: true }); + }); + } + + void sendPromise.then(() => { // Clear linked issue after successful message send if (linkedIssue) { setLinkedIssue(null); diff --git a/packages/ui/src/components/chat/MarkdownRenderer.tsx b/packages/ui/src/components/chat/MarkdownRenderer.tsx index e16ffc5e..36ba3ab3 100644 --- a/packages/ui/src/components/chat/MarkdownRenderer.tsx +++ b/packages/ui/src/components/chat/MarkdownRenderer.tsx @@ -737,10 +737,10 @@ const buildMarkdownComponents = ({ return {children}; }, ul({ children, ...props }) { - return
    {children}
; + return
    {children}
; }, ol({ children, ...props }) { - return
    {children}
; + return
    {children}
; }, li({ children, ...props }) { return
  • {children}
  • ; diff --git a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts index 15f226c0..a466d7fb 100644 --- a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts +++ b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts @@ -32,7 +32,8 @@ interface UseChatTimelineControllerOptions { scrollRef: React.RefObject; messageListRef: React.RefObject; loadMoreMessages: (sessionId: string, direction: 'up' | 'down') => Promise; - scrollToBottom: (options?: { instant?: boolean; force?: boolean }) => void; + prepareForBottomResume: (options?: { instant?: boolean; force?: boolean }) => void; + scrollToBottom: (options?: { instant?: boolean; force?: boolean; followBottom?: boolean }) => void; isPinned: boolean; isOverflowing: boolean; } @@ -65,6 +66,7 @@ export const useChatTimelineController = ({ scrollRef, messageListRef, loadMoreMessages, + prepareForBottomResume, scrollToBottom, isPinned, isOverflowing, @@ -484,21 +486,35 @@ export const useChatTimelineController = ({ } }, [attemptPendingScrollRequest, sessionId]); - const resumeToBottom = React.useCallback(() => { + const resumeToBottom = React.useCallback(async () => { const nextStart = getInitialTurnStart(turnModelRef.current.turnCount); - setTurnStart(nextStart); setPendingRevealWork(false); setIsLoadingOlder(false); - scrollToBottom({ force: true }); - }, [scrollToBottom]); + prepareForBottomResume({ force: true }); - const resumeToBottomInstant = React.useCallback(() => { + const shouldWaitForRender = nextStart !== turnStartRef.current; + if (shouldWaitForRender) { + setTurnStart(nextStart); + await waitForNextRenderCommit(); + } + + scrollToBottom({ force: true }); + }, [prepareForBottomResume, scrollToBottom, waitForNextRenderCommit]); + + const resumeToBottomInstant = React.useCallback(async () => { const nextStart = getInitialTurnStart(turnModelRef.current.turnCount); - setTurnStart(nextStart); setPendingRevealWork(false); setIsLoadingOlder(false); - scrollToBottom({ instant: true, force: true }); - }, [scrollToBottom]); + prepareForBottomResume({ instant: true, force: true }); + + const shouldWaitForRender = nextStart !== turnStartRef.current; + if (shouldWaitForRender) { + setTurnStart(nextStart); + await waitForNextRenderCommit(); + } + + scrollToBottom({ instant: true, force: true, followBottom: true }); + }, [prepareForBottomResume, scrollToBottom, waitForNextRenderCommit]); const handleActiveTurnChange = React.useCallback((turnId: string | null) => { setActiveTurnId(turnId); diff --git a/packages/ui/src/hooks/useChatScrollManager.ts b/packages/ui/src/hooks/useChatScrollManager.ts index 1f08d96c..14c97997 100644 --- a/packages/ui/src/hooks/useChatScrollManager.ts +++ b/packages/ui/src/hooks/useChatScrollManager.ts @@ -45,11 +45,19 @@ export interface AnimationHandlers { onAnimatedHeightChange?: (height: number) => void; } +type FollowMode = 'none' | 'smooth'; + +type AutoScrollMarker = { + top: number; + at: number; +}; + interface UseChatScrollManagerResult { scrollRef: React.RefObject; handleMessageContentChange: (reason?: ContentChangeReason) => void; getAnimationHandlers: (messageId: string) => AnimationHandlers; showScrollButton: boolean; + prepareForBottomResume: (options?: { instant?: boolean; force?: boolean }) => void; scrollToBottom: (options?: { instant?: boolean; force?: boolean }) => void; scrollToPosition: (position: number, options?: { instant?: boolean }) => void; releasePinnedScroll: () => void; @@ -59,7 +67,6 @@ interface UseChatScrollManagerResult { } const PROGRAMMATIC_SCROLL_SUPPRESS_MS = 200; -const DIRECT_SCROLL_INTENT_WINDOW_MS = 250; // Threshold for re-pinning: 10% of container height (matches bottom spacer) const PIN_THRESHOLD_RATIO = 0.10; const VIEWPORT_ANCHOR_MIN_UPDATE_MS = 150; @@ -106,22 +113,40 @@ export const useChatScrollManager = ({ const isOverflowingRef = React.useRef(false); const lastSessionIdRef = React.useRef(null); - const suppressUserScrollUntilRef = React.useRef(0); - const lastDirectScrollIntentAtRef = React.useRef(0); const isPinnedRef = React.useRef(true); const lastScrollTopRef = React.useRef(0); const touchLastYRef = React.useRef(null); const pinnedSyncRafRef = React.useRef(null); - const preferInstantPinRef = React.useRef(false); - const autoFollowDuringWorkRef = React.useRef(false); - const pendingSessionSwitchSnapRef = React.useRef(false); + const followModeRef = React.useRef('none'); + const autoScrollMarkerRef = React.useRef(null); const viewportAnchorTimerRef = React.useRef | null>(null); const pendingViewportAnchorRef = React.useRef<{ sessionId: string; anchor: number } | null>(null); const lastViewportAnchorRef = React.useRef<{ sessionId: string; anchor: number } | null>(null); const lastViewportAnchorWriteAtRef = React.useRef(0); - const markProgrammaticScroll = React.useCallback(() => { - suppressUserScrollUntilRef.current = Date.now() + PROGRAMMATIC_SCROLL_SUPPRESS_MS; + const markAutoScroll = React.useCallback((top: number) => { + autoScrollMarkerRef.current = { + top, + at: Date.now(), + }; + }, []); + + const isMarkedAutoScroll = React.useCallback((scrollTop: number) => { + const marker = autoScrollMarkerRef.current; + if (!marker) { + return false; + } + + if (Date.now() - marker.at > PROGRAMMATIC_SCROLL_SUPPRESS_MS) { + autoScrollMarkerRef.current = null; + return false; + } + + if (Math.abs(scrollTop - marker.top) > 2) { + return false; + } + + return true; }, []); const getDistanceFromBottom = React.useCallback(() => { @@ -147,6 +172,10 @@ export const useChatScrollManager = ({ setIsOverflowing((previous) => (previous === next ? previous : next)); }, []); + const setFollowMode = React.useCallback((next: FollowMode) => { + followModeRef.current = next; + }, []); + const shouldSkipLiveContentSync = React.useCallback(() => { return !isPinnedRef.current && showScrollButtonRef.current && isOverflowingRef.current; }, []); @@ -156,32 +185,38 @@ export const useChatScrollManager = ({ if (!container) return; const bottom = container.scrollHeight - container.clientHeight; - markProgrammaticScroll(); - scrollEngine.scrollToPosition(Math.max(0, bottom), options); - }, [markProgrammaticScroll, scrollEngine]); + markAutoScroll(Math.max(0, bottom)); + scrollEngine.scrollToPosition(Math.max(0, bottom), { + ...options, + persistFollow: Boolean(options?.followBottom && sessionIsWorking), + }); + }, [markAutoScroll, scrollEngine, sessionIsWorking]); const scrollPinnedToBottom = React.useCallback((distanceFromBottom: number) => { if (sessionIsWorking) { - if (autoFollowDuringWorkRef.current || scrollEngine.isFollowingBottom) { - autoFollowDuringWorkRef.current = true; + if (followModeRef.current === 'smooth' || scrollEngine.isFollowingBottom) { scrollToBottomInternal({ followBottom: true }); return; } - if (preferInstantPinRef.current || distanceFromBottom > getAutoFollowSnapThreshold()) { - autoFollowDuringWorkRef.current = false; + if (distanceFromBottom > getAutoFollowSnapThreshold()) { scrollToBottomInternal({ instant: true }); return; } - autoFollowDuringWorkRef.current = true; + setFollowMode('smooth'); scrollToBottomInternal({ followBottom: true }); return; } - autoFollowDuringWorkRef.current = false; + if (followModeRef.current === 'smooth' || scrollEngine.isFollowingBottom) { + scrollToBottomInternal({ followBottom: true }); + return; + } + + setFollowMode('none'); scrollToBottomInternal({ instant: true }); - }, [getAutoFollowSnapThreshold, scrollEngine.isFollowingBottom, scrollToBottomInternal, sessionIsWorking]); + }, [getAutoFollowSnapThreshold, scrollEngine.isFollowingBottom, scrollToBottomInternal, sessionIsWorking, setFollowMode]); const updateScrollButtonVisibility = React.useCallback(() => { const container = scrollRef.current; @@ -207,49 +242,31 @@ export const useChatScrollManager = ({ pinnedSyncRafRef.current = null; updateScrollButtonVisibility(); if (!isPinnedRef.current) { - pendingSessionSwitchSnapRef.current = false; + setFollowMode('none'); return; } const distanceFromBottom = getDistanceFromBottom(); if (sessionIsWorking) { - if (pendingSessionSwitchSnapRef.current && distanceFromBottom > 0.5) { - autoFollowDuringWorkRef.current = false; - scrollToBottomInternal({ instant: true }); - pendingSessionSwitchSnapRef.current = false; - preferInstantPinRef.current = false; - return; - } - - if (distanceFromBottom <= 0.5) { - if (!pendingSessionSwitchSnapRef.current) { - preferInstantPinRef.current = false; - } + if (distanceFromBottom <= getAutoFollowThreshold()) { return; } scrollPinnedToBottom(distanceFromBottom); - preferInstantPinRef.current = false; - pendingSessionSwitchSnapRef.current = false; return; } - pendingSessionSwitchSnapRef.current = false; - if (distanceFromBottom <= getAutoFollowThreshold()) { - preferInstantPinRef.current = false; - return; - } - - if (preferInstantPinRef.current) { - scrollToBottomInternal({ instant: true }); + if (followModeRef.current !== 'smooth') { + setFollowMode('none'); + } return; } if (distanceFromBottom > getAutoFollowThreshold()) { scrollPinnedToBottom(distanceFromBottom); } - }, [getAutoFollowThreshold, getDistanceFromBottom, scrollPinnedToBottom, scrollToBottomInternal, sessionIsWorking, updateScrollButtonVisibility]); + }, [getAutoFollowThreshold, getDistanceFromBottom, scrollPinnedToBottom, sessionIsWorking, setFollowMode, updateScrollButtonVisibility]); const schedulePinnedStateAndIndicators = React.useCallback(() => { if (typeof window === 'undefined') { @@ -315,28 +332,30 @@ export const useChatScrollManager = ({ const container = scrollRef.current; if (!container) return; - markProgrammaticScroll(); + markAutoScroll(Math.max(0, position)); scrollEngine.scrollToPosition(Math.max(0, position), options); - }, [markProgrammaticScroll, scrollEngine]); + }, [markAutoScroll, scrollEngine]); - const scrollToBottom = React.useCallback((options?: { instant?: boolean; force?: boolean }) => { + const prepareForBottomResume = React.useCallback(() => { + updatePinnedState(true); + setFollowMode(sessionIsWorking ? 'smooth' : 'none'); + setShowScrollButtonState(false); + }, [sessionIsWorking, setFollowMode, setShowScrollButtonState, updatePinnedState]); + + const scrollToBottom = React.useCallback((options?: { instant?: boolean; force?: boolean; followBottom?: boolean }) => { const container = scrollRef.current; if (!container) return; - // Re-pin when explicitly scrolling to bottom - updatePinnedState(true); - + prepareForBottomResume(); scrollToBottomInternal(options); - setShowScrollButtonState(false); - }, [scrollToBottomInternal, setShowScrollButtonState, updatePinnedState]); + }, [prepareForBottomResume, scrollToBottomInternal]); const releasePinnedScroll = React.useCallback(() => { scrollEngine.cancelFollow(); - autoFollowDuringWorkRef.current = false; - preferInstantPinRef.current = false; + setFollowMode('none'); updatePinnedState(false); schedulePinnedStateAndIndicators(); - }, [schedulePinnedStateAndIndicators, scrollEngine, updatePinnedState]); + }, [schedulePinnedStateAndIndicators, scrollEngine, setFollowMode, updatePinnedState]); const handleScrollEvent = React.useCallback((event?: Event) => { const container = scrollRef.current; @@ -344,9 +363,10 @@ export const useChatScrollManager = ({ return; } - const now = Date.now(); - const isProgrammatic = now < suppressUserScrollUntilRef.current; - const hasDirectIntent = now - lastDirectScrollIntentAtRef.current <= DIRECT_SCROLL_INTENT_WINDOW_MS; + const isProgrammatic = isMarkedAutoScroll(container.scrollTop); + if (isProgrammatic) { + autoScrollMarkerRef.current = null; + } scrollEngine.handleScroll(); schedulePinnedStateAndIndicators(); @@ -355,9 +375,9 @@ export const useChatScrollManager = ({ const currentScrollTop = container.scrollTop; const scrollingUp = currentScrollTop < lastScrollTopRef.current; - // Unpin requires strict user intent check - if (event?.isTrusted && !isProgrammatic && hasDirectIntent) { + if (event?.isTrusted && !isProgrammatic) { if (scrollingUp && isPinnedRef.current) { + setFollowMode('none'); updatePinnedState(false); } } @@ -366,7 +386,7 @@ export const useChatScrollManager = ({ if (!isPinnedRef.current) { const distanceFromBottom = getDistanceFromBottom(); if (distanceFromBottom <= getPinThreshold()) { - preferInstantPinRef.current = false; + setFollowMode(sessionIsWorking ? 'smooth' : 'none'); updatePinnedState(true); } } @@ -381,10 +401,13 @@ export const useChatScrollManager = ({ currentSessionId, getDistanceFromBottom, getPinThreshold, + isMarkedAutoScroll, queueViewportAnchor, schedulePinnedStateAndIndicators, scrollEngine, + setFollowMode, sessionMessageCount, + sessionIsWorking, updatePinnedState, ]); @@ -406,28 +429,21 @@ export const useChatScrollManager = ({ delta, })) { scrollEngine.cancelFollow(); - autoFollowDuringWorkRef.current = false; + setFollowMode('none'); updatePinnedState(false); } - }, [scrollEngine, updatePinnedState]); + }, [scrollEngine, setFollowMode, updatePinnedState]); 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; @@ -456,7 +472,7 @@ export const useChatScrollManager = ({ delta: syntheticWheelDelta, })) { scrollEngine.cancelFollow(); - autoFollowDuringWorkRef.current = false; + setFollowMode('none'); updatePinnedState(false); } }; @@ -471,7 +487,6 @@ export const useChatScrollManager = ({ 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 }); return () => { container.removeEventListener('scroll', handleScrollEvent as EventListener); @@ -480,9 +495,8 @@ export const useChatScrollManager = ({ container.removeEventListener('touchend', handleTouchEndIntent as EventListener); container.removeEventListener('touchcancel', handleTouchEndIntent as EventListener); container.removeEventListener('wheel', handleWheelIntent as EventListener); - container.removeEventListener('wheel', markDirectIntent as EventListener); }; - }, [handleScrollEvent, handleWheelIntent, scrollEngine, updatePinnedState]); + }, [handleScrollEvent, handleWheelIntent, scrollEngine, setFollowMode, updatePinnedState]); // Session switch - always start pinned at bottom React.useEffect(() => { @@ -494,28 +508,20 @@ export const useChatScrollManager = ({ MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId); flushViewportAnchor(); pendingViewportAnchorRef.current = null; - autoFollowDuringWorkRef.current = false; - pendingSessionSwitchSnapRef.current = true; // Always start pinned at bottom on session switch - preferInstantPinRef.current = true; + setFollowMode(sessionIsWorking ? 'smooth' : 'none'); updatePinnedState(true); setShowScrollButtonState(false); - - const container = scrollRef.current; - if (container) { - markProgrammaticScroll(); - scrollToBottomInternal({ instant: true }); - } - }, [currentSessionId, flushViewportAnchor, markProgrammaticScroll, scrollToBottomInternal, setShowScrollButtonState, updatePinnedState]); + }, [currentSessionId, flushViewportAnchor, sessionIsWorking, setFollowMode, setShowScrollButtonState, updatePinnedState]); // Maintain pin-to-bottom when content changes React.useEffect(() => { if (!sessionIsWorking) { - autoFollowDuringWorkRef.current = false; - pendingSessionSwitchSnapRef.current = false; + scrollEngine.cancelFollow(); + setFollowMode('none'); } - }, [sessionIsWorking]); + }, [scrollEngine, sessionIsWorking, setFollowMode]); React.useEffect(() => { if (isSyncing) { @@ -541,14 +547,16 @@ export const useChatScrollManager = ({ const scrollHeightChanged = nextScrollHeight !== lastScrollHeight; const clientHeightChanged = nextClientHeight !== lastClientHeight; - if (clientHeightChanged) { - if (isPinnedRef.current && sessionIsWorking) { - lastScrollHeight = nextScrollHeight; - lastClientHeight = nextClientHeight; - schedulePinnedStateAndIndicators(); - return; - } + if (scrollHeightChanged && isPinnedRef.current && sessionIsWorking) { + setFollowMode('smooth'); + scrollToBottomInternal({ followBottom: true }); + lastScrollHeight = nextScrollHeight; + lastClientHeight = nextClientHeight; + updateScrollButtonVisibility(); + return; + } + if (clientHeightChanged) { const previousDistanceFromBottom = Math.max( 0, lastScrollHeight - lastScrollTopRef.current - lastClientHeight, @@ -561,7 +569,7 @@ export const useChatScrollManager = ({ ); if (Math.abs(container.scrollTop - targetScrollTop) > 0.5) { - markProgrammaticScroll(); + markAutoScroll(targetScrollTop); container.scrollTop = targetScrollTop; lastScrollTopRef.current = targetScrollTop; } @@ -593,7 +601,7 @@ export const useChatScrollManager = ({ return () => { observer.disconnect(); }; - }, [markProgrammaticScroll, schedulePinnedStateAndIndicators, sessionIsWorking, shouldSkipLiveContentSync, updateScrollButtonVisibility]); + }, [markAutoScroll, schedulePinnedStateAndIndicators, scrollToBottomInternal, sessionIsWorking, setFollowMode, shouldSkipLiveContentSync, updateScrollButtonVisibility]); React.useEffect(() => { if (typeof window === 'undefined') { @@ -784,6 +792,7 @@ export const useChatScrollManager = ({ handleMessageContentChange, getAnimationHandlers, showScrollButton, + prepareForBottomResume, scrollToBottom, scrollToPosition, releasePinnedScroll, diff --git a/packages/ui/src/hooks/useScrollEngine.ts b/packages/ui/src/hooks/useScrollEngine.ts index 7dfef105..890c3634 100644 --- a/packages/ui/src/hooks/useScrollEngine.ts +++ b/packages/ui/src/hooks/useScrollEngine.ts @@ -9,6 +9,7 @@ type ScrollEngineOptions = { type ScrollOptions = { instant?: boolean; followBottom?: boolean; // Dynamically track bottom during streaming + persistFollow?: boolean; }; type ScrollEngineResult = { @@ -55,6 +56,7 @@ export const useScrollEngine = ({ // Continuous follow-bottom rAF loop (for streaming) const followRafRef = React.useRef(null); const followActiveRef = React.useRef(false); + const followPersistRef = React.useRef(false); const cancelSpring = React.useCallback(() => { if (scrollAnimRef.current) { @@ -69,6 +71,7 @@ export const useScrollEngine = ({ followRafRef.current = null; } followActiveRef.current = false; + followPersistRef.current = false; setIsFollowingBottom(false); }, []); @@ -78,7 +81,8 @@ export const useScrollEngine = ({ }, [cancelSpring, cancelFollow]); // Continuous lerp loop that chases scrollHeight - clientHeight. - const startFollowLoop = React.useCallback(() => { + const startFollowLoop = React.useCallback((persist = false) => { + followPersistRef.current = persist || followPersistRef.current; if (followActiveRef.current) return; // already running followActiveRef.current = true; setIsFollowingBottom(true); @@ -99,6 +103,11 @@ export const useScrollEngine = ({ if (Math.abs(delta) <= SNAP_EPSILON) { container.scrollTop = target; + if (followPersistRef.current) { + stableFrames = 0; + followRafRef.current = window.requestAnimationFrame(tick); + return; + } stableFrames += 1; if (stableFrames >= FOLLOW_STABLE_FRAME_LIMIT) { followActiveRef.current = false; @@ -126,6 +135,7 @@ export const useScrollEngine = ({ const target = Math.max(0, position); const preferInstant = options?.instant ?? false; const followBottom = options?.followBottom ?? false; + const persistFollow = options?.persistFollow ?? false; manualOverrideRef.current = false; @@ -134,6 +144,10 @@ export const useScrollEngine = ({ cancelAll(); container.scrollTop = target; + if (followBottom && typeof window !== 'undefined') { + startFollowLoop(persistFollow); + } + const atTop = target <= 1; if (atTopRef.current !== atTop) { atTopRef.current = atTop; @@ -145,7 +159,7 @@ export const useScrollEngine = ({ // Follow-bottom mode: start the continuous lerp loop if (followBottom) { cancelSpring(); - startFollowLoop(); + startFollowLoop(persistFollow); return; } diff --git a/packages/ui/src/hooks/useTimelineStaging.ts b/packages/ui/src/hooks/useTimelineStaging.ts index abec4ee2..8d3f6c68 100644 --- a/packages/ui/src/hooks/useTimelineStaging.ts +++ b/packages/ui/src/hooks/useTimelineStaging.ts @@ -21,6 +21,8 @@ type UseTimelineStagingResult = { stagedMessages: T[] /** Whether staging is still in progress */ isStaging: boolean + /** Force the current session timeline to render fully now */ + completeNow: () => boolean } const DEFAULT_CONFIG: StageConfig = { init: 1, batch: 3 } @@ -45,6 +47,31 @@ export function useTimelineStaging( const activeSession = useRef("") const frameRef = useRef(null) + const completeNow = () => { + if (!sessionKey) { + return false + } + + if (frameRef.current !== null) { + cancelAnimationFrame(frameRef.current) + frameRef.current = null + } + + activeSession.current = "" + completedSessions.current.add(sessionKey) + + const total = messages.length + let changed = false + setStagedCount((previous) => { + if (previous === total) { + return previous + } + changed = true + return total + }) + return changed + } + useEffect(() => { // Cancel any pending animation frame if (frameRef.current !== null) { @@ -110,5 +137,5 @@ export function useTimelineStaging( const isStaging = activeSession.current === sessionKey && !completedSessions.current.has(sessionKey) - return { stagedMessages, isStaging } + return { stagedMessages, isStaging, completeNow } } diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index b5da75ae..a5f32e00 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -1093,23 +1093,6 @@ html:not(.dark) .chat-scroll { background-color: var(--shiki-dark-bg) !important; } -/* Fix list styling - override Tailwind reset */ -.markdown-content ul { - list-style-type: disc; - list-style-position: outside; - padding-left: 1.5em; -} - -.markdown-content ol { - list-style-type: decimal; - list-style-position: outside; - padding-left: 1.5em; -} - -.markdown-content li { - display: list-item; -} - /* Reduce code block header height */ [data-markdown="code-block-header"] { margin: 0; diff --git a/packages/ui/src/styles/design-system.css b/packages/ui/src/styles/design-system.css index b0c33aa6..f429fb36 100644 --- a/packages/ui/src/styles/design-system.css +++ b/packages/ui/src/styles/design-system.css @@ -168,6 +168,36 @@ font-family: var(--font-mono, ui-monospace, SFMono-Regular, 'Liberation Mono', Menlo, monospace) !important; } + /* Restore default list styling inside markdown content - override Tailwind preflight */ + .markdown-content ul, + .markdown-content ol { + padding-left: 2em !important; + } + + .markdown-content ul { + list-style-type: none !important; + } + + .markdown-content ul > li { + position: relative; + } + + .markdown-content ul > li::before { + content: "–"; + position: absolute; + left: -1.25em; + color: inherit; + } + + .markdown-content ol { + list-style-type: decimal !important; + list-style-position: outside !important; + } + + .markdown-content li { + display: list-item !important; + } + /* Remove focus rings globally (except inputs/textareas/dropdowns/selects) */ *:focus:not(input):not(textarea):not([data-slot="dropdown-menu-content"]):not([data-slot="dropdown-menu-sub-content"]):not([data-slot="select-content"]) { outline: none !important;