diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index 3cc3f9411a1..579eacff1e1 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -44,6 +44,7 @@ const assistantAttachmentAvailabilityCache = new Map>(); 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(), diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 1e455c9fb99..0f41a571760 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -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, @@ -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[0]> = {}, +): Parameters[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[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 }> = []; diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index 3494a864d8e..bed388b08d8 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -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, +): 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 { + 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`
No matching messages
` : nothing} - ${repeat( - chatItems, - (item) => item.key, - (item) => { - if (item.kind === "divider") { - return html` -
- - ${item.description || item.action - ? html` -
- ${item.description - ? html` - ${item.description} - ` - : nothing} - ${item.action?.kind === "session-checkpoints" && - props.onOpenSessionCheckpoints - ? html` - - ` - : nothing} -
- ` - : nothing} -
- `; - } - 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` +
+ + ${item.description || item.action + ? html` +
+ ${item.description + ? html` + ${item.description} + ` + : nothing} + ${item.action?.kind === "session-checkpoints" && + props.onOpenSessionCheckpoints + ? html` + + ` + : nothing} +
+ ` + : nothing} +
+ `; + } + 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)}