Fix chat history loading around collapsed events

This commit is contained in:
Federico Jaramillo Martinez
2026-05-11 14:46:22 +02:00
parent d4df9ec6a0
commit 6a21d6768e
4 changed files with 129 additions and 15 deletions
+43
View File
@@ -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);
});
});
+25
View File
@@ -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<ChatHistoryLoadState, "scrollTop" | "topThreshold">): boolean {
return state.scrollTop < (state.topThreshold ?? DEFAULT_TOP_THRESHOLD);
}
export function doesNotFillViewport(state: Pick<ChatHistoryLoadState, "scrollHeight" | "clientHeight">): boolean {
return state.scrollHeight <= state.clientHeight + VIEWPORT_FILL_TOLERANCE;
}
+58 -14
View File
@@ -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<string, unknown>): 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`<div class="history-boundary"><span>Loading earlier messages…</span>${range}</div>`;
if (this.hasMore) return html`<div class="history-boundary"><span>Scroll up to load earlier messages</span>${range}</div>`;
if (this.hasMore) return html`
<div class="history-boundary">
<button type="button" class="history-load-button" @click=${() => { this.requestLoadMore(true); }}>Load earlier messages</button>
<span>Scroll up to load earlier messages</span>
${range}
</div>
`;
if (this.messages.length) return html`<div class="history-boundary"><span>Beginning of session</span>${range}</div>`;
return null;
}
@@ -193,7 +202,7 @@ export class ChatView extends LitElement {
private renderMessage(message: ChatLine, index: number) {
return html`
<article class="msg ${message.role}" data-index=${index}>
<article class="msg ${message.role}" data-index=${index} data-anchor-key=${this.messageAnchorKey(index)}>
${this.renderMessageHeader(message, String(index))}
${message.parts.map((part) => this.renderPart(part, message))}
</article>
@@ -203,7 +212,7 @@ export class ChatView extends LitElement {
private renderMessageGroup(messages: ChatLine[], startIndex: number) {
const key = this.groupKey(startIndex);
return html`
<details class="msg event-group" data-index=${startIndex} ?open=${this.openGroupKeys.has(key)} @toggle=${(event: Event) => { this.onGroupToggle(key, event); }}>
<details class="msg event-group" data-index=${startIndex} data-anchor-key=${this.groupAnchorKey(startIndex)} ?open=${this.openGroupKeys.has(key)} @toggle=${(event: Event) => { this.onGroupToggle(key, event); }}>
<summary>
<b class="label">events</b>
<span>${summarizeChatGroup(messages)}</span>
@@ -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<string> {
if (this.sessionId === "") return new Set();
try {
+3 -1
View File
@@ -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; }