From 09c415bd411fd75727237e2455749d0a6b74d6d9 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Fri, 2 Oct 2026 20:06:44 +0800 Subject: [PATCH] perf: render a newly opened conversation in two steps A freshly mounted pane first renders the trailing 20 messages and the rest of its 80-message batch right after the first paint, keeping the bottom or the reader's place. Saved scroll positions and note targets still render the full batch at once. Keeps the write-on-change guard for --chat-context-height. --- src/renderer/src/App.test.tsx | 10 +++- src/renderer/src/ChatHistoryPane.tsx | 73 +++++++++++++++++++++++++++- 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index e0c1c8e5..0b13137f 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -4474,8 +4474,16 @@ describe("App", () => { const { container } = render(); expect(await screen.findByText("历史消息 160")).toBeInTheDocument(); + // The first paint shows only the trailing messages; the rest of the batch + // follows right after. + expect(container.querySelectorAll(".message").length).toBeLessThanOrEqual(20); + expect( + screen.getByRole("button", { name: "加载更早的消息(还剩 81 条)" }), + ).toBeInTheDocument(); + await waitFor(() => + expect(container.querySelectorAll(".message")).toHaveLength(80), + ); expect(screen.queryByText("历史消息 080")).not.toBeInTheDocument(); - expect(container.querySelectorAll(".message")).toHaveLength(80); const chat = container.querySelector(".chat"); if (!chat) { throw new Error("Missing chat scroll container"); diff --git a/src/renderer/src/ChatHistoryPane.tsx b/src/renderer/src/ChatHistoryPane.tsx index cf5271df..2678228f 100644 --- a/src/renderer/src/ChatHistoryPane.tsx +++ b/src/renderer/src/ChatHistoryPane.tsx @@ -22,6 +22,13 @@ import { isUnusedConversation, type Conversation } from "./chat-conversation"; import type { TimeFormatLocale } from "./time-format"; export const messageRenderBatchSize = 80; +/** + * A freshly mounted pane first renders only this many trailing messages, which + * fill the viewport, and renders the rest of its batch right after the first + * paint. Opening a long conversation then costs roughly a quarter of + * the Markdown and layout work before anything is shown. + */ +export const initialMessageRenderCount = 20; const chatBottomProximity = 96; type ChatQuickAction = { @@ -123,15 +130,62 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({ const [showScrollToBottom, setShowScrollToBottom] = useState( scrollSnapshot ? !scrollSnapshot.pinnedToBottom : false, ); + // Only a pane that opens at the bottom renders in two steps. A saved scroll + // position or a note target refers to the full batch, so those render it + // at once. + const [initialRenderLimit, setInitialRenderLimit] = useState< + number | undefined + >(() => + (scrollSnapshot === undefined || scrollSnapshot.pinnedToBottom) && + noteMessageNavigation?.conversationId !== conversation.id && + Math.min(visibleMessageCount, conversation.messages.length) > + initialMessageRenderCount + ? initialMessageRenderCount + : undefined, + ); + const initialRenderScrollRef = useRef< + { scrollHeight: number; scrollTop: number } | undefined + >(undefined); + useEffect(() => { + if (initialRenderLimit === undefined) return; + // Render the rest once the first step has been painted. A plain update, + // not a transition: streaming rows commit synchronously many times per + // second and would keep restarting a transition (see live-message-store). + let timer: ReturnType | undefined; + const frame = requestAnimationFrame(() => { + timer = setTimeout(() => { + const scrollContainer = scrollRef.current; + initialRenderScrollRef.current = scrollContainer + ? { + scrollHeight: scrollContainer.scrollHeight, + scrollTop: scrollContainer.scrollTop, + } + : undefined; + setInitialRenderLimit(undefined); + }, 0); + }); + return () => { + cancelAnimationFrame(frame); + clearTimeout(timer); + }; + }, [initialRenderLimit]); + const renderedMessageCount = + initialRenderLimit === undefined + ? visibleMessageCount + : Math.min(visibleMessageCount, initialRenderLimit); const visibleMessageStartIndex = Math.max( 0, - conversation.messages.length - visibleMessageCount, + conversation.messages.length - renderedMessageCount, ); const visibleMessages = useMemo( () => conversation.messages.slice(visibleMessageStartIndex), [conversation.messages, visibleMessageStartIndex], ); - const hiddenMessageCount = visibleMessageStartIndex; + // The "load earlier" control reflects the real batch, not the first step. + const hiddenMessageCount = + initialRenderLimit === undefined + ? visibleMessageStartIndex + : Math.max(0, conversation.messages.length - visibleMessageCount); useLayoutEffect(() => { const context = contextRef.current; @@ -323,7 +377,22 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({ } }, [visibleMessageCount]); + // The rest of the first batch lands above what the reader sees: stay at the + // bottom, or keep the reader's place if they already scrolled up. + useLayoutEffect(() => { + if (initialRenderLimit !== undefined) return; + const previous = initialRenderScrollRef.current; + initialRenderScrollRef.current = undefined; + const scrollContainer = scrollRef.current; + if (!previous || !scrollContainer) return; + scrollContainer.scrollTop = pinnedToBottomRef.current + ? scrollContainer.scrollHeight + : previous.scrollTop + + (scrollContainer.scrollHeight - previous.scrollHeight); + }, [initialRenderLimit]); + const revealEarlierMessages = useCallback((): void => { + setInitialRenderLimit(undefined); const scrollContainer = scrollRef.current; if (scrollContainer) { prependScrollPositionRef.current = {