Archived
Fix chat history loading around collapsed events
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { LitElement, html } from "lit";
|
import { LitElement, html } from "lit";
|
||||||
import { customElement, property, query, state } from "lit/decorators.js";
|
import { customElement, property, query, state } from "lit/decorators.js";
|
||||||
import { groupChatMessages, summarizeChatGroup } from "../chatGroups";
|
import { groupChatMessages, summarizeChatGroup } from "../chatGroups";
|
||||||
|
import { shouldRequestEarlierMessages } from "../chatHistoryLoading";
|
||||||
import type { SessionActivity, SessionStatus } from "../api";
|
import type { SessionActivity, SessionStatus } from "../api";
|
||||||
import type { ChatLine, ChatPart } from "./shared";
|
import type { ChatLine, ChatPart } from "./shared";
|
||||||
import { chatStyles } from "./shared";
|
import { chatStyles } from "./shared";
|
||||||
@@ -11,13 +12,12 @@ interface PrependScrollAnchor {
|
|||||||
scrollHeight: number;
|
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"
|
return typeof value === "object"
|
||||||
&& value !== null
|
&& value !== null
|
||||||
&& "index" in value
|
|
||||||
&& "offset" 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")
|
@customElement("chat-view")
|
||||||
@@ -45,6 +45,7 @@ export class ChatView extends LitElement {
|
|||||||
private lastScrollTop = 0;
|
private lastScrollTop = 0;
|
||||||
private lastClientHeight = 0;
|
private lastClientHeight = 0;
|
||||||
private touchStartY: number | undefined;
|
private touchStartY: number | undefined;
|
||||||
|
private loadMoreRequested = false;
|
||||||
private readonly onViewportResize = () => {
|
private readonly onViewportResize = () => {
|
||||||
if (this.pinnedToBottom) this.scrollToBottom();
|
if (this.pinnedToBottom) this.scrollToBottom();
|
||||||
else this.lastClientHeight = this.chat?.clientHeight ?? 0;
|
else this.lastClientHeight = this.chat?.clientHeight ?? 0;
|
||||||
@@ -73,9 +74,11 @@ export class ChatView extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected override updated(changed: Map<string, unknown>): void {
|
protected override updated(changed: Map<string, unknown>): void {
|
||||||
if (changed.has("sessionId")) return;
|
if (changed.has("loadingMore") && !this.loadingMore) this.loadMoreRequested = false;
|
||||||
if (changed.has("messages") && this.pinnedToBottom) this.scrollToBottom();
|
if (changed.has("hasMore") && !this.hasMore) this.loadMoreRequested = false;
|
||||||
|
if (!changed.has("sessionId") && changed.has("messages") && this.pinnedToBottom) this.scrollToBottom();
|
||||||
this.updateLoadedScrollPercent();
|
this.updateLoadedScrollPercent();
|
||||||
|
if (changed.has("messages") || changed.has("hasMore") || changed.has("loadingMore")) this.requestLoadMoreIfNeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
@@ -179,7 +182,13 @@ export class ChatView extends LitElement {
|
|||||||
private renderHistoryBoundary() {
|
private renderHistoryBoundary() {
|
||||||
const range = this.historyRangeLabel();
|
const range = this.historyRangeLabel();
|
||||||
if (this.loadingMore) return html`<div class="history-boundary"><span>Loading earlier messages…</span>${range}</div>`;
|
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>`;
|
if (this.messages.length) return html`<div class="history-boundary"><span>Beginning of session</span>${range}</div>`;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -193,7 +202,7 @@ export class ChatView extends LitElement {
|
|||||||
|
|
||||||
private renderMessage(message: ChatLine, index: number) {
|
private renderMessage(message: ChatLine, index: number) {
|
||||||
return html`
|
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))}
|
${this.renderMessageHeader(message, String(index))}
|
||||||
${message.parts.map((part) => this.renderPart(part, message))}
|
${message.parts.map((part) => this.renderPart(part, message))}
|
||||||
</article>
|
</article>
|
||||||
@@ -203,7 +212,7 @@ export class ChatView extends LitElement {
|
|||||||
private renderMessageGroup(messages: ChatLine[], startIndex: number) {
|
private renderMessageGroup(messages: ChatLine[], startIndex: number) {
|
||||||
const key = this.groupKey(startIndex);
|
const key = this.groupKey(startIndex);
|
||||||
return html`
|
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>
|
<summary>
|
||||||
<b class="label">events</b>
|
<b class="label">events</b>
|
||||||
<span>${summarizeChatGroup(messages)}</span>
|
<span>${summarizeChatGroup(messages)}</span>
|
||||||
@@ -343,7 +352,7 @@ export class ChatView extends LitElement {
|
|||||||
|
|
||||||
private onScroll() {
|
private onScroll() {
|
||||||
this.updateLoadedScrollPercent();
|
this.updateLoadedScrollPercent();
|
||||||
if (this.chat && this.chat.scrollTop < 64 && this.hasMore && !this.loadingMore) this.onLoadMore?.();
|
this.requestLoadMoreIfNeeded();
|
||||||
this.updatePinnedToBottomFromScroll();
|
this.updatePinnedToBottomFromScroll();
|
||||||
if (!this.suppressScrollSave) this.scheduleScrollPositionSave();
|
if (!this.suppressScrollSave) this.scheduleScrollPositionSave();
|
||||||
}
|
}
|
||||||
@@ -392,6 +401,28 @@ export class ChatView extends LitElement {
|
|||||||
this.loadedScrollPercent = Math.max(0, Math.min(100, percent));
|
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 {
|
private isNearBottom(): boolean {
|
||||||
const chat = this.chat;
|
const chat = this.chat;
|
||||||
if (!chat) return true;
|
if (!chat) return true;
|
||||||
@@ -439,7 +470,7 @@ export class ChatView extends LitElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const article = this.articleAt(stored.index);
|
const article = this.articleAt(stored);
|
||||||
if (!article) {
|
if (!article) {
|
||||||
this.withSuppressedScrollSave(() => {
|
this.withSuppressedScrollSave(() => {
|
||||||
chat.scrollTop = chat.scrollHeight;
|
chat.scrollTop = chat.scrollHeight;
|
||||||
@@ -470,6 +501,7 @@ export class ChatView extends LitElement {
|
|||||||
this.lastScrollTop = chat.scrollTop;
|
this.lastScrollTop = chat.scrollTop;
|
||||||
});
|
});
|
||||||
this.updateLoadedScrollPercent();
|
this.updateLoadedScrollPercent();
|
||||||
|
this.requestLoadMoreIfNeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
saveScrollPosition(sessionId = this.sessionId) {
|
saveScrollPosition(sessionId = this.sessionId) {
|
||||||
@@ -487,6 +519,7 @@ export class ChatView extends LitElement {
|
|||||||
}
|
}
|
||||||
const chatTop = chat.getBoundingClientRect().top;
|
const chatTop = chat.getBoundingClientRect().top;
|
||||||
const position = {
|
const position = {
|
||||||
|
key: firstVisible.dataset["anchorKey"],
|
||||||
index: Number(firstVisible.dataset["index"] ?? 0),
|
index: Number(firstVisible.dataset["index"] ?? 0),
|
||||||
offset: firstVisible.getBoundingClientRect().top - chatTop,
|
offset: firstVisible.getBoundingClientRect().top - chatTop,
|
||||||
};
|
};
|
||||||
@@ -501,7 +534,7 @@ export class ChatView extends LitElement {
|
|||||||
this.saveScrollTimer = window.setTimeout(() => { this.saveScrollPosition(); }, 180);
|
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;
|
if (this.sessionId === "") return undefined;
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(this.storageKey());
|
const raw = localStorage.getItem(this.storageKey());
|
||||||
@@ -524,8 +557,11 @@ export class ChatView extends LitElement {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private articleAt(index: number): HTMLElement | undefined {
|
private articleAt(position: { index?: number; key?: string }): HTMLElement | undefined {
|
||||||
return this.articles().find((article) => Number(article.dataset["index"]) === index);
|
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[] {
|
private articles(): HTMLElement[] {
|
||||||
@@ -554,6 +590,14 @@ export class ChatView extends LitElement {
|
|||||||
return `${this.sessionId}:${String(startIndex)}`;
|
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> {
|
private readOpenGroupKeys(): Set<string> {
|
||||||
if (this.sessionId === "") return new Set();
|
if (this.sessionId === "") return new Set();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -164,7 +164,9 @@ export const chatStyles = css`
|
|||||||
.group-msg.tool { color: #d29922; }
|
.group-msg.tool { color: #d29922; }
|
||||||
.group-msg.system { color: #ff7b72; }
|
.group-msg.system { color: #ff7b72; }
|
||||||
.group-msg.bash { color: #3fb950; }
|
.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-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 { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
|
||||||
.queued-header strong { color: #d29922; }
|
.queued-header strong { color: #d29922; }
|
||||||
|
|||||||
Reference in New Issue
Block a user