fix: stop transcript pagination at first prompt (#3002)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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`.
|
||||
+108
@@ -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.
|
||||
+126
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user