From 143a9ad0540b853daa32f64cfccdb3fd1dfc0894 Mon Sep 17 00:00:00 2001 From: 0xSolarPunk <151763538+0xSolarPunk@users.noreply.github.com> Date: Sun, 7 Jun 2026 13:44:05 +0000 Subject: [PATCH] perf: defer heavy chat shell renderers --- .../__tests__/conversation-feed-items.test.ts | 21 +++++- web/src/lib/chat/conversation-feed-items.ts | 37 +++++++++- .../components/chat/ConversationFeed.svelte | 44 ++--------- .../chat/ConversationMessage.svelte | 28 +++++-- .../components/files/FileViewerHost.svelte | 74 +++++++++++++------ 5 files changed, 133 insertions(+), 71 deletions(-) diff --git a/web/src/lib/chat/__tests__/conversation-feed-items.test.ts b/web/src/lib/chat/__tests__/conversation-feed-items.test.ts index 6218ad95..a7b5431e 100644 --- a/web/src/lib/chat/__tests__/conversation-feed-items.test.ts +++ b/web/src/lib/chat/__tests__/conversation-feed-items.test.ts @@ -2,11 +2,13 @@ import { describe, expect, it } from 'vitest' import { AssistantMessage, BashToolUseMessage, + PermissionCancelledMessage, + PermissionResolvedMessage, ReadToolUseMessage, ToolResultMessage, UserMessage, } from '$shared/chat-types' -import { buildConversationFeedRenderItems } from '../conversation-feed-items' +import { buildConversationFeedRenderItems, buildConversationFeedRenderModel } from '../conversation-feed-items' const TS = '2026-05-29T00:00:00.000Z' @@ -74,4 +76,21 @@ describe('buildConversationFeedRenderItems', () => { expect(secondItems[0]).toMatchObject({ kind: 'bash-group' }) expect(firstItems[0].id).toBe(secondItems[0].id) }) + + it('builds render items and terminal lookup indexes in one pass', () => { + const messages = [ + new BashToolUseMessage(TS, 'bash-1', 'pwd'), + new ToolResultMessage(TS, 'bash-1', { content: 'ok' }, false), + new PermissionResolvedMessage(TS, 'perm-1', true), + new PermissionCancelledMessage(TS, 'perm-2', 'cancelled'), + new AssistantMessage(TS, 'done'), + ] + + const model = buildConversationFeedRenderModel(messages) + + expect(model.items.map((item) => item.kind)).toEqual(['message', 'message']) + expect(model.toolResultIndex.get('bash-1')?.content).toEqual({ content: 'ok' }) + expect(model.permissionTerminalById.get('perm-1')).toEqual({ state: 'resolved', allowed: true }) + expect(model.permissionTerminalById.get('perm-2')).toEqual({ state: 'cancelled', reason: 'cancelled' }) + }) }) diff --git a/web/src/lib/chat/conversation-feed-items.ts b/web/src/lib/chat/conversation-feed-items.ts index 01318c77..728b38d7 100644 --- a/web/src/lib/chat/conversation-feed-items.ts +++ b/web/src/lib/chat/conversation-feed-items.ts @@ -7,6 +7,12 @@ import { } from '$shared/chat-types' import type { ChatMessage } from '$shared/chat-types' +export interface PermissionTerminalState { + state: 'resolved' | 'cancelled' + allowed?: boolean + reason?: string +} + export type ConversationFeedRenderItem = | { kind: 'message' @@ -23,6 +29,12 @@ export type ConversationFeedRenderItem = prevMessage: ChatMessage | null } +export interface ConversationFeedRenderModel { + items: ConversationFeedRenderItem[] + toolResultIndex: Map + permissionTerminalById: Map +} + function shouldSkipStandaloneMessage(message: ChatMessage): boolean { return ( message instanceof ToolResultMessage || @@ -36,14 +48,30 @@ function bashGroupId(messages: BashToolUseMessage[]): string { return `bash-group-${messages[0]?.toolId ?? 'empty'}` } -export function buildConversationFeedRenderItems(messages: ChatMessage[]): ConversationFeedRenderItem[] { +export function buildConversationFeedRenderModel(messages: ChatMessage[]): ConversationFeedRenderModel { const items: ConversationFeedRenderItem[] = [] + const toolResultIndex = new Map() + const permissionTerminalById = new Map() let previousRenderable: ChatMessage | null = null let index = 0 while (index < messages.length) { const message = messages[index] + if (message instanceof ToolResultMessage) { + toolResultIndex.set(message.toolId, message) + } else if (message instanceof PermissionResolvedMessage) { + permissionTerminalById.set(message.permissionRequestId, { + state: 'resolved', + allowed: message.allowed, + }) + } else if (message instanceof PermissionCancelledMessage) { + permissionTerminalById.set(message.permissionRequestId, { + state: 'cancelled', + reason: message.reason, + }) + } + if (shouldSkipStandaloneMessage(message)) { index += 1 continue @@ -57,6 +85,7 @@ export function buildConversationFeedRenderItems(messages: ChatMessage[]): Conve while (index < messages.length) { const candidate = messages[index] if (candidate instanceof ToolResultMessage) { + toolResultIndex.set(candidate.toolId, candidate) index += 1 continue } @@ -97,5 +126,9 @@ export function buildConversationFeedRenderItems(messages: ChatMessage[]): Conve index += 1 } - return items + return { items, toolResultIndex, permissionTerminalById } +} + +export function buildConversationFeedRenderItems(messages: ChatMessage[]): ConversationFeedRenderItem[] { + return buildConversationFeedRenderModel(messages).items } diff --git a/web/src/lib/components/chat/ConversationFeed.svelte b/web/src/lib/components/chat/ConversationFeed.svelte index bd5d26e7..b8c8f116 100644 --- a/web/src/lib/components/chat/ConversationFeed.svelte +++ b/web/src/lib/components/chat/ConversationFeed.svelte @@ -3,16 +3,13 @@ import ChatBashToolGroup from './tools/ChatBashToolGroup.svelte'; import { isToolUseMessage, - ToolResultMessage, PermissionRequestMessage, - PermissionResolvedMessage, - PermissionCancelledMessage, } from '$shared/chat-types'; import type { PendingPermissionRequest } from '$lib/types/chat'; import { getChatState, getAgentState, getLocalSettings, getAppShell } from '$lib/context'; import * as m from '$lib/paraglide/messages.js'; import { createMessageIdAllocator } from '$lib/chat/message-id'; - import { buildConversationFeedRenderItems } from '$lib/chat/conversation-feed-items'; + import { buildConversationFeedRenderModel } from '$lib/chat/conversation-feed-items'; import { CHAT_MAX_WIDTH_FEED_CONTENT_CLASS, CHAT_MAX_WIDTH_FEED_VIEWPORT_CLASS } from '$lib/chat/chat-max-width'; import { Loader2, TriangleAlert, RefreshCw } from '@lucide/svelte'; import { Button } from '$lib/components/ui/button'; @@ -62,36 +59,6 @@ } }); - // Builds a lookup of toolId -> ToolResultMessage so tool-use messages - // can render their results inline. tool-result messages are then - // skipped from the main render loop. - const toolResultIndex = $derived.by(() => { - const index = new Map(); - for (const msg of chatState.visibleMessages) { - if (msg instanceof ToolResultMessage) { - index.set(msg.toolId, msg); - } - } - return index; - }); - - // Builds a lookup from permissionRequestId to terminal state so - // permission-request rows render as pending/resolved/cancelled. - // Terminal messages (permission-resolved, permission-cancelled) are - // skipped from the main render loop. - const permissionTerminalById = $derived.by(() => { - const map = new Map(); - for (const m of chatState.visibleMessages) { - if (m instanceof PermissionResolvedMessage) { - map.set(m.permissionRequestId, { state: 'resolved', allowed: m.allowed }); - } - if (m instanceof PermissionCancelledMessage) { - map.set(m.permissionRequestId, { state: 'cancelled', reason: m.reason }); - } - } - return map; - }); - // Tracks which ExitPlanMode synthetic permission requests are still // pending so we can derive terminal state for the tool-use rendering. // Matches both PascalCase and snake_case variants since claude-cli @@ -104,7 +71,10 @@ ), ); - const renderItems = $derived(buildConversationFeedRenderItems(chatState.visibleMessages)); + // Builds render items and lookup tables in a single pass over the + // visible transcript, keeping stream updates cheaper. + const renderModel = $derived(buildConversationFeedRenderModel(chatState.visibleMessages)); + const renderItems = $derived(renderModel.items); const feedScrollAreaClass = 'h-full overflow-hidden relative'; const feedViewportClass = $derived( @@ -233,13 +203,13 @@ {:else} {@const message = item.message} {@const toolResult = isToolUseMessage(message) - ? toolResultIndex.get(message.toolId) + ? renderModel.toolResultIndex.get(message.toolId) : undefined} {@const exitPlanId = message.type === 'exit-plan-mode-tool-use' ? `plan-exit-${message.toolId}` : null} {@const permTerminal = message instanceof PermissionRequestMessage - ? permissionTerminalById.get(message.permissionRequestId) + ? renderModel.permissionTerminalById.get(message.permissionRequestId) : exitPlanId ? (pendingExitPlanIds.has(exitPlanId) ? undefined : { state: 'resolved' as const, allowed: true }) : undefined} diff --git a/web/src/lib/components/chat/ConversationMessage.svelte b/web/src/lib/components/chat/ConversationMessage.svelte index 1e5262d6..a68a92b6 100644 --- a/web/src/lib/components/chat/ConversationMessage.svelte +++ b/web/src/lib/components/chat/ConversationMessage.svelte @@ -1,3 +1,14 @@ + + +