From d1cee518fa4c031126dbbfb6105092b6bb4873ca Mon Sep 17 00:00:00 2001 From: Rick Ye Date: Tue, 8 Sep 2026 00:08:49 +0800 Subject: [PATCH] =?UTF-8?q?perf:=20GSAP=20=E7=BB=9F=E4=B8=80=E4=BA=A4?= =?UTF-8?q?=E4=BA=92=E5=8A=A8=E7=94=BB=20+=20=E7=A0=8D=E6=8E=89=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=E7=83=AD=E8=B7=AF=E5=BE=84=E7=9A=84=E9=80=90=E5=B8=A7?= =?UTF-8?q?=E5=BC=80=E9=94=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/renderer/src/components/ChatView.tsx | 8 +- .../src/renderer/src/components/Markdown.tsx | 8 +- .../renderer/src/components/MessageItem.tsx | 45 ++++++---- .../src/renderer/src/components/Thinking.tsx | 8 +- .../src/renderer/src/hooks/useEntrance.ts | 17 +--- apps/desktop/src/renderer/src/lib/anim.ts | 84 +++++++++++++++++++ 6 files changed, 128 insertions(+), 42 deletions(-) create mode 100644 apps/desktop/src/renderer/src/lib/anim.ts diff --git a/apps/desktop/src/renderer/src/components/ChatView.tsx b/apps/desktop/src/renderer/src/components/ChatView.tsx index 8b03e28..5712caa 100644 --- a/apps/desktop/src/renderer/src/components/ChatView.tsx +++ b/apps/desktop/src/renderer/src/components/ChatView.tsx @@ -118,9 +118,13 @@ export function ChatView({ useEffect(() => { const el = scrollRef.current - if (el && pinnedRef.current) { + if (!el || !pinnedRef.current) return + // rAF 合帧:流式 delta 一帧内多次触发时只吸底一次, + // 且读 scrollHeight 的强制布局每帧至多一次 + const raf = requestAnimationFrame(() => { el.scrollTop = el.scrollHeight - } + }) + return () => cancelAnimationFrame(raf) }, [messages, tools, streaming]) const hasContent = messages.length > 0 diff --git a/apps/desktop/src/renderer/src/components/Markdown.tsx b/apps/desktop/src/renderer/src/components/Markdown.tsx index b1d2f80..6337fbd 100644 --- a/apps/desktop/src/renderer/src/components/Markdown.tsx +++ b/apps/desktop/src/renderer/src/components/Markdown.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, type ReactNode } from 'react' +import { memo, useEffect, useState, type ReactNode } from 'react' import ReactMarkdown, { type Components } from 'react-markdown' import remarkGfm from 'remark-gfm' import { @@ -261,7 +261,7 @@ function BlockView({ blockMatch }: { blockMatch: BlockMatch }): ReactNode { return } -export function Markdown({ text, streaming = false }: { text: string; streaming?: boolean }): ReactNode { +function MarkdownBase({ text, streaming = false }: { text: string; streaming?: boolean }): ReactNode { // 流式开启时 llm-ui 逐块渐进解析(未闭合围栏 → partial code block,未完段落 → partial markdown), // 已完成的块会被 lookBack 冻结不再重渲染;流式结束后整体收敛为最终形态 const { blockMatches } = useLLMOutput({ @@ -279,3 +279,7 @@ export function Markdown({ text, streaming = false }: { text: string; streaming? ) } + +/** memo:流式 delta 会让整份消息列表重渲染,历史消息的 text/streaming 不变, + * 直接跳过 react-markdown 的整棵 vdom diff —— 这是流式卡顿的大头。 */ +export const Markdown = memo(MarkdownBase) diff --git a/apps/desktop/src/renderer/src/components/MessageItem.tsx b/apps/desktop/src/renderer/src/components/MessageItem.tsx index 170be65..dcf85e6 100644 --- a/apps/desktop/src/renderer/src/components/MessageItem.tsx +++ b/apps/desktop/src/renderer/src/components/MessageItem.tsx @@ -1,5 +1,6 @@ -import { useEffect, useRef, useState, type ReactNode } from 'react' +import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react' import type { ApprovalRequest, ToolState, UiMessage } from '../hooks/useAgent' +import { animateCollapse, animateExpand } from '../lib/anim' import { Markdown } from './Markdown' import { Loading } from './Loading' import { StreamingText } from './StreamingText' @@ -169,6 +170,30 @@ export function AssistantTurn({ prevTurnActive.current = turnActive }, [turnActive]) + // 过程区折叠/展开:GSAP 高度 + 透明度补间(可中断续接)。内联高度完全由 + // 动画层持有,React 不渲染 style,避免重渲染时清掉补间中的值。 + // 首帧直接落位(历史回合默认收起),不播动画。 + const processRef = useRef(null) + const prevCollapsedRef = useRef(null) + useLayoutEffect(() => { + const el = processRef.current + if (!el) return + const effective = collapsed && hasProcess + const prev = prevCollapsedRef.current + prevCollapsedRef.current = effective + if (prev === null) { + if (effective) { + el.style.height = '0px' + el.style.opacity = '0' + el.style.pointerEvents = 'none' + } + return + } + if (prev === effective) return + if (effective) animateCollapse(el) + else animateExpand(el) + }, [collapsed, hasProcess]) + const workedFor = useWorkingSeconds(turnActive) const headerLabel = workedFor || `${calls} 次工具调用${thinking ? `,${thinking} 条消息` : ''}` @@ -239,21 +264,9 @@ export function AssistantTurn({ )} - {/* 过程区:grid-rows 0fr/1fr 高度动画 + 透明度渐隐,收起/展开更从容 */} -
-
- {processNodes} -
+ {/* 过程区:GSAP 高度 + 透明度补间(见上方 useLayoutEffect) */} +
+ {processNodes}
{/* 答案区:最终正文 / 流式中的答案雏形,始终可见 */} diff --git a/apps/desktop/src/renderer/src/components/Thinking.tsx b/apps/desktop/src/renderer/src/components/Thinking.tsx index ff26c14..aee9850 100644 --- a/apps/desktop/src/renderer/src/components/Thinking.tsx +++ b/apps/desktop/src/renderer/src/components/Thinking.tsx @@ -30,13 +30,7 @@ export function Thinking({ }, [rows, expanded]) return ( -
+
{/* 表头:状态词 + 旋转箭头 */}