diff --git a/apps/desktop/src/main/pi/agent-service.ts b/apps/desktop/src/main/pi/agent-service.ts index b6714b0..c70a48a 100644 --- a/apps/desktop/src/main/pi/agent-service.ts +++ b/apps/desktop/src/main/pi/agent-service.ts @@ -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 { - const tools: Record = {} - 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) ?? { toolName: m.toolName ?? 'tool' }), - result: toCloneable(m), - status: m.isError ? 'error' : 'done' - } - } - return tools - } - async ensureReady(): Promise { 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[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[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[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() + +function estimateMessageTokens(message: object): number { + let tokens = messageTokenCache.get(message) + if (tokens === undefined) { + tokens = estimateTokens(message as Parameters[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) diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 5760c89..b8fcd72 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -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} /> diff --git a/apps/desktop/src/renderer/src/components/ChatView.tsx b/apps/desktop/src/renderer/src/components/ChatView.tsx index c702be0..dfd6207 100644 --- a/apps/desktop/src/renderer/src/components/ChatView.tsx +++ b/apps/desktop/src/renderer/src/components/ChatView.tsx @@ -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, @@ -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(null) const pinnedRef = useRef(true) + // ---- 历史渐进填充 ---- + // 检测「历史被整体替换」(切换会话 / 压缩换血 / 回溯):这些路径会重建全部 + // 消息 key,而流式追加只改尾部。以首条 key 为代际标记,渲染期直接截断首屏 + // 挂载量(render-phase setState,React 会丢弃中间帧,全量列表不会被提交)。 + const historyKey = messages.length > 0 ? messages[0].key : '' + const epochRef = useRef(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(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({
{hasContent ? (
+ {hidden > 0 && ( +
+ + 正在载入更早的对话… +
+ )} {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 )}
) : ( diff --git a/apps/desktop/src/renderer/src/components/MessageItem.tsx b/apps/desktop/src/renderer/src/components/MessageItem.tsx index 9296b93..87a31c3 100644 --- a/apps/desktop/src/renderer/src/components/MessageItem.tsx +++ b/apps/desktop/src/renderer/src/components/MessageItem.tsx @@ -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() 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( -
- -
- ) - } - - /** 消费一条已完成消息: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( -
- -
- ) - thinking += 1 - continue - } - if (block.type === 'text' && block.text) { - flushRows() - if (asAnswer) answerText = answerText ? `${answerText}\n\n${block.text}` : block.text - const node = ( -
- -
- ) - 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( +
+ +
+ ) + } 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(() => !live && hasProcess) + // 过程内容懒挂载:历史回合默认收起,其思考/工具内容在用户首次展开前不渲染 + // (折叠只是视觉收起,子树照常挂载的话长会话切换仍要全量解析)。 + // live 回合从一开始就可见,挂载内容;一旦挂载过就保持(再次折叠走 GSAP + // 高度动画需要内容在场,且 Thinking 面板内部滚动状态不重置)。 + const [contentMounted, setContentMounted] = useState(() => 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( +
+ +
+ ) + } + for (const item of proc) { + if (item.k === 'think') { + // 已完成的思考以展开的 Thinking 面板留在过程区——与流式形态一致 + flushRows() + processNodes.push( +
+ +
+ ) + } else if (item.k === 'text') { + // 干活过程中模型穿插写的叙述性正文,样式与普通正文一致 + flushRows() + processNodes.push( +
+ +
+ ) + } 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({
)} - {/* 过程区:GSAP 高度 + 透明度补间(见上方 useLayoutEffect) */} + {/* 过程区:GSAP 高度 + 透明度补间(见上方 useLayoutEffect)。 + 未展开过的历史回合不挂载内容(懒渲染),首展时随本组件 state 更新入树 */}
- {processNodes} + {contentMounted ? processNodes : null}
{/* 答案区:最终正文 / 流式中的答案雏形,始终可见 */} @@ -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() +function SystemNotice({ message, animate = true }: { message: UiMessage; animate?: boolean }): ReactNode | null { + const ref = useEntrance(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 @@ -523,11 +549,13 @@ export function MessageItem({ /** 分支回溯进行中(禁用回溯入口) */ rewinding?: boolean onRewind?: (entryId: string, summarize: boolean) => void + /** 渐进填充历史时不播入场动画 */ + animate?: boolean }): ReactNode | null { - const ref = useEntrance() + const ref = useEntrance(animate) if (message.role === 'compactionSummary' || message.role === 'branchSummary') { - return + return } 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, + nextTools: Record, + 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, + next: ComponentProps +): 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, + next: ComponentProps +): 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) diff --git a/apps/desktop/src/renderer/src/hooks/useEntrance.ts b/apps/desktop/src/renderer/src/hooks/useEntrance.ts index 9c54739..25f84f6 100644 --- a/apps/desktop/src/renderer/src/hooks/useEntrance.ts +++ b/apps/desktop/src/renderer/src/hooks/useEntrance.ts @@ -2,12 +2,16 @@ import { useEffect, useRef } from 'react' import type { RefObject } from 'react' import { animateEntrance } from '../lib/anim' -/** 流式消息的入场动画(GSAP),仅挂载时执行一次 */ -export function useEntrance(): RefObject { +/** 流式消息的入场动画(GSAP),仅挂载时执行一次。 + * animate=false 供历史消息渐进填充时跳过动画——切会话时批量挂载的旧消息 + * 不需要逐条淡入,几十个并发 tween 会造成可感知的卡顿。取挂载时刻的值, + * 之后 animate 变化不追溯触发。 */ +export function useEntrance(animate = true): RefObject { const ref = useRef(null) + const animateRef = useRef(animate) useEffect(() => { const el = ref.current - if (el) animateEntrance(el) + if (el && animateRef.current) animateEntrance(el) }, []) return ref } diff --git a/apps/desktop/src/shared/types.ts b/apps/desktop/src/shared/types.ts index 04f662b..90187f0 100644 --- a/apps/desktop/src/shared/types.ts +++ b/apps/desktop/src/shared/types.ts @@ -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 } + | { 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 }