From a7b39a5194d23054bae0ff3b3656402619eea1e7 Mon Sep 17 00:00:00 2001 From: weishu Date: Fri, 19 Dec 2025 13:08:39 +0800 Subject: [PATCH] feat(web): optimize rendering with chat block reconciliation and component memoization Implement block reconciliation to maintain object identity across renders when content hasn't changed, reducing unnecessary re-renders. Add memoization and performance optimizations to ToolCard component. --- web/src/chat/reconcile.ts | 188 +++++++++++++++++++++++ web/src/components/SessionChat.tsx | 20 ++- web/src/components/ToolCard/ToolCard.tsx | 37 ++++- 3 files changed, 234 insertions(+), 11 deletions(-) create mode 100644 web/src/chat/reconcile.ts diff --git a/web/src/chat/reconcile.ts b/web/src/chat/reconcile.ts new file mode 100644 index 00000000..8e679ae1 --- /dev/null +++ b/web/src/chat/reconcile.ts @@ -0,0 +1,188 @@ +import type { + AgentEvent, + AgentEventBlock, + AgentTextBlock, + ChatBlock, + ToolCallBlock, + ToolPermission, + UserTextBlock, +} from '@/chat/types' + +export type ChatBlocksById = Map + +function indexBlocks(blocks: ChatBlock[], map: ChatBlocksById): void { + for (const block of blocks) { + map.set(block.id, block) + if (block.kind === 'tool-call') { + indexBlocks(block.children, map) + } + } +} + +function areStringArraysEqual(left?: string[] | null, right?: string[] | null): boolean { + if (left === right) return true + if (!left || !right) return false + if (left.length !== right.length) return false + for (let i = 0; i < left.length; i += 1) { + if (left[i] !== right[i]) return false + } + return true +} + +function areAnswersEqual( + left?: Record | null, + right?: Record | null +): boolean { + if (left === right) return true + if (!left || !right) return false + const leftKeys = Object.keys(left) + const rightKeys = Object.keys(right) + if (leftKeys.length !== rightKeys.length) return false + leftKeys.sort() + rightKeys.sort() + for (let i = 0; i < leftKeys.length; i += 1) { + const leftKey = leftKeys[i] + if (leftKey !== rightKeys[i]) return false + if (!areStringArraysEqual(left[leftKey], right[leftKey])) return false + } + return true +} + +function arePermissionsEqual(left?: ToolPermission, right?: ToolPermission): boolean { + if (left === right) return true + if (!left || !right) return false + return left.id === right.id + && left.status === right.status + && left.reason === right.reason + && left.mode === right.mode + && left.decision === right.decision + && left.date === right.date + && left.createdAt === right.createdAt + && left.completedAt === right.completedAt + && areStringArraysEqual(left.allowedTools, right.allowedTools) + && areAnswersEqual(left.answers, right.answers) +} + +function getEventKey(event: AgentEvent): string { + switch (event.type) { + case 'switch': + return `switch:${event.mode}` + case 'message': + return `message:${event.message}` + case 'title-changed': + return `title:${event.title}` + case 'limit-reached': + return `limit:${event.endsAt}` + case 'ready': + return 'ready' + default: + try { + return JSON.stringify(event) + } catch { + return event.type + } + } +} + +function areAgentEventsEqual(left: AgentEvent, right: AgentEvent): boolean { + if (left === right) return true + return getEventKey(left) === getEventKey(right) +} + +function areUserTextBlocksEqual(left: UserTextBlock, right: UserTextBlock): boolean { + return left.text === right.text + && left.status === right.status + && left.originalText === right.originalText + && left.localId === right.localId + && left.createdAt === right.createdAt + && left.meta === right.meta +} + +function areAgentTextBlocksEqual(left: AgentTextBlock, right: AgentTextBlock): boolean { + return left.text === right.text + && left.localId === right.localId + && left.createdAt === right.createdAt + && left.meta === right.meta +} + +function areAgentEventBlocksEqual(left: AgentEventBlock, right: AgentEventBlock): boolean { + return left.createdAt === right.createdAt + && left.meta === right.meta + && areAgentEventsEqual(left.event, right.event) +} + +function areToolCallsEqual(left: ToolCallBlock, right: ToolCallBlock, childrenSame: boolean): boolean { + if (!childrenSame) return false + return left.localId === right.localId + && left.createdAt === right.createdAt + && left.meta === right.meta + && left.tool.id === right.tool.id + && left.tool.name === right.tool.name + && left.tool.state === right.tool.state + && left.tool.input === right.tool.input + && left.tool.result === right.tool.result + && left.tool.description === right.tool.description + && left.tool.createdAt === right.tool.createdAt + && left.tool.startedAt === right.tool.startedAt + && left.tool.completedAt === right.tool.completedAt + && arePermissionsEqual(left.tool.permission, right.tool.permission) +} + +function reconcileBlockList(blocks: ChatBlock[], prevById: ChatBlocksById): ChatBlock[] { + let changed = false + const reconciled = blocks.map((block) => { + const next = reconcileBlock(block, prevById) + if (next !== block) { + changed = true + } + return next + }) + return changed ? reconciled : blocks +} + +function reconcileBlock(block: ChatBlock, prevById: ChatBlocksById): ChatBlock { + const prev = prevById.get(block.id) + + if (block.kind === 'tool-call') { + const nextChildren = reconcileBlockList(block.children, prevById) + const nextBlock = nextChildren === block.children + ? block + : { ...block, children: nextChildren } + + if (prev && prev.kind === 'tool-call') { + const childrenSame = prev.children.length === nextChildren.length + && prev.children.every((child, idx) => child === nextChildren[idx]) + if (areToolCallsEqual(prev, nextBlock, childrenSame)) { + return prev + } + } + return nextBlock + } + + if (!prev || prev.kind !== block.kind) { + return block + } + + if (block.kind === 'user-text') { + const prevBlock = prev as UserTextBlock + return areUserTextBlocksEqual(prevBlock, block) ? prevBlock : block + } + + if (block.kind === 'agent-text') { + const prevBlock = prev as AgentTextBlock + return areAgentTextBlocksEqual(prevBlock, block) ? prevBlock : block + } + + const prevBlock = prev as AgentEventBlock + return areAgentEventBlocksEqual(prevBlock, block) ? prevBlock : block +} + +export function reconcileChatBlocks(nextBlocks: ChatBlock[], prevById: ChatBlocksById): { + blocks: ChatBlock[] + byId: ChatBlocksById +} { + const blocks = reconcileBlockList(nextBlocks, prevById) + const byId: ChatBlocksById = new Map() + indexBlocks(blocks, byId) + return { blocks, byId } +} diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index d06de285..c501e09c 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -2,9 +2,10 @@ import { useCallback, useEffect, useMemo, useRef } from 'react' import { AssistantRuntimeProvider } from '@assistant-ui/react' import type { ApiClient } from '@/api/client' import type { DecryptedMessage, ModelMode, PermissionMode, Session } from '@/types/api' -import type { NormalizedMessage } from '@/chat/types' +import type { ChatBlock, NormalizedMessage } from '@/chat/types' import { normalizeDecryptedMessage } from '@/chat/normalize' import { reduceChatBlocks } from '@/chat/reducer' +import { reconcileChatBlocks } from '@/chat/reconcile' import { Button } from '@/components/ui/button' import { HappyComposer } from '@/components/AssistantChat/HappyComposer' import { HappyThread } from '@/components/AssistantChat/HappyThread' @@ -30,9 +31,11 @@ export function SessionChat(props: { const { haptic } = usePlatform() const controlsDisabled = !props.session.active const normalizedCacheRef = useRef>(new Map()) + const blocksByIdRef = useRef>(new Map()) useEffect(() => { normalizedCacheRef.current.clear() + blocksByIdRef.current.clear() }, [props.session.id]) const normalizedMessages: NormalizedMessage[] = useMemo(() => { @@ -58,7 +61,18 @@ export function SessionChat(props: { return normalized }, [props.messages]) - const reduced = useMemo(() => reduceChatBlocks(normalizedMessages, props.session.agentState), [normalizedMessages, props.session.agentState]) + const reduced = useMemo( + () => reduceChatBlocks(normalizedMessages, props.session.agentState), + [normalizedMessages, props.session.agentState] + ) + const reconciled = useMemo( + () => reconcileChatBlocks(reduced.blocks, blocksByIdRef.current), + [reduced.blocks] + ) + + useEffect(() => { + blocksByIdRef.current = reconciled.byId + }, [reconciled.byId]) // Permission mode change handler const handlePermissionModeChange = useCallback(async (mode: PermissionMode) => { @@ -92,7 +106,7 @@ export function SessionChat(props: { const runtime = useHappyRuntime({ session: props.session, - blocks: reduced.blocks, + blocks: reconciled.blocks, isSending: props.isSending, onSendMessage: props.onSend, onAbort: handleAbort diff --git a/web/src/components/ToolCard/ToolCard.tsx b/web/src/components/ToolCard/ToolCard.tsx index c4f1841e..b3cb9247 100644 --- a/web/src/components/ToolCard/ToolCard.tsx +++ b/web/src/components/ToolCard/ToolCard.tsx @@ -1,7 +1,7 @@ import type { ToolCallBlock } from '@/chat/types' import type { ApiClient } from '@/api/client' import type { SessionMetadataSummary } from '@/types/api' -import { useEffect, useState, type ReactNode } from 'react' +import { memo, useEffect, useMemo, useState, type ReactNode } from 'react' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { CodeBlock } from '@/components/CodeBlock' import { MarkdownRenderer } from '@/components/MarkdownRenderer' @@ -45,12 +45,14 @@ function truncate(text: string, maxLen: number): string { return text.slice(0, maxLen - 3) + '...' } +const ELAPSED_INTERVAL_MS = 1000 + function ElapsedView(props: { from: number; active: boolean }) { const [now, setNow] = useState(() => Date.now()) useEffect(() => { if (!props.active) return - const id = setInterval(() => setNow(Date.now()), 250) + const id = setInterval(() => setNow(Date.now()), ELAPSED_INTERVAL_MS) return () => clearInterval(id) }, [props.active]) @@ -96,7 +98,7 @@ function TaskStateIcon(props: { state: ToolCallBlock['tool']['state'] }) { return } -function renderTaskSummary(block: ToolCallBlock, metadata: SessionMetadataSummary | null): ReactNode | null { +function getTaskSummaryChildren(block: ToolCallBlock): { visible: ToolCallBlock[]; remaining: number } | null { if (block.tool.name !== 'Task') return null const children = block.children @@ -106,7 +108,15 @@ function renderTaskSummary(block: ToolCallBlock, metadata: SessionMetadataSummar if (children.length === 0) return null const visible = children.slice(-3) - const remaining = children.length - visible.length + return { visible, remaining: children.length - visible.length } +} + +function renderTaskSummary(block: ToolCallBlock, metadata: SessionMetadataSummary | null): ReactNode | null { + const summary = getTaskSummaryChildren(block) + if (!summary) return null + + const visible = summary.visible + const remaining = summary.remaining return (
@@ -288,22 +298,31 @@ function DetailsIcon() { ) } -export function ToolCard(props: { +type ToolCardProps = { api: ApiClient sessionId: string metadata: SessionMetadataSummary | null disabled: boolean onDone: () => void block: ToolCallBlock -}) { - const presentation = getToolPresentation({ +} + +function ToolCardInner(props: ToolCardProps) { + const presentation = useMemo(() => getToolPresentation({ toolName: props.block.tool.name, input: props.block.tool.input, result: props.block.tool.result, childrenCount: props.block.children.length, description: props.block.tool.description, metadata: props.metadata - }) + }), [ + props.block.tool.name, + props.block.tool.input, + props.block.tool.result, + props.block.children.length, + props.block.tool.description, + props.metadata + ]) const toolName = props.block.tool.name const toolTitle = presentation.title @@ -435,3 +454,5 @@ export function ToolCard(props: { ) } + +export const ToolCard = memo(ToolCardInner)