diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 0b13137f..faac5c75 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -4475,7 +4475,7 @@ describe("App", () => { expect(await screen.findByText("历史消息 160")).toBeInTheDocument(); // The first paint shows only the trailing messages; the rest of the batch - // follows right after. + // follows in steps. expect(container.querySelectorAll(".message").length).toBeLessThanOrEqual(20); expect( screen.getByRole("button", { name: "加载更早的消息(还剩 81 条)" }), @@ -4524,6 +4524,53 @@ describe("App", () => { expect(screen.getByText("历史消息 000").closest("article")).toHaveFocus(); }); + it("catches up the first message batch in several small steps", async () => { + vi.mocked(api.conversations.list).mockResolvedValueOnce([ + { + id: "00000000-0000-4000-8000-000000000422", + projectId, + title: "分步渲染会话", + updatedAt: 1_775_000_000_000, + messages: Array.from({ length: 161 }, (_, index) => ({ + id: `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`, + role: index % 2 === 0 ? ("user" as const) : ("assistant" as const), + content: `分步消息 ${String(index).padStart(3, "0")}`, + createdAt: 1_775_000_000_000 + index, + state: "complete" as const, + })), + }, + ]); + const { container } = render(); + const counts: number[] = []; + const record = (): void => { + const count = container.querySelectorAll(".message").length; + if (count > 0 && counts.at(-1) !== count) counts.push(count); + }; + const observer = new MutationObserver(record); + observer.observe(container, { childList: true, subtree: true }); + try { + expect(await screen.findByText("分步消息 160")).toBeInTheDocument(); + record(); + await waitFor(() => + expect(container.querySelectorAll(".message")).toHaveLength(80), + ); + record(); + } finally { + observer.disconnect(); + } + + expect(counts[0]).toBeLessThanOrEqual(20); + expect(counts.at(-1)).toBe(80); + // More than one catch-up commit, each adding at most 20 messages. + expect(counts.filter((count) => count > 20 && count < 80).length).toBeGreaterThan(0); + counts.slice(1).forEach((count, index) => { + expect(count - (counts[index] ?? 0)).toBeLessThanOrEqual(20); + }); + expect( + screen.getByRole("button", { name: "加载更早的消息(还剩 81 条)" }), + ).toBeInTheDocument(); + }); + it("keeps the reader position while a response continues below", async () => { render(); fireEvent.change(await screen.findByLabelText("向 GoodBuddy 提问"), { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a02a4283..dcfaa108 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -186,6 +186,7 @@ import { messageRenderBatchSize, type ChatScrollSnapshot, } from "./ChatHistoryPane"; +import { reconcilePaneOrder } from "./pane-order"; import { isUnusedConversation, sortConversationsForDisplay, type Conversation } from "./chat-conversation"; import { ComposerDraftEffect, @@ -3686,28 +3687,33 @@ function App(): React.JSX.Element { ); // Kept panes must keep a stable DOM order. Reordering keyed siblings makes // React detach and reinsert scroll containers, which resets their scrollTop. - const conversationPaneOrderRef = useRef([]); + // The order lives in state and is reconciled during render; the helper + // returns the previous array when nothing changed, so this settles at once. + const cachedConversationIds = useMemo( + () => + [activeId, ...cachedConversationViews.map((entry) => entry.key)].filter( + (id): id is string => Boolean(id), + ), + [activeId, cachedConversationViews], + ); + const [conversationPaneOrderState, setConversationPaneOrderState] = + useState([]); + const conversationPaneOrder = reconcilePaneOrder( + conversationPaneOrderState, + cachedConversationIds, + ); + if (conversationPaneOrder !== conversationPaneOrderState) { + setConversationPaneOrderState(conversationPaneOrder); + } const cachedConversations = useMemo(() => { const conversationById = new Map( conversations.map((conversation) => [conversation.id, conversation]), ); - const cachedIds = new Set( - [activeId, ...cachedConversationViews.map((entry) => entry.key)].filter( - Boolean, - ), - ); - const paneOrder = conversationPaneOrderRef.current.filter((id) => - cachedIds.has(id), - ); - cachedIds.forEach((id) => { - if (!paneOrder.includes(id)) paneOrder.push(id); - }); - conversationPaneOrderRef.current = paneOrder; - return paneOrder.flatMap((conversationId) => { + return conversationPaneOrder.flatMap((conversationId) => { const conversation = conversationById.get(conversationId); return conversation ? [conversation] : []; }); - }, [activeId, cachedConversationViews, conversations]); + }, [conversationPaneOrder, conversations]); const activeRuntimeResolution = useMemo( () => diff --git a/src/renderer/src/ChatHistoryPane.tsx b/src/renderer/src/ChatHistoryPane.tsx index 2678228f..73e57871 100644 --- a/src/renderer/src/ChatHistoryPane.tsx +++ b/src/renderer/src/ChatHistoryPane.tsx @@ -24,11 +24,16 @@ 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. + * fill the viewport, and renders the rest of its batch 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; +/** + * The rest of the batch is caught up in steps of this many messages, one step + * per frame, so no single catch-up commit becomes a long frame. + */ +export const catchUpMessageRenderStep = 20; const chatBottomProximity = 96; type ChatQuickAction = { @@ -130,9 +135,9 @@ 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 + // Only a pane that opens at the bottom renders in steps. A saved scroll // position or a note target refers to the full batch, so those render it - // at once. + // at once. `undefined` means the full batch is rendered. const [initialRenderLimit, setInitialRenderLimit] = useState< number | undefined >(() => @@ -143,14 +148,30 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({ ? initialMessageRenderCount : undefined, ); + const [steppedVisibleMessageCount] = useState(visibleMessageCount); + // Stepping only covers the batch the pane opened with. A changed batch + // ("load earlier", note navigation) or a note target for this pane ends the + // stepping and renders the full batch at once. Adjusting state during + // render avoids committing a partial step first. + const stopStepping = + initialRenderLimit !== undefined && + (visibleMessageCount !== steppedVisibleMessageCount || + noteMessageNavigation?.conversationId === conversation.id); + if (stopStepping) setInitialRenderLimit(undefined); + const steppingLimit = stopStepping ? undefined : initialRenderLimit; const initialRenderScrollRef = useRef< { scrollHeight: number; scrollTop: number } | undefined >(undefined); + const initialRenderTarget = Math.min( + visibleMessageCount, + conversation.messages.length, + ); 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). + if (steppingLimit === undefined) return; + // Grow by one step once the previous 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(() => { @@ -161,18 +182,21 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({ scrollTop: scrollContainer.scrollTop, } : undefined; - setInitialRenderLimit(undefined); + const nextLimit = steppingLimit + catchUpMessageRenderStep; + setInitialRenderLimit( + nextLimit >= initialRenderTarget ? undefined : nextLimit, + ); }, 0); }); return () => { cancelAnimationFrame(frame); clearTimeout(timer); }; - }, [initialRenderLimit]); + }, [steppingLimit, initialRenderTarget]); const renderedMessageCount = - initialRenderLimit === undefined + steppingLimit === undefined ? visibleMessageCount - : Math.min(visibleMessageCount, initialRenderLimit); + : Math.min(visibleMessageCount, steppingLimit); const visibleMessageStartIndex = Math.max( 0, conversation.messages.length - renderedMessageCount, @@ -181,11 +205,11 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({ () => conversation.messages.slice(visibleMessageStartIndex), [conversation.messages, 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); + // The "load earlier" control reflects the real batch, not the partial step. + const hiddenMessageCount = Math.max( + 0, + conversation.messages.length - visibleMessageCount, + ); useLayoutEffect(() => { const context = contextRef.current; @@ -377,10 +401,9 @@ 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. + // Each catch-up step 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; diff --git a/src/renderer/src/pane-order.test.ts b/src/renderer/src/pane-order.test.ts new file mode 100644 index 00000000..4182e40a --- /dev/null +++ b/src/renderer/src/pane-order.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { reconcilePaneOrder } from "./pane-order"; + +describe("reconcilePaneOrder", () => { + it("appends new ids in order", () => { + expect(reconcilePaneOrder([], ["a", "b"])).toEqual(["a", "b"]); + expect(reconcilePaneOrder(["a", "b"], ["c", "a", "b"])).toEqual(["a", "b", "c"]); + }); + + it("keeps the relative position of existing ids regardless of input order", () => { + expect(reconcilePaneOrder(["a", "b", "c"], ["c", "b", "a"])).toEqual(["a", "b", "c"]); + }); + + it("drops removed ids", () => { + expect(reconcilePaneOrder(["a", "b", "c"], ["c", "a", "d"])).toEqual(["a", "c", "d"]); + }); + + it("returns the same array when the order is unchanged", () => { + const previous = ["a", "b"]; + expect(reconcilePaneOrder(previous, new Set(["b", "a"]))).toBe(previous); + const empty: string[] = []; + expect(reconcilePaneOrder(empty, [])).toBe(empty); + }); + + it("ignores duplicate ids", () => { + expect(reconcilePaneOrder([], ["a", "a", "b"])).toEqual(["a", "b"]); + }); +}); diff --git a/src/renderer/src/pane-order.ts b/src/renderer/src/pane-order.ts new file mode 100644 index 00000000..222a8551 --- /dev/null +++ b/src/renderer/src/pane-order.ts @@ -0,0 +1,24 @@ +/** + * Reconciles a stable order for kept-alive panes. Ids already in `previous` + * keep their relative position, ids missing from `ids` are dropped and new ids + * are appended in iteration order. Returns `previous` itself when nothing + * changed, so it is safe to store the result in state during render. + */ +export function reconcilePaneOrder( + previous: readonly string[], + ids: Iterable, +): readonly string[] { + const wanted = new Set(ids); + const next = previous.filter((id) => wanted.has(id)); + const kept = new Set(next); + wanted.forEach((id) => { + if (!kept.has(id)) next.push(id); + }); + if ( + next.length === previous.length && + next.every((id, index) => id === previous[index]) + ) { + return previous; + } + return next; +}