diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 8957f216..653f370d 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -19,25 +19,22 @@ import type { StreamPhase } from './message/types'; import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; import { useSessionParts } from '@/sync/sync-context'; import type { ReviewTransferDirection } from '@/lib/reviewFlow'; +import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface'; const MESSAGE_LIST_VIRTUALIZE_THRESHOLD = 5; const EMPTY_STATIC_ENTRY_MESSAGES: ChatMessageEntry[] = []; const EMPTY_UNGROUPED_MESSAGE_IDS = new Set(); const MESSAGE_LIST_BUFFER_SIZE = 900; +// Touch surfaces fling-scroll natively and dispatch scroll events less often +// than the virtualizer can repaint, so a desktop-sized buffer leaves blank gaps +// during momentum that only fill once measurement catches up. A larger overscan +// keeps more rows mounted around the viewport so fast flings stay populated. +const MOBILE_MESSAGE_LIST_BUFFER_SIZE = 2400; +const resolveMessageListBufferSize = (): number => ( + isMobileSurfaceRuntime() ? MOBILE_MESSAGE_LIST_BUFFER_SIZE : MESSAGE_LIST_BUFFER_SIZE +); const TIMELINE_CACHE_LIMIT = 16; -const estimateHistoryEntryHeight = (entry: RenderEntry | undefined): number => { - if (!entry) { - return 160; - } - - if (entry.kind === 'turn') { - return 180 + Math.min(entry.turn.assistantMessages.length, 4) * 100; - } - - return 140; -}; - const sameKeys = (a: readonly string[] | undefined, b: readonly string[] | undefined): boolean => { if (a === b) return true; if (!a || !b) return false; @@ -982,8 +979,7 @@ const StaticHistoryList = React.memo(({ entries, shouldVirtualize, contentRef, s ref={virtualizerRef} data={entries} cache={virtualCache} - itemSize={virtualCache ? undefined : estimateHistoryEntryHeight(undefined)} - bufferSize={MESSAGE_LIST_BUFFER_SIZE} + bufferSize={resolveMessageListBufferSize()} shift={shift} scrollRef={scrollRef} > diff --git a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts index a8e72106..13a0793c 100644 --- a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts +++ b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts @@ -60,6 +60,24 @@ export interface UseChatTimelineControllerResult { const TURN_MODEL_CACHE_MAX = 30 const HISTORY_SCROLL_THRESHOLD = 200 +// On touch surfaces the user can drag continuously toward the top, and +// loadEarlier is an async (network) fetch. A 200px lead is enough on desktop +// (wheel + fast render) but the finger can outrun an in-flight fetch on mobile +// and hit the very top before history lands. Give touch a much larger, +// viewport-relative head start so the fetch completes before the top is +// reached, regardless of how fast the user drags. +const MOBILE_HISTORY_SCROLL_THRESHOLD_MIN = 1200 +const MOBILE_HISTORY_SCROLL_VIEWPORT_FACTOR = 2 + +const resolveHistoryScrollThreshold = (clientHeight: number): number => { + if (!isMobileSurfaceRuntime()) { + return HISTORY_SCROLL_THRESHOLD + } + return Math.max( + MOBILE_HISTORY_SCROLL_THRESHOLD_MIN, + clientHeight * MOBILE_HISTORY_SCROLL_VIEWPORT_FACTOR, + ) +} const VSCODE_TURN_MODEL_CACHE_MAX = 4 const VSCODE_TURN_MODEL_CACHE_MAX_MESSAGES = 30 const MOBILE_TURN_MODEL_CACHE_MAX = 4 @@ -521,7 +539,7 @@ export const useChatTimelineController = ({ const container = scrollRef.current; if (!container) return; if (isPinnedRef.current) return; - if (container.scrollTop >= HISTORY_SCROLL_THRESHOLD) return; + if (container.scrollTop >= resolveHistoryScrollThreshold(container.clientHeight)) return; if (!historySignalsRef.current.canLoadEarlier) return; if (isLoadingOlderRef.current || pendingRevealWorkRef.current) return;