From cb0ad281cecabca84b5b1ffe41f44cea126aa081 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 1 Jun 2026 07:27:08 +0100 Subject: [PATCH] perf(ui): cache chat transcript renders (#88952) --- ui/src/ui/views/chat.test.ts | 156 ++++++++++++++++++++++------------- ui/src/ui/views/chat.ts | 41 ++++++++- 2 files changed, 137 insertions(+), 60 deletions(-) diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index bb10f98d870..bc0e62053fd 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -44,6 +44,64 @@ const loadSessionsMock = vi.hoisted(() => } }), ); +const buildChatItemsMock = vi.hoisted(() => + vi.fn( + (props: { messages: unknown[]; stream: string | null; streamStartedAt: number | null }) => { + if ( + props.messages.some( + (message) => + typeof message === "object" && + message !== null && + (message as { __testDivider?: unknown })["__testDivider"] === true, + ) + ) { + return [ + { + kind: "divider", + key: "divider:compaction:test", + label: "Compacted history", + description: + "The compacted transcript is preserved as a checkpoint. Open session checkpoints to branch or restore from that compacted view.", + action: { + kind: "session-checkpoints", + label: "Open checkpoints", + }, + timestamp: 1, + }, + ]; + } + if (props.messages.length > 0) { + return [ + { + kind: "group", + key: "group:assistant:test", + role: "assistant", + messages: props.messages.map((message, index) => ({ + key: `message:${index}`, + message, + })), + timestamp: 1, + isStreaming: false, + }, + ]; + } + if (props.stream !== null) { + return props.stream + ? [ + { + kind: "stream", + key: "stream:test", + text: props.stream, + startedAt: props.streamStartedAt ?? 1, + isStreaming: true, + }, + ] + : [{ kind: "reading-indicator", key: "reading:test" }]; + } + return []; + }, + ), +); function requireFirstAttachmentsChange( onAttachmentsChange: ReturnType, @@ -64,64 +122,7 @@ vi.mock("../icons.ts", () => ({ })); vi.mock("../chat/build-chat-items.ts", () => ({ - buildChatItems: (props: { - messages: unknown[]; - stream: string | null; - streamStartedAt: number | null; - }) => { - if ( - props.messages.some( - (message) => - typeof message === "object" && - message !== null && - (message as { __testDivider?: unknown })["__testDivider"] === true, - ) - ) { - return [ - { - kind: "divider", - key: "divider:compaction:test", - label: "Compacted history", - description: - "The compacted transcript is preserved as a checkpoint. Open session checkpoints to branch or restore from that compacted view.", - action: { - kind: "session-checkpoints", - label: "Open checkpoints", - }, - timestamp: 1, - }, - ]; - } - if (props.messages.length > 0) { - return [ - { - kind: "group", - key: "group:assistant:test", - role: "assistant", - messages: props.messages.map((message, index) => ({ - key: `message:${index}`, - message, - })), - timestamp: 1, - isStreaming: false, - }, - ]; - } - if (props.stream !== null) { - return props.stream - ? [ - { - kind: "stream", - key: "stream:test", - text: props.stream, - startedAt: props.streamStartedAt ?? 1, - isStreaming: true, - }, - ] - : [{ kind: "reading-indicator", key: "reading:test" }]; - } - return []; - }, + buildChatItems: buildChatItemsMock, })); vi.mock("../chat/grouped-render.ts", () => ({ @@ -673,6 +674,7 @@ describe("chat composer workbench", () => { afterEach(() => { vi.useRealTimers(); + buildChatItemsMock.mockClear(); loadSessionsMock.mockClear(); refreshVisibleToolsEffectiveForCurrentSessionMock.mockClear(); resetChatViewState(); @@ -680,6 +682,44 @@ afterEach(() => { vi.unstubAllGlobals(); }); +describe("chat transcript rendering cache", () => { + it("does not rebuild transcript items for draft-only rerenders", () => { + const messages = [{ role: "assistant", content: "ready" }]; + const toolMessages: unknown[] = []; + const streamSegments: Array<{ text: string; ts: number }> = []; + const queue: ChatQueueItem[] = []; + + renderChatView({ messages, toolMessages, streamSegments, queue, draft: "" }); + renderChatView({ messages, toolMessages, streamSegments, queue, draft: "h" }); + renderChatView({ messages, toolMessages, streamSegments, queue, draft: "hello" }); + + expect(buildChatItemsMock).toHaveBeenCalledTimes(1); + }); + + it("rebuilds transcript items when the transcript reference changes", () => { + const toolMessages: unknown[] = []; + const streamSegments: Array<{ text: string; ts: number }> = []; + const queue: ChatQueueItem[] = []; + + renderChatView({ + messages: [{ role: "assistant", content: "ready" }], + toolMessages, + streamSegments, + queue, + draft: "", + }); + renderChatView({ + messages: [{ role: "assistant", content: "new reply" }], + toolMessages, + streamSegments, + queue, + draft: "", + }); + + expect(buildChatItemsMock).toHaveBeenCalledTimes(2); + }); +}); + describe("chat loading skeleton", () => { it("renders realtime Talk transcript as ordered voice turns", () => { const container = renderChatView({ diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index 879a1814840..3a0cecba656 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -13,7 +13,7 @@ import { CHAT_ATTACHMENT_ACCEPT, isSupportedChatAttachmentFile, } from "../chat/attachment-support.ts"; -import { buildChatItems } from "../chat/build-chat-items.ts"; +import { buildChatItems, type BuildChatItemsProps } from "../chat/build-chat-items.ts"; import { renderChatQueue } from "../chat/chat-queue.ts"; import { buildRawSidebarContent } from "../chat/chat-sidebar-raw.ts"; import { renderWelcomeState, resolveAssistantDisplayAvatar } from "../chat/chat-welcome.ts"; @@ -486,12 +486,49 @@ function createChatEphemeralState(): ChatEphemeralState { const vs = createChatEphemeralState(); +type CachedChatItems = { + input: BuildChatItemsProps | null; + items: ReturnType; +}; + +const chatItemsBySession = new Map(); + +function sameChatItemsInput(previous: BuildChatItemsProps, next: BuildChatItemsProps): boolean { + return ( + previous.sessionKey === next.sessionKey && + previous.messages === next.messages && + previous.toolMessages === next.toolMessages && + previous.streamSegments === next.streamSegments && + previous.stream === next.stream && + previous.streamStartedAt === next.streamStartedAt && + previous.queue === next.queue && + previous.showToolCalls === next.showToolCalls && + previous.searchOpen === next.searchOpen && + previous.searchQuery === next.searchQuery + ); +} + +function buildCachedChatItems(input: BuildChatItemsProps): ReturnType { + const cached = getOrCreateSessionCacheValue(chatItemsBySession, input.sessionKey, () => ({ + input: null, + items: [], + })); + if (cached.input && sameChatItemsInput(cached.input, input)) { + return cached.items; + } + const items = buildChatItems(input); + cached.input = input; + cached.items = items; + return items; +} + /** * Reset chat view ephemeral state when navigating away. * Clears search/slash UI that should not survive navigation. */ export function resetChatViewState() { Object.assign(vs, createChatEphemeralState()); + chatItemsBySession.clear(); } export const cleanupChatModuleState = resetChatViewState; @@ -1301,7 +1338,7 @@ export function renderChat(props: ChatProps) { ); }; - const chatItems = buildChatItems({ + const chatItems = buildCachedChatItems({ sessionKey: props.sessionKey, messages: props.messages, toolMessages: props.toolMessages,