Skip to content

Commit 402e2bb

Browse files
authored
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.
1 parent bc47071 commit 402e2bb

3 files changed

Lines changed: 328 additions & 198 deletions

File tree

ui/src/ui/chat/grouped-render.ts

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ const assistantAttachmentAvailabilityCache = new Map<string, AssistantAttachment
4444
const assistantAttachmentRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
4545
const ASSISTANT_ATTACHMENT_UNAVAILABLE_RETRY_MS = 5_000;
4646
const ASSISTANT_ATTACHMENT_MEDIA_TICKET_REFRESH_SKEW_MS = 30_000;
47+
let assistantAttachmentAvailabilityRenderVersion = 0;
4748

4849
export type ChatTimestampDisplay = {
4950
label: string;
@@ -94,6 +95,7 @@ function renderChatTimestamp(timestamp: number) {
9495

9596
export function resetAssistantAttachmentAvailabilityCacheForTest() {
9697
assistantAttachmentAvailabilityCache.clear();
98+
bumpAssistantAttachmentAvailabilityRenderVersion();
9799
for (const timer of assistantAttachmentRefreshTimers.values()) {
98100
clearTimeout(timer);
99101
}
@@ -106,6 +108,29 @@ export function resetAssistantAttachmentAvailabilityCacheForTest() {
106108
managedImageBlobUrlMissCache.clear();
107109
}
108110

111+
export function getAssistantAttachmentAvailabilityRenderVersion(): number {
112+
return assistantAttachmentAvailabilityRenderVersion;
113+
}
114+
115+
function bumpAssistantAttachmentAvailabilityRenderVersion() {
116+
assistantAttachmentAvailabilityRenderVersion =
117+
(assistantAttachmentAvailabilityRenderVersion + 1) % Number.MAX_SAFE_INTEGER;
118+
}
119+
120+
function setAssistantAttachmentAvailability(
121+
cacheKey: string,
122+
availability: AssistantAttachmentAvailability,
123+
) {
124+
assistantAttachmentAvailabilityCache.set(cacheKey, availability);
125+
bumpAssistantAttachmentAvailabilityRenderVersion();
126+
}
127+
128+
function deleteAssistantAttachmentAvailability(cacheKey: string) {
129+
if (assistantAttachmentAvailabilityCache.delete(cacheKey)) {
130+
bumpAssistantAttachmentAvailabilityRenderVersion();
131+
}
132+
}
133+
109134
type ImageBlock = {
110135
url: string;
111136
openUrl?: string;
@@ -1218,7 +1243,7 @@ function scheduleAssistantAttachmentRefresh(
12181243
if (cached?.status !== "available" || cached.mediaTicket !== availability.mediaTicket) {
12191244
return;
12201245
}
1221-
assistantAttachmentAvailabilityCache.delete(cacheKey);
1246+
deleteAssistantAttachmentAvailability(cacheKey);
12221247
onRequestUpdate();
12231248
}, refreshInMs);
12241249
assistantAttachmentRefreshTimers.set(cacheKey, timer);
@@ -1246,21 +1271,21 @@ function resolveAssistantAttachmentAvailability(
12461271
cached.status === "unavailable" &&
12471272
now - cached.checkedAt >= ASSISTANT_ATTACHMENT_UNAVAILABLE_RETRY_MS
12481273
) {
1249-
assistantAttachmentAvailabilityCache.delete(cacheKey);
1274+
deleteAssistantAttachmentAvailability(cacheKey);
12501275
} else if (
12511276
cached.status === "available" &&
12521277
cached.mediaTicket &&
12531278
(!cached.mediaTicketExpiresAt ||
12541279
cached.mediaTicketExpiresAt - now <= ASSISTANT_ATTACHMENT_MEDIA_TICKET_REFRESH_SKEW_MS)
12551280
) {
1256-
assistantAttachmentAvailabilityCache.delete(cacheKey);
1281+
deleteAssistantAttachmentAvailability(cacheKey);
12571282
} else {
12581283
scheduleAssistantAttachmentRefresh(cacheKey, cached, onRequestUpdate);
12591284
return cached;
12601285
}
12611286
}
12621287
clearAssistantAttachmentRefreshTimer(cacheKey);
1263-
assistantAttachmentAvailabilityCache.set(cacheKey, { status: "checking" });
1288+
setAssistantAttachmentAvailability(cacheKey, { status: "checking" });
12641289
if (typeof fetch === "function") {
12651290
const headers = new Headers({ Accept: "application/json" });
12661291
if (normalizedAuthToken) {
@@ -1283,7 +1308,7 @@ function resolveAssistantAttachmentAvailability(
12831308
const mediaTicketExpiresAt = Date.parse(payload.mediaTicketExpiresAt ?? "");
12841309
if (mediaTicket && !Number.isFinite(mediaTicketExpiresAt)) {
12851310
clearAssistantAttachmentRefreshTimer(cacheKey);
1286-
assistantAttachmentAvailabilityCache.set(cacheKey, {
1311+
setAssistantAttachmentAvailability(cacheKey, {
12871312
status: "unavailable",
12881313
reason: "Attachment unavailable",
12891314
checkedAt: Date.now(),
@@ -1294,11 +1319,11 @@ function resolveAssistantAttachmentAvailability(
12941319
status: "available",
12951320
...(mediaTicket ? { mediaTicket, mediaTicketExpiresAt } : {}),
12961321
};
1297-
assistantAttachmentAvailabilityCache.set(cacheKey, availability);
1322+
setAssistantAttachmentAvailability(cacheKey, availability);
12981323
scheduleAssistantAttachmentRefresh(cacheKey, availability, onRequestUpdate);
12991324
} else {
13001325
clearAssistantAttachmentRefreshTimer(cacheKey);
1301-
assistantAttachmentAvailabilityCache.set(cacheKey, {
1326+
setAssistantAttachmentAvailability(cacheKey, {
13021327
status: "unavailable",
13031328
reason: payload?.reason?.trim() || "Attachment unavailable",
13041329
checkedAt: Date.now(),
@@ -1307,7 +1332,7 @@ function resolveAssistantAttachmentAvailability(
13071332
})
13081333
.catch(() => {
13091334
clearAssistantAttachmentRefreshTimer(cacheKey);
1310-
assistantAttachmentAvailabilityCache.set(cacheKey, {
1335+
setAssistantAttachmentAvailability(cacheKey, {
13111336
status: "unavailable",
13121337
reason: "Attachment unavailable",
13131338
checkedAt: Date.now(),

ui/src/ui/views/chat.test.ts

Lines changed: 148 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,26 @@ const buildChatItemsMock = vi.hoisted(() =>
100100
return [];
101101
}),
102102
);
103+
const renderMessageGroupMock = vi.hoisted(() =>
104+
vi.fn((group: { messages: Array<{ message: unknown }> }) => {
105+
const element = document.createElement("div");
106+
element.className = "chat-group";
107+
element.textContent = group.messages
108+
.map(({ message }) => {
109+
if (typeof message === "object" && message !== null && "content" in message) {
110+
const content = (message as { content?: unknown }).content;
111+
if (typeof content === "string") {
112+
return content;
113+
}
114+
return content == null ? "" : JSON.stringify(content);
115+
}
116+
return String(message);
117+
})
118+
.join("\n");
119+
return element;
120+
}),
121+
);
122+
const assistantAttachmentRenderVersionMock = vi.hoisted(() => ({ value: 0 }));
103123

104124
function requireFirstAttachmentsChange(
105125
onAttachmentsChange: ReturnType<typeof vi.fn>,
@@ -124,23 +144,8 @@ vi.mock("../chat/build-chat-items.ts", () => ({
124144
}));
125145

126146
vi.mock("../chat/grouped-render.ts", () => ({
127-
renderMessageGroup: (group: { messages: Array<{ message: unknown }> }) => {
128-
const element = document.createElement("div");
129-
element.className = "chat-group";
130-
element.textContent = group.messages
131-
.map(({ message }) => {
132-
if (typeof message === "object" && message !== null && "content" in message) {
133-
const content = (message as { content?: unknown }).content;
134-
if (typeof content === "string") {
135-
return content;
136-
}
137-
return content == null ? "" : JSON.stringify(content);
138-
}
139-
return String(message);
140-
})
141-
.join("\n");
142-
return element;
143-
},
147+
getAssistantAttachmentAvailabilityRenderVersion: () => assistantAttachmentRenderVersionMock.value,
148+
renderMessageGroup: renderMessageGroupMock,
144149
renderReadingIndicatorGroup: () => {
145150
const element = document.createElement("div");
146151
element.className = "chat-reading-indicator";
@@ -490,84 +495,87 @@ function clickTalkSelectOption(container: Element, name: string, value: string):
490495
option.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
491496
}
492497

498+
function createChatProps(
499+
overrides: Partial<Parameters<typeof renderChat>[0]> = {},
500+
): Parameters<typeof renderChat>[0] {
501+
return {
502+
sessionKey: "main",
503+
onSessionKeyChange: () => undefined,
504+
thinkingLevel: null,
505+
showThinking: false,
506+
showToolCalls: true,
507+
loading: false,
508+
sending: false,
509+
compactionStatus: null,
510+
fallbackStatus: null,
511+
messages: [],
512+
sideResult: null,
513+
toolMessages: [],
514+
streamSegments: [],
515+
stream: null,
516+
streamStartedAt: null,
517+
assistantAvatarUrl: null,
518+
draft: "",
519+
queue: [],
520+
realtimeTalkActive: false,
521+
realtimeTalkStatus: "idle",
522+
realtimeTalkDetail: null,
523+
realtimeTalkTranscript: null,
524+
connected: true,
525+
canSend: true,
526+
disabledReason: null,
527+
error: null,
528+
sessions: null,
529+
sidebarOpen: false,
530+
sidebarContent: null,
531+
sidebarError: null,
532+
splitRatio: 0.6,
533+
canvasPluginSurfaceUrl: null,
534+
embedSandboxMode: "scripts",
535+
allowExternalEmbedUrls: false,
536+
assistantName: "Val",
537+
assistantAvatar: null,
538+
userName: null,
539+
userAvatar: null,
540+
localMediaPreviewRoots: [],
541+
assistantAttachmentAuthToken: null,
542+
autoExpandToolCalls: false,
543+
attachments: [],
544+
onAttachmentsChange: () => undefined,
545+
showNewMessages: false,
546+
onScrollToBottom: () => undefined,
547+
onRefresh: () => undefined,
548+
getDraft: () => "",
549+
onDraftChange: () => undefined,
550+
onRequestUpdate: () => undefined,
551+
onSend: () => undefined,
552+
onCompact: () => undefined,
553+
onToggleRealtimeTalk: () => undefined,
554+
onDismissError: () => undefined,
555+
onAbort: () => undefined,
556+
onQueueRemove: () => undefined,
557+
onQueueSteer: () => undefined,
558+
onDismissSideResult: () => undefined,
559+
onNewSession: () => undefined,
560+
onClearHistory: () => undefined,
561+
onOpenSessionCheckpoints: () => undefined,
562+
agentsList: null,
563+
currentAgentId: "main",
564+
onAgentChange: () => undefined,
565+
onNavigateToAgent: () => undefined,
566+
onSessionSelect: () => undefined,
567+
onOpenSidebar: () => undefined,
568+
onCloseSidebar: () => undefined,
569+
onSplitRatioChange: () => undefined,
570+
onChatScroll: () => undefined,
571+
basePath: "",
572+
...overrides,
573+
};
574+
}
575+
493576
function renderChatView(overrides: Partial<Parameters<typeof renderChat>[0]> = {}) {
494577
const container = document.createElement("div");
495-
render(
496-
renderChat({
497-
sessionKey: "main",
498-
onSessionKeyChange: () => undefined,
499-
thinkingLevel: null,
500-
showThinking: false,
501-
showToolCalls: true,
502-
loading: false,
503-
sending: false,
504-
compactionStatus: null,
505-
fallbackStatus: null,
506-
messages: [],
507-
sideResult: null,
508-
toolMessages: [],
509-
streamSegments: [],
510-
stream: null,
511-
streamStartedAt: null,
512-
assistantAvatarUrl: null,
513-
draft: "",
514-
queue: [],
515-
realtimeTalkActive: false,
516-
realtimeTalkStatus: "idle",
517-
realtimeTalkDetail: null,
518-
realtimeTalkTranscript: null,
519-
connected: true,
520-
canSend: true,
521-
disabledReason: null,
522-
error: null,
523-
sessions: null,
524-
sidebarOpen: false,
525-
sidebarContent: null,
526-
sidebarError: null,
527-
splitRatio: 0.6,
528-
canvasPluginSurfaceUrl: null,
529-
embedSandboxMode: "scripts",
530-
allowExternalEmbedUrls: false,
531-
assistantName: "Val",
532-
assistantAvatar: null,
533-
userName: null,
534-
userAvatar: null,
535-
localMediaPreviewRoots: [],
536-
assistantAttachmentAuthToken: null,
537-
autoExpandToolCalls: false,
538-
attachments: [],
539-
onAttachmentsChange: () => undefined,
540-
showNewMessages: false,
541-
onScrollToBottom: () => undefined,
542-
onRefresh: () => undefined,
543-
getDraft: () => "",
544-
onDraftChange: () => undefined,
545-
onRequestUpdate: () => undefined,
546-
onSend: () => undefined,
547-
onCompact: () => undefined,
548-
onToggleRealtimeTalk: () => undefined,
549-
onDismissError: () => undefined,
550-
onAbort: () => undefined,
551-
onQueueRemove: () => undefined,
552-
onQueueSteer: () => undefined,
553-
onDismissSideResult: () => undefined,
554-
onNewSession: () => undefined,
555-
onClearHistory: () => undefined,
556-
onOpenSessionCheckpoints: () => undefined,
557-
agentsList: null,
558-
currentAgentId: "main",
559-
onAgentChange: () => undefined,
560-
onNavigateToAgent: () => undefined,
561-
onSessionSelect: () => undefined,
562-
onOpenSidebar: () => undefined,
563-
onCloseSidebar: () => undefined,
564-
onSplitRatioChange: () => undefined,
565-
onChatScroll: () => undefined,
566-
basePath: "",
567-
...overrides,
568-
}),
569-
container,
570-
);
578+
render(renderChat(createChatProps(overrides)), container);
571579
return container;
572580
}
573581

@@ -673,6 +681,8 @@ describe("chat composer workbench", () => {
673681
afterEach(() => {
674682
vi.useRealTimers();
675683
buildChatItemsMock.mockClear();
684+
renderMessageGroupMock.mockClear();
685+
assistantAttachmentRenderVersionMock.value = 0;
676686
loadSessionsMock.mockClear();
677687
refreshVisibleToolsEffectiveForCurrentSessionMock.mockClear();
678688
resetChatViewState();
@@ -694,6 +704,51 @@ describe("chat transcript rendering cache", () => {
694704
expect(buildChatItemsMock).toHaveBeenCalledTimes(1);
695705
});
696706

707+
it("does not rerender transcript groups for draft-only rerenders", () => {
708+
const messages = [{ role: "assistant", content: "ready" }];
709+
const toolMessages: unknown[] = [];
710+
const streamSegments: Array<{ text: string; ts: number }> = [];
711+
const queue: ChatQueueItem[] = [];
712+
const container = document.createElement("div");
713+
714+
render(
715+
renderChat(createChatProps({ messages, toolMessages, streamSegments, queue })),
716+
container,
717+
);
718+
render(
719+
renderChat(createChatProps({ messages, toolMessages, streamSegments, queue, draft: "h" })),
720+
container,
721+
);
722+
render(
723+
renderChat(
724+
createChatProps({ messages, toolMessages, streamSegments, queue, draft: "hello" }),
725+
),
726+
container,
727+
);
728+
729+
expect(renderMessageGroupMock).toHaveBeenCalledTimes(1);
730+
});
731+
732+
it("rerenders transcript groups when assistant attachment availability changes", () => {
733+
const messages = [{ role: "assistant", content: "ready" }];
734+
const toolMessages: unknown[] = [];
735+
const streamSegments: Array<{ text: string; ts: number }> = [];
736+
const queue: ChatQueueItem[] = [];
737+
const container = document.createElement("div");
738+
739+
render(
740+
renderChat(createChatProps({ messages, toolMessages, streamSegments, queue })),
741+
container,
742+
);
743+
assistantAttachmentRenderVersionMock.value += 1;
744+
render(
745+
renderChat(createChatProps({ messages, toolMessages, streamSegments, queue, draft: "h" })),
746+
container,
747+
);
748+
749+
expect(renderMessageGroupMock).toHaveBeenCalledTimes(2);
750+
});
751+
697752
it("rebuilds transcript items when the transcript reference changes", () => {
698753
const toolMessages: unknown[] = [];
699754
const streamSegments: Array<{ text: string; ts: number }> = [];

0 commit comments

Comments
 (0)