fix: stop transcript pagination at first prompt (#3002)

This commit is contained in:
Carlos Florêncio
2026-08-25 07:56:03 +00:00
committed by GitHub
parent c8aa40b8d7
commit 3ca388ebe4
26 changed files with 982 additions and 593 deletions
@@ -31,9 +31,21 @@ import { scheduleClampedScrollRestore } from "./clamped-scroll-restore";
function useScrollPositionOnPrepend(
scrollRef: React.RefObject<HTMLDivElement | null>,
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<string | null>(getNewestNonSyntheticItemKey(items));
isLoadingMoreRef.current = isLoadingMore;
if (isLoadingMore) olderLoadPendingRef.current = true;
newestItemKeyRef.current = getNewestNonSyntheticItemKey(items);
const prevItemCountRef = useRef(items.length);
const prevFirstKeyRef = useRef<string | null>(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<HTMLElement>("[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,
@@ -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,
@@ -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
@@ -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<TipTapInputHandle, TipTapInputProps>(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,
@@ -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));
@@ -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 };
}
@@ -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 }),
@@ -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,
+3 -50
View File
@@ -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,
@@ -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 });
@@ -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,
@@ -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);
@@ -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);
@@ -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<typeof useAppStoreApi>;
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<BackfillStep> {
const { step } = await runBackfillRoundWithBudget(sessionId, store, round, isActive);
return step;
}
export async function autoBackfillUntilUserMessage(
sessionId: string,
store: SessionMessageStore,
isActive?: IsActive,
): Promise<void> {
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,
});
}
@@ -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<Message[]>();
const second = deferred<Message[]>();
@@ -41,12 +41,6 @@ type DoFetchMessagesParams = {
store: SessionMessageStore,
isActive?: () => boolean,
) => Promise<Message[]>;
autoBackfillUntilUserMessage: (
sessionId: string,
store: SessionMessageStore,
isActive?: () => boolean,
) => Promise<void>;
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<void> {
@@ -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);
@@ -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>): 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<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
const promise = new Promise<T>((resolvePromise) => {
@@ -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;
+62 -2
View File
@@ -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),
+107 -82
View File
@@ -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 };
}
@@ -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`.
@@ -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.
@@ -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.
+1
View File
@@ -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
@@ -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).
@@ -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.