perf(ui): guard chat transcript rerenders

Reduce Control UI draft-update work by guarding transcript group rendering while preserving assistant attachment availability invalidation. Verification: focused UI tests, format/lint/typecheck, autoreview clean, and changed gate tbx_01kt11qyc20ejbsbt8kd79bamx.
This commit is contained in:
Vincent Koc
2026-06-01 08:41:04 +01:00
committed by GitHub
parent bc470713bb
commit 402e2bb81a
3 changed files with 328 additions and 198 deletions

View File

@@ -44,6 +44,7 @@ const assistantAttachmentAvailabilityCache = new Map<string, AssistantAttachment
const assistantAttachmentRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
const ASSISTANT_ATTACHMENT_UNAVAILABLE_RETRY_MS = 5_000;
const ASSISTANT_ATTACHMENT_MEDIA_TICKET_REFRESH_SKEW_MS = 30_000;
let assistantAttachmentAvailabilityRenderVersion = 0;
export type ChatTimestampDisplay = {
label: string;
@@ -94,6 +95,7 @@ function renderChatTimestamp(timestamp: number) {
export function resetAssistantAttachmentAvailabilityCacheForTest() {
assistantAttachmentAvailabilityCache.clear();
bumpAssistantAttachmentAvailabilityRenderVersion();
for (const timer of assistantAttachmentRefreshTimers.values()) {
clearTimeout(timer);
}
@@ -106,6 +108,29 @@ export function resetAssistantAttachmentAvailabilityCacheForTest() {
managedImageBlobUrlMissCache.clear();
}
export function getAssistantAttachmentAvailabilityRenderVersion(): number {
return assistantAttachmentAvailabilityRenderVersion;
}
function bumpAssistantAttachmentAvailabilityRenderVersion() {
assistantAttachmentAvailabilityRenderVersion =
(assistantAttachmentAvailabilityRenderVersion + 1) % Number.MAX_SAFE_INTEGER;
}
function setAssistantAttachmentAvailability(
cacheKey: string,
availability: AssistantAttachmentAvailability,
) {
assistantAttachmentAvailabilityCache.set(cacheKey, availability);
bumpAssistantAttachmentAvailabilityRenderVersion();
}
function deleteAssistantAttachmentAvailability(cacheKey: string) {
if (assistantAttachmentAvailabilityCache.delete(cacheKey)) {
bumpAssistantAttachmentAvailabilityRenderVersion();
}
}
type ImageBlock = {
url: string;
openUrl?: string;
@@ -1218,7 +1243,7 @@ function scheduleAssistantAttachmentRefresh(
if (cached?.status !== "available" || cached.mediaTicket !== availability.mediaTicket) {
return;
}
assistantAttachmentAvailabilityCache.delete(cacheKey);
deleteAssistantAttachmentAvailability(cacheKey);
onRequestUpdate();
}, refreshInMs);
assistantAttachmentRefreshTimers.set(cacheKey, timer);
@@ -1246,21 +1271,21 @@ function resolveAssistantAttachmentAvailability(
cached.status === "unavailable" &&
now - cached.checkedAt >= ASSISTANT_ATTACHMENT_UNAVAILABLE_RETRY_MS
) {
assistantAttachmentAvailabilityCache.delete(cacheKey);
deleteAssistantAttachmentAvailability(cacheKey);
} else if (
cached.status === "available" &&
cached.mediaTicket &&
(!cached.mediaTicketExpiresAt ||
cached.mediaTicketExpiresAt - now <= ASSISTANT_ATTACHMENT_MEDIA_TICKET_REFRESH_SKEW_MS)
) {
assistantAttachmentAvailabilityCache.delete(cacheKey);
deleteAssistantAttachmentAvailability(cacheKey);
} else {
scheduleAssistantAttachmentRefresh(cacheKey, cached, onRequestUpdate);
return cached;
}
}
clearAssistantAttachmentRefreshTimer(cacheKey);
assistantAttachmentAvailabilityCache.set(cacheKey, { status: "checking" });
setAssistantAttachmentAvailability(cacheKey, { status: "checking" });
if (typeof fetch === "function") {
const headers = new Headers({ Accept: "application/json" });
if (normalizedAuthToken) {
@@ -1283,7 +1308,7 @@ function resolveAssistantAttachmentAvailability(
const mediaTicketExpiresAt = Date.parse(payload.mediaTicketExpiresAt ?? "");
if (mediaTicket && !Number.isFinite(mediaTicketExpiresAt)) {
clearAssistantAttachmentRefreshTimer(cacheKey);
assistantAttachmentAvailabilityCache.set(cacheKey, {
setAssistantAttachmentAvailability(cacheKey, {
status: "unavailable",
reason: "Attachment unavailable",
checkedAt: Date.now(),
@@ -1294,11 +1319,11 @@ function resolveAssistantAttachmentAvailability(
status: "available",
...(mediaTicket ? { mediaTicket, mediaTicketExpiresAt } : {}),
};
assistantAttachmentAvailabilityCache.set(cacheKey, availability);
setAssistantAttachmentAvailability(cacheKey, availability);
scheduleAssistantAttachmentRefresh(cacheKey, availability, onRequestUpdate);
} else {
clearAssistantAttachmentRefreshTimer(cacheKey);
assistantAttachmentAvailabilityCache.set(cacheKey, {
setAssistantAttachmentAvailability(cacheKey, {
status: "unavailable",
reason: payload?.reason?.trim() || "Attachment unavailable",
checkedAt: Date.now(),
@@ -1307,7 +1332,7 @@ function resolveAssistantAttachmentAvailability(
})
.catch(() => {
clearAssistantAttachmentRefreshTimer(cacheKey);
assistantAttachmentAvailabilityCache.set(cacheKey, {
setAssistantAttachmentAvailability(cacheKey, {
status: "unavailable",
reason: "Attachment unavailable",
checkedAt: Date.now(),

View File

@@ -100,6 +100,26 @@ const buildChatItemsMock = vi.hoisted(() =>
return [];
}),
);
const renderMessageGroupMock = vi.hoisted(() =>
vi.fn((group: { messages: Array<{ message: unknown }> }) => {
const element = document.createElement("div");
element.className = "chat-group";
element.textContent = group.messages
.map(({ message }) => {
if (typeof message === "object" && message !== null && "content" in message) {
const content = (message as { content?: unknown }).content;
if (typeof content === "string") {
return content;
}
return content == null ? "" : JSON.stringify(content);
}
return String(message);
})
.join("\n");
return element;
}),
);
const assistantAttachmentRenderVersionMock = vi.hoisted(() => ({ value: 0 }));
function requireFirstAttachmentsChange(
onAttachmentsChange: ReturnType<typeof vi.fn>,
@@ -124,23 +144,8 @@ vi.mock("../chat/build-chat-items.ts", () => ({
}));
vi.mock("../chat/grouped-render.ts", () => ({
renderMessageGroup: (group: { messages: Array<{ message: unknown }> }) => {
const element = document.createElement("div");
element.className = "chat-group";
element.textContent = group.messages
.map(({ message }) => {
if (typeof message === "object" && message !== null && "content" in message) {
const content = (message as { content?: unknown }).content;
if (typeof content === "string") {
return content;
}
return content == null ? "" : JSON.stringify(content);
}
return String(message);
})
.join("\n");
return element;
},
getAssistantAttachmentAvailabilityRenderVersion: () => assistantAttachmentRenderVersionMock.value,
renderMessageGroup: renderMessageGroupMock,
renderReadingIndicatorGroup: () => {
const element = document.createElement("div");
element.className = "chat-reading-indicator";
@@ -490,84 +495,87 @@ function clickTalkSelectOption(container: Element, name: string, value: string):
option.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
}
function createChatProps(
overrides: Partial<Parameters<typeof renderChat>[0]> = {},
): Parameters<typeof renderChat>[0] {
return {
sessionKey: "main",
onSessionKeyChange: () => undefined,
thinkingLevel: null,
showThinking: false,
showToolCalls: true,
loading: false,
sending: false,
compactionStatus: null,
fallbackStatus: null,
messages: [],
sideResult: null,
toolMessages: [],
streamSegments: [],
stream: null,
streamStartedAt: null,
assistantAvatarUrl: null,
draft: "",
queue: [],
realtimeTalkActive: false,
realtimeTalkStatus: "idle",
realtimeTalkDetail: null,
realtimeTalkTranscript: null,
connected: true,
canSend: true,
disabledReason: null,
error: null,
sessions: null,
sidebarOpen: false,
sidebarContent: null,
sidebarError: null,
splitRatio: 0.6,
canvasPluginSurfaceUrl: null,
embedSandboxMode: "scripts",
allowExternalEmbedUrls: false,
assistantName: "Val",
assistantAvatar: null,
userName: null,
userAvatar: null,
localMediaPreviewRoots: [],
assistantAttachmentAuthToken: null,
autoExpandToolCalls: false,
attachments: [],
onAttachmentsChange: () => undefined,
showNewMessages: false,
onScrollToBottom: () => undefined,
onRefresh: () => undefined,
getDraft: () => "",
onDraftChange: () => undefined,
onRequestUpdate: () => undefined,
onSend: () => undefined,
onCompact: () => undefined,
onToggleRealtimeTalk: () => undefined,
onDismissError: () => undefined,
onAbort: () => undefined,
onQueueRemove: () => undefined,
onQueueSteer: () => undefined,
onDismissSideResult: () => undefined,
onNewSession: () => undefined,
onClearHistory: () => undefined,
onOpenSessionCheckpoints: () => undefined,
agentsList: null,
currentAgentId: "main",
onAgentChange: () => undefined,
onNavigateToAgent: () => undefined,
onSessionSelect: () => undefined,
onOpenSidebar: () => undefined,
onCloseSidebar: () => undefined,
onSplitRatioChange: () => undefined,
onChatScroll: () => undefined,
basePath: "",
...overrides,
};
}
function renderChatView(overrides: Partial<Parameters<typeof renderChat>[0]> = {}) {
const container = document.createElement("div");
render(
renderChat({
sessionKey: "main",
onSessionKeyChange: () => undefined,
thinkingLevel: null,
showThinking: false,
showToolCalls: true,
loading: false,
sending: false,
compactionStatus: null,
fallbackStatus: null,
messages: [],
sideResult: null,
toolMessages: [],
streamSegments: [],
stream: null,
streamStartedAt: null,
assistantAvatarUrl: null,
draft: "",
queue: [],
realtimeTalkActive: false,
realtimeTalkStatus: "idle",
realtimeTalkDetail: null,
realtimeTalkTranscript: null,
connected: true,
canSend: true,
disabledReason: null,
error: null,
sessions: null,
sidebarOpen: false,
sidebarContent: null,
sidebarError: null,
splitRatio: 0.6,
canvasPluginSurfaceUrl: null,
embedSandboxMode: "scripts",
allowExternalEmbedUrls: false,
assistantName: "Val",
assistantAvatar: null,
userName: null,
userAvatar: null,
localMediaPreviewRoots: [],
assistantAttachmentAuthToken: null,
autoExpandToolCalls: false,
attachments: [],
onAttachmentsChange: () => undefined,
showNewMessages: false,
onScrollToBottom: () => undefined,
onRefresh: () => undefined,
getDraft: () => "",
onDraftChange: () => undefined,
onRequestUpdate: () => undefined,
onSend: () => undefined,
onCompact: () => undefined,
onToggleRealtimeTalk: () => undefined,
onDismissError: () => undefined,
onAbort: () => undefined,
onQueueRemove: () => undefined,
onQueueSteer: () => undefined,
onDismissSideResult: () => undefined,
onNewSession: () => undefined,
onClearHistory: () => undefined,
onOpenSessionCheckpoints: () => undefined,
agentsList: null,
currentAgentId: "main",
onAgentChange: () => undefined,
onNavigateToAgent: () => undefined,
onSessionSelect: () => undefined,
onOpenSidebar: () => undefined,
onCloseSidebar: () => undefined,
onSplitRatioChange: () => undefined,
onChatScroll: () => undefined,
basePath: "",
...overrides,
}),
container,
);
render(renderChat(createChatProps(overrides)), container);
return container;
}
@@ -673,6 +681,8 @@ describe("chat composer workbench", () => {
afterEach(() => {
vi.useRealTimers();
buildChatItemsMock.mockClear();
renderMessageGroupMock.mockClear();
assistantAttachmentRenderVersionMock.value = 0;
loadSessionsMock.mockClear();
refreshVisibleToolsEffectiveForCurrentSessionMock.mockClear();
resetChatViewState();
@@ -694,6 +704,51 @@ describe("chat transcript rendering cache", () => {
expect(buildChatItemsMock).toHaveBeenCalledTimes(1);
});
it("does not rerender transcript groups for draft-only rerenders", () => {
const messages = [{ role: "assistant", content: "ready" }];
const toolMessages: unknown[] = [];
const streamSegments: Array<{ text: string; ts: number }> = [];
const queue: ChatQueueItem[] = [];
const container = document.createElement("div");
render(
renderChat(createChatProps({ messages, toolMessages, streamSegments, queue })),
container,
);
render(
renderChat(createChatProps({ messages, toolMessages, streamSegments, queue, draft: "h" })),
container,
);
render(
renderChat(
createChatProps({ messages, toolMessages, streamSegments, queue, draft: "hello" }),
),
container,
);
expect(renderMessageGroupMock).toHaveBeenCalledTimes(1);
});
it("rerenders transcript groups when assistant attachment availability changes", () => {
const messages = [{ role: "assistant", content: "ready" }];
const toolMessages: unknown[] = [];
const streamSegments: Array<{ text: string; ts: number }> = [];
const queue: ChatQueueItem[] = [];
const container = document.createElement("div");
render(
renderChat(createChatProps({ messages, toolMessages, streamSegments, queue })),
container,
);
assistantAttachmentRenderVersionMock.value += 1;
render(
renderChat(createChatProps({ messages, toolMessages, streamSegments, queue, draft: "h" })),
container,
);
expect(renderMessageGroupMock).toHaveBeenCalledTimes(2);
});
it("rebuilds transcript items when the transcript reference changes", () => {
const toolMessages: unknown[] = [];
const streamSegments: Array<{ text: string; ts: number }> = [];

View File

@@ -1,4 +1,5 @@
import { html, nothing, type TemplateResult } from "lit";
import { guard } from "lit/directives/guard.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { ref } from "lit/directives/ref.js";
import { repeat } from "lit/directives/repeat.js";
@@ -21,6 +22,7 @@ import { renderContextNotice } from "../chat/context-notice.ts";
import { DeletedMessages } from "../chat/deleted-messages.ts";
import { exportChatMarkdown } from "../chat/export.ts";
import {
getAssistantAttachmentAvailabilityRenderVersion,
renderMessageGroup,
renderReadingIndicatorGroup,
renderStreamingGroup,
@@ -522,6 +524,27 @@ function buildCachedChatItems(input: BuildChatItemsProps): ReturnType<typeof bui
return items;
}
function deletedChatItemsSignature(
deleted: DeletedMessages,
chatItems: ReturnType<typeof buildChatItems>,
): string {
const deletedKeys = chatItems
.map((item) => item.key)
.filter((key) => deleted.has(key))
.toSorted();
return deletedKeys.length === 0 ? "" : deletedKeys.join("\u0000");
}
function stableBooleanMapSignature(values: ReadonlyMap<string, boolean>): string {
if (values.size === 0) {
return "";
}
return Array.from(values)
.toSorted(([left], [right]) => left.localeCompare(right))
.map(([key, value]) => `${key}:${value ? "1" : "0"}`)
.join("\u0000");
}
/**
* Reset chat view ephemeral state when navigating away.
* Clears search/slash UI that should not survive navigation.
@@ -1377,6 +1400,8 @@ export function renderChat(props: ChatProps) {
const hasRealtimeTalkConversation = (props.realtimeTalkConversation?.length ?? 0) > 0;
const isEmpty = chatItems.length === 0 && !props.loading && !hasRealtimeTalkConversation;
const showLoadingSkeleton = props.loading && chatItems.length === 0;
const threadContextWindow =
activeSession?.contextTokens ?? props.sessions?.defaults?.contextTokens ?? null;
const thread = html`
<div
@@ -1430,104 +1455,129 @@ export function renderChat(props: ChatProps) {
${isEmpty && vs.searchOpen
? html` <div class="agent-chat__empty">No matching messages</div> `
: nothing}
${repeat(
chatItems,
(item) => item.key,
(item) => {
if (item.kind === "divider") {
return html`
<div class="chat-divider" data-ts=${String(item.timestamp)}>
<div class="chat-divider__rule" role="separator" aria-label=${item.label}>
<span class="chat-divider__line"></span>
<span class="chat-divider__label">${item.label}</span>
<span class="chat-divider__line"></span>
</div>
${item.description || item.action
? html`
<div class="chat-divider__details">
${item.description
? html`<span class="chat-divider__description">
${item.description}
</span>`
: nothing}
${item.action?.kind === "session-checkpoints" &&
props.onOpenSessionCheckpoints
? html`
<button
type="button"
class="btn btn--subtle btn--sm chat-divider__action"
@click=${() => props.onOpenSessionCheckpoints?.()}
>
${item.action.label}
</button>
`
: nothing}
</div>
`
: nothing}
</div>
`;
}
if (item.kind === "reading-indicator") {
return renderReadingIndicatorGroup(
assistantIdentity,
props.basePath,
props.assistantAttachmentAuthToken ?? null,
);
}
if (item.kind === "stream") {
return renderStreamingGroup(
item.text,
item.startedAt,
item.isStreaming,
props.onOpenSidebar,
assistantIdentity,
props.basePath,
props.assistantAttachmentAuthToken ?? null,
);
}
if (item.kind === "group") {
if (deleted.has(item.key)) {
return nothing;
}
return renderMessageGroup(item, {
onOpenSidebar: props.onOpenSidebar,
sessionKey: props.sessionKey,
agentId: props.fullMessageAgentId,
showReasoning,
showToolCalls: props.showToolCalls,
autoExpandToolCalls: Boolean(props.autoExpandToolCalls),
isToolMessageExpanded: (messageId: string) => expandedToolCards.get(messageId),
onToggleToolMessageExpanded: (messageId: string, expanded?: boolean) => {
expandedToolCards.set(
messageId,
!(expanded ?? expandedToolCards.get(messageId) ?? false),
${guard(
[
chatItems,
deletedChatItemsSignature(deleted, chatItems),
stableBooleanMapSignature(expandedToolCards),
getAssistantAttachmentAvailabilityRenderVersion(),
props.sessionKey,
props.fullMessageAgentId,
showReasoning,
props.showToolCalls,
Boolean(props.autoExpandToolCalls),
props.assistantName,
assistantIdentity.avatar,
props.userName,
props.userAvatar,
props.basePath,
(props.localMediaPreviewRoots ?? []).join("\u0000"),
props.assistantAttachmentAuthToken,
props.canvasPluginSurfaceUrl,
props.embedSandboxMode ?? "scripts",
props.allowExternalEmbedUrls ?? false,
threadContextWindow,
],
() =>
repeat(
chatItems,
(item) => item.key,
(item) => {
if (item.kind === "divider") {
return html`
<div class="chat-divider" data-ts=${String(item.timestamp)}>
<div class="chat-divider__rule" role="separator" aria-label=${item.label}>
<span class="chat-divider__line"></span>
<span class="chat-divider__label">${item.label}</span>
<span class="chat-divider__line"></span>
</div>
${item.description || item.action
? html`
<div class="chat-divider__details">
${item.description
? html`<span class="chat-divider__description">
${item.description}
</span>`
: nothing}
${item.action?.kind === "session-checkpoints" &&
props.onOpenSessionCheckpoints
? html`
<button
type="button"
class="btn btn--subtle btn--sm chat-divider__action"
@click=${() => props.onOpenSessionCheckpoints?.()}
>
${item.action.label}
</button>
`
: nothing}
</div>
`
: nothing}
</div>
`;
}
if (item.kind === "reading-indicator") {
return renderReadingIndicatorGroup(
assistantIdentity,
props.basePath,
props.assistantAttachmentAuthToken ?? null,
);
requestUpdate();
},
isToolExpanded: (toolCardId: string) => expandedToolCards.get(toolCardId) ?? false,
onToggleToolExpanded: toggleToolCardExpanded,
onRequestUpdate: requestUpdate,
assistantName: props.assistantName,
assistantAvatar: assistantIdentity.avatar,
userName: props.userName ?? null,
userAvatar: props.userAvatar ?? null,
basePath: props.basePath,
localMediaPreviewRoots: props.localMediaPreviewRoots ?? [],
assistantAttachmentAuthToken: props.assistantAttachmentAuthToken ?? null,
canvasPluginSurfaceUrl: props.canvasPluginSurfaceUrl,
embedSandboxMode: props.embedSandboxMode ?? "scripts",
allowExternalEmbedUrls: props.allowExternalEmbedUrls ?? false,
contextWindow:
activeSession?.contextTokens ?? props.sessions?.defaults?.contextTokens ?? null,
onDelete: () => {
deleted.delete(item.key);
requestUpdate();
},
});
}
return nothing;
},
}
if (item.kind === "stream") {
return renderStreamingGroup(
item.text,
item.startedAt,
item.isStreaming,
props.onOpenSidebar,
assistantIdentity,
props.basePath,
props.assistantAttachmentAuthToken ?? null,
);
}
if (item.kind === "group") {
if (deleted.has(item.key)) {
return nothing;
}
return renderMessageGroup(item, {
onOpenSidebar: props.onOpenSidebar,
sessionKey: props.sessionKey,
agentId: props.fullMessageAgentId,
showReasoning,
showToolCalls: props.showToolCalls,
autoExpandToolCalls: Boolean(props.autoExpandToolCalls),
isToolMessageExpanded: (messageId: string) => expandedToolCards.get(messageId),
onToggleToolMessageExpanded: (messageId: string, expanded?: boolean) => {
expandedToolCards.set(
messageId,
!(expanded ?? expandedToolCards.get(messageId) ?? false),
);
requestUpdate();
},
isToolExpanded: (toolCardId: string) =>
expandedToolCards.get(toolCardId) ?? false,
onToggleToolExpanded: toggleToolCardExpanded,
onRequestUpdate: requestUpdate,
assistantName: props.assistantName,
assistantAvatar: assistantIdentity.avatar,
userName: props.userName ?? null,
userAvatar: props.userAvatar ?? null,
basePath: props.basePath,
localMediaPreviewRoots: props.localMediaPreviewRoots ?? [],
assistantAttachmentAuthToken: props.assistantAttachmentAuthToken ?? null,
canvasPluginSurfaceUrl: props.canvasPluginSurfaceUrl,
embedSandboxMode: props.embedSandboxMode ?? "scripts",
allowExternalEmbedUrls: props.allowExternalEmbedUrls ?? false,
contextWindow: threadContextWindow,
onDelete: () => {
deleted.delete(item.key);
requestUpdate();
},
});
}
return nothing;
},
),
)}
${renderRealtimeTalkConversation(props)}
</div>