From d71aec54db73221b797e6dd0cd4f9a6f28bdd2a9 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 3 Jul 2026 01:34:04 +0300 Subject: [PATCH] fix: stabilize chat history prepend scroll preservation on mobile and desktop - Mobile: defeat iOS momentum scroll when compensating history prepend (overflow toggle + short rAF watchdog); disable history virtualization and post-paint background prepends; preload Markdown renderer and use plain-text Suspense fallback to avoid first-frame geometry shifts - Desktop: stop double-compensating prepends on the virtualized list - virtua shift owns the adjustment; remove sticky-anchor heuristics that misfired as failed restores - Sync: skip no-op store writes when messages/parts are unchanged --- packages/ui/src/apps/renderMobileApp.tsx | 2 + .../src/components/chat/MarkdownRenderer.tsx | 29 ++- .../ui/src/components/chat/MessageList.tsx | 27 ++- .../hooks/useChatTimelineController.test.ts | 31 ++- .../chat/hooks/useChatTimelineController.ts | 204 ++++++++++++++++-- .../components/chat/markdownRendererLoader.ts | 13 ++ packages/ui/src/sync/use-sync.ts | 20 +- 7 files changed, 286 insertions(+), 40 deletions(-) create mode 100644 packages/ui/src/components/chat/markdownRendererLoader.ts diff --git a/packages/ui/src/apps/renderMobileApp.tsx b/packages/ui/src/apps/renderMobileApp.tsx index 64f77954..3c2beeaf 100644 --- a/packages/ui/src/apps/renderMobileApp.tsx +++ b/packages/ui/src/apps/renderMobileApp.tsx @@ -16,6 +16,7 @@ import { initializeLocale, I18nProvider } from '@/lib/i18n'; import { initializeAppearancePreferences, syncDesktopSettings } from '@/lib/persistence'; import { startModelPrefsAutoSave } from '@/lib/modelPrefsAutoSave'; import { startTypographyWatcher } from '@/lib/typographyWatcher'; +import { preloadMarkdownRenderer } from '@/components/chat/markdownRendererLoader'; import { MobileApp } from './MobileApp'; const initializeSharedPreferences = () => { @@ -42,6 +43,7 @@ const initializeSharedPreferences = () => { }; export function renderMobileApp(apis: RuntimeAPIs) { + preloadMarkdownRenderer(); initializeSharedPreferences(); // Expose the widget snapshot builder so the native shell can read the session overview diff --git a/packages/ui/src/components/chat/MarkdownRenderer.tsx b/packages/ui/src/components/chat/MarkdownRenderer.tsx index 4d56736b..c294bd6f 100644 --- a/packages/ui/src/components/chat/MarkdownRenderer.tsx +++ b/packages/ui/src/components/chat/MarkdownRenderer.tsx @@ -1,5 +1,8 @@ import React from 'react'; import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; +import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface'; +import { cn } from '@/lib/utils'; +import { loadMarkdownRendererModule } from './markdownRendererLoader'; // Thin lazy wrapper around the MarkdownRenderer implementation. // The full implementation (marked + Shiki highlighting + KaTeX + morphdom @@ -7,23 +10,41 @@ import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; // initial bundle lean. const MarkdownRendererLazy = lazyWithChunkRecovery(() => - import('./MarkdownRendererImpl').then((m) => ({ default: m.MarkdownRenderer })) + loadMarkdownRendererModule().then((m) => ({ default: m.MarkdownRenderer })) ); const SimpleMarkdownRendererLazy = lazyWithChunkRecovery(() => - import('./MarkdownRendererImpl').then((m) => ({ default: m.SimpleMarkdownRenderer })) + loadMarkdownRendererModule().then((m) => ({ default: m.SimpleMarkdownRenderer })) ); const fallback =
; +const fallbackContentClassName = (variant: unknown): string => { + if (variant === 'tool') return 'markdown-content markdown-tool'; + if (variant === 'reasoning') return 'markdown-content markdown-reasoning'; + return 'markdown-content leading-relaxed'; +}; + +const MobileMarkdownFallback = (props: { content?: unknown; className?: unknown; variant?: unknown }) => { + if (!isMobileSurfaceRuntime() || typeof props.content !== 'string' || props.content.length === 0) { + return fallback; + } + + return ( +
+ {props.content} +
+ ); +}; + export const MarkdownRenderer: React.FC> = (props) => ( - + }> ); export const SimpleMarkdownRenderer: React.FC> = (props) => ( - + }> ); diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 653f370d..73bcea09 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -155,6 +155,21 @@ const getMessageParentId = (message: ChatMessageEntry): string | null => { return typeof parentID === 'string' && parentID.trim().length > 0 ? parentID : null; }; +const isInsideStuckSticky = (node: HTMLElement, container: HTMLElement, containerTop: number): boolean => { + if (typeof window === 'undefined') return false; + + let current: HTMLElement | null = node; + while (current && current !== container) { + const computed = window.getComputedStyle(current); + if (computed.position === 'sticky' && current.getBoundingClientRect().top <= containerTop + 1) { + return true; + } + current = current.parentElement; + } + + return false; +}; + const isUserShellMarkerMessage = (message: ChatMessageEntry | undefined): boolean => { if (!message) return false; if (resolveMessageRole(message) !== 'user') return false; @@ -373,6 +388,7 @@ export interface MessageListHandle { scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior }) => boolean; captureViewportAnchor: () => { messageId: string; offsetTop: number } | null; restoreViewportAnchor: (anchor: { messageId: string; offsetTop: number }) => boolean; + isHistoryVirtualized: () => boolean; scrollToBottom: () => void; } @@ -1262,7 +1278,10 @@ const MessageList = React.forwardRef(({ } const historyEntries = staticRenderEntries; - const shouldVirtualizeHistory = historyEntries.length >= MESSAGE_LIST_VIRTUALIZE_THRESHOLD; + // Virtua hides unmeasured items until ResizeObserver reports their height. + // Mobile momentum scrolling can outrun that measurement and expose blank + // reserved rows, so keep the constrained mobile history mounted normally. + const shouldVirtualizeHistory = !isMobileSurfaceRuntime() && historyEntries.length >= MESSAGE_LIST_VIRTUALIZE_THRESHOLD; const historyEntryKeys = React.useMemo(() => historyEntries.map((entry) => entry.key), [historyEntries]); const virtualCache = React.useMemo( () => (shouldVirtualizeHistory ? readTimelineCache(sessionKey, historyEntryKeys) : undefined), @@ -1468,6 +1487,8 @@ const MessageList = React.forwardRef(({ ); }, + isHistoryVirtualized: () => shouldVirtualizeHistory, + captureViewportAnchor: () => { const container = resolveScrollContainer(); if (!container) { @@ -1486,9 +1507,7 @@ const MessageList = React.forwardRef(({ return true; } - const computed = window.getComputedStyle(node); - const isStuckSticky = computed.position === 'sticky' && rect.top <= containerRect.top + 1; - return !isStuckSticky; + return !isInsideStuckSticky(node, container, containerRect.top); }) ?? nodes.find((node) => node.getBoundingClientRect().bottom > containerRect.top + 1); if (!firstVisible) { return null; diff --git a/packages/ui/src/components/chat/hooks/useChatTimelineController.test.ts b/packages/ui/src/components/chat/hooks/useChatTimelineController.test.ts index 7c45cd4f..4eb0a72e 100644 --- a/packages/ui/src/components/chat/hooks/useChatTimelineController.test.ts +++ b/packages/ui/src/components/chat/hooks/useChatTimelineController.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from 'bun:test'; -import { shouldAutoLoadEarlierForUnderfilledPinnedViewport } from './useChatTimelineController'; +import { + isOlderHistoryPrependCommit, + shouldAutoLoadEarlierForUnderfilledPinnedViewport, +} from './useChatTimelineController'; const baseInput = { sessionId: 'ses_1', @@ -39,3 +42,29 @@ describe('shouldAutoLoadEarlierForUnderfilledPinnedViewport', () => { })).toBe(false); }); }); + +describe('isOlderHistoryPrependCommit', () => { + test('detects older messages inserted above the existing timeline', () => { + expect(isOlderHistoryPrependCommit({ + previousOldestId: 'msg_2', + previousNewestId: 'msg_4', + currentOldestId: 'msg_1', + currentNewestId: 'msg_4', + })).toBe(true); + }); + + test('does not treat appends or replacements as prepends', () => { + expect(isOlderHistoryPrependCommit({ + previousOldestId: 'msg_2', + previousNewestId: 'msg_4', + currentOldestId: 'msg_2', + currentNewestId: 'msg_5', + })).toBe(false); + expect(isOlderHistoryPrependCommit({ + previousOldestId: 'msg_2', + previousNewestId: 'msg_4', + currentOldestId: 'msg_1', + currentNewestId: 'msg_5', + })).toBe(false); + }); +}); diff --git a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts index 13a0793c..528eafa4 100644 --- a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts +++ b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts @@ -126,6 +126,75 @@ export const shouldAutoLoadEarlierForUnderfilledPinnedViewport = (input: { return input.scrollHeight <= input.clientHeight + 1; }; +export const isOlderHistoryPrependCommit = (input: { + previousOldestId: string | null; + previousNewestId: string | null; + currentOldestId: string | null; + currentNewestId: string | null; +}): boolean => Boolean( + input.previousOldestId + && input.currentOldestId + && input.currentOldestId !== input.previousOldestId + && input.previousNewestId + && input.currentNewestId + && input.currentNewestId === input.previousNewestId, +); + +// iOS WKWebView ignores programmatic scrollTop writes while a touch drag or +// momentum (fling) scroll is active: the native scroll animation keeps running +// and overwrites the value on the next frame. The mobile history threshold is +// large enough that the prepend commit almost always lands mid-fling, so a +// plain `container.scrollTop = target` never sticks. Toggling overflow kills +// the native scroll synchronously (pre-paint, invisible inside a layout +// effect); a short post-paint watchdog re-asserts the target if residual +// momentum still drags the viewport upward. +const MOMENTUM_WATCHDOG_FRAMES = 20; +const MOMENTUM_WATCHDOG_TOLERANCE_PX = 4; + +const setScrollTopDefeatingMomentum = (container: HTMLElement, target: number) => { + const previousOverflow = container.style.overflow; + container.style.overflow = 'hidden'; + container.scrollTop = target; + void container.scrollHeight; + container.style.overflow = previousOverflow; + container.scrollTop = target; + + if (typeof window === 'undefined') return; + let cancelled = false; + let frames = 0; + const cancelOnUserTouch = () => { + cancelled = true; + }; + container.addEventListener('touchstart', cancelOnUserTouch, { passive: true, once: true }); + const watch = () => { + if (cancelled) return; + // Only correct upward drift (residual momentum). Downward movement or + // content growth above the viewport must not be fought here. + if (container.scrollTop < target - MOMENTUM_WATCHDOG_TOLERANCE_PX) { + container.scrollTop = target; + } + frames += 1; + if (frames < MOMENTUM_WATCHDOG_FRAMES) { + window.requestAnimationFrame(watch); + } else { + container.removeEventListener('touchstart', cancelOnUserTouch); + } + }; + window.requestAnimationFrame(watch); +}; + +const hasInsertedBeforeKnownOldest = ( + previousOldestId: string | null, + currentOldestId: string | null, + messages: ChatMessageEntry[], +): boolean => { + if (!previousOldestId || !currentOldestId || currentOldestId === previousOldestId) { + return false; + } + + return messages.some((message) => message.info.id === previousOldestId); +}; + export const useChatTimelineController = ({ sessionId, messages, @@ -339,9 +408,12 @@ export const useChatTimelineController = ({ // before triggering the state change. useLayoutEffect consumes it // after React commits new DOM — before the browser paints. const prePrependScrollRef = React.useRef<{ + sessionId: string | null; height: number; top: number; anchor: ViewportAnchor | null; + oldestId: string | null; + newestId: string | null; } | null>(null); const captureViewportAnchor = React.useCallback((): ViewportAnchor | null => { @@ -364,26 +436,47 @@ export const useChatTimelineController = ({ scrollHeight: number; } | null>(null); + React.useLayoutEffect(() => { + prePrependScrollRef.current = null; + prependTrackingRef.current = null; + }, [sessionId]); + React.useLayoutEffect(() => { const container = scrollRef.current; if (!container) return; - const snap = prePrependScrollRef.current; + let snap = prePrependScrollRef.current; const prev = prependTrackingRef.current; const currentOldestId = renderedMessages[0]?.info?.id ?? null; const currentNewestId = renderedMessages[renderedMessages.length - 1]?.info?.id ?? null; - // A prepend = content inserted ABOVE the viewport: the oldest message id - // changed while the newest stayed the same. This distinguishes a history - // load from a bottom append, a streaming part growing, or a session switch. - const isPrepend = Boolean( - prev - && prev.oldestId - && currentOldestId - && currentOldestId !== prev.oldestId - && prev.newestId - && currentNewestId - && currentNewestId === prev.newestId, - ); + // A prepend = content inserted ABOVE the viewport: either the newest + // stayed fixed, or the old first message still exists below a new first + // message. The latter keeps preservation alive if a tail append lands in + // the same commit as the history page. + const isPrepend = prev + ? isOlderHistoryPrependCommit({ + previousOldestId: prev.oldestId, + previousNewestId: prev.newestId, + currentOldestId, + currentNewestId, + }) || hasInsertedBeforeKnownOldest(prev.oldestId, currentOldestId, renderedMessages) + : false; + + if (snap && snap.sessionId !== sessionIdRef.current) { + prePrependScrollRef.current = null; + snap = null; + } + + const isSnapshotPrepend = snap + ? isOlderHistoryPrependCommit({ + previousOldestId: snap.oldestId, + previousNewestId: snap.newestId, + currentOldestId, + currentNewestId, + }) || hasInsertedBeforeKnownOldest(snap.oldestId, currentOldestId, renderedMessages) + : false; + const didPrepend = isPrepend || isSnapshotPrepend; + const shouldConsumeSnapshot = Boolean(snap && (isPrepend || isSnapshotPrepend)); const updateTracking = () => { prependTrackingRef.current = { @@ -393,6 +486,22 @@ export const useChatTimelineController = ({ }; }; + const refreshPendingSnapshot = () => { + const pending = prePrependScrollRef.current; + if (!pending) { + return; + } + + prePrependScrollRef.current = { + ...pending, + height: container.scrollHeight, + top: container.scrollTop, + anchor: captureViewportAnchor(), + oldestId: currentOldestId, + newestId: currentNewestId, + }; + }; + if (isPinnedRef.current) { // Bottom-pinned. Only content inserted ABOVE (a prepend / history load) // needs an explicit re-pin: with overflow-anchor:none the browser leaves @@ -407,38 +516,74 @@ export const useChatTimelineController = ({ // best, and the source of the old up/down jiggle on send / from the // queue / while streaming. So for an append we do nothing and let // auto-follow own it. - if (snap || isPrepend) { + if (didPrepend) { prePrependScrollRef.current = null; goToBottom('instant'); + } else if (snap) { + refreshPendingSnapshot(); } updateTracking(); return; } - if (snap) { + // When the history list is virtualized, virtua runs with `shift` during + // history loads and compensates the prepend internally. Manual + // height-delta compensation on top of that applies the same delta twice + // and throws the viewport far downward. Anchor restore stays allowed — + // it corrects to an absolute element position, so it cannot double up. + const historyVirtualized = messageListRef.current?.isHistoryVirtualized() ?? false; + + if (snap && shouldConsumeSnapshot) { prePrependScrollRef.current = null; + const heightDelta = container.scrollHeight - snap.height; + const applyHeightDelta = (): boolean => { + if (historyVirtualized || heightDelta <= 0) { + return false; + } + container.scrollTop = snap.top + heightDelta; + return true; + }; + + if (isMobileSurfaceRuntime() && heightDelta > 0) { + setScrollTopDefeatingMomentum(container, snap.top + heightDelta); + updateTracking(); + return; + } + // When a viewport anchor is available, delegate to MessageList // restoreViewportAnchor which falls back to virtualizer-aware // scrollHistoryIndexIntoView when the element is not in the DOM. + // Note: an unchanged scrollTop after restore is NOT a failure here — + // the virtualized desktop list runs with virtua `shift`, which + // compensates the prepend internally, so staying near snap.top is + // the correct outcome. if (!(snap.anchor && restoreViewportAnchor(snap.anchor))) { // Fallback: height-delta compensation - const delta = container.scrollHeight - snap.height; - if (delta > 0) { - container.scrollTop = snap.top + delta; - } + applyHeightDelta(); } - } else if (isPrepend && prev) { + } else if (isPrepend && prev && !historyVirtualized) { // Released viewport: preserve the read position by compensating for the // exact height the prepend added above, with no intermediate frame for - // auto-follow to fight. + // auto-follow to fight. Virtualized lists skip this — virtua `shift` + // already compensated the prepend. const delta = container.scrollHeight - prev.scrollHeight; if (delta > 0) { - container.scrollTop = container.scrollTop + delta; + const target = container.scrollTop + delta; + if (isMobileSurfaceRuntime()) { + setScrollTopDefeatingMomentum(container, target); + } else { + container.scrollTop = target; + } } + } else if (snap) { + // setIsLoadingOlder/historyMeta can commit before the server page + // arrives. Keep the snapshot armed, but refresh it so later fallback + // compensation only accounts for rows actually prepended above. + refreshPendingSnapshot(); } updateTracking(); - }, [renderedMessages, scrollRef, restoreViewportAnchor, goToBottom]); + }, [captureViewportAnchor, messageListRef, renderedMessages, scrollRef, restoreViewportAnchor, goToBottom]); const revealBufferedTurns = React.useCallback(async (): Promise => false, []); @@ -462,9 +607,12 @@ export const useChatTimelineController = ({ // compensate synchronously when React commits the new messages. if (input.preserveViewport && container) { prePrependScrollRef.current = { + sessionId: sessionIdRef.current, height: container.scrollHeight, top: container.scrollTop, anchor: captureViewportAnchor(), + oldestId: beforeOldestMessageId, + newestId: beforeMessages[beforeMessages.length - 1]?.info?.id ?? null, }; } @@ -474,6 +622,7 @@ export const useChatTimelineController = ({ try { const targetSessionId = sessionIdRef.current; if (!targetSessionId) { + prePrependScrollRef.current = null; return false; } @@ -485,6 +634,7 @@ export const useChatTimelineController = ({ while (true) { await loadMoreMessages(targetSessionId, 'up'); if (sessionIdRef.current !== targetSessionId) { + prePrependScrollRef.current = null; return false; } @@ -506,6 +656,7 @@ export const useChatTimelineController = ({ return true; } if (!messageGrowth) { + prePrependScrollRef.current = null; return false; } if (!historySignalsRef.current.hasMoreAboveTurns) { @@ -516,6 +667,9 @@ export const useChatTimelineController = ({ loadedOldestMessageId = afterOldestMessageId; loadedLimit = afterLimit; } + } catch (error) { + prePrependScrollRef.current = null; + throw error; } finally { setIsLoadingOlder(false); settleHistoryInteraction(); @@ -547,6 +701,10 @@ export const useChatTimelineController = ({ }, [loadEarlier, scrollRef]); const loadEarlierIfPinnedViewportUnderfilled = React.useCallback(() => { + // On mobile the initial page is intentionally smaller. Auto-prepending + // older rows after first paint shifts the narrow timeline; let explicit + // upward scroll request history instead. + if (isMobileSurfaceRuntime()) return; if (historyInteractionRef.current) return; const container = scrollRef.current; if (!container) return; diff --git a/packages/ui/src/components/chat/markdownRendererLoader.ts b/packages/ui/src/components/chat/markdownRendererLoader.ts new file mode 100644 index 00000000..986fbedc --- /dev/null +++ b/packages/ui/src/components/chat/markdownRendererLoader.ts @@ -0,0 +1,13 @@ +let markdownRendererModulePromise: Promise | null = null; + +export const loadMarkdownRendererModule = () => { + markdownRendererModulePromise ??= import('./MarkdownRendererImpl').catch((error) => { + markdownRendererModulePromise = null; + throw error; + }); + return markdownRendererModulePromise; +}; + +export const preloadMarkdownRenderer = () => { + void loadMarkdownRendererModule().catch(() => undefined); +}; diff --git a/packages/ui/src/sync/use-sync.ts b/packages/ui/src/sync/use-sync.ts index b0d646d6..6271ffde 100644 --- a/packages/ui/src/sync/use-sync.ts +++ b/packages/ui/src/sync/use-sync.ts @@ -399,7 +399,12 @@ export function useSync() { complete: merged.complete, loading: false, }) - store.setState({ message: materialized.message, part: materialized.part }) + if (materialized.messagesChanged || materialized.partsChanged) { + store.setState({ + ...(materialized.messagesChanged ? { message: materialized.message } : {}), + ...(materialized.partsChanged ? { part: materialized.part } : {}), + }) + } setSessionPrefetch({ directory, sessionID, @@ -498,13 +503,12 @@ export function useSync() { shouldLoadMessages ? loadMessages(sessionID, { isStale }) : Promise.resolve(), ]) - // Progressive mount: after the initial page resolves, if the session - // isn't stale and the server indicated more messages, dispatch a - // second fetch to prepend older history. The user sees the first page - // immediately; the rest arrive shortly after. This gives the scroll - // container headroom above the viewport so the "load older on - // scroll-up" trigger fires before the user hits the absolute top. - if (!isStale()) { + // Progressive mount on desktop: after the initial page resolves, if the + // session isn't stale and the server indicated more messages, dispatch a + // second fetch to prepend older history. Mobile avoids this background + // prepend because adding rows after first paint on a narrow viewport can + // visibly shift the timeline; user scroll still loads older history. + if (!isStale() && !isMobileSurfaceRuntime()) { const currentMeta = getMetaFor(sessionID) if (currentMeta.cursor && !currentMeta.complete) { loadMessages(sessionID, { before: currentMeta.cursor, mode: "prepend", isStale })