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;
+}