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
This commit is contained in:
Bohdan Triapitsyn
2026-07-03 01:34:04 +03:00
parent 3bd785a10a
commit d71aec54db
7 changed files with 286 additions and 40 deletions
+2
View File
@@ -16,6 +16,7 @@ import { initializeLocale, I18nProvider } from '@/lib/i18n';
import { initializeAppearancePreferences, syncDesktopSettings } from '@/lib/persistence'; import { initializeAppearancePreferences, syncDesktopSettings } from '@/lib/persistence';
import { startModelPrefsAutoSave } from '@/lib/modelPrefsAutoSave'; import { startModelPrefsAutoSave } from '@/lib/modelPrefsAutoSave';
import { startTypographyWatcher } from '@/lib/typographyWatcher'; import { startTypographyWatcher } from '@/lib/typographyWatcher';
import { preloadMarkdownRenderer } from '@/components/chat/markdownRendererLoader';
import { MobileApp } from './MobileApp'; import { MobileApp } from './MobileApp';
const initializeSharedPreferences = () => { const initializeSharedPreferences = () => {
@@ -42,6 +43,7 @@ const initializeSharedPreferences = () => {
}; };
export function renderMobileApp(apis: RuntimeAPIs) { export function renderMobileApp(apis: RuntimeAPIs) {
preloadMarkdownRenderer();
initializeSharedPreferences(); initializeSharedPreferences();
// Expose the widget snapshot builder so the native shell can read the session overview // Expose the widget snapshot builder so the native shell can read the session overview
@@ -1,5 +1,8 @@
import React from 'react'; import React from 'react';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; 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. // Thin lazy wrapper around the MarkdownRenderer implementation.
// The full implementation (marked + Shiki highlighting + KaTeX + morphdom // The full implementation (marked + Shiki highlighting + KaTeX + morphdom
@@ -7,23 +10,41 @@ import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
// initial bundle lean. // initial bundle lean.
const MarkdownRendererLazy = lazyWithChunkRecovery(() => const MarkdownRendererLazy = lazyWithChunkRecovery(() =>
import('./MarkdownRendererImpl').then((m) => ({ default: m.MarkdownRenderer })) loadMarkdownRendererModule().then((m) => ({ default: m.MarkdownRenderer }))
); );
const SimpleMarkdownRendererLazy = lazyWithChunkRecovery(() => const SimpleMarkdownRendererLazy = lazyWithChunkRecovery(() =>
import('./MarkdownRendererImpl').then((m) => ({ default: m.SimpleMarkdownRenderer })) loadMarkdownRendererModule().then((m) => ({ default: m.SimpleMarkdownRenderer }))
); );
const fallback = <div className="break-words w-full min-w-0" />; const fallback = <div className="break-words w-full min-w-0" />;
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 (
<div className={cn('break-words w-full min-w-0 whitespace-pre-wrap', fallbackContentClassName(props.variant), typeof props.className === 'string' ? props.className : undefined)}>
{props.content}
</div>
);
};
export const MarkdownRenderer: React.FC<React.ComponentPropsWithoutRef<typeof MarkdownRendererLazy>> = (props) => ( export const MarkdownRenderer: React.FC<React.ComponentPropsWithoutRef<typeof MarkdownRendererLazy>> = (props) => (
<React.Suspense fallback={fallback}> <React.Suspense fallback={<MobileMarkdownFallback {...props} />}>
<MarkdownRendererLazy {...props} /> <MarkdownRendererLazy {...props} />
</React.Suspense> </React.Suspense>
); );
export const SimpleMarkdownRenderer: React.FC<React.ComponentPropsWithoutRef<typeof SimpleMarkdownRendererLazy>> = (props) => ( export const SimpleMarkdownRenderer: React.FC<React.ComponentPropsWithoutRef<typeof SimpleMarkdownRendererLazy>> = (props) => (
<React.Suspense fallback={fallback}> <React.Suspense fallback={<MobileMarkdownFallback {...props} />}>
<SimpleMarkdownRendererLazy {...props} /> <SimpleMarkdownRendererLazy {...props} />
</React.Suspense> </React.Suspense>
); );
@@ -155,6 +155,21 @@ const getMessageParentId = (message: ChatMessageEntry): string | null => {
return typeof parentID === 'string' && parentID.trim().length > 0 ? parentID : 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 => { const isUserShellMarkerMessage = (message: ChatMessageEntry | undefined): boolean => {
if (!message) return false; if (!message) return false;
if (resolveMessageRole(message) !== 'user') return false; if (resolveMessageRole(message) !== 'user') return false;
@@ -373,6 +388,7 @@ export interface MessageListHandle {
scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior }) => boolean; scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior }) => boolean;
captureViewportAnchor: () => { messageId: string; offsetTop: number } | null; captureViewportAnchor: () => { messageId: string; offsetTop: number } | null;
restoreViewportAnchor: (anchor: { messageId: string; offsetTop: number }) => boolean; restoreViewportAnchor: (anchor: { messageId: string; offsetTop: number }) => boolean;
isHistoryVirtualized: () => boolean;
scrollToBottom: () => void; scrollToBottom: () => void;
} }
@@ -1262,7 +1278,10 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
} }
const historyEntries = staticRenderEntries; 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 historyEntryKeys = React.useMemo(() => historyEntries.map((entry) => entry.key), [historyEntries]);
const virtualCache = React.useMemo( const virtualCache = React.useMemo(
() => (shouldVirtualizeHistory ? readTimelineCache(sessionKey, historyEntryKeys) : undefined), () => (shouldVirtualizeHistory ? readTimelineCache(sessionKey, historyEntryKeys) : undefined),
@@ -1468,6 +1487,8 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
); );
}, },
isHistoryVirtualized: () => shouldVirtualizeHistory,
captureViewportAnchor: () => { captureViewportAnchor: () => {
const container = resolveScrollContainer(); const container = resolveScrollContainer();
if (!container) { if (!container) {
@@ -1486,9 +1507,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
return true; return true;
} }
const computed = window.getComputedStyle(node); return !isInsideStuckSticky(node, container, containerRect.top);
const isStuckSticky = computed.position === 'sticky' && rect.top <= containerRect.top + 1;
return !isStuckSticky;
}) ?? nodes.find((node) => node.getBoundingClientRect().bottom > containerRect.top + 1); }) ?? nodes.find((node) => node.getBoundingClientRect().bottom > containerRect.top + 1);
if (!firstVisible) { if (!firstVisible) {
return null; return null;
@@ -1,6 +1,9 @@
import { describe, expect, test } from 'bun:test'; import { describe, expect, test } from 'bun:test';
import { shouldAutoLoadEarlierForUnderfilledPinnedViewport } from './useChatTimelineController'; import {
isOlderHistoryPrependCommit,
shouldAutoLoadEarlierForUnderfilledPinnedViewport,
} from './useChatTimelineController';
const baseInput = { const baseInput = {
sessionId: 'ses_1', sessionId: 'ses_1',
@@ -39,3 +42,29 @@ describe('shouldAutoLoadEarlierForUnderfilledPinnedViewport', () => {
})).toBe(false); })).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);
});
});
@@ -126,6 +126,75 @@ export const shouldAutoLoadEarlierForUnderfilledPinnedViewport = (input: {
return input.scrollHeight <= input.clientHeight + 1; 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 = ({ export const useChatTimelineController = ({
sessionId, sessionId,
messages, messages,
@@ -339,9 +408,12 @@ export const useChatTimelineController = ({
// before triggering the state change. useLayoutEffect consumes it // before triggering the state change. useLayoutEffect consumes it
// after React commits new DOM — before the browser paints. // after React commits new DOM — before the browser paints.
const prePrependScrollRef = React.useRef<{ const prePrependScrollRef = React.useRef<{
sessionId: string | null;
height: number; height: number;
top: number; top: number;
anchor: ViewportAnchor | null; anchor: ViewportAnchor | null;
oldestId: string | null;
newestId: string | null;
} | null>(null); } | null>(null);
const captureViewportAnchor = React.useCallback((): ViewportAnchor | null => { const captureViewportAnchor = React.useCallback((): ViewportAnchor | null => {
@@ -364,26 +436,47 @@ export const useChatTimelineController = ({
scrollHeight: number; scrollHeight: number;
} | null>(null); } | null>(null);
React.useLayoutEffect(() => {
prePrependScrollRef.current = null;
prependTrackingRef.current = null;
}, [sessionId]);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
const container = scrollRef.current; const container = scrollRef.current;
if (!container) return; if (!container) return;
const snap = prePrependScrollRef.current; let snap = prePrependScrollRef.current;
const prev = prependTrackingRef.current; const prev = prependTrackingRef.current;
const currentOldestId = renderedMessages[0]?.info?.id ?? null; const currentOldestId = renderedMessages[0]?.info?.id ?? null;
const currentNewestId = renderedMessages[renderedMessages.length - 1]?.info?.id ?? null; const currentNewestId = renderedMessages[renderedMessages.length - 1]?.info?.id ?? null;
// A prepend = content inserted ABOVE the viewport: the oldest message id // A prepend = content inserted ABOVE the viewport: either the newest
// changed while the newest stayed the same. This distinguishes a history // stayed fixed, or the old first message still exists below a new first
// load from a bottom append, a streaming part growing, or a session switch. // message. The latter keeps preservation alive if a tail append lands in
const isPrepend = Boolean( // the same commit as the history page.
prev const isPrepend = prev
&& prev.oldestId ? isOlderHistoryPrependCommit({
&& currentOldestId previousOldestId: prev.oldestId,
&& currentOldestId !== prev.oldestId previousNewestId: prev.newestId,
&& prev.newestId currentOldestId,
&& currentNewestId currentNewestId,
&& currentNewestId === prev.newestId, }) || 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 = () => { const updateTracking = () => {
prependTrackingRef.current = { 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) { if (isPinnedRef.current) {
// Bottom-pinned. Only content inserted ABOVE (a prepend / history load) // Bottom-pinned. Only content inserted ABOVE (a prepend / history load)
// needs an explicit re-pin: with overflow-anchor:none the browser leaves // 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 // 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 // queue / while streaming. So for an append we do nothing and let
// auto-follow own it. // auto-follow own it.
if (snap || isPrepend) { if (didPrepend) {
prePrependScrollRef.current = null; prePrependScrollRef.current = null;
goToBottom('instant'); goToBottom('instant');
} else if (snap) {
refreshPendingSnapshot();
} }
updateTracking(); updateTracking();
return; 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; 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 // When a viewport anchor is available, delegate to MessageList
// restoreViewportAnchor which falls back to virtualizer-aware // restoreViewportAnchor which falls back to virtualizer-aware
// scrollHistoryIndexIntoView when the element is not in the DOM. // 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))) { if (!(snap.anchor && restoreViewportAnchor(snap.anchor))) {
// Fallback: height-delta compensation // Fallback: height-delta compensation
const delta = container.scrollHeight - snap.height; applyHeightDelta();
if (delta > 0) {
container.scrollTop = snap.top + delta;
}
} }
} else if (isPrepend && prev) { } else if (isPrepend && prev && !historyVirtualized) {
// Released viewport: preserve the read position by compensating for the // Released viewport: preserve the read position by compensating for the
// exact height the prepend added above, with no intermediate frame for // 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; const delta = container.scrollHeight - prev.scrollHeight;
if (delta > 0) { 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(); updateTracking();
}, [renderedMessages, scrollRef, restoreViewportAnchor, goToBottom]); }, [captureViewportAnchor, messageListRef, renderedMessages, scrollRef, restoreViewportAnchor, goToBottom]);
const revealBufferedTurns = React.useCallback(async (): Promise<boolean> => false, []); const revealBufferedTurns = React.useCallback(async (): Promise<boolean> => false, []);
@@ -462,9 +607,12 @@ export const useChatTimelineController = ({
// compensate synchronously when React commits the new messages. // compensate synchronously when React commits the new messages.
if (input.preserveViewport && container) { if (input.preserveViewport && container) {
prePrependScrollRef.current = { prePrependScrollRef.current = {
sessionId: sessionIdRef.current,
height: container.scrollHeight, height: container.scrollHeight,
top: container.scrollTop, top: container.scrollTop,
anchor: captureViewportAnchor(), anchor: captureViewportAnchor(),
oldestId: beforeOldestMessageId,
newestId: beforeMessages[beforeMessages.length - 1]?.info?.id ?? null,
}; };
} }
@@ -474,6 +622,7 @@ export const useChatTimelineController = ({
try { try {
const targetSessionId = sessionIdRef.current; const targetSessionId = sessionIdRef.current;
if (!targetSessionId) { if (!targetSessionId) {
prePrependScrollRef.current = null;
return false; return false;
} }
@@ -485,6 +634,7 @@ export const useChatTimelineController = ({
while (true) { while (true) {
await loadMoreMessages(targetSessionId, 'up'); await loadMoreMessages(targetSessionId, 'up');
if (sessionIdRef.current !== targetSessionId) { if (sessionIdRef.current !== targetSessionId) {
prePrependScrollRef.current = null;
return false; return false;
} }
@@ -506,6 +656,7 @@ export const useChatTimelineController = ({
return true; return true;
} }
if (!messageGrowth) { if (!messageGrowth) {
prePrependScrollRef.current = null;
return false; return false;
} }
if (!historySignalsRef.current.hasMoreAboveTurns) { if (!historySignalsRef.current.hasMoreAboveTurns) {
@@ -516,6 +667,9 @@ export const useChatTimelineController = ({
loadedOldestMessageId = afterOldestMessageId; loadedOldestMessageId = afterOldestMessageId;
loadedLimit = afterLimit; loadedLimit = afterLimit;
} }
} catch (error) {
prePrependScrollRef.current = null;
throw error;
} finally { } finally {
setIsLoadingOlder(false); setIsLoadingOlder(false);
settleHistoryInteraction(); settleHistoryInteraction();
@@ -547,6 +701,10 @@ export const useChatTimelineController = ({
}, [loadEarlier, scrollRef]); }, [loadEarlier, scrollRef]);
const loadEarlierIfPinnedViewportUnderfilled = React.useCallback(() => { 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; if (historyInteractionRef.current) return;
const container = scrollRef.current; const container = scrollRef.current;
if (!container) return; if (!container) return;
@@ -0,0 +1,13 @@
let markdownRendererModulePromise: Promise<typeof import('./MarkdownRendererImpl')> | null = null;
export const loadMarkdownRendererModule = () => {
markdownRendererModulePromise ??= import('./MarkdownRendererImpl').catch((error) => {
markdownRendererModulePromise = null;
throw error;
});
return markdownRendererModulePromise;
};
export const preloadMarkdownRenderer = () => {
void loadMarkdownRendererModule().catch(() => undefined);
};
+12 -8
View File
@@ -399,7 +399,12 @@ export function useSync() {
complete: merged.complete, complete: merged.complete,
loading: false, 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({ setSessionPrefetch({
directory, directory,
sessionID, sessionID,
@@ -498,13 +503,12 @@ export function useSync() {
shouldLoadMessages ? loadMessages(sessionID, { isStale }) : Promise.resolve(), shouldLoadMessages ? loadMessages(sessionID, { isStale }) : Promise.resolve(),
]) ])
// Progressive mount: after the initial page resolves, if the session // Progressive mount on desktop: after the initial page resolves, if the
// isn't stale and the server indicated more messages, dispatch a // session isn't stale and the server indicated more messages, dispatch a
// second fetch to prepend older history. The user sees the first page // second fetch to prepend older history. Mobile avoids this background
// immediately; the rest arrive shortly after. This gives the scroll // prepend because adding rows after first paint on a narrow viewport can
// container headroom above the viewport so the "load older on // visibly shift the timeline; user scroll still loads older history.
// scroll-up" trigger fires before the user hits the absolute top. if (!isStale() && !isMobileSurfaceRuntime()) {
if (!isStale()) {
const currentMeta = getMetaFor(sessionID) const currentMeta = getMetaFor(sessionID)
if (currentMeta.cursor && !currentMeta.complete) { if (currentMeta.cursor && !currentMeta.complete) {
loadMessages(sessionID, { before: currentMeta.cursor, mode: "prepend", isStale }) loadMessages(sessionID, { before: currentMeta.cursor, mode: "prepend", isStale })