fix(web): recover restored transcript pagination (#3252)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
+147
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user