From 6a21d6768e9d222499722c31944437d80f920eff Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 11 May 2026 14:46:22 +0200 Subject: [PATCH] Fix chat history loading around collapsed events --- src/client/src/chatHistoryLoading.test.ts | 43 ++++++++++++++ src/client/src/chatHistoryLoading.ts | 25 ++++++++ src/client/src/components/ChatView.ts | 72 ++++++++++++++++++----- src/client/src/components/shared.ts | 4 +- 4 files changed, 129 insertions(+), 15 deletions(-) create mode 100644 src/client/src/chatHistoryLoading.test.ts create mode 100644 src/client/src/chatHistoryLoading.ts diff --git a/src/client/src/chatHistoryLoading.test.ts b/src/client/src/chatHistoryLoading.test.ts new file mode 100644 index 0000000..79298b4 --- /dev/null +++ b/src/client/src/chatHistoryLoading.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { doesNotFillViewport, isNearTop, shouldRequestEarlierMessages } from "./chatHistoryLoading"; + +describe("chat history loading decisions", () => { + const base = { + hasMore: true, + loadingMore: false, + canRequest: true, + scrollTop: 200, + scrollHeight: 1000, + clientHeight: 500, + }; + + it("requests earlier messages near the top", () => { + expect(shouldRequestEarlierMessages({ ...base, scrollTop: 20 })).toBe(true); + }); + + it("requests earlier messages when loaded content does not fill the viewport", () => { + expect(shouldRequestEarlierMessages({ ...base, scrollHeight: 500, clientHeight: 500 })).toBe(true); + }); + + it("does not request while loading", () => { + expect(shouldRequestEarlierMessages({ ...base, loadingMore: true, scrollTop: 0 })).toBe(false); + }); + + it("does not request when there is no earlier history", () => { + expect(shouldRequestEarlierMessages({ ...base, hasMore: false, scrollTop: 0 })).toBe(false); + }); + + it("does not request when no callback is available", () => { + expect(shouldRequestEarlierMessages({ ...base, canRequest: false, scrollTop: 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); + }); + + it("allows a custom top threshold", () => { + expect(isNearTop({ scrollTop: 80, topThreshold: 100 })).toBe(true); + expect(isNearTop({ scrollTop: 100, topThreshold: 100 })).toBe(false); + }); +}); diff --git a/src/client/src/chatHistoryLoading.ts b/src/client/src/chatHistoryLoading.ts new file mode 100644 index 0000000..3a09b60 --- /dev/null +++ b/src/client/src/chatHistoryLoading.ts @@ -0,0 +1,25 @@ +export interface ChatHistoryLoadState { + hasMore: boolean; + loadingMore: boolean; + canRequest: boolean; + scrollTop: number; + scrollHeight: number; + clientHeight: number; + topThreshold?: number; +} + +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; + return isNearTop(state) || doesNotFillViewport(state); +} + +export function isNearTop(state: Pick): boolean { + return state.scrollTop < (state.topThreshold ?? DEFAULT_TOP_THRESHOLD); +} + +export function doesNotFillViewport(state: Pick): boolean { + return state.scrollHeight <= state.clientHeight + VIEWPORT_FILL_TOLERANCE; +} diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts index 566f257..2071bc9 100644 --- a/src/client/src/components/ChatView.ts +++ b/src/client/src/components/ChatView.ts @@ -1,6 +1,7 @@ import { LitElement, html } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; import { groupChatMessages, summarizeChatGroup } from "../chatGroups"; +import { shouldRequestEarlierMessages } from "../chatHistoryLoading"; import type { SessionActivity, SessionStatus } from "../api"; import type { ChatLine, ChatPart } from "./shared"; import { chatStyles } from "./shared"; @@ -11,13 +12,12 @@ interface PrependScrollAnchor { scrollHeight: number; } -function isScrollPosition(value: unknown): value is { index: number; offset: number } { +function isScrollPosition(value: unknown): value is { index?: number; key?: string; offset: number } { return typeof value === "object" && value !== null - && "index" in value && "offset" in value - && typeof value.index === "number" - && typeof value.offset === "number"; + && typeof value.offset === "number" + && (("key" in value && typeof value.key === "string") || ("index" in value && typeof value.index === "number")); } @customElement("chat-view") @@ -45,6 +45,7 @@ export class ChatView extends LitElement { private lastScrollTop = 0; private lastClientHeight = 0; private touchStartY: number | undefined; + private loadMoreRequested = false; private readonly onViewportResize = () => { if (this.pinnedToBottom) this.scrollToBottom(); else this.lastClientHeight = this.chat?.clientHeight ?? 0; @@ -73,9 +74,11 @@ export class ChatView extends LitElement { } protected override updated(changed: Map): void { - if (changed.has("sessionId")) return; - if (changed.has("messages") && this.pinnedToBottom) this.scrollToBottom(); + if (changed.has("loadingMore") && !this.loadingMore) this.loadMoreRequested = false; + if (changed.has("hasMore") && !this.hasMore) this.loadMoreRequested = false; + if (!changed.has("sessionId") && changed.has("messages") && this.pinnedToBottom) this.scrollToBottom(); this.updateLoadedScrollPercent(); + if (changed.has("messages") || changed.has("hasMore") || changed.has("loadingMore")) this.requestLoadMoreIfNeeded(); } override render() { @@ -179,7 +182,13 @@ export class ChatView extends LitElement { private renderHistoryBoundary() { const range = this.historyRangeLabel(); if (this.loadingMore) return html`
Loading earlier messages…${range}
`; - if (this.hasMore) return html`
Scroll up to load earlier messages${range}
`; + if (this.hasMore) return html` +
+ + Scroll up to load earlier messages + ${range} +
+ `; if (this.messages.length) return html`
Beginning of session${range}
`; return null; } @@ -193,7 +202,7 @@ export class ChatView extends LitElement { private renderMessage(message: ChatLine, index: number) { return html` -
+
${this.renderMessageHeader(message, String(index))} ${message.parts.map((part) => this.renderPart(part, message))}
@@ -203,7 +212,7 @@ export class ChatView extends LitElement { private renderMessageGroup(messages: ChatLine[], startIndex: number) { const key = this.groupKey(startIndex); return html` -
{ this.onGroupToggle(key, event); }}> +
{ this.onGroupToggle(key, event); }}> events ${summarizeChatGroup(messages)} @@ -343,7 +352,7 @@ export class ChatView extends LitElement { private onScroll() { this.updateLoadedScrollPercent(); - if (this.chat && this.chat.scrollTop < 64 && this.hasMore && !this.loadingMore) this.onLoadMore?.(); + this.requestLoadMoreIfNeeded(); this.updatePinnedToBottomFromScroll(); if (!this.suppressScrollSave) this.scheduleScrollPositionSave(); } @@ -392,6 +401,28 @@ export class ChatView extends LitElement { this.loadedScrollPercent = Math.max(0, Math.min(100, percent)); } + private requestLoadMoreIfNeeded(): void { + requestAnimationFrame(() => { + const chat = this.chat; + if (!chat) return; + if (shouldRequestEarlierMessages({ + hasMore: this.hasMore, + loadingMore: this.loadingMore || this.loadMoreRequested, + canRequest: this.onLoadMore !== undefined, + scrollTop: chat.scrollTop, + scrollHeight: chat.scrollHeight, + clientHeight: chat.clientHeight, + })) this.requestLoadMore(); + }); + } + + private requestLoadMore(force = false): void { + if (!force && this.loadMoreRequested) return; + if (!this.hasMore || this.loadingMore || this.onLoadMore === undefined) return; + this.loadMoreRequested = true; + this.onLoadMore(); + } + private isNearBottom(): boolean { const chat = this.chat; if (!chat) return true; @@ -439,7 +470,7 @@ export class ChatView extends LitElement { return; } - const article = this.articleAt(stored.index); + const article = this.articleAt(stored); if (!article) { this.withSuppressedScrollSave(() => { chat.scrollTop = chat.scrollHeight; @@ -470,6 +501,7 @@ export class ChatView extends LitElement { this.lastScrollTop = chat.scrollTop; }); this.updateLoadedScrollPercent(); + this.requestLoadMoreIfNeeded(); } saveScrollPosition(sessionId = this.sessionId) { @@ -487,6 +519,7 @@ export class ChatView extends LitElement { } const chatTop = chat.getBoundingClientRect().top; const position = { + key: firstVisible.dataset["anchorKey"], index: Number(firstVisible.dataset["index"] ?? 0), offset: firstVisible.getBoundingClientRect().top - chatTop, }; @@ -501,7 +534,7 @@ export class ChatView extends LitElement { this.saveScrollTimer = window.setTimeout(() => { this.saveScrollPosition(); }, 180); } - private readStoredScrollPosition(): { index: number; offset: number } | undefined { + private readStoredScrollPosition(): { index?: number; key?: string; offset: number } | undefined { if (this.sessionId === "") return undefined; try { const raw = localStorage.getItem(this.storageKey()); @@ -524,8 +557,11 @@ export class ChatView extends LitElement { }); } - private articleAt(index: number): HTMLElement | undefined { - return this.articles().find((article) => Number(article.dataset["index"]) === index); + private articleAt(position: { index?: number; key?: string }): HTMLElement | undefined { + const articles = this.articles(); + const keyed = position.key === undefined ? undefined : articles.find((article) => article.dataset["anchorKey"] === position.key); + if (keyed !== undefined) return keyed; + return articles.find((article) => Number(article.dataset["index"]) === position.index); } private articles(): HTMLElement[] { @@ -554,6 +590,14 @@ export class ChatView extends LitElement { return `${this.sessionId}:${String(startIndex)}`; } + private messageAnchorKey(index: number): string { + return `m:${String(index)}`; + } + + private groupAnchorKey(startIndex: number): string { + return `g:${String(startIndex)}`; + } + private readOpenGroupKeys(): Set { if (this.sessionId === "") return new Set(); try { diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index ac6d81f..f6a7722 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -164,7 +164,9 @@ export const chatStyles = css` .group-msg.tool { color: #d29922; } .group-msg.system { color: #ff7b72; } .group-msg.bash { color: #3fb950; } - .history-boundary { display: grid; gap: 3px; margin: 0 0 14px; color: #8b949e; font-size: 12px; text-align: center; } + .history-boundary { display: grid; gap: 3px; justify-items: center; margin: 0 0 14px; color: #8b949e; font-size: 12px; text-align: center; } + .history-load-button { border: 1px solid #30363d; border-radius: 999px; background: #161b22; color: #c9d1d9; padding: 5px 12px; font: 12px system-ui, sans-serif; cursor: pointer; } + .history-load-button:hover, .history-load-button:focus { border-color: #58a6ff; color: #f0f6fc; } .queued-messages { max-width: 100%; min-width: 0; box-sizing: border-box; display: grid; gap: 8px; margin: 0 0 14px; padding: 12px; border: 1px solid #6e5200; border-radius: 10px; background: #1f1a10; color: #e6edf3; overflow: hidden; } .queued-header { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; } .queued-header strong { color: #d29922; }