fix(chat): count text parts in transcript lazy loads (#3219)
This commit is contained in:
@@ -227,7 +227,7 @@ function useScrollPositionOnPrepend(
|
||||
items: RenderItem[],
|
||||
isLoadingMore: boolean,
|
||||
isProgrammaticScrollLocked: () => boolean,
|
||||
) {
|
||||
): () => void {
|
||||
const scrollState = useRef<{
|
||||
scrollHeight: number;
|
||||
scrollTop: number;
|
||||
@@ -242,6 +242,12 @@ function useScrollPositionOnPrepend(
|
||||
newestItemKeyRef.current = getNewestNonSyntheticItemKey(items);
|
||||
const prevItemCountRef = useRef(items.length);
|
||||
const prevFirstKeyRef = useRef<string | null>(getOldestNonSyntheticItemKey(items));
|
||||
const beginOlderLoad = useCallback(() => {
|
||||
if (olderLoadPendingRef.current) return;
|
||||
const el = scrollRef.current;
|
||||
if (el) scrollState.current = capturePrependScrollState(el, newestItemKeyRef.current);
|
||||
olderLoadPendingRef.current = true;
|
||||
}, [scrollRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
@@ -252,7 +258,7 @@ function useScrollPositionOnPrepend(
|
||||
// Native overflow anchoring can adjust scrollTop while the older page is
|
||||
// being inserted. Keep the pre-request baseline until our layout effect
|
||||
// has restored the visual position explicitly.
|
||||
if (isLoadingMoreRef.current) return;
|
||||
if (olderLoadPendingRef.current || isLoadingMoreRef.current) return;
|
||||
scrollState.current = capturePrependScrollState(el, newestItemKeyRef.current);
|
||||
};
|
||||
onScroll();
|
||||
@@ -272,7 +278,7 @@ function useScrollPositionOnPrepend(
|
||||
nextFirstKey,
|
||||
});
|
||||
const olderLoadSettled = olderLoadPendingRef.current && !isLoadingMore;
|
||||
const prepend = olderLoadSettled || (!olderLoadPendingRef.current && identityPrepend);
|
||||
const prepend = olderLoadSettled || identityPrepend;
|
||||
prevItemCountRef.current = items.length;
|
||||
prevFirstKeyRef.current = nextFirstKey;
|
||||
if (olderLoadSettled) olderLoadPendingRef.current = false;
|
||||
@@ -287,6 +293,8 @@ function useScrollPositionOnPrepend(
|
||||
}
|
||||
scrollState.current = capturePrependScrollState(el, newestItemKeyRef.current);
|
||||
}, [items, scrollRef, isLoadingMore, isProgrammaticScrollLocked]);
|
||||
|
||||
return beginOlderLoad;
|
||||
}
|
||||
|
||||
function getOldestNonSyntheticItemKey(items: RenderItem[]): string | null {
|
||||
@@ -972,7 +980,16 @@ export function useNativeScrollManagement(params: {
|
||||
resyncIsNearBottom,
|
||||
);
|
||||
const handleScrollToMessage = useScrollToMessage(scrollRef, runGuardedScroll);
|
||||
useScrollPositionOnPrepend(scrollRef, items, isLoadingMore, isProgrammaticScrollLocked);
|
||||
const beginOlderLoad = useScrollPositionOnPrepend(
|
||||
scrollRef,
|
||||
items,
|
||||
isLoadingMore,
|
||||
isProgrammaticScrollLocked,
|
||||
);
|
||||
const loadMoreWithPrependBaseline = useCallback(() => {
|
||||
beginOlderLoad();
|
||||
return loadMore();
|
||||
}, [beginOlderLoad, loadMore]);
|
||||
const {
|
||||
sentinelRef,
|
||||
onUserGesture,
|
||||
@@ -985,7 +1002,7 @@ export function useNativeScrollManagement(params: {
|
||||
hasMore,
|
||||
blocked: messagesLoading,
|
||||
isLoadingMore,
|
||||
loadMore,
|
||||
loadMore: loadMoreWithPrependBaseline,
|
||||
});
|
||||
useRetryPaginationOnUpwardScroll(scrollRef, onUserGesture, isProgrammaticScrollLocked);
|
||||
useInitialScrollPosition(scrollRef, items.length, sessionId, enabled, isNearBottomRef);
|
||||
|
||||
@@ -51,6 +51,7 @@ const MISSING_SCROLL_CONTAINER_ERROR = "scroll container did not render";
|
||||
const TARGET_MESSAGE_ID = "target";
|
||||
const HANDLE_RENDER_ERROR = "handle did not render";
|
||||
const HARNESS_RENDER_ERROR = "harness did not render";
|
||||
const NATIVE_SCROLL_MANAGEMENT_TEST_ID = "native-scroll-management-container";
|
||||
const TEST_MESSAGES = [{} as Message];
|
||||
/** Always returns false: the harness never locks programmatic scrolling. */
|
||||
const NEVER_LOCKED = () => false;
|
||||
@@ -195,12 +196,14 @@ function NativeScrollManagementHarness({
|
||||
metrics,
|
||||
loadMore = async () => 0,
|
||||
sessionId = null,
|
||||
isLoadingMore = false,
|
||||
recoveryRef,
|
||||
}: {
|
||||
items: RenderItem[];
|
||||
metrics?: NativeScrollMetrics;
|
||||
loadMore?: () => Promise<number>;
|
||||
sessionId?: string | null;
|
||||
isLoadingMore?: boolean;
|
||||
recoveryRef?: { current: boolean };
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
@@ -214,13 +217,13 @@ function NativeScrollManagementHarness({
|
||||
hasUnreadDivider: false,
|
||||
messagesLoading: false,
|
||||
hasMore: true,
|
||||
isLoadingMore: false,
|
||||
isLoadingMore,
|
||||
loadMore,
|
||||
});
|
||||
if (recoveryRef) recoveryRef.current = showRecovery;
|
||||
useNativeScrollMetrics(scrollRef, metrics);
|
||||
return (
|
||||
<div data-testid="native-scroll-management-container" ref={scrollRef}>
|
||||
<div data-testid={NATIVE_SCROLL_MANAGEMENT_TEST_ID} ref={scrollRef}>
|
||||
<div ref={sentinelRef} />
|
||||
</div>
|
||||
);
|
||||
@@ -268,7 +271,7 @@ describe("useNativeScrollManagement transcript pagination", () => {
|
||||
it("retries a disarmed short page on the next upward scroll", () => {
|
||||
const metrics = { scrollHeight: 1000, scrollTop: 100, clientHeight: 400 };
|
||||
render(<NativeScrollManagementHarness items={[]} metrics={metrics} />);
|
||||
const scroller = screen.getByTestId("native-scroll-management-container");
|
||||
const scroller = screen.getByTestId(NATIVE_SCROLL_MANAGEMENT_TEST_ID);
|
||||
|
||||
act(() => {
|
||||
metrics.scrollTop = 80;
|
||||
@@ -420,7 +423,7 @@ describe("useNativeScrollManagement transcript pagination", () => {
|
||||
|
||||
act(() => {
|
||||
metrics.scrollTop = 40;
|
||||
screen.getByTestId("native-scroll-management-container").dispatchEvent(new Event("scroll"));
|
||||
screen.getByTestId(NATIVE_SCROLL_MANAGEMENT_TEST_ID).dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
metrics.scrollHeight = 200;
|
||||
rerender(
|
||||
@@ -443,7 +446,7 @@ describe("useNativeScrollManagement transcript pagination", () => {
|
||||
|
||||
act(() => {
|
||||
metrics.scrollTop = 40;
|
||||
screen.getByTestId("native-scroll-management-container").dispatchEvent(new Event("scroll"));
|
||||
screen.getByTestId(NATIVE_SCROLL_MANAGEMENT_TEST_ID).dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
metrics.scrollHeight = 200;
|
||||
rerender(
|
||||
@@ -455,6 +458,71 @@ describe("useNativeScrollManagement transcript pagination", () => {
|
||||
|
||||
expect(metrics.scrollTop).toBe(140);
|
||||
});
|
||||
|
||||
it("anchors each committed prepend while an accumulated load remains active", () => {
|
||||
const metrics = { scrollHeight: 100, scrollTop: 40, clientHeight: 50 };
|
||||
const activity = transcriptActivity("activity-old", "turn-1");
|
||||
const newest = transcriptMessage("newest");
|
||||
const { rerender } = render(
|
||||
<NativeScrollManagementHarness items={[activity, newest]} metrics={metrics} />,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
metrics.scrollTop = 40;
|
||||
screen.getByTestId(NATIVE_SCROLL_MANAGEMENT_TEST_ID).dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
rerender(
|
||||
<NativeScrollManagementHarness items={[activity, newest]} metrics={metrics} isLoadingMore />,
|
||||
);
|
||||
metrics.scrollHeight = 200;
|
||||
rerender(
|
||||
<NativeScrollManagementHarness
|
||||
items={[transcriptActivity("activity-new", "turn-1"), newest]}
|
||||
metrics={metrics}
|
||||
isLoadingMore
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(metrics.scrollTop).toBe(140);
|
||||
});
|
||||
|
||||
it("freezes the prepend baseline synchronously when an older load starts", async () => {
|
||||
const page = Promise.withResolvers<number>();
|
||||
const metrics = { scrollHeight: 100, scrollTop: 40, clientHeight: 50 };
|
||||
const activity = transcriptActivity("activity-old", "turn-1");
|
||||
const newest = transcriptMessage("newest");
|
||||
const { rerender } = render(
|
||||
<NativeScrollManagementHarness
|
||||
items={[activity, newest]}
|
||||
metrics={metrics}
|
||||
loadMore={() => page.promise}
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
metrics.scrollTop = 40;
|
||||
screen.getByTestId(NATIVE_SCROLL_MANAGEMENT_TEST_ID).dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
const wrappedLoadMore = sharedSentinelCalls.at(-1)?.[4] as () => Promise<number>;
|
||||
const pendingLoad = wrappedLoadMore();
|
||||
act(() => {
|
||||
metrics.scrollTop = 80;
|
||||
screen.getByTestId(NATIVE_SCROLL_MANAGEMENT_TEST_ID).dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
metrics.scrollHeight = 200;
|
||||
rerender(
|
||||
<NativeScrollManagementHarness
|
||||
items={[transcriptActivity("activity-new", "turn-1"), newest]}
|
||||
metrics={metrics}
|
||||
loadMore={() => page.promise}
|
||||
isLoadingMore
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(metrics.scrollTop).toBe(140);
|
||||
page.resolve(20);
|
||||
await pendingLoad;
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function -- this suite keeps the related scroll invariants together.
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useRef, memo, forwardRef, useImperativeHandle } fro
|
||||
import { SessionPanelContent } from "@kandev/ui/pannel-session";
|
||||
import type { Message, TaskSessionState } from "@/lib/types/http";
|
||||
import type { RenderItem } from "@/hooks/use-processed-messages";
|
||||
import { useLazyLoadMessages } from "@/hooks/use-lazy-load-messages";
|
||||
import { OLDER_PAGE_LIMIT, useLazyLoadMessages } from "@/hooks/use-lazy-load-messages";
|
||||
import { useSessionTurn } from "@/hooks/domains/session/use-session-turn";
|
||||
import { MessageListFooter } from "./message-list-footer";
|
||||
import { useNativeScrollManagement } from "./message-list-native-scroll";
|
||||
@@ -482,7 +482,9 @@ export const NativeMessageList = memo(
|
||||
isWorking,
|
||||
sessionState,
|
||||
});
|
||||
const { loadMore, hasMore, isLoadingMore } = useLazyLoadMessages(sessionId);
|
||||
const { loadMore, hasMore, isLoadingMore } = useLazyLoadMessages(sessionId, {
|
||||
minTextPartsPerLoad: OLDER_PAGE_LIMIT,
|
||||
});
|
||||
const { activeTurnId } = useSessionTurn(sessionId);
|
||||
const effectiveActiveTurnId = getEffectiveActiveTurnId(activeTurnId, isWorking);
|
||||
const streamingMessageId = getStreamingAgentMessageId(messages);
|
||||
|
||||
@@ -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 TEXT_BATCH_MARKER = "TEXT-BATCH-MARKER-1F9L";
|
||||
export const TEXT_BATCH_ANCHOR_MARKER = "TEXT-BATCH-ANCHOR-MARKER-4C7N";
|
||||
export const DEEP_PROMPT_MARKER = "DEEP-PROMPT-MARKER-2P7N";
|
||||
export const LONG_HISTORY_TAIL_MARKER = "LONG-HISTORY-TAIL-MARKER-6V4R";
|
||||
|
||||
@@ -215,6 +217,53 @@ export async function seedShortBoundaryPageHistory(
|
||||
return { taskId: task.id, sessionId };
|
||||
}
|
||||
|
||||
/** Seeds one older page of standalone tool rows between the newest window and
|
||||
* twenty older text rows. One upward reach should cross the tool-only page and
|
||||
* expose the text batch without requiring another gesture. */
|
||||
export async function seedTextSparseMessageHistory(
|
||||
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: TEXT_BATCH_MARKER,
|
||||
});
|
||||
await apiClient.seedAgentMessages(sessionId, 19, "TEXT-BATCH-OLDER-FILLER");
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
await apiClient.seedSessionMessage(sessionId, {
|
||||
type: "tool_call",
|
||||
content: `standalone completed tool ${index + 1}`,
|
||||
metadata: { status: "complete" },
|
||||
newTurn: true,
|
||||
});
|
||||
}
|
||||
await apiClient.seedSessionMessage(sessionId, {
|
||||
type: "message",
|
||||
content: TEXT_BATCH_ANCHOR_MARKER,
|
||||
newTurn: true,
|
||||
});
|
||||
await apiClient.seedAgentMessages(sessionId, 99, "TEXT-BATCH-VISIBLE-TAIL");
|
||||
|
||||
return { taskId: task.id, sessionId };
|
||||
}
|
||||
|
||||
/** Reads a rendered standalone message's viewport position from the list. */
|
||||
export async function readStandaloneMessageTop(list: Locator, marker: string): Promise<number> {
|
||||
return list.evaluate((element, messageMarker) => {
|
||||
@@ -254,13 +303,3 @@ export async function scrollToOldestLoadedEdge(
|
||||
};
|
||||
}, marker);
|
||||
}
|
||||
|
||||
/** Applies a small upward movement after prepend restoration. */
|
||||
export async function scrollUpSlightly(list: Locator): Promise<number> {
|
||||
return list.evaluate((element) => {
|
||||
const previous = element.scrollTop;
|
||||
element.scrollTop = Math.max(0, previous - 24);
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
return previous - element.scrollTop;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
RECENT_AGENT_MARKER,
|
||||
SHORT_PAGE_BOUNDARY_MARKER,
|
||||
TASK_DESCRIPTION_MARKER,
|
||||
TEXT_BATCH_ANCHOR_MARKER,
|
||||
TEXT_BATCH_MARKER,
|
||||
VISIBLE_PAGE_MARKER,
|
||||
LONG_HISTORY_TAIL_MARKER,
|
||||
readMessageRowTopById,
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
seedCollapsedMessageHistory,
|
||||
seedLongMessageHistory,
|
||||
seedShortBoundaryPageHistory,
|
||||
seedTextSparseMessageHistory,
|
||||
seedToolHeavyOpeningHistory,
|
||||
seedVisibleMessageHistory,
|
||||
scrollToOldestLoadedEdge,
|
||||
@@ -75,6 +78,9 @@ test.describe("@chat message pagination", () => {
|
||||
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);
|
||||
// Isolate prepend stability from the browser settling the synthetic edge scroll.
|
||||
await expect.poll(() => olderRequests.length).toBeGreaterThan(0);
|
||||
const anchoredTop = await readStandaloneMessageTop(list, LONG_HISTORY_TAIL_MARKER);
|
||||
|
||||
await expect
|
||||
.poll(() => olderRequests.length, {
|
||||
@@ -88,7 +94,7 @@ test.describe("@chat message pagination", () => {
|
||||
});
|
||||
|
||||
const afterLoadTop = await readStandaloneMessageTop(list, LONG_HISTORY_TAIL_MARKER);
|
||||
expect(Math.abs(afterLoadTop - edge.rowTop)).toBeLessThanOrEqual(8);
|
||||
expect(Math.abs(afterLoadTop - anchoredTop)).toBeLessThanOrEqual(8);
|
||||
await expect(chat.getByText(TASK_DESCRIPTION_MARKER, { exact: true })).toHaveCount(0);
|
||||
await expect(chat.getByTestId("load-older-messages")).toHaveCount(0);
|
||||
});
|
||||
@@ -159,6 +165,37 @@ test.describe("@chat message pagination", () => {
|
||||
expect(olderRequests).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("counts text parts instead of tool activity per upward reach", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const { taskId, sessionId } = await seedTextSparseMessageHistory(
|
||||
apiClient,
|
||||
seedData,
|
||||
"message-pagination-counts-text-parts",
|
||||
);
|
||||
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(TEXT_BATCH_MARKER, { exact: true })).toHaveCount(0);
|
||||
const edge = await scrollToOldestLoadedEdge(list, TEXT_BATCH_ANCHOR_MARKER);
|
||||
expect(Number.isFinite(edge.rowTop)).toBe(true);
|
||||
|
||||
await expect.poll(() => olderRequests.length).toBe(2);
|
||||
await expect(chat.getByText(TEXT_BATCH_MARKER, { exact: true })).toBeVisible();
|
||||
const afterLoadTop = await readStandaloneMessageTop(list, TEXT_BATCH_ANCHOR_MARKER);
|
||||
expect(Math.abs(afterLoadTop - edge.rowTop)).toBeLessThanOrEqual(8);
|
||||
await expect(chat.getByTestId("load-older-messages")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("hides the older control when only hidden pre-prompt rows remain", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
RECENT_AGENT_MARKER,
|
||||
SHORT_PAGE_BOUNDARY_MARKER,
|
||||
TASK_DESCRIPTION_MARKER,
|
||||
TEXT_BATCH_ANCHOR_MARKER,
|
||||
TEXT_BATCH_MARKER,
|
||||
VISIBLE_PAGE_MARKER,
|
||||
LONG_HISTORY_TAIL_MARKER,
|
||||
readMessageRowTopById,
|
||||
@@ -16,6 +18,7 @@ import {
|
||||
seedCollapsedMessageHistory,
|
||||
seedLongMessageHistory,
|
||||
seedShortBoundaryPageHistory,
|
||||
seedTextSparseMessageHistory,
|
||||
seedToolHeavyOpeningHistory,
|
||||
seedVisibleMessageHistory,
|
||||
scrollToOldestLoadedEdge,
|
||||
@@ -71,6 +74,9 @@ test.describe("Mobile chat message pagination", () => {
|
||||
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);
|
||||
// Isolate prepend stability from the browser settling the synthetic edge scroll.
|
||||
await expect.poll(() => olderRequests.length).toBeGreaterThan(0);
|
||||
const anchoredTop = await readStandaloneMessageTop(list, LONG_HISTORY_TAIL_MARKER);
|
||||
|
||||
await expect
|
||||
.poll(() => olderRequests.length, {
|
||||
@@ -84,7 +90,7 @@ test.describe("Mobile chat message pagination", () => {
|
||||
});
|
||||
|
||||
const afterLoadTop = await readStandaloneMessageTop(list, LONG_HISTORY_TAIL_MARKER);
|
||||
expect(Math.abs(afterLoadTop - edge.rowTop)).toBeLessThanOrEqual(8);
|
||||
expect(Math.abs(afterLoadTop - anchoredTop)).toBeLessThanOrEqual(8);
|
||||
await expect(chat.getByText(TASK_DESCRIPTION_MARKER, { exact: true })).toHaveCount(0);
|
||||
await expect(chat.getByTestId("load-older-messages")).toHaveCount(0);
|
||||
});
|
||||
@@ -152,6 +158,36 @@ test.describe("Mobile chat message pagination", () => {
|
||||
expect(olderRequests).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("counts text parts instead of tool activity per upward reach", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const { taskId, sessionId } = await seedTextSparseMessageHistory(
|
||||
apiClient,
|
||||
seedData,
|
||||
"mobile-message-pagination-counts-text-parts",
|
||||
);
|
||||
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(TEXT_BATCH_MARKER, { exact: true })).toHaveCount(0);
|
||||
const edge = await scrollToOldestLoadedEdge(list, TEXT_BATCH_ANCHOR_MARKER);
|
||||
expect(Number.isFinite(edge.rowTop)).toBe(true);
|
||||
|
||||
await expect.poll(() => olderRequests.length).toBe(2);
|
||||
await expect(chat.getByText(TEXT_BATCH_MARKER, { exact: true })).toBeVisible();
|
||||
const afterLoadTop = await readStandaloneMessageTop(list, TEXT_BATCH_ANCHOR_MARKER);
|
||||
expect(Math.abs(afterLoadTop - edge.rowTop)).toBeLessThanOrEqual(8);
|
||||
await expect(chat.getByTestId("load-older-messages")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("hides the older control when only hidden pre-prompt rows remain", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import type { useAppStoreApi } from "@/components/state-provider";
|
||||
import { listTaskSessionMessages } from "@/lib/api";
|
||||
import { createDebugLogger } from "@/lib/debug/log";
|
||||
import type { Message } from "@/lib/types/http";
|
||||
|
||||
const debug = createDebugLogger("messages:pagination");
|
||||
|
||||
export type OlderPageResult = {
|
||||
/** Messages actually prepended by this page. */
|
||||
/** Rows returned by this page. */
|
||||
count: number;
|
||||
/** Rows actually prepended after deduplication and session-lifecycle checks. */
|
||||
prependedMessages: Message[];
|
||||
/** The page size this request used (first-request-wins per (session, cursor)). */
|
||||
effectiveLimit: number;
|
||||
/** Post-merge pagination metadata. */
|
||||
@@ -123,11 +126,22 @@ async function performRequest(
|
||||
// unconditional prepend would resurrect orphaned conversation state. A
|
||||
// request can only legitimately be in flight when the session's message
|
||||
// state existed at start, so its absence now means it was purged.
|
||||
if (store.getState().messages?.bySession?.[sessionId] !== undefined) {
|
||||
const existingMessages = store.getState().messages?.bySession?.[sessionId];
|
||||
const existingIds = new Set(existingMessages?.map((message) => message.id));
|
||||
const prependedMessages = existingMessages
|
||||
? ordered.filter((message) => !existingIds.has(message.id))
|
||||
: [];
|
||||
if (existingMessages !== undefined) {
|
||||
store.getState().prependMessages(sessionId, ordered, {
|
||||
hasMore,
|
||||
oldestCursor: newOldestCursor,
|
||||
});
|
||||
}
|
||||
return { count: ordered.length, effectiveLimit: limit, hasMore, oldestCursor: newOldestCursor };
|
||||
return {
|
||||
count: ordered.length,
|
||||
prependedMessages,
|
||||
effectiveLimit: limit,
|
||||
hasMore,
|
||||
oldestCursor: newOldestCursor,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Message } from "@/lib/types/http";
|
||||
const listTaskSessionMessages = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/lib/api", () => ({ listTaskSessionMessages }));
|
||||
|
||||
@@ -11,7 +12,12 @@ const storeMock = vi.hoisted(() => ({
|
||||
isLoadingMore: false,
|
||||
},
|
||||
prepended: [] as Array<{ id: string }>,
|
||||
bySession: [] as Array<{ id: string; author_type?: string; prompt_index?: number }>,
|
||||
bySession: [] as Array<{
|
||||
id: string;
|
||||
author_type?: string;
|
||||
prompt_index?: number;
|
||||
type?: Message["type"];
|
||||
}>,
|
||||
setMessagesMetadata: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -60,7 +66,12 @@ function wireResponse(ids: string[], hasMore: boolean) {
|
||||
}
|
||||
|
||||
function wireTypedResponse(
|
||||
entries: Array<{ id: string; author_type?: string; prompt_index?: number }>,
|
||||
entries: Array<{
|
||||
id: string;
|
||||
author_type?: string;
|
||||
prompt_index?: number;
|
||||
type?: Message["type"];
|
||||
}>,
|
||||
hasMore: boolean,
|
||||
) {
|
||||
return {
|
||||
@@ -68,6 +79,7 @@ function wireTypedResponse(
|
||||
id: entry.id,
|
||||
author_type: entry.author_type ?? "agent",
|
||||
prompt_index: entry.prompt_index,
|
||||
type: entry.type,
|
||||
created_at: "2026-01-01T00:00:00.000000Z",
|
||||
})),
|
||||
has_more: hasMore,
|
||||
@@ -286,3 +298,169 @@ describe("useLazyLoadMessages minUserPromptsPerLoad", () => {
|
||||
expect(listTaskSessionMessages).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useLazyLoadMessages minTextPartsPerLoad", () => {
|
||||
// @covers AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.13
|
||||
it("continues past a raw page whose tool activity leaves the text target unmet", async () => {
|
||||
const toolParts = Array.from({ length: 19 }, (_, index) => ({
|
||||
id: `tool-${index}`,
|
||||
type: "tool_call" as const,
|
||||
}));
|
||||
listTaskSessionMessages
|
||||
.mockResolvedValueOnce(
|
||||
wireTypedResponse([...toolParts, { id: "text-1", type: "message" }], true),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
wireTypedResponse(
|
||||
Array.from({ length: 19 }, (_, index) => ({
|
||||
id: `text-${index + 2}`,
|
||||
type: "content" as const,
|
||||
})),
|
||||
false,
|
||||
),
|
||||
);
|
||||
const { result } = renderHook(() => useLazyLoadMessages("s1", { minTextPartsPerLoad: 20 }));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadMore();
|
||||
});
|
||||
|
||||
expect(listTaskSessionMessages).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not count live appended text toward the older-text target", async () => {
|
||||
const firstPage = Promise.withResolvers<unknown>();
|
||||
listTaskSessionMessages
|
||||
.mockReturnValueOnce(firstPage.promise)
|
||||
.mockResolvedValueOnce(
|
||||
wireTypedResponse([{ id: "twentieth-older-text", type: "message" }], false),
|
||||
);
|
||||
const { result } = renderHook(() => useLazyLoadMessages("s1", { minTextPartsPerLoad: 20 }));
|
||||
|
||||
const loadPromise = result.current.loadMore();
|
||||
storeMock.bySession = [{ id: "live-text", type: "message" }];
|
||||
await act(async () => {
|
||||
firstPage.resolve(
|
||||
wireTypedResponse(
|
||||
Array.from({ length: 19 }, (_, index) => ({
|
||||
id: `older-text-${index}`,
|
||||
type: "message" as const,
|
||||
})),
|
||||
true,
|
||||
),
|
||||
);
|
||||
await loadPromise;
|
||||
});
|
||||
|
||||
expect(listTaskSessionMessages).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("counts message, content, and legacy rows but excludes other activity", async () => {
|
||||
listTaskSessionMessages
|
||||
.mockResolvedValueOnce(
|
||||
wireTypedResponse(
|
||||
[
|
||||
{ id: "message", type: "message" },
|
||||
{ id: "content", type: "content" },
|
||||
{ id: "legacy" },
|
||||
{ id: "thinking", type: "thinking" },
|
||||
],
|
||||
true,
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(wireTypedResponse([{ id: "fourth-text", type: "message" }], false));
|
||||
const { result } = renderHook(() => useLazyLoadMessages("s1", { minTextPartsPerLoad: 4 }));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadMore();
|
||||
});
|
||||
|
||||
expect(listTaskSessionMessages).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps loading true across every page in an accumulated batch", async () => {
|
||||
const firstPage = Promise.withResolvers<unknown>();
|
||||
const secondPage = Promise.withResolvers<unknown>();
|
||||
listTaskSessionMessages
|
||||
.mockReturnValueOnce(firstPage.promise)
|
||||
.mockReturnValueOnce(secondPage.promise);
|
||||
const { result } = renderHook(() => useLazyLoadMessages("s1", { minTextPartsPerLoad: 2 }));
|
||||
|
||||
let loadPromise!: Promise<number>;
|
||||
act(() => {
|
||||
loadPromise = result.current.loadMore();
|
||||
});
|
||||
expect(result.current.isLoadingMore).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
firstPage.resolve(wireTypedResponse([{ id: "first", type: "message" }], true));
|
||||
await vi.waitFor(() => expect(listTaskSessionMessages).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
expect(result.current.isLoadingMore).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
secondPage.resolve(wireTypedResponse([{ id: "second", type: "message" }], false));
|
||||
await loadPromise;
|
||||
});
|
||||
expect(result.current.isLoadingMore).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useLazyLoadMessages minTextPartsPerLoad stops", () => {
|
||||
it("stops at the first prompt when the text target remains unmet", async () => {
|
||||
listTaskSessionMessages
|
||||
.mockResolvedValueOnce(
|
||||
wireTypedResponse(
|
||||
[{ id: "first-prompt", author_type: "user", prompt_index: 1, type: "message" }],
|
||||
true,
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(wireTypedResponse([{ id: "hidden", type: "message" }], false));
|
||||
const { result } = renderHook(() => useLazyLoadMessages("s1", { minTextPartsPerLoad: 20 }));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadMore();
|
||||
});
|
||||
|
||||
expect(listTaskSessionMessages).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stops when raw history is exhausted before the text target", async () => {
|
||||
listTaskSessionMessages.mockResolvedValueOnce(
|
||||
wireTypedResponse([{ id: "only-text", type: "message" }], false),
|
||||
);
|
||||
const { result } = renderHook(() => useLazyLoadMessages("s1", { minTextPartsPerLoad: 20 }));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadMore();
|
||||
});
|
||||
|
||||
expect(listTaskSessionMessages).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stops after a zero-result page before the text target", async () => {
|
||||
listTaskSessionMessages.mockResolvedValueOnce(wireTypedResponse([], true));
|
||||
const { result } = renderHook(() => useLazyLoadMessages("s1", { minTextPartsPerLoad: 20 }));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadMore();
|
||||
});
|
||||
|
||||
expect(listTaskSessionMessages).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stops at the page safety bound when activity never advances the target", async () => {
|
||||
for (let index = 0; index < 11; index += 1) {
|
||||
listTaskSessionMessages.mockResolvedValueOnce(
|
||||
wireTypedResponse([{ id: `tool-${index}`, type: "tool_call" }], true),
|
||||
);
|
||||
}
|
||||
const { result } = renderHook(() => useLazyLoadMessages("s1", { minTextPartsPerLoad: 20 }));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadMore();
|
||||
});
|
||||
|
||||
expect(listTaskSessionMessages).toHaveBeenCalledTimes(10);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
|
||||
import { createDebugLogger } from "@/lib/debug/log";
|
||||
import {
|
||||
@@ -11,9 +11,8 @@ const debug = createDebugLogger("messages:lazyload");
|
||||
|
||||
export const OLDER_PAGE_LIMIT = 20;
|
||||
|
||||
/** Safety cap on message pages fetched inside a single loadMore call when
|
||||
* `minUserPromptsPerLoad` is set, so a pathological session cannot loop
|
||||
* forever. */
|
||||
/** Safety cap on message pages fetched inside a single accumulating loadMore
|
||||
* call, so a pathological session cannot loop forever. */
|
||||
const MAX_PAGES_PER_LOAD = 10;
|
||||
const EMPTY_MESSAGES: Message[] = [];
|
||||
|
||||
@@ -22,9 +21,12 @@ export type LazyLoadMessagesOptions = {
|
||||
* returning. A fixed message page can contain only a few user prompts among
|
||||
* agent replies, so the panel requests pages until the threshold is met,
|
||||
* pagination is exhausted, a zero-result page stops progress, or
|
||||
* MAX_PAGES_PER_LOAD is reached. Default: single page (transcript behavior
|
||||
* unchanged). */
|
||||
* MAX_PAGES_PER_LOAD is reached. Default: single page. */
|
||||
minUserPromptsPerLoad?: number;
|
||||
/** Accumulate at least this many NEW text parts per loadMore call. Text parts
|
||||
* are message/content rows (plus legacy untyped rows); tool and activity rows
|
||||
* remain loaded but do not advance this target. */
|
||||
minTextPartsPerLoad?: number;
|
||||
};
|
||||
|
||||
function describeSkip(args: {
|
||||
@@ -50,6 +52,53 @@ function countUserPrompts(messages: Message[]): number {
|
||||
return messages.filter((message) => message.author_type === "user").length;
|
||||
}
|
||||
|
||||
function countTextParts(messages: Message[]): number {
|
||||
return messages.filter(
|
||||
(message) => !message.type || message.type === "message" || message.type === "content",
|
||||
).length;
|
||||
}
|
||||
|
||||
function loadTargetsReached(args: {
|
||||
loadedPrompts: number;
|
||||
loadedTextParts: number;
|
||||
minUserPromptsPerLoad?: number;
|
||||
minTextPartsPerLoad?: number;
|
||||
}): boolean {
|
||||
const { loadedPrompts, loadedTextParts, minUserPromptsPerLoad, minTextPartsPerLoad } = args;
|
||||
return (
|
||||
(!minUserPromptsPerLoad || loadedPrompts >= minUserPromptsPerLoad) &&
|
||||
(!minTextPartsPerLoad || loadedTextParts >= minTextPartsPerLoad)
|
||||
);
|
||||
}
|
||||
|
||||
async function loadPagesToTargets(args: {
|
||||
fetchPage: () => Promise<{ count: number; prependedMessages: Message[] }>;
|
||||
minUserPromptsPerLoad?: number;
|
||||
minTextPartsPerLoad?: number;
|
||||
}): Promise<number> {
|
||||
const { fetchPage, minUserPromptsPerLoad, minTextPartsPerLoad } = args;
|
||||
let total = 0;
|
||||
let loadedPrompts = 0;
|
||||
let loadedTextParts = 0;
|
||||
for (let page = 0; page < MAX_PAGES_PER_LOAD; page++) {
|
||||
const result = await fetchPage();
|
||||
total += result.count;
|
||||
loadedPrompts += countUserPrompts(result.prependedMessages);
|
||||
loadedTextParts += countTextParts(result.prependedMessages);
|
||||
if (result.count === 0) break;
|
||||
if (
|
||||
loadTargetsReached({
|
||||
loadedPrompts,
|
||||
loadedTextParts,
|
||||
minUserPromptsPerLoad,
|
||||
minTextPartsPerLoad,
|
||||
})
|
||||
)
|
||||
break;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function useVisibleHasMore(sessionId: string | null, rawHasMore: boolean): boolean {
|
||||
const firstPromptLoaded = useAppStore((state) =>
|
||||
sessionId ? containsFirstPrompt(state.messages.bySession[sessionId] ?? EMPTY_MESSAGES) : false,
|
||||
@@ -57,36 +106,22 @@ function useVisibleHasMore(sessionId: string | null, rawHasMore: boolean): boole
|
||||
return visibleHasMore(rawHasMore, firstPromptLoaded);
|
||||
}
|
||||
|
||||
export function useLazyLoadMessages(sessionId: string | null, options?: LazyLoadMessagesOptions) {
|
||||
const { minUserPromptsPerLoad } = options ?? {};
|
||||
// Use refs for values that should not trigger callback recreation
|
||||
const rawHasMore = useAppStore((state) =>
|
||||
sessionId ? (state.messages.metaBySession[sessionId]?.hasMore ?? false) : false,
|
||||
);
|
||||
const hasMore = useVisibleHasMore(sessionId, rawHasMore);
|
||||
const oldestCursor = useAppStore((state) =>
|
||||
sessionId ? (state.messages.metaBySession[sessionId]?.oldestCursor ?? null) : null,
|
||||
);
|
||||
const isLoadingMore = useAppStore((state) =>
|
||||
sessionId ? (state.messages.metaBySession[sessionId]?.isLoadingMore ?? false) : false,
|
||||
);
|
||||
|
||||
// Store current values in refs to avoid recreating loadMore on every state change
|
||||
const stateRef = useRef({ hasMore, rawHasMore, oldestCursor, isLoadingMore });
|
||||
useEffect(() => {
|
||||
stateRef.current = { hasMore, rawHasMore, oldestCursor, isLoadingMore };
|
||||
}, [hasMore, rawHasMore, oldestCursor, isLoadingMore]);
|
||||
type LazyLoadState = {
|
||||
hasMore: boolean;
|
||||
rawHasMore: boolean;
|
||||
oldestCursor: string | null;
|
||||
isLoadingMore: boolean;
|
||||
};
|
||||
|
||||
/** Builds the coordinated single-page loader shared by visible and raw consumers. */
|
||||
function useOlderPageFetcher(sessionId: string | null, stateRef: { current: LazyLoadState }) {
|
||||
const store = useAppStoreApi();
|
||||
|
||||
// Visible pagination stops at prompt #1; raw pagination is for explicit
|
||||
// recovery such as session search. Both paths share the coordinator.
|
||||
const fetchPage = useCallback(
|
||||
async (mode: "visible" | "raw" = "visible") => {
|
||||
const { hasMore, rawHasMore, isLoadingMore, oldestCursor } = stateRef.current;
|
||||
const canLoad = mode === "raw" ? rawHasMore : hasMore;
|
||||
|
||||
if (!sessionId || !oldestCursor) return 0;
|
||||
if (!sessionId || !oldestCursor) return { count: 0, prependedMessages: EMPTY_MESSAGES };
|
||||
|
||||
// Check the per-session in-flight map BEFORE the local loading guards: a
|
||||
// panel callback during transcript loading or automatic backfill joins
|
||||
@@ -103,18 +138,16 @@ export function useLazyLoadMessages(sessionId: string | null, options?: LazyLoad
|
||||
oldestCursor: result.oldestCursor,
|
||||
// The coordinator clears the store's shared isLoadingMore when the
|
||||
// session's LAST flight settles (this join may be one of several).
|
||||
// Reflect the settled store value so a follow-up page in the
|
||||
// minUserPromptsPerLoad loop is not blocked by a stale in-flight
|
||||
// flag, while still blocking fresh requests when other flights
|
||||
// remain in flight.
|
||||
// Reflect the settled store value so a follow-up page in an
|
||||
// accumulation loop is not blocked by a stale in-flight flag.
|
||||
isLoadingMore:
|
||||
store.getState().messages.metaBySession[sessionId]?.isLoadingMore ??
|
||||
stateRef.current.isLoadingMore,
|
||||
};
|
||||
return result.count;
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("[useLazyLoadMessages] Error loading messages:", error);
|
||||
return 0;
|
||||
return { count: 0, prependedMessages: EMPTY_MESSAGES };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +158,7 @@ export function useLazyLoadMessages(sessionId: string | null, options?: LazyLoad
|
||||
hasMore: canLoad,
|
||||
oldestCursor,
|
||||
});
|
||||
return 0;
|
||||
return { count: 0, prependedMessages: EMPTY_MESSAGES };
|
||||
}
|
||||
|
||||
debug("loadMore: requesting older page", {
|
||||
@@ -134,7 +167,7 @@ export function useLazyLoadMessages(sessionId: string | null, options?: LazyLoad
|
||||
limit: OLDER_PAGE_LIMIT,
|
||||
});
|
||||
|
||||
// Update ref synchronously so concurrent calls are blocked immediately
|
||||
// Update ref synchronously so concurrent calls are blocked immediately.
|
||||
stateRef.current.isLoadingMore = true;
|
||||
try {
|
||||
const result = await requestOlderMessages({
|
||||
@@ -143,8 +176,7 @@ export function useLazyLoadMessages(sessionId: string | null, options?: LazyLoad
|
||||
limit: OLDER_PAGE_LIMIT,
|
||||
store,
|
||||
});
|
||||
// Sync ref immediately so the next intersection callback sees correct state
|
||||
// (the useEffect sync may not have run yet between store update and next observer fire)
|
||||
// Sync immediately; the effect may not run before the next page starts.
|
||||
const loadedMessages = store.getState().messages.bySession[sessionId] ?? EMPTY_MESSAGES;
|
||||
stateRef.current = {
|
||||
hasMore: visibleHasMore(result.hasMore, containsFirstPrompt(loadedMessages)),
|
||||
@@ -152,35 +184,70 @@ export function useLazyLoadMessages(sessionId: string | null, options?: LazyLoad
|
||||
oldestCursor: result.oldestCursor,
|
||||
isLoadingMore: false,
|
||||
};
|
||||
return result.count;
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("[useLazyLoadMessages] Error loading messages:", error);
|
||||
debug("loadMore: error", { sessionId, error });
|
||||
stateRef.current.isLoadingMore = false;
|
||||
return 0;
|
||||
return { count: 0, prependedMessages: EMPTY_MESSAGES };
|
||||
}
|
||||
},
|
||||
[sessionId, store],
|
||||
[sessionId, stateRef, store],
|
||||
);
|
||||
return { fetchPage };
|
||||
}
|
||||
|
||||
export function useLazyLoadMessages(sessionId: string | null, options?: LazyLoadMessagesOptions) {
|
||||
const { minUserPromptsPerLoad, minTextPartsPerLoad } = options ?? {};
|
||||
const [isAccumulating, setIsAccumulating] = useState(false);
|
||||
// Use refs for values that should not trigger callback recreation
|
||||
const rawHasMore = useAppStore((state) =>
|
||||
sessionId ? (state.messages.metaBySession[sessionId]?.hasMore ?? false) : false,
|
||||
);
|
||||
const hasMore = useVisibleHasMore(sessionId, rawHasMore);
|
||||
const oldestCursor = useAppStore((state) =>
|
||||
sessionId ? (state.messages.metaBySession[sessionId]?.oldestCursor ?? null) : null,
|
||||
);
|
||||
const isLoadingMore = useAppStore((state) =>
|
||||
sessionId ? (state.messages.metaBySession[sessionId]?.isLoadingMore ?? false) : false,
|
||||
);
|
||||
|
||||
// `minUserPromptsPerLoad` accumulates prompts across pages until the
|
||||
// threshold, exhaustion, a zero-result page, or the safety cap.
|
||||
// Store current values in refs to avoid recreating loadMore on every state change
|
||||
const stateRef = useRef<LazyLoadState>({ hasMore, rawHasMore, oldestCursor, isLoadingMore });
|
||||
useEffect(() => {
|
||||
stateRef.current = { hasMore, rawHasMore, oldestCursor, isLoadingMore };
|
||||
}, [hasMore, rawHasMore, oldestCursor, isLoadingMore]);
|
||||
|
||||
// Visible pagination stops at prompt #1; raw pagination remains available
|
||||
// to explicit recovery consumers such as session search.
|
||||
const { fetchPage } = useOlderPageFetcher(sessionId, stateRef);
|
||||
|
||||
// Optional per-consumer targets accumulate matching rows across pages until
|
||||
// every configured threshold, exhaustion, a zero-result page, or the safety
|
||||
// cap. Raw rows still determine progress and cursor movement.
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!minUserPromptsPerLoad || !sessionId) return fetchPage();
|
||||
const countPrompts = () =>
|
||||
countUserPrompts(store.getState().messages.bySession[sessionId] ?? EMPTY_MESSAGES);
|
||||
const before = countPrompts();
|
||||
let total = 0;
|
||||
for (let page = 0; page < MAX_PAGES_PER_LOAD; page++) {
|
||||
const added = await fetchPage();
|
||||
total += added;
|
||||
if (added === 0) break;
|
||||
if (countPrompts() - before >= minUserPromptsPerLoad) break;
|
||||
if ((!minUserPromptsPerLoad && !minTextPartsPerLoad) || !sessionId) {
|
||||
return (await fetchPage()).count;
|
||||
}
|
||||
return total;
|
||||
}, [fetchPage, minUserPromptsPerLoad, sessionId, store]);
|
||||
setIsAccumulating(true);
|
||||
try {
|
||||
return await loadPagesToTargets({
|
||||
fetchPage,
|
||||
minUserPromptsPerLoad,
|
||||
minTextPartsPerLoad,
|
||||
});
|
||||
} finally {
|
||||
setIsAccumulating(false);
|
||||
}
|
||||
}, [fetchPage, minTextPartsPerLoad, minUserPromptsPerLoad, sessionId]);
|
||||
|
||||
const loadMoreRaw = useCallback(() => fetchPage("raw"), [fetchPage]);
|
||||
const loadMoreRaw = useCallback(async () => (await fetchPage("raw")).count, [fetchPage]);
|
||||
|
||||
return { loadMore, loadMoreRaw, hasMore, rawHasMore, isLoadingMore };
|
||||
return {
|
||||
loadMore,
|
||||
loadMoreRaw,
|
||||
hasMore,
|
||||
rawHasMore,
|
||||
isLoadingMore: isLoadingMore || isAccumulating,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
---
|
||||
created: 2026-08-31
|
||||
status: completed
|
||||
requirements:
|
||||
- REQ-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/task-prompt-transcript-visibility.md
|
||||
legacy_specs: []
|
||||
---
|
||||
|
||||
# Implementation plan: Text-aware transcript pagination
|
||||
|
||||
## Overview
|
||||
|
||||
Make one upward transcript load target 20 readable text parts instead of 20
|
||||
arbitrary persisted rows. Extend the existing per-consumer accumulation path,
|
||||
opt only the native transcript into it, then prove the behavior on desktop and
|
||||
mobile with standalone tool activity that currently consumes a page and stops
|
||||
the preload cycle.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- Count `message`, `content`, and legacy untyped rows toward the transcript's
|
||||
20-part lazy-load target.
|
||||
- Keep tool calls and all other activity rows in the loaded transcript without
|
||||
letting them satisfy the text target.
|
||||
- Preserve prompt `#1`, raw exhaustion, no-progress, and bounded-loop stops.
|
||||
- Preserve Prompt History's user-prompt target and raw search/drain semantics.
|
||||
- Add focused hook coverage and desktop/mobile browser regressions.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Backend pagination queries, cursors, page limits, persistence, or API fields.
|
||||
- Initial newest-window size or eager history loading on task open.
|
||||
- Message filtering, grouping, collapsing, or transcript virtualization.
|
||||
- New controls, copy, layout, or mobile composition.
|
||||
- Changing the prompt `#1` visible-start boundary or recovery behavior.
|
||||
|
||||
## Confirmed root cause
|
||||
|
||||
`useLazyLoadMessages` requests `OLDER_PAGE_LIMIT` (20) raw rows. The native
|
||||
transcript calls the hook without an accumulation option, so `loadMore` returns
|
||||
after the first page and `OlderPageResult.count` reports every prepended row,
|
||||
including tool activity.
|
||||
|
||||
The committed-geometry sentinel can request another page only after that load
|
||||
returns and React commits it. Standalone tool rows can move the sentinel out of
|
||||
the preload region, so a page containing mostly or entirely tool calls consumes
|
||||
one upward reach without delivering a useful text batch.
|
||||
|
||||
The smallest regression is two raw pages: the first contains 19 tool rows and
|
||||
one text row; the second supplies the remaining text rows. The current
|
||||
transcript load settles after page one. The corrected load continues until 20
|
||||
new `message`/`content` rows arrive or a defined stop condition applies.
|
||||
|
||||
## Technical approach
|
||||
|
||||
### Text-aware hook accumulation
|
||||
|
||||
- Extend `LazyLoadMessagesOptions` in
|
||||
`apps/web/hooks/use-lazy-load-messages.ts` with a text-part target.
|
||||
- Count only message types `message`, `content`, and absent legacy types.
|
||||
- Reuse the current multi-page accumulation loop, joined-flight handling,
|
||||
prompt-`#1` boundary, zero-progress stop, and pages-per-load safety bound.
|
||||
- Keep `loadMore`'s numeric result as total raw rows prepended so sentinel
|
||||
progress and recovery semantics do not change.
|
||||
- Leave `loadMoreRaw` and callers without a target on their current single-page
|
||||
path. Leave Prompt History's `minUserPromptsPerLoad` behavior unchanged.
|
||||
|
||||
### Native transcript wiring
|
||||
|
||||
- Pass a 20-text-part target from
|
||||
`apps/web/components/task/chat/message-list-native.tsx` when constructing the
|
||||
native transcript loader.
|
||||
- Keep the loading state active across the accumulated batch and restore the
|
||||
prepend anchor after each intermediate React commit.
|
||||
- Keep sentinel continuation and recovery contracts unchanged.
|
||||
|
||||
### Browser regression
|
||||
|
||||
- Add a shared fixture in
|
||||
`apps/web/e2e/tests/chat/message-pagination-helpers.ts` with an older text
|
||||
batch behind one raw page of tool calls placed in separate turns. Separate
|
||||
turns keep the tool rows standalone so the current geometry rule stops after
|
||||
the first page.
|
||||
- In desktop Chromium and mobile Chrome, perform one upward reach, assert more
|
||||
than one older-page request, assert the older text marker becomes visible,
|
||||
and retain the existing prepend-anchor tolerance.
|
||||
|
||||
## Tests
|
||||
|
||||
| Acceptance criterion | Test evidence |
|
||||
| --- | --- |
|
||||
| `AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.13` | `apps/web/hooks/use-lazy-load-messages.test.ts` proves mixed tool/text pages accumulate 20 text parts and that `message`, `content`, and legacy rows count while activity rows do not. |
|
||||
| `AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.3`, `.7` | Hook cases preserve prompt `#1`, exhaustion, zero-progress, and safety-bound stops. |
|
||||
| `AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.9`, `.11`, `.13` | Existing desktop and mobile message-pagination specs gain the standalone-tool-page regression and anchor assertion. |
|
||||
|
||||
## E2E tests
|
||||
|
||||
- Desktop: `apps/web/e2e/tests/chat/message-pagination.spec.ts`, `chromium`
|
||||
project.
|
||||
- Mobile: `apps/web/e2e/tests/chat/mobile-message-pagination.spec.ts`,
|
||||
`mobile-chrome` project.
|
||||
- Shared cues: older-request count, older text marker, stable anchor-row top,
|
||||
and absence of the recovery control during successful loading.
|
||||
|
||||
## Mobile design contract
|
||||
|
||||
- Desktop outcome: one upward reach loads a useful text batch even when tool
|
||||
activity occupies the first raw page.
|
||||
- Mobile entry point: existing Chat tab in the full-height task layout.
|
||||
- Nearest exemplar: `apps/web/components/task/task-layout.tsx` and the current
|
||||
`mobile-message-pagination.spec.ts` flow.
|
||||
- Hierarchy and surface: no composition change; transcript remains the only
|
||||
vertical scroll owner.
|
||||
- Shared logic: desktop and mobile use the same store, text predicate, cursor
|
||||
coordinator, sentinel, and anchor restoration.
|
||||
- Mobile proof: Pixel 5 `mobile-chrome` performs the same one-reach tool-heavy
|
||||
pagination scenario and observes the older text marker.
|
||||
|
||||
## Work orders
|
||||
|
||||
- [completed] [Task 01: Count transcript text during lazy loads](task-01-count-transcript-text.md)
|
||||
|
||||
## Verification results
|
||||
|
||||
- Focused Vitest: 50 tests passed across the lazy-load hook and native scroll
|
||||
management suites.
|
||||
- TypeScript typecheck, changed-file ESLint, production E2E build, i18n
|
||||
new-code ratchet, and E2E sleep ratchet passed.
|
||||
- Desktop Chromium message pagination: 7 tests passed.
|
||||
- Mobile Chrome message pagination: 7 tests passed.
|
||||
- Specification linter tests: 20 passed; all specification files passed.
|
||||
- `git diff --check` passed.
|
||||
|
||||
## Risks
|
||||
|
||||
- Sequential pages increase request volume for tool-heavy histories; the
|
||||
existing per-load safety bound must remain effective.
|
||||
- Joined requests can use another consumer's page size, so progress must come
|
||||
from the merged store delta rather than assumptions about response size.
|
||||
- `content` and legacy untyped rows must count consistently without counting
|
||||
structured activity whose payload also contains text.
|
||||
- Intermediate store commits must not break prepend anchoring or flash the
|
||||
recovery control between successful pages.
|
||||
@@ -0,0 +1,117 @@
|
||||
---
|
||||
id: "01-count-transcript-text"
|
||||
title: "Count transcript text during lazy loads"
|
||||
status: completed
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001
|
||||
acceptance_criteria:
|
||||
- AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.3
|
||||
- AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.7
|
||||
- AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.9
|
||||
- AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.11
|
||||
- AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.13
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/task-prompt-transcript-visibility.md
|
||||
---
|
||||
|
||||
# Task 01: Count transcript text during lazy loads
|
||||
|
||||
## Summary
|
||||
|
||||
Make the native transcript accumulate older raw pages until it loads 20 text
|
||||
parts, without counting tool or other activity rows. Preserve existing
|
||||
boundaries and prove the same one-reach behavior on desktop and mobile.
|
||||
|
||||
## In scope
|
||||
|
||||
- Add the text-part accumulation option and predicate to the shared lazy-load
|
||||
hook.
|
||||
- Opt the native transcript into a target of 20 text parts.
|
||||
- Add red-first unit coverage for mixed pages and all stop conditions.
|
||||
- Add a distinct-turn tool-page fixture and desktop/mobile Playwright coverage.
|
||||
- Preserve the existing prepend-anchor tolerance and successful-load recovery
|
||||
state.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Backend/API changes or a different raw page size.
|
||||
- Prompt History, raw search, message drain, grouping, or rendering changes.
|
||||
- New UI, copy, or responsive composition.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- One transcript `loadMore` call continues past raw pages whose activity rows
|
||||
leave the 20-text-part target unmet.
|
||||
- `message`, `content`, and legacy untyped rows advance the target; tool and
|
||||
other activity rows do not. Existing early stops still terminate safely.
|
||||
- One desktop or mobile upward reach crosses a standalone tool page, reveals
|
||||
the older text marker, preserves its anchor, and does not show recovery UI.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm --filter @kandev/web test -- hooks/use-lazy-load-messages.test.ts
|
||||
pnpm --filter @kandev/web run typecheck
|
||||
cd web
|
||||
pnpm exec eslint hooks/use-lazy-load-messages.ts hooks/use-lazy-load-messages.test.ts components/task/chat/message-list-native.tsx components/task/chat/message-list-native-scroll.ts components/task/chat/message-list-native.test.tsx e2e/tests/chat/message-pagination-helpers.ts e2e/tests/chat/message-pagination.spec.ts e2e/tests/chat/mobile-message-pagination.spec.ts
|
||||
pnpm e2e:run --host --project chromium tests/chat/message-pagination.spec.ts -- --retries=0
|
||||
pnpm e2e:run --host --no-build --project mobile-chrome tests/chat/mobile-message-pagination.spec.ts -- --retries=0
|
||||
cd ../..
|
||||
python3 scripts/lint-spec-files.py --all
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/hooks/use-lazy-load-messages.ts`
|
||||
- `apps/web/hooks/use-lazy-load-messages.test.ts`
|
||||
- `apps/web/components/task/chat/message-list-native.tsx`
|
||||
- `apps/web/components/task/chat/message-list-native-scroll.ts`
|
||||
- `apps/web/components/task/chat/message-list-native.test.tsx`
|
||||
- `apps/web/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
|
||||
|
||||
None.
|
||||
|
||||
## Risks
|
||||
|
||||
- Tool-heavy histories can require several sequential requests within one
|
||||
load; retain the bounded loop.
|
||||
- Joined flights may use a different effective page size.
|
||||
- React can commit between pages, so anchoring and loading state must remain
|
||||
stable across the full operation.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- `REQ-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001`, especially acceptance
|
||||
criteria `.3`, `.7`, `.9`, `.11`, and `.13`.
|
||||
- Text-aware load batches, upward pagination, recovery, responsive behavior,
|
||||
and test boundaries in the system design.
|
||||
- Existing `minUserPromptsPerLoad`, cursor coordination, and desktop/mobile
|
||||
message-pagination patterns.
|
||||
|
||||
## Results
|
||||
|
||||
- RED: the mixed tool/text hook case and desktop/mobile tool-page scenarios
|
||||
stopped after one raw request instead of reaching the 20-text target.
|
||||
- GREEN: the native transcript now counts only `message`, `content`, and legacy
|
||||
untyped rows toward its 20-part batch while retaining all raw activity.
|
||||
- GREEN: prompt `#1`, exhaustion, zero-result, and 10-page safety stops passed;
|
||||
aggregate loading and per-commit prepend anchoring passed focused tests.
|
||||
- GREEN: 50 focused Vitest tests, typecheck, changed-file ESLint, the production
|
||||
E2E build, all 7 desktop pagination tests, and all 7 mobile pagination tests
|
||||
passed.
|
||||
- GREEN: i18n and E2E sleep ratchets, 20 specification-linter tests, the full
|
||||
specification lint, and `git diff --check` passed.
|
||||
@@ -22,6 +22,10 @@ prompt, and upward navigation must not require routine use of a manual loader.
|
||||
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.
|
||||
- **Text part:** A persisted user or agent transcript row whose message type is
|
||||
`message` or `content`, including a legacy row without an explicit type.
|
||||
Tool calls, thinking, progress, status, and other activity rows are not text
|
||||
parts for lazy-load batch sizing.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -45,6 +49,7 @@ start.
|
||||
- **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.
|
||||
- **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.13:** When an upward lazy-load operation sizes its batch, it shall target 20 newly loaded text parts. Tool calls and other activity rows shall not advance that target; the operation can stop earlier when prompt `#1` is loaded, persisted history is exhausted, a request makes no progress, or its bounded safety limit is reached.
|
||||
|
||||
## Exclusions
|
||||
|
||||
|
||||
@@ -40,11 +40,12 @@ and `prompt_index` field.
|
||||
- `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.
|
||||
requests. It also owns opt-in per-consumer accumulation targets and keeps raw
|
||||
pagination available to explicit recovery consumers.
|
||||
- The native transcript consumes `useLazyLoadMessages`. Prompt History consumes
|
||||
its own user-message request and pagination state.
|
||||
- The native transcript owns upward intent, committed sentinel geometry, scroll
|
||||
restoration, and retry-control visibility.
|
||||
- The native transcript owns upward intent, committed sentinel geometry,
|
||||
scroll restoration, and retry-control visibility.
|
||||
- `useLazyLoadSentinel` owns observer lifecycle and request serialization.
|
||||
- Transcript navigation first uses loaded messages. When a selected prompt is
|
||||
absent, it requests an around window and merges the result before scrolling.
|
||||
@@ -105,6 +106,30 @@ 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.
|
||||
|
||||
## Text-aware load batches
|
||||
|
||||
The backend pagination contract remains cursor-based and counts every persisted
|
||||
row. Each request still asks for 20 raw rows and prepends every returned row so
|
||||
tool activity remains available in the transcript.
|
||||
|
||||
The native transcript opts into a 20-text-part target on
|
||||
`useLazyLoadMessages`. One `loadMore` invocation snapshots the loaded text-part
|
||||
count, requests consecutive raw pages, and returns only after the delta reaches
|
||||
the target or a normal stop condition applies. A text part has type `message`
|
||||
or `content`; a missing type counts for legacy compatibility. The predicate
|
||||
does not count tool, thinking, progress, status, or other activity rows, and it
|
||||
does not discard those rows from the store or renderer.
|
||||
|
||||
The accumulation loop preserves the existing bounded pages-per-load safeguard.
|
||||
Prompt `#1`, raw exhaustion, a zero-result page, or the safeguard can end a
|
||||
batch before 20 text parts arrive. The returned progress count remains the
|
||||
total number of raw rows prepended so the sentinel's no-progress recovery
|
||||
contract is unchanged.
|
||||
|
||||
Prompt History continues to use its separate user-prompt target. Raw search,
|
||||
drain, and backfill consumers retain their existing page semantics; text-aware
|
||||
batching is opt-in only for the native transcript.
|
||||
|
||||
## Upward pagination
|
||||
|
||||
The transcript starts a load cycle when the user reaches the oldest loaded
|
||||
@@ -178,9 +203,15 @@ anchor behavior as desktop.
|
||||
reset.
|
||||
- Native transcript tests cover recovery-control visibility and prepend anchor
|
||||
preservation.
|
||||
- Lazy-load hook tests cover mixed `message`, `content`, legacy, and tool rows,
|
||||
the 20-text-part target, every early stop, and unchanged raw/single-page
|
||||
consumers.
|
||||
- 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.
|
||||
- Desktop and mobile browser tests also place standalone tool activity ahead of
|
||||
older text so the first raw page leaves the preload region. One upward reach
|
||||
must still cross that page and reveal the targeted older text batch.
|
||||
- Existing prompt-`#1`, hidden pre-prompt, and inactive-session reconciliation
|
||||
scenarios remain covered.
|
||||
|
||||
Reference in New Issue
Block a user