From bf6c4a91a2a971721d255196e456515da13a685c Mon Sep 17 00:00:00 2001 From: mesalogo Date: Fri, 2 Oct 2026 15:52:48 +0800 Subject: [PATCH] fix: keep scroll position when switching between cached conversations --- src/renderer/src/App.test.tsx | 7 +++++++ src/renderer/src/App.tsx | 22 +++++++++++++++------- src/renderer/src/ChatHistoryPane.tsx | 12 ++++++++++++ 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 01c769cc..e2b90f13 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -4777,9 +4777,16 @@ describe("App", () => { }); expect(messageRenderProbe).not.toHaveBeenCalled(); expect(firstPane?.querySelectorAll(".message")).toHaveLength(160); + const paneOrder = (): string[] => + [...container.querySelectorAll(".chat-history-pane")].map( + (pane) => pane.dataset.conversationId ?? "", + ); + const warmPaneOrder = paneOrder(); fireEvent.click(screen.getByText("第一段长会话").closest("button")!); expect(await screen.findByText("第一段历史 001")).toBeInTheDocument(); + // Chromium resets scrollTop when React moves a kept pane; jsdom does not. + expect(paneOrder()).toEqual(warmPaneOrder); const restoredFirstPane = container.querySelector( `[data-conversation-id="${firstConversationId}"]`, ); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index f48469fc..6bfd5f54 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -3594,18 +3594,26 @@ function App(): React.JSX.Element { () => new Set(cachedWorkspaceViews.map((entry) => entry.key)), [cachedWorkspaceViews], ); + // 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([]); const cachedConversations = useMemo(() => { const conversationById = new Map( conversations.map((conversation) => [conversation.id, conversation]), ); - const cachedIds = [ - activeId, - ...cachedConversationViews.map((entry) => entry.key), - ].filter( - (conversationId, index, values) => - conversationId && values.indexOf(conversationId) === index, + const cachedIds = new Set( + [activeId, ...cachedConversationViews.map((entry) => entry.key)].filter( + Boolean, + ), ); - return cachedIds.flatMap((conversationId) => { + 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) => { const conversation = conversationById.get(conversationId); return conversation ? [conversation] : []; }); diff --git a/src/renderer/src/ChatHistoryPane.tsx b/src/renderer/src/ChatHistoryPane.tsx index 5c389ab3..11bfe327 100644 --- a/src/renderer/src/ChatHistoryPane.tsx +++ b/src/renderer/src/ChatHistoryPane.tsx @@ -108,6 +108,7 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({ const pinnedToBottomRef = useRef(scrollSnapshot?.pinnedToBottom ?? true); const latestScrollSnapshotRef = useRef(scrollSnapshot); const restorePendingRef = useRef(true); + const wasActiveRef = useRef(false); const prependScrollPositionRef = useRef< | { scrollHeight: number; @@ -234,12 +235,15 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({ useLayoutEffect(() => { if (!active) { + wasActiveRef.current = false; return; } const scrollContainer = scrollRef.current; if (!scrollContainer) { return; } + const activated = !wasActiveRef.current; + wasActiveRef.current = true; if (restorePendingRef.current) { restorePendingRef.current = false; if (scrollSnapshot && !scrollSnapshot.pinnedToBottom) { @@ -247,6 +251,14 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({ scrollContainer.scrollTop = scrollSnapshot.scrollTop; return; } + } else if (activated && !pinnedToBottomRef.current) { + // Hidden panes may lose scrollTop when the browser detaches or re-lays + // them out; restore the reader's last position instead of the top. + const saved = latestScrollSnapshotRef.current; + if (saved && !saved.pinnedToBottom) { + scrollContainer.scrollTop = saved.scrollTop; + return; + } } if (pinnedToBottomRef.current) { scrollContainer.scrollTo({