diff --git a/apps/web/components/task/chat/message-list-native-scroll.ts b/apps/web/components/task/chat/message-list-native-scroll.ts index 5b870248f2..083a5d5d7d 100644 --- a/apps/web/components/task/chat/message-list-native-scroll.ts +++ b/apps/web/components/task/chat/message-list-native-scroll.ts @@ -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(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); diff --git a/apps/web/components/task/chat/message-list-native.test.tsx b/apps/web/components/task/chat/message-list-native.test.tsx index 468e12ac94..eca5455664 100644 --- a/apps/web/components/task/chat/message-list-native.test.tsx +++ b/apps/web/components/task/chat/message-list-native.test.tsx @@ -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; sessionId?: string | null; + isLoadingMore?: boolean; recoveryRef?: { current: boolean }; }) { const scrollRef = useRef(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 ( -
+
); @@ -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(); - 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( + , + ); + + act(() => { + metrics.scrollTop = 40; + screen.getByTestId(NATIVE_SCROLL_MANAGEMENT_TEST_ID).dispatchEvent(new Event("scroll")); + }); + rerender( + , + ); + metrics.scrollHeight = 200; + rerender( + , + ); + + expect(metrics.scrollTop).toBe(140); + }); + + it("freezes the prepend baseline synchronously when an older load starts", async () => { + const page = Promise.withResolvers(); + const metrics = { scrollHeight: 100, scrollTop: 40, clientHeight: 50 }; + const activity = transcriptActivity("activity-old", "turn-1"); + const newest = transcriptMessage("newest"); + const { rerender } = render( + 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; + const pendingLoad = wrappedLoadMore(); + act(() => { + metrics.scrollTop = 80; + screen.getByTestId(NATIVE_SCROLL_MANAGEMENT_TEST_ID).dispatchEvent(new Event("scroll")); + }); + metrics.scrollHeight = 200; + rerender( + 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. diff --git a/apps/web/components/task/chat/message-list-native.tsx b/apps/web/components/task/chat/message-list-native.tsx index eaf76b9337..3f5abe7435 100644 --- a/apps/web/components/task/chat/message-list-native.tsx +++ b/apps/web/components/task/chat/message-list-native.tsx @@ -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); diff --git a/apps/web/e2e/tests/chat/message-pagination-helpers.ts b/apps/web/e2e/tests/chat/message-pagination-helpers.ts index 8dba015b9b..2590cc751d 100644 --- a/apps/web/e2e/tests/chat/message-pagination-helpers.ts +++ b/apps/web/e2e/tests/chat/message-pagination-helpers.ts @@ -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 { 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 { - 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; - }); -} diff --git a/apps/web/e2e/tests/chat/message-pagination.spec.ts b/apps/web/e2e/tests/chat/message-pagination.spec.ts index 184cba9afe..2384bdc0da 100644 --- a/apps/web/e2e/tests/chat/message-pagination.spec.ts +++ b/apps/web/e2e/tests/chat/message-pagination.spec.ts @@ -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, diff --git a/apps/web/e2e/tests/chat/mobile-message-pagination.spec.ts b/apps/web/e2e/tests/chat/mobile-message-pagination.spec.ts index 6f01604fc0..e7e92bf841 100644 --- a/apps/web/e2e/tests/chat/mobile-message-pagination.spec.ts +++ b/apps/web/e2e/tests/chat/mobile-message-pagination.spec.ts @@ -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, diff --git a/apps/web/hooks/domains/session/older-message-pagination.ts b/apps/web/hooks/domains/session/older-message-pagination.ts index 62f3ea7484..7a377850c8 100644 --- a/apps/web/hooks/domains/session/older-message-pagination.ts +++ b/apps/web/hooks/domains/session/older-message-pagination.ts @@ -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, + }; } diff --git a/apps/web/hooks/use-lazy-load-messages.test.ts b/apps/web/hooks/use-lazy-load-messages.test.ts index 2a19032871..4962bbd3a5 100644 --- a/apps/web/hooks/use-lazy-load-messages.test.ts +++ b/apps/web/hooks/use-lazy-load-messages.test.ts @@ -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(); + 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(); + const secondPage = Promise.withResolvers(); + listTaskSessionMessages + .mockReturnValueOnce(firstPage.promise) + .mockReturnValueOnce(secondPage.promise); + const { result } = renderHook(() => useLazyLoadMessages("s1", { minTextPartsPerLoad: 2 })); + + let loadPromise!: Promise; + 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); + }); +}); diff --git a/apps/web/hooks/use-lazy-load-messages.ts b/apps/web/hooks/use-lazy-load-messages.ts index 9b2ade291f..2a51e92298 100644 --- a/apps/web/hooks/use-lazy-load-messages.ts +++ b/apps/web/hooks/use-lazy-load-messages.ts @@ -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 { + 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({ 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, + }; } diff --git a/docs/plans/transcript-text-aware-pagination/plan.md b/docs/plans/transcript-text-aware-pagination/plan.md new file mode 100644 index 0000000000..c3a79f9bda --- /dev/null +++ b/docs/plans/transcript-text-aware-pagination/plan.md @@ -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. diff --git a/docs/plans/transcript-text-aware-pagination/task-01-count-transcript-text.md b/docs/plans/transcript-text-aware-pagination/task-01-count-transcript-text.md new file mode 100644 index 0000000000..b506f3b13d --- /dev/null +++ b/docs/plans/transcript-text-aware-pagination/task-01-count-transcript-text.md @@ -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. diff --git a/docs/specs/ui/requirements/task-prompt-transcript-visibility.md b/docs/specs/ui/requirements/task-prompt-transcript-visibility.md index d333187914..b26b7711f7 100644 --- a/docs/specs/ui/requirements/task-prompt-transcript-visibility.md +++ b/docs/specs/ui/requirements/task-prompt-transcript-visibility.md @@ -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 diff --git a/docs/specs/ui/system-design/task-prompt-transcript-visibility.md b/docs/specs/ui/system-design/task-prompt-transcript-visibility.md index d0bc4b4498..60167f1c28 100644 --- a/docs/specs/ui/system-design/task-prompt-transcript-visibility.md +++ b/docs/specs/ui/system-design/task-prompt-transcript-visibility.md @@ -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.