Improve chat rendering performance and history loading

This commit is contained in:
Federico Jaramillo Martinez
2026-05-11 16:02:48 +02:00
parent 3be4489ee7
commit 1ccae8b1b7
5 changed files with 74 additions and 20 deletions
+8 -1
View File
@@ -19,12 +19,19 @@ describe("mergeChatHistory", () => {
expect(mergeChatHistory(existing, incoming)).toEqual(page(0, 4, ["a", "b", "c", "d"])); 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"]); const incoming = page(0, 2, ["fresh-a", "fresh-b"]);
expect(mergeChatHistory(page(0, 3, ["stale-a", "stale-b", "stale-c"]), incoming)).toEqual(incoming); 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", () => { it("uses incoming history instead of creating a gapped page", () => {
const incoming = page(8, 10, ["i", "j"]); const incoming = page(8, 10, ["i", "j"]);
+6 -2
View File
@@ -37,7 +37,7 @@ export function writeChatHistoryCache(sessionId: string, page: RawMessagePage):
export function mergeChatHistory(existing: RawMessagePage | undefined, incoming: RawMessagePage): RawMessagePage { export function mergeChatHistory(existing: RawMessagePage | undefined, incoming: RawMessagePage): RawMessagePage {
if (existing === undefined) return incoming; 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 start = Math.min(existing.start, incoming.start);
const end = Math.max(existing.start + existing.messages.length, incoming.start + incoming.messages.length); 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); copyInto(messages, start, incoming);
if (hasSparseEntries(messages)) return 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 { function hasSparseEntries(messages: unknown[]): boolean {
+8 -16
View File
@@ -1,5 +1,6 @@
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 { repeat } from "lit/directives/repeat.js";
import { groupChatMessages, summarizeChatGroup } from "../chatGroups"; import { groupChatMessages, summarizeChatGroup } from "../chatGroups";
import { shouldRequestEarlierMessages } from "../chatHistoryLoading"; import { shouldRequestEarlierMessages } from "../chatHistoryLoading";
import type { SessionActivity, SessionStatus } from "../api"; import type { SessionActivity, SessionStatus } from "../api";
@@ -37,7 +38,6 @@ export class ChatView extends LitElement {
@query(".chat") private chat?: HTMLDivElement; @query(".chat") private chat?: HTMLDivElement;
@state() private pinnedToBottom = true; @state() private pinnedToBottom = true;
@state() private openGroupKeys = new Set<string>(); @state() private openGroupKeys = new Set<string>();
@state() private loadedScrollPercent = 100;
@state() private expandedMetaKey: string | undefined; @state() private expandedMetaKey: string | undefined;
@state() private copiedMessageKey: string | undefined; @state() private copiedMessageKey: string | undefined;
private suppressScrollSave = false; private suppressScrollSave = false;
@@ -77,7 +77,6 @@ export class ChatView extends LitElement {
if (changed.has("loadingMore") && !this.loadingMore) this.loadMoreRequested = false; if (changed.has("loadingMore") && !this.loadingMore) this.loadMoreRequested = false;
if (changed.has("hasMore") && !this.hasMore) this.loadMoreRequested = false; if (changed.has("hasMore") && !this.hasMore) this.loadMoreRequested = false;
if (!changed.has("sessionId") && changed.has("messages") && this.pinnedToBottom) this.scrollToBottom(); 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(); if (changed.has("messages") || changed.has("hasMore") || changed.has("loadingMore")) this.requestLoadMoreIfNeeded();
} }
@@ -87,9 +86,13 @@ export class ChatView extends LitElement {
${this.renderHistoryIndicator()} ${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); }}> <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()} ${this.renderHistoryBoundary()}
${groupChatMessages(this.messages, this.messageStart).map((group) => group.kind === "message" ${repeat(
? this.renderMessage(group.message, group.index) groupChatMessages(this.messages, this.messageStart),
: this.renderMessageGroup(group.messages, group.startIndex))} (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.renderQueuedMessages()}
${this.renderSessionActivity()} ${this.renderSessionActivity()}
</div> </div>
@@ -174,7 +177,6 @@ export class ChatView extends LitElement {
return html` return html`
<div class="history-indicator"> <div class="history-indicator">
<div>${fullHistory}</div> <div>${fullHistory}</div>
<div>loaded scroll: ${String(this.loadedScrollPercent)}% from top</div>
</div> </div>
`; `;
} }
@@ -351,7 +353,6 @@ export class ChatView extends LitElement {
} }
private onScroll() { private onScroll() {
this.updateLoadedScrollPercent();
this.requestLoadMoreIfNeeded(); this.requestLoadMoreIfNeeded();
this.updatePinnedToBottomFromScroll(); this.updatePinnedToBottomFromScroll();
if (!this.suppressScrollSave) this.scheduleScrollPositionSave(); if (!this.suppressScrollSave) this.scheduleScrollPositionSave();
@@ -393,14 +394,6 @@ export class ChatView extends LitElement {
return chat !== undefined && this.lastClientHeight !== 0 && chat.clientHeight !== this.lastClientHeight; 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 { private requestLoadMoreIfNeeded(): void {
requestAnimationFrame(() => { requestAnimationFrame(() => {
const chat = this.chat; const chat = this.chat;
@@ -500,7 +493,6 @@ export class ChatView extends LitElement {
chat.scrollTop = anchor.scrollTop + (chat.scrollHeight - anchor.scrollHeight); chat.scrollTop = anchor.scrollTop + (chat.scrollHeight - anchor.scrollHeight);
this.lastScrollTop = chat.scrollTop; this.lastScrollTop = chat.scrollTop;
}); });
this.updateLoadedScrollPercent();
this.requestLoadMoreIfNeeded(); this.requestLoadMoreIfNeeded();
} }
@@ -13,6 +13,8 @@ export class SessionController {
private readonly socket = new SessionSocket(); private readonly socket = new SessionSocket();
private selectionSeq = 0; private selectionSeq = 0;
private catchupStreamSessionId: string | undefined; private catchupStreamSessionId: string | undefined;
private pendingTranscriptEvents: SessionUiEvent[] = [];
private pendingTranscriptFrame: number | undefined;
constructor(private readonly getState: GetState, private readonly setState: SetState, private readonly updateUrl: UpdateUrl) {} constructor(private readonly getState: GetState, private readonly setState: SetState, private readonly updateUrl: UpdateUrl) {}
@@ -24,11 +26,13 @@ export class SessionController {
dispose() { dispose() {
this.socket.close(); this.socket.close();
this.clearPendingTranscriptEvents();
} }
clearActiveSession() { clearActiveSession() {
this.socket.close(); this.socket.close();
this.catchupStreamSessionId = undefined; this.catchupStreamSessionId = undefined;
this.clearPendingTranscriptEvents();
this.setState({ selectedSession: undefined, messages: [], messagePageStart: 0, messagePageTotal: 0, isLoadingEarlierMessages: false, isReceivingPartialStream: false, status: undefined, activity: undefined }); 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; const seq = ++this.selectionSeq;
this.socket.close(); this.socket.close();
this.catchupStreamSessionId = undefined; this.catchupStreamSessionId = undefined;
this.clearPendingTranscriptEvents();
const cached = readChatHistoryCache(session.id); const cached = readChatHistoryCache(session.id);
this.setState({ this.setState({
selectedSession: session, selectedSession: session,
@@ -342,6 +347,12 @@ export class SessionController {
if (isTranscriptEvent(event)) return; if (isTranscriptEvent(event)) return;
} }
if (isHighFrequencyTranscriptEvent(event)) {
this.queueTranscriptEvent(event);
return;
}
this.flushPendingTranscriptEvents();
const transcript = applyTranscriptEvent(this.getState().messages, event); const transcript = applyTranscriptEvent(this.getState().messages, event);
if (transcript) { if (transcript) {
this.setState({ messages: 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) { private finishStreamCatchup(sessionId: string) {
if (this.catchupStreamSessionId !== sessionId) return; if (this.catchupStreamSessionId !== sessionId) return;
this.catchupStreamSessionId = undefined; 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); 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";
}
+12 -1
View File
@@ -3,9 +3,20 @@ import { marked } from "marked";
const renderer = new marked.Renderer(); const renderer = new marked.Renderer();
renderer.html = ({ text }) => escapeHtml(text); renderer.html = ({ text }) => escapeHtml(text);
const MAX_MARKDOWN_CACHE_ENTRIES = 300;
const markdownHtmlCache = new Map<string, string>();
export function toSafeMarkdownHtml(text: 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 }); 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 { function escapeHtml(text: string): string {