From 3ca388ebe4889a30e845c95354b792c484acc8ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Tue, 25 Aug 2026 07:56:03 +0000 Subject: [PATCH] fix: stop transcript pagination at first prompt (#3002) --- .../task/chat/message-list-native-scroll.ts | 70 +++++- .../task/chat/message-list-shared.test.tsx | 17 -- .../task/chat/message-list-shared.tsx | 16 -- .../web/components/task/chat/tiptap-input.tsx | 4 +- .../chat/use-drain-older-messages.test.ts | 28 ++- .../task/chat/use-drain-older-messages.ts | 32 ++- .../prompt-history-panel-content.test.tsx | 4 +- .../task/prompt-history-panel-content.tsx | 7 +- apps/web/components/task/task-chat-panel.tsx | 53 +--- .../e2e/tests/chat/last-prompt-scroll.spec.ts | 5 +- .../tests/chat/message-pagination-helpers.ts | 81 +++++- .../e2e/tests/chat/message-pagination.spec.ts | 75 +++++- .../chat/mobile-message-pagination.spec.ts | 61 ++++- .../hooks/domains/session/message-backfill.ts | 116 --------- .../session/use-session-message-fetch.test.ts | 20 +- .../session/use-session-message-fetch.ts | 11 - .../session/use-session-messages.test.ts | 237 ------------------ .../domains/session/use-session-messages.ts | 13 - apps/web/hooks/use-lazy-load-messages.test.ts | 64 ++++- apps/web/hooks/use-lazy-load-messages.ts | 189 ++++++++------ .../plan.md | 135 ++++++++++ ...stop-visible-pagination-at-first-prompt.md | 108 ++++++++ .../task-02-stop-eager-history-loading.md | 126 ++++++++++ docs/specs/ui/README.md | 1 + .../task-prompt-transcript-visibility.md | 3 +- .../task-prompt-transcript-visibility.md | 99 ++++++++ 26 files changed, 982 insertions(+), 593 deletions(-) delete mode 100644 apps/web/hooks/domains/session/message-backfill.ts create mode 100644 docs/plans/hide-redundant-older-messages-control/plan.md create mode 100644 docs/plans/hide-redundant-older-messages-control/task-01-stop-visible-pagination-at-first-prompt.md create mode 100644 docs/plans/hide-redundant-older-messages-control/task-02-stop-eager-history-loading.md create mode 100644 docs/specs/ui/system-design/task-prompt-transcript-visibility.md 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 dc4e10a6cb..fcffaec8d5 100644 --- a/apps/web/components/task/chat/message-list-native-scroll.ts +++ b/apps/web/components/task/chat/message-list-native-scroll.ts @@ -31,9 +31,21 @@ import { scheduleClampedScrollRestore } from "./clamped-scroll-restore"; function useScrollPositionOnPrepend( scrollRef: React.RefObject, items: RenderItem[], + isLoadingMore: boolean, isProgrammaticScrollLocked: () => boolean, ) { - const scrollState = useRef({ scrollHeight: 0, scrollTop: 0 }); + const scrollState = useRef<{ + scrollHeight: number; + scrollTop: number; + anchorKey: string | null; + anchorTop: number | null; + }>({ scrollHeight: 0, scrollTop: 0, anchorKey: null, anchorTop: null }); + const isLoadingMoreRef = useRef(isLoadingMore); + const olderLoadPendingRef = useRef(false); + const newestItemKeyRef = useRef(getNewestNonSyntheticItemKey(items)); + isLoadingMoreRef.current = isLoadingMore; + if (isLoadingMore) olderLoadPendingRef.current = true; + newestItemKeyRef.current = getNewestNonSyntheticItemKey(items); const prevItemCountRef = useRef(items.length); const prevFirstKeyRef = useRef(getOldestNonSyntheticItemKey(items)); @@ -43,8 +55,11 @@ function useScrollPositionOnPrepend( /** Captures the container's current scrollHeight/scrollTop so a later * prepend can restore the visual position. */ const onScroll = () => { - scrollState.current.scrollHeight = el.scrollHeight; - scrollState.current.scrollTop = el.scrollTop; + // 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; + scrollState.current = capturePrependScrollState(el, newestItemKeyRef.current); }; onScroll(); el.addEventListener("scroll", onScroll, { passive: true }); @@ -54,7 +69,7 @@ function useScrollPositionOnPrepend( useLayoutEffect(() => { const el = scrollRef.current; const nextFirstKey = getOldestNonSyntheticItemKey(items); - const prepend = + const identityPrepend = !!el && isPrependUpdate({ prevItemCount: prevItemCountRef.current, @@ -62,15 +77,22 @@ function useScrollPositionOnPrepend( prevFirstKey: prevFirstKeyRef.current, nextFirstKey, }); + const olderLoadSettled = olderLoadPendingRef.current && !isLoadingMore; + const prepend = olderLoadSettled || (!olderLoadPendingRef.current && identityPrepend); prevItemCountRef.current = items.length; prevFirstKeyRef.current = nextFirstKey; + if (olderLoadSettled) olderLoadPendingRef.current = false; if (!el || !prepend || isProgrammaticScrollLocked()) return; const prev = scrollState.current; - const delta = el.scrollHeight - prev.scrollHeight; - if (delta > 0) { - el.scrollTop = prev.scrollTop + delta; + const anchor = findMessageRow(el, prev.anchorKey); + if (anchor && prev.anchorTop !== null) { + el.scrollTop += anchor.getBoundingClientRect().top - prev.anchorTop; + } else { + const delta = el.scrollHeight - prev.scrollHeight; + if (delta > 0) el.scrollTop = prev.scrollTop + delta; } - }, [items, scrollRef, isProgrammaticScrollLocked]); + scrollState.current = capturePrependScrollState(el, newestItemKeyRef.current); + }, [items, scrollRef, isLoadingMore, isProgrammaticScrollLocked]); } function getOldestNonSyntheticItemKey(items: RenderItem[]): string | null { @@ -81,6 +103,36 @@ function getOldestNonSyntheticItemKey(items: RenderItem[]): string | null { return oldestRealItem ? getItemKey(oldestRealItem) : null; } +function getNewestNonSyntheticItemKey(items: RenderItem[]): string | null { + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index]; + if (item.type === "prepare_progress" || item.type === "agent_error_notice") continue; + if (item.type === "message" && item.message.id === TASK_DESCRIPTION_SYNTHETIC_ID) continue; + return getItemKey(item); + } + return null; +} + +function findMessageRow(scrollRoot: HTMLElement, itemKey: string | null): HTMLElement | null { + if (!itemKey) return null; + const expectedId = `msg-${itemKey}`; + return ( + Array.from(scrollRoot.querySelectorAll("[id^='msg-']")).find( + (candidate) => candidate.id === expectedId, + ) ?? null + ); +} + +function capturePrependScrollState(scrollRoot: HTMLElement, anchorKey: string | null) { + const anchor = findMessageRow(scrollRoot, anchorKey); + return { + scrollHeight: scrollRoot.scrollHeight, + scrollTop: scrollRoot.scrollTop, + anchorKey, + anchorTop: anchor?.getBoundingClientRect().top ?? null, + }; +} + /** * Observes a sentinel element at the top of the list to trigger lazy loading. * Delegates to the shared useLazyLoadSentinel hook with positive-result @@ -578,7 +630,7 @@ export function useNativeScrollManagement(params: { resyncIsNearBottom, ); const handleScrollToMessage = useScrollToMessage(scrollRef, runGuardedScroll); - useScrollPositionOnPrepend(scrollRef, items, isProgrammaticScrollLocked); + useScrollPositionOnPrepend(scrollRef, items, isLoadingMore, isProgrammaticScrollLocked); const { sentinelRef } = useLazyLoadSentinel( scrollRef, hasMore, diff --git a/apps/web/components/task/chat/message-list-shared.test.tsx b/apps/web/components/task/chat/message-list-shared.test.tsx index 87b71bbcc5..5da3fc498d 100644 --- a/apps/web/components/task/chat/message-list-shared.test.tsx +++ b/apps/web/components/task/chat/message-list-shared.test.tsx @@ -96,7 +96,6 @@ import { resolveLastPromptControls, resolveLastPromptEdge, shouldAutoScrollToBottom, - shouldLoadMoreForTranscriptTarget, } from "./message-list-shared"; describe("anchoredBarScrollOffsetPx", () => { @@ -502,22 +501,6 @@ describe("getFirstUserMessageId", () => { }); }); -describe("shouldLoadMoreForTranscriptTarget", () => { - const message = (id: string, author_type: "user" | "agent") => ({ id, author_type }) as Message; - - it("keeps loading older pages when the latest loaded page has no user prompt", () => { - expect( - shouldLoadMoreForTranscriptTarget("last_prompt", [message("reply", "agent")], true), - ).toBe(true); - }); - - it("keeps loading for scroll-to-start until pagination reaches the real first prompt", () => { - expect( - shouldLoadMoreForTranscriptTarget("start", [message("loaded-prompt", "user")], true), - ).toBe(true); - }); -}); - describe("shouldAutoScrollToBottom", () => { const base = { isNearBottom: true, diff --git a/apps/web/components/task/chat/message-list-shared.tsx b/apps/web/components/task/chat/message-list-shared.tsx index ed6bb77b07..2e085b7de3 100644 --- a/apps/web/components/task/chat/message-list-shared.tsx +++ b/apps/web/components/task/chat/message-list-shared.tsx @@ -120,22 +120,6 @@ export function getFirstUserMessageId(messages: Message[]): string | null { return first ? first.id : null; } -export type TranscriptNavigationTarget = "last_prompt" | "start"; - -/** - * Whether resolving a transcript-navigation target needs another older page. - * The latest prompt may sit before a long agent response; the true start can - * only be known after the pagination cursor is exhausted. - */ -export function shouldLoadMoreForTranscriptTarget( - target: TranscriptNavigationTarget, - messages: Message[], - hasMore: boolean, -): boolean { - if (!hasMore) return false; - return target === "start" || getLastUserMessageId(messages) === null; -} - /** * Whether the transcript's auto-follow-bottom behavior should force a scroll * to the bottom right now. `false` whenever a user-initiated programmatic diff --git a/apps/web/components/task/chat/tiptap-input.tsx b/apps/web/components/task/chat/tiptap-input.tsx index 0b1cbb1541..63607c10c6 100644 --- a/apps/web/components/task/chat/tiptap-input.tsx +++ b/apps/web/components/task/chat/tiptap-input.tsx @@ -47,6 +47,8 @@ import { EntityReferenceSuggestionPluginKey } from "./tiptap-entity-reference-su import type { ImagePasteIssue } from "./clipboard-attachments"; import { useTranslation } from "react-i18next"; +const RAW_DRAIN = { rawPagination: true } as const; + export type { TipTapInputHandle } from "./use-tiptap-editor"; // ── Props ─────────────────────────────────────────────────────────── @@ -441,7 +443,7 @@ export const TipTapInput = forwardRef(funct editorRef, ); const { history, getHistory } = useChatHistory(sessionId); - const { isDraining } = useDrainOlderMessages(sessionId, overlay.isReverseSearchOpen); + const { isDraining } = useDrainOlderMessages(sessionId, overlay.isReverseSearchOpen, RAW_DRAIN); const { editor, applyHistoryEntry } = useTipTapEditor({ value, onChange, diff --git a/apps/web/components/task/chat/use-drain-older-messages.test.ts b/apps/web/components/task/chat/use-drain-older-messages.test.ts index 0ab2abd9e9..1f22708099 100644 --- a/apps/web/components/task/chat/use-drain-older-messages.test.ts +++ b/apps/web/components/task/chat/use-drain-older-messages.test.ts @@ -21,6 +21,8 @@ vi.mock("@/components/state-provider", () => ({ // mock separately. let mockPages: number[] = []; let mockLoadMoreCalls = 0; +let mockLoadMoreRawCalls = 0; +let mockRawHasMore = true; // When set, overrides the page-array-driven default: called with the same // `setState` the fake hook itself uses, so every path (page-array or custom) // updates `hasMore`/`isLoadingMore` identically and consistently. @@ -39,7 +41,21 @@ function useLazyLoadMessagesFake() { setState({ hasMore, isLoadingMore: false }); return fetched; }, []); - return { loadMore, hasMore: state.hasMore, isLoadingMore: state.isLoadingMore }; + const loadMoreRaw = useCallback(async () => { + mockLoadMoreRawCalls++; + const index = mockLoadMoreRawCalls - 1; + const fetched = mockPages[index] ?? 0; + mockRawHasMore = index + 1 < mockPages.length; + setState((current) => ({ ...current, isLoadingMore: false })); + return fetched; + }, []); + return { + loadMore, + loadMoreRaw, + hasMore: state.hasMore, + rawHasMore: mockRawHasMore, + isLoadingMore: state.isLoadingMore, + }; } vi.mock("@/hooks/use-lazy-load-messages", () => ({ @@ -56,6 +72,8 @@ afterEach(() => { cleanup(); mockPages = []; mockLoadMoreCalls = 0; + mockLoadMoreRawCalls = 0; + mockRawHasMore = true; mockLoadMoreImpl = null; storeMock.messagesLoading = false; }); @@ -83,6 +101,14 @@ describe("useDrainOlderMessages", () => { expect(mockLoadMoreCalls).toBe(3); }); + it("uses raw pagination for reverse search backfill", async () => { + mockPages = [20, 0]; + const { result } = renderHook(() => useDrainOlderMessages("s1", true, { rawPagination: true })); + await waitFor(() => expect(result.current.isDraining).toBe(false)); + expect(mockLoadMoreCalls).toBe(0); + expect(mockLoadMoreRawCalls).toBe(2); + }); + it("stops at the 1000-message budget when the session never reports exhaustion", async () => { mockPages = new Array(200).fill(20); const { result } = renderHook(() => useDrainOlderMessages("s1", true)); diff --git a/apps/web/components/task/chat/use-drain-older-messages.ts b/apps/web/components/task/chat/use-drain-older-messages.ts index 3828be8da6..65c9d677f6 100644 --- a/apps/web/components/task/chat/use-drain-older-messages.ts +++ b/apps/web/components/task/chat/use-drain-older-messages.ts @@ -8,13 +8,17 @@ import { useAppStore } from "@/components/state-provider"; * most one page (mixed first-request-wins page sizes). */ export const MAX_DRAIN_MESSAGES = 1000; +export type DrainOlderMessagesOptions = { + /** Use backend-wide pagination for explicit reverse-search backfill. */ + rawPagination?: boolean; +}; + /** * When `active` flips true, walk the message pagination cursor until the - * server reports no more older messages (or the message budget is hit). Used - * by the Ctrl+R reverse-search overlay so the user can fuzzy-search the entire - * session history, not only the pages already loaded by the chat list, and by - * the transcript's scroll-to-start affordance so it lands on the true first - * prompt instead of a partial-page boundary. + * server reports no more older messages (or the message budget is hit). The + * default visible path is used by the transcript's scroll-to-start affordance + * and stops at the first prompt. The Ctrl+R reverse-search overlay opts into + * raw pagination so it can search the complete backend session history. * * Step-driven, not an imperative loop: each batch only fires once `isLoadingMore` * (older-page, from `useLazyLoadMessages`, reactive) is confirmed false AND the @@ -28,8 +32,16 @@ export const MAX_DRAIN_MESSAGES = 1000; * whose cumulative count reaches or exceeds MAX_DRAIN_MESSAGES, and stops * immediately after a zero-result batch even when `hasMore` remains true. */ -export function useDrainOlderMessages(sessionId: string | null, active: boolean) { - const { loadMore, hasMore, isLoadingMore } = useLazyLoadMessages(sessionId); +export function useDrainOlderMessages( + sessionId: string | null, + active: boolean, + options?: DrainOlderMessagesOptions, +) { + const { loadMore, loadMoreRaw, hasMore, rawHasMore, isLoadingMore } = + useLazyLoadMessages(sessionId); + const rawPagination = options?.rawPagination ?? false; + const drainLoadMore = rawPagination ? loadMoreRaw : loadMore; + const drainHasMore = rawPagination ? rawHasMore : hasMore; const messagesLoading = useAppStore((state) => sessionId ? (state.messages.metaBySession[sessionId]?.isLoading ?? false) : false, ); @@ -46,12 +58,12 @@ export function useDrainOlderMessages(sessionId: string | null, active: boolean) }, [active]); const isDraining = - active && Boolean(sessionId) && hasMore && fetchedMessageCount < MAX_DRAIN_MESSAGES; + active && Boolean(sessionId) && drainHasMore && fetchedMessageCount < MAX_DRAIN_MESSAGES; useEffect(() => { if (!isDraining || !sessionId || messagesLoading || isLoadingMore) return; let cancelled = false; - void loadMore() + void drainLoadMore() .then((fetched) => { if (cancelled) return; setFetchedMessageCount((count) => { @@ -77,7 +89,7 @@ export function useDrainOlderMessages(sessionId: string | null, active: boolean) return () => { cancelled = true; }; - }, [isDraining, sessionId, messagesLoading, isLoadingMore, loadMore, fetchedMessageCount]); + }, [isDraining, sessionId, messagesLoading, isLoadingMore, drainLoadMore, fetchedMessageCount]); return { isDraining }; } diff --git a/apps/web/components/task/prompt-history-panel-content.test.tsx b/apps/web/components/task/prompt-history-panel-content.test.tsx index 745503d344..2619340b20 100644 --- a/apps/web/components/task/prompt-history-panel-content.test.tsx +++ b/apps/web/components/task/prompt-history-panel-content.test.tsx @@ -770,8 +770,8 @@ describe("PromptHistoryPanelContent — auto-load sentinel", () => { expect(pagination.loadMore).toHaveBeenCalledTimes(1); }); - it("removes the sentinel once prompt #1 is loaded, with no button", () => { - pagination.hasMore = true; + it("removes the sentinel when the shared hook reports the first-prompt boundary", () => { + pagination.hasMore = false; messagesBySession[SESSION_A] = [ message({ id: "first", prompt_index: 1, content: "first prompt" }), message({ id: "newest", prompt_index: 2, content: "newest prompt", created_at: LATER_TIME }), diff --git a/apps/web/components/task/prompt-history-panel-content.tsx b/apps/web/components/task/prompt-history-panel-content.tsx index 92836a51b3..c0668f74a9 100644 --- a/apps/web/components/task/prompt-history-panel-content.tsx +++ b/apps/web/components/task/prompt-history-panel-content.tsx @@ -79,10 +79,9 @@ export function PromptHistoryPanelContent({ onNavigateToPrompt }: PromptHistoryP // Minimum-display grace so consecutive auto-loads show one continuous // indicator instead of a per-page flash (scoped to the active session). const showLoadingGrace = useLoadingGrace(sessionId, isLoadingMore); - // Pagination continues while the server reports older messages and no loaded - // entry is the session's first prompt (#1). If payloads omit ordinals, the - // hasMore term alone drives exhaustion. - const shouldPaginate = hasMore && !entries.some((entry) => entry.promptNumber === 1); + // The shared hook owns the visible transcript boundary. If payloads omit + // ordinals, it retains the raw hasMore compatibility behavior. + const shouldPaginate = hasMore; const showLoading = shouldPaginate && (isLoadingMore || showLoadingGrace); const { sentinelRef, onUserGesture } = usePanelOlderPromptSentinel({ scrollRef, diff --git a/apps/web/components/task/task-chat-panel.tsx b/apps/web/components/task/task-chat-panel.tsx index 837cdbade0..07f9f710e5 100644 --- a/apps/web/components/task/task-chat-panel.tsx +++ b/apps/web/components/task/task-chat-panel.tsx @@ -15,7 +15,6 @@ import { getLastUserMessageId, getFirstUserMessageId, resolveLastPromptControls, - shouldLoadMoreForTranscriptTarget, } from "@/components/task/chat/message-list-shared"; import { AnchoredLastPromptBar } from "@/components/task/chat/anchored-last-prompt-bar"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; @@ -41,16 +40,6 @@ import { TaskChatLaunchError } from "./simple/components/task-chat-launch-error" import { useTaskLaunchErrorContext } from "./task-launch-error-context"; import { useTaskStatusSummary } from "@/hooks/domains/task/use-task-status-summary"; -/** - * Cap on how many extra pages the last-prompt background lookup will fetch - * (see the `lastPromptLookupPagesRef` effect below) before giving up and - * falling back to the transcript's manual "Load older messages" pagination. - * 3 pages of 20 covers a long single agent turn without silently draining - * an entire long-lived conversation that happens to have no recent user - * message at all. - */ -const MAX_LAST_PROMPT_LOOKUP_PAGES = 3; - /** Returns a `clarificationKey` that increments each time a pending * clarification is resolved, letting the composer reset its input state for * the next clarification round. */ @@ -449,7 +438,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({ const [isFirstMessageHidden, setIsFirstMessageHidden] = useState(false); const showScrollToStartButton = showScrollToStart && Boolean(firstMessageId) && isFirstMessageHidden; - const { loadMore, hasMore, isLoadingMore } = useLazyLoadMessages(resolvedSessionId); + const { loadMoreRaw, hasMore } = useLazyLoadMessages(resolvedSessionId); // A paginated session's `firstMessageId` only reflects the oldest message in // the currently loaded page while `hasMore` is true — jumping there directly // lands on a partial-page boundary, not the transcript's real start. Drain @@ -474,44 +463,8 @@ export const TaskChatPanel = memo(function TaskChatPanel({ messageListRef.current?.scrollToMessage(firstMessageId, { align: "start" }); } }, [hasMore, firstMessageId]); - // Bounded background lookup: the last prompt is usually within a page or - // two of a long single agent turn, but a session with no recent user - // message at all (e.g. hours of tool-only activity) would otherwise drain - // its *entire* history just to power this convenience affordance. Stop - // after a handful of pages and fall back to the manual "Load older - // messages" pagination the transcript already offers. - const lastPromptLookupPagesRef = useRef(0); - useEffect(() => { - lastPromptLookupPagesRef.current = 0; - }, [resolvedSessionId]); - useEffect(() => { - if (lastPromptMessageId !== null) { - lastPromptLookupPagesRef.current = 0; - return; - } - // Never start an older-page lookup against a stale SSR cursor while the - // session's initial/refetch load is in flight: both writes independently - // update hasMore/oldestCursor, and completion order could regress the - // pagination metadata. - if ( - messagesLoading || - isLoadingMore || - lastPromptLookupPagesRef.current >= MAX_LAST_PROMPT_LOOKUP_PAGES - ) - return; - if (!shouldLoadMoreForTranscriptTarget("last_prompt", allMessages, hasMore)) return; - lastPromptLookupPagesRef.current += 1; - void loadMore(); - }, [ - allMessages, - hasMore, - isLoadingMore, - loadMore, - lastPromptMessageId, - messagesLoading, - resolvedSessionId, - ]); - const search = useSessionSearch(resolvedSessionId, loadMore); + // Search can target backend rows before the visible transcript boundary. + const search = useSessionSearch(resolvedSessionId, loadMoreRaw); const { label: agentLabel, name: agentName } = useSessionAgentIdentity(resolvedSessionId); usePanelSearch({ containerRef: panelRef, diff --git a/apps/web/e2e/tests/chat/last-prompt-scroll.spec.ts b/apps/web/e2e/tests/chat/last-prompt-scroll.spec.ts index bdfa51aa0c..f07541ec79 100644 --- a/apps/web/e2e/tests/chat/last-prompt-scroll.spec.ts +++ b/apps/web/e2e/tests/chat/last-prompt-scroll.spec.ts @@ -76,7 +76,7 @@ test.describe("@chat last prompt scroll affordance", () => { await expect(chat.getByTestId("scroll-to-start-button")).toHaveCount(0); }); - test("loads older pages only until it finds the latest prompt", async ({ + test("keeps task opening bounded before last-prompt navigation", async ({ testPage, apiClient, seedData, @@ -110,7 +110,8 @@ test.describe("@chat last prompt scroll affordance", () => { await expect(marker).not.toBeInViewport(); const button = chat.getByTestId("scroll-to-last-prompt-button"); await expect(button).toBeVisible({ timeout: 15_000 }); - expect(olderPageRequests.length).toBeGreaterThan(0); + // Opening the task must not fetch older pages just to locate the prompt. + expect(olderPageRequests).toHaveLength(0); await expect(chat.getByText(FIRST_PROMPT_MARKER, { exact: false })).toHaveCount(0); await button.click(); await expect(marker).toBeInViewport({ timeout: 10_000 }); diff --git a/apps/web/e2e/tests/chat/message-pagination-helpers.ts b/apps/web/e2e/tests/chat/message-pagination-helpers.ts index b7c28e101e..810ab8c557 100644 --- a/apps/web/e2e/tests/chat/message-pagination-helpers.ts +++ b/apps/web/e2e/tests/chat/message-pagination-helpers.ts @@ -1,13 +1,15 @@ -import type { Locator } from "@playwright/test"; +import type { Locator, Page } from "@playwright/test"; import type { SeedData } from "../../fixtures/test-base"; import type { ApiClient } from "../../helpers/api-client"; export const TASK_DESCRIPTION_MARKER = "TASK-DESCRIPTION-FALLBACK-9R4M"; export const INITIAL_PROMPT_MARKER = "INITIAL-PROMPT-MARKER-7Q2X"; export const RECENT_AGENT_MARKER = "RECENT-AGENT-MARKER-4K8P"; +export const PRE_PROMPT_MARKER = "HIDDEN-PRE-PROMPT-MARKER-6N3V"; +export const EAGER_HISTORY_PROMPT_MARKER = "EAGER-HISTORY-PROMPT-MARKER-3J6W"; -/** Seeds a user prompt followed by one collapsed same-turn tool history. */ -export async function seedCollapsedMessageHistory( +/** Seeds an older prompt followed by a tool-only newest window. */ +export async function seedToolHeavyOpeningHistory( apiClient: ApiClient, seedData: SeedData, title: string, @@ -22,18 +24,83 @@ export async function seedCollapsedMessageHistory( state: "IDLE", repositoryId: seedData.repositoryId, }); - await apiClient.seedSessionMessage(sessionId, { type: "message", - content: INITIAL_PROMPT_MARKER, + content: EAGER_HISTORY_PROMPT_MARKER, authorType: "user", }); await apiClient.seedTaskSession(task.id, { sessionId, state: "IDLE", - commandCount: 80, + commandCount: 150, }); - await apiClient.seedToolCallMessages(sessionId, 60); + return { taskId: task.id, sessionId }; +} + +/** Captures older-page requests made after this watcher is installed. */ +export function watchOlderMessageRequests(page: Page, sessionId: string): string[] { + const requests: string[] = []; + page.on("request", (request) => { + const url = request.url(); + if (url.includes(`/task-sessions/${sessionId}/messages?`) && url.includes("before=")) { + requests.push(url); + } + }); + return requests; +} + +/** Seeds collapsed history around the visible prompt boundary. */ +export async function seedCollapsedMessageHistory( + apiClient: ApiClient, + seedData: SeedData, + title: string, + options?: { promptOutsideInitialWindow?: boolean }, +): 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, + }); + + if (options?.promptOutsideInitialWindow) { + await apiClient.seedSessionMessage(sessionId, { + type: "message", + content: INITIAL_PROMPT_MARKER, + authorType: "user", + }); + await apiClient.seedTaskSession(task.id, { + sessionId, + state: "IDLE", + commandCount: 80, + }); + await apiClient.seedToolCallMessages(sessionId, 60); + } else { + for (let i = 0; i < 20; i += 1) { + await apiClient.seedSessionMessage(sessionId, { + type: "tool_call", + content: `${PRE_PROMPT_MARKER} ${i + 1}`, + }); + } + await apiClient.seedSessionMessage(sessionId, { + type: "message", + content: INITIAL_PROMPT_MARKER, + authorType: "user", + }); + await apiClient.seedTaskSession(task.id, { + sessionId, + state: "IDLE", + commandCount: 0, + }); + // Keep the prompt at the oldest edge of the initial 100-message window: + // 98 collapsed rows plus the recent agent row follow it, while the 20 + // pre-prompt rows remain on the next backend page. + await apiClient.seedToolCallMessages(sessionId, 98); + } await apiClient.seedSessionMessage(sessionId, { type: "message", content: RECENT_AGENT_MARKER, diff --git a/apps/web/e2e/tests/chat/message-pagination.spec.ts b/apps/web/e2e/tests/chat/message-pagination.spec.ts index 97d63af745..5f12767d84 100644 --- a/apps/web/e2e/tests/chat/message-pagination.spec.ts +++ b/apps/web/e2e/tests/chat/message-pagination.spec.ts @@ -1,19 +1,49 @@ -// Chat message pagination — upward scrolling walks a collapsed conversation -// all the way back to the first stored prompt without repeated button actions. -// Covers the native transcript renderer and its prepend scroll anchoring. +// Chat message pagination — the first stored prompt is the visible transcript +// start even when older internal rows remain on the backend. import { test, expect } from "../../fixtures/test-base"; +import { dwell } from "../../helpers/causal-waits"; import { SessionPage } from "../../pages/session-page"; import { + EAGER_HISTORY_PROMPT_MARKER, INITIAL_PROMPT_MARKER, + PRE_PROMPT_MARKER, RECENT_AGENT_MARKER, TASK_DESCRIPTION_MARKER, readStandaloneMessageTop, seedCollapsedMessageHistory, + seedToolHeavyOpeningHistory, scrollToOldestLoadedEdge, + watchOlderMessageRequests, } from "./message-pagination-helpers"; test.describe("@chat message pagination", () => { - test("upward scrolling reaches the initial prompt through collapsed history", async ({ + test("does not load older history while opening a task", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(180_000); + + const { taskId, sessionId } = await seedToolHeavyOpeningHistory( + apiClient, + seedData, + "message-pagination-does-not-eager-load", + ); + const olderRequests = watchOlderMessageRequests(testPage, sessionId); + + await testPage.goto(`/t/${taskId}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await session.waitForChatIdle({ timeout: 30_000 }); + await dwell(testPage, 500, "negative-assertion", "observe background pagination after open"); + const chat = session.activeChat(); + + await expect(chat.getByText(TASK_DESCRIPTION_MARKER, { exact: true })).toBeVisible(); + await expect(chat.getByText(EAGER_HISTORY_PROMPT_MARKER, { exact: true })).toHaveCount(0); + expect(olderRequests).toHaveLength(0); + }); + + test("hides the older control when only hidden pre-prompt rows remain", async ({ testPage, apiClient, seedData, @@ -33,14 +63,41 @@ test.describe("@chat message pagination", () => { const chat = session.activeChat(); const list = chat.locator(".chat-message-list"); + await expect(chat.getByText(INITIAL_PROMPT_MARKER, { exact: true })).toBeVisible(); + await expect(chat.getByText(TASK_DESCRIPTION_MARKER, { exact: true })).toHaveCount(0); + await expect(chat.getByText(PRE_PROMPT_MARKER, { exact: false })).toHaveCount(0); + await expect(chat.getByTestId("load-older-messages")).toHaveCount(0); + + const edge = await scrollToOldestLoadedEdge(list, INITIAL_PROMPT_MARKER); + expect(Number.isFinite(edge.rowTop)).toBe(true); + await expect(chat.getByText(PRE_PROMPT_MARKER, { exact: false })).toHaveCount(0); + await expect(chat.getByTestId("load-older-messages")).toHaveCount(0); + }); + + test("preserves the prepend anchor while reaching the first prompt", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(180_000); + + const { taskId } = await seedCollapsedMessageHistory( + apiClient, + seedData, + "message-pagination-preserves-prepend-anchor", + { promptOutsideInitialWindow: true }, + ); + + 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(TASK_DESCRIPTION_MARKER, { exact: true })).toBeVisible(); await expect(chat.getByText(INITIAL_PROMPT_MARKER, { exact: true })).toHaveCount(0); - // Each upward gesture reaches the current oldest loaded edge. Older pages - // may only extend a collapsed activity row, so keep scrolling until the - // stored prompt appears. The row-position assertion belongs to each load: - // it verifies prepend anchoring without treating the user's next upward - // gesture as an anchoring regression. for (let attempt = 0; attempt < 10; attempt += 1) { const edge = await scrollToOldestLoadedEdge(list, RECENT_AGENT_MARKER); expect(Number.isFinite(edge.rowTop)).toBe(true); 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 fdb51d8278..52a9fa33c2 100644 --- a/apps/web/e2e/tests/chat/mobile-message-pagination.spec.ts +++ b/apps/web/e2e/tests/chat/mobile-message-pagination.spec.ts @@ -1,18 +1,47 @@ import { test, expect } from "../../fixtures/test-base"; +import { dwell } from "../../helpers/causal-waits"; import { SessionPage } from "../../pages/session-page"; import { + EAGER_HISTORY_PROMPT_MARKER, INITIAL_PROMPT_MARKER, + PRE_PROMPT_MARKER, RECENT_AGENT_MARKER, TASK_DESCRIPTION_MARKER, readStandaloneMessageTop, seedCollapsedMessageHistory, + seedToolHeavyOpeningHistory, scrollToOldestLoadedEdge, + watchOlderMessageRequests, } from "./message-pagination-helpers"; test.describe("Mobile chat message pagination", () => { test.describe.configure({ timeout: 180_000 }); - test("reaches the initial prompt through collapsed history by upward scrolling", async ({ + test("does not load older history while opening a task", async ({ + testPage, + apiClient, + seedData, + }) => { + const { taskId, sessionId } = await seedToolHeavyOpeningHistory( + apiClient, + seedData, + "mobile-message-pagination-does-not-eager-load", + ); + const olderRequests = watchOlderMessageRequests(testPage, sessionId); + + await testPage.goto(`/t/${taskId}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await session.waitForChatIdle({ timeout: 30_000 }); + await dwell(testPage, 500, "negative-assertion", "observe mobile pagination after open"); + const chat = session.activeChat(); + + await expect(chat.getByText(TASK_DESCRIPTION_MARKER, { exact: true })).toBeVisible(); + await expect(chat.getByText(EAGER_HISTORY_PROMPT_MARKER, { exact: true })).toHaveCount(0); + expect(olderRequests).toHaveLength(0); + }); + + test("hides the older control when only hidden pre-prompt rows remain", async ({ testPage, apiClient, seedData, @@ -30,6 +59,36 @@ test.describe("Mobile chat message pagination", () => { const chat = session.activeChat(); const list = chat.locator(".chat-message-list"); + await expect(chat.getByText(INITIAL_PROMPT_MARKER, { exact: true })).toBeVisible(); + await expect(chat.getByText(TASK_DESCRIPTION_MARKER, { exact: true })).toHaveCount(0); + await expect(chat.getByText(PRE_PROMPT_MARKER, { exact: false })).toHaveCount(0); + await expect(chat.getByTestId("load-older-messages")).toHaveCount(0); + + const edge = await scrollToOldestLoadedEdge(list, INITIAL_PROMPT_MARKER); + expect(Number.isFinite(edge.rowTop)).toBe(true); + await expect(chat.getByText(PRE_PROMPT_MARKER, { exact: false })).toHaveCount(0); + await expect(chat.getByTestId("load-older-messages")).toHaveCount(0); + }); + + test("preserves the prepend anchor while reaching the first prompt", async ({ + testPage, + apiClient, + seedData, + }) => { + const { taskId } = await seedCollapsedMessageHistory( + apiClient, + seedData, + "mobile-message-pagination-preserves-prepend-anchor", + { promptOutsideInitialWindow: true }, + ); + + 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(TASK_DESCRIPTION_MARKER, { exact: true })).toBeVisible(); await expect(chat.getByText(INITIAL_PROMPT_MARKER, { exact: true })).toHaveCount(0); diff --git a/apps/web/hooks/domains/session/message-backfill.ts b/apps/web/hooks/domains/session/message-backfill.ts deleted file mode 100644 index 03a160b448..0000000000 --- a/apps/web/hooks/domains/session/message-backfill.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { useAppStoreApi } from "@/components/state-provider"; -import { createDebugLogger } from "@/lib/debug/log"; -import type { Message } from "@/lib/types/http"; -import { requestOlderMessages } from "./older-message-pagination"; - -const BACKFILL_PAGE_LIMIT = 100; -const debug = createDebugLogger("messages:fetch"); - -export const MAX_AUTO_BACKFILL_PAGES = 10; -// The backfill ceiling is a MESSAGE budget (was MAX_AUTO_BACKFILL_PAGES * -// BACKFILL_PAGE_LIMIT pages); the permitted page count derives from the -// coordinator's actual first-request-wins page size, so a 20-row winner -// reaches the same maximum message depth as a 100-row winner. -export const MAX_AUTO_BACKFILL_MESSAGES = MAX_AUTO_BACKFILL_PAGES * BACKFILL_PAGE_LIMIT; -export type BackfillStep = "continue" | "stop"; - -type IsActive = () => boolean; -type SessionMessageStore = ReturnType; - -function isInactive(isActive?: IsActive): boolean { - return isActive !== undefined && !isActive(); -} - -export function hasUserOrAgentMessage(messages: Message[]): boolean { - return messages.some( - (message) => - message.type === "message" && - (message.author_type === "user" || message.author_type === "agent"), - ); -} - -async function fetchAndPrependOlder( - sessionId: string, - store: SessionMessageStore, - oldestCursor: string, - isActive?: IsActive, -): Promise<{ count: number; effectiveLimit: number }> { - const result = await requestOlderMessages({ - sessionId, - cursor: oldestCursor, - limit: BACKFILL_PAGE_LIMIT, - store, - }); - if (isInactive(isActive)) return { count: 0, effectiveLimit: result.effectiveLimit }; - return { count: result.count, effectiveLimit: result.effectiveLimit }; -} - -async function runBackfillRoundWithBudget( - sessionId: string, - store: SessionMessageStore, - round: number, - isActive?: IsActive, -): Promise<{ step: BackfillStep; consumed: number }> { - if (isInactive(isActive)) return { step: "stop", consumed: 0 }; - const meta = store.getState().messages.metaBySession[sessionId]; - const messages = store.getState().messages.bySession[sessionId] ?? []; - if (hasUserOrAgentMessage(messages)) return { step: "stop", consumed: 0 }; - if (!meta?.hasMore || !meta.oldestCursor) { - debug("autoBackfill: stopping (no more older messages)", { - sessionId, - round, - hasMore: meta?.hasMore ?? false, - }); - return { step: "stop", consumed: 0 }; - } - debug("autoBackfill: window has no user/agent message, fetching older", { - sessionId, - round, - currentCount: messages.length, - oldestCursor: meta.oldestCursor, - }); - try { - const { count, effectiveLimit } = await fetchAndPrependOlder( - sessionId, - store, - meta.oldestCursor, - isActive, - ); - if (isInactive(isActive)) return { step: "stop", consumed: 0 }; - return { step: count === 0 ? "stop" : "continue", consumed: effectiveLimit }; - } catch (err) { - debug("autoBackfill: fetch failed, stopping", { sessionId, round, err }); - return { step: "stop", consumed: 0 }; - } -} - -export async function runBackfillRound( - sessionId: string, - store: SessionMessageStore, - round: number, - isActive?: IsActive, -): Promise { - const { step } = await runBackfillRoundWithBudget(sessionId, store, round, isActive); - return step; -} - -export async function autoBackfillUntilUserMessage( - sessionId: string, - store: SessionMessageStore, - isActive?: IsActive, -): Promise { - let messageBudget = MAX_AUTO_BACKFILL_MESSAGES; - let round = 0; - while (messageBudget > 0) { - if (isInactive(isActive)) return; - const { step, consumed } = await runBackfillRoundWithBudget(sessionId, store, round, isActive); - if (step === "stop") return; - messageBudget -= consumed; - round += 1; - } - debug("autoBackfill: hit message budget without finding user/agent message", { - sessionId, - pageBudget: round, - messageBudget: MAX_AUTO_BACKFILL_MESSAGES, - }); -} diff --git a/apps/web/hooks/domains/session/use-session-message-fetch.test.ts b/apps/web/hooks/domains/session/use-session-message-fetch.test.ts index 99d5679b1e..5c5be23dcf 100644 --- a/apps/web/hooks/domains/session/use-session-message-fetch.test.ts +++ b/apps/web/hooks/domains/session/use-session-message-fetch.test.ts @@ -30,12 +30,28 @@ function makeParams( initialFetchStartRef: { current: null }, lastFetchedSessionIdRef: { current: null }, fetchAndStoreMessages, - autoBackfillUntilUserMessage: vi.fn().mockResolvedValue(undefined), - hasUserOrAgentMessage: vi.fn().mockReturnValue(true), }; } describe("doFetchMessages", () => { + it("settles a tool-only initial fetch without waiting for older history", async () => { + const setMessagesLoading = vi.fn(); + const params = makeParams( + vi.fn().mockResolvedValue([ + { + id: "tool-1", + type: "tool_call", + author_type: "agent", + } as Message, + ]), + setMessagesLoading, + ); + + await doFetchMessages(params as never); + + expect(setMessagesLoading).toHaveBeenLastCalledWith(SESSION_ID, false); + }); + it("keeps the shared loading flag set until overlapping fetches all settle", async () => { const first = deferred(); const second = deferred(); diff --git a/apps/web/hooks/domains/session/use-session-message-fetch.ts b/apps/web/hooks/domains/session/use-session-message-fetch.ts index 70b759f6ec..174b936c4a 100644 --- a/apps/web/hooks/domains/session/use-session-message-fetch.ts +++ b/apps/web/hooks/domains/session/use-session-message-fetch.ts @@ -41,12 +41,6 @@ type DoFetchMessagesParams = { store: SessionMessageStore, isActive?: () => boolean, ) => Promise; - autoBackfillUntilUserMessage: ( - sessionId: string, - store: SessionMessageStore, - isActive?: () => boolean, - ) => Promise; - hasUserOrAgentMessage: (messages: Message[]) => boolean; onError?: (error: unknown) => void; isActive?: () => boolean; }; @@ -59,8 +53,6 @@ export async function doFetchMessages({ initialFetchStartRef, lastFetchedSessionIdRef, fetchAndStoreMessages, - autoBackfillUntilUserMessage, - hasUserOrAgentMessage, onError, isActive, }: DoFetchMessagesParams): Promise { @@ -77,9 +69,6 @@ export async function doFetchMessages({ if (isInactive(isActive)) return; lastFetchedSessionIdRef.current = taskSessionId; if (fetched.length > 0) setIsWaitingForInitialMessages(false); - if (fetched.length > 0 && !hasUserOrAgentMessage(fetched)) { - await autoBackfillUntilUserMessage(taskSessionId, store, isActive); - } } catch (error) { if (isInactive(isActive)) return; if (onError) onError(error); diff --git a/apps/web/hooks/domains/session/use-session-messages.test.ts b/apps/web/hooks/domains/session/use-session-messages.test.ts index 043f431a47..59174778d9 100644 --- a/apps/web/hooks/domains/session/use-session-messages.test.ts +++ b/apps/web/hooks/domains/session/use-session-messages.test.ts @@ -2,7 +2,6 @@ import { act, renderHook } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import type { Message } from "@/lib/types/http"; -const mockListTaskSessionMessages = vi.fn(); const mockListSessionTurns = vi.fn(); const mockWebSocketClient = { getSessionSubscriptionReadiness: vi.fn(), @@ -40,10 +39,6 @@ const mockState = { reconcileActiveTurnAfterHydration: vi.fn(), }; -vi.mock("@/lib/api", () => ({ - listTaskSessionMessages: (...args: unknown[]) => mockListTaskSessionMessages(...args), -})); - vi.mock("@/lib/api/domains/session-api", () => ({ listSessionTurns: (...args: unknown[]) => mockListSessionTurns(...args), })); @@ -61,7 +56,6 @@ import { taskId, sessionId } from "@/lib/types/ids"; beforeEach(() => { vi.clearAllMocks(); - mockListTaskSessionMessages.mockResolvedValue({ messages: [], has_more: false }); mockListSessionTurns.mockResolvedValue({ turns: [], total: 0 }); mockWebSocketClient.request.mockResolvedValue({ messages: [], has_more: false }); mockWebSocketClient.subscribeSession.mockReturnValue(vi.fn()); @@ -91,15 +85,11 @@ afterEach(() => { }); import { hasUserPromptInActiveTurn, - hasUserOrAgentMessage, isTurnSettleTransition, shouldRunMessageBackfill, shouldRetryUnknownSessionSubscription, - runBackfillRound, - autoBackfillUntilUserMessage, nextFetchSeq, commitFetchSeq, - MAX_AUTO_BACKFILL_PAGES, useSessionMessages, } from "./use-session-messages"; import type { TaskSessionState } from "@/lib/types/http"; @@ -117,79 +107,6 @@ function makeMessage(overrides: Partial): Message { } as Message; } -/** Stateful store mock — prependMessages actually updates the stored messages - * and the shared coordinator's setMessagesMetadata tracks isLoadingMore. */ -function makeStore(options: { - messages?: Message[]; - hasMore?: boolean; - oldestCursor?: string | null; -}) { - let messages: Message[] = options.messages ?? []; - let meta = { - hasMore: options.hasMore ?? false, - oldestCursor: options.oldestCursor ?? null, - isLoading: false, - isLoadingMore: false, - }; - const prependMessages = vi.fn( - ( - _sessionId: string, - newMsgs: Message[], - newMeta: { hasMore: boolean; oldestCursor: string | null }, - ) => { - messages = [...newMsgs, ...messages]; - meta = { ...meta, ...newMeta, isLoadingMore: false }; - }, - ); - const setMessagesMetadata = vi.fn((_sessionId: string, patch: { isLoadingMore?: boolean }) => { - meta = { ...meta, ...patch }; - }); - return { - getState: () => ({ - messages: { - bySession: { "sess-1": messages }, - metaBySession: { "sess-1": meta }, - }, - prependMessages, - setMessagesMetadata, - }), - _prependMessages: prependMessages, - _setMessagesMetadata: setMessagesMetadata, - }; -} - -describe("hasUserOrAgentMessage", () => { - it("returns true for a user message", () => { - expect(hasUserOrAgentMessage([makeMessage({ type: "message", author_type: "user" })])).toBe( - true, - ); - }); - - it("returns true for an agent message", () => { - expect(hasUserOrAgentMessage([makeMessage({ type: "message", author_type: "agent" })])).toBe( - true, - ); - }); - - it("returns false for tool_call only", () => { - expect(hasUserOrAgentMessage([makeMessage({ type: "tool_call", author_type: "agent" })])).toBe( - false, - ); - }); - - it("returns false for empty array", () => { - expect(hasUserOrAgentMessage([])).toBe(false); - }); - - it("returns true when mixed messages include a user message", () => { - const msgs = [ - makeMessage({ id: "t1", type: "tool_call", author_type: "agent" }), - makeMessage({ id: "u1", type: "message", author_type: "user" }), - ]; - expect(hasUserOrAgentMessage(msgs)).toBe(true); - }); -}); - describe("isTurnSettleTransition", () => { const settled: TaskSessionState[] = [ "IDLE", @@ -335,74 +252,6 @@ describe("unknown session subscription retry guard", () => { }); }); -describe("runBackfillRound", () => { - it("returns 'stop' when a user/agent message already exists in the store", async () => { - const store = makeStore({ - messages: [makeMessage({ type: "message", author_type: "user" })], - hasMore: true, - oldestCursor: "msg-1", - }); - const result = await runBackfillRound("sess-1", store as never, 0); - expect(result).toBe("stop"); - expect(mockListTaskSessionMessages).not.toHaveBeenCalled(); - }); - - it("returns 'stop' when hasMore is false", async () => { - const store = makeStore({ messages: [], hasMore: false, oldestCursor: "msg-1" }); - const result = await runBackfillRound("sess-1", store as never, 0); - expect(result).toBe("stop"); - }); - - it("returns 'stop' when oldestCursor is null", async () => { - const store = makeStore({ messages: [], hasMore: true, oldestCursor: null }); - const result = await runBackfillRound("sess-1", store as never, 0); - expect(result).toBe("stop"); - }); - - it("returns 'continue' and calls prependMessages when older messages are fetched", async () => { - mockListTaskSessionMessages.mockResolvedValue({ - messages: [makeMessage({ id: "old-1" })], - has_more: true, - }); - const store = makeStore({ messages: [], hasMore: true, oldestCursor: "msg-1" }); - const result = await runBackfillRound("sess-1", store as never, 0); - expect(result).toBe("continue"); - expect(store._prependMessages).toHaveBeenCalled(); - }); - - it("returns 'stop' when fetched batch is empty", async () => { - mockListTaskSessionMessages.mockResolvedValue({ messages: [], has_more: true }); - const store = makeStore({ messages: [], hasMore: true, oldestCursor: "msg-1" }); - const result = await runBackfillRound("sess-1", store as never, 0); - expect(result).toBe("stop"); - }); - - it("returns 'stop' on fetch error", async () => { - mockListTaskSessionMessages.mockRejectedValue(new Error("network error")); - const store = makeStore({ messages: [], hasMore: true, oldestCursor: "msg-1" }); - const result = await runBackfillRound("sess-1", store as never, 0); - expect(result).toBe("stop"); - }); - - it("stops the round when cleanup occurs during the fetch, without a second request", async () => { - const request = deferred<{ messages: Message[]; has_more: boolean }>(); - mockListTaskSessionMessages.mockReturnValueOnce(request.promise); - const store = makeStore({ messages: [], hasMore: true, oldestCursor: "msg-1" }); - let active = true; - const round = runBackfillRound("sess-1", store as never, 0, () => active); - - expect(mockListTaskSessionMessages).toHaveBeenCalledTimes(1); - active = false; - request.resolve({ messages: [makeMessage({ id: "old-1" })], has_more: true }); - - await expect(round).resolves.toBe("stop"); - // The round stops and the coordinator settles its loading flag; no second - // page is requested after deactivation. - expect(mockListTaskSessionMessages).toHaveBeenCalledTimes(1); - expect(store._setMessagesMetadata).toHaveBeenCalledWith("sess-1", { isLoadingMore: false }); - }); -}); - describe("stale concurrent fetch guard", () => { it("rejects an older fetch that completes after a newer one merged", () => { const sid = "guard-sess-a"; @@ -429,92 +278,6 @@ describe("stale concurrent fetch guard", () => { }); }); -describe("autoBackfillUntilUserMessage", () => { - it("continues past three pages until a user/agent message is found", async () => { - mockListTaskSessionMessages - .mockResolvedValueOnce({ - messages: [makeMessage({ id: "tool-1", type: "tool_call", author_type: "agent" })], - has_more: true, - }) - .mockResolvedValueOnce({ - messages: [makeMessage({ id: "tool-2", type: "tool_call", author_type: "agent" })], - has_more: true, - }) - .mockResolvedValueOnce({ - messages: [makeMessage({ id: "tool-3", type: "tool_call", author_type: "agent" })], - has_more: true, - }) - .mockResolvedValueOnce({ - messages: [makeMessage({ id: "tool-4", type: "tool_call", author_type: "agent" })], - has_more: true, - }) - .mockResolvedValueOnce({ - messages: [makeMessage({ id: "user-1", type: "message", author_type: "user" })], - has_more: true, - }); - const store = makeStore({ messages: [], hasMore: true, oldestCursor: "cursor-0" }); - - await autoBackfillUntilUserMessage("sess-1", store as never); - - expect(mockListTaskSessionMessages).toHaveBeenCalledTimes(5); - }); - - it("stops after the auto-backfill page budget without finding a user/agent message", async () => { - mockListTaskSessionMessages.mockResolvedValue({ - messages: [makeMessage({ id: "t1", type: "tool_call", author_type: "agent" })], - has_more: true, - }); - const store = makeStore({ messages: [], hasMore: true, oldestCursor: "cursor-0" }); - await autoBackfillUntilUserMessage("sess-1", store as never); - expect(mockListTaskSessionMessages).toHaveBeenCalledTimes(MAX_AUTO_BACKFILL_PAGES); - }); - - it("derives the page budget from the first-request-wins page size (message budget)", async () => { - // A concurrent panel request wins the shared cursor with limit 20 before - // the backfill's round-1 request starts, so that round joins the - // effective 20-row page. The 1000-message budget then permits the - // remaining rounds at 100 rows each — the same maximum message depth a - // 100-row winner would reach. - let calls = 0; - mockListTaskSessionMessages.mockImplementation(() => { - calls++; - return Promise.resolve({ - messages: [makeMessage({ id: `tool-${calls}`, type: "tool_call", author_type: "agent" })], - has_more: true, - }); - }); - const store = makeStore({ messages: [], hasMore: true, oldestCursor: "cursor-0" }); - const { requestOlderMessages } = await import("./older-message-pagination"); - // Synchronously start the panel request; the backfill's round-1 request - // for the same cursor joins it (first-request-wins limit 20). - const seed = requestOlderMessages({ - sessionId: "sess-1", - cursor: "cursor-0", - limit: 20, - store: store as never, - }); - - await autoBackfillUntilUserMessage("sess-1", store as never); - await seed; - - // 1 seed request + 1 joined round (20 rows) + 9 × 100-row rounds + 1 - // final 100-row round (budget 1000 − 20 = 980; 9 full rounds, then one - // more overshoot) = 11 total requests (1 seed + 10 backfill fetches). - expect(mockListTaskSessionMessages).toHaveBeenCalledTimes(11); - }); - - it("stops after round 1 once a user message is prepended", async () => { - mockListTaskSessionMessages.mockResolvedValue({ - messages: [makeMessage({ id: "u1", type: "message", author_type: "user" })], - has_more: true, - }); - const store = makeStore({ messages: [], hasMore: true, oldestCursor: "cursor-0" }); - await autoBackfillUntilUserMessage("sess-1", store as never); - // After round 0, prependMessages adds the user message; round 1 finds it and stops. - expect(mockListTaskSessionMessages).toHaveBeenCalledTimes(1); - }); -}); - function deferred() { let resolve!: (value: T | PromiseLike) => void; const promise = new Promise((resolvePromise) => { diff --git a/apps/web/hooks/domains/session/use-session-messages.ts b/apps/web/hooks/domains/session/use-session-messages.ts index a83ea86502..c1b9c4d977 100644 --- a/apps/web/hooks/domains/session/use-session-messages.ts +++ b/apps/web/hooks/domains/session/use-session-messages.ts @@ -10,17 +10,8 @@ import { useUnknownSessionSubscriptionRetryEffect, } from "./use-session-subscription-retry"; import { doFetchMessages } from "./use-session-message-fetch"; -import { autoBackfillUntilUserMessage, hasUserOrAgentMessage } from "./message-backfill"; import { t } from "@/lib/i18n"; -export { - autoBackfillUntilUserMessage, - type BackfillStep, - hasUserOrAgentMessage, - MAX_AUTO_BACKFILL_PAGES, - runBackfillRound, -} from "./message-backfill"; - export { shouldRetryUnknownSessionSubscription } from "./use-session-subscription-retry"; // Test seam: exported for direct unit coverage of hydration dedup/guards. export { ensureSessionTurnsLoaded } from "./use-session-turns-hydration"; @@ -314,8 +305,6 @@ function useTerminalStateFetch( taskSessionId, ...refs, fetchAndStoreMessages, - autoBackfillUntilUserMessage, - hasUserOrAgentMessage, onError: (error) => console.error("Failed to fetch messages after state change:", error), }); }, [taskSessionId, taskSessionState, hasAgentMessage, connectionStatus, refs]); @@ -639,8 +628,6 @@ export function useSessionMessages(taskSessionId: string | null): UseSessionMess taskSessionId, ...fetchRefs, fetchAndStoreMessages, - autoBackfillUntilUserMessage, - hasUserOrAgentMessage, isActive: () => active, }); return deactivate; diff --git a/apps/web/hooks/use-lazy-load-messages.test.ts b/apps/web/hooks/use-lazy-load-messages.test.ts index 2d674e624f..2a19032871 100644 --- a/apps/web/hooks/use-lazy-load-messages.test.ts +++ b/apps/web/hooks/use-lazy-load-messages.test.ts @@ -11,7 +11,7 @@ const storeMock = vi.hoisted(() => ({ isLoadingMore: false, }, prepended: [] as Array<{ id: string }>, - bySession: [] as Array<{ id: string; author_type?: string }>, + bySession: [] as Array<{ id: string; author_type?: string; prompt_index?: number }>, setMessagesMetadata: vi.fn(), })); @@ -59,11 +59,15 @@ function wireResponse(ids: string[], hasMore: boolean) { }; } -function wireTypedResponse(entries: Array<{ id: string; author_type?: string }>, hasMore: boolean) { +function wireTypedResponse( + entries: Array<{ id: string; author_type?: string; prompt_index?: number }>, + hasMore: boolean, +) { return { messages: entries.map((entry) => ({ id: entry.id, author_type: entry.author_type ?? "agent", + prompt_index: entry.prompt_index, created_at: "2026-01-01T00:00:00.000000Z", })), has_more: hasMore, @@ -71,6 +75,30 @@ function wireTypedResponse(entries: Array<{ id: string; author_type?: string }>, } describe("useLazyLoadMessages loadMore", () => { + it("hides older pages once the first user prompt is loaded", async () => { + storeMock.bySession = [{ id: "first", author_type: "user", prompt_index: 1 }]; + const { result } = renderHook(() => useLazyLoadMessages("s1")); + + expect(result.current.hasMore).toBe(false); + await act(async () => { + await result.current.loadMore(); + }); + expect(listTaskSessionMessages).not.toHaveBeenCalled(); + }); + + it("keeps raw backfill available after the visible prompt boundary", async () => { + storeMock.bySession = [{ id: "first", author_type: "user", prompt_index: 1 }]; + listTaskSessionMessages.mockResolvedValueOnce(wireResponse(["hidden"], false)); + const { result } = renderHook(() => useLazyLoadMessages("s1")); + + expect(result.current.hasMore).toBe(false); + expect(result.current.rawHasMore).toBe(true); + await act(async () => { + await result.current.loadMoreRaw(); + }); + expect(listTaskSessionMessages).toHaveBeenCalledTimes(1); + }); + it("joins an in-flight request for the same cursor instead of skipping", async () => { let resolvePage: (value: unknown) => void = () => {}; listTaskSessionMessages.mockReturnValueOnce( @@ -127,6 +155,13 @@ describe("useLazyLoadMessages loadMore", () => { }); expect(listTaskSessionMessages).not.toHaveBeenCalled(); }); + + it("keeps raw pagination when loaded messages have no prompt ordinal", () => { + storeMock.bySession = [{ id: "legacy-user", author_type: "user" }]; + const { result } = renderHook(() => useLazyLoadMessages("s1")); + + expect(result.current.hasMore).toBe(true); + }); }); describe("useLazyLoadMessages minUserPromptsPerLoad", () => { @@ -213,6 +248,31 @@ describe("useLazyLoadMessages minUserPromptsPerLoad", () => { expect(listTaskSessionMessages).toHaveBeenCalledTimes(2); }); + it("stops accumulating when a page loads prompt #1", async () => { + listTaskSessionMessages + .mockResolvedValueOnce( + wireTypedResponse( + [ + { id: "m3", author_type: "agent" }, + { id: "m2", author_type: "user", prompt_index: 2 }, + ], + true, + ), + ) + .mockResolvedValueOnce( + wireTypedResponse([{ id: "m1", author_type: "user", prompt_index: 1 }], true), + ) + .mockResolvedValueOnce(wireTypedResponse([{ id: "hidden" }], false)); + const { result } = renderHook(() => useLazyLoadMessages("s1", { minUserPromptsPerLoad: 3 })); + + await act(async () => { + await result.current.loadMore(); + }); + + expect(listTaskSessionMessages).toHaveBeenCalledTimes(2); + expect(storeMock.bySession.some((message) => message.prompt_index === 1)).toBe(true); + }); + it("fetches a single page when no threshold is set (transcript behavior)", async () => { listTaskSessionMessages.mockResolvedValueOnce( wireTypedResponse([{ id: "m1", author_type: "user" }], false), diff --git a/apps/web/hooks/use-lazy-load-messages.ts b/apps/web/hooks/use-lazy-load-messages.ts index fc7a196e01..9b2ade291f 100644 --- a/apps/web/hooks/use-lazy-load-messages.ts +++ b/apps/web/hooks/use-lazy-load-messages.ts @@ -5,6 +5,7 @@ import { joinOlderMessages, requestOlderMessages, } from "@/hooks/domains/session/older-message-pagination"; +import type { Message } from "@/lib/types/http"; const debug = createDebugLogger("messages:lazyload"); @@ -14,6 +15,7 @@ export const OLDER_PAGE_LIMIT = 20; * `minUserPromptsPerLoad` is set, so a pathological session cannot loop * forever. */ const MAX_PAGES_PER_LOAD = 10; +const EMPTY_MESSAGES: Message[] = []; export type LazyLoadMessagesOptions = { /** Accumulate at least this many NEW user prompts per loadMore call before @@ -36,12 +38,32 @@ function describeSkip(args: { return "no-cursor"; } +function containsFirstPrompt(messages: Message[]): boolean { + return messages.some((message) => message.author_type === "user" && message.prompt_index === 1); +} + +function visibleHasMore(rawHasMore: boolean, firstPromptLoaded: boolean): boolean { + return rawHasMore && !firstPromptLoaded; +} + +function countUserPrompts(messages: Message[]): number { + return messages.filter((message) => message.author_type === "user").length; +} + +function useVisibleHasMore(sessionId: string | null, rawHasMore: boolean): boolean { + const firstPromptLoaded = useAppStore((state) => + sessionId ? containsFirstPrompt(state.messages.bySession[sessionId] ?? EMPTY_MESSAGES) : false, + ); + 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 hasMore = useAppStore((state) => + 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, ); @@ -50,112 +72,115 @@ export function useLazyLoadMessages(sessionId: string | null, options?: LazyLoad ); // Store current values in refs to avoid recreating loadMore on every state change - const stateRef = useRef({ hasMore, oldestCursor, isLoadingMore }); + const stateRef = useRef({ hasMore, rawHasMore, oldestCursor, isLoadingMore }); useEffect(() => { - stateRef.current = { hasMore, oldestCursor, isLoadingMore }; - }, [hasMore, oldestCursor, isLoadingMore]); + stateRef.current = { hasMore, rawHasMore, oldestCursor, isLoadingMore }; + }, [hasMore, rawHasMore, oldestCursor, isLoadingMore]); const store = useAppStoreApi(); - // Fetches one older-message page at the current cursor. Stable - only - // depends on sessionId and the store API. Requests go through the shared - // per-session coordinator, which dedups per (sessionId, cursor): a - // concurrent caller (panel sentinel, backfill, last-prompt preload) joins - // the in-flight request instead of duplicating it. - const fetchPage = useCallback(async () => { - const { hasMore, isLoadingMore, oldestCursor } = stateRef.current; + // 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 0; - // Check the per-session in-flight map BEFORE the local loading guards: a - // panel callback during transcript loading or automatic backfill joins - // the existing promise (first-request-wins) instead of skipping. - const inFlight = joinOlderMessages(sessionId, oldestCursor); - if (inFlight) { - debug("loadMore: joining in-flight request", { sessionId, before: oldestCursor }); + // Check the per-session in-flight map BEFORE the local loading guards: a + // panel callback during transcript loading or automatic backfill joins + // the existing promise (first-request-wins) instead of skipping. + const inFlight = joinOlderMessages(sessionId, oldestCursor); + if (inFlight) { + debug("loadMore: joining in-flight request", { sessionId, before: oldestCursor }); + try { + const result = await inFlight; + const loadedMessages = store.getState().messages.bySession[sessionId] ?? EMPTY_MESSAGES; + stateRef.current = { + hasMore: visibleHasMore(result.hasMore, containsFirstPrompt(loadedMessages)), + rawHasMore: result.hasMore, + 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. + isLoadingMore: + store.getState().messages.metaBySession[sessionId]?.isLoadingMore ?? + stateRef.current.isLoadingMore, + }; + return result.count; + } catch (error) { + console.error("[useLazyLoadMessages] Error loading messages:", error); + return 0; + } + } + + if (!canLoad || isLoadingMore) { + debug("loadMore: skipped", { + sessionId, + reason: describeSkip({ sessionId, isLoadingMore, hasMore: canLoad }), + hasMore: canLoad, + oldestCursor, + }); + return 0; + } + + debug("loadMore: requesting older page", { + sessionId, + before: oldestCursor, + limit: OLDER_PAGE_LIMIT, + }); + + // Update ref synchronously so concurrent calls are blocked immediately + stateRef.current.isLoadingMore = true; try { - const result = await inFlight; + const result = await requestOlderMessages({ + sessionId, + cursor: oldestCursor, + 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) + const loadedMessages = store.getState().messages.bySession[sessionId] ?? EMPTY_MESSAGES; stateRef.current = { - hasMore: result.hasMore, + hasMore: visibleHasMore(result.hasMore, containsFirstPrompt(loadedMessages)), + rawHasMore: result.hasMore, 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. - isLoadingMore: - store.getState().messages.metaBySession[sessionId]?.isLoadingMore ?? - stateRef.current.isLoadingMore, + isLoadingMore: false, }; return result.count; } catch (error) { console.error("[useLazyLoadMessages] Error loading messages:", error); + debug("loadMore: error", { sessionId, error }); + stateRef.current.isLoadingMore = false; return 0; } - } + }, + [sessionId, store], + ); - if (!hasMore || isLoadingMore) { - debug("loadMore: skipped", { - sessionId, - reason: describeSkip({ sessionId, isLoadingMore, hasMore }), - hasMore, - oldestCursor, - }); - return 0; - } - - debug("loadMore: requesting older page", { - sessionId, - before: oldestCursor, - limit: OLDER_PAGE_LIMIT, - }); - - // Update ref synchronously so concurrent calls are blocked immediately - stateRef.current.isLoadingMore = true; - try { - const result = await requestOlderMessages({ - sessionId, - cursor: oldestCursor, - 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) - stateRef.current = { - hasMore: result.hasMore, - oldestCursor: result.oldestCursor, - isLoadingMore: false, - }; - return result.count; - } catch (error) { - console.error("[useLazyLoadMessages] Error loading messages:", error); - debug("loadMore: error", { sessionId, error }); - stateRef.current.isLoadingMore = false; - return 0; - } - }, [sessionId, store]); - - // With `minUserPromptsPerLoad`, one sentinel trigger accumulates at least - // that many NEW user prompts: message pages contain agent replies too, so a - // single page can yield only a few prompts. Loops until the threshold, - // pagination exhaustion, a zero-result page, or MAX_PAGES_PER_LOAD. + // `minUserPromptsPerLoad` accumulates prompts across pages until the + // threshold, exhaustion, a zero-result page, or the safety cap. const loadMore = useCallback(async () => { if (!minUserPromptsPerLoad || !sessionId) return fetchPage(); - const countUserPrompts = () => - (store.getState().messages.bySession[sessionId] ?? []).filter( - (message) => message.author_type === "user", - ).length; - const before = countUserPrompts(); + 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 (countUserPrompts() - before >= minUserPromptsPerLoad) break; + if (countPrompts() - before >= minUserPromptsPerLoad) break; } return total; }, [fetchPage, minUserPromptsPerLoad, sessionId, store]); - return { loadMore, hasMore, isLoadingMore }; + const loadMoreRaw = useCallback(() => fetchPage("raw"), [fetchPage]); + + return { loadMore, loadMoreRaw, hasMore, rawHasMore, isLoadingMore }; } diff --git a/docs/plans/hide-redundant-older-messages-control/plan.md b/docs/plans/hide-redundant-older-messages-control/plan.md new file mode 100644 index 0000000000..bfda2e8828 --- /dev/null +++ b/docs/plans/hide-redundant-older-messages-control/plan.md @@ -0,0 +1,135 @@ +--- +created: 2026-08-24 +status: done +requirements: + - REQ-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001 +system_design: + - ../../specs/ui/system-design/task-prompt-transcript-visibility.md +legacy_specs: [] +--- + +# Implementation Plan: Hide the Redundant Older-Messages Control + +## Overview + +The transcript will stop visible pagination when it loads the first user prompt. The fix will use the existing prompt ordinal and shared pagination hook. + +The backend can report older rows after prompt `#1`. These rows contain internal session activity and do not precede the visible transcript start. + +## Scope + +### In scope + +- Derive visible transcript pagination from raw `has_more` and the loaded prompt ordinals. +- Apply the boundary to automatic loading, transcript navigation, and the explicit older-page control. +- Keep raw backend pagination metadata in the store. +- Preserve an explicit raw-pagination path for reverse-search backfill, while keeping transcript consumers on visible pagination. +- Keep task opening bounded to the newest message window. +- Load older transcript pages only after upward navigation or an explicit recovery action. +- Extend desktop and mobile pagination coverage with hidden pre-prompt rows. +- Retain desktop and mobile prepend-anchor coverage for older-page loading. + +### Out of scope + +- Backend queries, API fields, message persistence, or schema changes. +- Changes to transcript ordering or message visibility filters. +- Removal of the older-page recovery control before prompt `#1` is known. +- New copy, translation keys, layout, navigation, or touch behavior. + +## Confirmed root cause + +`useLazyLoadMessages` returns `messages.metaBySession[sessionId].hasMore` without a visible-start check. The value remains true when internal rows exist before prompt `#1`. + +The Prompt History panel already stops at prompt `#1`. The native transcript does not use that boundary. + +A throwaway hook test loaded prompt `#1` with raw `has_more: true`. The test expected false but received true. + +The focused run reported one failed regression and seven passing tests. The throwaway test was removed after the run. + +## Technical approach + +### Shared visible pagination + +- Add one helper that detects a loaded user message with `prompt_index === 1`. +- Make `useLazyLoadMessages` return raw `hasMore` only while prompt `#1` is absent. +- Recalculate this value after joined and completed requests. +- Use the recalculated value in the multi-page accumulation loop. +- Keep `requestOlderMessages` and stored pagination metadata unchanged. +- Expose raw pagination separately for direct recovery consumers. + +### Consumer alignment + +- Keep the native transcript, transcript navigation, and drain hook on `useLazyLoadMessages`. +- Keep transcript navigation on visible pagination, and let session search use the raw loader when a backend hit is before prompt `#1`. +- Remove duplicate prompt-`#1` pagination logic from the Prompt History panel if the shared hook owns the complete rule. +- Keep request coordination shared between visible and raw loaders. + +### Bounded task opening + +- Remove background pagination that locates the last prompt when a task opens. +- Remove initial backfill for a tool-only newest window. +- Keep the task-description fallback as the readable start until upward navigation loads a prompt. +- Preserve a stable message-row position across each older-page request. + +### Browser regression + +- Seed more than one page of hidden setup rows before the first user prompt. +- Keep the existing long collapsed activity history after the prompt. +- Scroll to prompt `#1` and stop before the hidden rows load. +- Assert that the older-page control is absent on desktop and mobile. +- Preserve the existing prepend-anchor assertion in a separate desktop and mobile flow. + +## Tests + +| Acceptance criterion | Test evidence | +| --- | --- | +| `AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.4` | Existing native sentinel tests remain green. | +| `AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.5` | Desktop and mobile tests load collapsed older history after upward navigation. | +| `AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.6` | Desktop and mobile tests reach the first prompt without another action. | +| `AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.7` | The hook stops a multi-page load at prompt `#1`. | +| `AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.9` | The hook and browser tests show no visible older history at prompt `#1`. | + +## E2E tests + +- Desktop: `apps/web/e2e/tests/chat/message-pagination.spec.ts`. +- Mobile: `apps/web/e2e/tests/chat/mobile-message-pagination.spec.ts` in the `mobile-chrome` project. +- Shared seed: `apps/web/e2e/tests/chat/message-pagination-helpers.ts`. + +The shared seed will add hidden pre-prompt rows. Both tests already assert that prompt `#1` is visible and the control is absent. + +## Mobile design contract + +- Desktop outcome: the transcript removes the older-page control at prompt `#1`. +- Mobile entry point: the existing Chat tab in the full-height task layout. +- Nearest exemplar: `apps/web/components/task/task-layout.tsx` and the current mobile pagination test. +- Hierarchy and surface: no composition change. The transcript remains the only vertical scroll owner. +- Shared logic: desktop and mobile use the same store, pagination hook, native transcript, and cursor coordinator. +- Mobile proof: the existing mobile pagination test will include hidden pre-prompt rows and assert the same result. + +## Work orders + +- [x] [Task 01: Stop visible pagination at the first prompt](task-01-stop-visible-pagination-at-first-prompt.md) +- [x] [Task 02: Stop eager history loading on chat open](task-02-stop-eager-history-loading.md) + +## Verification results + +- `pnpm --filter @kandev/web test -- hooks/use-lazy-load-messages.test.ts components/task/prompt-history-panel-content.test.tsx components/task/chat/use-drain-older-messages.test.ts` — 3 files, 73 tests passed. +- `pnpm --filter @kandev/web run typecheck` — passed. +- `pnpm --filter @kandev/web lint` — passed. +- `pnpm e2e:run --project chromium tests/chat/message-pagination.spec.ts` — 2 tests passed, including the visible-boundary and prepend-anchor flows. +- `pnpm e2e:run --project mobile-chrome tests/chat/mobile-message-pagination.spec.ts` — 2 tests passed, including the visible-boundary and prepend-anchor flows. +- `pnpm exec vitest run components/task/chat/message-list-shared.test.tsx hooks/domains/session/use-session-message-fetch.test.ts hooks/domains/session/use-session-messages.test.ts` — 3 files, 66 tests passed. +- `pnpm --filter @kandev/web run typecheck` — passed after the bounded-opening change. +- `pnpm --filter @kandev/web lint` — passed after the bounded-opening change. +- `pnpm --filter @kandev/web run i18n:ratchet` — passed. +- `pnpm --filter @kandev/web run e2e:sleep-ratchet` — passed. +- `pnpm e2e:run --host --no-build --project chromium -- tests/chat/message-pagination.spec.ts` — 3 tests passed. +- `pnpm e2e:run --host --no-build --project mobile-chrome -- tests/chat/mobile-message-pagination.spec.ts` — 3 tests passed. + +## Risks + +- The raw store value must remain available to low-level recovery code. +- A joined request must update the local hook state with the visible boundary. +- The multi-page load loop must stop without one extra pre-prompt request. +- Payloads without prompt ordinals must retain raw `has_more` behavior. +- The browser seed must create a stable page boundary around prompt `#1`. diff --git a/docs/plans/hide-redundant-older-messages-control/task-01-stop-visible-pagination-at-first-prompt.md b/docs/plans/hide-redundant-older-messages-control/task-01-stop-visible-pagination-at-first-prompt.md new file mode 100644 index 0000000000..4fa3285672 --- /dev/null +++ b/docs/plans/hide-redundant-older-messages-control/task-01-stop-visible-pagination-at-first-prompt.md @@ -0,0 +1,108 @@ +--- +id: "01-stop-visible-pagination-at-first-prompt" +title: "Stop visible pagination at the first prompt" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001 +acceptance_criteria: + - AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.4 + - AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.7 + - AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.9 +system_design: + - ../../specs/ui/system-design/task-prompt-transcript-visibility.md +--- + +# Task 01: Stop Visible Pagination at the First Prompt + +## Summary + +Make the shared older-message hook stop at loaded prompt `#1`. Prove the behavior with hook tests and the existing desktop and mobile pagination flows. + +## In scope + +- Derive visible `hasMore` in `useLazyLoadMessages`. +- Update joined and completed request state with the same boundary. +- Stop the multi-page loop at prompt `#1`. +- Align the Prompt History panel with the shared boundary. +- Preserve raw pagination for explicit session-search backfill. +- Seed hidden pre-prompt rows in the shared browser fixture. +- Preserve the prepend-anchor regression in desktop and mobile flows. + +## Out of scope + +- Backend, API, persistence, and schema changes. +- Changes to visible message filtering. +- Removal of the recovery control before the first prompt is known. +- Layout, copy, and translation changes. + +## Acceptance + +- Prompt `#1` makes the shared hook report no visible older history, even when raw `has_more` is true. +- Missing prompt ordinals keep the current raw-pagination fallback. +- Reverse search can backfill raw session history without changing the visible transcript boundary. +- Desktop and mobile show prompt `#1` without an older-page control while hidden pre-prompt rows remain unloaded. + +## Verification + +```bash +cd apps +pnpm --filter @kandev/web test -- \ + hooks/use-lazy-load-messages.test.ts \ + components/task/prompt-history-panel-content.test.tsx \ + components/task/chat/use-drain-older-messages.test.ts +pnpm --filter @kandev/web run typecheck +pnpm --filter @kandev/web lint +``` + +```bash +cd apps/web +pnpm e2e:run --project chromium tests/chat/message-pagination.spec.ts +pnpm e2e:run --project mobile-chrome tests/chat/mobile-message-pagination.spec.ts +``` + +## Files likely touched + +- `apps/web/hooks/use-lazy-load-messages.ts` +- `apps/web/hooks/use-lazy-load-messages.test.ts` +- `apps/web/components/task/prompt-history-panel-content.tsx` +- `apps/web/components/task/prompt-history-panel-content.test.tsx` +- `apps/web/components/task/chat/use-drain-older-messages.ts` +- `apps/web/components/task/chat/use-drain-older-messages.test.ts` +- `apps/web/components/task/chat/tiptap-input.tsx` +- `apps/web/components/task/task-chat-panel.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 + +- Keep raw store metadata separate from the visible pagination result and expose raw loading only to explicit recovery consumers. +- Recalculate the boundary before another page starts in one load operation. +- Keep the compatibility path for payloads without `prompt_index`. +- Preserve the prepend anchor while visible older pages are loaded. + +## Parallelism + +`sequential` + +## Inputs + +- `REQ-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001` and its acceptance criteria. +- The task transcript system design. +- The existing prompt-ordinal boundary in the Prompt History panel. +- The current desktop and mobile message-pagination tests. + +## Results + +- `pnpm --filter @kandev/web test -- hooks/use-lazy-load-messages.test.ts components/task/prompt-history-panel-content.test.tsx components/task/chat/use-drain-older-messages.test.ts` — 3 files, 73 tests passed. +- `pnpm --filter @kandev/web run typecheck` — passed. +- `pnpm --filter @kandev/web lint` — passed. +- `pnpm e2e:run --project chromium tests/chat/message-pagination.spec.ts` — 2 tests passed. +- `pnpm e2e:run --project mobile-chrome tests/chat/mobile-message-pagination.spec.ts` — 2 tests passed. diff --git a/docs/plans/hide-redundant-older-messages-control/task-02-stop-eager-history-loading.md b/docs/plans/hide-redundant-older-messages-control/task-02-stop-eager-history-loading.md new file mode 100644 index 0000000000..ee322dbbde --- /dev/null +++ b/docs/plans/hide-redundant-older-messages-control/task-02-stop-eager-history-loading.md @@ -0,0 +1,126 @@ +--- +id: "02-stop-eager-history-loading" +title: "Stop eager history loading on chat open" +status: done +wave: 2 +depends_on: + - "01-stop-visible-pagination-at-first-prompt" +plan: "plan.md" +requirements: + - REQ-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001 +acceptance_criteria: + - AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.4 + - AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.5 + - AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.6 +system_design: + - ../../specs/ui/system-design/task-prompt-transcript-visibility.md +--- + +# Task 02: Stop Eager History Loading on Chat Open + +## Summary + +Keep chat opening bounded to the newest message window. Older pages load only +after the user navigates upward or starts an explicit recovery action. + +## Confirmed root cause + +`TaskChatPanel` starts as many as three 20-row pagination requests merely to +locate the last prompt. The uncached initial-fetch path can separately prepend +as many as ten 100-row pages when the newest window contains only tool activity. +Every page immediately changes the rendered transcript and scroll geometry. + +An isolated 451-row browser reproduction issued three unsolicited older-page +requests and grew the transcript from 1,807px to 3,407px while opening. + +## In scope + +- Remove background last-prompt pagination from task opening. +- Remove initial tool-only history backfill. +- Preserve scroll-triggered pagination and explicit search recovery. +- Add desktop and mobile regressions proving that task opening requests no + older page. + +## Out of scope + +- Backend pagination, persistence, schema, or API changes. +- Transcript virtualization. +- Changes to explicit upward pagination, search recovery, or scroll-to-start. +- New copy, layout, navigation, or touch behavior. + +## Acceptance + +- Opening a task renders only the newest message window. +- Opening a task does not request a page with a `before` cursor. +- Older messages remain reachable by scrolling upward on desktop and mobile. +- A tool-only initial window does not keep initial loading active while older + pages are fetched. + +## Verification + +```bash +cd apps +pnpm --filter @kandev/web test -- \ + hooks/domains/session/use-session-message-fetch.test.ts \ + hooks/domains/session/use-session-messages.test.ts \ + components/task/chat/message-list-shared.test.tsx +pnpm --filter @kandev/web run typecheck +pnpm --filter @kandev/web lint +``` + +```bash +cd apps/web +pnpm e2e:run --project chromium tests/chat/message-pagination.spec.ts +pnpm e2e:run --project mobile-chrome tests/chat/mobile-message-pagination.spec.ts +``` + +## Files likely touched + +- `apps/web/components/task/task-chat-panel.tsx` +- `apps/web/components/task/chat/message-list-shared.tsx` +- `apps/web/hooks/domains/session/use-session-message-fetch.ts` +- `apps/web/hooks/domains/session/use-session-messages.ts` +- `apps/web/hooks/domains/session/message-backfill.ts` +- `apps/web/hooks/domains/session/use-session-message-fetch.test.ts` +- `apps/web/hooks/domains/session/use-session-messages.test.ts` +- `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` + +## Mobile design contract + +- Desktop outcome: chat opens on the newest bounded window without background + older-page requests. +- Mobile entry point: the existing Chat tab in the full-height task layout. +- Nearest exemplar: `apps/web/components/task/task-layout.tsx` and the existing + mobile pagination test. +- Hierarchy and surface: unchanged. The native transcript remains the single + vertical scroll owner. +- Shared logic: desktop and mobile use the same task panel, message hook, and + pagination coordinator. +- Mobile proof: the mobile pagination spec opens tool-heavy history, proves no + eager request, then confirms upward navigation still loads older history. + +## Risks + +- Last-prompt controls remain unavailable until a user prompt enters the loaded + window. +- The task-description fallback must remain the readable anchor for a tool-only + newest window. +- Removing initial backfill must not block explicit pagination after loading + settles. + +## Parallelism + +`sequential` + +## Results + +- Removed task-open pagination for last-prompt discovery. +- Removed the tool-only initial backfill path and its unused helper. +- Added a stable row anchor for complete older-page request cycles. +- Added desktop and mobile coverage that observes zero older-page requests during task opening. +- `pnpm exec vitest run components/task/chat/message-list-shared.test.tsx hooks/domains/session/use-session-message-fetch.test.ts hooks/domains/session/use-session-messages.test.ts` passed 66 tests. +- `pnpm run typecheck` passed. +- `pnpm e2e:run --host --no-build --project chromium -- tests/chat/message-pagination.spec.ts` passed 3 tests. +- `pnpm e2e:run --host --no-build --project mobile-chrome -- tests/chat/mobile-message-pagination.spec.ts` passed 3 tests. diff --git a/docs/specs/ui/README.md b/docs/specs/ui/README.md index 4feae7e32a..72cb31b82f 100644 --- a/docs/specs/ui/README.md +++ b/docs/specs/ui/README.md @@ -153,6 +153,7 @@ contract that other capabilities can reuse. - [Prompt History Panel](system-design/prompt-history-panel.md) - [Quick Chat and Terminal Tabs](system-design/quick-terminal.md) - [Task Layout Profiles](system-design/task-layout-profiles.md) +- [Task Transcript History Visibility](system-design/task-prompt-transcript-visibility.md) ## Migration record diff --git a/docs/specs/ui/requirements/task-prompt-transcript-visibility.md b/docs/specs/ui/requirements/task-prompt-transcript-visibility.md index ea7251edf2..55fc5ffd0b 100644 --- a/docs/specs/ui/requirements/task-prompt-transcript-visibility.md +++ b/docs/specs/ui/requirements/task-prompt-transcript-visibility.md @@ -27,6 +27,7 @@ The transcript can lose the original task prompt after a reload. Long activity g - **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.6:** Do not require another user action before a new standalone transcript entry appears. - **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.7:** Stop automatic loading in these cases: - **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.8:** Older content moves the load boundary above the preload region. +- **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.9:** When the first user prompt of a session is loaded, the transcript shall not show an older-page control. ## Migrated source detail @@ -113,4 +114,4 @@ prompt. This requirement includes sessions with more than 100 tool events. ## Implementation plan -See [the implementation plan](../../../plans/task-prompt-transcript-visibility/plan.md). +See [the implementation plan](../../../plans/hide-redundant-older-messages-control/plan.md). diff --git a/docs/specs/ui/system-design/task-prompt-transcript-visibility.md b/docs/specs/ui/system-design/task-prompt-transcript-visibility.md new file mode 100644 index 0000000000..424282d34a --- /dev/null +++ b/docs/specs/ui/system-design/task-prompt-transcript-visibility.md @@ -0,0 +1,99 @@ +--- +status: current +system: ui +requirements: + - REQ-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001 +created: 2026-08-24 +owners: + - kandev +--- + +# Task Transcript History Visibility System Design + +## Purpose and boundaries + +This design defines the visible start of a task transcript. The first user prompt is the visible start, even when internal rows exist before it. + +The design changes no backend query, message order, persistence rule, or API field. It uses the existing `prompt_index` message field. + +## Requirement mapping + +| Requirement | Design section | +| --- | --- | +| `REQ-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001` | [Opening boundary](#opening-boundary), [Pagination boundary](#pagination-boundary), [Failure and compatibility](#failure-and-compatibility) | + +## Components and responsibilities + +- `useLazyLoadMessages` owns the visible pagination boundary for transcript consumers. +- The session message fetch owns one bounded initial request for the newest message window. +- `messages.metaBySession[sessionId].hasMore` keeps the raw backend `has_more` value. +- `messages.bySession[sessionId]` supplies the loaded messages and their prompt ordinals. +- The native transcript and Prompt History panel consume the shared hook result. +- Transcript navigation uses only the messages that the transcript already loaded. +- The hook also exposes an explicit raw-pagination loader for direct recovery consumers, such as session search. +- `requestOlderMessages` keeps request coordination and raw cursor metadata unchanged. + +The Prompt History panel already uses prompt `#1` as its terminal boundary. The transcript uses the same boundary through the shared hook. + +## Opening boundary + +The session message fetch requests only the newest bounded window when a task opens. A tool-only window does not start an automatic backfill. + +`TaskChatPanel` does not load older pages to find the last prompt. The last-prompt action becomes available after the prompt enters the loaded window. + +If the newest window has no user message, the transcript shows the task-description fallback. Upward navigation then loads older pages through visible pagination. + +## Pagination boundary + +The frontend derives a visible `hasMore` value from two inputs: + +1. The raw backend value is true. +2. No loaded user message has `prompt_index` equal to `1`. + +If both conditions are true, transcript consumers can request another page. If prompt `#1` is loaded, the shared hook reports no visible older history. + +The hook applies this rule to its reactive return value. It also applies the rule after each joined or completed request. + +This immediate update stops one multi-page load operation at prompt `#1`. It prevents an extra request for pre-prompt internal rows. + +The store keeps the raw backend value. Direct recovery code can use the hook's explicit raw-pagination loader when its contract requires the complete message stream. Session search uses this path because backend search can return a hit from before prompt `#1`. Transcript rendering and navigation continue to use visible pagination, so this recovery path does not restore the older-page control. + +## Control flow + +1. The initial message request stores one newest suffix and raw pagination metadata. +2. The shared hook reports visible older history while prompt `#1` is absent. +3. The user reaches the oldest loaded point. +4. An older-page request prepends messages through the existing coordinator. +5. The native transcript preserves a stable message-row position across the complete request. +6. If the page contains prompt `#1`, the shared hook changes its visible value to false. +7. The transcript removes its sentinel, loading state, and older-page control. +8. Explicit reverse search uses raw pagination when it must backfill a backend search hit. + +## Failure and compatibility + +If a request fails before prompt `#1` is known, the explicit older-page control remains available. A zero-result response keeps the existing retry behavior. + +An initial tool-only window completes without an older-page request. The task-description fallback remains visible until upward pagination loads a stored user prompt. + +Older payloads can omit `prompt_index`. In this case, the shared hook uses raw `has_more` exhaustion as the compatibility fallback. + +## Responsive behavior + +Desktop and mobile use the same native transcript and one vertical scroll owner. This change adds no mobile surface, control, or touch behavior. + +The existing full-height mobile task layout remains the nearest mobile exemplar. The mobile pagination scenario proves the same visible start. + +## Test boundaries + +- A hook test covers raw `has_more: true` with loaded prompt `#1`. +- A hook test covers the compatibility path when the ordinal is absent. +- A hook test proves that a multi-page load stops when a response adds prompt `#1`. +- Hook and drain tests prove that explicit search backfill can continue through the raw boundary. +- Desktop and mobile browser tests seed hidden pre-prompt rows and prove that no older-page control remains. +- Desktop and mobile browser tests prove that task opening makes no request with an older-page cursor. +- A message-fetch test proves that a tool-only initial window completes without automatic backfill. +- Separate desktop and mobile browser tests preserve the prepend anchor while loading older pages. + +## Related design + +- [Prompt History Panel](prompt-history-panel.md) defines the `prompt_index` contract and its compatibility behavior.