Archived
Improve chat rendering performance and history loading
This commit is contained in:
@@ -19,12 +19,19 @@ describe("mergeChatHistory", () => {
|
||||
expect(mergeChatHistory(existing, incoming)).toEqual(page(0, 4, ["a", "b", "c", "d"]));
|
||||
});
|
||||
|
||||
it("uses incoming history when totals shrink", () => {
|
||||
it("uses incoming history when a complete cached history shrinks", () => {
|
||||
const incoming = page(0, 2, ["fresh-a", "fresh-b"]);
|
||||
|
||||
expect(mergeChatHistory(page(0, 3, ["stale-a", "stale-b", "stale-c"]), incoming)).toEqual(incoming);
|
||||
});
|
||||
|
||||
it("keeps adjacent cached history when an older page reports a lower total", () => {
|
||||
const existing = page(100, 200, ["newer-a", "newer-b"]);
|
||||
const incoming = page(98, 150, ["older-a", "older-b"]);
|
||||
|
||||
expect(mergeChatHistory(existing, incoming)).toEqual(page(98, 200, ["older-a", "older-b", "newer-a", "newer-b"]));
|
||||
});
|
||||
|
||||
it("uses incoming history instead of creating a gapped page", () => {
|
||||
const incoming = page(8, 10, ["i", "j"]);
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export function writeChatHistoryCache(sessionId: string, page: RawMessagePage):
|
||||
|
||||
export function mergeChatHistory(existing: RawMessagePage | undefined, incoming: RawMessagePage): RawMessagePage {
|
||||
if (existing === undefined) return incoming;
|
||||
if (existing.total > incoming.total) return incoming;
|
||||
if (isCompleteReplacement(existing, incoming)) return incoming;
|
||||
|
||||
const start = Math.min(existing.start, incoming.start);
|
||||
const end = Math.max(existing.start + existing.messages.length, incoming.start + incoming.messages.length);
|
||||
@@ -46,7 +46,11 @@ export function mergeChatHistory(existing: RawMessagePage | undefined, incoming:
|
||||
copyInto(messages, start, incoming);
|
||||
|
||||
if (hasSparseEntries(messages)) return incoming;
|
||||
return { start, total: incoming.total, messages };
|
||||
return { start, total: Math.max(existing.total, incoming.total), messages };
|
||||
}
|
||||
|
||||
function isCompleteReplacement(existing: RawMessagePage, incoming: RawMessagePage): boolean {
|
||||
return existing.total > incoming.total && existing.start === 0 && incoming.start === 0 && incoming.messages.length === incoming.total;
|
||||
}
|
||||
|
||||
function hasSparseEntries(messages: unknown[]): boolean {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators.js";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
import { groupChatMessages, summarizeChatGroup } from "../chatGroups";
|
||||
import { shouldRequestEarlierMessages } from "../chatHistoryLoading";
|
||||
import type { SessionActivity, SessionStatus } from "../api";
|
||||
@@ -37,7 +38,6 @@ export class ChatView extends LitElement {
|
||||
@query(".chat") private chat?: HTMLDivElement;
|
||||
@state() private pinnedToBottom = true;
|
||||
@state() private openGroupKeys = new Set<string>();
|
||||
@state() private loadedScrollPercent = 100;
|
||||
@state() private expandedMetaKey: string | undefined;
|
||||
@state() private copiedMessageKey: string | undefined;
|
||||
private suppressScrollSave = false;
|
||||
@@ -77,7 +77,6 @@ export class ChatView extends LitElement {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -87,9 +86,13 @@ export class ChatView extends LitElement {
|
||||
${this.renderHistoryIndicator()}
|
||||
<div class="chat" @scroll=${() => { this.onScroll(); }} @wheel=${(event: WheelEvent) => { this.onWheel(event); }} @touchstart=${(event: TouchEvent) => { this.onTouchStart(event); }} @touchmove=${(event: TouchEvent) => { this.onTouchMove(event); }}>
|
||||
${this.renderHistoryBoundary()}
|
||||
${groupChatMessages(this.messages, this.messageStart).map((group) => group.kind === "message"
|
||||
? this.renderMessage(group.message, group.index)
|
||||
: this.renderMessageGroup(group.messages, group.startIndex))}
|
||||
${repeat(
|
||||
groupChatMessages(this.messages, this.messageStart),
|
||||
(group) => group.kind === "message" ? this.messageAnchorKey(group.index) : this.groupAnchorKey(group.startIndex),
|
||||
(group) => group.kind === "message"
|
||||
? this.renderMessage(group.message, group.index)
|
||||
: this.renderMessageGroup(group.messages, group.startIndex),
|
||||
)}
|
||||
${this.renderQueuedMessages()}
|
||||
${this.renderSessionActivity()}
|
||||
</div>
|
||||
@@ -174,7 +177,6 @@ export class ChatView extends LitElement {
|
||||
return html`
|
||||
<div class="history-indicator">
|
||||
<div>${fullHistory}</div>
|
||||
<div>loaded scroll: ${String(this.loadedScrollPercent)}% from top</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -351,7 +353,6 @@ export class ChatView extends LitElement {
|
||||
}
|
||||
|
||||
private onScroll() {
|
||||
this.updateLoadedScrollPercent();
|
||||
this.requestLoadMoreIfNeeded();
|
||||
this.updatePinnedToBottomFromScroll();
|
||||
if (!this.suppressScrollSave) this.scheduleScrollPositionSave();
|
||||
@@ -393,14 +394,6 @@ export class ChatView extends LitElement {
|
||||
return chat !== undefined && this.lastClientHeight !== 0 && chat.clientHeight !== this.lastClientHeight;
|
||||
}
|
||||
|
||||
private updateLoadedScrollPercent(): void {
|
||||
const chat = this.chat;
|
||||
if (!chat) return;
|
||||
const maxScroll = chat.scrollHeight - chat.clientHeight;
|
||||
const percent = maxScroll <= 0 ? 100 : Math.round((chat.scrollTop / maxScroll) * 100);
|
||||
this.loadedScrollPercent = Math.max(0, Math.min(100, percent));
|
||||
}
|
||||
|
||||
private requestLoadMoreIfNeeded(): void {
|
||||
requestAnimationFrame(() => {
|
||||
const chat = this.chat;
|
||||
@@ -500,7 +493,6 @@ export class ChatView extends LitElement {
|
||||
chat.scrollTop = anchor.scrollTop + (chat.scrollHeight - anchor.scrollHeight);
|
||||
this.lastScrollTop = chat.scrollTop;
|
||||
});
|
||||
this.updateLoadedScrollPercent();
|
||||
this.requestLoadMoreIfNeeded();
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ export class SessionController {
|
||||
private readonly socket = new SessionSocket();
|
||||
private selectionSeq = 0;
|
||||
private catchupStreamSessionId: string | undefined;
|
||||
private pendingTranscriptEvents: SessionUiEvent[] = [];
|
||||
private pendingTranscriptFrame: number | undefined;
|
||||
|
||||
constructor(private readonly getState: GetState, private readonly setState: SetState, private readonly updateUrl: UpdateUrl) {}
|
||||
|
||||
@@ -24,11 +26,13 @@ export class SessionController {
|
||||
|
||||
dispose() {
|
||||
this.socket.close();
|
||||
this.clearPendingTranscriptEvents();
|
||||
}
|
||||
|
||||
clearActiveSession() {
|
||||
this.socket.close();
|
||||
this.catchupStreamSessionId = undefined;
|
||||
this.clearPendingTranscriptEvents();
|
||||
this.setState({ selectedSession: undefined, messages: [], messagePageStart: 0, messagePageTotal: 0, isLoadingEarlierMessages: false, isReceivingPartialStream: false, status: undefined, activity: undefined });
|
||||
}
|
||||
|
||||
@@ -48,6 +52,7 @@ export class SessionController {
|
||||
const seq = ++this.selectionSeq;
|
||||
this.socket.close();
|
||||
this.catchupStreamSessionId = undefined;
|
||||
this.clearPendingTranscriptEvents();
|
||||
const cached = readChatHistoryCache(session.id);
|
||||
this.setState({
|
||||
selectedSession: session,
|
||||
@@ -342,6 +347,12 @@ export class SessionController {
|
||||
if (isTranscriptEvent(event)) return;
|
||||
}
|
||||
|
||||
if (isHighFrequencyTranscriptEvent(event)) {
|
||||
this.queueTranscriptEvent(event);
|
||||
return;
|
||||
}
|
||||
|
||||
this.flushPendingTranscriptEvents();
|
||||
const transcript = applyTranscriptEvent(this.getState().messages, event);
|
||||
if (transcript) {
|
||||
this.setState({ messages: transcript });
|
||||
@@ -354,6 +365,31 @@ export class SessionController {
|
||||
}
|
||||
}
|
||||
|
||||
private queueTranscriptEvent(event: SessionUiEvent): void {
|
||||
this.pendingTranscriptEvents.push(event);
|
||||
if (this.pendingTranscriptFrame !== undefined) return;
|
||||
this.pendingTranscriptFrame = requestAnimationFrame(() => {
|
||||
this.pendingTranscriptFrame = undefined;
|
||||
this.flushPendingTranscriptEvents();
|
||||
});
|
||||
}
|
||||
|
||||
private flushPendingTranscriptEvents(): void {
|
||||
if (this.pendingTranscriptEvents.length === 0) return;
|
||||
const events = this.pendingTranscriptEvents;
|
||||
this.pendingTranscriptEvents = [];
|
||||
let messages = this.getState().messages;
|
||||
for (const event of events) messages = applyTranscriptEvent(messages, event) ?? messages;
|
||||
if (messages !== this.getState().messages) this.setState({ messages });
|
||||
}
|
||||
|
||||
private clearPendingTranscriptEvents(): void {
|
||||
this.pendingTranscriptEvents = [];
|
||||
if (this.pendingTranscriptFrame === undefined) return;
|
||||
cancelAnimationFrame(this.pendingTranscriptFrame);
|
||||
this.pendingTranscriptFrame = undefined;
|
||||
}
|
||||
|
||||
private finishStreamCatchup(sessionId: string) {
|
||||
if (this.catchupStreamSessionId !== sessionId) return;
|
||||
this.catchupStreamSessionId = undefined;
|
||||
@@ -377,3 +413,7 @@ function isTranscriptEvent(event: SessionUiEvent): boolean {
|
||||
return ["message.append", "assistant.delta", "assistant.thinking.delta", "tool.start", "tool.end", "shell.start", "shell.chunk", "shell.end", "command.output", "session.error"].includes(event.type);
|
||||
}
|
||||
|
||||
function isHighFrequencyTranscriptEvent(event: SessionUiEvent): boolean {
|
||||
return event.type === "assistant.delta" || event.type === "assistant.thinking.delta" || event.type === "shell.chunk";
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,20 @@ import { marked } from "marked";
|
||||
const renderer = new marked.Renderer();
|
||||
renderer.html = ({ text }) => escapeHtml(text);
|
||||
|
||||
const MAX_MARKDOWN_CACHE_ENTRIES = 300;
|
||||
const markdownHtmlCache = new Map<string, string>();
|
||||
|
||||
export function toSafeMarkdownHtml(text: string): string {
|
||||
const cached = markdownHtmlCache.get(text);
|
||||
if (cached !== undefined) return cached;
|
||||
const html = marked.parse(text, { async: false, breaks: true, gfm: true, renderer });
|
||||
return sanitizeHtml(html);
|
||||
const safeHtml = sanitizeHtml(html);
|
||||
markdownHtmlCache.set(text, safeHtml);
|
||||
if (markdownHtmlCache.size > MAX_MARKDOWN_CACHE_ENTRIES) {
|
||||
const oldest = markdownHtmlCache.keys().next().value;
|
||||
if (oldest !== undefined) markdownHtmlCache.delete(oldest);
|
||||
}
|
||||
return safeHtml;
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
|
||||
Reference in New Issue
Block a user