From 54e50bad5a197bc7edbe0faf701841e8a3489fac Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 16 Aug 2026 17:06:18 -0700 Subject: [PATCH] refactor(ui): simplify chat history pagination (#124921) --- .../chat-gateway.abort-diagnostic.test.ts | 1 + ui/src/pages/chat/chat-gateway.test.ts | 1 + ...chat-history-subscription-disposal.test.ts | 1 + ui/src/pages/chat/chat-history.ts | 12 ++++----- ui/src/pages/chat/chat-pane-base.ts | 3 --- ui/src/pages/chat/chat-pane-catalog.test.ts | 27 ++++++++++++++++++- ui/src/pages/chat/chat-pane-history.test.ts | 5 ---- ui/src/pages/chat/chat-pane-history.ts | 22 +++------------ ui/src/pages/chat/chat-pane-render.ts | 2 +- .../pages/chat/chat-pane-reply-navigation.ts | 4 +-- ui/src/pages/chat/chat-pane-session.ts | 7 +++-- ui/src/pages/chat/chat-pane.test-support.ts | 1 - ui/src/pages/chat/chat-state-contract.ts | 2 +- ui/src/pages/chat/chat-view.ts | 10 ++++--- .../components/chat-thread-interactions.ts | 6 +---- ui/src/pages/chat/components/chat-thread.ts | 13 +++------ 16 files changed, 60 insertions(+), 57 deletions(-) diff --git a/ui/src/pages/chat/chat-gateway.abort-diagnostic.test.ts b/ui/src/pages/chat/chat-gateway.abort-diagnostic.test.ts index 52d9af9ab4b2..38898e1d4f29 100644 --- a/ui/src/pages/chat/chat-gateway.abort-diagnostic.test.ts +++ b/ui/src/pages/chat/chat-gateway.abort-diagnostic.test.ts @@ -18,6 +18,7 @@ type AbortDiagnosticState = ChatState & { function createAbortDiagnosticState(runId = "run-validation-abort"): AbortDiagnosticState { return { chatAttachments: [], + chatHistoryPagination: { hasMore: false }, chatLoading: false, chatMessage: "", chatMessages: [], diff --git a/ui/src/pages/chat/chat-gateway.test.ts b/ui/src/pages/chat/chat-gateway.test.ts index 145b0bbfbb55..3b4500dca3ee 100644 --- a/ui/src/pages/chat/chat-gateway.test.ts +++ b/ui/src/pages/chat/chat-gateway.test.ts @@ -19,6 +19,7 @@ import { function createState(overrides: Partial = {}): ChatState { return { chatAttachments: [], + chatHistoryPagination: { hasMore: false }, chatLoading: false, chatMessage: "", chatMessages: [], diff --git a/ui/src/pages/chat/chat-history-subscription-disposal.test.ts b/ui/src/pages/chat/chat-history-subscription-disposal.test.ts index 006cbd0bad7d..339c509b0700 100644 --- a/ui/src/pages/chat/chat-history-subscription-disposal.test.ts +++ b/ui/src/pages/chat/chat-history-subscription-disposal.test.ts @@ -21,6 +21,7 @@ function createSubscriptionState( connected: true, connectionEpoch: 1, sessionKey: subscription.key, + chatHistoryPagination: { hasMore: false }, chatLoading: false, chatMessages: [], chatThinkingLevel: null, diff --git a/ui/src/pages/chat/chat-history.ts b/ui/src/pages/chat/chat-history.ts index ac1072eaea5b..3ac63ceee1b2 100644 --- a/ui/src/pages/chat/chat-history.ts +++ b/ui/src/pages/chat/chat-history.ts @@ -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, }, ); diff --git a/ui/src/pages/chat/chat-pane-base.ts b/ui/src/pages/chat/chat-pane-base.ts index fee97ff45d9b..d221cc2915d8 100644 --- a/ui/src/pages/chat/chat-pane-base.ts +++ b/ui/src/pages/chat/chat-pane-base.ts @@ -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(); - protected readonly olderOffsetsSeen = new Set(); constructor() { super(); diff --git a/ui/src/pages/chat/chat-pane-catalog.test.ts b/ui/src/pages/chat/chat-pane-catalog.test.ts index 6533c8c8c77c..1569f6373019 100644 --- a/ui/src/pages/chat/chat-pane-catalog.test.ts +++ b/ui/src/pages/chat/chat-pane-catalog.test.ts @@ -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", diff --git a/ui/src/pages/chat/chat-pane-history.test.ts b/ui/src/pages/chat/chat-pane-history.test.ts index 41430064c308..d241d6ea2f65 100644 --- a/ui/src/pages/chat/chat-pane-history.test.ts +++ b/ui/src/pages/chat/chat-pane-history.test.ts @@ -33,7 +33,6 @@ type TestChatPane = HTMLElement & { currentReplyNavigationId: (sessionKey: string) => string | null; hasOlderMessages: () => boolean; loadingOlder: boolean; - olderOffsetsSeen: Set; resetOlderMessagesViewport: () => void; readonly updateComplete: Promise; 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 () => { diff --git a/ui/src/pages/chat/chat-pane-history.ts b/ui/src/pages/chat/chat-pane-history.ts index 830aac346ec0..67b3cf264829 100644 --- a/ui/src/pages/chat/chat-pane-history.ts +++ b/ui/src/pages/chat/chat-pane-history.ts @@ -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; diff --git a/ui/src/pages/chat/chat-pane-render.ts b/ui/src/pages/chat/chat-pane-render.ts index 37f051bda2d5..5006ffc9d630 100644 --- a/ui/src/pages/chat/chat-pane-render.ts +++ b/ui/src/pages/chat/chat-pane-render.ts @@ -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), diff --git a/ui/src/pages/chat/chat-pane-reply-navigation.ts b/ui/src/pages/chat/chat-pane-reply-navigation.ts index 541e5389f2b3..13eb290dcae7 100644 --- a/ui/src/pages/chat/chat-pane-reply-navigation.ts +++ b/ui/src/pages/chat/chat-pane-reply-navigation.ts @@ -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?.(); } diff --git a/ui/src/pages/chat/chat-pane-session.ts b/ui/src/pages/chat/chat-pane-session.ts index 376c08dd3e3a..935d05320409 100644 --- a/ui/src/pages/chat/chat-pane-session.ts +++ b/ui/src/pages/chat/chat-pane-session.ts @@ -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(); + } } } } diff --git a/ui/src/pages/chat/chat-pane.test-support.ts b/ui/src/pages/chat/chat-pane.test-support.ts index f9df007b22cd..889ddc23eef2 100644 --- a/ui/src/pages/chat/chat-pane.test-support.ts +++ b/ui/src/pages/chat/chat-pane.test-support.ts @@ -117,7 +117,6 @@ export type TestChatPane = HTMLElement & { loadingOlder: boolean; catalogCursor: string | undefined; olderCursorsSeen: Set; - olderOffsetsSeen: Set; headerEditing: boolean; headerRenameValue: string; beginHeaderRename: (row: GatewaySessionRow) => void; diff --git a/ui/src/pages/chat/chat-state-contract.ts b/ui/src/pages/chat/chat-state-contract.ts index 42faa3c12349..694fe7cd94af 100644 --- a/ui/src/pages/chat/chat-state-contract.ts +++ b/ui/src/pages/chat/chat-state-contract.ts @@ -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. */ diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts index 91797ac66261..83182a7db732 100644 --- a/ui/src/pages/chat/chat-view.ts +++ b/ui/src/pages/chat/chat-view.ts @@ -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; onGatewayQuestionSkip?: (id: string) => void | Promise; 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, diff --git a/ui/src/pages/chat/components/chat-thread-interactions.ts b/ui/src/pages/chat/components/chat-thread-interactions.ts index dae609f3d853..1c687e739227 100644 --- a/ui/src/pages/chat/components/chat-thread-interactions.ts +++ b/ui/src/pages/chat/components/chat-thread-interactions.ts @@ -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[]; diff --git a/ui/src/pages/chat/components/chat-thread.ts b/ui/src/pages/chat/components/chat-thread.ts index 70fec717c2fe..b4ee4fbf2b70 100644 --- a/ui/src/pages/chat/components/chat-thread.ts +++ b/ui/src/pages/chat/components/chat-thread.ts @@ -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`
- ${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`
${t("chat.thread.noMatches")}
` : nothing}
` - : projection.renderRows( - props.historyPagination - ? renderHistorySentinel(props.historyPagination.loading) - : nothing, - ); + : projection.renderRows(historySentinel); return html`