perf(ui): cache chat transcript renders (#88952)

This commit is contained in:
Vincent Koc
2026-06-01 07:27:08 +01:00
committed by GitHub
parent c429a3c472
commit cb0ad281ce
2 changed files with 137 additions and 60 deletions

View File

@@ -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<typeof vi.fn>,
@@ -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({

View File

@@ -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<typeof buildChatItems>;
};
const chatItemsBySession = new Map<string, CachedChatItems>();
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<typeof buildChatItems> {
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,