diff --git a/src/client/src/chatHistoryCache.test.ts b/src/client/src/chatHistoryCache.test.ts index 5d57a16..5fd87ff 100644 --- a/src/client/src/chatHistoryCache.test.ts +++ b/src/client/src/chatHistoryCache.test.ts @@ -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"]); diff --git a/src/client/src/chatHistoryCache.ts b/src/client/src/chatHistoryCache.ts index 97891f6..a2a4f79 100644 --- a/src/client/src/chatHistoryCache.ts +++ b/src/client/src/chatHistoryCache.ts @@ -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 { diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts index 2071bc9..a4fc46f 100644 --- a/src/client/src/components/ChatView.ts +++ b/src/client/src/components/ChatView.ts @@ -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(); - @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()}
{ 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()}
@@ -174,7 +177,6 @@ export class ChatView extends LitElement { return html`
${fullHistory}
-
loaded scroll: ${String(this.loadedScrollPercent)}% from top
`; } @@ -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(); } diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index 9773799..6e3bca9 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -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"; +} + diff --git a/src/client/src/formatting/markdown.ts b/src/client/src/formatting/markdown.ts index 8eaaa52..99c5eb7 100644 --- a/src/client/src/formatting/markdown.ts +++ b/src/client/src/formatting/markdown.ts @@ -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(); + 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 {