mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
refactor(ui): simplify chat history pagination (#124921)
This commit is contained in:
committed by
GitHub
parent
e2cefa8fe4
commit
54e50bad5a
@@ -18,6 +18,7 @@ type AbortDiagnosticState = ChatState & {
|
||||
function createAbortDiagnosticState(runId = "run-validation-abort"): AbortDiagnosticState {
|
||||
return {
|
||||
chatAttachments: [],
|
||||
chatHistoryPagination: { hasMore: false },
|
||||
chatLoading: false,
|
||||
chatMessage: "",
|
||||
chatMessages: [],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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?.();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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" : ""}"
|
||||
|
||||
Reference in New Issue
Block a user