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 083a5d5d7d..68a4ccd6ae 100644 --- a/apps/web/components/task/chat/message-list-native-scroll.ts +++ b/apps/web/components/task/chat/message-list-native-scroll.ts @@ -362,6 +362,8 @@ function useLazyLoadSentinel(params: { const requestRef = useRef(null); const sessionEpochRef = useSessionEpoch(sessionId); const { showRecovery, reportRecovery } = usePaginationRecovery(sessionId, hasMore); + const showRecoveryRef = useRef(showRecovery); + showRecoveryRef.current = showRecovery; const reportSettle = useCallback( (result: LazyLoadSentinelSettleResult) => { @@ -432,6 +434,9 @@ function useLazyLoadSentinel(params: { rootMargin: TRANSCRIPT_SENTINEL_ROOT_MARGIN, rearmWhileIntersecting: true, shouldContinueWhileIntersecting, + // Continuation and lifecycle/input eligibility both require the + // sentinel to remain inside the transcript's current preload region. + isCurrentGeometryEligible: shouldContinueWhileIntersecting, onLoadSettled: reportSettle, isRequestCurrent, }, @@ -443,11 +448,18 @@ function useLazyLoadSentinel(params: { }, [sharedSentinel.sentinelRef], ); + const onUserGesture = useCallback(() => { + if (!showRecoveryRef.current) sharedSentinel.onUserGesture(); + }, [sharedSentinel.onUserGesture]); + const recheck = useCallback(() => { + if (!showRecoveryRef.current) sharedSentinel.recheck(); + }, [sharedSentinel.recheck]); return { sentinelRef, - onUserGesture: sharedSentinel.onUserGesture, + onUserGesture, retry: sharedSentinel.retry, + recheck, showRecovery, }; } @@ -461,21 +473,79 @@ function useLazyLoadSentinel(params: { function useRetryPaginationOnUpwardScroll( scrollRef: React.RefObject, onUserGesture: () => void, + recheck: () => void, isProgrammaticScrollLocked: () => boolean, ) { useEffect(() => { const scroller = scrollRef.current; if (!scroller) return; let previousScrollTop = scroller.scrollTop; + let touchStartY: number | null = null; + let touchHandled = false; + const canRecheckHardTop = () => scroller.scrollTop <= 0 && !isProgrammaticScrollLocked(); const onScroll = () => { const nextScrollTop = scroller.scrollTop; const movedUp = nextScrollTop < previousScrollTop; previousScrollTop = nextScrollTop; if (movedUp && !isProgrammaticScrollLocked()) onUserGesture(); }; + const onWheel = (event: WheelEvent) => { + if (event.deltaY < 0 && canRecheckHardTop()) recheck(); + }; + const onKeyDown = (event: KeyboardEvent) => { + const movesUp = event.key === "ArrowUp" || event.key === "PageUp" || event.key === "Home"; + if (movesUp && canRecheckHardTop()) recheck(); + }; + const onTouchStart = (event: TouchEvent) => { + touchStartY = event.touches[0]?.clientY ?? null; + touchHandled = false; + }; + const onTouchMove = (event: TouchEvent) => { + const nextY = event.touches[0]?.clientY; + if ( + !touchHandled && + touchStartY !== null && + nextY !== undefined && + nextY > touchStartY && + canRecheckHardTop() + ) { + touchHandled = true; + recheck(); + } + }; scroller.addEventListener("scroll", onScroll, { passive: true }); - return () => scroller.removeEventListener("scroll", onScroll); - }, [isProgrammaticScrollLocked, onUserGesture, scrollRef]); + scroller.addEventListener("wheel", onWheel, { passive: true }); + scroller.addEventListener("keydown", onKeyDown); + scroller.addEventListener("touchstart", onTouchStart, { passive: true }); + scroller.addEventListener("touchmove", onTouchMove, { passive: true }); + return () => { + scroller.removeEventListener("scroll", onScroll); + scroller.removeEventListener("wheel", onWheel); + scroller.removeEventListener("keydown", onKeyDown); + scroller.removeEventListener("touchstart", onTouchStart); + scroller.removeEventListener("touchmove", onTouchMove); + }; + }, [isProgrammaticScrollLocked, onUserGesture, recheck, scrollRef]); +} + +/** Rechecks pagination after Dockview restores a hidden panel's scroll offset. + * Two frames place this after SessionPanelContent's one-frame restore without + * treating the initial visible mount as pagination intent. */ +function useRecheckPaginationOnVisible(isVisible: boolean, recheck: () => void) { + const previousVisibleRef = useRef(isVisible); + useEffect(() => { + const becameVisible = !previousVisibleRef.current && isVisible; + previousVisibleRef.current = isVisible; + if (!becameVisible) return; + let secondFrame: number | null = null; + const firstFrame = requestAnimationFrame(() => { + secondFrame = requestAnimationFrame(recheck); + }); + return () => { + cancelAnimationFrame(firstFrame); + if (secondFrame !== null) cancelAnimationFrame(secondFrame); + }; + }, [isVisible, recheck]); } /** Duration a programmatic scroll's guard stays held if the browser never @@ -949,6 +1019,7 @@ export function useNativeScrollManagement(params: { hasMore: boolean; isLoadingMore: boolean; loadMore: () => Promise; + isVisible: boolean; }) { const { scrollRef, @@ -962,6 +1033,7 @@ export function useNativeScrollManagement(params: { hasMore, isLoadingMore, loadMore, + isVisible, } = params; const programmaticScrollLockRef = useRef(false); const isProgrammaticScrollLocked = useCallback(() => programmaticScrollLockRef.current, []); @@ -994,6 +1066,7 @@ export function useNativeScrollManagement(params: { sentinelRef, onUserGesture, retry: retryLoadMore, + recheck, showRecovery, } = useLazyLoadSentinel({ scrollRef, @@ -1004,7 +1077,8 @@ export function useNativeScrollManagement(params: { isLoadingMore, loadMore: loadMoreWithPrependBaseline, }); - useRetryPaginationOnUpwardScroll(scrollRef, onUserGesture, isProgrammaticScrollLocked); + useRetryPaginationOnUpwardScroll(scrollRef, onUserGesture, recheck, isProgrammaticScrollLocked); + useRecheckPaginationOnVisible(isVisible, recheck); useInitialScrollPosition(scrollRef, items.length, sessionId, enabled, isNearBottomRef); return { 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 eca5455664..4d04d96055 100644 --- a/apps/web/components/task/chat/message-list-native.test.tsx +++ b/apps/web/components/task/chat/message-list-native.test.tsx @@ -8,6 +8,7 @@ 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()); +const sharedSentinelRecheck = vi.hoisted(() => vi.fn()); vi.mock("@/hooks/use-lazy-load-sentinel", () => ({ useLazyLoadSentinel: (...args: unknown[]) => { @@ -16,6 +17,7 @@ vi.mock("@/hooks/use-lazy-load-sentinel", () => ({ sentinelRef: () => {}, onUserGesture: sharedSentinelUserGesture, retry: sharedSentinelRetry, + recheck: sharedSentinelRecheck, }; }, })); @@ -56,6 +58,12 @@ const TEST_MESSAGES = [{} as Message]; /** Always returns false: the harness never locks programmatic scrolling. */ const NEVER_LOCKED = () => false; +function touchEvent(type: "touchstart" | "touchmove", clientY: number): TouchEvent { + const event = new Event(type) as TouchEvent; + Object.defineProperty(event, "touches", { value: [{ clientY }] }); + return event; +} + /** Renders a scroll container wired to useScrollToDividerOrBottom with mocked divider geometry. */ function Harness({ itemCount, @@ -153,6 +161,7 @@ afterEach(() => { sharedSentinelCalls.length = 0; sharedSentinelUserGesture.mockReset(); sharedSentinelRetry.mockReset(); + sharedSentinelRecheck.mockReset(); vi.restoreAllMocks(); }); @@ -198,6 +207,7 @@ function NativeScrollManagementHarness({ sessionId = null, isLoadingMore = false, recoveryRef, + isVisible = true, }: { items: RenderItem[]; metrics?: NativeScrollMetrics; @@ -205,6 +215,7 @@ function NativeScrollManagementHarness({ sessionId?: string | null; isLoadingMore?: boolean; recoveryRef?: { current: boolean }; + isVisible?: boolean; }) { const scrollRef = useRef(null); const { sentinelRef, showRecovery } = useNativeScrollManagement({ @@ -219,6 +230,7 @@ function NativeScrollManagementHarness({ hasMore: true, isLoadingMore, loadMore, + isVisible, }); if (recoveryRef) recoveryRef.current = showRecovery; useNativeScrollMetrics(scrollRef, metrics); @@ -268,6 +280,133 @@ describe("isElementInPreloadRegion", () => { // eslint-disable-next-line max-lines-per-function -- pagination invariants share one fixture and lifecycle. describe("useNativeScrollManagement transcript pagination", () => { + it("rechecks restored geometry once when a hidden transcript becomes visible", () => { + const requestAnimationFrame = vi + .spyOn(window, "requestAnimationFrame") + .mockImplementation((callback) => { + callback(0); + return 1; + }); + const { rerender } = render(); + + rerender(); + + expect(sharedSentinelRecheck).toHaveBeenCalledTimes(1); + requestAnimationFrame.mockRestore(); + }); + + it("rechecks pagination on upward input at the hard top without a scroll event", () => { + const metrics = { scrollHeight: 1000, scrollTop: 0, clientHeight: 400 }; + render(); + const scroller = screen.getByTestId(NATIVE_SCROLL_MANAGEMENT_TEST_ID); + + act(() => { + scroller.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 })); + }); + + expect(sharedSentinelRecheck).toHaveBeenCalledTimes(1); + }); + + it("rechecks pagination for every upward keyboard command at the hard top", () => { + const metrics = { scrollHeight: 1000, scrollTop: 0, clientHeight: 400 }; + render(); + const scroller = screen.getByTestId(NATIVE_SCROLL_MANAGEMENT_TEST_ID); + + for (const key of ["ArrowUp", "PageUp", "Home"]) { + act(() => { + scroller.dispatchEvent(new KeyboardEvent("keydown", { key })); + }); + } + + expect(sharedSentinelRecheck).toHaveBeenCalledTimes(3); + }); + + it("ignores keyboard input away from the hard top and non-upward commands", () => { + const metrics = { scrollHeight: 1000, scrollTop: 50, clientHeight: 400 }; + render(); + const scroller = screen.getByTestId(NATIVE_SCROLL_MANAGEMENT_TEST_ID); + + act(() => { + scroller.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp" })); + metrics.scrollTop = 0; + scroller.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown" })); + }); + + expect(sharedSentinelRecheck).not.toHaveBeenCalled(); + }); + + it("rechecks once for a directional touch gesture at the hard top", () => { + const metrics = { scrollHeight: 1000, scrollTop: 0, clientHeight: 400 }; + render(); + const scroller = screen.getByTestId(NATIVE_SCROLL_MANAGEMENT_TEST_ID); + + act(() => { + scroller.dispatchEvent(touchEvent("touchstart", 300)); + scroller.dispatchEvent(touchEvent("touchmove", 350)); + scroller.dispatchEvent(touchEvent("touchmove", 400)); + }); + + expect(sharedSentinelRecheck).toHaveBeenCalledTimes(1); + }); + + it("ignores non-upward touch input and touch input away from the hard top", () => { + const metrics = { scrollHeight: 1000, scrollTop: 0, clientHeight: 400 }; + render(); + const scroller = screen.getByTestId(NATIVE_SCROLL_MANAGEMENT_TEST_ID); + + act(() => { + scroller.dispatchEvent(touchEvent("touchstart", 300)); + scroller.dispatchEvent(touchEvent("touchmove", 250)); + metrics.scrollTop = 50; + scroller.dispatchEvent(touchEvent("touchstart", 300)); + scroller.dispatchEvent(touchEvent("touchmove", 350)); + }); + + expect(sharedSentinelRecheck).not.toHaveBeenCalled(); + }); + + it("does not recheck a restored transcript while explicit recovery is active", () => { + const requestAnimationFrame = vi + .spyOn(window, "requestAnimationFrame") + .mockImplementation((callback) => { + callback(0); + return 1; + }); + const recoveryRef = { current: false }; + const { rerender } = render( + , + ); + const options = sharedSentinelCalls.at(-1)?.[5] as { + onLoadSettled: (result: { + count: number; + rejected: boolean; + continuation: "no-progress"; + }) => void; + }; + act(() => { + options.onLoadSettled({ count: 0, rejected: false, continuation: "no-progress" }); + }); + sharedSentinelRecheck.mockClear(); + + rerender( + , + ); + + expect(recoveryRef.current).toBe(true); + expect(sharedSentinelRecheck).not.toHaveBeenCalled(); + requestAnimationFrame.mockRestore(); + }); + it("retries a disarmed short page on the next upward scroll", () => { const metrics = { scrollHeight: 1000, scrollTop: 100, clientHeight: 400 }; render(); @@ -294,6 +433,7 @@ describe("useNativeScrollManagement transcript pagination", () => { rootMargin: "200px 0px 0px 0px", rearmWhileIntersecting: true, shouldContinueWhileIntersecting: expect.any(Function), + isCurrentGeometryEligible: expect.any(Function), onLoadSettled: expect.any(Function), isRequestCurrent: expect.any(Function), }); diff --git a/apps/web/components/task/chat/message-list-native.tsx b/apps/web/components/task/chat/message-list-native.tsx index 3f5abe7435..b5e5d00a13 100644 --- a/apps/web/components/task/chat/message-list-native.tsx +++ b/apps/web/components/task/chat/message-list-native.tsx @@ -93,6 +93,7 @@ type NativeMessageListScrollParams = { onFirstMessageHiddenChange: ((isHidden: boolean) => void) | undefined; /** Changes when transcript status rows can add/remove space above messages. */ scrollLayoutKey: string; + isVisible: boolean; }; type ScrollToDividerOptions = { @@ -120,6 +121,7 @@ function useNativeMessageListScroll(params: NativeMessageListScrollParams) { firstMessageId, onFirstMessageHiddenChange, scrollLayoutKey, + isVisible, } = params; const { handleScrollToMessage, sentinelRef, markNotNearBottom, retryLoadMore, showRecovery } = useNativeScrollManagement({ @@ -134,6 +136,7 @@ function useNativeMessageListScroll(params: NativeMessageListScrollParams) { hasMore, isLoadingMore, loadMore, + isVisible, }); const anchoredBarOffsetPx = anchoredBarScrollOffsetPx(anchoredBarHeight); useEffect(() => { @@ -471,6 +474,7 @@ export const NativeMessageList = memo( stickyPromptBar, dividerBeforeItemKey, anchoredBarHeight, + isVisible = true, }: MessageListProps, ref, ) { @@ -517,6 +521,7 @@ export const NativeMessageList = memo( hasMore, isWorking, ].join(":"), + isVisible, }); return ( diff --git a/apps/web/components/task/chat/message-list-shared.tsx b/apps/web/components/task/chat/message-list-shared.tsx index afbc2b9593..f2aeb833d1 100644 --- a/apps/web/components/task/chat/message-list-shared.tsx +++ b/apps/web/components/task/chat/message-list-shared.tsx @@ -61,6 +61,10 @@ export type MessageListProps = { * to the top of the transcript (e.g. the unread "New" divider) reserve * room for the overlay instead of being covered by it. */ anchoredBarHeight?: number; + /** Whether the host panel is actually visible. Persistent Dockview panels + * remain mounted while inactive and use this transition to recover missed + * oldest-page sentinel observations. */ + isVisible?: boolean; }; /** Imperative handle exposed by `MessageList`, letting the chat panel scroll diff --git a/apps/web/components/task/task-chat-panel.tsx b/apps/web/components/task/task-chat-panel.tsx index f7d8f57108..40fef88ed2 100644 --- a/apps/web/components/task/task-chat-panel.tsx +++ b/apps/web/components/task/task-chat-panel.tsx @@ -651,6 +651,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({ firstMessageId={firstMessageId} onFirstMessageHiddenChange={setIsFirstMessageHidden} anchoredBarHeight={showAnchoredBar && lastPromptMessage ? anchoredBarHeight : 0} + isVisible={isVisible} stickyPromptBar={ showAnchoredBar && lastPromptMessage ? (