fix(chat): count text parts in transcript lazy loads (#3219)

This commit is contained in:
José Almeida
2026-09-01 12:40:42 +00:00
committed by GitHub
parent 40beea5f2e
commit 2023febe63
13 changed files with 848 additions and 90 deletions
@@ -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,
};
}
+180 -2
View File
@@ -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);
});
});
+125 -58
View File
@@ -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.