perf: 会话切换去卡顿——历史渐进渲染 + 消息树 memo + 主进程减负

This commit is contained in:
Rick Ye
2026-09-09 02:05:44 +08:00
parent 24df36a9b4
commit 3f9d79ca30
6 changed files with 308 additions and 118 deletions
+29 -32
View File
@@ -308,40 +308,17 @@ export class AgentService {
private emitSwitched(session: AgentSession): void {
const state = this.buildState(session)
// 工具状态不再随事件重发:渲染层用 historyToTools 从 messages 自行重建,
// 重复克隆一遍全量工具参数/结果是长会话切换卡顿的大头(历史被序列化两次)
this.emit({
type: 'session_switched',
state,
messages: session.messages.map((m) => toCloneable(m)),
activeTools: this.collectToolStates(session)
messages: session.messages.map((m) => toCloneable(m))
})
// 切换/回溯后立刻带上一次统计(上下文容量、缓存命中率),Usage Card 无需等第一回合
void this.emitStats()
}
/** 从历史消息中恢复工具状态(用于切换会话后 ToolCard 的参数/结果展示) */
private collectToolStates(session: AgentSession): Record<string, unknown> {
const tools: Record<string, unknown> = {}
for (const message of session.messages) {
const m = message as { role?: string; content?: unknown; toolCallId?: unknown; toolName?: unknown; isError?: unknown }
if (m.role !== 'assistant' || !Array.isArray(m.content)) continue
for (const block of m.content as Array<{ type?: string; id?: string; name?: string; arguments?: unknown }>) {
if (block.type === 'toolCall' && block.id) {
tools[block.id] = { toolName: block.name ?? 'tool', args: toCloneable(block.arguments), status: 'done' }
}
}
}
for (const message of session.messages) {
const m = message as { role?: string; toolCallId?: unknown; toolName?: unknown; content?: unknown; isError?: unknown }
if (m.role !== 'toolResult' || typeof m.toolCallId !== 'string') continue
tools[m.toolCallId] = {
...((tools[m.toolCallId] as Record<string, unknown>) ?? { toolName: m.toolName ?? 'tool' }),
result: toCloneable(m),
status: m.isError ? 'error' : 'done'
}
}
return tools
}
async ensureReady(): Promise<SessionState> {
try {
const runtime = await this.getRuntime()
@@ -498,12 +475,12 @@ export class AgentService {
switch (m.role) {
case 'user':
case 'assistant':
byKey.get('messages')!.tokens += estimateTokens(message as Parameters<typeof estimateTokens>[0])
byKey.get('messages')!.tokens += estimateMessageTokens(message)
break
case 'toolResult': {
const key =
typeof m.toolName === 'string' && CUSTOM_TOOL_NAMES.has(m.toolName) ? 'customTools' : 'builtinTools'
byKey.get(key)!.tokens += estimateTokens(message as Parameters<typeof estimateTokens>[0])
byKey.get(key)!.tokens += estimateMessageTokens(message)
break
}
// bash 执行、扩展注入、压缩/分支摘要等注入上下文的消息
@@ -511,7 +488,7 @@ export class AgentService {
case 'custom':
case 'compactionSummary':
case 'branchSummary':
byKey.get('other')!.tokens += estimateTokens(message as Parameters<typeof estimateTokens>[0])
byKey.get('other')!.tokens += estimateMessageTokens(message)
break
default:
break
@@ -1154,15 +1131,35 @@ function samePath(a: string, b: string): boolean {
return a.replace(/\\/g, '/').toLowerCase() === b.replace(/\\/g, '/').toLowerCase()
}
/** 事件消息对象转为可结构化克隆的普通对象(剥离 undefined/函数/循环引用) */
/** 事件消息对象转为可结构化克隆的普通对象(剥离函数/循环引用等不可克隆值)。
* structuredClone 远快于 JSON 往返——切换会话时整段历史都要过一遍这里,
* 长上下文下 JSON 序列化+反序列化是主进程最重的同步开销。 */
function toCloneable(value: unknown): unknown {
if (value === null || typeof value !== 'object') return value ?? null
try {
return JSON.parse(JSON.stringify(value ?? null))
return structuredClone(value)
} catch {
return null
try {
return JSON.parse(JSON.stringify(value))
} catch {
return null
}
}
}
/** 消息级 token 估算缓存:消息对象落定后不可变(pi 只追加),按引用缓存。
* emitStats 在每条 assistant 消息后都会全量重扫,不缓存的话整个会话是 O(n²) 的重复估算。 */
const messageTokenCache = new WeakMap<object, number>()
function estimateMessageTokens(message: object): number {
let tokens = messageTokenCache.get(message)
if (tokens === undefined) {
tokens = estimateTokens(message as Parameters<typeof estimateTokens>[0])
messageTokenCache.set(message, tokens)
}
return tokens
}
function readRole(message: unknown): string {
if (message && typeof message === 'object' && 'role' in message) {
return String((message as { role: unknown }).role)
+9 -1
View File
@@ -168,6 +168,14 @@ export default function App(): JSX.Element {
send(`${instruction}:\n\n> ${selection.split('\n').join('\n> ')}`)
}
// 回溯入口(稳定引用:MessageItem memo 依赖它,内联箭头每次渲染都会失效 memo)
const handleRewind = useCallback(
(entryId: string, summarize: boolean): void => {
navigateTree(entryId, { summarize })
},
[navigateTree]
)
// 标题栏会话名:优先当前会话名,其次会话列表里的名称/首条消息
const activeMeta = state.sessionState?.sessionId
? sessions.find((s) => s.id === state.sessionState?.sessionId)
@@ -241,7 +249,7 @@ export default function App(): JSX.Element {
onDismissError={clearError}
onDismissCompactionNotice={dismissCompactionNotice}
onAbortCompaction={abortCompaction}
onRewind={(entryId, summarize) => navigateTree(entryId, { summarize })}
onRewind={handleRewind}
onPickSuggestion={handlePickSuggestion}
onApplySelection={handleApplySelection}
/>
@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { LuFoldVertical, LuLoaderCircle, LuRotateCw, LuTriangleAlert, LuX } from 'react-icons/lu'
import type { ApprovalRequest, ForkPoint } from '@shared/types'
@@ -13,6 +13,33 @@ const SUGGESTIONS = [
'查询最近一次上架任务的执行结果'
]
/** 渐进渲染参数:切会话首屏只挂载最近的 HISTORY_INITIAL 条消息,
* 其余在浏览器空闲时按 HISTORY_BATCH 分批向前补齐——长上下文会话
* (几百条消息、上百个 Markdown 解析)一次性挂载会让渲染线程阻塞
* 数秒,分批后首帧开销恒定,补齐过程对用户不可感。
* 首屏只需覆盖视口(约 3-5 条消息)加少量余量;批次足够小以把
* 单次挂载的长任务压到不可感知(生产构建 <50ms)。 */
const HISTORY_INITIAL = 24
const HISTORY_BATCH = 36
/** 把「第一个可见消息」回退到回合边界:避免从 assistant 组的中间切开,
* 导致首条渲染的回合缺了前半段的思考/工具调用 */
function snapToTurnStart(messages: UiMessage[], start: number): number {
let i = Math.min(Math.max(0, start), messages.length)
while (i > 0 && messages[i]?.role === 'assistant') i -= 1
return i
}
/** [from, to) 区间内的用户消息数(回溯点按用户消息序对齐,被隐藏的前缀
* 占用的序号要跳过;本地乐观插入不算) */
function countUserMessages(messages: UiMessage[], from: number, to: number): number {
let n = 0
for (let i = from; i < to && i < messages.length; i++) {
if (messages[i].role === 'user' && !messages[i].local) n += 1
}
return n
}
/** 空状态:一行小标题 + 三条建议(纯文本行,点击填入输入框) */
function EmptyState({ onPick }: { onPick: (text: string) => void }): ReactNode {
return (
@@ -37,7 +64,9 @@ function EmptyState({ onPick }: { onPick: (text: string) => void }): ReactNode {
/** 连续 assistant 消息(pi 每个工具轮次一条)合并为一个回合渲染,
* 整轮思考 + 工具调用共享一张 beautifului ToolChips 卡。
* pinnedRef 向下透传:回合结束时据此判断用户是否位于底部,位于底部才即时折叠。
* forkPoints 与时间线中的用户消息按出现顺序一一对齐(pi getUserMessagesForForking 同序)。 */
* forkPoints 与时间线中的用户消息按出现顺序一一对齐(pi getUserMessagesForForking 同序),
* startUserIdx 为被渐进填充隐藏的前缀中已占用的用户消息序号。
* animate=false(正在补齐历史)时挂载的消息不播入场动画。 */
function renderTurns(
messages: UiMessage[],
tools: Record<string, ToolState>,
@@ -47,11 +76,13 @@ function renderTurns(
pinnedRef: { current: boolean },
forkPoints: ForkPoint[],
rewinding: boolean,
onRewind: (entryId: string, summarize: boolean) => void
onRewind: (entryId: string, summarize: boolean) => void,
startUserIdx = 0,
animate = true
): ReactNode[] {
const items: ReactNode[] = []
let i = 0
let userMessageIdx = 0
let userMessageIdx = startUserIdx
while (i < messages.length) {
const message = messages[i]
if (message.role === 'assistant') {
@@ -89,6 +120,7 @@ function renderTurns(
forkEntryId={forkEntryId}
rewinding={rewinding}
onRewind={onRewind}
animate={animate}
/>
)
i += 1
@@ -148,6 +180,53 @@ export function ChatView({
const scrollRef = useRef<HTMLDivElement>(null)
const pinnedRef = useRef(true)
// ---- 历史渐进填充 ----
// 检测「历史被整体替换」(切换会话 / 压缩换血 / 回溯):这些路径会重建全部
// 消息 key,而流式追加只改尾部。以首条 key 为代际标记,渲染期直接截断首屏
// 挂载量(render-phase setState,React 会丢弃中间帧,全量列表不会被提交)。
const historyKey = messages.length > 0 ? messages[0].key : ''
const epochRef = useRef<string | null>(null)
const [hidden, setHidden] = useState(0)
if (epochRef.current !== historyKey) {
epochRef.current = historyKey
// 切换后回到吸底状态(旧会话里上翻过的 pinned 需要复位)
pinnedRef.current = true
setHidden(snapToTurnStart(messages, messages.length - HISTORY_INITIAL))
}
// 空闲时分批向前补齐(超时兜底较紧:忙时也持续小步推进,不会停滞)
const messagesRef = useRef(messages)
messagesRef.current = messages
useEffect(() => {
if (hidden <= 0) return
const id = requestIdleCallback(
() => setHidden((h) => snapToTurnStart(messagesRef.current, h - HISTORY_BATCH)),
{ timeout: 150 }
)
return () => cancelIdleCallback(id)
}, [hidden])
// 补齐批次在视口上方插入内容:吸底时保持贴底;用户已上翻阅读时锚定视口
// (滚动位置随内容增长平移,阅读位置不跳)。cleanup 在下一次更新前捕获
// 变更前的滚动状态,作为锚点。代际切换时高度差跨内容无意义,跳过锚定。
const fillAnchorRef = useRef<{ scrollTop: number; height: number } | null>(null)
const anchorEpochRef = useRef<string | null>(null)
useLayoutEffect(() => {
const el = scrollRef.current
if (!el) return
const epochChanged = anchorEpochRef.current !== historyKey
anchorEpochRef.current = historyKey
const anchor = fillAnchorRef.current
if (anchor && !epochChanged) {
if (pinnedRef.current) el.scrollTop = el.scrollHeight
else el.scrollTop = anchor.scrollTop + (el.scrollHeight - anchor.height)
}
fillAnchorRef.current = { scrollTop: el.scrollTop, height: el.scrollHeight }
return () => {
fillAnchorRef.current = { scrollTop: el.scrollTop, height: el.scrollHeight }
}
}, [hidden, historyKey])
// 用户上滚时停止自动吸底,回到底部附近恢复
const handleScroll = (): void => {
const el = scrollRef.current
@@ -276,8 +355,14 @@ export function ChatView({
<div ref={scrollRef} onScroll={handleScroll} className="min-h-0 flex-1 overflow-y-auto">
{hasContent ? (
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
{hidden > 0 && (
<div className="flex items-center justify-center gap-1.5 text-[12px] text-ink-3">
<LuLoaderCircle size={12} className="animate-spin" />
正在载入更早的对话…
</div>
)}
{renderTurns(
messages,
messages.slice(hidden),
tools,
streaming,
turnActive,
@@ -285,7 +370,9 @@ export function ChatView({
pinnedRef,
forkPoints,
navigating,
onRewind
onRewind,
countUserMessages(messages, 0, hidden),
hidden === 0
)}
</div>
) : (
@@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
import { memo, useEffect, useLayoutEffect, useRef, useState, type ComponentProps, type ReactNode } from 'react'
import type { ApprovalRequest, ToolState, UiMessage } from '../hooks/useAgent'
import { animateCollapse, animateExpand } from '../lib/anim'
import { Markdown } from './Markdown'
@@ -123,7 +123,7 @@ function CopyAnswerButton({ text }: { text: string }): ReactNode {
)
}
export function AssistantTurn({
function AssistantTurnBase({
messages,
tools,
streaming,
@@ -140,75 +140,21 @@ export function AssistantTurn({
* 时执行即时折叠——用户上翻阅读时折叠会猛拽滚动位置/滚动条,保持展开 */
scrollPinnedRef?: { current: boolean }
}): ReactNode {
const processNodes: ReactNode[] = []
// ---- 第一遍:块级描述符(只做类型判断与字符串引用收集,不拆行、不建 JSX) ----
// 长会话渐进补齐时每个批次会挂载几十个历史回合,若在首遍就执行
// thinkingRows 拆行 / Markdown 解析 / ToolChips 行构建,批次依旧很重。
// 重活推迟到「过程区确实要显示」(contentMounted)时再做。
type ProcItem =
| { k: 'think'; text: string }
| { k: 'text'; text: string }
| { k: 'tool'; id?: string; name: string }
const proc: ProcItem[] = []
const answerNodes: ReactNode[] = []
const labelCount = new Map<string, number>()
let calls = 0
let thinking = 0
let pendingCalls: ToolCallView[] = []
let stream: UiMessage['stream'] | null = null
let answerText = ''
/** 连续的工具行作为一组裸行铺出(组内保留顺序),不缩进 */
const flushRows = (): void => {
if (!pendingCalls.length) return
const group = pendingCalls
pendingCalls = []
processNodes.push(
<div key={`rows-${processNodes.length}`} className="my-1.5">
<ToolChips calls={group} />
</div>
)
}
/** 消费一条已完成消息:asAnswer=false 时正文作为过程叙述留在过程区 */
const consume = (raw: unknown, asAnswer: boolean): void => {
for (const block of readBlocks(raw)) {
const thinkingText = block.type === 'thinking' ? (block.text ?? block.thinking) : undefined
if (block.type === 'thinking' && typeof thinkingText === 'string' && thinkingText.trim()) {
// 已完成的思考以展开的 Thinking 面板留在过程区——与流式形态一致。
// 以前在这里压成 ToolChips 的单行摘要,每个工具轮次 message_end 时
// 展开面板「缩」成一行、下一轮又展开新面板,看起来像过程区被反复收起。
flushRows()
processNodes.push(
<div key={`think-${processNodes.length}`} className="mb-3">
<Thinking rows={thinkingRows(thinkingText)} streaming={false} />
</div>
)
thinking += 1
continue
}
if (block.type === 'text' && block.text) {
flushRows()
if (asAnswer) answerText = answerText ? `${answerText}\n\n${block.text}` : block.text
const node = (
<div key={`${asAnswer ? 'a' : 'p'}-${processNodes.length + answerNodes.length}`} className="my-3">
<Markdown text={block.text} />
</div>
)
if (asAnswer) answerNodes.push(node)
else processNodes.push(node)
continue
}
if (block.type === 'toolCall') {
const name = block.name ?? 'tool'
const tool = block.id ? tools[block.id] : undefined
if (!tool) {
const pending = pendingApprovals.find((a) => a.toolName === name)
if (pending) {
pendingCalls.push({ id: block.id, name, pendingApproval: { args: pending.args } })
calls += 1
continue
}
}
const dup = labelCount.get(name) ?? 0
labelCount.set(name, dup + 1)
pendingCalls.push({ id: block.id, name, tool })
calls += 1
}
}
}
// 流式中的消息 = 答案雏形(最后一条);其余消息全部是过程。
// 只有回合结束(settled)且没有流式占位时,最后一条已完成消息的正文才是答案。
const streamPos = messages.findIndex((m) => m.stream)
@@ -219,9 +165,33 @@ export function AssistantTurn({
stream = message.stream
continue
}
consume(message.raw, settled && streamPos === -1 && idx === messages.length - 1)
const asAnswer = settled && streamPos === -1 && idx === messages.length - 1
for (const block of readBlocks(message.raw)) {
const thinkingText = block.type === 'thinking' ? (block.text ?? block.thinking) : undefined
if (block.type === 'thinking' && typeof thinkingText === 'string' && thinkingText.trim()) {
proc.push({ k: 'think', text: thinkingText })
thinking += 1
continue
}
if (block.type === 'text' && block.text) {
if (asAnswer) {
answerText = answerText ? `${answerText}\n\n${block.text}` : block.text
answerNodes.push(
<div key={`a-${answerNodes.length}`} className="my-3">
<Markdown text={block.text} />
</div>
)
} else {
proc.push({ k: 'text', text: block.text })
}
continue
}
if (block.type === 'toolCall') {
proc.push({ k: 'tool', id: block.id, name: block.name ?? 'tool' })
calls += 1
}
}
}
flushRows()
const streamHasThinking = stream
? stream.segments.some((s) => s.kind === 'thinking' && s.text.trim().length > 0)
@@ -238,6 +208,11 @@ export function AssistantTurn({
// 上翻阅读时折叠会猛拽滚动位置,保持展开。因抖动被自动收起的回合在 live 恢复
// 时自动重新展开;用户手动收起的不参与自动展开。
const [collapsed, setCollapsed] = useState<boolean>(() => !live && hasProcess)
// 过程内容懒挂载:历史回合默认收起,其思考/工具内容在用户首次展开前不渲染
// (折叠只是视觉收起,子树照常挂载的话长会话切换仍要全量解析)。
// live 回合从一开始就可见,挂载内容;一旦挂载过就保持(再次折叠走 GSAP
// 高度动画需要内容在场,且 Thinking 面板内部滚动状态不重置)。
const [contentMounted, setContentMounted] = useState<boolean>(() => live)
const autoCollapsedRef = useRef(false)
const prevLiveRef = useRef(live)
useEffect(() => {
@@ -254,10 +229,58 @@ export function AssistantTurn({
}
if (!prev && live && autoCollapsedRef.current) {
autoCollapsedRef.current = false
setContentMounted(true)
setCollapsed(false)
}
}, [live, scrollPinnedRef])
// ---- 第二遍:按需物化过程区(仅在内容已挂载时执行重活) ----
const processNodes: ReactNode[] = []
if (contentMounted) {
/** 连续的工具行作为一组裸行铺出(组内保留顺序),不缩进 */
let pendingCalls: ToolCallView[] = []
const flushRows = (): void => {
if (!pendingCalls.length) return
const group = pendingCalls
pendingCalls = []
processNodes.push(
<div key={`rows-${processNodes.length}`} className="my-1.5">
<ToolChips calls={group} />
</div>
)
}
for (const item of proc) {
if (item.k === 'think') {
// 已完成的思考以展开的 Thinking 面板留在过程区——与流式形态一致
flushRows()
processNodes.push(
<div key={`think-${processNodes.length}`} className="mb-3">
<Thinking rows={thinkingRows(item.text)} streaming={false} />
</div>
)
} else if (item.k === 'text') {
// 干活过程中模型穿插写的叙述性正文,样式与普通正文一致
flushRows()
processNodes.push(
<div key={`p-${processNodes.length}`} className="my-3">
<Markdown text={item.text} />
</div>
)
} else {
const tool = item.id ? tools[item.id] : undefined
if (!tool) {
const pending = pendingApprovals.find((a) => a.toolName === item.name)
if (pending) {
pendingCalls.push({ id: item.id, name: item.name, pendingApproval: { args: pending.args } })
continue
}
}
pendingCalls.push({ id: item.id, name: item.name, tool })
}
}
flushRows()
}
// 过程区折叠/展开:GSAP 高度 + 透明度补间(可中断续接)。内联高度完全由
// 动画层持有,React 不渲染 style,避免重渲染时清掉补间中的值。
// 首帧直接落位(历史回合默认收起),不播动画。
@@ -340,6 +363,7 @@ export function AssistantTurn({
aria-expanded={!collapsed}
onClick={() => {
autoCollapsedRef.current = false
setContentMounted(true)
setCollapsed((v) => !v)
}}
className="-mx-1.5 flex h-7 w-fit items-center gap-1.5 rounded-control px-1.5 text-[12.5px] text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
@@ -385,9 +409,10 @@ export function AssistantTurn({
</div>
)}
{/* 过程区:GSAP 高度 + 透明度补间(见上方 useLayoutEffect) */}
{/* 过程区:GSAP 高度 + 透明度补间(见上方 useLayoutEffect)。
未展开过的历史回合不挂载内容(懒渲染),首展时随本组件 state 更新入树 */}
<div ref={processRef} aria-hidden={collapsed && hasProcess} className="overflow-hidden">
{processNodes}
{contentMounted ? processNodes : null}
</div>
{/* 答案区:最终正文 / 流式中的答案雏形,始终可见 */}
@@ -468,8 +493,8 @@ function RewindMenu({ entryId, onRewind }: { entryId: string; onRewind?: (entryI
}
/** 压缩 / 分支摘要通知行(pi 会话时间线中的 compactionSummary / branchSummary) */
function SystemNotice({ message }: { message: UiMessage }): ReactNode | null {
const ref = useEntrance<HTMLDivElement>()
function SystemNotice({ message, animate = true }: { message: UiMessage; animate?: boolean }): ReactNode | null {
const ref = useEntrance<HTMLDivElement>(animate)
const raw = (message.raw ?? {}) as { summary?: unknown; tokensBefore?: unknown }
const summary = typeof raw.summary === 'string' ? raw.summary : ''
const [expanded, setExpanded] = useState(false)
@@ -501,7 +526,7 @@ function SystemNotice({ message }: { message: UiMessage }): ReactNode | null {
)
}
export function MessageItem({
function MessageItemBase({
message,
tools,
streaming,
@@ -510,7 +535,8 @@ export function MessageItem({
scrollPinnedRef,
forkEntryId,
rewinding,
onRewind
onRewind,
animate = true
}: {
message: UiMessage
tools: Record<string, ToolState>
@@ -523,11 +549,13 @@ export function MessageItem({
/** 分支回溯进行中(禁用回溯入口) */
rewinding?: boolean
onRewind?: (entryId: string, summarize: boolean) => void
/** 渐进填充历史时不播入场动画 */
animate?: boolean
}): ReactNode | null {
const ref = useEntrance<HTMLDivElement>()
const ref = useEntrance<HTMLDivElement>(animate)
if (message.role === 'compactionSummary' || message.role === 'branchSummary') {
return <SystemNotice message={message} />
return <SystemNotice message={message} animate={animate} />
}
if (message.role === 'user') {
@@ -565,3 +593,69 @@ export function MessageItem({
// 其他角色(custom / notification 等)暂不渲染
return null
}
// ---- memo ----
// ChatView 在每次流式刷新(delta 合批约 40ms 一次)都会重跑 renderTurns,
// 重建整棵消息列表的 React 元素。没有 memo 时所有历史回合都会跟着重渲染
// (重跑块扫描、重建 ToolChips 行、diff Markdown vdom)——长会话下流式
// 也会越写越卡。比较器按「本回合实际引用的东西」判断:消息对象按引用相等
// (reducer 只替换变更的消息),tools 只比较本回合 toolCall 引用到的条目。
/** messages 数组按元素引用相等(renderTurns 每次 slice 出新数组,引用比较无意义) */
function sameMessages(prev: UiMessage[], next: UiMessage[]): boolean {
if (prev === next) return true
if (prev.length !== next.length) return false
for (let i = 0; i < prev.length; i++) {
if (prev[i] !== next[i]) return false
}
return true
}
/** tools 记录中本回合引用的条目是否有变化(未引用的变化不需要重渲染) */
function referencedToolChanged(
prevTools: Record<string, ToolState>,
nextTools: Record<string, ToolState>,
messages: UiMessage[]
): boolean {
for (const m of messages) {
for (const block of readBlocks(m.raw)) {
if (block.type === 'toolCall' && block.id && prevTools[block.id] !== nextTools[block.id]) return true
}
}
return false
}
function sameApprovals(prev: ApprovalRequest[], next: ApprovalRequest[]): boolean {
if (prev === next) return true
if (prev.length === 0 && next.length === 0) return true
return false
}
function sameTurnProps(
prev: ComponentProps<typeof AssistantTurnBase>,
next: ComponentProps<typeof AssistantTurnBase>
): boolean {
if (prev.streaming !== next.streaming || prev.turnActive !== next.turnActive) return false
if (!sameApprovals(prev.pendingApprovals, next.pendingApprovals)) return false
if (!sameMessages(prev.messages, next.messages)) return false
if (prev.tools !== next.tools && referencedToolChanged(prev.tools, next.tools, next.messages)) return false
return true
}
function sameItemProps(
prev: ComponentProps<typeof MessageItemBase>,
next: ComponentProps<typeof MessageItemBase>
): boolean {
if (prev.message !== next.message) return false
if (prev.streaming !== next.streaming || prev.turnActive !== next.turnActive) return false
if (prev.forkEntryId !== next.forkEntryId || prev.rewinding !== next.rewinding) return false
if (prev.onRewind !== next.onRewind || prev.animate !== next.animate) return false
if (!sameApprovals(prev.pendingApprovals ?? [], next.pendingApprovals ?? [])) return false
if (prev.tools !== next.tools && next.message.role === 'assistant') {
if (referencedToolChanged(prev.tools, next.tools, [next.message])) return false
}
return true
}
export const AssistantTurn = memo(AssistantTurnBase, sameTurnProps)
export const MessageItem = memo(MessageItemBase, sameItemProps)
@@ -2,12 +2,16 @@ import { useEffect, useRef } from 'react'
import type { RefObject } from 'react'
import { animateEntrance } from '../lib/anim'
/** 流式消息的入场动画(GSAP),仅挂载时执行一次 */
export function useEntrance<T extends HTMLElement>(): RefObject<T | null> {
/** 流式消息的入场动画(GSAP),仅挂载时执行一次。
* animate=false 供历史消息渐进填充时跳过动画——切会话时批量挂载的旧消息
* 不需要逐条淡入,几十个并发 tween 会造成可感知的卡顿。取挂载时刻的值,
* 之后 animate 变化不追溯触发。 */
export function useEntrance<T extends HTMLElement>(animate = true): RefObject<T | null> {
const ref = useRef<T | null>(null)
const animateRef = useRef(animate)
useEffect(() => {
const el = ref.current
if (el) animateEntrance(el)
if (el && animateRef.current) animateEntrance(el)
}, [])
return ref
}
+1 -1
View File
@@ -308,7 +308,7 @@ export interface BrowserImportResult {
export type AgentUiEvent =
| { type: 'session_ready'; state: SessionState }
| { type: 'session_error'; message: string }
| { type: 'session_switched'; state: SessionState; messages: unknown[]; activeTools: Record<string, unknown> }
| { type: 'session_switched'; state: SessionState; messages: unknown[] }
| { type: 'state'; state: SessionState }
| { type: 'approval_request'; request: ApprovalRequest }
| { type: 'approval_settled'; id: string; approved: boolean; timedOut: boolean }