refactor(ui): simplify chat history pagination (#124921)

This commit is contained in:
Peter Steinberger
2026-08-16 17:06:18 -07:00
committed by GitHub
parent e2cefa8fe4
commit 54e50bad5a
16 changed files with 60 additions and 57 deletions
@@ -18,6 +18,7 @@ type AbortDiagnosticState = ChatState & {
function createAbortDiagnosticState(runId = "run-validation-abort"): AbortDiagnosticState {
return {
chatAttachments: [],
chatHistoryPagination: { hasMore: false },
chatLoading: false,
chatMessage: "",
chatMessages: [],
+1
View File
@@ -19,6 +19,7 @@ import {
function createState(overrides: Partial<ChatState> = {}): ChatState {
return {
chatAttachments: [],
chatHistoryPagination: { hasMore: false },
chatLoading: false,
chatMessage: "",
chatMessages: [],
@@ -21,6 +21,7 @@ function createSubscriptionState(
connected: true,
connectionEpoch: 1,
sessionKey: subscription.key,
chatHistoryPagination: { hasMore: false },
chatLoading: false,
chatMessages: [],
chatThinkingLevel: null,
+6 -6
View File
@@ -511,8 +511,8 @@ function resolveChatHistorySessionId(result: ChatHistoryResult): string | null {
: null;
}
function retainedRawHistoryStart(pagination: ChatHistoryPagination | undefined): number | null {
const totalMessages = pagination?.totalMessages;
function retainedRawHistoryStart(pagination: ChatHistoryPagination): number | null {
const totalMessages = pagination.totalMessages;
if (
typeof totalMessages !== "number" ||
!Number.isSafeInteger(totalMessages) ||
@@ -520,7 +520,7 @@ function retainedRawHistoryStart(pagination: ChatHistoryPagination | undefined):
) {
return null;
}
const retainedDepth = pagination?.hasMore ? pagination.nextOffset : totalMessages;
const retainedDepth = pagination.hasMore ? pagination.nextOffset : totalMessages;
const start = totalMessages - retainedDepth + 1;
return Number.isSafeInteger(start) && start > 0 ? start : null;
}
@@ -530,7 +530,7 @@ function reconcileLoadedHistoryTail(options: {
nextPagination: ChatHistoryPagination;
nextSessionId: string | null;
previousMessages: unknown[];
previousPagination: ChatHistoryPagination | undefined;
previousPagination: ChatHistoryPagination;
previousSessionId: string | null;
}): { messages: unknown[]; pagination: ChatHistoryPagination } | null {
if (
@@ -540,7 +540,7 @@ function reconcileLoadedHistoryTail(options: {
) {
return null;
}
const previousTotal = options.previousPagination?.totalMessages;
const previousTotal = options.previousPagination.totalMessages;
const nextTotal = options.nextPagination.totalMessages;
const previousStart = retainedRawHistoryStart(options.previousPagination);
const nextStart = retainedRawHistoryStart(options.nextPagination);
@@ -1036,7 +1036,7 @@ function replaceCachedChatMessages(state: ChatState, sessionKey: string, agentId
? { displayedLeafEntryId: state.chatDisplayedLeafEntryId }
: {}),
messages: state.chatMessages,
pagination: state.chatHistoryPagination ?? { hasMore: false },
pagination: state.chatHistoryPagination,
sessionId: state.currentSessionId ?? null,
},
);
-3
View File
@@ -38,7 +38,6 @@ import { PollController } from "../../lit/poll-controller.ts";
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
import type { BoardChatDockSize } from "./board-session-surface.ts";
import { ChatComposerCapabilityHost } from "./chat-composer-capability-host.ts";
import type { ChatHistoryPagination } from "./chat-history-pagination.ts";
import { sendSessionObserverVisibility } from "./chat-observer.ts";
import {
boardChatDockLayout,
@@ -379,12 +378,10 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement {
protected historyIntentTimer: number | null = null;
protected historyTouchY: number | null = null;
protected transcriptScrollTop: number | null = null;
protected nativePaginationSnapshot: ChatHistoryPagination | null = null;
// Older cursors already requested this session. A provider that cycles cursors
// (c1 -> c2 -> c1) on empty/duplicate pages would otherwise loop forever, since
// the sentinel never scrolls out of view when nothing new renders.
protected readonly olderCursorsSeen = new Set<string>();
protected readonly olderOffsetsSeen = new Set<number>();
constructor() {
super();
+26 -1
View File
@@ -236,7 +236,7 @@ describe("chat pane catalog session lifecycle", () => {
const readPage: SessionsCatalogReadResult = {
hostId: "gateway:local",
threadId: "thread-1",
items: [{ id: "u1", type: "userMessage", text: "hi" }],
items: [{ id: "x1", type: "other" }],
// Same cursor the request was made with: a stale provider that would loop.
nextCursor: "cursor-1",
};
@@ -259,6 +259,31 @@ describe("chat pane catalog session lifecycle", () => {
expect(pane.catalogCursor).toBeUndefined();
});
it("counts visible messages on an exhausted final page as progress", async () => {
const readPage: SessionsCatalogReadResult = {
hostId: "gateway:local",
threadId: "thread-1",
items: [{ id: "u1", type: "userMessage", text: "oldest message" }],
};
const client = {
request: vi.fn(async () => readPage),
} as unknown as GatewayBrowserClient;
const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability });
const key = "catalog:claude:gateway%3Alocal:thread-1";
state.sessionKey = key;
pane.sessionKey = key;
pane.catalogCursor = "final-page";
const progressed = await pane.loadCatalogSession(
{ catalogId: "claude", hostId: "gateway:local", threadId: "thread-1" },
true,
);
expect(progressed).toBe(true);
expect(pane.catalogMessages).toHaveLength(1);
expect(pane.catalogCursor).toBeUndefined();
});
it("keeps paging when an advancing older page renders nothing new", async () => {
const readPage: SessionsCatalogReadResult = {
hostId: "gateway:local",
@@ -33,7 +33,6 @@ type TestChatPane = HTMLElement & {
currentReplyNavigationId: (sessionKey: string) => string | null;
hasOlderMessages: () => boolean;
loadingOlder: boolean;
olderOffsetsSeen: Set<number>;
resetOlderMessagesViewport: () => void;
readonly updateComplete: Promise<boolean>;
transcriptScrollTop: number | null;
@@ -688,9 +687,6 @@ describe("chat pane native history pagination", () => {
nativeHistoryMessage(4),
];
state.chatHistoryPagination = { hasMore: false, totalMessages: 4 };
pane.olderOffsetsSeen.add(2);
pane.olderOffsetsSeen.add(4);
await loadChatHistory(state);
expect(state.chatMessages.map(nativeHistorySeq)).toEqual([1, 2, 3, 4]);
@@ -699,7 +695,6 @@ describe("chat pane native history pagination", () => {
totalMessages: 4,
});
expect(pane.hasOlderMessages()).toBe(false);
expect(pane.olderOffsetsSeen).toEqual(new Set());
});
it("keeps projected siblings while replacing the overlapping tail", async () => {
+4 -18
View File
@@ -56,12 +56,7 @@ export abstract class ChatPaneHistory extends ChatPaneReplyNavigation {
if (parseCatalogSessionKey(state.sessionKey)) {
return Boolean(this.catalogCursor && !this.catalogLoading);
}
const pagination = state.chatHistoryPagination ?? { hasMore: false };
if (pagination !== this.nativePaginationSnapshot) {
this.nativePaginationSnapshot = pagination;
this.olderOffsetsSeen.clear();
}
return pagination.hasMore && !state.chatLoading;
return state.chatHistoryPagination.hasMore && !state.chatLoading;
}
protected resetOlderMessagesViewport(): void {
@@ -80,8 +75,6 @@ export abstract class ChatPaneHistory extends ChatPaneReplyNavigation {
}
this.transcriptScrollTop = null;
this.olderCursorsSeen.clear();
this.olderOffsetsSeen.clear();
this.nativePaginationSnapshot = null;
this.clearHistoryObserver();
}
@@ -328,18 +321,15 @@ export abstract class ChatPaneHistory extends ChatPaneReplyNavigation {
let prepended = false;
try {
if (catalogKey) {
const previousCount = this.catalogMessages.length;
const progressed = await this.loadCatalogSession(catalogKey, true);
prepended = progressed || this.catalogMessages.length > previousCount;
prepended = await this.loadCatalogSession(catalogKey, true);
} else {
const pagination = state.chatHistoryPagination;
if (!pagination?.hasMore) {
if (!pagination.hasMore) {
return false;
}
const requestedOffset = pagination.nextOffset;
const expectedSessionId =
typeof state.currentSessionId === "string" ? state.currentSessionId.trim() : "";
this.olderOffsetsSeen.add(requestedOffset);
const result = await loadOlderChatHistoryPage(state, requestedOffset);
if (!result || generation !== this.olderLoadGeneration) {
return false;
@@ -358,10 +348,7 @@ export abstract class ChatPaneHistory extends ChatPaneReplyNavigation {
return true;
}
const nextPagination = resolveChatHistoryPagination(result);
const exhausted =
!nextPagination.hasMore ||
nextPagination.nextOffset <= requestedOffset ||
this.olderOffsetsSeen.has(nextPagination.nextOffset);
const exhausted = !nextPagination.hasMore || nextPagination.nextOffset <= requestedOffset;
const messages = Array.isArray(result.messages) ? result.messages : [];
const nextMessages = this.prependUniqueNativeMessages(messages, state.chatMessages);
const grew = nextMessages.length > state.chatMessages.length;
@@ -375,7 +362,6 @@ export abstract class ChatPaneHistory extends ChatPaneReplyNavigation {
}
: nextPagination;
state.chatHistoryPagination = appliedPagination;
this.nativePaginationSnapshot = appliedPagination;
state.lastError = null;
scheduleChatScroll(state, false);
prepended = grew || !exhausted;
+1 -1
View File
@@ -285,7 +285,7 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender {
const attachmentReadSignal = attachmentReads.readSignal;
const historyHasMore = catalogKey
? Boolean(this.catalogCursor)
: state.chatHistoryPagination?.hasMore === true;
: state.chatHistoryPagination.hasMore;
const sessionActionCallbacks = createChatPaneSessionActionCallbacks({
getSnapshot: () => this.context.gateway.snapshot,
hasLocalRun: () => Boolean(state.chatRunId),
@@ -146,7 +146,7 @@ export abstract class ChatPaneReplyNavigation extends ChatPaneSession {
if (!this.replyNavigationIsCurrent(navigation, state, sessionKey, sessionId)) {
return;
}
if (!state.chatHistoryPagination?.hasMore) {
if (!state.chatHistoryPagination.hasMore) {
if (this.replyNavigationIsCurrent(navigation, state, sessionKey, sessionId)) {
state.lastError = t("chat.messages.originalUnavailable");
state.requestUpdate?.();
@@ -158,7 +158,7 @@ export abstract class ChatPaneReplyNavigation extends ChatPaneSession {
return;
}
if (!loaded) {
if (!state.chatHistoryPagination?.hasMore && !state.lastError) {
if (!state.chatHistoryPagination.hasMore && !state.lastError) {
state.lastError = t("chat.messages.originalUnavailable");
state.requestUpdate?.();
}
+5 -2
View File
@@ -434,6 +434,7 @@ export abstract class ChatPaneSession extends ChatPaneTaskSuggestions {
.map((item) => this.catalogItemMessage(item))
.filter((message) => message !== null);
const nextMessages = older ? this.prependUniqueCatalogMessages(messages) : messages;
const addedMessages = nextMessages.length > this.catalogMessages.length;
// Exhaust when the cursor cannot make new forward progress: absent, unchanged,
// or already visited this session (a provider cycling c1 -> c2 -> c1). Any of
// these stops the re-armed observer from looping. An advancing, never-seen
@@ -449,7 +450,7 @@ export abstract class ChatPaneSession extends ChatPaneTaskSuggestions {
const currentState = this.state ?? state;
currentState.lastError = null;
scheduleChatScroll(currentState, !older);
return older ? !olderExhausted : true;
return !older || addedMessages || !olderExhausted;
} catch (error) {
if (isCurrent()) {
(this.state ?? state).lastError = formatUiError(error);
@@ -462,7 +463,9 @@ export abstract class ChatPaneSession extends ChatPaneTaskSuggestions {
this.catalogLoading = false;
currentState.chatLoading = false;
}
currentState.requestUpdate();
if (!older) {
currentState.requestUpdate();
}
}
}
}
@@ -117,7 +117,6 @@ export type TestChatPane = HTMLElement & {
loadingOlder: boolean;
catalogCursor: string | undefined;
olderCursorsSeen: Set<string>;
olderOffsetsSeen: Set<number>;
headerEditing: boolean;
headerRenameValue: string;
beginHeaderRename: (row: GatewaySessionRow) => void;
+1 -1
View File
@@ -23,7 +23,7 @@ export type ChatState = {
currentSessionId?: string | null;
reconnectResumeSessionId?: string | null;
chatLoading: boolean;
chatHistoryPagination?: ChatHistoryPagination;
chatHistoryPagination: ChatHistoryPagination;
chatMessages: unknown[];
chatMessagesBySession?: ChatMessageCache;
/** Active leaf of the history snapshot currently rendered by this pane. */
+7 -3
View File
@@ -51,7 +51,7 @@ import type { SidebarContent, SidebarFullMessageLoader } from "./components/chat
import { renderChatSwarmProgress } from "./components/chat-swarm-progress.ts";
import { renderChatTaskSuggestionTray } from "./components/chat-task-suggestions.ts";
import type { ChatTaskSuggestionTrayProps } from "./components/chat-task-suggestions.ts";
import type { ChatThreadProps, ReplyMessageAccess } from "./components/chat-thread-interactions.ts";
import type { ReplyMessageAccess } from "./components/chat-thread-interactions.ts";
import {
renderTranscriptSearch,
toggleTranscriptSearch,
@@ -101,7 +101,11 @@ export type ChatProps = ChatTaskSuggestionTrayProps &
) => void | Promise<void>;
onGatewayQuestionSkip?: (id: string) => void | Promise<void>;
messages: unknown[];
historyPagination?: ChatThreadProps["historyPagination"];
historyPagination?: {
hasMore: boolean;
loading: boolean;
onShowEarlier: () => void;
};
toolMessages: unknown[];
streamSegments: ChatStreamSegment[];
stream: string | null;
@@ -287,7 +291,7 @@ export function renderChat(props: ChatProps) {
sessionKey: props.sessionKey,
announceTranscript: props.announceTranscript,
loading: props.loading,
historyPagination: props.historyPagination,
historyLoading: props.historyPagination?.loading,
messages: props.messages,
toolMessages: props.toolMessages,
streamSegments: props.streamSegments,
@@ -60,11 +60,7 @@ export type ChatThreadProps = {
boardProvider?: BoardProvider;
announceTranscript?: boolean;
loading: boolean;
historyPagination?: {
hasMore: boolean;
loading: boolean;
onShowEarlier: () => void;
};
historyLoading?: boolean;
messages: unknown[];
toolMessages: unknown[];
streamSegments: ChatStreamSegment[];
+4 -9
View File
@@ -87,25 +87,20 @@ function renderTranscriptShell(
transcript: ChatTranscriptSession,
): TemplateResult {
const projection = projectChatTranscript(props, transcript);
const historySentinel =
props.historyLoading === undefined ? nothing : renderHistorySentinel(props.historyLoading);
const transcriptContents =
projection.showLoadingSkeleton || projection.isEmpty
? html`
<div class="chat-thread-inner">
${props.historyPagination
? renderHistorySentinel(props.historyPagination.loading)
: nothing}
${projection.showLoadingSkeleton ? renderLoadingSkeleton() : nothing}
${historySentinel} ${projection.showLoadingSkeleton ? renderLoadingSkeleton() : nothing}
${projection.isEmpty && !projection.searchOpen ? renderWelcomeState(props) : nothing}
${projection.isEmpty && projection.searchOpen
? html` <div class="agent-chat__empty">${t("chat.thread.noMatches")}</div> `
: nothing}
</div>
`
: projection.renderRows(
props.historyPagination
? renderHistorySentinel(props.historyPagination.loading)
: nothing,
);
: projection.renderRows(historySentinel);
return html`
<div
class="chat-thread ${projection.isDirectThread ? "chat-thread--direct" : ""}"