diff --git a/apps/web/components/task/chat/message-list-native-scroll.ts b/apps/web/components/task/chat/message-list-native-scroll.ts index d57058c61d..5b870248f2 100644 --- a/apps/web/components/task/chat/message-list-native-scroll.ts +++ b/apps/web/components/task/chat/message-list-native-scroll.ts @@ -1,6 +1,6 @@ /* eslint-disable max-lines -- pagination, scroll anchoring, and retry state share one boundary. */ -import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { useDockviewStore } from "@/lib/state/dockview-store"; import { useAppStoreApi } from "@/components/state-provider"; import { getStoredAutoScrollTop } from "@/lib/local-storage"; @@ -24,6 +24,8 @@ import { scheduleClampedScrollRestore } from "./clamped-scroll-restore"; import { createDebugLogger, isDebug } from "@/lib/debug/log"; const paginationDebug = createDebugLogger("messages:pagination"); +// i18n-exempt: IntersectionObserver root-margin configuration, not user-facing copy. +export const TRANSCRIPT_SENTINEL_ROOT_MARGIN = "200px 0px 0px 0px"; // INT32_MAX: WebKit resolves Number.MAX_SAFE_INTEGER to 0 (not bottom). const NATIVE_BOTTOM_SCROLL_TOP = 2_147_483_647; @@ -36,6 +38,7 @@ export function scrollNativeToBottom(element: HTMLElement): void { type PaginationRequest = { boundaryBefore: string | null; + sessionEpoch: number; debug?: { generation: number; scrollTopBefore: number | null; @@ -66,6 +69,7 @@ function resolvePaginationSettleReason( result: LazyLoadSentinelSettleResult, boundaryUnchanged: boolean, hasMore: boolean, + sentinelInPreload: boolean, ): PaginationStopReason | null { switch (result.continuation) { case "continued": @@ -75,12 +79,137 @@ function resolvePaginationSettleReason( return "no-progress"; case "caller-stopped": case "no-more": + if (result.continuation === "caller-stopped" && !sentinelInPreload) { + return "sentinel-left-preload"; + } return resolvePaginationStopReason(boundaryUnchanged, hasMore); default: return result.continuation; } } +type RootMarginPixels = { + top: number; + right: number; + bottom: number; + left: number; +}; + +function parseRootMarginPixels(rootMargin: string): RootMarginPixels { + const values = rootMargin + .trim() + .split(/\s+/) + .map((value) => Number.parseFloat(value)) + .map((value) => (Number.isFinite(value) ? value : 0)); + const [top = 0, right = top, bottom = top, left = right] = values; + return { top, right, bottom, left }; +} + +/** Returns whether the current sentinel geometry is inside the observer's + * preload rectangle. This is intentionally measured after a page commit, + * rather than inferred from the last IntersectionObserver entry. */ +export function isElementInPreloadRegion( + scrollRoot: HTMLElement, + sentinel: HTMLElement, + rootMargin = TRANSCRIPT_SENTINEL_ROOT_MARGIN, +): boolean { + const rootRect = scrollRoot.getBoundingClientRect(); + const sentinelRect = sentinel.getBoundingClientRect(); + const margin = parseRootMarginPixels(rootMargin); + return ( + sentinelRect.bottom >= rootRect.top - margin.top && + sentinelRect.top <= rootRect.bottom + margin.bottom && + sentinelRect.right >= rootRect.left - margin.left && + sentinelRect.left <= rootRect.right + margin.right + ); +} + +function resolveRecoveryVisibility( + result: LazyLoadSentinelSettleResult, + hasMore: boolean, +): boolean | null { + if (result.continuation === "stale") return null; + if (hasMore && (result.continuation === "rejected" || result.continuation === "no-progress")) { + return true; + } + if (result.count > 0 || !hasMore) return false; + return null; +} + +function usePaginationRecovery(sessionId: string | null, hasMore: boolean) { + const [showRecovery, setShowRecovery] = useState(false); + const recoverySessionRef = useRef(sessionId); + + useEffect(() => { + if (recoverySessionRef.current !== sessionId || !sessionId || !hasMore) { + setShowRecovery(false); + } + recoverySessionRef.current = sessionId; + }, [hasMore, sessionId]); + + const reportRecovery = useCallback( + (result: LazyLoadSentinelSettleResult) => { + const nextVisibility = resolveRecoveryVisibility(result, hasMore); + if (nextVisibility !== null) setShowRecovery(nextVisibility); + }, + [hasMore], + ); + + return { showRecovery, reportRecovery }; +} + +function useSessionEpoch(sessionId: string | null) { + const epochRef = useRef(0); + const previousSessionIdRef = useRef(sessionId); + if (previousSessionIdRef.current !== sessionId) { + previousSessionIdRef.current = sessionId; + epochRef.current += 1; + } + return epochRef; +} + +function reportPaginationSettleDebug(params: { + result: LazyLoadSentinelSettleResult; + sessionId: string | null; + request: PaginationRequest | null; + items: RenderItem[]; + scrollRoot: HTMLElement | null; + sentinel: HTMLElement | null; + hasMore: boolean; +}) { + const request = params.request; + if (!isDebug() || !request?.debug) return; + const { result, scrollRoot, sentinel } = params; + const requestDebug = request.debug; + const boundaryAfter = getOldestVisibleBoundaryKey(params.items); + const boundaryUnchanged = request.boundaryBefore === boundaryAfter; + const sentinelInPreload = Boolean( + scrollRoot && + sentinel && + isElementInPreloadRegion(scrollRoot, sentinel, TRANSCRIPT_SENTINEL_ROOT_MARGIN), + ); + paginationDebug("older page settled", { + sessionId: params.sessionId, + trigger: "top-intersection", + generation: requestDebug.generation, + loadedCount: result.count, + boundaryBefore: request.boundaryBefore, + boundaryAfter, + scrollTopBefore: requestDebug.scrollTopBefore, + scrollTopAfter: scrollRoot?.scrollTop ?? null, + scrollHeightBefore: requestDebug.scrollHeightBefore, + scrollHeightAfter: scrollRoot?.scrollHeight ?? null, + continued: result.continuation === "continued", + stopReason: resolvePaginationSettleReason( + result, + boundaryUnchanged, + params.hasMore, + sentinelInPreload, + ), + continuation: result.continuation, + }); +} + /** * Continuously captures scroll state via scroll listener. * On a genuine prepend (older messages loaded above the current view, so the @@ -200,11 +329,12 @@ function capturePrependScrollState(scrollRoot: HTMLElement, anchorKey: string | /** * Observes a sentinel element at the top of the list to trigger lazy loading. - * Re-arms only while older pages leave the committed visible boundary - * unchanged, which crosses collapsed activity without cascading through - * standalone messages. The transcript does not join an in-flight request. - * The explicit button remains the recovery path for errors and no-op pages. + * Re-arms only while the committed sentinel remains inside the preload + * region, which crosses both collapsed activity and standalone messages. The + * transcript does not join an in-flight request. The explicit button is + * rendered only as the recovery path for errors and no-op pages. */ +// eslint-disable-next-line max-lines-per-function -- request epoch, geometry, and recovery must stay synchronized here. function useLazyLoadSentinel(params: { scrollRef: React.RefObject; items: RenderItem[]; @@ -219,40 +349,34 @@ function useLazyLoadSentinel(params: { itemsRef.current = items; const hasMoreRef = useRef(hasMore); hasMoreRef.current = hasMore; + const sentinelNodeRef = useRef(null); const requestGenerationRef = useRef(0); const requestRef = useRef(null); + const sessionEpochRef = useSessionEpoch(sessionId); + const { showRecovery, reportRecovery } = usePaginationRecovery(sessionId, hasMore); const reportSettle = useCallback( (result: LazyLoadSentinelSettleResult) => { - if (!isDebug()) return; const request = requestRef.current; - const requestDebug = request?.debug; - if (!request || !requestDebug) return; - const boundaryAfter = getOldestVisibleBoundaryKey(itemsRef.current); - const scroller = scrollRef.current; - const boundaryUnchanged = request.boundaryBefore === boundaryAfter; - paginationDebug("older page settled", { + const staleSession = request !== null && request.sessionEpoch !== sessionEpochRef.current; + if (!staleSession) reportRecovery(result); + reportPaginationSettleDebug({ + result, sessionId, - trigger: "top-intersection", - generation: requestDebug.generation, - loadedCount: result.count, - boundaryBefore: request.boundaryBefore, - boundaryAfter, - scrollTopBefore: requestDebug.scrollTopBefore, - scrollTopAfter: scroller?.scrollTop ?? null, - scrollHeightBefore: requestDebug.scrollHeightBefore, - scrollHeightAfter: scroller?.scrollHeight ?? null, - continued: result.continuation === "continued", - stopReason: resolvePaginationSettleReason(result, boundaryUnchanged, hasMoreRef.current), - continuation: result.continuation, + request, + items: itemsRef.current, + scrollRoot: scrollRef.current, + sentinel: sentinelNodeRef.current, + hasMore: hasMoreRef.current, }); }, - [scrollRef, sessionId], + [reportRecovery, scrollRef, sessionId], ); const loadPage = useCallback(async () => { const request: PaginationRequest = { boundaryBefore: getOldestVisibleBoundaryKey(itemsRef.current), + sessionEpoch: sessionEpochRef.current, }; requestRef.current = request; if (isDebug()) { @@ -274,19 +398,50 @@ function useLazyLoadSentinel(params: { return loadMore(); }, [loadMore, scrollRef, sessionId]); - const shouldContinueWhileIntersecting = useCallback(() => { + const isRequestCurrent = useCallback(() => { const request = requestRef.current; - if (!request) return false; - const boundaryAfter = getOldestVisibleBoundaryKey(itemsRef.current); - const boundaryUnchanged = request.boundaryBefore === boundaryAfter; - return boundaryUnchanged && hasMoreRef.current; + return request === null || request.sessionEpoch === sessionEpochRef.current; }, []); - return useSharedLazyLoadSentinel(scrollRef, hasMore, blocked, isLoadingMore, loadPage, { - rearmWhileIntersecting: true, - shouldContinueWhileIntersecting, - onLoadSettled: isDebug() ? reportSettle : undefined, - }); + const shouldContinueWhileIntersecting = useCallback(() => { + const scrollRoot = scrollRef.current; + const sentinel = sentinelNodeRef.current; + return Boolean( + hasMoreRef.current && + scrollRoot && + sentinel && + isElementInPreloadRegion(scrollRoot, sentinel, TRANSCRIPT_SENTINEL_ROOT_MARGIN), + ); + }, [scrollRef]); + + const sharedSentinel = useSharedLazyLoadSentinel( + scrollRef, + hasMore, + blocked, + isLoadingMore, + loadPage, + { + rootMargin: TRANSCRIPT_SENTINEL_ROOT_MARGIN, + rearmWhileIntersecting: true, + shouldContinueWhileIntersecting, + onLoadSettled: reportSettle, + isRequestCurrent, + }, + ); + const sentinelRef = useCallback( + (node: HTMLDivElement | null) => { + sentinelNodeRef.current = node; + sharedSentinel.sentinelRef(node); + }, + [sharedSentinel.sentinelRef], + ); + + return { + sentinelRef, + onUserGesture: sharedSentinel.onUserGesture, + retry: sharedSentinel.retry, + showRecovery, + }; } /** @@ -818,7 +973,12 @@ export function useNativeScrollManagement(params: { ); const handleScrollToMessage = useScrollToMessage(scrollRef, runGuardedScroll); useScrollPositionOnPrepend(scrollRef, items, isLoadingMore, isProgrammaticScrollLocked); - const { sentinelRef, onUserGesture } = useLazyLoadSentinel({ + const { + sentinelRef, + onUserGesture, + retry: retryLoadMore, + showRecovery, + } = useLazyLoadSentinel({ scrollRef, items, sessionId, @@ -830,5 +990,12 @@ export function useNativeScrollManagement(params: { useRetryPaginationOnUpwardScroll(scrollRef, onUserGesture, isProgrammaticScrollLocked); useInitialScrollPosition(scrollRef, items.length, sessionId, enabled, isNearBottomRef); - return { handleScrollToMessage, sentinelRef, resyncIsNearBottom, markNotNearBottom }; + return { + handleScrollToMessage, + sentinelRef, + resyncIsNearBottom, + markNotNearBottom, + retryLoadMore, + showRecovery, + }; } diff --git a/apps/web/components/task/chat/message-list-native.test.tsx b/apps/web/components/task/chat/message-list-native.test.tsx index a19a98b3be..468e12ac94 100644 --- a/apps/web/components/task/chat/message-list-native.test.tsx +++ b/apps/web/components/task/chat/message-list-native.test.tsx @@ -7,11 +7,16 @@ import type { RenderItem } from "@/hooks/use-processed-messages"; const sharedSentinelCalls = vi.hoisted(() => [] as unknown[][]); const sharedSentinelUserGesture = vi.hoisted(() => vi.fn()); +const sharedSentinelRetry = vi.hoisted(() => vi.fn()); vi.mock("@/hooks/use-lazy-load-sentinel", () => ({ useLazyLoadSentinel: (...args: unknown[]) => { sharedSentinelCalls.push(args); - return { sentinelRef: () => {}, onUserGesture: sharedSentinelUserGesture }; + return { + sentinelRef: () => {}, + onUserGesture: sharedSentinelUserGesture, + retry: sharedSentinelRetry, + }; }, })); @@ -31,7 +36,9 @@ vi.mock("@/components/state-provider", () => ({ import { useScrollToDividerOrBottom } from "./message-list-native"; import { + isElementInPreloadRegion, resolvePaginationStopReason, + TRANSCRIPT_SENTINEL_ROOT_MARGIN, useAutoScroll, useNativeScrollManagement, useScrollToMessage, @@ -144,6 +151,7 @@ afterEach(() => { cleanup(); sharedSentinelCalls.length = 0; sharedSentinelUserGesture.mockReset(); + sharedSentinelRetry.mockReset(); vi.restoreAllMocks(); }); @@ -155,29 +163,16 @@ function transcriptActivity(id: string, turnId = id): RenderItem { return { type: "turn_group", id, turnId, messages: [] }; } -function NativeScrollManagementHarness({ - items, - metrics, - loadMore = async () => 0, -}: { - items: RenderItem[]; - metrics?: { scrollHeight: number; scrollTop: number; clientHeight: number }; - loadMore?: () => Promise; -}) { - const scrollRef = useRef(null); - useNativeScrollManagement({ - scrollRef, - items, - messages: [], - isWorking: false, - sessionId: null, - enabled: false, - hasUnreadDivider: false, - messagesLoading: false, - hasMore: true, - isLoadingMore: false, - loadMore, - }); +type NativeScrollMetrics = { + scrollHeight: number; + scrollTop: number; + clientHeight: number; +}; + +function useNativeScrollMetrics( + scrollRef: { current: HTMLDivElement | null }, + metrics?: NativeScrollMetrics, +) { useLayoutEffect(() => { const element = scrollRef.current; if (!element || !metrics) return; @@ -192,8 +187,43 @@ function NativeScrollManagementHarness({ }, }, }); - }, [metrics]); - return
; + }, [metrics, scrollRef]); +} + +function NativeScrollManagementHarness({ + items, + metrics, + loadMore = async () => 0, + sessionId = null, + recoveryRef, +}: { + items: RenderItem[]; + metrics?: NativeScrollMetrics; + loadMore?: () => Promise; + sessionId?: string | null; + recoveryRef?: { current: boolean }; +}) { + const scrollRef = useRef(null); + const { sentinelRef, showRecovery } = useNativeScrollManagement({ + scrollRef, + items, + messages: [], + isWorking: false, + sessionId, + enabled: false, + hasUnreadDivider: false, + messagesLoading: false, + hasMore: true, + isLoadingMore: false, + loadMore, + }); + if (recoveryRef) recoveryRef.current = showRecovery; + useNativeScrollMetrics(scrollRef, metrics); + return ( +
+
+
+ ); } describe("resolvePaginationStopReason", () => { @@ -210,6 +240,30 @@ describe("resolvePaginationStopReason", () => { ); }); +describe("isElementInPreloadRegion", () => { + it("uses current root and sentinel geometry instead of a stale intersection", () => { + const root = document.createElement("div"); + const sentinel = document.createElement("div"); + Object.defineProperty(root, "getBoundingClientRect", { + configurable: true, + value: () => createRect(100, 400), + }); + Object.defineProperty(sentinel, "getBoundingClientRect", { + configurable: true, + value: () => createRect(-102, 1), + }); + + expect(isElementInPreloadRegion(root, sentinel, TRANSCRIPT_SENTINEL_ROOT_MARGIN)).toBe(false); + + Object.defineProperty(sentinel, "getBoundingClientRect", { + configurable: true, + value: () => createRect(-50, 1), + }); + expect(isElementInPreloadRegion(root, sentinel, TRANSCRIPT_SENTINEL_ROOT_MARGIN)).toBe(true); + }); +}); + +// eslint-disable-next-line max-lines-per-function -- pagination invariants share one fixture and lifecycle. describe("useNativeScrollManagement transcript pagination", () => { it("retries a disarmed short page on the next upward scroll", () => { const metrics = { scrollHeight: 1000, scrollTop: 100, clientHeight: 400 }; @@ -229,18 +283,98 @@ describe("useNativeScrollManagement transcript pagination", () => { expect(sharedSentinelUserGesture).toHaveBeenCalledTimes(1); }); - it("re-arms the native sentinel with a visible-boundary continuation decision", () => { + it("re-arms the native sentinel with a current-geometry continuation decision", () => { render(); const options = sharedSentinelCalls[0]?.[5]; expect(options).toEqual({ + rootMargin: "200px 0px 0px 0px", rearmWhileIntersecting: true, shouldContinueWhileIntersecting: expect.any(Function), onLoadSettled: expect.any(Function), + isRequestCurrent: expect.any(Function), }); }); - it("continues only while an older page keeps the same visible boundary", async () => { + it("shows recovery only after no progress and clears it after success or session change", () => { + const recoveryRef = { current: false }; + const { rerender } = render( + , + ); + const options = sharedSentinelCalls.at(-1)?.[5] as { + onLoadSettled: (result: { + count: number; + rejected: boolean; + continuation: "no-progress" | "continued"; + }) => void; + }; + + act(() => { + options.onLoadSettled({ count: 0, rejected: false, continuation: "no-progress" }); + }); + expect(recoveryRef.current).toBe(true); + + act(() => { + options.onLoadSettled({ count: 20, rejected: false, continuation: "continued" }); + }); + expect(recoveryRef.current).toBe(false); + + act(() => { + options.onLoadSettled({ count: 0, rejected: true, continuation: "no-progress" }); + }); + expect(recoveryRef.current).toBe(true); + rerender( + , + ); + expect(recoveryRef.current).toBe(false); + }); + + it("ignores a settlement from the previous session epoch", async () => { + const recoveryRef = { current: false }; + let resolveLoad: (value: number) => void = () => {}; + const loadMore = vi.fn( + () => + new Promise((resolve) => { + resolveLoad = resolve; + }), + ); + const { rerender } = render( + , + ); + const initialCall = sharedSentinelCalls.at(-1); + const loadPage = initialCall?.[4] as () => Promise; + const oldOptions = initialCall?.[5] as { + onLoadSettled: (result: { + count: number; + rejected: boolean; + continuation: "rejected"; + }) => void; + }; + const pendingLoad = loadPage(); + + rerender( + , + ); + act(() => { + oldOptions.onLoadSettled({ count: 0, rejected: true, continuation: "rejected" }); + }); + expect(recoveryRef.current).toBe(false); + + resolveLoad(0); + await pendingLoad; + }); + + it("continues while the sentinel remains in preload even when the visible boundary changes", async () => { const loadMore = vi.fn(async () => 20); const newest = transcriptMessage("newest"); const { rerender } = render( @@ -273,7 +407,7 @@ describe("useNativeScrollManagement transcript pagination", () => { options = sharedSentinelCalls.at(-1)?.[5] as { shouldContinueWhileIntersecting: () => boolean; }; - expect(options.shouldContinueWhileIntersecting()).toBe(false); + expect(options.shouldContinueWhileIntersecting()).toBe(true); }); it("anchors a prepend below a fixed task description row", () => { diff --git a/apps/web/components/task/chat/message-list-native.tsx b/apps/web/components/task/chat/message-list-native.tsx index 3fae945c3f..eaf76b9337 100644 --- a/apps/web/components/task/chat/message-list-native.tsx +++ b/apps/web/components/task/chat/message-list-native.tsx @@ -121,19 +121,20 @@ function useNativeMessageListScroll(params: NativeMessageListScrollParams) { onFirstMessageHiddenChange, scrollLayoutKey, } = params; - const { handleScrollToMessage, sentinelRef, markNotNearBottom } = useNativeScrollManagement({ - scrollRef, - items, - messages, - isWorking, - sessionId, - enabled, - hasUnreadDivider: Boolean(dividerBeforeItemKey), - messagesLoading, - hasMore, - isLoadingMore, - loadMore, - }); + const { handleScrollToMessage, sentinelRef, markNotNearBottom, retryLoadMore, showRecovery } = + useNativeScrollManagement({ + scrollRef, + items, + messages, + isWorking, + sessionId, + enabled, + hasUnreadDivider: Boolean(dividerBeforeItemKey), + messagesLoading, + hasMore, + isLoadingMore, + loadMore, + }); const anchoredBarOffsetPx = anchoredBarScrollOffsetPx(anchoredBarHeight); useEffect(() => { scrollRef.current?.style.setProperty("--anchored-bar-h", `${anchoredBarOffsetPx}px`); @@ -152,7 +153,7 @@ function useNativeMessageListScroll(params: NativeMessageListScrollParams) { firstMessageId, onFirstMessageHiddenChange, ); - return { handleScrollToMessage, sentinelRef }; + return { handleScrollToMessage, sentinelRef, retryLoadMore, showRecovery }; } type MessageRowProps = { @@ -236,7 +237,8 @@ type NativeMessageListBodyProps = { isLoadingMore: boolean; isInitialLoading: boolean; showLoadingState: boolean; - loadMore: () => Promise; + retryLoadMore: () => void; + showRecovery: boolean; sentinelRef: (node: HTMLDivElement | null) => void; lastTurnGroupId: string | null; activeTurnId: string | null; @@ -378,7 +380,8 @@ function NativeMessageListBody({ isLoadingMore, isInitialLoading, showLoadingState, - loadMore, + retryLoadMore, + showRecovery, sentinelRef, lastTurnGroupId, activeTurnId, @@ -399,7 +402,8 @@ function NativeMessageListBody({ messagesLoading={messagesLoading} isInitialLoading={isInitialLoading} messagesCount={messages.length} - onLoadMore={loadMore} + onLoadMore={retryLoadMore} + showRecovery={showRecovery} /> {items.map((item) => ( @@ -484,33 +488,34 @@ export const NativeMessageList = memo( const streamingMessageId = getStreamingAgentMessageId(messages); const lastTurnGroupId = useMemo(() => getLastTurnGroupId(items), [items]); const autoScrollEnabled = useTranscriptAutoScrollEnabled(sessionId); - const { handleScrollToMessage, sentinelRef } = useNativeMessageListScroll({ - scrollRef, - ref, - items, - messages, - isWorking, - sessionId, - enabled: autoScrollEnabled, - dividerBeforeItemKey, - anchoredBarHeight, - messagesLoading, - hasMore, - isLoadingMore, - loadMore, - lastPromptMessageId, - onLastPromptEdgeChange, - firstMessageId, - onFirstMessageHiddenChange, - scrollLayoutKey: [ - messagesLoading, - isInitialLoading, - showLoadingState, - isLoadingMore, - hasMore, + const { handleScrollToMessage, sentinelRef, retryLoadMore, showRecovery } = + useNativeMessageListScroll({ + scrollRef, + ref, + items, + messages, isWorking, - ].join(":"), - }); + sessionId, + enabled: autoScrollEnabled, + dividerBeforeItemKey, + anchoredBarHeight, + messagesLoading, + hasMore, + isLoadingMore, + loadMore, + lastPromptMessageId, + onLastPromptEdgeChange, + firstMessageId, + onFirstMessageHiddenChange, + scrollLayoutKey: [ + messagesLoading, + isInitialLoading, + showLoadingState, + isLoadingMore, + hasMore, + isWorking, + ].join(":"), + }); return ( { }); describe("MessageListStatus", () => { + it("does not render the older control during routine pagination", () => { + render( + , + ); + + expect(screen.queryByTestId("load-older-messages")).toBeNull(); + }); + + it("renders one explicit retry control during recovery", () => { + const onLoadMore = vi.fn(); + render( + , + ); + + const button = screen.getByTestId("load-older-messages"); + button.click(); + expect(onLoadMore).toHaveBeenCalledTimes(1); + }); + it("renders a conversation loading indicator while existing content remains visible", () => { render( void; + /** Shows the explicit control only after a recoverable pagination failure. */ + showRecovery?: boolean; }) { const { t } = useTranslation(); + const { isFinePointer } = useResponsiveBreakpoint(); return ( <> {isLoadingMore && hasMore && ( @@ -409,13 +414,13 @@ export function MessageListStatus({ {t("task:loadingOlderMessages")}
)} - {hasMore && !isLoadingMore && onLoadMore && ( + {hasMore && !isLoadingMore && showRecovery && onLoadMore && (