From fa60c8d9636e726d27629142533bd1070d942c45 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 11 May 2026 22:25:22 +0200 Subject: [PATCH] Stabilize chat history pagination --- src/client/src/chatHistoryLoading.test.ts | 4 ++++ src/client/src/chatHistoryLoading.ts | 2 +- .../src/controllers/sessionController.ts | 23 +++++++++++++------ 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/client/src/chatHistoryLoading.test.ts b/src/client/src/chatHistoryLoading.test.ts index 79298b4..a2e4cf7 100644 --- a/src/client/src/chatHistoryLoading.test.ts +++ b/src/client/src/chatHistoryLoading.test.ts @@ -31,6 +31,10 @@ describe("chat history loading decisions", () => { expect(shouldRequestEarlierMessages({ ...base, canRequest: false, scrollTop: 0 })).toBe(false); }); + it("does not request while the scroll container is hidden", () => { + expect(shouldRequestEarlierMessages({ ...base, scrollTop: 0, scrollHeight: 0, clientHeight: 0 })).toBe(false); + }); + it("uses a small tolerance for underfilled viewports", () => { expect(doesNotFillViewport({ scrollHeight: 501, clientHeight: 500 })).toBe(true); expect(doesNotFillViewport({ scrollHeight: 502, clientHeight: 500 })).toBe(false); diff --git a/src/client/src/chatHistoryLoading.ts b/src/client/src/chatHistoryLoading.ts index 3a09b60..d1f582d 100644 --- a/src/client/src/chatHistoryLoading.ts +++ b/src/client/src/chatHistoryLoading.ts @@ -12,7 +12,7 @@ const DEFAULT_TOP_THRESHOLD = 64; const VIEWPORT_FILL_TOLERANCE = 1; export function shouldRequestEarlierMessages(state: ChatHistoryLoadState): boolean { - if (!state.hasMore || state.loadingMore || !state.canRequest) return false; + if (!state.hasMore || state.loadingMore || !state.canRequest || state.clientHeight <= 0) return false; return isNearTop(state) || doesNotFillViewport(state); } diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index c53a976..a48256e 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -68,7 +68,7 @@ export class SessionController { if (session.archived === true) { const page = await api.messages(session.id, { limit: MESSAGE_PAGE_SIZE }); if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return; - const history = this.mergeAndCacheHistory(session.id, page); + const history = this.mergeAndCacheHistory(session.id, page, this.currentHistoryPage()); this.setState({ messages: normalizeMessages(history.messages), messagePageStart: history.start, messagePageTotal: history.total, isLoadingEarlierMessages: false, isReceivingPartialStream: false, status: undefined, activity: undefined }); if (options?.updateUrl !== false) this.updateUrl(); return; @@ -81,7 +81,7 @@ export class SessionController { ); const [page, status] = await Promise.all([api.messages(session.id, { limit: MESSAGE_PAGE_SIZE }), api.status(session.id)]); if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return; - const history = this.mergeAndCacheHistory(session.id, page); + const history = this.mergeAndCacheHistory(session.id, page, this.currentHistoryPage()); const isReceivingPartialStream = status.isStreaming; this.catchupStreamSessionId = isReceivingPartialStream ? session.id : undefined; this.setState({ messages: normalizeMessages(history.messages), messagePageStart: history.start, messagePageTotal: history.total, isLoadingEarlierMessages: false, isReceivingPartialStream, status, activity: this.getState().sessionActivities[session.id] }); @@ -100,9 +100,10 @@ export class SessionController { if (!session || state.isLoadingEarlierMessages || state.messagePageStart <= 0) return; this.setState({ isLoadingEarlierMessages: true }); try { + const base = this.currentHistoryPage(); const page = await api.messages(session.id, { before: state.messagePageStart, limit: MESSAGE_PAGE_SIZE }); if (this.getState().selectedSession?.id !== session.id) return; - const history = this.mergeAndCacheHistory(session.id, page); + const history = this.mergeAndCacheHistory(session.id, page, base); this.setState({ messages: normalizeMessages(history.messages), messagePageStart: history.start, @@ -287,9 +288,10 @@ export class SessionController { if (sessionId === undefined || session?.id !== sessionId || session.archived === true) return; try { this.flushPendingTranscriptEvents(); + const base = this.currentHistoryPage(); const [page, status] = await Promise.all([api.messages(sessionId, { limit: MESSAGE_PAGE_SIZE }), api.status(sessionId)]); if (this.getState().selectedSession?.id !== sessionId) return; - const history = this.mergeAndCacheHistory(sessionId, page); + const history = this.mergeAndCacheHistory(sessionId, page, base); this.setState({ messages: normalizeMessages(history.messages), messagePageStart: history.start, @@ -312,12 +314,18 @@ export class SessionController { }); } - private mergeAndCacheHistory(sessionId: string, page: RawMessagePage): RawMessagePage { - const history = mergeChatHistory(readChatHistoryCache(sessionId), page); + private mergeAndCacheHistory(sessionId: string, page: RawMessagePage, base = readChatHistoryCache(sessionId)): RawMessagePage { + const history = mergeChatHistory(base, page); writeChatHistoryCache(sessionId, history); return history; } + private currentHistoryPage(): RawMessagePage | undefined { + const state = this.getState(); + if (state.messages.length === 0 && state.messagePageTotal === 0) return undefined; + return { messages: state.messages, start: state.messagePageStart, total: state.messagePageTotal }; + } + private applyCommandResult(result: CommandResult) { if (result.type === "select") { this.setState({ commandDialog: result }); @@ -425,9 +433,10 @@ export class SessionController { private async refreshMessages(sessionId: string) { try { + const base = this.currentHistoryPage(); const page = await api.messages(sessionId, { limit: MESSAGE_PAGE_SIZE }); if (this.getState().selectedSession?.id !== sessionId) return; - const history = this.mergeAndCacheHistory(sessionId, page); + const history = this.mergeAndCacheHistory(sessionId, page, base); this.setState({ messages: normalizeMessages(history.messages), messagePageStart: history.start, messagePageTotal: history.total }); } catch (error) { if (this.getState().selectedSession?.id === sessionId) this.setState({ error: String(error) });