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 b3da0a83..81614b25 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,16 @@ 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'; @@ -71,4 +76,24 @@ 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 bf721f15..fcc9c427 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 || @@ -37,16 +49,32 @@ function bashGroupId(messages: BashToolUseMessage[]): string { return `bash-group-${messages[0]?.toolId ?? 'empty'}`; } -export function buildConversationFeedRenderItems( +export function buildConversationFeedRenderModel( messages: ChatMessage[], -): ConversationFeedRenderItem[] { +): 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; @@ -60,6 +88,7 @@ export function buildConversationFeedRenderItems( while (index < messages.length) { const candidate = messages[index]; if (candidate instanceof ToolResultMessage) { + toolResultIndex.set(candidate.toolId, candidate); index += 1; continue; } @@ -100,5 +129,11 @@ export function buildConversationFeedRenderItems( 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 49f50b3a..f97005d9 100644 --- a/web/src/lib/components/chat/ConversationFeed.svelte +++ b/web/src/lib/components/chat/ConversationFeed.svelte @@ -4,16 +4,13 @@ import MessageRenderFallback from './MessageRenderFallback.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, @@ -69,39 +66,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< - string, - { state: 'resolved' | 'cancelled'; allowed?: boolean; reason?: string } - >(); - 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 @@ -114,7 +78,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( @@ -239,13 +206,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 diff --git a/web/src/lib/components/chat/ConversationMessage.svelte b/web/src/lib/components/chat/ConversationMessage.svelte index d3e1a2b2..5f3bc7d7 100644 --- a/web/src/lib/components/chat/ConversationMessage.svelte +++ b/web/src/lib/components/chat/ConversationMessage.svelte @@ -1,3 +1,14 @@ + + +