fix(web): recover restored transcript pagination (#3252)

This commit is contained in:
Carlos Florêncio
2026-09-01 15:20:56 +00:00
committed by GitHub
parent 3f1962662b
commit 57f90475cf
14 changed files with 787 additions and 12 deletions
@@ -362,6 +362,8 @@ function useLazyLoadSentinel(params: {
const requestRef = useRef<PaginationRequest | null>(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<HTMLDivElement | null>,
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<number>;
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 {
@@ -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<HTMLDivElement>(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(<NativeScrollManagementHarness items={[]} isVisible={false} />);
rerender(<NativeScrollManagementHarness items={[]} isVisible />);
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(<NativeScrollManagementHarness items={[]} metrics={metrics} />);
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(<NativeScrollManagementHarness items={[]} metrics={metrics} />);
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(<NativeScrollManagementHarness items={[]} metrics={metrics} />);
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(<NativeScrollManagementHarness items={[]} metrics={metrics} />);
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(<NativeScrollManagementHarness items={[]} metrics={metrics} />);
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(
<NativeScrollManagementHarness
items={[]}
sessionId="session-1"
recoveryRef={recoveryRef}
isVisible={false}
/>,
);
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(
<NativeScrollManagementHarness
items={[]}
sessionId="session-1"
recoveryRef={recoveryRef}
isVisible
/>,
);
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(<NativeScrollManagementHarness items={[]} metrics={metrics} />);
@@ -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),
});
@@ -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 (
@@ -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
@@ -651,6 +651,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({
firstMessageId={firstMessageId}
onFirstMessageHiddenChange={setIsFirstMessageHidden}
anchoredBarHeight={showAnchoredBar && lastPromptMessage ? anchoredBarHeight : 0}
isVisible={isVisible}
stickyPromptBar={
showAnchoredBar && lastPromptMessage ? (
<AnchoredLastPromptBar
@@ -13,6 +13,65 @@ export const TEXT_BATCH_MARKER = "TEXT-BATCH-MARKER-1F9L";
export const TEXT_BATCH_ANCHOR_MARKER = "TEXT-BATCH-ANCHOR-MARKER-4C7N";
export const DEEP_PROMPT_MARKER = "DEEP-PROMPT-MARKER-2P7N";
export const LONG_HISTORY_TAIL_MARKER = "LONG-HISTORY-TAIL-MARKER-6V4R";
export const RESTORED_SESSION_OLDER_MARKER = "RESTORED-SESSION-OLDER-MARKER-1C9F";
export const RESTORED_SESSION_TAIL_MARKER = "RESTORED-SESSION-TAIL-MARKER-8B3K";
/** Simulates the browser missing a fresh transcript sentinel entry after a
* hidden/restored geometry transition. Current-geometry recovery must remain
* independently reachable from panel activation or hard-top input. */
export async function suppressChatPaginationIntersections(page: Page): Promise<void> {
await page.addInitScript(() => {
const NativeIntersectionObserver = window.IntersectionObserver;
window.IntersectionObserver = class extends NativeIntersectionObserver {
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
super((entries, observer) => {
callback(
entries.filter((entry) => !entry.target.closest(".chat-message-list")),
observer,
);
}, options);
}
};
});
}
/** Seeds two sessions so the target transcript hydrates behind the active
* primary Dockview tab with its oldest-page sentinel at hidden geometry. */
export async function seedRestoredInactiveSessionHistory(
apiClient: ApiClient,
seedData: SeedData,
title: string,
): Promise<{ taskId: string; primarySessionId: string; targetSessionId: string }> {
const task = await apiClient.createTask(seedData.workspaceId, title, {
description: TASK_DESCRIPTION_MARKER,
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
const { session_id: primarySessionId } = await apiClient.seedTaskSession(task.id, {
state: "IDLE",
repositoryId: seedData.repositoryId,
});
await apiClient.seedSessionMessage(primarySessionId, {
type: "message",
content: "RESTORED-SESSION-PRIMARY-MARKER-4H2D",
});
const { session_id: targetSessionId } = await apiClient.seedTaskSession(task.id, {
state: "IDLE",
repositoryId: seedData.repositoryId,
});
await apiClient.seedSessionMessage(targetSessionId, {
type: "message",
content: INITIAL_PROMPT_MARKER,
authorType: "user",
});
await apiClient.seedSessionMessage(targetSessionId, {
type: "message",
content: RESTORED_SESSION_OLDER_MARKER,
});
await apiClient.seedAgentMessages(targetSessionId, 110, RESTORED_SESSION_TAIL_MARKER);
return { taskId: task.id, primarySessionId, targetSessionId };
}
/** Seeds an older prompt followed by a tool-only newest window. */
export async function seedToolHeavyOpeningHistory(
@@ -15,19 +15,79 @@ import {
TEXT_BATCH_MARKER,
VISIBLE_PAGE_MARKER,
LONG_HISTORY_TAIL_MARKER,
RESTORED_SESSION_OLDER_MARKER,
RESTORED_SESSION_TAIL_MARKER,
readMessageRowTopById,
readStandaloneMessageTop,
seedCollapsedMessageHistory,
seedLongMessageHistory,
seedRestoredInactiveSessionHistory,
seedShortBoundaryPageHistory,
seedTextSparseMessageHistory,
seedToolHeavyOpeningHistory,
seedVisibleMessageHistory,
scrollToOldestLoadedEdge,
suppressChatPaginationIntersections,
watchOlderMessageRequests,
} from "./message-pagination-helpers";
test.describe("@chat message pagination", () => {
test("restored inactive session resumes pagination from hard-top input", async ({
testPage,
apiClient,
seedData,
prCapture,
}) => {
test.setTimeout(180_000);
// @covers AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.14
// Reproduce the restored-panel race deterministically: the browser keeps
// the hidden chat mounted but does not deliver a fresh sentinel entry when
// its geometry later becomes visible.
await suppressChatPaginationIntersections(testPage);
const { taskId, primarySessionId, targetSessionId } = await seedRestoredInactiveSessionHistory(
apiClient,
seedData,
"message-pagination-restored-inactive-session",
);
const olderRequests = watchOlderMessageRequests(testPage, targetSessionId);
await testPage.goto(`/t/${taskId}`);
let session = new SessionPage(testPage);
await session.waitForLoad();
await session.sessionTabBySessionId(primarySessionId).click();
await expect(session.sessionTabBySessionId(targetSessionId)).toBeVisible();
await testPage.reload();
session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.sessionTabBySessionId(primarySessionId)).toBeVisible();
await expect(session.sessionTabBySessionId(targetSessionId)).toBeVisible();
const restoredChat = testPage
.locator('[data-testid="session-chat"]')
.filter({ hasText: RESTORED_SESSION_TAIL_MARKER });
await expect(restoredChat).toBeAttached();
olderRequests.length = 0;
await session.sessionTabBySessionId(targetSessionId).click();
const chat = session.activeChat();
const list = chat.locator(".chat-message-list");
await list.evaluate((element) => {
element.scrollTop = 0;
});
expect(await list.evaluate((element) => element.scrollTop)).toBe(0);
await list.dispatchEvent("wheel", { deltaY: -120 });
const olderMarker = chat.getByText(RESTORED_SESSION_OLDER_MARKER, { exact: true });
await expect(olderMarker).toBeVisible({
timeout: 15_000,
});
await expect.poll(() => olderRequests.length).toBeGreaterThan(0);
expect(new URL(olderRequests[0]).searchParams.get("before")).toBeTruthy();
await olderMarker.scrollIntoViewIfNeeded();
await prCapture.screenshot("restored-chat-hard-top-pagination", {
caption: "Restored secondary chat after hard-top input loads its older history",
});
});
test("does not load older history while opening a task", async ({
testPage,
apiClient,
@@ -22,12 +22,43 @@ import {
seedToolHeavyOpeningHistory,
seedVisibleMessageHistory,
scrollToOldestLoadedEdge,
suppressChatPaginationIntersections,
watchOlderMessageRequests,
} from "./message-pagination-helpers";
test.describe("Mobile chat message pagination", () => {
test.describe.configure({ timeout: 180_000 });
test("uses a hard-top touch gesture to recover a missed sentinel entry", async ({
testPage,
apiClient,
seedData,
}) => {
await suppressChatPaginationIntersections(testPage);
const { taskId, sessionId } = await seedVisibleMessageHistory(
apiClient,
seedData,
"mobile-message-pagination-hard-top-touch-recovery",
);
const olderRequests = watchOlderMessageRequests(testPage, sessionId);
await testPage.goto(`/t/${taskId}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle({ timeout: 30_000 });
const list = session.activeChat().locator(".chat-message-list");
await list.evaluate((element) => {
element.scrollTop = 0;
});
expect(await list.evaluate((element) => element.scrollTop)).toBe(0);
await list.dispatchEvent("touchstart", { touches: [{ identifier: 1, clientY: 300 }] });
await list.dispatchEvent("touchmove", { touches: [{ identifier: 1, clientY: 350 }] });
await expect.poll(() => olderRequests.length).toBeGreaterThan(0);
expect(new URL(olderRequests[0]).searchParams.get("before")).toBeTruthy();
});
test("does not load older history while opening a task", async ({
testPage,
apiClient,
@@ -75,6 +75,25 @@ afterEach(() => {
});
describe("useLazyLoadSentinel", () => {
it("rechecks current geometry when a restored viewport becomes eligible", async () => {
const scrollRef = makeScrollRef();
const loadMore = vi.fn(async () => 20);
const { result } = renderHook(() =>
useLazyLoadSentinel(scrollRef, true, false, false, loadMore, {
isCurrentGeometryEligible: () => true,
}),
);
const node = document.createElement("div");
act(() => result.current.sentinelRef(node));
expect(result.current).toHaveProperty("recheck");
if (!("recheck" in result.current)) return;
act(() => result.current.recheck());
await act(async () => {});
expect(loadMore).toHaveBeenCalledTimes(1);
});
it("defaults exactly to the transcript margin, no re-arm, and no join", () => {
const scrollRef = makeScrollRef();
const loadMore = vi.fn(async () => 20);
+23 -1
View File
@@ -13,6 +13,9 @@ export type LazyLoadSentinelOptions = {
* observed exit/re-entry or a later user-gesture retry. Defaults to true
* when re-arm is enabled. */
shouldContinueWhileIntersecting?: () => boolean;
/** Measures whether the sentinel is currently inside the caller's preload
* region. Lifecycle retries use this instead of a stale observer entry. */
isCurrentGeometryEligible?: () => boolean;
/** Reports one terminal outcome for each request that started. The
* continuation value reflects the final firing guards, not only the
* caller's boundary decision. */
@@ -64,6 +67,7 @@ type SentinelMutableRefs = {
joinInFlightWhileLoading: boolean;
stickToBottomWhileLoading: boolean;
shouldContinueWhileIntersecting?: () => boolean;
isCurrentGeometryEligible?: () => boolean;
onLoadSettled?: (result: LazyLoadSentinelSettleResult) => void;
isRequestCurrent?: () => boolean;
}>;
@@ -422,6 +426,7 @@ export function useLazyLoadSentinel(
sentinelRef: (node: HTMLDivElement | null) => void;
onUserGesture: () => void;
retry: () => void;
recheck: () => void;
} {
const {
rootMargin = "200px 0px 0px 0px",
@@ -429,6 +434,7 @@ export function useLazyLoadSentinel(
joinInFlightWhileLoading = false,
stickToBottomWhileLoading = false,
shouldContinueWhileIntersecting,
isCurrentGeometryEligible,
onLoadSettled,
isRequestCurrent,
} = options ?? {};
@@ -442,6 +448,7 @@ export function useLazyLoadSentinel(
joinInFlightWhileLoading,
stickToBottomWhileLoading,
shouldContinueWhileIntersecting,
isCurrentGeometryEligible,
onLoadSettled,
isRequestCurrent,
});
@@ -451,6 +458,7 @@ export function useLazyLoadSentinel(
joinInFlightWhileLoading,
stickToBottomWhileLoading,
shouldContinueWhileIntersecting,
isCurrentGeometryEligible,
onLoadSettled,
isRequestCurrent,
};
@@ -459,6 +467,7 @@ export function useLazyLoadSentinel(
joinInFlightWhileLoading,
stickToBottomWhileLoading,
shouldContinueWhileIntersecting,
isCurrentGeometryEligible,
onLoadSettled,
isRequestCurrent,
]);
@@ -585,6 +594,19 @@ export function useLazyLoadSentinel(
void fireLoad();
}, [fireLoad]);
// A restored panel can become visible while the observer still holds an
// entry captured for zero-size hidden geometry. Measure current geometry
// without requiring the sentinel to leave and re-enter the preload region.
const recheck = useCallback(() => {
const { hasMore, blocked, isLoadingMore } = stateRef.current;
const { joinInFlightWhileLoading, isCurrentGeometryEligible } = optionsRef.current;
if (!hasMore || blocked || (isLoadingMore && !joinInFlightWhileLoading)) return;
const eligible = isCurrentGeometryEligible?.() ?? intersectingRef.current;
if (!eligible) return;
disarmedRef.current = false;
void fireLoad();
}, [fireLoad]);
// Explicit recovery is allowed regardless of the sentinel's current
// intersection. The user has supplied fresh pagination intent, so a
// button click can retry a disarmed request even when the sentinel is just
@@ -598,5 +620,5 @@ export function useLazyLoadSentinel(
void fireLoad();
}, [fireLoad]);
return { sentinelRef, onUserGesture, retry };
return { sentinelRef, onUserGesture, retry, recheck };
}
@@ -0,0 +1,173 @@
---
created: 2026-09-01
status: implemented
requirements:
- REQ-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001
system_design:
- ../../specs/ui/system-design/task-prompt-transcript-visibility.md
legacy_specs: []
---
# Implementation plan: Restored transcript pagination recovery
## Overview
Repair older-history pagination when a persistent Dockview chat is initialized
while inactive and later becomes visible at its restored top edge. The fix will
turn the existing panel-visibility signal and hard-top input into guarded
current-geometry checks, without changing backend cursors, eager-opening
history, or explicit failure recovery.
## Scope
### In scope
- Pass real chat-host visibility into the native transcript lifecycle.
- Re-evaluate the oldest-page sentinel once after a hidden Dockview transcript
becomes visible and its saved scroll offset is restored.
- Accept upward input at `scrollTop === 0` as fresh pagination intent even when
the browser emits no `scroll` event.
- Reuse current sentinel geometry, serialization, session-epoch, and recovery
guards for both paths.
- Add focused unit regressions and a desktop restored-secondary-tab browser
regression.
- Retain mobile pagination, anchor, and recovery behavior through the shared
native transcript path.
### Out of scope
- Backend message queries, pagination cursors, ordering, persistence, or page
sizes.
- Eagerly loading older history when a task opens away from the oldest loaded
edge.
- Retrying rejected or zero-progress requests on panel activation.
- Changes to Prompt History pagination, Dockview layout persistence, or chat
scroll ownership.
- New user-facing copy or a new mobile composition.
## Confirmed root cause
The affected secondary session is restored as an inactive Dockview panel. Its
message request succeeds with 100 newest rows, `has_more: true`, and a valid
oldest cursor. The backend also returns older rows when that cursor is supplied.
The inactive transcript mounts behind a zero-size panel. When it becomes
visible already at `scrollTop = 0`, its `IntersectionObserver` does not deliver
a fresh eligible entry. The fallback in
`useRetryPaginationOnUpwardScroll` requires
`nextScrollTop < previousScrollTop`; at the hard top that comparison is
`0 < 0`, so another upward gesture cannot invoke the sentinel retry. The
background newest-window fetch then repeats the same 100 rows and cursor while
the older-page path is never called.
## Technical approach
### Visibility-aware sentinel recovery
- Extend the message-list contract with the existing `TaskChatPanel`
visibility state and carry it into native scroll management.
- On a hidden-to-visible transition, wait for the panel's existing saved-offset
restoration to commit before issuing one lifecycle recheck.
- Make the shared sentinel recheck current root/sentinel geometry instead of
trusting an intersection sample captured while hidden.
- Fire only when visible pagination remains, initial/refetch loading is clear,
the sentinel is inside preload, no request is active, the session is current,
and transcript recovery is not active.
- Keep the first visible mount bounded: a lifecycle recheck does not paginate
unless the restored viewport is already at the oldest loaded edge.
### Hard-top user intent
- Observe directional wheel, keyboard, touch, and scroll movement at the
transcript scroll owner.
- Treat an upward action at the hard top as fresh intent even when scroll
position does not change.
- Route the action through the same current-geometry sentinel recheck and
programmatic-scroll guard.
- Coalesce one physical gesture so it cannot start duplicate cursor requests.
### Diagnostics and failure behavior
- Retain the existing bounded pagination diagnostics so the cursor-based older
request remains distinguishable from newest-window running backfill.
- Preserve explicit retry for rejected or zero-progress requests; activation
must not create an automatic retry loop.
- Preserve anchor restoration and continuation stop reasons after a positive
older page commits.
## Tests
| Acceptance criteria | Evidence |
| --- | --- |
| `AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.5` to `.9` | Existing sentinel and native transcript suites plus new current-geometry and hard-top regressions |
| `AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.10` | Existing no-eager-open browser assertion remains green |
| `AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.11` | Existing mobile pagination suite remains green after the shared-path change |
| `AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.12` | Existing inactive-session reconciliation coverage remains green |
| `AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.14` | New hidden-to-visible unit regression and restored secondary-session desktop browser flow |
The red-first unit case mounts a transcript hidden with `hasMore: true`, makes
it visible at restored `scrollTop = 0`, and expects exactly one older-page
request after the restore frame. A second case dispatches upward input at hard
top without changing `scrollTop` and expects the guarded retry path to run.
## E2E tests
- Desktop Chromium: extend
`apps/web/e2e/tests/chat/message-pagination.spec.ts` with two seeded sessions
in one restored Dockview group. Keep the target session inactive during
hydration, suppress the fresh observer entry that can be missed during
restoration, activate its tab, supply upward input at the hard top, and
assert that the next request includes the previous oldest cursor and reveals
older content.
- Keep the watcher capable of distinguishing newest-window refreshes from
`before=<cursor>` older-page requests. Assert that activation starts one
cursor advance, not repeated newest-window backfill.
- Mobile Chrome: rerun
`apps/web/e2e/tests/chat/mobile-message-pagination.spec.ts` as regression
evidence for shared sentinel, anchor, and recovery behavior. No new mobile
layout is required because the defect depends on inactive Dockview tabs.
## Mobile design contract
- Desktop outcome: activating a restored secondary chat at its saved top edge
resumes older-history pagination without scroll-away and scroll-back.
- Mobile entry point: the existing full-height Chat tab.
- Nearest exemplar: `apps/web/components/task/task-layout.tsx` and the existing
mobile message-pagination specification.
- Hierarchy and surface: the transcript remains the only vertical scroll
owner; no sheet, drawer, or compact desktop tab is added.
- Shared logic: visibility and hard-top intent use the native transcript's
sentinel guards. Mobile supplies mounted visibility rather than Dockview
activation.
- Mobile proof: the existing mobile long-history, prompt boundary, recovery,
and prepend-anchor cases remain green.
## Work orders
- [x] [Task 01: Recover pagination after transcript activation](task-01-recover-pagination-after-activation.md) (`done`)
## Verification results
- Focused Vitest: 87 tests passed across the shared sentinel, native transcript,
and lazy-message pagination suites.
- TypeScript typecheck, targeted ESLint, targeted Prettier, and the complete spec
linter passed.
- Desktop Chromium: all eight message-pagination cases passed, including the
restored inactive-session hard-top regression.
- Mobile Chrome: all eight mobile message-pagination cases passed, including
the hard-top touch recovery regression.
- A fresh desktop PR screenshot was captured after the restored secondary chat
loaded its older marker.
## Risks
- Running the lifecycle check before `SessionPanelContent` restores scroll can
read zero-size or transient geometry and either miss or spuriously load.
- Treating every resize or active render as intent can duplicate requests or
create an automatic loop.
- Direct changes to the shared sentinel can regress Prompt History's
bottom-pinned append behavior.
- Wheel, keyboard, scrollbar, and touch signals need coalescing while retaining
true hard-top intent.
- The browser fixture must reproduce restored inactive tabs, not only switch
between two already-active panels.
@@ -0,0 +1,147 @@
---
id: "01-recover-pagination-after-activation"
title: "Recover pagination after transcript activation"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001
acceptance_criteria:
- AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.5
- AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.7
- AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.8
- AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.9
- AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.10
- AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.11
- AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.12
- AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.14
system_design:
- ../../specs/ui/system-design/task-prompt-transcript-visibility.md
---
# Task 01: Recover pagination after transcript activation
## Summary
Make a restored, previously hidden transcript re-evaluate older-page
eligibility after activation, and make an upward action at hard top recover
even when the browser emits no scroll-position change.
## In scope
- Add failing native transcript and sentinel regressions before production
changes.
- Carry real host visibility from `TaskChatPanel` through `MessageList` to
native scroll management.
- Add a one-shot hidden-to-visible current-geometry recheck after saved scroll
restoration.
- Add directional hard-top input recovery without treating programmatic scroll
restoration as user intent.
- Preserve request serialization, session epochs, continuation, failure
recovery, and prepend anchoring.
- Add a restored secondary-session desktop browser regression and run the
existing mobile pagination suite.
## Out of scope
- Backend cursor, message-list, WebSocket, or persistence changes.
- Prompt History behavior changes.
- Automatic retries after rejected or zero-progress requests.
- New UI controls, localized copy, or mobile navigation.
## Acceptance
- A transcript hydrated while hidden issues exactly one cursor-based older-page
request after it becomes visible at its restored oldest loaded edge.
- Upward input at `scrollTop = 0` can start the same guarded request without a
scroll event, while repeated lifecycle renders and one physical gesture do
not duplicate it.
- Desktop restored-tab E2E reaches older content, and existing desktop/mobile
pagination, prompt-`#1`, bounded-opening, recovery, and anchor cases remain
green.
## Verification
Run from `apps/web`:
```bash
pnpm exec vitest run \
hooks/use-lazy-load-sentinel.test.ts \
components/task/chat/message-list-native.test.tsx \
hooks/use-lazy-load-messages.test.ts
pnpm run typecheck
pnpm exec eslint \
hooks/use-lazy-load-sentinel.ts \
hooks/use-lazy-load-sentinel.test.ts \
components/task/task-chat-panel.tsx \
components/task/chat/message-list-shared.tsx \
components/task/chat/message-list-native.tsx \
components/task/chat/message-list-native-scroll.ts \
components/task/chat/message-list-native.test.tsx \
e2e/tests/chat/message-pagination-helpers.ts \
e2e/tests/chat/message-pagination.spec.ts
pnpm exec prettier --check \
hooks/use-lazy-load-sentinel.ts \
hooks/use-lazy-load-sentinel.test.ts \
components/task/task-chat-panel.tsx \
components/task/chat/message-list-shared.tsx \
components/task/chat/message-list-native.tsx \
components/task/chat/message-list-native-scroll.ts \
components/task/chat/message-list-native.test.tsx \
e2e/tests/chat/message-pagination-helpers.ts \
e2e/tests/chat/message-pagination.spec.ts
pnpm e2e:run --host --project chromium -- \
tests/chat/message-pagination.spec.ts --grep "restored inactive session"
pnpm e2e:run --host --no-build --project mobile-chrome -- \
tests/chat/mobile-message-pagination.spec.ts
```
## Files likely touched
- `apps/web/components/task/task-chat-panel.tsx`
- `apps/web/components/task/chat/message-list-shared.tsx`
- `apps/web/components/task/chat/message-list-native.tsx`
- `apps/web/components/task/chat/message-list-native-scroll.ts`
- `apps/web/components/task/chat/message-list-native.test.tsx`
- `apps/web/hooks/use-lazy-load-sentinel.ts`
- `apps/web/hooks/use-lazy-load-sentinel.test.ts`
- `apps/web/e2e/tests/chat/message-pagination-helpers.ts`
- `apps/web/e2e/tests/chat/message-pagination.spec.ts`
## Dependencies
None. The existing `usePanelActive` signal, `SessionPanelContent` scroll restore,
message cursor API, and pagination fixtures are inputs to this work.
## Risks
- A recheck scheduled too early can run before restored geometry stabilizes.
- Relaxing the sentinel's disarmed state can bypass explicit failure recovery.
- Shared hook changes can affect Prompt History's bottom sentinel.
- Input listeners can double-fire when one gesture also creates a scroll event.
## Parallelism
`sequential`
## Inputs
- `REQ-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001` acceptance criteria `.5`,
`.7` to `.14`.
- Upward pagination, failure and recovery, observability, and responsive
sections of the paired system design.
- Existing panel visibility, scroll restoration, sentinel, desktop pagination,
and mobile pagination implementations and tests.
## Results
- Added a guarded current-geometry recheck to the shared lazy-load sentinel.
- Carried Dockview visibility into native transcript scroll management and
rechecked after hidden-to-visible restoration settles.
- Added hard-top wheel, keyboard, and touch intent recovery while preserving
programmatic-scroll and explicit-recovery guards.
- Added red-first unit regressions and a restored secondary-session Chromium
regression that proves the older request includes a cursor.
- Verified 87 focused unit tests, eight desktop pagination cases, eight mobile
pagination cases, typecheck, targeted lint/formatting, and spec lint.
@@ -50,6 +50,7 @@ start.
- **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.11:** Desktop and mobile transcripts shall provide the same history boundary, continuous upward loading, recovery, and scroll-position behavior.
- **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.12:** When a previously opened session is revisited after more than one message page was persisted while it was inactive, the transcript shall reconcile to a contiguous newest window and upward pagination shall reach every persisted user prompt without gaps or duplicate rows.
- **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.13:** When an upward lazy-load operation sizes its batch, it shall target 20 newly loaded text parts. Tool calls and other activity rows shall not advance that target; the operation can stop earlier when prompt `#1` is loaded, persisted history is exhausted, a request makes no progress, or its bounded safety limit is reached.
- **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.14:** When a previously hidden transcript becomes visible with its restored viewport at the oldest loaded edge and older history remains, the transcript shall resume older-history loading without requiring the user to move away from and return to that edge.
## Exclusions
@@ -44,9 +44,16 @@ and `prompt_index` field.
pagination available to explicit recovery consumers.
- The native transcript consumes `useLazyLoadMessages`. Prompt History consumes
its own user-message request and pagination state.
- The native transcript owns upward intent, committed sentinel geometry,
scroll restoration, and retry-control visibility.
- `useLazyLoadSentinel` owns observer lifecycle and request serialization.
- `TaskChatPanel` supplies the native transcript with the host's real visibility
state. Dockview hosts use panel activation; mobile and other direct hosts use
their existing mounted visibility.
- The native transcript owns upward intent, committed sentinel geometry, scroll
restoration, host-visibility recovery, and retry-control visibility.
- `SessionPanelContent` restores the saved scroll offset after a hidden panel
receives non-zero geometry. The native transcript waits for that restore to
commit before it asks the sentinel to re-evaluate the viewport.
- `useLazyLoadSentinel` owns observer lifecycle, current-geometry eligibility,
request serialization, and guarded retries from lifecycle or user intent.
- Transcript navigation first uses loaded messages. When a selected prompt is
absent, it requests an around window and merges the result before scrolling.
- `requestOlderMessages` keeps transcript request coordination and raw cursor
@@ -136,6 +143,21 @@ The transcript starts a load cycle when the user reaches the oldest loaded
edge. Before each request it captures a stable rendered-row anchor. After the
page commits, scroll restoration keeps that anchor at the same visual position.
A hidden Dockview panel can keep its transcript mounted while its scroll root
has zero geometry. When that panel becomes visible, the native transcript
waits until `SessionPanelContent` has restored the saved scroll offset, then
performs one current-geometry eligibility check. If the restored sentinel is
inside the preload region and pagination is otherwise eligible, that check
starts or resumes the normal load cycle. It does not use an intersection entry
captured while the panel was hidden.
An upward wheel, touch, keyboard, or scrollbar action at the hard top can
produce no `scroll` event because `scrollTop` cannot decrease below zero. The
native transcript therefore treats directional input at that boundary as
fresh pagination intent and checks current sentinel geometry. Input and panel
activation use the same request guards and cannot bypass loading, visible-start,
failure-recovery, or session-epoch stops.
Continuation uses committed sentinel geometry, not the identity of the oldest
standalone row:
@@ -161,6 +183,12 @@ automatic cycle is disarmed and the explicit older-page retry control is
visible. A successful retry clears recovery state and resumes the geometry
rule. Session changes, prompt `#1`, and raw exhaustion also clear it.
Panel activation does not retry a request that already entered recovery state.
The lifecycle check repairs a missed observer transition only; request failure
and no-progress outcomes still require the explicit retry control. Repeated
active renders and resize notifications do not create additional pagination
intent.
Routine successful pagination does not render the retry control. The control
keeps the existing localized label and has a coarse-pointer hit area of at
least 44 pixels without increasing fine-pointer desktop density.
@@ -182,6 +210,12 @@ prompt returns the phone to Chat and uses the around-window path when the row is
not loaded. The recovery button uses the same placement on both surfaces with a
coarse-pointer touch target.
Dockview activation recovery is desktop-specific because inactive desktop tabs
remain mounted with zero geometry. The eligibility and hard-top gesture rules
remain in the shared native transcript path. Mobile has no hidden Dockview tab,
so it keeps mounted visibility and must retain the same upward pagination,
anchor, and failure-recovery behavior.
## Around-window navigation
The chat panel sends `GET /api/v1/task-sessions/:id/messages?around=<message-id>&sort=desc&limit=N` after it confirms that the target belongs to the active session. The backend returns the target and newer rows in newest-first order. The chat panel merges the response into `messages.bySession`, waits for the target row to render, and then scrolls to it. The target token is session-scoped and is cleared only after a successful scroll or a confirmed missing target. A stale response cannot consume a newer selection.
@@ -199,10 +233,10 @@ anchor behavior as desktop.
- Session-state tests cover initialization through boot hydration, newest
fetch, purge, and refetch without inferring exhaustion from defaults.
- Sentinel tests cover committed in-region continuation, out-of-region stop,
stale observer entries, no-progress recovery, successful retry, and session
reset.
- Native transcript tests cover recovery-control visibility and prepend anchor
preservation.
stale observer entries, one-shot lifecycle rechecks, hard-top intent,
no-progress recovery, successful retry, and session reset.
- Native transcript tests cover hidden-to-visible recovery, recovery-control
visibility, and prepend anchor preservation.
- Lazy-load hook tests cover mixed `message`, `content`, legacy, and tool rows,
the 20-text-part target, every early stop, and unchanged raw/single-page
consumers.
@@ -215,3 +249,8 @@ anchor behavior as desktop.
must still cross that page and reveal the targeted older text batch.
- Existing prompt-`#1`, hidden pre-prompt, and inactive-session reconciliation
scenarios remain covered.
- A desktop browser test restores two session tabs with the target tab inactive,
suppresses the fresh observer entry that can be missed during restoration,
activates the target, supplies upward input at the hard top, and proves that
the next request carries an older-page cursor instead of repeating only the
newest 100-row window.