fix: continue paginating transcript history (#3174)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable max-lines -- pagination, scroll anchoring, and retry state share one boundary. */
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useDockviewStore } from "@/lib/state/dockview-store";
|
||||
import { useAppStoreApi } from "@/components/state-provider";
|
||||
import { getStoredAutoScrollTop } from "@/lib/local-storage";
|
||||
@@ -24,6 +24,8 @@ import { scheduleClampedScrollRestore } from "./clamped-scroll-restore";
|
||||
import { createDebugLogger, isDebug } from "@/lib/debug/log";
|
||||
|
||||
const paginationDebug = createDebugLogger("messages:pagination");
|
||||
// i18n-exempt: IntersectionObserver root-margin configuration, not user-facing copy.
|
||||
export const TRANSCRIPT_SENTINEL_ROOT_MARGIN = "200px 0px 0px 0px";
|
||||
|
||||
// INT32_MAX: WebKit resolves Number.MAX_SAFE_INTEGER to 0 (not bottom).
|
||||
const NATIVE_BOTTOM_SCROLL_TOP = 2_147_483_647;
|
||||
@@ -36,6 +38,7 @@ export function scrollNativeToBottom(element: HTMLElement): void {
|
||||
|
||||
type PaginationRequest = {
|
||||
boundaryBefore: string | null;
|
||||
sessionEpoch: number;
|
||||
debug?: {
|
||||
generation: number;
|
||||
scrollTopBefore: number | null;
|
||||
@@ -66,6 +69,7 @@ function resolvePaginationSettleReason(
|
||||
result: LazyLoadSentinelSettleResult,
|
||||
boundaryUnchanged: boolean,
|
||||
hasMore: boolean,
|
||||
sentinelInPreload: boolean,
|
||||
): PaginationStopReason | null {
|
||||
switch (result.continuation) {
|
||||
case "continued":
|
||||
@@ -75,12 +79,137 @@ function resolvePaginationSettleReason(
|
||||
return "no-progress";
|
||||
case "caller-stopped":
|
||||
case "no-more":
|
||||
if (result.continuation === "caller-stopped" && !sentinelInPreload) {
|
||||
return "sentinel-left-preload";
|
||||
}
|
||||
return resolvePaginationStopReason(boundaryUnchanged, hasMore);
|
||||
default:
|
||||
return result.continuation;
|
||||
}
|
||||
}
|
||||
|
||||
type RootMarginPixels = {
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
};
|
||||
|
||||
function parseRootMarginPixels(rootMargin: string): RootMarginPixels {
|
||||
const values = rootMargin
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((value) => Number.parseFloat(value))
|
||||
.map((value) => (Number.isFinite(value) ? value : 0));
|
||||
const [top = 0, right = top, bottom = top, left = right] = values;
|
||||
return { top, right, bottom, left };
|
||||
}
|
||||
|
||||
/** Returns whether the current sentinel geometry is inside the observer's
|
||||
* preload rectangle. This is intentionally measured after a page commit,
|
||||
* rather than inferred from the last IntersectionObserver entry. */
|
||||
export function isElementInPreloadRegion(
|
||||
scrollRoot: HTMLElement,
|
||||
sentinel: HTMLElement,
|
||||
rootMargin = TRANSCRIPT_SENTINEL_ROOT_MARGIN,
|
||||
): boolean {
|
||||
const rootRect = scrollRoot.getBoundingClientRect();
|
||||
const sentinelRect = sentinel.getBoundingClientRect();
|
||||
const margin = parseRootMarginPixels(rootMargin);
|
||||
return (
|
||||
sentinelRect.bottom >= rootRect.top - margin.top &&
|
||||
sentinelRect.top <= rootRect.bottom + margin.bottom &&
|
||||
sentinelRect.right >= rootRect.left - margin.left &&
|
||||
sentinelRect.left <= rootRect.right + margin.right
|
||||
);
|
||||
}
|
||||
|
||||
function resolveRecoveryVisibility(
|
||||
result: LazyLoadSentinelSettleResult,
|
||||
hasMore: boolean,
|
||||
): boolean | null {
|
||||
if (result.continuation === "stale") return null;
|
||||
if (hasMore && (result.continuation === "rejected" || result.continuation === "no-progress")) {
|
||||
return true;
|
||||
}
|
||||
if (result.count > 0 || !hasMore) return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
function usePaginationRecovery(sessionId: string | null, hasMore: boolean) {
|
||||
const [showRecovery, setShowRecovery] = useState(false);
|
||||
const recoverySessionRef = useRef(sessionId);
|
||||
|
||||
useEffect(() => {
|
||||
if (recoverySessionRef.current !== sessionId || !sessionId || !hasMore) {
|
||||
setShowRecovery(false);
|
||||
}
|
||||
recoverySessionRef.current = sessionId;
|
||||
}, [hasMore, sessionId]);
|
||||
|
||||
const reportRecovery = useCallback(
|
||||
(result: LazyLoadSentinelSettleResult) => {
|
||||
const nextVisibility = resolveRecoveryVisibility(result, hasMore);
|
||||
if (nextVisibility !== null) setShowRecovery(nextVisibility);
|
||||
},
|
||||
[hasMore],
|
||||
);
|
||||
|
||||
return { showRecovery, reportRecovery };
|
||||
}
|
||||
|
||||
function useSessionEpoch(sessionId: string | null) {
|
||||
const epochRef = useRef(0);
|
||||
const previousSessionIdRef = useRef(sessionId);
|
||||
if (previousSessionIdRef.current !== sessionId) {
|
||||
previousSessionIdRef.current = sessionId;
|
||||
epochRef.current += 1;
|
||||
}
|
||||
return epochRef;
|
||||
}
|
||||
|
||||
function reportPaginationSettleDebug(params: {
|
||||
result: LazyLoadSentinelSettleResult;
|
||||
sessionId: string | null;
|
||||
request: PaginationRequest | null;
|
||||
items: RenderItem[];
|
||||
scrollRoot: HTMLElement | null;
|
||||
sentinel: HTMLElement | null;
|
||||
hasMore: boolean;
|
||||
}) {
|
||||
const request = params.request;
|
||||
if (!isDebug() || !request?.debug) return;
|
||||
const { result, scrollRoot, sentinel } = params;
|
||||
const requestDebug = request.debug;
|
||||
const boundaryAfter = getOldestVisibleBoundaryKey(params.items);
|
||||
const boundaryUnchanged = request.boundaryBefore === boundaryAfter;
|
||||
const sentinelInPreload = Boolean(
|
||||
scrollRoot &&
|
||||
sentinel &&
|
||||
isElementInPreloadRegion(scrollRoot, sentinel, TRANSCRIPT_SENTINEL_ROOT_MARGIN),
|
||||
);
|
||||
paginationDebug("older page settled", {
|
||||
sessionId: params.sessionId,
|
||||
trigger: "top-intersection",
|
||||
generation: requestDebug.generation,
|
||||
loadedCount: result.count,
|
||||
boundaryBefore: request.boundaryBefore,
|
||||
boundaryAfter,
|
||||
scrollTopBefore: requestDebug.scrollTopBefore,
|
||||
scrollTopAfter: scrollRoot?.scrollTop ?? null,
|
||||
scrollHeightBefore: requestDebug.scrollHeightBefore,
|
||||
scrollHeightAfter: scrollRoot?.scrollHeight ?? null,
|
||||
continued: result.continuation === "continued",
|
||||
stopReason: resolvePaginationSettleReason(
|
||||
result,
|
||||
boundaryUnchanged,
|
||||
params.hasMore,
|
||||
sentinelInPreload,
|
||||
),
|
||||
continuation: result.continuation,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Continuously captures scroll state via scroll listener.
|
||||
* On a genuine prepend (older messages loaded above the current view, so the
|
||||
@@ -200,11 +329,12 @@ function capturePrependScrollState(scrollRoot: HTMLElement, anchorKey: string |
|
||||
|
||||
/**
|
||||
* Observes a sentinel element at the top of the list to trigger lazy loading.
|
||||
* Re-arms only while older pages leave the committed visible boundary
|
||||
* unchanged, which crosses collapsed activity without cascading through
|
||||
* standalone messages. The transcript does not join an in-flight request.
|
||||
* The explicit button remains the recovery path for errors and no-op pages.
|
||||
* Re-arms only while the committed sentinel remains inside the preload
|
||||
* region, which crosses both collapsed activity and standalone messages. The
|
||||
* transcript does not join an in-flight request. The explicit button is
|
||||
* rendered only as the recovery path for errors and no-op pages.
|
||||
*/
|
||||
// eslint-disable-next-line max-lines-per-function -- request epoch, geometry, and recovery must stay synchronized here.
|
||||
function useLazyLoadSentinel(params: {
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
items: RenderItem[];
|
||||
@@ -219,40 +349,34 @@ function useLazyLoadSentinel(params: {
|
||||
itemsRef.current = items;
|
||||
const hasMoreRef = useRef(hasMore);
|
||||
hasMoreRef.current = hasMore;
|
||||
const sentinelNodeRef = useRef<HTMLDivElement | null>(null);
|
||||
const requestGenerationRef = useRef(0);
|
||||
const requestRef = useRef<PaginationRequest | null>(null);
|
||||
const sessionEpochRef = useSessionEpoch(sessionId);
|
||||
const { showRecovery, reportRecovery } = usePaginationRecovery(sessionId, hasMore);
|
||||
|
||||
const reportSettle = useCallback(
|
||||
(result: LazyLoadSentinelSettleResult) => {
|
||||
if (!isDebug()) return;
|
||||
const request = requestRef.current;
|
||||
const requestDebug = request?.debug;
|
||||
if (!request || !requestDebug) return;
|
||||
const boundaryAfter = getOldestVisibleBoundaryKey(itemsRef.current);
|
||||
const scroller = scrollRef.current;
|
||||
const boundaryUnchanged = request.boundaryBefore === boundaryAfter;
|
||||
paginationDebug("older page settled", {
|
||||
const staleSession = request !== null && request.sessionEpoch !== sessionEpochRef.current;
|
||||
if (!staleSession) reportRecovery(result);
|
||||
reportPaginationSettleDebug({
|
||||
result,
|
||||
sessionId,
|
||||
trigger: "top-intersection",
|
||||
generation: requestDebug.generation,
|
||||
loadedCount: result.count,
|
||||
boundaryBefore: request.boundaryBefore,
|
||||
boundaryAfter,
|
||||
scrollTopBefore: requestDebug.scrollTopBefore,
|
||||
scrollTopAfter: scroller?.scrollTop ?? null,
|
||||
scrollHeightBefore: requestDebug.scrollHeightBefore,
|
||||
scrollHeightAfter: scroller?.scrollHeight ?? null,
|
||||
continued: result.continuation === "continued",
|
||||
stopReason: resolvePaginationSettleReason(result, boundaryUnchanged, hasMoreRef.current),
|
||||
continuation: result.continuation,
|
||||
request,
|
||||
items: itemsRef.current,
|
||||
scrollRoot: scrollRef.current,
|
||||
sentinel: sentinelNodeRef.current,
|
||||
hasMore: hasMoreRef.current,
|
||||
});
|
||||
},
|
||||
[scrollRef, sessionId],
|
||||
[reportRecovery, scrollRef, sessionId],
|
||||
);
|
||||
|
||||
const loadPage = useCallback(async () => {
|
||||
const request: PaginationRequest = {
|
||||
boundaryBefore: getOldestVisibleBoundaryKey(itemsRef.current),
|
||||
sessionEpoch: sessionEpochRef.current,
|
||||
};
|
||||
requestRef.current = request;
|
||||
if (isDebug()) {
|
||||
@@ -274,19 +398,50 @@ function useLazyLoadSentinel(params: {
|
||||
return loadMore();
|
||||
}, [loadMore, scrollRef, sessionId]);
|
||||
|
||||
const shouldContinueWhileIntersecting = useCallback(() => {
|
||||
const isRequestCurrent = useCallback(() => {
|
||||
const request = requestRef.current;
|
||||
if (!request) return false;
|
||||
const boundaryAfter = getOldestVisibleBoundaryKey(itemsRef.current);
|
||||
const boundaryUnchanged = request.boundaryBefore === boundaryAfter;
|
||||
return boundaryUnchanged && hasMoreRef.current;
|
||||
return request === null || request.sessionEpoch === sessionEpochRef.current;
|
||||
}, []);
|
||||
|
||||
return useSharedLazyLoadSentinel(scrollRef, hasMore, blocked, isLoadingMore, loadPage, {
|
||||
rearmWhileIntersecting: true,
|
||||
shouldContinueWhileIntersecting,
|
||||
onLoadSettled: isDebug() ? reportSettle : undefined,
|
||||
});
|
||||
const shouldContinueWhileIntersecting = useCallback(() => {
|
||||
const scrollRoot = scrollRef.current;
|
||||
const sentinel = sentinelNodeRef.current;
|
||||
return Boolean(
|
||||
hasMoreRef.current &&
|
||||
scrollRoot &&
|
||||
sentinel &&
|
||||
isElementInPreloadRegion(scrollRoot, sentinel, TRANSCRIPT_SENTINEL_ROOT_MARGIN),
|
||||
);
|
||||
}, [scrollRef]);
|
||||
|
||||
const sharedSentinel = useSharedLazyLoadSentinel(
|
||||
scrollRef,
|
||||
hasMore,
|
||||
blocked,
|
||||
isLoadingMore,
|
||||
loadPage,
|
||||
{
|
||||
rootMargin: TRANSCRIPT_SENTINEL_ROOT_MARGIN,
|
||||
rearmWhileIntersecting: true,
|
||||
shouldContinueWhileIntersecting,
|
||||
onLoadSettled: reportSettle,
|
||||
isRequestCurrent,
|
||||
},
|
||||
);
|
||||
const sentinelRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
sentinelNodeRef.current = node;
|
||||
sharedSentinel.sentinelRef(node);
|
||||
},
|
||||
[sharedSentinel.sentinelRef],
|
||||
);
|
||||
|
||||
return {
|
||||
sentinelRef,
|
||||
onUserGesture: sharedSentinel.onUserGesture,
|
||||
retry: sharedSentinel.retry,
|
||||
showRecovery,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -818,7 +973,12 @@ export function useNativeScrollManagement(params: {
|
||||
);
|
||||
const handleScrollToMessage = useScrollToMessage(scrollRef, runGuardedScroll);
|
||||
useScrollPositionOnPrepend(scrollRef, items, isLoadingMore, isProgrammaticScrollLocked);
|
||||
const { sentinelRef, onUserGesture } = useLazyLoadSentinel({
|
||||
const {
|
||||
sentinelRef,
|
||||
onUserGesture,
|
||||
retry: retryLoadMore,
|
||||
showRecovery,
|
||||
} = useLazyLoadSentinel({
|
||||
scrollRef,
|
||||
items,
|
||||
sessionId,
|
||||
@@ -830,5 +990,12 @@ export function useNativeScrollManagement(params: {
|
||||
useRetryPaginationOnUpwardScroll(scrollRef, onUserGesture, isProgrammaticScrollLocked);
|
||||
useInitialScrollPosition(scrollRef, items.length, sessionId, enabled, isNearBottomRef);
|
||||
|
||||
return { handleScrollToMessage, sentinelRef, resyncIsNearBottom, markNotNearBottom };
|
||||
return {
|
||||
handleScrollToMessage,
|
||||
sentinelRef,
|
||||
resyncIsNearBottom,
|
||||
markNotNearBottom,
|
||||
retryLoadMore,
|
||||
showRecovery,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,11 +7,16 @@ import type { RenderItem } from "@/hooks/use-processed-messages";
|
||||
|
||||
const sharedSentinelCalls = vi.hoisted(() => [] as unknown[][]);
|
||||
const sharedSentinelUserGesture = vi.hoisted(() => vi.fn());
|
||||
const sharedSentinelRetry = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/hooks/use-lazy-load-sentinel", () => ({
|
||||
useLazyLoadSentinel: (...args: unknown[]) => {
|
||||
sharedSentinelCalls.push(args);
|
||||
return { sentinelRef: () => {}, onUserGesture: sharedSentinelUserGesture };
|
||||
return {
|
||||
sentinelRef: () => {},
|
||||
onUserGesture: sharedSentinelUserGesture,
|
||||
retry: sharedSentinelRetry,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -31,7 +36,9 @@ vi.mock("@/components/state-provider", () => ({
|
||||
|
||||
import { useScrollToDividerOrBottom } from "./message-list-native";
|
||||
import {
|
||||
isElementInPreloadRegion,
|
||||
resolvePaginationStopReason,
|
||||
TRANSCRIPT_SENTINEL_ROOT_MARGIN,
|
||||
useAutoScroll,
|
||||
useNativeScrollManagement,
|
||||
useScrollToMessage,
|
||||
@@ -144,6 +151,7 @@ afterEach(() => {
|
||||
cleanup();
|
||||
sharedSentinelCalls.length = 0;
|
||||
sharedSentinelUserGesture.mockReset();
|
||||
sharedSentinelRetry.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -155,29 +163,16 @@ function transcriptActivity(id: string, turnId = id): RenderItem {
|
||||
return { type: "turn_group", id, turnId, messages: [] };
|
||||
}
|
||||
|
||||
function NativeScrollManagementHarness({
|
||||
items,
|
||||
metrics,
|
||||
loadMore = async () => 0,
|
||||
}: {
|
||||
items: RenderItem[];
|
||||
metrics?: { scrollHeight: number; scrollTop: number; clientHeight: number };
|
||||
loadMore?: () => Promise<number>;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
useNativeScrollManagement({
|
||||
scrollRef,
|
||||
items,
|
||||
messages: [],
|
||||
isWorking: false,
|
||||
sessionId: null,
|
||||
enabled: false,
|
||||
hasUnreadDivider: false,
|
||||
messagesLoading: false,
|
||||
hasMore: true,
|
||||
isLoadingMore: false,
|
||||
loadMore,
|
||||
});
|
||||
type NativeScrollMetrics = {
|
||||
scrollHeight: number;
|
||||
scrollTop: number;
|
||||
clientHeight: number;
|
||||
};
|
||||
|
||||
function useNativeScrollMetrics(
|
||||
scrollRef: { current: HTMLDivElement | null },
|
||||
metrics?: NativeScrollMetrics,
|
||||
) {
|
||||
useLayoutEffect(() => {
|
||||
const element = scrollRef.current;
|
||||
if (!element || !metrics) return;
|
||||
@@ -192,8 +187,43 @@ function NativeScrollManagementHarness({
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [metrics]);
|
||||
return <div data-testid="native-scroll-management-container" ref={scrollRef} />;
|
||||
}, [metrics, scrollRef]);
|
||||
}
|
||||
|
||||
function NativeScrollManagementHarness({
|
||||
items,
|
||||
metrics,
|
||||
loadMore = async () => 0,
|
||||
sessionId = null,
|
||||
recoveryRef,
|
||||
}: {
|
||||
items: RenderItem[];
|
||||
metrics?: NativeScrollMetrics;
|
||||
loadMore?: () => Promise<number>;
|
||||
sessionId?: string | null;
|
||||
recoveryRef?: { current: boolean };
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const { sentinelRef, showRecovery } = useNativeScrollManagement({
|
||||
scrollRef,
|
||||
items,
|
||||
messages: [],
|
||||
isWorking: false,
|
||||
sessionId,
|
||||
enabled: false,
|
||||
hasUnreadDivider: false,
|
||||
messagesLoading: false,
|
||||
hasMore: true,
|
||||
isLoadingMore: false,
|
||||
loadMore,
|
||||
});
|
||||
if (recoveryRef) recoveryRef.current = showRecovery;
|
||||
useNativeScrollMetrics(scrollRef, metrics);
|
||||
return (
|
||||
<div data-testid="native-scroll-management-container" ref={scrollRef}>
|
||||
<div ref={sentinelRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe("resolvePaginationStopReason", () => {
|
||||
@@ -210,6 +240,30 @@ describe("resolvePaginationStopReason", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("isElementInPreloadRegion", () => {
|
||||
it("uses current root and sentinel geometry instead of a stale intersection", () => {
|
||||
const root = document.createElement("div");
|
||||
const sentinel = document.createElement("div");
|
||||
Object.defineProperty(root, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value: () => createRect(100, 400),
|
||||
});
|
||||
Object.defineProperty(sentinel, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value: () => createRect(-102, 1),
|
||||
});
|
||||
|
||||
expect(isElementInPreloadRegion(root, sentinel, TRANSCRIPT_SENTINEL_ROOT_MARGIN)).toBe(false);
|
||||
|
||||
Object.defineProperty(sentinel, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value: () => createRect(-50, 1),
|
||||
});
|
||||
expect(isElementInPreloadRegion(root, sentinel, TRANSCRIPT_SENTINEL_ROOT_MARGIN)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function -- pagination invariants share one fixture and lifecycle.
|
||||
describe("useNativeScrollManagement transcript pagination", () => {
|
||||
it("retries a disarmed short page on the next upward scroll", () => {
|
||||
const metrics = { scrollHeight: 1000, scrollTop: 100, clientHeight: 400 };
|
||||
@@ -229,18 +283,98 @@ describe("useNativeScrollManagement transcript pagination", () => {
|
||||
expect(sharedSentinelUserGesture).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("re-arms the native sentinel with a visible-boundary continuation decision", () => {
|
||||
it("re-arms the native sentinel with a current-geometry continuation decision", () => {
|
||||
render(<NativeScrollManagementHarness items={[]} />);
|
||||
|
||||
const options = sharedSentinelCalls[0]?.[5];
|
||||
expect(options).toEqual({
|
||||
rootMargin: "200px 0px 0px 0px",
|
||||
rearmWhileIntersecting: true,
|
||||
shouldContinueWhileIntersecting: expect.any(Function),
|
||||
onLoadSettled: expect.any(Function),
|
||||
isRequestCurrent: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it("continues only while an older page keeps the same visible boundary", async () => {
|
||||
it("shows recovery only after no progress and clears it after success or session change", () => {
|
||||
const recoveryRef = { current: false };
|
||||
const { rerender } = render(
|
||||
<NativeScrollManagementHarness items={[]} sessionId="session-1" recoveryRef={recoveryRef} />,
|
||||
);
|
||||
const options = sharedSentinelCalls.at(-1)?.[5] as {
|
||||
onLoadSettled: (result: {
|
||||
count: number;
|
||||
rejected: boolean;
|
||||
continuation: "no-progress" | "continued";
|
||||
}) => void;
|
||||
};
|
||||
|
||||
act(() => {
|
||||
options.onLoadSettled({ count: 0, rejected: false, continuation: "no-progress" });
|
||||
});
|
||||
expect(recoveryRef.current).toBe(true);
|
||||
|
||||
act(() => {
|
||||
options.onLoadSettled({ count: 20, rejected: false, continuation: "continued" });
|
||||
});
|
||||
expect(recoveryRef.current).toBe(false);
|
||||
|
||||
act(() => {
|
||||
options.onLoadSettled({ count: 0, rejected: true, continuation: "no-progress" });
|
||||
});
|
||||
expect(recoveryRef.current).toBe(true);
|
||||
rerender(
|
||||
<NativeScrollManagementHarness items={[]} sessionId="session-2" recoveryRef={recoveryRef} />,
|
||||
);
|
||||
expect(recoveryRef.current).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores a settlement from the previous session epoch", async () => {
|
||||
const recoveryRef = { current: false };
|
||||
let resolveLoad: (value: number) => void = () => {};
|
||||
const loadMore = vi.fn(
|
||||
() =>
|
||||
new Promise<number>((resolve) => {
|
||||
resolveLoad = resolve;
|
||||
}),
|
||||
);
|
||||
const { rerender } = render(
|
||||
<NativeScrollManagementHarness
|
||||
items={[]}
|
||||
sessionId="session-1"
|
||||
loadMore={loadMore}
|
||||
recoveryRef={recoveryRef}
|
||||
/>,
|
||||
);
|
||||
const initialCall = sharedSentinelCalls.at(-1);
|
||||
const loadPage = initialCall?.[4] as () => Promise<number>;
|
||||
const oldOptions = initialCall?.[5] as {
|
||||
onLoadSettled: (result: {
|
||||
count: number;
|
||||
rejected: boolean;
|
||||
continuation: "rejected";
|
||||
}) => void;
|
||||
};
|
||||
const pendingLoad = loadPage();
|
||||
|
||||
rerender(
|
||||
<NativeScrollManagementHarness
|
||||
items={[]}
|
||||
sessionId="session-2"
|
||||
loadMore={loadMore}
|
||||
recoveryRef={recoveryRef}
|
||||
/>,
|
||||
);
|
||||
act(() => {
|
||||
oldOptions.onLoadSettled({ count: 0, rejected: true, continuation: "rejected" });
|
||||
});
|
||||
expect(recoveryRef.current).toBe(false);
|
||||
|
||||
resolveLoad(0);
|
||||
await pendingLoad;
|
||||
});
|
||||
|
||||
it("continues while the sentinel remains in preload even when the visible boundary changes", async () => {
|
||||
const loadMore = vi.fn(async () => 20);
|
||||
const newest = transcriptMessage("newest");
|
||||
const { rerender } = render(
|
||||
@@ -273,7 +407,7 @@ describe("useNativeScrollManagement transcript pagination", () => {
|
||||
options = sharedSentinelCalls.at(-1)?.[5] as {
|
||||
shouldContinueWhileIntersecting: () => boolean;
|
||||
};
|
||||
expect(options.shouldContinueWhileIntersecting()).toBe(false);
|
||||
expect(options.shouldContinueWhileIntersecting()).toBe(true);
|
||||
});
|
||||
|
||||
it("anchors a prepend below a fixed task description row", () => {
|
||||
|
||||
@@ -121,19 +121,20 @@ function useNativeMessageListScroll(params: NativeMessageListScrollParams) {
|
||||
onFirstMessageHiddenChange,
|
||||
scrollLayoutKey,
|
||||
} = params;
|
||||
const { handleScrollToMessage, sentinelRef, markNotNearBottom } = useNativeScrollManagement({
|
||||
scrollRef,
|
||||
items,
|
||||
messages,
|
||||
isWorking,
|
||||
sessionId,
|
||||
enabled,
|
||||
hasUnreadDivider: Boolean(dividerBeforeItemKey),
|
||||
messagesLoading,
|
||||
hasMore,
|
||||
isLoadingMore,
|
||||
loadMore,
|
||||
});
|
||||
const { handleScrollToMessage, sentinelRef, markNotNearBottom, retryLoadMore, showRecovery } =
|
||||
useNativeScrollManagement({
|
||||
scrollRef,
|
||||
items,
|
||||
messages,
|
||||
isWorking,
|
||||
sessionId,
|
||||
enabled,
|
||||
hasUnreadDivider: Boolean(dividerBeforeItemKey),
|
||||
messagesLoading,
|
||||
hasMore,
|
||||
isLoadingMore,
|
||||
loadMore,
|
||||
});
|
||||
const anchoredBarOffsetPx = anchoredBarScrollOffsetPx(anchoredBarHeight);
|
||||
useEffect(() => {
|
||||
scrollRef.current?.style.setProperty("--anchored-bar-h", `${anchoredBarOffsetPx}px`);
|
||||
@@ -152,7 +153,7 @@ function useNativeMessageListScroll(params: NativeMessageListScrollParams) {
|
||||
firstMessageId,
|
||||
onFirstMessageHiddenChange,
|
||||
);
|
||||
return { handleScrollToMessage, sentinelRef };
|
||||
return { handleScrollToMessage, sentinelRef, retryLoadMore, showRecovery };
|
||||
}
|
||||
|
||||
type MessageRowProps = {
|
||||
@@ -236,7 +237,8 @@ type NativeMessageListBodyProps = {
|
||||
isLoadingMore: boolean;
|
||||
isInitialLoading: boolean;
|
||||
showLoadingState: boolean;
|
||||
loadMore: () => Promise<number>;
|
||||
retryLoadMore: () => void;
|
||||
showRecovery: boolean;
|
||||
sentinelRef: (node: HTMLDivElement | null) => void;
|
||||
lastTurnGroupId: string | null;
|
||||
activeTurnId: string | null;
|
||||
@@ -378,7 +380,8 @@ function NativeMessageListBody({
|
||||
isLoadingMore,
|
||||
isInitialLoading,
|
||||
showLoadingState,
|
||||
loadMore,
|
||||
retryLoadMore,
|
||||
showRecovery,
|
||||
sentinelRef,
|
||||
lastTurnGroupId,
|
||||
activeTurnId,
|
||||
@@ -399,7 +402,8 @@ function NativeMessageListBody({
|
||||
messagesLoading={messagesLoading}
|
||||
isInitialLoading={isInitialLoading}
|
||||
messagesCount={messages.length}
|
||||
onLoadMore={loadMore}
|
||||
onLoadMore={retryLoadMore}
|
||||
showRecovery={showRecovery}
|
||||
/>
|
||||
|
||||
{items.map((item) => (
|
||||
@@ -484,33 +488,34 @@ export const NativeMessageList = memo(
|
||||
const streamingMessageId = getStreamingAgentMessageId(messages);
|
||||
const lastTurnGroupId = useMemo(() => getLastTurnGroupId(items), [items]);
|
||||
const autoScrollEnabled = useTranscriptAutoScrollEnabled(sessionId);
|
||||
const { handleScrollToMessage, sentinelRef } = useNativeMessageListScroll({
|
||||
scrollRef,
|
||||
ref,
|
||||
items,
|
||||
messages,
|
||||
isWorking,
|
||||
sessionId,
|
||||
enabled: autoScrollEnabled,
|
||||
dividerBeforeItemKey,
|
||||
anchoredBarHeight,
|
||||
messagesLoading,
|
||||
hasMore,
|
||||
isLoadingMore,
|
||||
loadMore,
|
||||
lastPromptMessageId,
|
||||
onLastPromptEdgeChange,
|
||||
firstMessageId,
|
||||
onFirstMessageHiddenChange,
|
||||
scrollLayoutKey: [
|
||||
messagesLoading,
|
||||
isInitialLoading,
|
||||
showLoadingState,
|
||||
isLoadingMore,
|
||||
hasMore,
|
||||
const { handleScrollToMessage, sentinelRef, retryLoadMore, showRecovery } =
|
||||
useNativeMessageListScroll({
|
||||
scrollRef,
|
||||
ref,
|
||||
items,
|
||||
messages,
|
||||
isWorking,
|
||||
].join(":"),
|
||||
});
|
||||
sessionId,
|
||||
enabled: autoScrollEnabled,
|
||||
dividerBeforeItemKey,
|
||||
anchoredBarHeight,
|
||||
messagesLoading,
|
||||
hasMore,
|
||||
isLoadingMore,
|
||||
loadMore,
|
||||
lastPromptMessageId,
|
||||
onLastPromptEdgeChange,
|
||||
firstMessageId,
|
||||
onFirstMessageHiddenChange,
|
||||
scrollLayoutKey: [
|
||||
messagesLoading,
|
||||
isInitialLoading,
|
||||
showLoadingState,
|
||||
isLoadingMore,
|
||||
hasMore,
|
||||
isWorking,
|
||||
].join(":"),
|
||||
});
|
||||
|
||||
return (
|
||||
<SessionPanelContent
|
||||
@@ -537,7 +542,8 @@ export const NativeMessageList = memo(
|
||||
isLoadingMore={isLoadingMore}
|
||||
isInitialLoading={isInitialLoading}
|
||||
showLoadingState={showLoadingState}
|
||||
loadMore={loadMore}
|
||||
retryLoadMore={retryLoadMore}
|
||||
showRecovery={showRecovery}
|
||||
sentinelRef={sentinelRef}
|
||||
lastTurnGroupId={lastTurnGroupId}
|
||||
activeTurnId={effectiveActiveTurnId}
|
||||
|
||||
@@ -672,6 +672,42 @@ describe("isElementFullyVisible", () => {
|
||||
});
|
||||
|
||||
describe("MessageListStatus", () => {
|
||||
it("does not render the older control during routine pagination", () => {
|
||||
render(
|
||||
<MessageListStatus
|
||||
isLoadingMore={false}
|
||||
hasMore
|
||||
showLoadingState={false}
|
||||
messagesLoading={false}
|
||||
isInitialLoading={false}
|
||||
messagesCount={1}
|
||||
onLoadMore={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("load-older-messages")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders one explicit retry control during recovery", () => {
|
||||
const onLoadMore = vi.fn();
|
||||
render(
|
||||
<MessageListStatus
|
||||
isLoadingMore={false}
|
||||
hasMore
|
||||
showRecovery
|
||||
showLoadingState={false}
|
||||
messagesLoading={false}
|
||||
isInitialLoading={false}
|
||||
messagesCount={1}
|
||||
onLoadMore={onLoadMore}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByTestId("load-older-messages");
|
||||
button.click();
|
||||
expect(onLoadMore).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders a conversation loading indicator while existing content remains visible", () => {
|
||||
render(
|
||||
<MessageListStatus
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
readLastAgentError,
|
||||
} from "@/lib/session-last-agent-error";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
|
||||
export type MessageListProps = {
|
||||
items: RenderItem[];
|
||||
@@ -387,6 +388,7 @@ export function MessageListStatus({
|
||||
isInitialLoading,
|
||||
messagesCount,
|
||||
onLoadMore,
|
||||
showRecovery = false,
|
||||
}: {
|
||||
isLoadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
@@ -400,8 +402,11 @@ export function MessageListStatus({
|
||||
* fails to re-arm (e.g. pinned at the very top with the sentinel always in view).
|
||||
*/
|
||||
onLoadMore?: () => void;
|
||||
/** Shows the explicit control only after a recoverable pagination failure. */
|
||||
showRecovery?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { isFinePointer } = useResponsiveBreakpoint();
|
||||
return (
|
||||
<>
|
||||
{isLoadingMore && hasMore && (
|
||||
@@ -409,13 +414,13 @@ export function MessageListStatus({
|
||||
{t("task:loadingOlderMessages")}
|
||||
</div>
|
||||
)}
|
||||
{hasMore && !isLoadingMore && onLoadMore && (
|
||||
{hasMore && !isLoadingMore && showRecovery && onLoadMore && (
|
||||
<div className="flex justify-center py-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="cursor-pointer text-xs text-muted-foreground"
|
||||
className={`cursor-pointer text-xs text-muted-foreground ${isFinePointer ? "" : "min-h-11"}`}
|
||||
data-testid="load-older-messages"
|
||||
onClick={onLoadMore}
|
||||
>
|
||||
|
||||
@@ -432,6 +432,7 @@ function useSessionData(
|
||||
messages,
|
||||
isLoading: messagesLoading,
|
||||
isInitialMessagesLoading,
|
||||
historyInitialized,
|
||||
hasMore: hasOlderMessages,
|
||||
} = useSessionMessages(resolvedSessionId);
|
||||
const turns = useAppStore((state) =>
|
||||
@@ -443,6 +444,7 @@ function useSessionData(
|
||||
);
|
||||
const lastAgentError = useMemo(() => readLastAgentError(session?.metadata), [session?.metadata]);
|
||||
const processed = useProcessedMessages(messages, taskId, resolvedSessionId, taskDescription, {
|
||||
historyInitialized,
|
||||
hasOlderMessages,
|
||||
lastAgentError,
|
||||
currentTurnId,
|
||||
|
||||
@@ -1363,11 +1363,16 @@ export class ApiClient {
|
||||
await this.request("POST", "/api/v1/_test/messages", body);
|
||||
}
|
||||
|
||||
async seedToolCallMessages(sessionId: string, count: number): Promise<void> {
|
||||
async seedToolCallMessages(
|
||||
sessionId: string,
|
||||
count: number,
|
||||
metadata?: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
for (let i = 0; i < count; i++) {
|
||||
await this.seedSessionMessage(sessionId, {
|
||||
type: "tool_call",
|
||||
content: `synthetic tool call ${i + 1}`,
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ export const PRE_PROMPT_MARKER = "HIDDEN-PRE-PROMPT-MARKER-6N3V";
|
||||
export const EAGER_HISTORY_PROMPT_MARKER = "EAGER-HISTORY-PROMPT-MARKER-3J6W";
|
||||
export const VISIBLE_PAGE_MARKER = "VISIBLE-PAGE-MARKER-8D5H";
|
||||
export const SHORT_PAGE_BOUNDARY_MARKER = "SHORT-PAGE-BOUNDARY-MARKER-5T1C";
|
||||
export const DEEP_PROMPT_MARKER = "DEEP-PROMPT-MARKER-2P7N";
|
||||
export const LONG_HISTORY_TAIL_MARKER = "LONG-HISTORY-TAIL-MARKER-6V4R";
|
||||
|
||||
/** Seeds an older prompt followed by a tool-only newest window. */
|
||||
export async function seedToolHeavyOpeningHistory(
|
||||
@@ -39,6 +41,47 @@ export async function seedToolHeavyOpeningHistory(
|
||||
return { taskId: task.id, sessionId };
|
||||
}
|
||||
|
||||
/** Seeds a prompt more than twenty older pages behind a bounded tool-only
|
||||
* newest window. All newer activity shares one turn so collapsed rendering
|
||||
* keeps the sentinel in preload while the cursor pages are committed. */
|
||||
export async function seedLongMessageHistory(
|
||||
apiClient: ApiClient,
|
||||
seedData: SeedData,
|
||||
title: string,
|
||||
): Promise<{ taskId: string; sessionId: 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: sessionId } = await apiClient.seedTaskSession(task.id, {
|
||||
state: "IDLE",
|
||||
repositoryId: seedData.repositoryId,
|
||||
});
|
||||
|
||||
await apiClient.seedSessionMessage(sessionId, {
|
||||
type: "message",
|
||||
content: INITIAL_PROMPT_MARKER,
|
||||
authorType: "user",
|
||||
});
|
||||
await apiClient.seedSessionMessage(sessionId, {
|
||||
type: "message",
|
||||
content: DEEP_PROMPT_MARKER,
|
||||
authorType: "user",
|
||||
});
|
||||
await apiClient.seedToolCallMessages(sessionId, 520, { status: "complete" });
|
||||
await apiClient.seedSessionMessage(sessionId, {
|
||||
type: "message",
|
||||
content: LONG_HISTORY_TAIL_MARKER,
|
||||
});
|
||||
// Keep the initial bounded window scrollable while the collapsed tool group
|
||||
// above the anchor remains height-stable across older-page commits.
|
||||
await apiClient.seedAgentMessages(sessionId, 20, "LONG-HISTORY-VISIBLE-TAIL");
|
||||
|
||||
return { taskId: task.id, sessionId };
|
||||
}
|
||||
|
||||
/** Captures older-page requests made after this watcher is installed. */
|
||||
export function watchOlderMessageRequests(page: Page, sessionId: string): string[] {
|
||||
const requests: string[] = [];
|
||||
@@ -78,9 +121,8 @@ export async function seedCollapsedMessageHistory(
|
||||
await apiClient.seedTaskSession(task.id, {
|
||||
sessionId,
|
||||
state: "IDLE",
|
||||
commandCount: 80,
|
||||
});
|
||||
await apiClient.seedToolCallMessages(sessionId, 60);
|
||||
await apiClient.seedToolCallMessages(sessionId, 140, { status: "complete" });
|
||||
} else {
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
await apiClient.seedSessionMessage(sessionId, {
|
||||
@@ -107,6 +149,9 @@ export async function seedCollapsedMessageHistory(
|
||||
type: "message",
|
||||
content: RECENT_AGENT_MARKER,
|
||||
});
|
||||
if (options?.promptOutsideInitialWindow) {
|
||||
await apiClient.seedAgentMessages(sessionId, 20, "RECENT-VISIBLE-TAIL");
|
||||
}
|
||||
|
||||
return { taskId: task.id, sessionId };
|
||||
}
|
||||
@@ -136,9 +181,9 @@ export async function seedVisibleMessageHistory(
|
||||
return { taskId: task.id, sessionId };
|
||||
}
|
||||
|
||||
/** Seeds a boundary-changing older page whose rendered height stays inside
|
||||
* the sentinel's 200px preload margin: one short message plus one collapsed
|
||||
* group containing the other 19 backend rows. */
|
||||
/** Seeds a short standalone boundary page followed by a collapsed page. The
|
||||
* first older page remains inside the sentinel preload margin, so the native
|
||||
* transcript must continue once before the second page moves it out. */
|
||||
export async function seedShortBoundaryPageHistory(
|
||||
apiClient: ApiClient,
|
||||
seedData: SeedData,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { test, expect } from "../../fixtures/test-base";
|
||||
import { dwell } from "../../helpers/causal-waits";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
import {
|
||||
DEEP_PROMPT_MARKER,
|
||||
EAGER_HISTORY_PROMPT_MARKER,
|
||||
INITIAL_PROMPT_MARKER,
|
||||
PRE_PROMPT_MARKER,
|
||||
@@ -11,14 +12,15 @@ import {
|
||||
SHORT_PAGE_BOUNDARY_MARKER,
|
||||
TASK_DESCRIPTION_MARKER,
|
||||
VISIBLE_PAGE_MARKER,
|
||||
LONG_HISTORY_TAIL_MARKER,
|
||||
readMessageRowTopById,
|
||||
readStandaloneMessageTop,
|
||||
seedCollapsedMessageHistory,
|
||||
seedLongMessageHistory,
|
||||
seedShortBoundaryPageHistory,
|
||||
seedToolHeavyOpeningHistory,
|
||||
seedVisibleMessageHistory,
|
||||
scrollToOldestLoadedEdge,
|
||||
scrollUpSlightly,
|
||||
watchOlderMessageRequests,
|
||||
} from "./message-pagination-helpers";
|
||||
|
||||
@@ -44,11 +46,53 @@ test.describe("@chat message pagination", () => {
|
||||
await dwell(testPage, 500, "negative-assertion", "observe background pagination after open");
|
||||
const chat = session.activeChat();
|
||||
|
||||
await expect(chat.getByText(TASK_DESCRIPTION_MARKER, { exact: true })).toBeVisible();
|
||||
await expect(chat.getByText(TASK_DESCRIPTION_MARKER, { exact: true })).toHaveCount(0);
|
||||
await expect(chat.getByText(EAGER_HISTORY_PROMPT_MARKER, { exact: true })).toHaveCount(0);
|
||||
expect(olderRequests).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("continues through more than twenty older pages in one upward reach", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
const { taskId, sessionId } = await seedLongMessageHistory(
|
||||
apiClient,
|
||||
seedData,
|
||||
"message-pagination-long-history",
|
||||
);
|
||||
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 chat = session.activeChat();
|
||||
const list = chat.locator(".chat-message-list");
|
||||
|
||||
await expect(chat.getByText(DEEP_PROMPT_MARKER, { exact: true })).toHaveCount(0);
|
||||
const edge = await scrollToOldestLoadedEdge(list, LONG_HISTORY_TAIL_MARKER);
|
||||
expect(Number.isFinite(edge.rowTop)).toBe(true);
|
||||
|
||||
await expect
|
||||
.poll(() => olderRequests.length, {
|
||||
timeout: 30_000,
|
||||
intervals: [100],
|
||||
message: "Long history reaches the deep stored prompt",
|
||||
})
|
||||
.toBeGreaterThan(20);
|
||||
await expect(chat.getByText(DEEP_PROMPT_MARKER, { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const afterLoadTop = await readStandaloneMessageTop(list, LONG_HISTORY_TAIL_MARKER);
|
||||
expect(Math.abs(afterLoadTop - edge.rowTop)).toBeLessThanOrEqual(8);
|
||||
await expect(chat.getByText(TASK_DESCRIPTION_MARKER, { exact: true })).toHaveCount(0);
|
||||
await expect(chat.getByTestId("load-older-messages")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("loads one visible page per upward reach without cascading", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
@@ -86,7 +130,7 @@ test.describe("@chat message pagination", () => {
|
||||
expect(olderRequests).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("retries a short boundary page on the next upward movement", async ({
|
||||
test("continues across a short boundary page and stops outside preload", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
@@ -108,15 +152,11 @@ test.describe("@chat message pagination", () => {
|
||||
const edge = await scrollToOldestLoadedEdge(list, VISIBLE_PAGE_MARKER);
|
||||
|
||||
await expect(chat.getByText(SHORT_PAGE_BOUNDARY_MARKER, { exact: true })).toBeVisible();
|
||||
await expect.poll(() => olderRequests.length).toBe(1);
|
||||
await expect.poll(() => olderRequests.length).toBe(2);
|
||||
const heightAfterShortPage = await list.evaluate((element) => element.scrollHeight);
|
||||
expect(heightAfterShortPage - edge.scrollHeight).toBeGreaterThan(0);
|
||||
expect(heightAfterShortPage - edge.scrollHeight).toBeLessThan(200);
|
||||
await dwell(testPage, 500, "negative-assertion", "observe short-page pagination stop");
|
||||
expect(olderRequests).toHaveLength(1);
|
||||
|
||||
expect(await scrollUpSlightly(list)).toBeGreaterThan(0);
|
||||
await expect.poll(() => olderRequests.length, { timeout: 15_000 }).toBe(2);
|
||||
expect(olderRequests).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("hides the older control when only hidden pre-prompt rows remain", async ({
|
||||
@@ -172,7 +212,7 @@ test.describe("@chat message pagination", () => {
|
||||
const chat = session.activeChat();
|
||||
const list = chat.locator(".chat-message-list");
|
||||
|
||||
await expect(chat.getByText(TASK_DESCRIPTION_MARKER, { exact: true })).toBeVisible();
|
||||
await expect(chat.getByText(TASK_DESCRIPTION_MARKER, { exact: true })).toHaveCount(0);
|
||||
await expect(chat.getByText(INITIAL_PROMPT_MARKER, { exact: true })).toHaveCount(0);
|
||||
|
||||
const edge = await scrollToOldestLoadedEdge(list, RECENT_AGENT_MARKER);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { test, expect } from "../../fixtures/test-base";
|
||||
import { dwell } from "../../helpers/causal-waits";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
import {
|
||||
DEEP_PROMPT_MARKER,
|
||||
EAGER_HISTORY_PROMPT_MARKER,
|
||||
INITIAL_PROMPT_MARKER,
|
||||
PRE_PROMPT_MARKER,
|
||||
@@ -9,14 +10,15 @@ import {
|
||||
SHORT_PAGE_BOUNDARY_MARKER,
|
||||
TASK_DESCRIPTION_MARKER,
|
||||
VISIBLE_PAGE_MARKER,
|
||||
LONG_HISTORY_TAIL_MARKER,
|
||||
readMessageRowTopById,
|
||||
readStandaloneMessageTop,
|
||||
seedCollapsedMessageHistory,
|
||||
seedLongMessageHistory,
|
||||
seedShortBoundaryPageHistory,
|
||||
seedToolHeavyOpeningHistory,
|
||||
seedVisibleMessageHistory,
|
||||
scrollToOldestLoadedEdge,
|
||||
scrollUpSlightly,
|
||||
watchOlderMessageRequests,
|
||||
} from "./message-pagination-helpers";
|
||||
|
||||
@@ -42,11 +44,51 @@ test.describe("Mobile chat message pagination", () => {
|
||||
await dwell(testPage, 500, "negative-assertion", "observe mobile pagination after open");
|
||||
const chat = session.activeChat();
|
||||
|
||||
await expect(chat.getByText(TASK_DESCRIPTION_MARKER, { exact: true })).toBeVisible();
|
||||
await expect(chat.getByText(TASK_DESCRIPTION_MARKER, { exact: true })).toHaveCount(0);
|
||||
await expect(chat.getByText(EAGER_HISTORY_PROMPT_MARKER, { exact: true })).toHaveCount(0);
|
||||
expect(olderRequests).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("continues through more than twenty older pages in one upward reach", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const { taskId, sessionId } = await seedLongMessageHistory(
|
||||
apiClient,
|
||||
seedData,
|
||||
"mobile-message-pagination-long-history",
|
||||
);
|
||||
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 chat = session.activeChat();
|
||||
const list = chat.locator(".chat-message-list");
|
||||
|
||||
await expect(chat.getByText(DEEP_PROMPT_MARKER, { exact: true })).toHaveCount(0);
|
||||
const edge = await scrollToOldestLoadedEdge(list, LONG_HISTORY_TAIL_MARKER);
|
||||
expect(Number.isFinite(edge.rowTop)).toBe(true);
|
||||
|
||||
await expect
|
||||
.poll(() => olderRequests.length, {
|
||||
timeout: 30_000,
|
||||
intervals: [100],
|
||||
message: "Long mobile history reaches the deep stored prompt",
|
||||
})
|
||||
.toBeGreaterThan(20);
|
||||
await expect(chat.getByText(DEEP_PROMPT_MARKER, { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const afterLoadTop = await readStandaloneMessageTop(list, LONG_HISTORY_TAIL_MARKER);
|
||||
expect(Math.abs(afterLoadTop - edge.rowTop)).toBeLessThanOrEqual(8);
|
||||
await expect(chat.getByText(TASK_DESCRIPTION_MARKER, { exact: true })).toHaveCount(0);
|
||||
await expect(chat.getByTestId("load-older-messages")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("loads one visible page per upward reach without cascading", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
@@ -82,7 +124,7 @@ test.describe("Mobile chat message pagination", () => {
|
||||
expect(olderRequests).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("retries a short boundary page on the next upward movement", async ({
|
||||
test("continues across a short boundary page and stops outside preload", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
@@ -103,15 +145,11 @@ test.describe("Mobile chat message pagination", () => {
|
||||
const edge = await scrollToOldestLoadedEdge(list, VISIBLE_PAGE_MARKER);
|
||||
|
||||
await expect(chat.getByText(SHORT_PAGE_BOUNDARY_MARKER, { exact: true })).toBeVisible();
|
||||
await expect.poll(() => olderRequests.length).toBe(1);
|
||||
await expect.poll(() => olderRequests.length).toBe(2);
|
||||
const heightAfterShortPage = await list.evaluate((element) => element.scrollHeight);
|
||||
expect(heightAfterShortPage - edge.scrollHeight).toBeGreaterThan(0);
|
||||
expect(heightAfterShortPage - edge.scrollHeight).toBeLessThan(200);
|
||||
await dwell(testPage, 500, "negative-assertion", "observe mobile short-page stop");
|
||||
expect(olderRequests).toHaveLength(1);
|
||||
|
||||
expect(await scrollUpSlightly(list)).toBeGreaterThan(0);
|
||||
await expect.poll(() => olderRequests.length, { timeout: 15_000 }).toBe(2);
|
||||
expect(olderRequests).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("hides the older control when only hidden pre-prompt rows remain", async ({
|
||||
@@ -163,7 +201,7 @@ test.describe("Mobile chat message pagination", () => {
|
||||
const chat = session.activeChat();
|
||||
const list = chat.locator(".chat-message-list");
|
||||
|
||||
await expect(chat.getByText(TASK_DESCRIPTION_MARKER, { exact: true })).toBeVisible();
|
||||
await expect(chat.getByText(TASK_DESCRIPTION_MARKER, { exact: true })).toHaveCount(0);
|
||||
await expect(chat.getByText(INITIAL_PROMPT_MARKER, { exact: true })).toHaveCount(0);
|
||||
|
||||
const edge = await scrollToOldestLoadedEdge(list, RECENT_AGENT_MARKER);
|
||||
|
||||
@@ -14,7 +14,13 @@ const mockState = {
|
||||
messages: {
|
||||
bySession: { "sess-1": [] as Message[] },
|
||||
metaBySession: {
|
||||
"sess-1": { hasMore: false, oldestCursor: null, isLoading: false, isLoadingMore: false },
|
||||
"sess-1": {
|
||||
historyInitialized: false,
|
||||
hasMore: false,
|
||||
oldestCursor: null,
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
taskSessions: { items: { "sess-1": { state: "RUNNING" } } },
|
||||
@@ -63,6 +69,7 @@ beforeEach(() => {
|
||||
mockWebSocketClient.subscribeSession.mockReturnValue(vi.fn());
|
||||
mockState.messages.bySession["sess-1"] = [];
|
||||
mockState.messages.metaBySession["sess-1"] = {
|
||||
historyInitialized: false,
|
||||
hasMore: false,
|
||||
oldestCursor: null,
|
||||
isLoading: false,
|
||||
@@ -342,7 +349,7 @@ describe("session subscription hydration ordering", () => {
|
||||
expect(mockState.mergeMessages).toHaveBeenCalledWith(
|
||||
"sess-1",
|
||||
[fetchedOldest, fetchedNewest, liveDuringFetch],
|
||||
{ hasMore: true, oldestCursor: "fetched-3" },
|
||||
{ historyInitialized: true, hasMore: true, oldestCursor: "fetched-3" },
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
@@ -551,7 +558,7 @@ describe("deduplicated message request baselines", () => {
|
||||
expect(mockState.mergeMessages).toHaveBeenLastCalledWith(
|
||||
"sess-1",
|
||||
[fetchedOldest, fetchedNewest, liveDuringFetch],
|
||||
{ hasMore: true, oldestCursor: "fetched-3" },
|
||||
{ historyInitialized: true, hasMore: true, oldestCursor: "fetched-3" },
|
||||
);
|
||||
first.unmount();
|
||||
second.unmount();
|
||||
|
||||
@@ -131,6 +131,7 @@ interface UseSessionMessagesReturn {
|
||||
isLoading: boolean;
|
||||
isInitialMessagesLoading: boolean;
|
||||
messages: Message[];
|
||||
historyInitialized: boolean;
|
||||
hasMore: boolean;
|
||||
oldestCursor: string | null;
|
||||
}
|
||||
@@ -143,7 +144,13 @@ type InFlightMessageRequest = {
|
||||
};
|
||||
|
||||
const EMPTY_MESSAGES: Message[] = [];
|
||||
const EMPTY_META = { isLoading: false, isLoadingMore: false, hasMore: false, oldestCursor: null };
|
||||
const EMPTY_META = {
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
historyInitialized: false,
|
||||
hasMore: false,
|
||||
oldestCursor: null,
|
||||
};
|
||||
const inFlightMessageRequests = new Map<string, InFlightMessageRequest>();
|
||||
|
||||
/** Debug-only summary of a fetch response (no-op unless debug logging is on). */
|
||||
@@ -258,6 +265,7 @@ async function fetchAndStoreMessages(
|
||||
// preserving object/array identity for unchanged messages so the periodic
|
||||
// refetch doesn't re-render the whole chat (see reconcileMessages).
|
||||
store.getState().mergeMessages(sessionId, merged, {
|
||||
historyInitialized: true,
|
||||
hasMore: response.has_more ?? false,
|
||||
oldestCursor,
|
||||
});
|
||||
@@ -649,6 +657,7 @@ export function useSessionMessages(taskSessionId: string | null): UseSessionMess
|
||||
isLoading: isLoading || isWaitingForInitialMessages || messagesMeta.isLoading,
|
||||
isInitialMessagesLoading: isWaitingForInitialMessages,
|
||||
messages,
|
||||
historyInitialized: messagesMeta.historyInitialized,
|
||||
hasMore: messagesMeta.hasMore,
|
||||
oldestCursor: messagesMeta.oldestCursor,
|
||||
};
|
||||
|
||||
@@ -289,17 +289,11 @@ describe("useLazyLoadSentinel — re-arm, disarm, and stale completions", () =>
|
||||
await waitFor(() => expect(loadMore).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("reports the actual continuation outcome after all firing guards", async () => {
|
||||
it("uses the caller continuation predicate even after an observer exit", async () => {
|
||||
const scrollRef = makeScrollRef();
|
||||
const onLoadSettled = vi.fn();
|
||||
const shouldContinueWhileIntersecting = vi.fn(() => true);
|
||||
let resolveLoad: (value: number) => void = () => {};
|
||||
const loadMore = vi.fn(
|
||||
() =>
|
||||
new Promise<number>((resolve) => {
|
||||
resolveLoad = resolve;
|
||||
}),
|
||||
);
|
||||
const loadMore = vi.fn().mockResolvedValueOnce(20).mockResolvedValueOnce(0);
|
||||
const { result } = renderHook(() =>
|
||||
useLazyLoadSentinel(scrollRef, true, false, false, loadMore, {
|
||||
rearmWhileIntersecting: true,
|
||||
@@ -312,17 +306,14 @@ describe("useLazyLoadSentinel — re-arm, disarm, and stale completions", () =>
|
||||
|
||||
fire(records[0], true, node);
|
||||
fire(records[0], false, node);
|
||||
await act(async () => resolveLoad(20));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onLoadSettled).toHaveBeenCalledWith({
|
||||
count: 20,
|
||||
rejected: false,
|
||||
continuation: "sentinel-left-preload",
|
||||
}),
|
||||
);
|
||||
expect(loadMore).toHaveBeenCalledTimes(1);
|
||||
expect(shouldContinueWhileIntersecting).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(loadMore).toHaveBeenCalledTimes(2));
|
||||
expect(shouldContinueWhileIntersecting).toHaveBeenCalledTimes(1);
|
||||
expect(onLoadSettled).toHaveBeenNthCalledWith(1, {
|
||||
count: 20,
|
||||
rejected: false,
|
||||
continuation: "continued",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -336,7 +327,7 @@ describe("useLazyLoadSentinel — stickToBottomWhileLoading", () => {
|
||||
Object.defineProperty(scroller, "scrollHeight", { configurable: true, value: 600 });
|
||||
scroller.scrollTop = 200;
|
||||
let resolveLoad: (value: number) => void = () => {};
|
||||
const loadMore = vi.fn(
|
||||
const loadMore = vi.fn().mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<number>((resolve) => {
|
||||
resolveLoad = resolve;
|
||||
@@ -623,6 +614,103 @@ describe("useLazyLoadSentinel — failure recovery and stale completions", () =>
|
||||
await act(async () => {});
|
||||
expect(loadMore).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("allows an explicit retry after the sentinel leaves preload", async () => {
|
||||
const scrollRef = makeScrollRef();
|
||||
const loadMore = vi.fn().mockResolvedValueOnce(0).mockResolvedValueOnce(20);
|
||||
const { result } = renderHook(() =>
|
||||
useLazyLoadSentinel(scrollRef, true, false, false, loadMore, {
|
||||
rearmWhileIntersecting: true,
|
||||
}),
|
||||
);
|
||||
const node = document.createElement("div");
|
||||
act(() => result.current.sentinelRef(node));
|
||||
|
||||
fire(records[0], true, node);
|
||||
await act(async () => {});
|
||||
fire(records[0], false, node);
|
||||
|
||||
act(() => result.current.retry());
|
||||
await waitFor(() => expect(loadMore).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("ignores a request settlement invalidated by the owning view", async () => {
|
||||
const scrollRef = makeScrollRef();
|
||||
let resolveLoad: (value: number) => void = () => {};
|
||||
let requestCurrent = true;
|
||||
const onLoadSettled = vi.fn();
|
||||
const loadMore = vi.fn().mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<number>((resolve) => {
|
||||
resolveLoad = resolve;
|
||||
}),
|
||||
);
|
||||
loadMore.mockResolvedValueOnce(0);
|
||||
const { result } = renderHook(() =>
|
||||
useLazyLoadSentinel(scrollRef, true, false, false, loadMore, {
|
||||
rearmWhileIntersecting: true,
|
||||
isRequestCurrent: () => requestCurrent,
|
||||
onLoadSettled,
|
||||
}),
|
||||
);
|
||||
const node = document.createElement("div");
|
||||
act(() => result.current.sentinelRef(node));
|
||||
|
||||
fire(records[0], true, node);
|
||||
requestCurrent = false;
|
||||
await act(async () => resolveLoad(0));
|
||||
|
||||
expect(onLoadSettled).toHaveBeenCalledWith({
|
||||
count: 0,
|
||||
rejected: false,
|
||||
continuation: "stale",
|
||||
});
|
||||
|
||||
requestCurrent = true;
|
||||
fire(records[0], true, node);
|
||||
await waitFor(() => expect(loadMore).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
});
|
||||
|
||||
describe("useLazyLoadSentinel — stale view handoff", () => {
|
||||
it("replays an eligible intersection after a stale request releases the lock", async () => {
|
||||
const scrollRef = makeScrollRef();
|
||||
let activeView = "A";
|
||||
let resolveA: (value: number) => void = () => {};
|
||||
const loadA = vi.fn(
|
||||
() =>
|
||||
new Promise<number>((resolve) => {
|
||||
resolveA = resolve;
|
||||
}),
|
||||
);
|
||||
const loadB = vi.fn(async () => 0);
|
||||
const { result, rerender } = renderHook(
|
||||
({ view }: { view: string }) =>
|
||||
useLazyLoadSentinel(scrollRef, true, false, false, view === "A" ? loadA : loadB, {
|
||||
rearmWhileIntersecting: true,
|
||||
isRequestCurrent: () => activeView === view,
|
||||
}),
|
||||
{ initialProps: { view: "A" } },
|
||||
);
|
||||
const node = document.createElement("div");
|
||||
act(() => result.current.sentinelRef(node));
|
||||
|
||||
fire(records[0], true, node);
|
||||
expect(loadA).toHaveBeenCalledTimes(1);
|
||||
|
||||
activeView = "B";
|
||||
rerender({ view: "B" });
|
||||
fire(records[1], true, node);
|
||||
|
||||
// B's observer sees the eligible sentinel, but A still owns the shared
|
||||
// in-flight lock until its stale request settles.
|
||||
expect(loadB).not.toHaveBeenCalled();
|
||||
await act(async () => resolveA(20));
|
||||
|
||||
// No exit/re-entry or recovery click: releasing A must replay B's current
|
||||
// intersection through the normal sentinel path.
|
||||
await waitFor(() => expect(loadB).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
});
|
||||
|
||||
describe("useLazyLoadSentinel — stale observers", () => {
|
||||
|
||||
@@ -17,6 +17,8 @@ export type LazyLoadSentinelOptions = {
|
||||
* continuation value reflects the final firing guards, not only the
|
||||
* caller's boundary decision. */
|
||||
onLoadSettled?: (result: LazyLoadSentinelSettleResult) => void;
|
||||
/** Returns false when the request settled after its owning view changed. */
|
||||
isRequestCurrent?: () => boolean;
|
||||
/** Fire (and join) even while an older-page request is in flight. Never
|
||||
* bypasses `blocked`. Defaults to false. */
|
||||
joinInFlightWhileLoading?: boolean;
|
||||
@@ -63,6 +65,7 @@ type SentinelMutableRefs = {
|
||||
stickToBottomWhileLoading: boolean;
|
||||
shouldContinueWhileIntersecting?: () => boolean;
|
||||
onLoadSettled?: (result: LazyLoadSentinelSettleResult) => void;
|
||||
isRequestCurrent?: () => boolean;
|
||||
}>;
|
||||
observerRef: React.MutableRefObject<IntersectionObserver | null>;
|
||||
sentinelNodeRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||
@@ -205,10 +208,6 @@ function useSentinelSettle(opts: {
|
||||
notify("stale");
|
||||
return;
|
||||
}
|
||||
if (!opts.refs.intersectingRef.current) {
|
||||
notify("sentinel-left-preload");
|
||||
return;
|
||||
}
|
||||
if (opts.refs.disarmedRef.current) {
|
||||
notify("disarmed");
|
||||
return;
|
||||
@@ -222,9 +221,8 @@ function useSentinelSettle(opts: {
|
||||
notify("blocked");
|
||||
return;
|
||||
}
|
||||
const shouldContinue =
|
||||
opts.refs.optionsRef.current.shouldContinueWhileIntersecting?.() ?? true;
|
||||
if (!shouldContinue) {
|
||||
const shouldContinue = opts.refs.optionsRef.current.shouldContinueWhileIntersecting;
|
||||
if (shouldContinue && !shouldContinue()) {
|
||||
// The loaded page added a visible boundary. A stale true
|
||||
// intersection must not chain another page; wait for fresh
|
||||
// upward movement or an observed exit/re-entry.
|
||||
@@ -232,6 +230,10 @@ function useSentinelSettle(opts: {
|
||||
notify("caller-stopped");
|
||||
return;
|
||||
}
|
||||
if (!shouldContinue && !opts.refs.intersectingRef.current) {
|
||||
notify("sentinel-left-preload");
|
||||
return;
|
||||
}
|
||||
// The just-completed request owns this re-arm. `loadMore` still
|
||||
// applies its own cursor/in-flight guard, so do not reject this
|
||||
// hand-off on a stale loading render from the request that has
|
||||
@@ -356,6 +358,31 @@ function useRetryWhenSentinelBecomesEligible({
|
||||
}, [hasMore, blocked, isLoadingMore, fireLoad, refs]);
|
||||
}
|
||||
|
||||
/** Returns true when a stale request can hand its current intersection to the
|
||||
* replacement view after the shared in-flight lock is released. */
|
||||
function shouldReplayCurrentIntersection(
|
||||
refs: SentinelMutableRefs,
|
||||
previousObserver: IntersectionObserver,
|
||||
previousNode: HTMLDivElement,
|
||||
): boolean {
|
||||
const { hasMore, blocked, isLoadingMore } = refs.stateRef.current;
|
||||
const { joinInFlightWhileLoading } = refs.optionsRef.current;
|
||||
return Boolean(
|
||||
refs.mountedRef.current &&
|
||||
refs.observerRef.current &&
|
||||
refs.sentinelNodeRef.current &&
|
||||
(refs.observerRef.current !== previousObserver ||
|
||||
refs.sentinelNodeRef.current !== previousNode) &&
|
||||
refs.intersectingRef.current &&
|
||||
!refs.disarmedRef.current &&
|
||||
!refs.loadInFlightRef.current &&
|
||||
!refs.continuationScheduledRef.current &&
|
||||
hasMore &&
|
||||
!blocked &&
|
||||
(!isLoadingMore || joinInFlightWhileLoading),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Observes a sentinel element to trigger older-message lazy loading, shared by
|
||||
* the native transcript (top-of-list sentinel, no automatic re-arm) and the
|
||||
@@ -380,7 +407,8 @@ function useRetryWhenSentinelBecomesEligible({
|
||||
* another request was in flight, an eligibility transition retries it while
|
||||
* it remains intersecting; this closes the gap where IntersectionObserver does
|
||||
* not emit a second entry after the blocked state changes. Stale completions
|
||||
* (unmount, observer cleanup, sentinel replacement) never re-arm.
|
||||
* never re-arm their original observer, but replay an eligible intersection
|
||||
* already observed by a replacement observer after the in-flight lock clears.
|
||||
*/
|
||||
// eslint-disable-next-line max-params, max-lines-per-function -- plan-mandated sentinel state machine; eligibility retry and deferred re-arm stay coordinated with the extracted observer/settle/pin helpers
|
||||
export function useLazyLoadSentinel(
|
||||
@@ -393,6 +421,7 @@ export function useLazyLoadSentinel(
|
||||
): {
|
||||
sentinelRef: (node: HTMLDivElement | null) => void;
|
||||
onUserGesture: () => void;
|
||||
retry: () => void;
|
||||
} {
|
||||
const {
|
||||
rootMargin = "200px 0px 0px 0px",
|
||||
@@ -401,6 +430,7 @@ export function useLazyLoadSentinel(
|
||||
stickToBottomWhileLoading = false,
|
||||
shouldContinueWhileIntersecting,
|
||||
onLoadSettled,
|
||||
isRequestCurrent,
|
||||
} = options ?? {};
|
||||
|
||||
const stateRef = useRef({ hasMore, blocked, isLoadingMore });
|
||||
@@ -413,6 +443,7 @@ export function useLazyLoadSentinel(
|
||||
stickToBottomWhileLoading,
|
||||
shouldContinueWhileIntersecting,
|
||||
onLoadSettled,
|
||||
isRequestCurrent,
|
||||
});
|
||||
useEffect(() => {
|
||||
optionsRef.current = {
|
||||
@@ -421,6 +452,7 @@ export function useLazyLoadSentinel(
|
||||
stickToBottomWhileLoading,
|
||||
shouldContinueWhileIntersecting,
|
||||
onLoadSettled,
|
||||
isRequestCurrent,
|
||||
};
|
||||
}, [
|
||||
rearmWhileIntersecting,
|
||||
@@ -428,6 +460,7 @@ export function useLazyLoadSentinel(
|
||||
stickToBottomWhileLoading,
|
||||
shouldContinueWhileIntersecting,
|
||||
onLoadSettled,
|
||||
isRequestCurrent,
|
||||
]);
|
||||
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
@@ -494,13 +527,23 @@ export function useLazyLoadSentinel(
|
||||
let count = 0;
|
||||
let rejected = false;
|
||||
const onLoadSettled = optionsRef.current.onLoadSettled;
|
||||
const isRequestCurrent = optionsRef.current.isRequestCurrent;
|
||||
try {
|
||||
count = await loadMore();
|
||||
} catch {
|
||||
rejected = true;
|
||||
} finally {
|
||||
settleLoad(node, observer, { count, rejected }, onLoadSettled);
|
||||
const outcome = { count, rejected };
|
||||
const staleRequest = Boolean(isRequestCurrent && !isRequestCurrent());
|
||||
if (staleRequest) {
|
||||
onLoadSettled?.({ ...outcome, continuation: "stale" });
|
||||
} else {
|
||||
settleLoad(node, observer, outcome, onLoadSettled);
|
||||
}
|
||||
loadInFlightRef.current = false;
|
||||
if (staleRequest && shouldReplayCurrentIntersection(refs, observer, node)) {
|
||||
void fireLoadRef.current?.();
|
||||
}
|
||||
}
|
||||
}, [loadMore, refreshPinned, settleLoad]);
|
||||
fireLoadRef.current = fireLoad;
|
||||
@@ -542,5 +585,18 @@ export function useLazyLoadSentinel(
|
||||
void fireLoad();
|
||||
}, [fireLoad]);
|
||||
|
||||
return { sentinelRef, onUserGesture };
|
||||
// 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
|
||||
// outside the preload region. The normal observer path remains geometry
|
||||
// gated and never calls this callback automatically.
|
||||
const retry = useCallback(() => {
|
||||
const { hasMore, blocked, isLoadingMore } = stateRef.current;
|
||||
const { joinInFlightWhileLoading } = optionsRef.current;
|
||||
if (!hasMore || blocked || (isLoadingMore && !joinInFlightWhileLoading)) return;
|
||||
disarmedRef.current = false;
|
||||
void fireLoad();
|
||||
}, [fireLoad]);
|
||||
|
||||
return { sentinelRef, onUserGesture, retry };
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
} from "@/lib/types/http";
|
||||
import { useProcessedMessages } from "./use-processed-messages";
|
||||
|
||||
const TASK_DESCRIPTION = "task description";
|
||||
|
||||
function makeMessage(id: string, authorType: "agent" | "user", content: string): Message {
|
||||
return {
|
||||
id,
|
||||
@@ -24,7 +26,10 @@ describe("useProcessedMessages task description fallback", () => {
|
||||
it("keeps the task description before visible agent history", () => {
|
||||
const agentMessage = makeMessage("agent-1", "agent", "boot");
|
||||
const { result } = renderHook(() =>
|
||||
useProcessedMessages([agentMessage], "t1", "s1", "task description"),
|
||||
useProcessedMessages([agentMessage], "t1", "s1", TASK_DESCRIPTION, {
|
||||
historyInitialized: true,
|
||||
hasOlderMessages: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.allMessages.map((message) => message.id)).toEqual([
|
||||
@@ -33,10 +38,37 @@ describe("useProcessedMessages task description fallback", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not synthesize a fallback while older history remains", () => {
|
||||
const agentMessage = makeMessage("agent-1", "agent", "boot");
|
||||
const { result } = renderHook(() =>
|
||||
useProcessedMessages([agentMessage], "t1", "s1", TASK_DESCRIPTION, {
|
||||
historyInitialized: true,
|
||||
hasOlderMessages: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.allMessages.map((message) => message.id)).toEqual(["agent-1"]);
|
||||
});
|
||||
|
||||
it("does not synthesize a fallback before history initializes", () => {
|
||||
const agentMessage = makeMessage("agent-1", "agent", "boot");
|
||||
const { result } = renderHook(() =>
|
||||
useProcessedMessages([agentMessage], "t1", "s1", TASK_DESCRIPTION, {
|
||||
historyInitialized: false,
|
||||
hasOlderMessages: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.allMessages.map((message) => message.id)).toEqual(["agent-1"]);
|
||||
});
|
||||
|
||||
it("does not duplicate a visible stored user prompt", () => {
|
||||
const userMessage = makeMessage("user-1", "user", "stored prompt");
|
||||
const { result } = renderHook(() =>
|
||||
useProcessedMessages([userMessage], "t1", "s1", "task description"),
|
||||
useProcessedMessages([userMessage], "t1", "s1", TASK_DESCRIPTION, {
|
||||
historyInitialized: true,
|
||||
hasOlderMessages: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.allMessages.map((message) => message.id)).toEqual(["user-1"]);
|
||||
|
||||
@@ -144,6 +144,7 @@ export type GroupedRenderOptions = {
|
||||
};
|
||||
|
||||
export type ProcessedMessagesOptions = {
|
||||
historyInitialized?: boolean;
|
||||
hasOlderMessages?: boolean;
|
||||
lastAgentError?: LastAgentError | null;
|
||||
currentTurnId?: string | null;
|
||||
@@ -479,9 +480,13 @@ function useVisibleMessages(
|
||||
export function shouldShowTaskDescriptionFallback(
|
||||
taskDescription: string | null,
|
||||
visibleMessages: Message[],
|
||||
options: Pick<ProcessedMessagesOptions, "historyInitialized" | "hasOlderMessages"> = {},
|
||||
): boolean {
|
||||
return (
|
||||
Boolean(taskDescription) && !visibleMessages.some((message) => message.author_type === "user")
|
||||
Boolean(taskDescription) &&
|
||||
options.historyInitialized === true &&
|
||||
options.hasOlderMessages === false &&
|
||||
!visibleMessages.some((message) => message.author_type === "user")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -533,8 +538,8 @@ export function useProcessedMessages(
|
||||
const visibleMessages = useVisibleMessages(messages, toolCallIds, subagentChildIds, scope);
|
||||
|
||||
const showTaskDescriptionFallback = useMemo(
|
||||
() => shouldShowTaskDescriptionFallback(taskDescription, visibleMessages),
|
||||
[taskDescription, visibleMessages],
|
||||
() => shouldShowTaskDescriptionFallback(taskDescription, visibleMessages, options),
|
||||
[taskDescription, visibleMessages, options.historyInitialized, options.hasOlderMessages],
|
||||
);
|
||||
const taskDescriptionMessage: Message | null = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -146,6 +146,7 @@ export function taskToState(
|
||||
[resolvedSessionId]: {
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
historyInitialized: true,
|
||||
hasMore: messages.hasMore ?? false,
|
||||
oldestCursor: messages.oldestCursor ?? messages.items[0]?.id ?? null,
|
||||
},
|
||||
|
||||
@@ -87,13 +87,26 @@ describe("mergeMessages", () => {
|
||||
expect((next[0].metadata as { x: number }).x).toBe(2);
|
||||
});
|
||||
|
||||
it("applies metadata (hasMore / oldestCursor) on merge", () => {
|
||||
it("applies history and pagination metadata on merge", () => {
|
||||
const store = makeStore();
|
||||
store.getState().mergeMessages(SESSION, snapshot(), { hasMore: true, oldestCursor: "a" });
|
||||
store.getState().mergeMessages(SESSION, snapshot(), {
|
||||
historyInitialized: true,
|
||||
hasMore: true,
|
||||
oldestCursor: "a",
|
||||
});
|
||||
const meta = store.getState().messages.metaBySession[SESSION];
|
||||
expect(meta.historyInitialized).toBe(true);
|
||||
expect(meta.hasMore).toBe(true);
|
||||
expect(meta.oldestCursor).toBe("a");
|
||||
});
|
||||
|
||||
it("does not initialize history from live-message inserts", () => {
|
||||
const store = makeStore();
|
||||
store.getState().addMessage(makeMessage({ id: "live", author_type: "agent" }));
|
||||
|
||||
const meta = store.getState().messages.metaBySession[SESSION];
|
||||
expect(meta).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeMessages — prompt_index reconciliation", () => {
|
||||
|
||||
@@ -33,6 +33,7 @@ function ensureMessageMeta(
|
||||
metaBySession[sessionId] = {
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
historyInitialized: false,
|
||||
hasMore: false,
|
||||
oldestCursor: null,
|
||||
};
|
||||
@@ -44,6 +45,7 @@ function applyMessageMeta(
|
||||
metaBySession: SessionSliceState["messages"]["metaBySession"],
|
||||
sessionId: string,
|
||||
meta: {
|
||||
historyInitialized?: boolean;
|
||||
hasMore?: boolean;
|
||||
oldestCursor?: string | null;
|
||||
isLoading?: boolean;
|
||||
@@ -51,6 +53,9 @@ function applyMessageMeta(
|
||||
},
|
||||
) {
|
||||
ensureMessageMeta(metaBySession, sessionId);
|
||||
if (meta.historyInitialized !== undefined) {
|
||||
metaBySession[sessionId].historyInitialized = meta.historyInitialized;
|
||||
}
|
||||
if (meta.hasMore !== undefined) metaBySession[sessionId].hasMore = meta.hasMore;
|
||||
if (meta.isLoading !== undefined) metaBySession[sessionId].isLoading = meta.isLoading;
|
||||
if (meta.isLoadingMore !== undefined) metaBySession[sessionId].isLoadingMore = meta.isLoadingMore;
|
||||
|
||||
@@ -19,6 +19,8 @@ export type MessagesState = {
|
||||
isLoading: boolean;
|
||||
/** Older-page request in flight (set by the shared pagination coordinator). */
|
||||
isLoadingMore: boolean;
|
||||
/** True after an authoritative newest-window response or boot payload. */
|
||||
historyInitialized: boolean;
|
||||
hasMore: boolean;
|
||||
oldestCursor: string | null;
|
||||
}
|
||||
@@ -210,7 +212,11 @@ export type SessionSliceActions = {
|
||||
setMessages: (
|
||||
sessionId: string,
|
||||
messages: Message[],
|
||||
meta?: { hasMore?: boolean; oldestCursor?: string | null },
|
||||
meta?: {
|
||||
historyInitialized?: boolean;
|
||||
hasMore?: boolean;
|
||||
oldestCursor?: string | null;
|
||||
},
|
||||
) => void;
|
||||
addMessage: (message: Message) => void;
|
||||
updateMessage: (message: Message) => void;
|
||||
@@ -225,16 +231,25 @@ export type SessionSliceActions = {
|
||||
mergeMessages: (
|
||||
sessionId: string,
|
||||
messages: Message[],
|
||||
meta?: { hasMore?: boolean; oldestCursor?: string | null },
|
||||
meta?: {
|
||||
historyInitialized?: boolean;
|
||||
hasMore?: boolean;
|
||||
oldestCursor?: string | null;
|
||||
},
|
||||
) => void;
|
||||
prependMessages: (
|
||||
sessionId: string,
|
||||
messages: Message[],
|
||||
meta?: { hasMore?: boolean; oldestCursor?: string | null },
|
||||
meta?: {
|
||||
historyInitialized?: boolean;
|
||||
hasMore?: boolean;
|
||||
oldestCursor?: string | null;
|
||||
},
|
||||
) => void;
|
||||
setMessagesMetadata: (
|
||||
sessionId: string,
|
||||
meta: {
|
||||
historyInitialized?: boolean;
|
||||
hasMore?: boolean;
|
||||
isLoading?: boolean;
|
||||
isLoadingMore?: boolean;
|
||||
|
||||
+10
-18
@@ -22,6 +22,12 @@ import type * as UISliceTypes from "./slices/ui/types";
|
||||
import type { AgentUpdateJob, InstallJob } from "./slices/settings/types";
|
||||
import { mergeInitialState } from "./default-state";
|
||||
import { buildStateOverrides } from "./store-overrides";
|
||||
|
||||
type MessageHistoryMetadata = {
|
||||
historyInitialized?: boolean;
|
||||
hasMore?: boolean;
|
||||
oldestCursor?: string | null;
|
||||
};
|
||||
import {
|
||||
createKanbanSlice,
|
||||
createWorkspaceSlice,
|
||||
@@ -409,18 +415,10 @@ export type AppState = KanbanSlice & {
|
||||
toggleBottomTerminal: () => void;
|
||||
openBottomTerminalWithCommand: (command: string) => void;
|
||||
clearBottomTerminalCommand: () => void;
|
||||
setMessages: (
|
||||
sessionId: string,
|
||||
messages: Message[],
|
||||
meta?: { hasMore?: boolean; oldestCursor?: string | null },
|
||||
) => void;
|
||||
setMessages: (sessionId: string, messages: Message[], meta?: MessageHistoryMetadata) => void;
|
||||
/** Adds a message to a session, merging fields when the message already exists. */
|
||||
addMessage: (message: Message) => void;
|
||||
mergeMessages: (
|
||||
sessionId: string,
|
||||
messages: Message[],
|
||||
meta?: { hasMore?: boolean; oldestCursor?: string | null },
|
||||
) => void;
|
||||
mergeMessages: (sessionId: string, messages: Message[], meta?: MessageHistoryMetadata) => void;
|
||||
/** Upserts a turn row, rejecting stale updates (see shouldApplyTurnUpdate). */
|
||||
addTurn: (turn: Turn) => void;
|
||||
/** Merges a complete REST snapshot and reconciles its marker atomically. */
|
||||
@@ -441,18 +439,12 @@ export type AppState = KanbanSlice & {
|
||||
updateMessage: (message: Message) => void;
|
||||
updateMessages: (messages: Message[]) => void;
|
||||
removeMessage: (sessionId: string, messageId: string) => void;
|
||||
prependMessages: (
|
||||
sessionId: string,
|
||||
messages: Message[],
|
||||
meta?: { hasMore?: boolean; oldestCursor?: string | null },
|
||||
) => void;
|
||||
prependMessages: (sessionId: string, messages: Message[], meta?: MessageHistoryMetadata) => void;
|
||||
setMessagesMetadata: (
|
||||
sessionId: string,
|
||||
meta: {
|
||||
hasMore?: boolean;
|
||||
meta: MessageHistoryMetadata & {
|
||||
isLoading?: boolean;
|
||||
isLoadingMore?: boolean;
|
||||
oldestCursor?: string | null;
|
||||
},
|
||||
) => void;
|
||||
setMessagesLoading: (sessionId: string, loading: boolean) => void;
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
---
|
||||
created: 2026-08-30
|
||||
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: Continuous conversation history pagination
|
||||
|
||||
## Overview
|
||||
|
||||
Make the transcript distinguish initialized history from default empty state,
|
||||
then use that state to suppress the task-description fallback until the true
|
||||
conversation start is reached. After that boundary is trustworthy, replace
|
||||
visible-row identity pagination stops with committed sentinel geometry and
|
||||
show the manual loader only for recoverable failures.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- Record whether authoritative message pagination metadata initialized.
|
||||
- Gate the synthetic task description on initialized, exhausted history.
|
||||
- Continue upward pagination while the committed sentinel remains in preload.
|
||||
- Keep prompt `#1` as the visible start.
|
||||
- Show the explicit older-page control only after error or no progress.
|
||||
- Preserve scroll anchors and raw search pagination.
|
||||
- Prove the same outcome on desktop and mobile with tool-heavy history.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Backend queries, cursor fields, persistence, or message ordering.
|
||||
- Eagerly loading history when a task opens.
|
||||
- Rendering internal rows before prompt `#1`.
|
||||
- Changing prompt-history panel behavior beyond shared state compatibility.
|
||||
- New user-facing copy or a new mobile composition.
|
||||
|
||||
## Confirmed root cause
|
||||
|
||||
`shouldShowTaskDescriptionFallback` checks only the currently loaded visible
|
||||
rows. A bounded newest window containing no user row therefore synthesizes the
|
||||
task description while raw `has_more` is still true.
|
||||
|
||||
The native transcript stops a load cycle whenever an older page changes the
|
||||
oldest visible boundary. Its retry path requires `scrollTop` to decrease, which
|
||||
cannot happen while the reader is pinned at the top. The always-visible manual
|
||||
loader becomes the only reliable continuation.
|
||||
|
||||
The reported running session placed the latest user prompt behind 516 newer
|
||||
rows. The initial 100-row window plus 20-row older pages required 21 requests,
|
||||
which made both defects visible.
|
||||
|
||||
## Technical approach
|
||||
|
||||
### Trustworthy transcript start
|
||||
|
||||
- Add an initialization bit to per-session message metadata and its merge,
|
||||
hydration, default, reconciliation, and purge paths.
|
||||
- Expose initialized and raw-history state from `useSessionMessages`.
|
||||
- Pass an explicit history-start condition into `useProcessedMessages`.
|
||||
- Require initialized, exhausted history before synthesizing the task
|
||||
description when no stored user prompt is visible.
|
||||
- Keep prompt ordinals and the visible `hasMore` boundary unchanged.
|
||||
|
||||
### Geometry-driven pagination and recovery
|
||||
|
||||
- Re-evaluate the sentinel against the scroll root and configured preload
|
||||
margin after page commit and prepend restoration.
|
||||
- Continue positive loads while current geometry remains eligible, regardless
|
||||
of whether a standalone boundary key changed.
|
||||
- Retain boundary keys for scroll anchoring and diagnostics, not continuation.
|
||||
- Track a transcript-local recovery state for rejected or zero-progress loads.
|
||||
- Render the existing localized older-page button only in recovery state and
|
||||
clear that state after progress, exhaustion, prompt `#1`, or session change.
|
||||
- Give the recovery control a coarse-pointer 44-pixel hit area while retaining
|
||||
existing desktop density.
|
||||
|
||||
## Tests
|
||||
|
||||
| Acceptance criteria | Evidence |
|
||||
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| `AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.1` to `.4` | `use-processed-messages-fallback.test.ts`, session slice/fetch tests |
|
||||
| `AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.5` to `.8` | `use-lazy-load-sentinel.test.ts`, `message-list-native.test.tsx`, `message-list-shared.test.tsx` |
|
||||
| `AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.9` | Existing native prepend-anchor unit and browser assertions |
|
||||
| `AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.10` | Existing bounded-opening browser request watcher |
|
||||
| `AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.12` | Existing inactive-session reconciliation coverage |
|
||||
|
||||
The TDD regression starts with the current tool-heavy case: initialized
|
||||
history has `hasMore: true`, contains agent/tool rows, and has no loaded user
|
||||
row. The expected synthetic fallback count is zero, which fails against the
|
||||
current implementation.
|
||||
|
||||
## E2E tests
|
||||
|
||||
- Desktop Chromium: update
|
||||
`apps/web/e2e/tests/chat/message-pagination.spec.ts` to prove one upward
|
||||
navigation crosses more than twenty tool-heavy pages, reveals the stored
|
||||
latest prompt, and never shows the task-description fallback or normal retry
|
||||
control.
|
||||
- Mobile Chrome: update
|
||||
`apps/web/e2e/tests/chat/mobile-message-pagination.spec.ts` with the same user
|
||||
outcome in the full-height mobile Chat surface.
|
||||
- Shared seed: extend
|
||||
`apps/web/e2e/tests/chat/message-pagination-helpers.ts` with an efficient
|
||||
long-history fixture and retain prompt-`#1`, hidden pre-prompt, and anchor
|
||||
scenarios.
|
||||
|
||||
## Mobile design contract
|
||||
|
||||
- Desktop outcome: continuous upward history loading reaches the stored prompt
|
||||
without a misleading synthetic prompt or routine manual loader.
|
||||
- Mobile entry point: the existing Chat tab in the full-height task layout.
|
||||
- Nearest exemplar: `apps/web/components/task/task-layout.tsx` and the current
|
||||
mobile pagination spec.
|
||||
- Hierarchy and surface: no composition change; the transcript remains the
|
||||
single vertical scroll owner.
|
||||
- Shared logic: desktop and mobile use the same store, hooks, cursor
|
||||
coordinator, sentinel, and recovery state.
|
||||
- Mobile-specific detail: the recovery control keeps a 44-pixel coarse-pointer
|
||||
hit area.
|
||||
- Mobile proof: the `mobile-chrome` flow reaches the stored prompt with one
|
||||
upward navigation and exercises recovery without horizontal or nested
|
||||
scrolling.
|
||||
|
||||
## Work orders
|
||||
|
||||
- [x] [Task 01: Establish the true transcript start](task-01-establish-transcript-start.md) (`done`)
|
||||
- [x] [Task 02: Continue pagination through the preload region](task-02-continue-preload-pagination.md) (`done`)
|
||||
|
||||
## Verification results
|
||||
|
||||
- Focused Task 01 Vitest suite: passed (5 files, 87 tests).
|
||||
- Focused Task 02 Vitest suite: passed (4 files, 115 tests).
|
||||
- `pnpm run typecheck`: passed.
|
||||
- `make build-web`: passed; Vite emitted existing chunk-size and dynamic-import
|
||||
warnings only.
|
||||
- Desktop Chromium pagination: passed (6 tests).
|
||||
- Mobile Chrome pagination: passed (6 tests).
|
||||
- `git diff --check`: passed.
|
||||
|
||||
## Risks
|
||||
|
||||
- Default `hasMore: false` must not be mistaken for an authoritative exhausted
|
||||
response before history initializes.
|
||||
- Boot hydration, WebSocket-only rows, refetch reconciliation, and deletion
|
||||
must not leave stale initialization metadata.
|
||||
- Geometry checks must use committed layout and current scroll-root bounds so
|
||||
stale observer entries cannot cascade through off-screen history.
|
||||
- A zero-result response and a real request error currently both surface as no
|
||||
progress; both require recovery without an automatic retry loop.
|
||||
- The long-history browser seed must remain deterministic and fast enough for
|
||||
desktop and mobile projects.
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
---
|
||||
id: "01-establish-transcript-start"
|
||||
title: "Establish the true transcript start"
|
||||
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.1
|
||||
- AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.2
|
||||
- AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.3
|
||||
- AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.4
|
||||
- AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.10
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/task-prompt-transcript-visibility.md
|
||||
---
|
||||
|
||||
# Task 01: Establish the true transcript start
|
||||
|
||||
## Summary
|
||||
|
||||
Record when session message history has authoritative pagination metadata and
|
||||
use that state to prevent a bounded tool-only window from synthesizing the task
|
||||
description. Preserve bounded opening and prompt-`#1` behavior.
|
||||
|
||||
## In scope
|
||||
|
||||
- Add and maintain per-session message-history initialization state.
|
||||
- Expose initialized and raw-history state through the session hook.
|
||||
- Gate task-description fallback on initialized, exhausted history.
|
||||
- Preserve stored user prompts as authoritative.
|
||||
- Add the failing tool-only-window regression before implementation.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Sentinel continuation and retry-control behavior.
|
||||
- Backend message APIs or persistence.
|
||||
- Eager history loading.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- An initialized window with older history and no user row renders no synthetic
|
||||
task description.
|
||||
- Exhausted legacy history with no user row renders one non-empty fallback.
|
||||
- Uninitialized, refetching, prompt-`#1`, and empty-description cases do not
|
||||
produce a misleading or duplicate fallback.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
pnpm exec vitest run \
|
||||
hooks/use-processed-messages-fallback.test.ts \
|
||||
hooks/domains/session/use-session-messages.test.ts \
|
||||
hooks/domains/session/use-session-message-fetch.test.ts \
|
||||
lib/state/slices/session/session-slice.merge-messages.test.ts \
|
||||
components/task/chat/message-list-shared.test.tsx
|
||||
pnpm run typecheck
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/lib/state/slices/session/types.ts`
|
||||
- `apps/web/lib/state/slices/session/session-slice.ts`
|
||||
- `apps/web/lib/state/slices/session/session-slice.merge-messages.test.ts`
|
||||
- `apps/web/lib/state/hydration/merge-strategies.ts`
|
||||
- `apps/web/hooks/domains/session/use-session-messages.ts`
|
||||
- `apps/web/hooks/domains/session/use-session-message-fetch.test.ts`
|
||||
- `apps/web/components/task/chat/use-chat-panel-state.ts`
|
||||
- `apps/web/hooks/use-processed-messages.ts`
|
||||
- `apps/web/hooks/use-processed-messages-fallback.test.ts`
|
||||
- `apps/web/components/task/chat/message-list-shared.test.tsx`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Risks
|
||||
|
||||
- Boot-hydrated and client-fetched sessions must set the same initialization
|
||||
state.
|
||||
- Live rows arriving before the newest snapshot must not claim that history is
|
||||
initialized or exhausted.
|
||||
- Purged sessions must not retain metadata that affects a later session.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- `REQ-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001` acceptance criteria `.1` to
|
||||
`.4` and `.10`.
|
||||
- History state and opening boundary sections of the system design.
|
||||
- Existing message-window reconciliation and processed-message fallback tests.
|
||||
|
||||
## Results
|
||||
|
||||
- Added per-session `historyInitialized` metadata with safe defaults, live-row
|
||||
isolation, newest-window fetch/boot hydration, and purge-compatible state.
|
||||
- Gated the task-description fallback on initialized, exhausted history while
|
||||
preserving stored user prompts and the prompt-`#1` visible boundary.
|
||||
- Added regressions for older history, uninitialized history, live inserts, and
|
||||
authoritative fetch metadata.
|
||||
- The focused Task 01 Vitest suite passed (5 files, 87 tests).
|
||||
- `pnpm run typecheck` passed.
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
---
|
||||
id: "02-continue-preload-pagination"
|
||||
title: "Continue pagination through the preload region"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on:
|
||||
- "01-establish-transcript-start"
|
||||
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.6
|
||||
- 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.11
|
||||
- AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.12
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/task-prompt-transcript-visibility.md
|
||||
---
|
||||
|
||||
# Task 02: Continue pagination through the preload region
|
||||
|
||||
## Summary
|
||||
|
||||
Make positive older-page loads continue from committed sentinel geometry
|
||||
instead of visible-row identity. Show the existing loader only for recoverable
|
||||
error or no-progress state, then prove the combined flow on desktop and mobile.
|
||||
|
||||
## In scope
|
||||
|
||||
- Evaluate current sentinel geometry after layout and scroll restoration.
|
||||
- Continue while the sentinel remains inside the configured preload region.
|
||||
- Retain prompt `#1`, exhaustion, stale-session, and no-progress stops.
|
||||
- Track recovery state and expose a managed retry action.
|
||||
- Hide the manual loader during normal pagination.
|
||||
- Preserve prepend anchoring and raw search backfill.
|
||||
- Add desktop and mobile long-history regressions.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Changes to initial history-fetch size or older-page size.
|
||||
- Backend cursors or message persistence.
|
||||
- A new mobile surface or new localized copy.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- One upward navigation crosses short standalone and collapsed pages while the
|
||||
sentinel remains in preload, then stops when current geometry leaves it.
|
||||
- Successful pagination never exposes the manual loader; failure or no progress
|
||||
exposes one retry control and a successful retry clears it.
|
||||
- Desktop and mobile reach a stored prompt more than twenty older pages away
|
||||
without a click while retaining stable scroll position.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
pnpm exec vitest run \
|
||||
hooks/use-lazy-load-sentinel.test.ts \
|
||||
hooks/use-lazy-load-messages.test.ts \
|
||||
components/task/chat/message-list-native.test.tsx \
|
||||
components/task/chat/message-list-shared.test.tsx
|
||||
pnpm run typecheck
|
||||
```
|
||||
|
||||
```bash
|
||||
make build-web
|
||||
cd apps/web
|
||||
pnpm e2e:run --host --no-build --project chromium -- \
|
||||
tests/chat/message-pagination.spec.ts
|
||||
pnpm e2e:run --host --no-build --project mobile-chrome -- \
|
||||
tests/chat/mobile-message-pagination.spec.ts
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/hooks/use-lazy-load-sentinel.ts`
|
||||
- `apps/web/hooks/use-lazy-load-sentinel.test.ts`
|
||||
- `apps/web/components/task/chat/message-list-native-scroll.ts`
|
||||
- `apps/web/components/task/chat/message-list-native.tsx`
|
||||
- `apps/web/components/task/chat/message-list-native.test.tsx`
|
||||
- `apps/web/components/task/chat/message-list-shared.tsx`
|
||||
- `apps/web/components/task/chat/message-list-shared.test.tsx`
|
||||
- `apps/web/e2e/tests/chat/message-pagination-helpers.ts`
|
||||
- `apps/web/e2e/tests/chat/message-pagination.spec.ts`
|
||||
- `apps/web/e2e/tests/chat/mobile-message-pagination.spec.ts`
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Task 01 must provide authoritative transcript-start state before the combined
|
||||
browser flow changes fallback expectations.
|
||||
|
||||
## Risks
|
||||
|
||||
- Geometry must be measured after prepend restoration, not from a stale
|
||||
IntersectionObserver sample.
|
||||
- Recovery retries must not loop automatically on the same failed cursor.
|
||||
- The recovery control needs a 44-pixel coarse-pointer target without changing
|
||||
fine-pointer desktop density.
|
||||
- Long-history fixtures must not make focused E2E runs flaky or exceed their
|
||||
existing timeout budget.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- `REQ-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001` acceptance criteria `.5` to
|
||||
`.9`, `.11`, and `.12`.
|
||||
- Upward pagination, failure and recovery, and responsive sections of the
|
||||
system design.
|
||||
- Existing lazy-sentinel, native-scroll, desktop, and mobile pagination tests.
|
||||
|
||||
## Results
|
||||
|
||||
- Replaced visible-boundary continuation with committed sentinel/root geometry,
|
||||
retaining boundary keys for anchoring and diagnostics.
|
||||
- Added transcript-local recovery state and an explicit retry path for rejected
|
||||
or zero-progress requests; routine successful pagination stays button-free.
|
||||
- Added committed-geometry continuation and session-epoch guards so stale
|
||||
observer entries and prior-session settlements cannot stop or alter the
|
||||
current transcript.
|
||||
- Preserved the desktop fine-pointer density and added a coarse-pointer
|
||||
minimum 44-pixel recovery target.
|
||||
- Added long-history and short-boundary fixtures plus matching desktop/mobile
|
||||
browser regressions.
|
||||
- The focused Task 02 Vitest suite passed (4 files, 115 tests).
|
||||
- `pnpm run typecheck` and `make build-web` passed.
|
||||
- Chromium pagination passed (6 tests); mobile Chrome pagination passed (6
|
||||
tests).
|
||||
@@ -5,126 +5,50 @@ created: 2026-08-22
|
||||
owners:
|
||||
- kandev
|
||||
---
|
||||
# Task transcript history visibility Requirements
|
||||
|
||||
# Task transcript history visibility requirements
|
||||
|
||||
## Overview
|
||||
|
||||
The transcript can lose the original task prompt after a reload. Long activity groups can also make older history difficult to reach. An older page can add tool events only inside an existing collapsed row. The user then sees no new entry and must select **Load older messages** again.
|
||||
Task transcripts open on a bounded newest window and load older history as the
|
||||
user navigates upward. A bounded window is not the start of the conversation.
|
||||
The transcript must not substitute the task description for an unloaded user
|
||||
prompt, and upward navigation must not require routine use of a manual loader.
|
||||
|
||||
## Terms
|
||||
|
||||
- **Visible start:** The first stored user prompt, normally prompt `#1`.
|
||||
- **Task-description fallback:** A synthetic user row used only for legacy
|
||||
history that contains no stored user prompt.
|
||||
- **Preload region:** The area near the oldest loaded edge where upward
|
||||
pagination can continue without another user action.
|
||||
|
||||
## Requirements
|
||||
|
||||
### REQ-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001: Task transcript history visibility
|
||||
|
||||
**Intent:** The transcript can lose the original task prompt after a reload. Long activity groups can also make older history difficult to reach. An older page can add tool events only inside an existing collapsed row. The user then sees no new entry and must select **Load older messages** again.
|
||||
**Intent:** Users can navigate continuously from the newest response to the
|
||||
first prompt without mistaking a bounded history window for the conversation
|
||||
start.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.1:** When visible session history has no user-authored message, show the task description as the first user prompt.
|
||||
- **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.2:** Keep the task description visible while agent or environment messages load.
|
||||
- **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.3:** Treat a visible, stored user message as authoritative. Do not also show the task-description fallback in this case.
|
||||
- **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.4:** When the user scrolls to the oldest loaded point, load older history without a separate button action.
|
||||
- **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.5:** Continue automatic loading while older pages only extend a collapsed activity row.
|
||||
- **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.6:** Do not require another user action before a new standalone transcript entry appears.
|
||||
- **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.7:** Stop automatic loading in these cases:
|
||||
- **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.8:** Older content moves the load boundary above the preload region.
|
||||
- **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.9:** When the first user prompt of a session is loaded, the transcript shall not show an older-page control.
|
||||
- **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.10:** 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.1:** When older session history remains and the loaded window contains no user-authored message, the transcript shall not show the task description as a user prompt.
|
||||
- **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.2:** The transcript shall show each stored user prompt at its chronological position after that prompt is loaded.
|
||||
- **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.3:** When prompt `#1` is loaded, the transcript shall treat it as the visible start and shall not expose older internal rows through transcript pagination or an older-page control.
|
||||
- **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.4:** When persisted history is exhausted without a stored user prompt, the transcript shall show a non-empty task description as the first user prompt and shall not add an empty fallback.
|
||||
- **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.5:** When the user reaches the oldest loaded edge, the transcript shall load older history without a separate button action.
|
||||
- **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.6:** After an older page commits, the transcript shall continue loading while the oldest-page sentinel remains in the preload region, whether the page extends a collapsed activity group or adds a standalone row.
|
||||
- **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.7:** Automatic older-history loading shall stop when the sentinel leaves the preload region, prompt `#1` is loaded, persisted history is exhausted, or a request fails to make progress.
|
||||
- **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.8:** When an older-page request fails or makes no progress while older history remains, the transcript shall show a retry control. Routine successful pagination shall not show this control.
|
||||
- **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.9:** The transcript shall keep the reader's current visual position stable while older content is inserted.
|
||||
- **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.10:** Opening a task shall request only the bounded newest window until the user navigates upward.
|
||||
- **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.
|
||||
|
||||
## Migrated source detail
|
||||
## Exclusions
|
||||
|
||||
## Why
|
||||
|
||||
The transcript can lose the original task prompt after a reload. Long activity
|
||||
groups can also make older history difficult to reach. An older page can add
|
||||
tool events only inside an existing collapsed row. The user then sees no new
|
||||
entry and must select **Load older messages** again.
|
||||
|
||||
Users need continuous upward navigation from the latest response to the first
|
||||
prompt. This requirement includes sessions with more than 100 tool events.
|
||||
|
||||
## What
|
||||
|
||||
- When visible session history has no user-authored message, show the task
|
||||
description as the first user prompt.
|
||||
- Keep the task description visible while agent or environment messages load.
|
||||
- Treat a visible, stored user message as authoritative. Do not also show the
|
||||
task-description fallback in this case.
|
||||
- When the user scrolls to the oldest loaded point, load older history without
|
||||
a separate button action.
|
||||
- Continue automatic loading while older pages only extend a collapsed activity
|
||||
row.
|
||||
- Do not require another user action before a new standalone transcript entry
|
||||
appears.
|
||||
- Stop automatic loading in these cases:
|
||||
- Older content moves the load boundary above the preload region.
|
||||
- The session start is reached.
|
||||
- A request makes no progress.
|
||||
- Keep the current reading position stable as older content appears above it.
|
||||
- Keep the explicit load action as a fallback for an error or a no-progress
|
||||
response.
|
||||
- Show the first prompt at the start after all older history is loaded.
|
||||
- Apply the same transcript rule on desktop and mobile.
|
||||
- Reconcile a revisited session to one contiguous newest window before older
|
||||
pagination resumes. Stale cached rows must not create an unreachable gap.
|
||||
|
||||
## Scenarios
|
||||
|
||||
### Agent-only history
|
||||
|
||||
- **GIVEN** a task has a description and visible agent or environment messages.
|
||||
- **GIVEN** the session has no visible user message.
|
||||
- **WHEN** the transcript loads or reloads.
|
||||
- **THEN** the task description remains the first message.
|
||||
- **THEN** the other visible messages follow it.
|
||||
|
||||
### Stored user prompt
|
||||
|
||||
- **GIVEN** a task has a description and a visible stored user message.
|
||||
- **WHEN** the transcript loads.
|
||||
- **THEN** the stored user message represents the prompt.
|
||||
- **THEN** the transcript does not show a duplicate task-description fallback.
|
||||
|
||||
### Empty task description
|
||||
|
||||
- **GIVEN** a task has no description and has agent-only history.
|
||||
- **WHEN** the transcript loads.
|
||||
- **THEN** the transcript does not add an empty fallback message.
|
||||
|
||||
### Collapsed activity spans older pages
|
||||
|
||||
- **GIVEN** a transcript has more than 100 tool events in one collapsed activity
|
||||
row.
|
||||
- **GIVEN** older pages contain no standalone transcript entry.
|
||||
- **WHEN** the user scrolls to the oldest loaded point.
|
||||
- **THEN** the transcript loads additional pages without repeated button
|
||||
actions.
|
||||
- **THEN** the current reading position remains stable.
|
||||
|
||||
### First prompt after complete pagination
|
||||
|
||||
- **GIVEN** the first prompt is older than the initial message window.
|
||||
- **WHEN** the user continues to scroll upward until the session start.
|
||||
- **THEN** the first prompt is visible at the start of the transcript.
|
||||
- **THEN** no older-page control remains.
|
||||
|
||||
### Revisit after inactive-session activity
|
||||
|
||||
- **GIVEN** a session was opened and its older rows remain cached.
|
||||
- **GIVEN** more than one newest-page window is persisted while that session is
|
||||
inactive.
|
||||
- **WHEN** the user revisits the session and navigates upward.
|
||||
- **THEN** the transcript exposes every persisted user prompt in chronological
|
||||
order without a skipped middle range or duplicate row.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Backfill or repair missing message records in the database.
|
||||
- Change message persistence, API contracts, or transcript ordering.
|
||||
- Expand activity groups by default.
|
||||
- Remove the explicit older-page fallback action.
|
||||
- Load the complete transcript before the user navigates upward.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
- [Hide redundant older-message controls](../../../plans/hide-redundant-older-messages-control/plan.md)
|
||||
- [Reconcile inactive-session transcript windows](../../../plans/inactive-session-transcript-reconciliation/plan.md)
|
||||
- Backfill or repair of missing persisted message records.
|
||||
- Changes to backend message ordering or cursor contracts.
|
||||
- Loading the complete transcript before the user navigates upward.
|
||||
- Rendering internal rows that precede stored prompt `#1`.
|
||||
|
||||
@@ -8,161 +8,162 @@ owners:
|
||||
- kandev
|
||||
---
|
||||
|
||||
# Task Transcript History Visibility System Design
|
||||
# Task transcript history visibility system design
|
||||
|
||||
## Purpose and boundaries
|
||||
|
||||
This design defines the visible start of a task transcript. The first user prompt is the visible start, even when internal rows exist before it.
|
||||
This design defines how the task transcript distinguishes a bounded newest
|
||||
window from the visible conversation start. It keeps task opening bounded,
|
||||
loads older history only after upward navigation, and preserves prompt `#1` as
|
||||
the visible start even when older internal rows remain on the backend.
|
||||
|
||||
The design changes no backend query, message order, persistence rule, or API field. It uses the existing `prompt_index` message field.
|
||||
The design changes no backend query, persistence rule, message order, or API
|
||||
field. It uses existing cursor metadata and the existing `prompt_index` field.
|
||||
|
||||
## Requirement mapping
|
||||
|
||||
| Requirement | Design section |
|
||||
| --- | --- |
|
||||
| `REQ-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001` | [Opening boundary](#opening-boundary), [Latest-window reconciliation](#latest-window-reconciliation), [Pagination boundary](#pagination-boundary), [Failure and compatibility](#failure-and-compatibility) |
|
||||
| Requirement | Design sections |
|
||||
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `REQ-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001` | [History state](#history-state), [Opening boundary](#opening-boundary), [Upward pagination](#upward-pagination), [Failure and recovery](#failure-and-recovery), [Responsive behavior](#responsive-behavior) |
|
||||
|
||||
## Components and responsibilities
|
||||
|
||||
- `useLazyLoadMessages` owns the visible pagination boundary for transcript consumers.
|
||||
- The session message fetch owns one bounded initial request for the newest message window.
|
||||
- `messages.metaBySession[sessionId].hasMore` keeps the raw backend `has_more` value.
|
||||
- `messages.metaBySession[sessionId].oldestCursor` names the oldest row in the
|
||||
contiguous loaded window, never an older row separated from the newest
|
||||
snapshot by an unloaded gap.
|
||||
- `messages.bySession[sessionId]` supplies the loaded messages and their prompt ordinals.
|
||||
- The native transcript and Prompt History panel consume the shared hook result.
|
||||
- The native transcript owns the upward-navigation intent and the visible top-boundary key.
|
||||
- The session message fetch owns one bounded newest-window request and records
|
||||
when authoritative pagination metadata has initialized.
|
||||
- `messages.metaBySession[sessionId]` owns raw `hasMore`, `oldestCursor`,
|
||||
initial/refetch loading, older-page loading, and history initialization.
|
||||
- `useSessionMessages` exposes the raw history state to chat composition.
|
||||
- `useProcessedMessages` filters and groups stored messages. It may synthesize
|
||||
the task-description fallback only after the session start is known.
|
||||
- `useLazyLoadMessages` owns the prompt-`#1` visible boundary and older-page
|
||||
requests. It keeps raw pagination available to explicit recovery consumers.
|
||||
- The native transcript owns upward intent, committed sentinel geometry,
|
||||
scroll restoration, and retry-control visibility.
|
||||
- `useLazyLoadSentinel` owns observer lifecycle and request serialization.
|
||||
- Transcript navigation uses only the messages that the transcript already loaded.
|
||||
- The hook also exposes an explicit raw-pagination loader for direct recovery consumers, such as session search.
|
||||
- `requestOlderMessages` keeps request coordination and raw cursor metadata unchanged.
|
||||
|
||||
The Prompt History panel already uses prompt `#1` as its terminal boundary. The transcript uses the same boundary through the shared hook.
|
||||
## History state
|
||||
|
||||
The frontend distinguishes these facts:
|
||||
|
||||
- **History initialized:** An authoritative newest-window response or boot
|
||||
payload supplied pagination metadata for the session.
|
||||
- **Raw history remains:** The backend reported `has_more: true`.
|
||||
- **Visible start loaded:** A loaded user message has `prompt_index === 1`.
|
||||
- **History exhausted:** History is initialized and raw history does not remain.
|
||||
|
||||
Default store values are not proof that history is exhausted. The session
|
||||
message metadata therefore records initialization separately from `hasMore`.
|
||||
Session deletion purges this state with the rest of the session transcript.
|
||||
|
||||
The task-description fallback is eligible only when all of these conditions
|
||||
are true:
|
||||
|
||||
1. History is initialized.
|
||||
2. Raw history is exhausted.
|
||||
3. No visible stored user message exists.
|
||||
4. The task description is non-empty.
|
||||
|
||||
A tool-only newest window with `has_more: true` does not satisfy this contract.
|
||||
It renders only the stored rows in that window until the user navigates upward.
|
||||
|
||||
## Opening boundary
|
||||
|
||||
The session message fetch requests only the newest bounded window when a task opens. A tool-only window does not start an automatic backfill.
|
||||
The session message fetch requests only the newest bounded window when a task
|
||||
opens. It does not paginate backward to locate a prompt. A live WebSocket row
|
||||
can join that contiguous newest window through the existing reconciliation
|
||||
rules.
|
||||
|
||||
`TaskChatPanel` does not load older pages to find the last prompt. The last-prompt action becomes available after the prompt enters the loaded window.
|
||||
The latest-window reconciliation contract remains unchanged:
|
||||
|
||||
If the newest window has no user message, the transcript shows the task-description fallback. Upward navigation then loads older pages through visible pagination.
|
||||
- An overlapping fetched suffix joins the cached contiguous window.
|
||||
- A disjoint cached prefix is removed before the fetched suffix becomes the
|
||||
new authoritative window.
|
||||
- The oldest cursor always names the oldest row in the contiguous window.
|
||||
|
||||
## Latest-window reconciliation
|
||||
The absence of a user row in the newest window neither starts background
|
||||
pagination nor authorizes the task-description fallback.
|
||||
|
||||
The frontend represents each session transcript as one contiguous loaded
|
||||
window. A latest-message fetch reconciles its bounded newest suffix with the
|
||||
rows already cached for that session:
|
||||
## Visible pagination boundary
|
||||
|
||||
- If the fetched suffix overlaps the cached window, deduplicate and retain the
|
||||
complete joined window. The oldest cursor remains the oldest row in that
|
||||
joined window.
|
||||
- If the fetched suffix does not overlap the cached window, the older cached
|
||||
rows cannot be joined under one pagination cursor. Replace that disjoint
|
||||
cache with the fetched suffix. Retain only live rows that arrived after the
|
||||
fetch started and therefore follow the response snapshot.
|
||||
- Set the oldest cursor from the actual contiguous boundary. A disjoint cached
|
||||
row must never become the cursor for the newest fetched suffix.
|
||||
Transcript pagination remains available while raw history remains and loaded
|
||||
messages do not contain user prompt `#1`. Loading prompt `#1` immediately
|
||||
removes the transcript sentinel and retry control, even if raw backend history
|
||||
contains internal rows before that prompt.
|
||||
|
||||
The dropped browser cache is not data loss. Persisted messages remain
|
||||
authoritative and are loaded again through normal upward pagination. This rule
|
||||
also preserves the bounded opening behavior: revisiting a session does not
|
||||
subscribe to inactive siblings or eagerly load its complete transcript.
|
||||
Payloads without prompt ordinals use raw exhaustion as the compatibility
|
||||
boundary. Session search can continue through the explicit raw loader when a
|
||||
backend search hit precedes the visible transcript start.
|
||||
|
||||
## Pagination boundary
|
||||
## Upward pagination
|
||||
|
||||
The frontend derives a visible `hasMore` value from two inputs:
|
||||
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.
|
||||
|
||||
1. The raw backend value is true.
|
||||
2. No loaded user message has `prompt_index` equal to `1`.
|
||||
Continuation uses committed sentinel geometry, not the identity of the oldest
|
||||
standalone row:
|
||||
|
||||
If both conditions are true, transcript consumers can request another page. If prompt `#1` is loaded, the shared hook reports no visible older history.
|
||||
1. Re-evaluate the sentinel against the transcript's preload region after
|
||||
React layout and scroll restoration commit.
|
||||
2. Continue when the sentinel remains in that region and visible pagination
|
||||
still has more history.
|
||||
3. Stop when inserted content moves the sentinel outside the region. A later
|
||||
upward reach starts the next cycle through the normal observer path.
|
||||
4. Stop at prompt `#1`, raw exhaustion, a stale session, or no progress.
|
||||
|
||||
The hook applies this rule to its reactive return value. It also applies the rule after each joined or completed request.
|
||||
This rule crosses both collapsed activity pages and short standalone pages. It
|
||||
also avoids cascading through content that has already moved above the user's
|
||||
current preload region. A stale IntersectionObserver entry is not sufficient
|
||||
to continue; the decision uses geometry from the current sentinel and current
|
||||
scroll root after commit.
|
||||
|
||||
This immediate update stops one multi-page load operation at prompt `#1`. It prevents an extra request for pre-prompt internal rows.
|
||||
## Failure and recovery
|
||||
|
||||
The store keeps the raw backend value. Direct recovery code can use the hook's explicit raw-pagination loader when its contract requires the complete message stream. Session search uses this path because backend search can return a hit from before prompt `#1`. Transcript rendering and navigation continue to use visible pagination, so this recovery path does not restore the older-page control.
|
||||
A rejected request or a zero-row response with visible history still remaining
|
||||
sets a transcript-local recovery state. While that state is active, the normal
|
||||
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.
|
||||
|
||||
## Upward load cycle
|
||||
|
||||
An upward user action starts one transcript load cycle. The cycle starts when the reader reaches the oldest loaded boundary.
|
||||
|
||||
The transcript records the oldest standalone message key before each request. This key excludes the task-description fallback, transient status rows, and collapsed activity-group keys that can change when older tools join the group.
|
||||
|
||||
After React commits the prepended page, the transcript compares the current key with the recorded key:
|
||||
|
||||
- If the key changes, the page added a new visible top entry. The cycle stops until the reader reaches the new boundary.
|
||||
- If the key does not change, the page only extended the current collapsed activity group. The cycle can request the next page.
|
||||
- If the request fails, returns no rows, reaches prompt `#1`, or exhausts raw history, the cycle stops.
|
||||
|
||||
The shared sentinel must not continue from a stale intersection value. A completed request must use the committed render boundary before it starts another request.
|
||||
|
||||
Scroll restoration remains part of the same cycle. The transcript restores one stable row after each prepend. The restoration does not create new upward-navigation intent.
|
||||
|
||||
## Control flow
|
||||
|
||||
1. The initial message request stores one newest suffix and raw pagination metadata.
|
||||
2. The fetch reconciles that suffix with any cached rows as one contiguous
|
||||
window and records its true oldest cursor.
|
||||
3. The shared hook reports visible older history while prompt `#1` is absent.
|
||||
4. The user reaches the oldest loaded point.
|
||||
5. An older-page request prepends messages through the existing coordinator.
|
||||
6. The native transcript preserves a stable message-row position across the complete request.
|
||||
7. The transcript compares the committed visible top boundary with the request baseline.
|
||||
8. If the boundary changed, the current load cycle stops.
|
||||
9. If only a collapsed activity group changed, the same cycle can request another page.
|
||||
10. If the page contains prompt `#1`, the shared hook changes its visible value to false.
|
||||
11. The transcript removes its sentinel, loading state, and older-page control.
|
||||
12. Explicit reverse search uses raw pagination when it must backfill a backend search hit.
|
||||
|
||||
## Failure and compatibility
|
||||
|
||||
If a request fails before prompt `#1` is known, the explicit older-page control remains available. A zero-result response keeps the existing retry behavior.
|
||||
|
||||
If a latest fetch is disjoint from a stale cache, the UI temporarily stops
|
||||
rendering the stale prefix rather than presenting a false contiguous window.
|
||||
The older-page control remains available from the fetched suffix and reloads
|
||||
that durable history from the correct cursor.
|
||||
|
||||
An initial tool-only window completes without an older-page request. The task-description fallback remains visible until upward pagination loads a stored user prompt.
|
||||
|
||||
Older payloads can omit `prompt_index`. In this case, the shared hook uses raw `has_more` exhaustion as the compatibility fallback.
|
||||
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.
|
||||
|
||||
## Observability
|
||||
|
||||
The `messages:pagination` debug namespace records one start event and one settle event for each older-page request.
|
||||
|
||||
The events include the session ID, trigger, page count, visible boundary keys, scroll geometry, and the continuation reason. The events do not include message content.
|
||||
|
||||
The settle reason distinguishes collapsed-group continuation from visible-boundary stop, exhaustion, no progress, and request error.
|
||||
The `messages:pagination` namespace records page start and settle events
|
||||
without message content. Settle events include the session, trigger, count,
|
||||
raw and visible boundaries, committed sentinel geometry, continuation, and
|
||||
stop reason. Recovery entry and exit use distinct reasons so a real request
|
||||
failure is distinguishable from leaving the preload region.
|
||||
|
||||
## Responsive behavior
|
||||
|
||||
Desktop and mobile use the same native transcript and one vertical scroll owner. This change adds no mobile surface, control, or touch behavior.
|
||||
Desktop and mobile continue to use the existing full-height task Chat surface,
|
||||
shared store, shared hooks, and one vertical transcript scroll owner. No new
|
||||
mobile composition or navigation is introduced. The recovery button uses the
|
||||
same placement on both surfaces with a coarse-pointer touch target.
|
||||
|
||||
The existing full-height mobile task layout remains the nearest mobile exemplar. The mobile pagination scenario proves the same load-cycle boundary.
|
||||
The nearest mobile exemplar is `apps/web/components/task/task-layout.tsx` and
|
||||
the existing `mobile-message-pagination.spec.ts` flow. The mobile scenario
|
||||
must prove the same continuous loading, true-start fallback, recovery, and
|
||||
anchor behavior as desktop.
|
||||
|
||||
## Test boundaries
|
||||
|
||||
- A hook test covers raw `has_more: true` with loaded prompt `#1`.
|
||||
- A hook test covers the compatibility path when the ordinal is absent.
|
||||
- A hook test proves that a multi-page load stops when a response adds prompt `#1`.
|
||||
- Hook and drain tests prove that explicit search backfill can continue through the raw boundary.
|
||||
- Desktop and mobile browser tests seed hidden pre-prompt rows and prove that no older-page control remains.
|
||||
- Desktop and mobile browser tests prove that task opening makes no request with an older-page cursor.
|
||||
- A message-fetch test proves that a tool-only initial window completes without automatic backfill.
|
||||
- Latest-window reconciliation tests cover overlapping windows, a disjoint
|
||||
stale cache, and live rows received while the fetch is in flight.
|
||||
- A same-task sibling-session browser test caches an older receiver window,
|
||||
persists a peer prompt plus more than one page while the receiver is
|
||||
inactive, revisits it, and reaches the attributed prompt through upward
|
||||
pagination.
|
||||
- Separate desktop and mobile browser tests preserve the prepend anchor while loading older pages.
|
||||
- Desktop and mobile browser tests prove that one upward action loads only one page when that page adds visible entries.
|
||||
- A collapsed-activity browser scenario proves that the same load cycle continues until a new visible top entry appears.
|
||||
- Sentinel unit tests prove that stale intersection state cannot start a second visible-page request.
|
||||
|
||||
## Related design
|
||||
|
||||
- [Prompt History Panel](prompt-history-panel.md) defines the `prompt_index` contract and its compatibility behavior.
|
||||
- Processed-message tests cover an uninitialized window, a tool-only window
|
||||
with older history, exhausted legacy history, an empty description, and a
|
||||
loaded stored prompt.
|
||||
- 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.
|
||||
- Desktop and mobile browser tests seed more than twenty older pages between
|
||||
the newest window and the latest user prompt. They prove that no synthetic
|
||||
initial prompt or routine retry control appears and that one upward
|
||||
navigation reaches the stored prompt without a click.
|
||||
- Existing prompt-`#1`, hidden pre-prompt, and inactive-session reconciliation
|
||||
scenarios remain covered.
|
||||
|
||||
Reference in New Issue
Block a user