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.
This commit is contained in:
weishu
2025-12-19 13:08:39 +08:00
parent 24d5056169
commit a7b39a5194
3 changed files with 234 additions and 11 deletions
+188
View File
@@ -0,0 +1,188 @@
import type {
AgentEvent,
AgentEventBlock,
AgentTextBlock,
ChatBlock,
ToolCallBlock,
ToolPermission,
UserTextBlock,
} from '@/chat/types'
export type ChatBlocksById = Map<string, ChatBlock>
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<string, string[]> | null,
right?: Record<string, string[]> | 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 }
}
+17 -3
View File
@@ -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<Map<string, { source: DecryptedMessage; normalized: NormalizedMessage | null }>>(new Map())
const blocksByIdRef = useRef<Map<string, ChatBlock>>(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
+29 -8
View File
@@ -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 <span className="text-amber-600 animate-pulse"></span>
}
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 (
<div className="flex flex-col gap-1 px-1">
@@ -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: {
</Card>
)
}
export const ToolCard = memo(ToolCardInner)